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.
This commit is contained in:
evanpelle
2025-03-09 14:24:39 -07:00
committed by GitHub
parent 84951fed9f
commit 33292aec5c
16 changed files with 239 additions and 161 deletions
+23 -43
View File
@@ -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<ClientGameRunner> {
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) {
+3 -12
View File
@@ -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,
}),
+42 -24
View File
@@ -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() {
+49 -12
View File
@@ -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);
}
}
+15 -12
View File
@@ -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");
}
+1 -6
View File
@@ -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,
}),
+13 -11
View File
@@ -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,
}),
+11 -14
View File
@@ -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));
+2 -2
View File
@@ -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),
+4 -1
View File
@@ -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({
+21 -2
View File
@@ -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);
}
+9 -7
View File
@@ -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<GameRecord> {
export async function readGameRecord(
gameId: GameID,
): Promise<GameRecord | null> {
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;
}
}
+1 -1
View File
@@ -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,
) {}
+24 -10
View File
@@ -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);
}
}
+20 -3
View File
@@ -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