Merge branch 'v25'

This commit is contained in:
evanpelle
2025-09-18 11:26:53 -07:00
33 changed files with 508 additions and 374 deletions
+58 -155
View File
@@ -1,6 +1,12 @@
import { S3 } from "@aws-sdk/client-s3";
import z from "zod";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
import {
GameID,
GameRecord,
GameRecordSchema,
ID,
PartialGameRecord,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { logger } from "./Logger";
@@ -8,179 +14,76 @@ const config = getServerConfigFromServer();
const log = logger.child({ component: "Archive" });
// R2 client configuration
const r2 = new S3({
region: "auto", // R2 ignores region, but it's required by the SDK
endpoint: config.r2Endpoint(),
credentials: {
accessKeyId: config.r2AccessKey(),
secretAccessKey: config.r2SecretKey(),
},
});
const bucket = config.r2Bucket();
const gameFolder = "games";
const analyticsFolder = "analytics";
export async function archive(gameRecord: GameRecord) {
try {
gameRecord.gitCommit = config.gitCommit();
// Archive to R2
await archiveAnalyticsToR2(gameRecord);
// Archive full game if there are turns
if (gameRecord.turns.length > 0) {
log.info(
`${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`,
);
await archiveFullGameToR2(gameRecord);
}
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameRecord.info.gameID}: Final archive error. Non-Error type: ${String(error)}`,
);
const parsed = GameRecordSchema.safeParse(gameRecord);
if (!parsed.success) {
log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, {
gameID: gameRecord.info.gameID,
});
return;
}
const { message, stack, name } = error;
log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(gameRecord, replacer),
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
},
});
}
}
async function archiveAnalyticsToR2(gameRecord: GameRecord) {
// Create analytics data object
const { info, version, gitCommit, subdomain, domain } = gameRecord;
const analyticsData: AnalyticsRecord = {
info,
version,
gitCommit,
subdomain,
domain,
};
try {
// Store analytics data using just the game ID as the key
const analyticsKey = `${info.gameID}.json`;
await r2.putObject({
Bucket: bucket,
Key: `${analyticsFolder}/${analyticsKey}`,
Body: JSON.stringify(analyticsData, replacer),
ContentType: "application/json",
});
log.info(`${info.gameID}: successfully wrote game analytics to R2`);
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameRecord.info.gameID}: Error writing game analytics to R2. Non-Error type: ${String(error)}`,
);
if (!response.ok) {
log.error(`error archiving game record: ${response.statusText}`, {
gameID: gameRecord.info.gameID,
});
return;
}
const { message, stack, name } = error;
log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
});
throw error;
}
}
async function archiveFullGameToR2(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = structuredClone(gameRecord);
// Players may see this so make sure to clear PII
recordCopy.info.players.forEach((p) => {
p.persistentID = "REDACTED";
});
try {
await r2.putObject({
Bucket: bucket,
Key: `${gameFolder}/${recordCopy.info.gameID}`,
Body: JSON.stringify(recordCopy, replacer),
ContentType: "application/json",
});
} catch (error) {
log.error(`error saving game ${gameRecord.info.gameID}`);
throw error;
log.error(`error archiving game record: ${error}`, {
gameID: gameRecord.info.gameID,
});
return;
}
log.info(`${gameRecord.info.gameID}: game record successfully written to R2`);
}
export async function readGameRecord(
gameId: GameID,
): Promise<GameRecord | null> {
try {
// Check if file exists and download in one operation
const response = await r2.getObject({
Bucket: bucket,
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
});
// Parse the response body
if (response.Body === undefined) return null;
const bodyContents = await response.Body.transformToString();
return JSON.parse(bodyContents) as GameRecord;
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`,
);
if (!ID.safeParse(gameId).success) {
log.error(`invalid game ID: ${gameId}`);
return null;
}
const { message, stack, name } = error;
// Log the error for monitoring purposes
log.error(`${gameId}: Error reading game record from R2: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
const url = `${config.jwtIssuer()}/game/${gameId}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const record = await response.json();
if (!response.ok) {
log.error(`error reading game record: ${response.statusText}`, {
gameID: gameId,
});
return null;
}
return GameRecordSchema.parse(record);
} catch (error) {
log.error(`error reading game record: ${error}`, {
gameID: gameId,
});
// Return null instead of throwing the error
return null;
}
}
export async function gameRecordExists(gameId: GameID): Promise<boolean> {
try {
await r2.headObject({
Bucket: bucket,
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
});
return true;
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameId}: Error checking archive existence. Non-Error type: ${String(error)}`,
);
return false;
}
const { message, stack, name } = error;
if (name === "NotFound") {
return false;
}
log.error(`${gameId}: Error checking archive existence: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
});
return false;
}
export function finalizeGameRecord(
clientRecord: PartialGameRecord,
): GameRecord {
return {
...clientRecord,
gitCommit: config.gitCommit(),
subdomain: config.subdomain(),
domain: config.domain(),
};
}
+3 -1
View File
@@ -1,13 +1,15 @@
import WebSocket from "ws";
import { TokenPayload } from "../core/ApiSchemas";
import { Tick } from "../core/game/Game";
import { ClientID } from "../core/Schemas";
import { ClientID, Winner } from "../core/Schemas";
export class Client {
public lastPing: number = Date.now();
public hashes: Map<Tick, number> = new Map();
public reportedWinner: Winner | null = null;
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
+65 -22
View File
@@ -2,6 +2,8 @@ import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
import { z } from "zod";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
@@ -19,10 +21,8 @@ import {
ServerTurnMessage,
Turn,
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
export enum GamePhase {
Lobby = "LOBBY",
@@ -64,6 +64,11 @@ export class GameServer {
private websockets: Set<WebSocket> = new Set();
private winnerVotes: Map<
string,
{ winner: ClientSendWinnerMessage; ips: Set<string> }
> = new Map();
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -190,6 +195,7 @@ export class GameServer {
}
client.lastPing = existing.lastPing;
client.reportedWinner = existing.reportedWinner;
this.activeClients = this.activeClients.filter((c) => c !== existing);
}
@@ -289,15 +295,7 @@ export class GameServer {
break;
}
case "winner": {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.winner !== null
) {
return;
}
this.winner = clientMsg;
this.archiveGame();
this.handleWinner(client, clientMsg);
break;
}
default: {
@@ -688,15 +686,16 @@ export class GameServer {
},
);
archive(
createGameRecord(
this.id,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
Date.now(),
this.winner?.winner,
this.config,
finalizeGameRecord(
createPartialGameRecord(
this.id,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
Date.now(),
this.winner?.winner,
),
),
);
}
@@ -799,4 +798,48 @@ export class GameServer {
outOfSyncClients,
};
}
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.winner !== null ||
client.reportedWinner !== null
) {
return;
}
client.reportedWinner = clientMsg.winner;
// Add client vote
const winnerKey = JSON.stringify(clientMsg.winner);
if (!this.winnerVotes.has(winnerKey)) {
this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg });
}
const potentialWinner = this.winnerVotes.get(winnerKey)!;
potentialWinner.ips.add(client.ip);
const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip));
const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`;
this.log.info(
`recieved winner vote ${clientMsg.winner}, ${ratio} votes for this winner`,
{
clientID: client.clientID,
},
);
if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) {
return;
}
// Vote succeeded
this.winner = potentialWinner.winner;
this.log.info(
`Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`,
{
winnerKey: winnerKey,
},
);
this.archiveGame();
}
}
+1 -2
View File
@@ -24,8 +24,7 @@ if (config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers: Record<string, string> = {};
headers["Authorization"] = config.otelAuthHeader();
headers["Authorization"] = "Basic " + config.otelAuthHeader();
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
+47 -52
View File
@@ -1,3 +1,4 @@
import compression from "compression";
import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
@@ -6,18 +7,17 @@ import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameRecord,
GameRecordSchema,
ID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, readGameRecord } from "./Archive";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
@@ -81,6 +81,7 @@ export async function startWorker() {
});
app.set("trust proxy", 3);
app.use(compression());
app.use(express.json());
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
@@ -210,55 +211,47 @@ export async function startWorker() {
res.json(game.gameInfo());
});
app.get("/api/archived_game/:id", async (req, res) => {
const gameRecord = await readGameRecord(req.params.id);
if (!gameRecord) {
return res.status(404).json({
success: false,
error: "Game not found",
exists: false,
});
}
if (
config.env() !== GameEnv.Dev &&
gameRecord.gitCommit !== config.gitCommit()
) {
log.warn(
`git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`,
);
return res.status(409).json({
success: false,
error: "Version mismatch",
exists: true,
details: {
expectedCommit: config.gitCommit(),
actualCommit: gameRecord.gitCommit,
},
});
}
return res.status(200).json({
success: true,
exists: true,
gameRecord: gameRecord,
});
});
app.post("/api/archive_singleplayer_game", async (req, res) => {
const result = GameRecordSchema.safeParse(req.body);
if (!result.success) {
const error = z.prettifyError(result.error);
log.info(error);
return res.status(400).json({ error });
}
try {
const record = req.body;
const gameRecord: GameRecord = result.data;
archive(gameRecord);
res.json({
success: true,
});
const result = PartialGameRecordSchema.safeParse(record);
if (!result.success) {
const error = z.prettifyError(result.error);
log.info(error);
return res.status(400).json({ error });
}
const gameRecord = result.data;
if (gameRecord.info.config.gameType !== GameType.Singleplayer) {
log.warn(
`cannot archive singleplayer with game type ${gameRecord.info.config.gameType}`,
{
gameID: gameRecord.info.gameID,
},
);
return res.status(400).json({ error: "Invalid request" });
}
if (result.data.info.players.length !== 1) {
log.warn(`cannot archive singleplayer game multiple players`, {
gameID: gameRecord.info.gameID,
});
return res.status(400).json({ error: "Invalid request" });
}
log.info("archiving singleplayer game", {
gameID: gameRecord.info.gameID,
});
archive(finalizeGameRecord(gameRecord));
res.json({
success: true,
});
} catch (error) {
log.error("Error processing archive request:", error);
res.status(500).json({ error: "Internal server error" });
}
});
app.post("/api/kick_player/:gameID/:clientID", async (req, res) => {
@@ -311,7 +304,9 @@ export async function startWorker() {
// Ignore ping
return;
} else if (clientMsg.type !== "join") {
log.warn(`Invalid message before join: ${JSON.stringify(clientMsg)}`);
log.warn(
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
);
return;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function initWorkerMetrics(gameManager: GameManager): void {
// Configure auth headers
const headers: Record<string, string> = {};
if (config.otelEnabled()) {
headers["Authorization"] = config.otelAuthHeader();
headers["Authorization"] = "Basic " + config.otelAuthHeader();
}
// Create metrics exporter