import { z } from "zod"; import { EventBus } from "../core/EventBus"; import { AllPlayersStats, ClientID, ClientMessage, ClientSendWinnerMessage, PartialGameRecordSchema, PlayerRecord, ServerMessage, ServerStartGameMessage, StampedIntent, Turn, } from "../core/Schemas"; import { createPartialGameRecord, decompressGameRecord, getClanTag, replacer, } from "../core/Util"; import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; import { defaultReplaySpeedMultiplier, ReplaySpeedMultiplier, } from "./utilities/ReplaySpeedMultiplier"; // build a small backlog so MAX can catch up. const MAX_REPLAY_BACKLOG_TURNS = 60; export class LocalServer { // All turns from the game record on replay. private replayTurns: Turn[] = []; private turns: Turn[] = []; private intents: StampedIntent[] = []; private startedAt: number; private paused = false; private rewriteFrozen = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; private clientID: ClientID | undefined; private winner: ClientSendWinnerMessage | null = null; private allPlayersStats: AllPlayersStats = {}; private turnsExecuted = 0; private turnStartTime = 0; private turnCheckInterval: NodeJS.Timeout; private clientConnect: () => void; private clientMessage: (message: ServerMessage) => void; constructor( private lobbyConfig: LobbyConfig, private isReplay: boolean, private eventBus: EventBus, ) {} public updateCallback( clientConnect: () => void, clientMessage: (message: ServerMessage) => void, ) { this.clientConnect = clientConnect; this.clientMessage = clientMessage; } start() { console.log("local server starting"); this.turnCheckInterval = setInterval(() => { const turnIntervalMs = this.lobbyConfig.serverConfig.turnIntervalMs() * this.replaySpeedMultiplier; const backlog = Math.max(0, this.turns.length - this.turnsExecuted); const allowReplayBacklog = this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest && this.lobbyConfig.gameRecord !== undefined; const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0; const canQueueNextTurn = backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog); if ( canQueueNextTurn && Date.now() > this.turnStartTime + turnIntervalMs ) { this.turnStartTime = Date.now(); // End turn on the server means the client will start processing the turn. this.endTurn(); } }, 5); this.eventBus.on(ReplaySpeedChangeEvent, (event) => { this.replaySpeedMultiplier = event.replaySpeedMultiplier; }); this.startedAt = Date.now(); this.clientConnect(); if (this.lobbyConfig.gameRecord) { this.replayTurns = decompressGameRecord( this.lobbyConfig.gameRecord, ).turns; } if (this.lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } this.clientID = this.lobbyConfig.gameStartInfo.players[0]?.clientID; if (!this.clientID) { throw new Error("missing clientID"); } this.clientMessage({ type: "start", gameStartInfo: this.lobbyConfig.gameStartInfo, turns: [], lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt, myClientID: this.clientID, } satisfies ServerStartGameMessage); } onMessage(clientMsg: ClientMessage) { if (clientMsg.type === "rejoin") { if (!this.clientID) { throw new Error("missing clientID"); } this.clientMessage({ type: "start", gameStartInfo: this.lobbyConfig.gameStartInfo!, turns: this.turns, lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt, myClientID: this.clientID, } satisfies ServerStartGameMessage); } if (clientMsg.type === "intent") { // Server stamps clientID - client doesn't send it const stampedIntent = { ...clientMsg.intent, clientID: this.clientID!, }; if (stampedIntent.type === "toggle_pause") { if (stampedIntent.paused) { // Pausing: add intent and end turn before pause takes effect this.intents.push(stampedIntent); this.endTurn(); this.paused = true; } else { // Unpausing: clear pause flag before adding intent so next turn can execute this.paused = false; this.intents.push(stampedIntent); this.endTurn(); } return; } // Don't process non-pause intents during replays or while paused if (this.lobbyConfig.gameRecord || this.paused) { return; } this.intents.push(stampedIntent); } if (clientMsg.type === "hash") { if (!this.lobbyConfig.gameRecord) { if (clientMsg.turnNumber % 100 === 0) { // In singleplayer, only store hash every 100 turns to reduce size of game record. const turn = this.turns[clientMsg.turnNumber]; if (turn) { turn.hash = clientMsg.hash; } } return; } // If we are replaying a game then verify hash. const archivedHash = this.replayTurns[clientMsg.turnNumber].hash; if (!archivedHash) { console.warn( `no archived hash found for turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}`, ); return; } if (archivedHash !== clientMsg.hash) { console.error( `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; this.allPlayersStats = clientMsg.allPlayersStats; } } // This is so the client can tell us when it finished processing the turn. public turnComplete() { this.turnsExecuted++; } // endTurn in this context means the server has collected all the intents // and will send the turn to the client. private endTurn() { if (this.paused || this.rewriteFrozen) { return; } if (this.replayTurns.length > 0) { if (this.turns.length >= this.replayTurns.length) { this.endGame(); return; } this.intents = this.replayTurns[this.turns.length].intents; } const pastTurn: Turn = { turnNumber: this.turns.length, intents: this.intents, }; this.turns.push(pastTurn); this.intents = []; this.clientMessage({ type: "turn", turn: pastTurn, }); } setRewriteFrozen(frozen: boolean): void { this.rewriteFrozen = frozen; } truncateToTurnCount(turnCount: number): void { const clamped = Math.max(0, Math.min(turnCount, this.turns.length)); this.turns = this.turns.slice(0, clamped); this.intents = []; // After rewriting history, the client will re-send turns to a fresh worker, // so turnsExecuted must start from 0 to keep the backlog logic correct. this.turnsExecuted = 0; this.turnStartTime = Date.now(); } public endGame() { console.log("local server ending game"); clearInterval(this.turnCheckInterval); if (this.isReplay) { return; } const players: PlayerRecord[] = [ { persistentID: getPersistentID(), username: this.lobbyConfig.playerName, clientID: this.clientID!, stats: this.allPlayersStats[this.clientID!], cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics, clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined, }, ]; if (this.lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } const record = createPartialGameRecord( this.lobbyConfig.gameStartInfo.gameID, this.lobbyConfig.gameStartInfo.config, players, this.turns, this.startedAt, Date.now(), this.winner?.winner, ); const result = PartialGameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing game record", error); return; } const workerPath = this.lobbyConfig.serverConfig.workerPath( this.lobbyConfig.gameStartInfo.gameID, ); const jsonString = JSON.stringify(result.data, replacer); compress(jsonString) .then((compressedData) => { return fetch(`/${workerPath}/api/archive_singleplayer_game`, { method: "POST", headers: { "Content-Type": "application/json", "Content-Encoding": "gzip", }, body: compressedData, keepalive: true, // Ensures request completes even if page unloads }); }) .catch((error) => { console.error("Failed to archive singleplayer game:", error); }); } } async function compress(data: string): Promise { const stream = new CompressionStream("gzip"); const writer = stream.writable.getWriter(); const reader = stream.readable.getReader(); // Write the data to the compression stream writer.write(new TextEncoder().encode(data)); writer.close(); // Read the compressed data const chunks: Uint8Array[] = []; let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) { chunks.push(value); } } // Combine all chunks into a single Uint8Array const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); const compressedData = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { compressedData.set(chunk, offset); offset += chunk.length; } return compressedData.buffer; }