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