Fix client reconnection after page refresh (#3117)

## Description:

- Removed all code related to generating a client ID on the client. The
server now assigns the client ID and sends it to the client in lobby
messages.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-02-09 01:10:11 +00:00
committed by GitHub
parent e7676b4260
commit 8dcc7cfb9a
15 changed files with 321 additions and 276 deletions
-20
View File
@@ -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
+31 -33
View File
@@ -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<TerrainMapData> | 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,
eventBus: EventBus,
transport: Transport,
userSettings: UserSettings,
@@ -221,16 +220,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 +240,7 @@ async function createClientGame(
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
@@ -269,6 +266,7 @@ export class ClientGameRunner {
constructor(
private lobby: LobbyConfig,
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
@@ -302,8 +300,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 +358,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 +420,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 +457,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 +468,7 @@ export class ClientGameRunner {
message.error,
message.message,
this.lobby.gameID,
this.lobby.clientID,
this.clientID,
true,
false,
"error_modal.connection_error",
@@ -554,7 +552,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 +587,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 +648,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 +667,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 +764,7 @@ function showErrorModal(
error: string,
message: string | undefined,
gameID: GameID,
clientID: ClientID,
clientID: ClientID | undefined,
closable = false,
showDiscord = true,
heading = "error_modal.crashed",
+41 -11
View File
@@ -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,14 @@ export class HostLobbyModal extends BaseModal {
private leaveLobbyOnClose = true;
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
if (lobby.clients) {
this.clients = lobby.clients;
}
};
private renderOptionToggle(
labelKey: string,
checked: boolean,
@@ -137,6 +149,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 +663,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 +684,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 +749,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 +1113,20 @@ export class HostLobbyModal extends BaseModal {
}
}
async function createLobby(
creatorClientID: string,
gameID: string,
): Promise<GameInfo> {
async function createLobby(gameID: string): Promise<GameInfo> {
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
},
);
+4 -11
View File
@@ -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";
@@ -61,9 +60,7 @@ export class JoinLobbyModal extends BaseModal {
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
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 +332,6 @@ export class JoinLobbyModal extends BaseModal {
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: this.currentClientID,
source: "public",
} as JoinLobbyEvent,
bubbles: true,
@@ -346,7 +342,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;
@@ -545,9 +542,7 @@ export class JoinLobbyModal extends BaseModal {
}
}
this.lobbyCreatorClientID = this.isPrivateLobby()
? (lobby.clients?.[0]?.clientID ?? null)
: null;
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
}
private startLobbyUpdates() {
@@ -776,7 +771,6 @@ export class JoinLobbyModal extends BaseModal {
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: this.currentClientID,
source: "private",
} as JoinLobbyEvent,
bubbles: true,
@@ -835,7 +829,6 @@ export class JoinLobbyModal extends BaseModal {
detail: {
gameID: lobbyId,
gameRecord: parsed.data,
clientID: this.currentClientID,
source: "private",
} as JoinLobbyEvent,
bubbles: true,
+25 -9
View File
@@ -2,13 +2,14 @@ import { z } from "zod";
import { EventBus } from "../core/EventBus";
import {
AllPlayersStats,
ClientID,
ClientMessage,
ClientSendWinnerMessage,
Intent,
PartialGameRecordSchema,
PlayerRecord,
ServerMessage,
ServerStartGameMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import {
@@ -34,12 +35,13 @@ export class LocalServer {
private turns: Turn[] = [];
private intents: Intent[] = [];
private intents: StampedIntent[] = [];
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,
},
+2 -2
View File
@@ -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");
@@ -818,7 +819,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,
},
+1 -2
View File
@@ -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,
-1
View File
@@ -912,7 +912,6 @@ export class SinglePlayerModal extends BaseModal {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
clientID: clientID,
gameID: gameID,
gameStartInfo: {
gameID: gameID,
+2 -25
View File
@@ -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,
});
}
+42 -33
View File
@@ -148,6 +148,7 @@ const ClientInfoSchema = z.object({
export const GameInfoSchema = z.object({
gameID: z.string(),
clients: z.array(ClientInfoSchema).optional(),
lobbyCreatorClientID: z.string().optional(),
startsAt: z.number().optional(),
serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
@@ -166,7 +167,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 +284,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 +445,17 @@ const IntentSchema = z.discriminatedUnion("type", [
UpdateGameConfigIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
export type StampedIntent = Intent & { clientID: ClientID };
//
// Server utility types
//
export const TurnSchema = z.object({
turnNumber: z.number(),
intents: IntentSchema.array(),
intents: StampedIntentSchema.array(),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
});
@@ -539,6 +544,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 +566,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 +612,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 +626,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,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { ClientID, GameID, StampedIntent, Turn } 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: StampedIntent): Execution {
const player = this.mg.playerByClientID(intent.clientID);
if (!player) {
console.warn(`player with clientID ${intent.clientID} not found`);
-1
View File
@@ -20,6 +20,5 @@ export class Client {
public readonly username: string,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
public readonly isRejoin: boolean = false,
) {}
}
+14 -16
View File
@@ -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,32 +32,30 @@ export class GameManager {
);
}
joinClient(client: Client, gameID: GameID): boolean {
joinClient(
client: Client,
gameID: GameID,
): "joined" | "kicked" | "rejected" | "not_found" {
const game = this.games.get(gameID);
if (game) {
game.joinClient(client);
return true;
}
return false;
if (!game) return "not_found";
return game.joinClient(client);
}
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 +81,7 @@ export class GameManager {
disabledUnits: [],
...gameConfig,
},
creatorClientID,
creatorPersistentID,
startsAt,
);
this.games.set(id, game);
+95 -81
View File
@@ -7,13 +7,11 @@ import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientRejoinMessage,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
GameStartInfo,
GameStartInfoSchema,
Intent,
PlayerRecord,
ServerDesyncSchema,
ServerErrorMessage,
@@ -21,6 +19,7 @@ import {
ServerPrestartMessageSchema,
ServerStartGameMessage,
ServerTurnMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
@@ -43,9 +42,11 @@ export class GameServer {
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
private turns: Turn[] = [];
private intents: Intent[] = [];
private intents: StampedIntent[] = [];
public activeClients: Client[] = [];
private allClients: Map<ClientID, Client> = new Map();
// Map persistentID to clientID for reconnection lookup
private persistentIdToClientId: Map<string, ClientID> = new Map();
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
private _hasStarted = false;
private _startTime: number | null = null;
@@ -63,7 +64,7 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
private kickedPersistentIds: Set<string> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
private isPaused = false;
@@ -87,12 +88,18 @@ 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 });
}
private get lobbyCreatorID(): ClientID | undefined {
return this.creatorPersistentID
? this.persistentIdToClientId.get(this.creatorPersistentID)
: undefined;
}
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
@@ -150,20 +157,24 @@ 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)
);
}
if (this.allClients.has(client.clientID)) {
this.log.warn("cannot add client, already in game", {
clientID: client.clientID,
});
return;
// 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;
}
public joinClient(client: Client): "joined" | "kicked" | "rejected" {
if (this.kickedPersistentIds.has(client.persistentID)) {
return "kicked";
}
if (
@@ -180,16 +191,9 @@ export class GameServer {
error: "full-lobby",
} satisfies ServerErrorMessage),
);
return;
return "rejected";
}
// Log when lobby creator joins private game
if (client.clientID === this.lobbyCreatorID) {
this.log.info("Lobby creator joined", {
gameID: this.id,
creatorID: this.lobbyCreatorID,
});
}
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
@@ -206,7 +210,7 @@ export class GameServer {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
});
return;
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
@@ -231,6 +235,8 @@ export class GameServer {
}
// Client connection accepted
this.websockets.add(client.ws);
this.persistentIdToClientId.set(client.persistentID, client.clientID);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
@@ -242,54 +248,47 @@ export class GameServer {
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, 0);
}
return "joined";
}
// 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 {
lastTurn: number = 0,
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
const client = this.allClients.get(clientID);
if (!client) return false;
this.websockets.add(ws);
this.log.info("client rejoining", { clientID, lastTurn });
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;
}
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) {
@@ -321,13 +320,12 @@ export class GameServer {
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 +340,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 +357,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 +398,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 +414,7 @@ export class GameServer {
},
);
this.updateGameConfig(clientMsg.intent.config);
this.updateGameConfig(stampedIntent.config);
return;
}
case "toggle_pause": {
@@ -430,15 +428,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 +449,7 @@ export class GameServer {
default: {
// Don't process intents while game is paused
if (!this.isPaused) {
this.addIntent(clientMsg.intent);
this.addIntent(stampedIntent);
}
break;
}
@@ -501,6 +499,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 +578,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 +632,7 @@ export class GameServer {
});
}
private addIntent(intent: Intent) {
private addIntent(intent: StampedIntent) {
this.intents.push(intent);
}
@@ -646,6 +657,7 @@ export class GameServer {
turns: this.turns.slice(lastTurn),
gameStartInfo: this.gameStartInfo,
lobbyCreatedAt: this.createdAt,
myClientID: client.clientID,
} satisfies ServerStartGameMessage),
);
} catch (error) {
@@ -808,6 +820,7 @@ export class GameServer {
username: c.username,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
gameConfig: this.gameConfig,
startsAt: this.startsAt,
serverTime: Date.now(),
@@ -822,7 +835,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 +843,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 +852,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 +1055,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
) {
+62 -29
View File
@@ -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,7 +429,8 @@ 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,
});
}
@@ -417,7 +438,7 @@ export async function startWorker() {
// Create client and add to game
const client = new Client(
clientMsg.clientID,
generateID(),
persistentId,
claims,
roles,
@@ -428,11 +449,23 @@ export async function startWorker() {
cosmeticResult.cosmetics,
);
const wasFound = gm.joinClient(client, clientMsg.gameID);
const joinResult = gm.joinClient(client, clientMsg.gameID);
if (!wasFound) {
if (joinResult === "not_found") {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
// Handle game not found case
ws.close(1002, "Game not found");
} else if (joinResult === "kicked") {
log.warn(`kicked client tried to join game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Cannot join game");
} else if (joinResult === "rejected") {
log.info(`client rejected from game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Lobby full");
}
// Handle other message types