diff --git a/src/client/Auth.ts b/src/client/Auth.ts index 6dd5f4992a..c5fdef9816 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -2,16 +2,12 @@ import { decodeJwt } from "jose"; import { z } from "zod"; import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; -import { ID } from "../core/Schemas"; -import { generateID } from "../core/Util"; import { getApiBase, getAudience } from "./Api"; import { generateCryptoRandomUUID } from "./Utils"; export type UserAuth = { jwt: string; claims: TokenPayload } | false; const PERSISTENT_ID_KEY = "player_persistent_id"; -const CLIENT_ID_KEY = "client_join_id"; -const CLIENT_GAME_ID_KEY = "client_join_game_id"; let __jwt: string | null = null; @@ -213,22 +209,6 @@ export function getPersistentID(): string { return base64urlToUuid(sub); } -export function getClientIDForGame(gameID: string): string { - const storedGameID = sessionStorage.getItem(CLIENT_GAME_ID_KEY); - const storedClientID = sessionStorage.getItem(CLIENT_ID_KEY); - if ( - storedGameID === gameID && - storedClientID && - ID.safeParse(storedClientID).success - ) { - return storedClientID; - } - const newID = generateID(); - sessionStorage.setItem(CLIENT_GAME_ID_KEY, gameID); - sessionStorage.setItem(CLIENT_ID_KEY, newID); - return newID; -} - // WARNING: DO NOT EXPOSE THIS ID function getPersistentIDFromLocalStorage(): string { // Try to get existing localStorage diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ef0981bebf..9a0f03789e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -56,7 +56,6 @@ export interface LobbyConfig { serverConfig: ServerConfig; cosmetics: PlayerCosmeticRefs; playerName: string; - clientID: ClientID; gameID: GameID; turnstileToken: string | null; // GameStartInfo only exists when playing a singleplayer game. @@ -71,9 +70,10 @@ export function joinLobby( onPrestart: () => void, onJoin: () => void, ): (force?: boolean) => boolean { - console.log( - `joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, - ); + // Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer) + let clientID: ClientID | undefined; + + console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`); const userSettings: UserSettings = new UserSettings(); startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {}); @@ -82,23 +82,18 @@ export function joinLobby( let currentGameRunner: ClientGameRunner | null = null; - let hasJoined = false; - const onconnect = () => { - if (hasJoined) { - console.log("rejoining game"); - transport.rejoinGame(0); - } else { - hasJoined = true; - console.log(`Joining game lobby ${lobbyConfig.gameID}`); - transport.joinGame(); - } + // Always send join - server will detect reconnection via persistentID + console.log(`Joining game lobby ${lobbyConfig.gameID}`); + transport.joinGame(); }; let terrainLoad: Promise | null = null; const onmessage = (message: ServerMessage) => { if (message.type === "lobby_info") { - eventBus.emit(new LobbyInfoEvent(message.lobby)); + // Server tells us our assigned clientID + clientID = message.myClientID; + eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID)); return; } if (message.type === "prestart") { @@ -118,11 +113,14 @@ export function joinLobby( console.log( `lobby: game started: ${JSON.stringify(message, replacer, 2)}`, ); + // Server tells us our assigned clientID (also sent on start for late joins) + clientID = message.myClientID; onJoin(); // For multiplayer games, GameStartInfo is not known until game starts. lobbyConfig.gameStartInfo = message.gameStartInfo; createClientGame( lobbyConfig, + clientID, eventBus, transport, userSettings, @@ -148,7 +146,7 @@ export function joinLobby( e.message, e.stack, lobbyConfig.gameID, - lobbyConfig.clientID, + clientID, true, false, "error_modal.connection_error", @@ -169,7 +167,7 @@ export function joinLobby( message.error, message.message, lobbyConfig.gameID, - lobbyConfig.clientID, + clientID, true, false, "error_modal.connection_error", @@ -196,6 +194,7 @@ export function joinLobby( async function createClientGame( lobbyConfig: LobbyConfig, + clientID: ClientID | undefined, eventBus: EventBus, transport: Transport, userSettings: UserSettings, @@ -205,6 +204,10 @@ async function createClientGame( if (lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } + // For local games only, derive clientID from the first player in GameStartInfo + if (!clientID && transport.isLocal) { + clientID = lobbyConfig.gameStartInfo.players[0]?.clientID; + } const config = await getConfig( lobbyConfig.gameStartInfo.config, userSettings, @@ -221,16 +224,13 @@ async function createClientGame( mapLoader, ); } - const worker = new WorkerClient( - lobbyConfig.gameStartInfo, - lobbyConfig.clientID, - ); + const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID!); await worker.initialize(); const gameView = new GameView( worker, config, gameMap, - lobbyConfig.clientID, + clientID!, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, ); @@ -244,6 +244,7 @@ async function createClientGame( return new ClientGameRunner( lobbyConfig, + clientID, eventBus, gameRenderer, new InputHandler(gameRenderer.uiState, canvas, eventBus), @@ -269,6 +270,7 @@ export class ClientGameRunner { constructor( private lobby: LobbyConfig, + private clientID: ClientID | undefined, private eventBus: EventBus, private renderer: GameRenderer, private input: InputHandler, @@ -302,8 +304,8 @@ export class ClientGameRunner { { persistentID: getPersistentID(), username: this.lobby.playerName, - clientID: this.lobby.clientID, - stats: update.allPlayersStats[this.lobby.clientID], + clientID: this.clientID!, + stats: update.allPlayersStats[this.clientID!], }, ]; @@ -360,7 +362,7 @@ export class ClientGameRunner { gu.errMsg, gu.stack ?? "missing", this.lobby.gameStartInfo.gameID, - this.lobby.clientID, + this.clientID, ); console.error(gu.stack); this.stop(); @@ -422,7 +424,7 @@ export class ClientGameRunner { "spawn_failed", translateText("error_modal.spawn_failed.description"), this.lobby.gameID, - this.lobby.clientID, + this.clientID, true, false, translateText("error_modal.spawn_failed.title"), @@ -459,7 +461,7 @@ export class ClientGameRunner { `desync from server: ${JSON.stringify(message)}`, "", this.lobby.gameStartInfo.gameID, - this.lobby.clientID, + this.clientID, true, false, "error_modal.desync_notice", @@ -470,7 +472,7 @@ export class ClientGameRunner { message.error, message.message, this.lobby.gameID, - this.lobby.clientID, + this.clientID, true, false, "error_modal.connection_error", @@ -554,7 +556,7 @@ export class ClientGameRunner { return; } if (this.myPlayer === null) { - const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + const myPlayer = this.gameView.playerByClientID(this.clientID!); if (myPlayer === null) return; this.myPlayer = myPlayer; } @@ -589,7 +591,7 @@ export class ClientGameRunner { const tile = this.gameView.ref(cell.x, cell.y); if (this.myPlayer === null) { - const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + const myPlayer = this.gameView.playerByClientID(this.clientID!); if (myPlayer === null) return; this.myPlayer = myPlayer; } @@ -650,7 +652,7 @@ export class ClientGameRunner { } if (this.myPlayer === null) { - const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + const myPlayer = this.gameView.playerByClientID(this.clientID!); if (myPlayer === null) return; this.myPlayer = myPlayer; } @@ -669,7 +671,7 @@ export class ClientGameRunner { } if (this.myPlayer === null) { - const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + const myPlayer = this.gameView.playerByClientID(this.clientID!); if (myPlayer === null) return; this.myPlayer = myPlayer; } @@ -766,7 +768,7 @@ function showErrorModal( error: string, message: string | undefined, gameID: GameID, - clientID: ClientID, + clientID: ClientID | undefined, closable = false, showDiscord = true, heading = "error_modal.crashed", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 6bca2ca8c8..55bc9b2593 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,7 +1,8 @@ import { TemplateResult, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { EventBus } from "../core/EventBus"; import { Difficulty, Duos, @@ -17,11 +18,12 @@ import { ClientInfo, GameConfig, GameInfo, + LobbyInfoEvent, TeamCountConfig, isValidGameID, } from "../core/Schemas"; import { generateID } from "../core/Util"; -import { getClientIDForGame } from "./Auth"; +import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; @@ -74,6 +76,8 @@ export class HostLobbyModal extends BaseModal { @state() private lobbyCreatorClientID: string = ""; @state() private nationCount: number = 0; + @property({ attribute: false }) eventBus: EventBus | null = null; + private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; @@ -81,6 +85,17 @@ export class HostLobbyModal extends BaseModal { private leaveLobbyOnClose = true; + private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { + const lobby = event.lobby; + if (!this.lobbyId || lobby.gameID !== this.lobbyId) { + return; + } + this.lobbyCreatorClientID = event.myClientID; + if (lobby.clients) { + this.clients = lobby.clients; + } + }; + private renderOptionToggle( labelKey: string, checked: boolean, @@ -137,6 +152,21 @@ export class HostLobbyModal extends BaseModal { } } + private startLobbyUpdates() { + this.stopLobbyUpdates(); + if (!this.eventBus) { + console.warn( + "HostLobbyModal: eventBus not set, cannot subscribe to lobby updates", + ); + return; + } + this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo); + } + + private stopLobbyUpdates() { + this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo); + } + render() { const maxTimerHandlers = this.createToggleHandlers( () => this.maxTimer, @@ -636,10 +666,13 @@ export class HostLobbyModal extends BaseModal { } protected onOpen(): void { + this.startLobbyUpdates(); this.lobbyId = generateID(); - this.lobbyCreatorClientID = getClientIDForGame(this.lobbyId); + // Note: clientID will be assigned by server when we join the lobby + // lobbyCreatorClientID stays empty until then - createLobby(this.lobbyCreatorClientID, this.lobbyId) + // Pass auth token for creator identification (server extracts persistentID from it) + createLobby(this.lobbyId) .then(async (lobby) => { this.lobbyId = lobby.gameID; if (!isValidGameID(this.lobbyId)) { @@ -654,7 +687,6 @@ export class HostLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: this.lobbyId, - clientID: this.lobbyCreatorClientID, source: "host", } as JoinLobbyEvent, bubbles: true, @@ -720,6 +752,7 @@ export class HostLobbyModal extends BaseModal { protected onClose(): void { console.log("Closing host lobby modal"); + this.stopLobbyUpdates(); if (this.leaveLobbyOnClose) { this.leaveLobby(); this.updateHistory("/"); // Reset URL to base @@ -1083,20 +1116,20 @@ export class HostLobbyModal extends BaseModal { } } -async function createLobby( - creatorClientID: string, - gameID: string, -): Promise { +async function createLobby(gameID: string): Promise { const config = await getServerConfigFromClient(); + // Send JWT token for creator identification - server extracts persistentID from it + // persistentID should never be exposed to other clients + const token = await getPlayToken(); try { const response = await fetch( - `/${config.workerPath(gameID)}/api/create_game/${gameID}?creatorClientID=${encodeURIComponent(creatorClientID)}`, + `/${config.workerPath(gameID)}/api/create_game/${gameID}`, { method: "POST", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, - // body: JSON.stringify(data), // Include this if you need to send data }, ); diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 0f1c5e2da7..23dbc0cb6b 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -25,7 +25,6 @@ import { HumansVsNations, } from "../core/game/Game"; import { getApiBase } from "./Api"; -import { getClientIDForGame } from "./Auth"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; @@ -64,6 +63,7 @@ export class JoinLobbyModal extends BaseModal { if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) { return; } + this.currentClientID = event.myClientID; // Only stop showing spinner when we have player info if (this.isConnecting && lobby.clients) { this.isConnecting = false; @@ -335,7 +335,6 @@ export class JoinLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: this.currentClientID, source: "public", } as JoinLobbyEvent, bubbles: true, @@ -346,7 +345,8 @@ export class JoinLobbyModal extends BaseModal { private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) { this.currentLobbyId = lobbyId; - this.currentClientID = getClientIDForGame(lobbyId); + // clientID will be assigned by server via lobby_info message + this.currentClientID = ""; this.gameConfig = null; this.players = []; this.nationCount = 0; @@ -776,7 +776,6 @@ export class JoinLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: this.currentClientID, source: "private", } as JoinLobbyEvent, bubbles: true, @@ -835,7 +834,6 @@ export class JoinLobbyModal extends BaseModal { detail: { gameID: lobbyId, gameRecord: parsed.data, - clientID: this.currentClientID, source: "private", } as JoinLobbyEvent, bubbles: true, diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 75121b38ae..10868b8bdc 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -2,14 +2,15 @@ import { z } from "zod"; import { EventBus } from "../core/EventBus"; import { AllPlayersStats, + ClientID, ClientMessage, ClientSendWinnerMessage, - Intent, PartialGameRecordSchema, PlayerRecord, ServerMessage, ServerStartGameMessage, Turn, + TurnIntent, } from "../core/Schemas"; import { createPartialGameRecord, @@ -34,12 +35,13 @@ export class LocalServer { private turns: Turn[] = []; - private intents: Intent[] = []; + private intents: TurnIntent[] = []; private startedAt: number; private paused = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; + private clientID: ClientID | undefined; private winner: ClientSendWinnerMessage | null = null; private allPlayersStats: AllPlayersStats = {}; @@ -102,34 +104,48 @@ export class LocalServer { 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") { - if (clientMsg.intent.type === "toggle_pause") { - if (clientMsg.intent.paused) { + // 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(clientMsg.intent); + 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(clientMsg.intent); + this.intents.push(stampedIntent); this.endTurn(); } return; @@ -139,7 +155,7 @@ export class LocalServer { return; } - this.intents.push(clientMsg.intent); + this.intents.push(stampedIntent); } if (clientMsg.type === "hash") { if (!this.lobbyConfig.gameRecord) { @@ -224,8 +240,8 @@ export class LocalServer { { persistentID: getPersistentID(), username: this.lobbyConfig.playerName, - clientID: this.lobbyConfig.clientID, - stats: this.allPlayersStats[this.lobbyConfig.clientID], + clientID: this.clientID!, + stats: this.allPlayersStats[this.clientID!], cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics, clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined, }, diff --git a/src/client/Main.ts b/src/client/Main.ts index ba65698893..945a48e758 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -210,7 +210,6 @@ declare global { } export interface JoinLobbyEvent { - clientID: string; // Multiplayer games only have gameID, gameConfig is not known until game starts. gameID: string; // GameConfig only exists when playing a singleplayer game. @@ -504,6 +503,8 @@ class Client { ) as HostPrivateLobbyModal; if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) { console.warn("Host private lobby modal element not found"); + } else { + this.hostModal.eventBus = this.eventBus; } const hostLobbyButton = document.getElementById("host-lobby-button"); if (hostLobbyButton === null) throw new Error("Missing host-lobby-button"); @@ -792,7 +793,6 @@ class Client { turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), - clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 3fa4af738c..06d57d6b3b 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { getUserMe, hasLinkedAccount } from "./Api"; -import { getClientIDForGame, getPlayToken } from "./Auth"; +import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/PatternButton"; @@ -230,7 +230,6 @@ export class MatchmakingModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: this.gameID, - clientID: getClientIDForGame(this.gameID), source: "matchmaking", } as JoinLobbyEvent, bubbles: true, diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 1623d996e6..4f7bf279c7 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -912,7 +912,6 @@ export class SinglePlayerModal extends BaseModal { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { - clientID: clientID, gameID: gameID, gameStartInfo: { gameID: gameID, diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 58307e1132..e046d10682 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -402,7 +402,7 @@ export class Transport { this.sendMsg({ type: "join", gameID: this.lobbyConfig.gameID, - clientID: this.lobbyConfig.clientID, + // Note: clientID is not sent - server assigns it based on persistentID username: this.lobbyConfig.playerName, cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, @@ -414,7 +414,7 @@ export class Transport { this.sendMsg({ type: "rejoin", gameID: this.lobbyConfig.gameID, - clientID: this.lobbyConfig.clientID, + // Note: clientID is not sent - server looks it up from persistentID in token lastTurn: lastTurn, token: await getPlayToken(), } satisfies ClientRejoinMessage); @@ -443,7 +443,6 @@ export class Transport { private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) { this.sendIntent({ type: "allianceRequest", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } @@ -451,7 +450,6 @@ export class Transport { private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) { this.sendIntent({ type: "allianceRequestReply", - clientID: this.lobbyConfig.clientID, requestor: event.requestor.id(), accept: event.accepted, }); @@ -460,7 +458,6 @@ export class Transport { private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) { this.sendIntent({ type: "breakAlliance", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } @@ -470,7 +467,6 @@ export class Transport { ) { this.sendIntent({ type: "allianceExtension", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } @@ -478,7 +474,6 @@ export class Transport { private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) { this.sendIntent({ type: "spawn", - clientID: this.lobbyConfig.clientID, tile: event.tile, }); } @@ -486,7 +481,6 @@ export class Transport { private onSendAttackIntent(event: SendAttackIntentEvent) { this.sendIntent({ type: "attack", - clientID: this.lobbyConfig.clientID, targetID: event.targetID, troops: event.troops, }); @@ -495,7 +489,6 @@ export class Transport { private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) { this.sendIntent({ type: "boat", - clientID: this.lobbyConfig.clientID, troops: event.troops, dst: event.dst, }); @@ -505,7 +498,6 @@ export class Transport { this.sendIntent({ type: "upgrade_structure", unit: event.unitType, - clientID: this.lobbyConfig.clientID, unitId: event.unitId, }); } @@ -513,7 +505,6 @@ export class Transport { private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) { this.sendIntent({ type: "targetPlayer", - clientID: this.lobbyConfig.clientID, target: event.targetID, }); } @@ -521,7 +512,6 @@ export class Transport { private onSendEmojiIntent(event: SendEmojiIntentEvent) { this.sendIntent({ type: "emoji", - clientID: this.lobbyConfig.clientID, recipient: event.recipient === AllPlayers ? AllPlayers : event.recipient.id(), emoji: event.emoji, @@ -531,7 +521,6 @@ export class Transport { private onSendDonateGoldIntent(event: SendDonateGoldIntentEvent) { this.sendIntent({ type: "donate_gold", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), gold: event.gold ? Number(event.gold) : null, }); @@ -540,7 +529,6 @@ export class Transport { private onSendDonateTroopIntent(event: SendDonateTroopsIntentEvent) { this.sendIntent({ type: "donate_troops", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), troops: event.troops, }); @@ -549,7 +537,6 @@ export class Transport { private onSendQuickChatIntent(event: SendQuickChatEvent) { this.sendIntent({ type: "quick_chat", - clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), quickChatKey: event.quickChatKey, target: event.target, @@ -559,7 +546,6 @@ export class Transport { private onSendEmbargoIntent(event: SendEmbargoIntentEvent) { this.sendIntent({ type: "embargo", - clientID: this.lobbyConfig.clientID, targetID: event.target.id(), action: event.action, }); @@ -568,7 +554,6 @@ export class Transport { private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) { this.sendIntent({ type: "embargo_all", - clientID: this.lobbyConfig.clientID, action: event.action, }); } @@ -576,7 +561,6 @@ export class Transport { private onBuildUnitIntent(event: BuildUnitIntentEvent) { this.sendIntent({ type: "build_unit", - clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, rocketDirectionUp: event.rocketDirectionUp, @@ -586,7 +570,6 @@ export class Transport { private onPauseGameIntent(event: PauseGameIntentEvent) { this.sendIntent({ type: "toggle_pause", - clientID: this.lobbyConfig.clientID, paused: event.paused, }); } @@ -626,7 +609,6 @@ export class Transport { private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) { this.sendIntent({ type: "cancel_attack", - clientID: this.lobbyConfig.clientID, attackID: event.attackID, }); } @@ -634,7 +616,6 @@ export class Transport { private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) { this.sendIntent({ type: "cancel_boat", - clientID: this.lobbyConfig.clientID, unitID: event.unitID, }); } @@ -642,7 +623,6 @@ export class Transport { private onMoveWarshipEvent(event: MoveWarshipIntentEvent) { this.sendIntent({ type: "move_warship", - clientID: this.lobbyConfig.clientID, unitId: event.unitId, tile: event.tile, }); @@ -651,7 +631,6 @@ export class Transport { private onSendDeleteUnitIntent(event: SendDeleteUnitIntentEvent) { this.sendIntent({ type: "delete_unit", - clientID: this.lobbyConfig.clientID, unitId: event.unitId, }); } @@ -659,7 +638,6 @@ export class Transport { private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) { this.sendIntent({ type: "kick_player", - clientID: this.lobbyConfig.clientID, target: event.target, }); } @@ -667,7 +645,6 @@ export class Transport { private onSendUpdateGameConfigIntent(event: SendUpdateGameConfigIntentEvent) { this.sendIntent({ type: "update_game_config", - clientID: this.lobbyConfig.clientID, config: event.config, }); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 0aa06a3166..449611b8f4 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -166,7 +166,10 @@ export const PublicGamesSchema = z.object({ }); export class LobbyInfoEvent implements GameEvent { - constructor(public lobby: GameInfo) {} + constructor( + public lobby: GameInfo, + public myClientID: ClientID, + ) {} } export interface ClientInfo { @@ -280,139 +283,136 @@ export const QuickChatKeySchema = z.enum( // Intents // -const BaseIntentSchema = z.object({ - clientID: ID, -}); - -export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({ +export const AllianceExtensionIntentSchema = z.object({ type: z.literal("allianceExtension"), recipient: ID, }); -export const AttackIntentSchema = BaseIntentSchema.extend({ +export const AttackIntentSchema = z.object({ type: z.literal("attack"), targetID: ID.nullable(), troops: z.number().nonnegative().nullable(), }); -export const SpawnIntentSchema = BaseIntentSchema.extend({ +export const SpawnIntentSchema = z.object({ type: z.literal("spawn"), tile: z.number(), }); -export const BoatAttackIntentSchema = BaseIntentSchema.extend({ +export const BoatAttackIntentSchema = z.object({ type: z.literal("boat"), troops: z.number().nonnegative(), dst: z.number(), }); -export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ +export const AllianceRequestIntentSchema = z.object({ type: z.literal("allianceRequest"), recipient: ID, }); -export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({ +export const AllianceRequestReplyIntentSchema = z.object({ type: z.literal("allianceRequestReply"), requestor: ID, // The one who made the original alliance request accept: z.boolean(), }); -export const BreakAllianceIntentSchema = BaseIntentSchema.extend({ +export const BreakAllianceIntentSchema = z.object({ type: z.literal("breakAlliance"), recipient: ID, }); -export const TargetPlayerIntentSchema = BaseIntentSchema.extend({ +export const TargetPlayerIntentSchema = z.object({ type: z.literal("targetPlayer"), target: ID, }); -export const EmojiIntentSchema = BaseIntentSchema.extend({ +export const EmojiIntentSchema = z.object({ type: z.literal("emoji"), recipient: z.union([ID, z.literal(AllPlayers)]), emoji: EmojiSchema, }); -export const EmbargoIntentSchema = BaseIntentSchema.extend({ +export const EmbargoIntentSchema = z.object({ type: z.literal("embargo"), targetID: ID, action: z.union([z.literal("start"), z.literal("stop")]), }); -export const EmbargoAllIntentSchema = BaseIntentSchema.extend({ +export const EmbargoAllIntentSchema = z.object({ type: z.literal("embargo_all"), action: z.union([z.literal("start"), z.literal("stop")]), }); -export const DonateGoldIntentSchema = BaseIntentSchema.extend({ +export const DonateGoldIntentSchema = z.object({ type: z.literal("donate_gold"), recipient: ID, gold: z.number().nonnegative().nullable(), }); -export const DonateTroopIntentSchema = BaseIntentSchema.extend({ +export const DonateTroopIntentSchema = z.object({ type: z.literal("donate_troops"), recipient: ID, troops: z.number().nonnegative().nullable(), }); -export const BuildUnitIntentSchema = BaseIntentSchema.extend({ +export const BuildUnitIntentSchema = z.object({ type: z.literal("build_unit"), unit: z.enum(UnitType), tile: z.number(), rocketDirectionUp: z.boolean().optional(), }); -export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ +export const UpgradeStructureIntentSchema = z.object({ type: z.literal("upgrade_structure"), unit: z.enum(UnitType), unitId: z.number(), }); -export const CancelAttackIntentSchema = BaseIntentSchema.extend({ +export const CancelAttackIntentSchema = z.object({ type: z.literal("cancel_attack"), attackID: z.string(), }); -export const CancelBoatIntentSchema = BaseIntentSchema.extend({ +export const CancelBoatIntentSchema = z.object({ type: z.literal("cancel_boat"), unitID: z.number(), }); -export const MoveWarshipIntentSchema = BaseIntentSchema.extend({ +export const MoveWarshipIntentSchema = z.object({ type: z.literal("move_warship"), unitId: z.number(), tile: z.number(), }); -export const DeleteUnitIntentSchema = BaseIntentSchema.extend({ +export const DeleteUnitIntentSchema = z.object({ type: z.literal("delete_unit"), unitId: z.number(), }); -export const QuickChatIntentSchema = BaseIntentSchema.extend({ +export const QuickChatIntentSchema = z.object({ type: z.literal("quick_chat"), recipient: ID, quickChatKey: QuickChatKeySchema, target: ID.optional(), }); -export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({ +export const MarkDisconnectedIntentSchema = z.object({ type: z.literal("mark_disconnected"), + clientID: ID, isDisconnected: z.boolean(), }); -export const KickPlayerIntentSchema = BaseIntentSchema.extend({ +export const KickPlayerIntentSchema = z.object({ type: z.literal("kick_player"), target: ID, }); -export const TogglePauseIntentSchema = BaseIntentSchema.extend({ +export const TogglePauseIntentSchema = z.object({ type: z.literal("toggle_pause"), paused: z.boolean().default(false), }); -export const UpdateGameConfigIntentSchema = BaseIntentSchema.extend({ +export const UpdateGameConfigIntentSchema = z.object({ type: z.literal("update_game_config"), config: GameConfigSchema.partial(), }); @@ -444,13 +444,17 @@ const IntentSchema = z.discriminatedUnion("type", [ UpdateGameConfigIntentSchema, ]); +// TurnIntent = Intent with server-stamped clientID (used in turns and execution) +export const TurnIntentSchema = IntentSchema.and(z.object({ clientID: ID })); +export type TurnIntent = Intent & { clientID: ClientID }; + // // Server utility types // export const TurnSchema = z.object({ turnNumber: z.number(), - intents: IntentSchema.array(), + intents: TurnIntentSchema.array(), // The hash of the game state at the end of the turn. hash: z.number().nullable().optional(), }); @@ -539,6 +543,8 @@ export const ServerStartGameMessageSchema = z.object({ turns: TurnSchema.array(), gameStartInfo: GameStartInfoSchema, lobbyCreatedAt: z.number(), + // The clientID assigned to this connection by the server + myClientID: ID, }); export const ServerDesyncSchema = z.object({ @@ -559,6 +565,8 @@ export const ServerErrorSchema = z.object({ export const ServerLobbyInfoMessageSchema = z.object({ type: z.literal("lobby_info"), lobby: GameInfoSchema, + // The clientID assigned to this connection by the server + myClientID: ID, }); export const ServerMessageSchema = z.discriminatedUnion("type", [ @@ -603,10 +611,10 @@ export const ClientIntentMessageSchema = z.object({ }); // WARNING: never send this message to clients. +// Note: clientID is NOT included - server assigns it based on persistentID from token export const ClientJoinMessageSchema = z.object({ type: z.literal("join"), - clientID: ID, - token: TokenSchema, // WARNING: PII + token: TokenSchema, // WARNING: PII - server extracts persistentID from this gameID: ID, username: UsernameSchema, // Server replaces the refs with the actual cosmetic data. @@ -617,7 +625,7 @@ export const ClientJoinMessageSchema = z.object({ export const ClientRejoinMessageSchema = z.object({ type: z.literal("rejoin"), gameID: ID, - clientID: ID, + // Note: clientID is NOT sent - server looks it up from persistentID in token lastTurn: z.number(), token: TokenSchema, }); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 56d66e5472..04f1517026 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -1,6 +1,6 @@ import { Execution, Game } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; -import { ClientID, GameID, Intent, Turn } from "../Schemas"; +import { ClientID, GameID, Turn, TurnIntent } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution"; import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; @@ -46,7 +46,7 @@ export class Executor { return turn.intents.map((i) => this.createExec(i)); } - createExec(intent: Intent): Execution { + createExec(intent: TurnIntent): Execution { const player = this.mg.playerByClientID(intent.clientID); if (!player) { console.warn(`player with clientID ${intent.clientID} not found`); diff --git a/src/server/Client.ts b/src/server/Client.ts index 9f879ddddb..6f07b65627 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -20,6 +20,5 @@ export class Client { public readonly username: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, - public readonly isRejoin: boolean = false, ) {} } diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 7b7e4358a1..5ec2a0ba59 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -8,7 +8,7 @@ import { GameMode, GameType, } from "../core/game/Game"; -import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas"; +import { GameConfig, GameID } from "../core/Schemas"; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; @@ -32,6 +32,13 @@ export class GameManager { ); } + // Get or create a clientID for this persistentID in the given game + getOrCreateClientId(gameID: GameID, persistentID: string): string | null { + const game = this.games.get(gameID); + if (!game) return null; + return game.getOrCreateClientId(persistentID); + } + joinClient(client: Client, gameID: GameID): boolean { const game = this.games.get(gameID); if (game) { @@ -44,20 +51,18 @@ export class GameManager { rejoinClient( ws: WebSocket, persistentID: string, - msg: ClientRejoinMessage, + gameID: GameID, + lastTurn: number = 0, ): boolean { - const game = this.games.get(msg.gameID); - if (game) { - game.rejoinClient(ws, persistentID, msg); - return true; - } - return false; + const game = this.games.get(gameID); + if (!game) return false; + return game.rejoinClient(ws, persistentID, lastTurn); } createGame( id: GameID, gameConfig: GameConfig | undefined, - creatorClientID?: string, + creatorPersistentID?: string, startsAt?: number, ) { const game = new GameServer( @@ -83,7 +88,7 @@ export class GameManager { disabledUnits: [], ...gameConfig, }, - creatorClientID, + creatorPersistentID, startsAt, ); this.games.set(id, game); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index f8b183dce7..3881d4411c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -7,13 +7,11 @@ import { GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, - ClientRejoinMessage, ClientSendWinnerMessage, GameConfig, GameInfo, GameStartInfo, GameStartInfoSchema, - Intent, PlayerRecord, ServerDesyncSchema, ServerErrorMessage, @@ -22,8 +20,9 @@ import { ServerStartGameMessage, ServerTurnMessage, Turn, + TurnIntent, } from "../core/Schemas"; -import { createPartialGameRecord, getClanTag } from "../core/Util"; +import { createPartialGameRecord, generateID, getClanTag } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; export enum GamePhase { @@ -43,9 +42,11 @@ export class GameServer { private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds private turns: Turn[] = []; - private intents: Intent[] = []; + private intents: TurnIntent[] = []; public activeClients: Client[] = []; private allClients: Map = new Map(); + // Map persistentID to clientID for reconnection lookup + private persistentIdToClientId: Map = new Map(); private clientsDisconnectedStatus: Map = new Map(); private _hasStarted = false; private _startTime: number | null = null; @@ -63,7 +64,7 @@ export class GameServer { private _hasPrestarted = false; - private kickedClients: Set = new Set(); + private kickedPersistentIds: Set = new Set(); private outOfSyncClients: Set = new Set(); private isPaused = false; @@ -87,12 +88,15 @@ export class GameServer { public readonly createdAt: number, private config: ServerConfig, public gameConfig: GameConfig, - private lobbyCreatorID?: string, + private creatorPersistentID?: string, private startsAt?: number, ) { this.log = log_.child({ gameID: id }); } + // Lobby creator clientID - set when the creator joins + private lobbyCreatorID?: string; + public updateGameConfig(gameConfig: Partial): void { if (gameConfig.gameMap !== undefined) { this.gameConfig.gameMap = gameConfig.gameMap; @@ -150,21 +154,41 @@ export class GameServer { } } - public joinClient(client: Client) { - this.websockets.add(client.ws); - if (this.kickedClients.has(client.clientID)) { - this.log.warn(`cannot add client, already kicked`, { - clientID: client.clientID, - }); - return; + private isKicked(clientID: ClientID): boolean { + const persistentID = this.allClients.get(clientID)?.persistentID; + return ( + persistentID !== undefined && this.kickedPersistentIds.has(persistentID) + ); + } + + // Get existing clientID for this persistentID, or null if new player + public getClientIdForPersistentId(persistentID: string): ClientID | null { + const clientID = this.persistentIdToClientId.get(persistentID); + if (!clientID) return null; + if (this.kickedPersistentIds.has(persistentID)) return null; + return clientID; + } + + // Get existing clientID or create a new one for this persistentID + // Returns null if this persistentID has been kicked + public getOrCreateClientId(persistentID: string): ClientID | null { + // Check if this persistentID has been kicked + if (this.kickedPersistentIds.has(persistentID)) { + return null; } - if (this.allClients.has(client.clientID)) { - this.log.warn("cannot add client, already in game", { - clientID: client.clientID, - }); - return; + const existingClientID = this.getClientIdForPersistentId(persistentID); + if (existingClientID) { + return existingClientID; } + // Generate new clientID for new player + const newClientID = generateID(); + this.persistentIdToClientId.set(persistentID, newClientID); + return newClientID; + } + + public joinClient(client: Client) { + this.websockets.add(client.ws); if ( this.gameConfig.maxPlayers && @@ -183,11 +207,18 @@ export class GameServer { return; } - // Log when lobby creator joins private game - if (client.clientID === this.lobbyCreatorID) { + // Check if this client is the lobby creator (by persistentID) + // Server-assigned clientID becomes the lobbyCreatorID + if ( + this.creatorPersistentID && + client.persistentID === this.creatorPersistentID && + !this.lobbyCreatorID + ) { + this.lobbyCreatorID = client.clientID; this.log.info("Lobby creator joined", { gameID: this.id, creatorID: this.lobbyCreatorID, + persistentID: client.persistentID, }); } this.log.info("client joining game", { @@ -244,52 +275,43 @@ export class GameServer { } } + // Attempt to reconnect a client by persistentID. Returns true if successful. + // Only the WebSocket is updated — username, cosmetics, etc. are preserved + // from the original join to maintain consistency throughout the game session. public rejoinClient( ws: WebSocket, persistentID: string, - msg: ClientRejoinMessage, - ): void { - this.websockets.add(ws); + lastTurn: number = 0, + ): boolean { + const clientID = this.getClientIdForPersistentId(persistentID); + if (!clientID) return false; + const client = this.allClients.get(clientID); + if (!client) return false; - if (this.kickedClients.has(msg.clientID)) { - this.log.warn("cannot rejoin client, client has been kicked", { - clientID: msg.clientID, - }); - return; - } - - const client = this.allClients.get(msg.clientID); - if (!client) { - this.log.warn("cannot rejoin client, existing client not found", { - clientID: msg.clientID, - }); - return; - } + this.websockets.add(ws); + this.log.info("client rejoining", { clientID, lastTurn }); - if (client.persistentID !== persistentID) { - this.log.error("persistent ids do not match", { - clientID: msg.clientID, - clientPersistentID: persistentID, - existingIP: ipAnonymize(client.ip), - existingPersistentID: client.persistentID, - }); - return; + // Close old WebSocket to prevent resource leaks + if (client.ws !== ws) { + client.ws.removeAllListeners(); + client.ws.close(); } this.activeClients = this.activeClients.filter( - (c) => c.clientID !== msg.clientID, + (c) => c.clientID !== client.clientID, ); this.activeClients.push(client); client.lastPing = Date.now(); - this.markClientDisconnected(msg.clientID, false); + this.markClientDisconnected(client.clientID, false); client.ws = ws; this.addListeners(client); this.startLobbyInfoBroadcast(); if (this._hasStarted) { - this.sendStartGameMsg(client.ws, msg.lastTurn); + this.sendStartGameMsg(client.ws, lastTurn); } + return true; } private addListeners(client: Client) { @@ -313,21 +335,13 @@ export class GameServer { } const clientMsg = parsed.data; switch (clientMsg.type) { - case "rejoin": { - // Client is already connected, no auth required, send start game message if game has started - if (this._hasStarted) { - this.sendStartGameMsg(client.ws, clientMsg.lastTurn); - } - break; - } case "intent": { - if (clientMsg.intent.clientID !== client.clientID) { - this.log.warn( - `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, - ); - return; - } - switch (clientMsg.intent.type) { + // Server stamps clientID from the authenticated connection + const stampedIntent = { + ...clientMsg.intent, + clientID: client.clientID, + }; + switch (stampedIntent.type) { case "mark_disconnected": { this.log.warn( `Should not receive mark_disconnected intent from client`, @@ -342,14 +356,14 @@ export class GameServer { this.log.warn(`Only lobby creator can kick players`, { clientID: client.clientID, creatorID: this.lobbyCreatorID, - target: clientMsg.intent.target, + target: stampedIntent.target, gameID: this.id, }); return; } // Don't allow lobby creator to kick themselves - if (client.clientID === clientMsg.intent.target) { + if (client.clientID === stampedIntent.target) { this.log.warn(`Cannot kick yourself`, { clientID: client.clientID, }); @@ -359,13 +373,13 @@ export class GameServer { // Log and execute the kick this.log.info(`Lobby creator initiated kick of player`, { creatorID: client.clientID, - target: clientMsg.intent.target, + target: stampedIntent.target, gameID: this.id, kickMethod: "websocket", }); this.kickClient( - clientMsg.intent.target, + stampedIntent.target, KICK_REASON_LOBBY_CREATOR, ); return; @@ -400,7 +414,7 @@ export class GameServer { return; } - if (clientMsg.intent.config.gameType === GameType.Public) { + if (stampedIntent.config.gameType === GameType.Public) { this.log.warn(`Cannot update game to public via WebSocket`, { gameID: this.id, clientID: client.clientID, @@ -416,7 +430,7 @@ export class GameServer { }, ); - this.updateGameConfig(clientMsg.intent.config); + this.updateGameConfig(stampedIntent.config); return; } case "toggle_pause": { @@ -430,15 +444,15 @@ export class GameServer { return; } - if (clientMsg.intent.paused) { + if (stampedIntent.paused) { // Pausing: send intent and complete current turn before pause takes effect - this.addIntent(clientMsg.intent); + this.addIntent(stampedIntent); this.endTurn(); this.isPaused = true; } else { // Unpausing: clear pause flag before sending intent so next turn can execute this.isPaused = false; - this.addIntent(clientMsg.intent); + this.addIntent(stampedIntent); this.endTurn(); } @@ -451,7 +465,7 @@ export class GameServer { default: { // Don't process intents while game is paused if (!this.isPaused) { - this.addIntent(clientMsg.intent); + this.addIntent(stampedIntent); } break; } @@ -471,6 +485,12 @@ export class GameServer { this.handleWinner(client, clientMsg); break; } + case "rejoin": { + if (this._hasStarted) { + this.sendStartGameMsg(client.ws, clientMsg.lastTurn); + } + break; + } default: { this.log.warn(`Unknown message type: ${(clientMsg as any).type}`, { clientID: client.clientID, @@ -501,6 +521,17 @@ export class GameServer { client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1"); } }); + + // Check if WebSocket already closed before we added the listener (race condition) + if (client.ws.readyState >= 2) { + this.log.info("client WebSocket already closing/closed, removing", { + clientID: client.clientID, + readyState: client.ws.readyState, + }); + this.activeClients = this.activeClients.filter( + (c) => c.clientID !== client.clientID, + ); + } } public numClients(): number { @@ -569,12 +600,14 @@ export class GameServer { } private broadcastLobbyInfo() { - const msg = JSON.stringify({ - type: "lobby_info", - lobby: this.gameInfo(), - } satisfies ServerLobbyInfoMessage); + const lobbyInfo = this.gameInfo(); this.activeClients.forEach((c) => { if (c.ws.readyState === WebSocket.OPEN) { + const msg = JSON.stringify({ + type: "lobby_info", + lobby: lobbyInfo, + myClientID: c.clientID, + } satisfies ServerLobbyInfoMessage); c.ws.send(msg); } }); @@ -621,7 +654,7 @@ export class GameServer { }); } - private addIntent(intent: Intent) { + private addIntent(intent: TurnIntent) { this.intents.push(intent); } @@ -646,6 +679,7 @@ export class GameServer { turns: this.turns.slice(lastTurn), gameStartInfo: this.gameStartInfo, lobbyCreatedAt: this.createdAt, + myClientID: client.clientID, } satisfies ServerStartGameMessage), ); } catch (error) { @@ -822,7 +856,7 @@ export class GameServer { clientID: ClientID, reasonKey: string = KICK_REASON_DUPLICATE_SESSION, ): void { - if (this.kickedClients.has(clientID)) { + if (this.isKicked(clientID)) { this.log.warn(`cannot kick client, already kicked`, { clientID, reasonKey, @@ -830,7 +864,8 @@ export class GameServer { return; } - if (!this.allClients.has(clientID)) { + const clientToKick = this.allClients.get(clientID); + if (!clientToKick) { this.log.warn(`cannot kick client, not found in game`, { clientID, reasonKey, @@ -838,7 +873,7 @@ export class GameServer { return; } - this.kickedClients.add(clientID); + this.kickedPersistentIds.add(clientToKick.persistentID); const client = this.activeClients.find((c) => c.clientID === clientID); if (client) { @@ -1041,7 +1076,7 @@ export class GameServer { private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) { if ( this.outOfSyncClients.has(client.clientID) || - this.kickedClients.has(client.clientID) || + this.isKicked(client.clientID) || this.winner !== null || client.reportedWinner !== null ) { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2d8d8dcea9..af7de22d22 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -12,7 +12,6 @@ import { GameType } from "../core/game/Game"; import { ClientMessageSchema, GameID, - ID, PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; @@ -125,12 +124,27 @@ export async function startWorker() { app.post("/api/create_game/:id", async (req, res) => { const id = req.params.id; - const creatorClientID = (() => { - if (typeof req.query.creatorClientID !== "string") return undefined; - const trimmed = req.query.creatorClientID.trim(); - return ID.safeParse(trimmed).success ? trimmed : undefined; - })(); + // Extract persistentID from Authorization header token + // Never accept persistentID directly from client + let creatorPersistentID: string | undefined; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring("Bearer ".length); + const result = await verifyClientToken(token, config); + if (result.type === "success") { + creatorPersistentID = result.persistentId; + } else { + log.warn(`Invalid creator token: ${result.message}`); + return res.status(401).json({ error: "Invalid creator token" }); + } + } else if ( + !req.headers[config.adminHeader()] // Public games use admin token instead + ) { + return res + .status(400) + .json({ error: "Authorization header required to create a game" }); + } if (!id) { log.warn(`cannot create game, id not found`); @@ -164,11 +178,11 @@ export async function startWorker() { return res.status(400).json({ error: "Worker, game id mismatch" }); } - // Pass creatorClientID to createGame - const game = gm.createGame(id, gc, creatorClientID); + // Pass creatorPersistentID to createGame + const game = gm.createGame(id, gc, creatorPersistentID); log.info( - `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`, + `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorPersistentID ? `, creator: ${creatorPersistentID.substring(0, 8)}...` : ""}`, ); res.json(game.gameInfo()); }); @@ -311,12 +325,9 @@ export async function startWorker() { const result = await verifyClientToken(clientMsg.token, config); if (result.type === "error") { log.warn(`Invalid token: ${result.message}`, { - clientID: clientMsg.clientID, + gameID: clientMsg.gameID, }); - ws.close( - 1002, - `Unauthorized: invalid token for client ${clientMsg.clientID}`, - ); + ws.close(1002, `Unauthorized: invalid token`); return; } const { persistentId, claims } = result; @@ -324,11 +335,14 @@ export async function startWorker() { if (clientMsg.type === "rejoin") { log.info("rejoining game", { gameID: clientMsg.gameID, - clientID: clientMsg.clientID, persistentID: persistentId, }); - const wasFound = gm.rejoinClient(ws, persistentId, clientMsg); - + const wasFound = gm.rejoinClient( + ws, + persistentId, + clientMsg.gameID, + clientMsg.lastTurn, + ); if (!wasFound) { log.warn( `game ${clientMsg.gameID} not found on worker ${workerId}`, @@ -338,6 +352,12 @@ export async function startWorker() { return; } + // Try to reconnect an existing client (e.g., page refresh) + // If successful, skip all authorization + if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) { + return; + } + let roles: string[] | undefined; let flares: string[] | undefined; @@ -353,12 +373,10 @@ export async function startWorker() { const result = await getUserMe(clientMsg.token, config); if (result.type === "error") { log.warn(`Unauthorized: ${result.message}`, { - clientID: clientMsg.clientID, + persistentID: persistentId, + gameID: clientMsg.gameID, }); - ws.close( - 1002, - `Unauthorized: user me fetch failed for client ${clientMsg.clientID}`, - ); + ws.close(1002, "Unauthorized: user me fetch failed"); return; } roles = result.response.player.roles; @@ -384,7 +402,8 @@ export async function startWorker() { if (cosmeticResult.type === "forbidden") { log.warn(`Forbidden: ${cosmeticResult.reason}`, { - clientID: clientMsg.clientID, + persistentID: persistentId, + gameID: clientMsg.gameID, }); ws.close(1002, cosmeticResult.reason); return; @@ -401,7 +420,8 @@ export async function startWorker() { break; case "rejected": log.warn("Unauthorized: Turnstile token rejected", { - clientID: clientMsg.clientID, + persistentID: persistentId, + gameID: clientMsg.gameID, reason: turnstileResult.reason, }); ws.close(1002, "Unauthorized: Turnstile token rejected"); @@ -409,15 +429,29 @@ export async function startWorker() { case "error": // Fail open, allow the client to join. log.error("Turnstile token error", { - clientID: clientMsg.clientID, + persistentID: persistentId, + gameID: clientMsg.gameID, reason: turnstileResult.reason, }); } } + // Get or create clientID for this persistentID after authorization succeeds + // Server is authoritative over clientID assignment + const clientID = gm.getOrCreateClientId(clientMsg.gameID, persistentId); + if (clientID === null) { + // Could be game not found OR user was kicked - don't reveal which + log.warn(`client cannot join game ${clientMsg.gameID}`, { + gameID: clientMsg.gameID, + workerId, + }); + ws.close(1002, "Cannot join game"); + return; + } + // Create client and add to game const client = new Client( - clientMsg.clientID, + clientID, persistentId, claims, roles, @@ -432,7 +466,7 @@ export async function startWorker() { if (!wasFound) { log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`); - // Handle game not found case + ws.close(1002, "Game not found"); } // Handle other message types