From 33292aec5c541c137d3358ebe0c477e59a687f30 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 9 Mar 2025 14:24:39 -0700 Subject: [PATCH] feat: replay archived games, gamestate hash verification (#195) create endpoint to load archived game. when joining game client first checks if the game is active, if not it requests the game archive from the server. the archive is sent to LocalServer to replay the game locally. Every 10 ticks a hash is stored on the archive, and during replay the LocalServer verifies this hash. --- src/client/ClientGameRunner.ts | 66 +++++++++---------------- src/client/HostLobbyModal.ts | 15 ++---- src/client/JoinPrivateLobbyModal.ts | 66 ++++++++++++++++--------- src/client/LocalServer.ts | 61 ++++++++++++++++++----- src/client/Main.ts | 27 +++++----- src/client/PublicLobby.ts | 7 +-- src/client/SinglePlayerModal.ts | 24 ++++----- src/client/Transport.ts | 25 +++++----- src/client/graphics/layers/UnitLayer.ts | 4 +- src/core/Schemas.ts | 5 +- src/core/Util.ts | 23 ++++++++- src/server/Archive.ts | 16 +++--- src/server/Client.ts | 2 +- src/server/GameServer.ts | 34 +++++++++---- src/server/Worker.ts | 23 +++++++-- src/server/gatekeeper | 2 +- 16 files changed, 239 insertions(+), 161 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 02c6adf1e..0cb616f92 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -8,6 +8,7 @@ import { GameID, ServerMessage, PlayerRecord, + GameRecord, } from "../core/Schemas"; import { loadTerrainMap } from "../core/game/TerrainMapLoader"; import { @@ -30,7 +31,7 @@ import { GameView, PlayerView } from "../core/game/GameView"; import { GameUpdateViewData } from "../core/game/GameUpdates"; import { UserSettings } from "../core/game/UserSettings"; import { LocalPersistantStats } from "./LocalPersistantStats"; -import { CreateGameRecord } from "../core/Util"; +import { createGameRecord } from "../core/Util"; import { getPersistentIDFromCookie } from "./Main"; export interface LobbyConfig { @@ -40,15 +41,11 @@ export interface LobbyConfig { clientID: ClientID; playerID: PlayerID; persistentID: string; - gameType: GameType; gameID: GameID; - map: GameMapType | null; - difficulty: Difficulty | null; - infiniteGold: boolean | null; - infiniteTroops: boolean | null; - instantBuild: boolean | null; - bots: number | null; - disableNPCs: boolean | null; + // GameConfig only exists when playing a singleplayer game. + gameConfig?: GameConfig; + // GameRecord exists when replaying an archived game. + gameRecord?: GameRecord; } export function joinLobby( @@ -63,28 +60,13 @@ export function joinLobby( ); const userSettings: UserSettings = new UserSettings(); - const gameConfig: GameConfig = { - gameType: lobbyConfig.gameType, - gameMap: lobbyConfig.map, - difficulty: lobbyConfig.difficulty, - disableNPCs: lobbyConfig.disableNPCs, - bots: lobbyConfig.bots, - infiniteGold: lobbyConfig.infiniteGold, - infiniteTroops: lobbyConfig.infiniteTroops, - instantBuild: lobbyConfig.instantBuild, - }; LocalPersistantStats.startGame( lobbyConfig.gameID, lobbyConfig.playerID, - gameConfig, + lobbyConfig.gameConfig, ); - const transport = new Transport( - lobbyConfig, - gameConfig, - eventBus, - lobbyConfig.serverConfig, - ); + const transport = new Transport(lobbyConfig, eventBus); const onconnect = () => { consolex.log(`Joined game lobby ${lobbyConfig.gameID}`); @@ -94,13 +76,11 @@ export function joinLobby( if (message.type == "start") { consolex.log("lobby: game started"); onjoin(); - createClientGame( - lobbyConfig, - message.config, - eventBus, - transport, - userSettings, - ).then((r) => r.start()); + // For multiplayer games, GameConfig is not known until game starts. + lobbyConfig.gameConfig = message.config; + createClientGame(lobbyConfig, eventBus, transport, userSettings).then( + (r) => r.start(), + ); } }; transport.connect(onconnect, onmessage); @@ -112,17 +92,16 @@ export function joinLobby( export async function createClientGame( lobbyConfig: LobbyConfig, - gameConfig: GameConfig, eventBus: EventBus, transport: Transport, userSettings: UserSettings, ): Promise { - const config = await getConfig(gameConfig, userSettings); + const config = await getConfig(lobbyConfig.gameConfig, userSettings); - const gameMap = await loadTerrainMap(gameConfig.gameMap); + const gameMap = await loadTerrainMap(lobbyConfig.gameConfig.gameMap); const worker = new WorkerClient( lobbyConfig.gameID, - gameConfig, + lobbyConfig.gameConfig, lobbyConfig.clientID, ); await worker.initialize(); @@ -145,11 +124,10 @@ export async function createClientGame( ); consolex.log( - `creating private game got difficulty: ${gameConfig.difficulty}`, + `creating private game got difficulty: ${lobbyConfig.gameConfig.difficulty}`, ); return new ClientGameRunner( - gameConfig, lobbyConfig, eventBus, gameRenderer, @@ -168,7 +146,6 @@ export class ClientGameRunner { private hasJoined = false; constructor( - private gameConfig: GameConfig, private lobby: LobbyConfig, private eventBus: EventBus, private renderer: GameRenderer, @@ -187,9 +164,9 @@ export class ClientGameRunner { clientID: this.lobby.clientID, }, ]; - const record = CreateGameRecord( + const record = createGameRecord( this.lobby.gameID, - this.gameConfig, + this.lobby.gameConfig, players, // Not saving turns locally [], @@ -211,6 +188,7 @@ export class ClientGameRunner { this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { if ("errMsg" in gu) { showErrorModal(gu.errMsg, gu.stack, this.lobby.clientID); + this.stop(); return; } gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { @@ -256,10 +234,12 @@ export class ClientGameRunner { } if (message.type == "desync") { showErrorModal( - `desync from server: ${JSON.stringify(message)}`, + `game: ${this.lobby.gameID}, clientID: ${this.lobby.clientID}, desync from server: ${JSON.stringify(message)}`, "", this.lobby.clientID, ); + this.stop(); + return; } if (message.type == "turn") { if (!this.hasJoined) { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 2fecd8440..d59d5c845 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -12,6 +12,7 @@ import { getConfig, getServerConfigFromClient, } from "../core/configuration/Config"; +import { JoinLobbyEvent } from "./Main"; @customElement("host-lobby-modal") export class HostLobbyModal extends LitElement { @@ -548,18 +549,8 @@ export class HostLobbyModal extends LitElement { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { - gameType: GameType.Private, - lobby: { - gameID: this.lobbyId, - }, - map: this.selectedMap, - difficulty: this.selectedDifficulty, - disableNPCs: this.disableNPCs, - bots: this.bots, - infiniteGold: this.infiniteGold, - infiniteTroops: this.infiniteTroops, - instantBuild: this.instantBuild, - }, + gameID: this.lobbyId, + } as JoinLobbyEvent, bubbles: true, composed: true, }), diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index be32e4878..7d64227d6 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -2,8 +2,9 @@ import { LitElement, css, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { consolex } from "../core/Consolex"; import { GameMapType, GameType } from "../core/game/Game"; -import { GameInfo } from "../core/Schemas"; +import { GameInfo, GameRecord } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/Config"; +import { JoinLobbyEvent } from "./Main"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @@ -370,39 +371,56 @@ export class JoinPrivateLobbyModal extends LitElement { const config = await getServerConfigFromClient(); const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; - fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => { - return response.json(); - }) - .then((data) => { - if (data.exists) { - this.message = "Joined successfully! Waiting for game to start..."; - this.hasJoined = true; + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const gameInfo = await response.json(); + if (gameInfo.exists) { + this.message = "Joined successfully! Waiting for game to start..."; + this.hasJoined = true; + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + this.playersInterval = setInterval(() => this.pollPlayers(), 1000); + } else { + const archive_url = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`; + const archive_response = await fetch(archive_url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const archive_data = await archive_response.json(); + if (archive_data.exists) { + const gr = archive_data.gameRecord as GameRecord; this.dispatchEvent( new CustomEvent("join-lobby", { detail: { - lobby: { gameID: lobbyId }, - gameType: GameType.Private, - map: GameMapType.World, - }, + gameID: lobbyId, + gameRecord: gr, + } as JoinLobbyEvent, bubbles: true, composed: true, }), ); - this.playersInterval = setInterval(() => this.pollPlayers(), 1000); } else { this.message = "Lobby not found. Please check the ID and try again."; } - }) - .catch((error) => { - consolex.error("Error checking lobby existence:", error); - this.message = "An error occurred. Please try again."; - }); + } + } catch (error) { + consolex.error("Error checking lobby existence:", error); + this.message = "An error occurred. Please try again."; + } } private async pollPlayers() { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index ad1b374b5..3fc6e6b47 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -16,7 +16,11 @@ import { ServerTurnMessageSchema, Turn, } from "../core/Schemas"; -import { CreateGameRecord, generateID } from "../core/Util"; +import { + createGameRecord, + decompressGameRecord, + generateID, +} from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { getPersistentIDFromCookie } from "./Main"; @@ -33,8 +37,6 @@ export class LocalServer { private allPlayersStats: AllPlayersStats = {}; constructor( - private serverConfig: ServerConfig, - private gameConfig: GameConfig, private lobbyConfig: LobbyConfig, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void, @@ -42,16 +44,21 @@ export class LocalServer { start() { this.startedAt = Date.now(); - this.endTurnIntervalID = setInterval( - () => this.endTurn(), - this.serverConfig.turnIntervalMs(), - ); + if (!this.lobbyConfig.gameRecord) { + this.endTurnIntervalID = setInterval( + () => this.endTurn(), + this.lobbyConfig.serverConfig.turnIntervalMs(), + ); + } this.clientConnect(); + if (this.lobbyConfig.gameRecord) { + this.turns = decompressGameRecord(this.lobbyConfig.gameRecord).turns; + } this.clientMessage( ServerStartGameMessageSchema.parse({ type: "start", - config: this.gameConfig, - turns: [], + config: this.lobbyConfig.gameConfig, + turns: this.turns, }), ); } @@ -69,6 +76,10 @@ export class LocalServer { JSON.parse(message), ); if (clientMsg.type == "intent") { + if (this.lobbyConfig.gameRecord) { + // If we are replaying a game, we don't want to process intents + return; + } if (this.paused) { if (clientMsg.intent.type == "troop_ratio") { // Store troop change events because otherwise they are @@ -79,6 +90,30 @@ export class LocalServer { } this.intents.push(clientMsg.intent); } + if (clientMsg.type == "hash") { + if (!this.lobbyConfig.gameRecord) { + // Don't do hash verification on singleplayer games. + return; + } + const archivedHash = this.turns[clientMsg.turnNumber].hash; + if (archivedHash != clientMsg.hash) { + console.warn( + `desync detected on turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}, server hash: ${archivedHash}`, + ); + this.clientMessage({ + type: "desync", + turn: clientMsg.turnNumber, + correctHash: archivedHash, + clientsWithCorrectHash: 0, + totalActiveClients: 1, + yourHash: clientMsg.hash, + }); + } else { + console.log( + `hash verified on turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}, server hash: ${archivedHash}`, + ); + } + } if (clientMsg.type == "winner") { this.winner = clientMsg.winner; this.allPlayersStats = clientMsg.allPlayersStats; @@ -113,9 +148,9 @@ export class LocalServer { clientID: this.lobbyConfig.clientID, }, ]; - const record = CreateGameRecord( + const record = createGameRecord( this.lobbyConfig.gameID, - this.gameConfig, + this.lobbyConfig.gameConfig, players, this.turns, this.startedAt, @@ -129,7 +164,9 @@ export class LocalServer { const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], { type: "application/json", }); - const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID); + const workerPath = this.lobbyConfig.serverConfig.workerPath( + this.lobbyConfig.gameID, + ); navigator.sendBeacon(`/${workerPath}/api/archive_singleplayer_game`, blob); } } diff --git a/src/client/Main.ts b/src/client/Main.ts index 311e579c5..274546b5b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -23,6 +23,16 @@ import { HelpModal } from "./HelpModal"; import { GameType } from "../core/game/Game"; import { getServerConfigFromClient } from "../core/configuration/Config"; import GoogleAdElement from "./GoogleAdElement"; +import { GameConfig, GameInfo, GameRecord } from "../core/Schemas"; + +export interface JoinLobbyEvent { + // Multiplayer games only have gameID, gameConfig is not known until game starts. + gameID: string; + // GameConfig only exists when playing a singleplayer game. + gameConfig?: GameConfig; + // GameRecord exists when replaying an archived game. + gameRecord?: GameRecord; +} class Client { private gameStop: () => void; @@ -137,18 +147,16 @@ class Client { } private async handleJoinLobby(event: CustomEvent) { - const lobby = event.detail.lobby; - consolex.log(`joining lobby ${lobby.id}`); + const lobby = event.detail as JoinLobbyEvent; + consolex.log(`joining lobby ${lobby.gameID}`); if (this.gameStop != null) { consolex.log("joining lobby, stopping existing game"); this.gameStop(); } const config = await getServerConfigFromClient(); - const gameType = event.detail.gameType; this.gameStop = joinLobby( { serverConfig: config, - gameType: gameType, flag: (): string => this.flagInput.getCurrentFlag() == "xx" ? "" @@ -158,13 +166,8 @@ class Client { persistentID: getPersistentIDFromCookie(), playerID: generateID(), clientID: generateID(), - map: event.detail.map, - difficulty: event.detail.difficulty, - infiniteGold: event.detail.infiniteGold, - infiniteTroops: event.detail.infiniteTroops, - instantBuild: event.detail.instantBuild, - bots: event.detail.bots, - disableNPCs: event.detail.disableNPCs, + gameConfig: lobby.gameConfig ?? lobby.gameRecord?.gameConfig, + gameRecord: lobby.gameRecord, }, () => { this.joinModal.close(); @@ -180,7 +183,7 @@ class Client { startingModal instanceof GameStartingModal; startingModal.show(); - if (gameType != GameType.Singleplayer) { + if (event.detail.gameConfig?.gameType != GameType.Singleplayer) { window.history.pushState({}, "", `/join/${lobby.gameID}`); sessionStorage.setItem("inLobby", "true"); } diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 9272a2d70..d2594de55 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -158,12 +158,7 @@ export class PublicLobby extends LitElement { this.currLobby = lobby; this.dispatchEvent( new CustomEvent("join-lobby", { - detail: { - lobby, - gameType: GameType.Public, - map: GameMapType.World, - difficulty: Difficulty.Medium, - }, + detail: lobby, bubbles: true, composed: true, }), diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index facaed818..23ef02c37 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -7,6 +7,8 @@ import "./components/Difficulties"; import { DifficultyDescription } from "./components/Difficulties"; import "./components/Maps"; import randomMap from "../../resources/images/RandomMap.png"; +import { GameInfo } from "../core/Schemas"; +import { JoinLobbyEvent } from "./Main"; @customElement("single-player-modal") export class SinglePlayerModal extends LitElement { @@ -482,18 +484,18 @@ export class SinglePlayerModal extends LitElement { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { - gameType: GameType.Singleplayer, - lobby: { - gameID: generateID(), + gameID: generateID(), + gameConfig: { + gameMap: this.selectedMap, + gameType: GameType.Singleplayer, + difficulty: this.selectedDifficulty, + disableNPCs: this.disableNPCs, + bots: this.bots, + infiniteGold: this.infiniteGold, + infiniteTroops: this.infiniteTroops, + instantBuild: this.instantBuild, }, - map: this.selectedMap, - difficulty: this.selectedDifficulty, - disableNPCs: this.disableNPCs, - bots: this.bots, - infiniteGold: this.infiniteGold, - infiniteTroops: this.infiniteTroops, - instantBuild: this.instantBuild, - }, + } as JoinLobbyEvent, bubbles: true, composed: true, }), diff --git a/src/client/Transport.ts b/src/client/Transport.ts index def2ee561..e6058e5a6 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -150,12 +150,13 @@ export class Transport { constructor( private lobbyConfig: LobbyConfig, - // gameConfig only set on private games - private gameConfig: GameConfig | null, private eventBus: EventBus, - private serverConfig: ServerConfig, ) { - this.isLocal = lobbyConfig.gameType == GameType.Singleplayer; + // If gameRecord is not null, we are replaying an archived game. + // For multiplayer games, GameConfig is not known until game starts. + this.isLocal = + lobbyConfig.gameRecord != null || + lobbyConfig.gameConfig?.gameType == GameType.Singleplayer; this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e), @@ -237,13 +238,7 @@ export class Transport { onconnect: () => void, onmessage: (message: ServerMessage) => void, ) { - this.localServer = new LocalServer( - this.serverConfig, - this.gameConfig, - this.lobbyConfig, - onconnect, - onmessage, - ); + this.localServer = new LocalServer(this.lobbyConfig, onconnect, onmessage); this.localServer.start(); } @@ -255,7 +250,9 @@ export class Transport { this.maybeKillSocket(); const wsHost = window.location.host; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID); + const workerPath = this.lobbyConfig.serverConfig.workerPath( + this.lobbyConfig.gameID, + ); this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`); this.onconnect = onconnect; this.onmessage = onmessage; @@ -273,7 +270,7 @@ export class Transport { this.onmessage(serverMsg); } catch (error) { console.error( - `Failed to process server message ${event.data}: ${error}`, + `Failed to process server message ${event.data}: ${error}, ${error.stack}`, ); } }; @@ -503,7 +500,7 @@ export class Transport { clientID: this.lobbyConfig.clientID, persistentID: this.lobbyConfig.persistentID, gameID: this.lobbyConfig.gameID, - tick: event.tick, + turnNumber: event.tick, hash: event.hash, }); this.sendMsg(JSON.stringify(msg)); diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 5529cf76f..3ac64c441 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -334,7 +334,7 @@ export class UnitLayer implements Layer { for (const t of this.game.bfs( unit.lastTile(), - euclDistFN(unit.lastTile(), range), + euclDistFN(unit.lastTile(), range, false), )) { this.clearCell(this.game.x(t), this.game.y(t)); } @@ -342,7 +342,7 @@ export class UnitLayer implements Layer { if (unit.isActive()) { for (const t of this.game.bfs( unit.tile(), - euclDistFN(unit.tile(), range), + euclDistFN(unit.tile(), range, false), )) { this.paintCell( this.game.x(t), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c17afe9d8..5146695f9 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -280,6 +280,8 @@ export const TurnSchema = z.object({ turnNumber: z.number(), gameID: ID, intents: z.array(IntentSchema), + // The hash of the game state at the end of the turn. + hash: z.number().nullable().optional(), }); // Server @@ -310,6 +312,7 @@ export const ServerDesyncSchema = ServerBaseMessageSchema.extend({ correctHash: z.number().nullable(), clientsWithCorrectHash: z.number(), totalActiveClients: z.number(), + yourHash: z.number().optional(), }); export const ServerMessageSchema = z.union([ @@ -337,7 +340,7 @@ export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({ export const ClientHashSchema = ClientBaseMessageSchema.extend({ type: z.literal("hash"), hash: z.number(), - tick: z.number(), + turnNumber: z.number(), }); export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({ diff --git a/src/core/Util.ts b/src/core/Util.ts index 6dd69a070..1d6caef67 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -255,7 +255,7 @@ export function onlyImages(html: string) { }); } -export function CreateGameRecord( +export function createGameRecord( id: GameID, gameConfig: GameConfig, // username does not need to be set. @@ -278,7 +278,7 @@ export function CreateGameRecord( }; for (const turn of turns) { - if (turn.intents.length != 0) { + if (turn.intents.length != 0 || turn.hash != undefined) { record.turns.push(turn); for (const intent of turn.intents) { if (intent.type == "spawn") { @@ -300,6 +300,25 @@ export function CreateGameRecord( return record; } +export function decompressGameRecord(gameRecord: GameRecord) { + const turns = []; + let lastTurnNum = 0; + for (const turn of gameRecord.turns) { + while (lastTurnNum < turn.turnNumber - 1) { + lastTurnNum++; + turns.push({ + gameID: gameRecord.id, + turnNumber: lastTurnNum, + intents: [], + }); + } + turns.push(turn); + lastTurnNum = turn.turnNumber; + } + gameRecord.turns = turns; + return gameRecord; +} + export function assertNever(x: never): never { throw new Error("Unexpected value: " + x); } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 01094599f..ab5069875 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,4 +1,4 @@ -import { GameRecord, GameID } from "../core/Schemas"; +import { GameRecord, GameID, GameRecordSchema } from "../core/Schemas"; import { S3 } from "@aws-sdk/client-s3"; import { RedshiftData } from "@aws-sdk/client-redshift-data"; import { @@ -107,27 +107,29 @@ async function archiveFullGameToS3(gameRecord: GameRecord) { console.log(`${gameRecord.id}: game record successfully written to S3`); } -export async function readGameRecord(gameId: GameID): Promise { +export async function readGameRecord( + gameId: GameID, +): Promise { try { // Check if file exists and download in one operation const response = await s3.getObject({ Bucket: gameBucket, Key: gameId, }); - // Parse the response body const bodyContents = await response.Body.transformToString(); - const gameRecord = JSON.parse(bodyContents); - - return gameRecord as GameRecord; + return JSON.parse(bodyContents) as GameRecord; } catch (error) { + // Log the error for monitoring purposes console.error(`${gameId}: Error reading game record from S3: ${error}`, { message: error?.message || error, stack: error?.stack, name: error?.name, ...(error && typeof error === "object" ? error : {}), }); - throw error; + + // Return null instead of throwing the error + return null; } } diff --git a/src/server/Client.ts b/src/server/Client.ts index 1765693f2..df00ebb0c 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -10,7 +10,7 @@ export class Client { constructor( public readonly clientID: ClientID, public readonly persistentID: string, - public readonly ip: string | null, + public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, ) {} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c787c5a94..e591f0d83 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -14,7 +14,7 @@ import { ServerTurnMessageSchema, Turn, } from "../core/Schemas"; -import { CreateGameRecord } from "../core/Util"; +import { createGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { archive } from "./Archive"; @@ -161,7 +161,7 @@ export class GameServer { client.lastPing = Date.now(); } if (clientMsg.type == "hash") { - client.hashes.set(clientMsg.tick, clientMsg.hash); + client.hashes.set(clientMsg.turnNumber, clientMsg.hash); } if (clientMsg.type == "winner") { this.winner = clientMsg.winner; @@ -238,7 +238,12 @@ export class GameServer { ), ); } catch (error) { - throw new Error(`error sending start message for game ${this.id}`); + throw new Error( + `error sending start message for game ${this.id}, ${error}`.substring( + 0, + 250, + ), + ); } } @@ -251,7 +256,7 @@ export class GameServer { this.turns.push(pastTurn); this.intents = []; - this.maybeSendDesync(); + this.handleSynchronization(); let msg = ""; try { @@ -262,7 +267,12 @@ export class GameServer { }), ); } catch (error) { - console.log(`error sending message for game ${this.id}`); + console.log( + `error sending message for game ${this.id}, error ${error}`.substring( + 0, + 250, + ), + ); return; } @@ -294,7 +304,7 @@ export class GameServer { persistentID: client.persistentID, })); archive( - CreateGameRecord( + createGameRecord( this.id, this.gameConfig, playerRecords, @@ -405,8 +415,8 @@ export class GameServer { return this.gameConfig.gameType == GameType.Public; } - private maybeSendDesync() { - if (this.activeClients.length <= 1) { + private handleSynchronization() { + if (this.activeClients.length < 1) { return; } if (this.turns.length % 10 == 0 && this.turns.length != 0) { @@ -415,7 +425,12 @@ export class GameServer { let { mostCommonHash, outOfSyncClients } = this.findOutOfSyncClients(lastHashTurn); + if (outOfSyncClients.length == 0) { + this.turns[lastHashTurn].hash = mostCommonHash; + } + if ( + outOfSyncClients.length > 0 && outOfSyncClients.length >= Math.floor(this.activeClients.length / 2) ) { // If half clients out of sync assume all are out of sync. @@ -430,8 +445,6 @@ export class GameServer { this.outOfSyncClients.add(oos.clientID); } } - return; - // TODO: renable this once desync issue fixed const serverDesync = ServerDesyncSchema.safeParse({ type: "desync", @@ -465,6 +478,7 @@ export class GameServer { for (const client of this.activeClients) { if (client.hashes.has(turnNumber)) { const clientHash = client.hashes.get(turnNumber)!; + console.log(`clientHash: ${clientHash}`); counts.set(clientHash, (counts.get(clientHash) || 0) + 1); } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index b667f7088..941b83f7f 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -12,7 +12,7 @@ import { RateLimiterMemory } from "rate-limiter-flexible"; import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas"; import { slog } from "./StructuredLog"; import { GameType } from "../core/game/Game"; -import { archive } from "./Archive"; +import { archive, readGameRecord } from "./Archive"; import { gatekeeper, LimiterType } from "./Gatekeeper"; const config = getServerConfigFromServer(); @@ -175,12 +175,29 @@ export function startWorker() { const game = gm.game(req.params.id); if (game == null) { console.log(`lobby ${req.params.id} not found`); - return res.status(404).json({ error: "Game not found" }); + return res.status(404); } res.json(game.gameInfo()); }), ); + app.get( + "/api/archived_game/:id", + gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { + const gameRecord = await readGameRecord(req.params.id); + if (!gameRecord) { + res.json({ + exists: false, + }); + return; + } + res.json({ + exists: true, + gameRecord: gameRecord, + }); + }), + ); + app.post( "/api/archive_singleplayer_game", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { @@ -208,7 +225,7 @@ export function startWorker() { const forwarded = req.headers["x-forwarded-for"]; const ip = Array.isArray(forwarded) ? forwarded[0] - : forwarded || req.socket.remoteAddress; + : forwarded || req.socket.remoteAddress || "unknown"; try { // Process WebSocket messages as in your original code diff --git a/src/server/gatekeeper b/src/server/gatekeeper index 089ff9e29..6afe22e56 160000 --- a/src/server/gatekeeper +++ b/src/server/gatekeeper @@ -1 +1 @@ -Subproject commit 089ff9e297d590ef997d11681668017b2ef7f200 +Subproject commit 6afe22e564293acdb1bd4fe58d4d87ef57822fd6