mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 11:15:09 +00:00
Merge branch 'v25'
This commit is contained in:
+58
-155
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user