From e9dfaf48748c54f6d8780d86ffadd17005ba12c3 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Mon, 1 Dec 2025 16:38:55 -0500 Subject: [PATCH 01/12] feat: add spectator mode for all games --- package-lock.json | 45 +++------ resources/lang/en.json | 9 +- src/client/ClientGameRunner.ts | 1 + src/client/HostLobbyModal.ts | 14 +++ src/client/JoinPrivateLobbyModal.ts | 62 +++++++++--- src/client/Main.ts | 2 + src/client/Transport.ts | 1 + src/client/graphics/layers/Leaderboard.ts | 38 +++++++ src/core/Schemas.ts | 3 + src/server/Client.ts | 1 + src/server/GameServer.ts | 115 ++++++++++++++++++++++ src/server/Worker.ts | 1 + 12 files changed, 246 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 236574ed45..3223dfaeff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1049,7 +1049,6 @@ "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2922,7 +2921,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2946,7 +2944,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -5101,7 +5098,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -6420,6 +6416,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -6436,6 +6433,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -6452,6 +6450,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -6468,6 +6467,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -6484,6 +6484,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -6500,6 +6501,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -6516,6 +6518,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -6532,6 +6535,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -6548,6 +6552,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -6564,6 +6569,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -6599,6 +6605,7 @@ "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -7253,7 +7260,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7488,7 +7494,6 @@ "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", @@ -8259,7 +8264,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8318,7 +8322,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8884,7 +8887,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -9063,7 +9065,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1" @@ -9085,7 +9086,6 @@ "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -9690,7 +9690,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10278,7 +10277,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11021,7 +11019,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -11191,7 +11188,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11550,7 +11546,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -12451,7 +12446,6 @@ "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -14981,7 +14975,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -16695,7 +16688,6 @@ "integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", @@ -16753,7 +16745,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17092,7 +17083,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -18362,7 +18352,6 @@ "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^13.0.5", @@ -19075,7 +19064,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19307,7 +19295,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19495,7 +19482,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -19578,8 +19564,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -19668,7 +19653,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19963,7 +19947,6 @@ "integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -20013,7 +19996,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -20097,7 +20079,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20213,7 +20194,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20306,7 +20286,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/resources/lang/en.json b/resources/lang/en.json index 933a1e8b63..9687220ccb 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -226,10 +226,12 @@ "player": "Player", "players": "Players", "join_lobby": "Join Lobby", + "join_as_spectator": "Join as Spectator", "checking": "Checking lobby...", "not_found": "Lobby not found. Please check the ID and try again.", "error": "An error occurred. Please try again or contact support.", "joined_waiting": "Joined successfully! Waiting for game to start...", + "joined_as_spectator": "Joined as spectator! You will be able to view the match.", "version_mismatch": "This game was created with a different version. Cannot join." }, "public_lobby": { @@ -281,7 +283,8 @@ "assigned_teams": "Assigned Teams", "empty_teams": "Empty Teams", "empty_team": "Empty", - "remove_player": "Remove {{username}}" + "remove_player": "Remove {{username}}", + "spectators_count": "{count} spectator(s) watching" }, "team_colors": { "red": "Red", @@ -552,7 +555,9 @@ "warships": "Warships", "cities": "Cities", "show_control": "Show Control", - "show_units": "Show Units" + "show_units": "Show Units", + "spectator": "spectator watching", + "spectators": "spectators watching" }, "player_info_overlay": { "type": "Type", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a4..21c683abba 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -58,6 +58,7 @@ export interface LobbyConfig { clientID: ClientID; gameID: GameID; token: string; + isSpectator?: boolean; // GameStartInfo only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 9ace197cf7..db237e5553 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -54,6 +54,7 @@ export class HostLobbyModal extends LitElement { @state() private lobbyId = ""; @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; + @state() private spectators: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; @@ -562,6 +563,18 @@ export class HostLobbyModal extends LitElement { .teamCount=${this.teamCount} .onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)} > + + ${ + this.spectators.length > 0 + ? html` +
+ ${translateText("host_modal.spectators_count", { + count: this.spectators.length, + })} +
+ ` + : null + }
@@ -844,6 +857,7 @@ export class HostLobbyModal extends LitElement { console.log(`got game info response: ${JSON.stringify(data)}`); this.clients = data.clients ?? []; + this.spectators = data.spectators ?? []; }); } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 2c89e9804e..52fd6ef76a 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -18,6 +18,7 @@ export class JoinPrivateLobbyModal extends LitElement { @state() private message: string = ""; @state() private hasJoined = false; @state() private players: string[] = []; + @state() private spectatorCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; @@ -86,16 +87,33 @@ export class JoinPrivateLobbyModal extends LitElement { (player) => html`${player}`, )}
+ ${this.spectatorCount > 0 + ? html` +
+ ${translateText("host_modal.spectators_count", { + count: this.spectatorCount, + })} +
+ ` + : null} ` : ""} -
+
${!this.hasJoined - ? html` ` + ? html` + + + ` : ""}
@@ -170,16 +188,31 @@ export class JoinPrivateLobbyModal extends LitElement { } private async joinLobby(): Promise { + await this.joinLobbyInternal(false); + } + + private async joinAsSpectator(): Promise { + await this.joinLobbyInternal(true); + } + + private async joinLobbyInternal(isSpectator: boolean): Promise { const lobbyId = this.lobbyIdInput.value; - console.log(`Joining lobby with ID: ${lobbyId}`); + console.log( + `Joining lobby with ID: ${lobbyId} as ${isSpectator ? "spectator" : "player"}`, + ); this.message = `${translateText("private_lobby.checking")}`; try { // First, check if the game exists in active lobbies - const gameExists = await this.checkActiveLobby(lobbyId); + const gameExists = await this.checkActiveLobby(lobbyId, isSpectator); if (gameExists) return; - // If not active, check archived games + // If not active, check archived games (spectators cannot join archived games) + if (isSpectator) { + this.message = `${translateText("private_lobby.not_found")}`; + return; + } + switch (await this.checkArchivedGame(lobbyId)) { case "success": return; @@ -199,7 +232,10 @@ export class JoinPrivateLobbyModal extends LitElement { } } - private async checkActiveLobby(lobbyId: string): Promise { + private async checkActiveLobby( + lobbyId: string, + isSpectator: boolean = false, + ): Promise { const config = await getServerConfigFromClient(); const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; @@ -211,7 +247,9 @@ export class JoinPrivateLobbyModal extends LitElement { const gameInfo = await response.json(); if (gameInfo.exists) { - this.message = translateText("private_lobby.joined_waiting"); + this.message = isSpectator + ? translateText("private_lobby.joined_as_spectator") + : translateText("private_lobby.joined_waiting"); this.hasJoined = true; this.dispatchEvent( @@ -219,6 +257,7 @@ export class JoinPrivateLobbyModal extends LitElement { detail: { gameID: lobbyId, clientID: generateID(), + isSpectator: isSpectator, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -315,6 +354,7 @@ export class JoinPrivateLobbyModal extends LitElement { .then((response) => response.json()) .then((data: GameInfo) => { this.players = data.clients?.map((p) => p.username) ?? []; + this.spectatorCount = data.spectators?.length ?? 0; }) .catch((error) => { console.error("Error polling players:", error); diff --git a/src/client/Main.ts b/src/client/Main.ts index ade4758385..675ae2ef6a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -82,6 +82,7 @@ export interface JoinLobbyEvent { clientID: string; // Multiplayer games only have gameID, gameConfig is not known until game starts. gameID: string; + isSpectator?: boolean; // GameConfig only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. @@ -487,6 +488,7 @@ class Client { playerName: this.usernameInput?.getCurrentUsername() ?? "", token: getPlayToken(), clientID: lobby.clientID, + isSpectator: lobby.isSpectator, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 98b8bde167..e128a511da 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -385,6 +385,7 @@ export class Transport { token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, cosmetics: this.lobbyConfig.cosmetics, + isSpectator: this.lobbyConfig.isSpectator, } satisfies ClientJoinMessage); } diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 21aee6e32f..451cfba7ea 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -2,6 +2,7 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { translateText } from "../../../client/Utils"; +import { getServerConfigFromClient } from "../../../core/configuration/ConfigLoader"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { renderNumber } from "../../Utils"; @@ -49,6 +50,9 @@ export class Leaderboard extends LitElement implements Layer { @state() private _sortOrder: "asc" | "desc" = "desc"; + @state() + private spectatorCount: number = 0; + createRenderRoot() { return this; // use light DOM for Tailwind support } @@ -61,6 +65,28 @@ export class Leaderboard extends LitElement implements Layer { if (this.game.ticks() % 10 === 0) { this.updateLeaderboard(); } + // Update spectator count every 5 seconds (300 ticks) + if (this.game.ticks() % 300 === 0) { + this.updateSpectatorCount(); + } + } + + private async updateSpectatorCount() { + if (this.game === null) return; + try { + const gameID = this.game.gameID(); + const config = await getServerConfigFromClient(); + const response = await fetch( + `/${config.workerPath(gameID)}/api/game/${gameID}`, + ); + if (response.ok) { + const data = await response.json(); + this.spectatorCount = data.spectators?.length ?? 0; + } + } catch (error) { + // Silently fail - spectator count is not critical + console.debug("Failed to fetch spectator count:", error); + } } private setSort(key: "tiles" | "gold" | "troops") { @@ -172,6 +198,18 @@ export class Leaderboard extends LitElement implements Layer { return html``; } return html` + ${this.spectatorCount > 0 + ? html` +
+ 👁️ ${this.spectatorCount} + ${this.spectatorCount === 1 + ? translateText("leaderboard.spectator") + : translateText("leaderboard.spectators")} +
+ ` + : null}
= new Map(); private clientsDisconnectedStatus: Map = new Map(); private _hasStarted = false; @@ -137,6 +138,32 @@ export class GameServer { }); return; } + + // Handle spectators separately + if (client.isSpectator) { + this.log.info("spectator joining game", { + clientID: client.clientID, + persistentID: client.persistentID, + clientIP: ipAnonymize(client.ip), + }); + + // Remove stale spectator if this is a reconnect + const existing = this.spectators.find( + (c) => c.clientID === client.clientID, + ); + if (existing !== undefined) { + this.spectators = this.spectators.filter((c) => c !== existing); + } + + this.spectators.push(client); + client.lastPing = Date.now(); + this.allClients.set(client.clientID, client); + + // Set up spectator message handlers (receive only, no intents) + this.setupSpectatorHandlers(client, lastTurn); + return; + } + // Log when lobby creator joins private game if (client.clientID === this.lobbyCreatorID) { this.log.info("Lobby creator joined", { @@ -340,6 +367,71 @@ export class GameServer { } } + private setupSpectatorHandlers(client: Client, lastTurn: number) { + client.ws.removeAllListeners("message"); + client.ws.on("message", async (message: string) => { + try { + const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + if (!parsed.success) { + this.log.warn("Spectator sent invalid message", { + clientID: client.clientID, + }); + return; + } + const clientMsg = parsed.data; + + // Spectators can only ping, not send intents or other commands + switch (clientMsg.type) { + case "ping": { + this.lastPingUpdate = Date.now(); + client.lastPing = Date.now(); + break; + } + case "intent": { + this.log.warn("Spectator attempted to send intent, ignoring", { + clientID: client.clientID, + intentType: clientMsg.intent.type, + }); + break; + } + default: { + this.log.warn("Spectator sent non-ping message, ignoring", { + clientID: client.clientID, + messageType: (clientMsg as any).type, + }); + break; + } + } + } catch (error) { + this.log.warn("Error handling spectator message", { + clientID: client.clientID, + error: String(error), + }); + } + }); + + client.ws.on("close", () => { + this.log.info("spectator disconnected", { + clientID: client.clientID, + persistentID: client.persistentID, + }); + this.spectators = this.spectators.filter( + (c) => c.clientID !== client.clientID, + ); + }); + + client.ws.on("error", (error: Error) => { + if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { + client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1"); + } + }); + + // Send start message to spectator if game already started + if (this._hasStarted) { + this.sendStartGameMsg(client.ws, lastTurn); + } + } + public numClients(): number { return this.activeClients.length; } @@ -383,6 +475,13 @@ export class GameServer { }); c.ws.send(msg); }); + // Also send to spectators + this.spectators.forEach((c) => { + this.log.info("sending prestart message to spectator", { + clientID: c.clientID, + }); + c.ws.send(msg); + }); } public start() { @@ -423,6 +522,13 @@ export class GameServer { }); this.sendStartGameMsg(c.ws, 0); }); + // Also send to spectators + this.spectators.forEach((c) => { + this.log.info("sending start message to spectator", { + clientID: c.clientID, + }); + this.sendStartGameMsg(c.ws, 0); + }); } private addIntent(intent: Intent) { @@ -467,6 +573,10 @@ export class GameServer { this.activeClients.forEach((c) => { c.ws.send(msg); }); + // Also send to spectators + this.spectators.forEach((c) => { + c.ws.send(msg); + }); } async end() { @@ -592,6 +702,11 @@ export class GameServer { username: c.username, clientID: c.clientID, })), + spectators: this.spectators.map((c) => ({ + username: c.username, + clientID: c.clientID, + isSpectator: true, + })), gameConfig: this.gameConfig, msUntilStart: this.isPublic() ? this.createdAt + this.config.gameCreationRate() diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 212f1bcf04..55544bacef 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -400,6 +400,7 @@ export async function startWorker() { clientMsg.username, ws, cosmeticResult.cosmetics, + clientMsg.isSpectator ?? false, ); const wasFound = gm.addClient( From bc6b2f64034ac2536d437e76abf17b2b559a7bdc Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Mon, 1 Dec 2025 16:55:58 -0500 Subject: [PATCH 02/12] fixed comments which are outside the diff --- src/server/GameServer.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 75763caa0d..727209227c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -648,6 +648,21 @@ export class GameServer { } } this.activeClients = alive; + // Clean up stale spectators + const aliveSpectators: Client[] = []; + for (const spectator of this.spectators) { + if (now - spectator.lastPing > 60_000) { + this.log.info("spectator no pings, terminating", { + clientID: spectator.clientID, + }); + if (spectator.ws.readyState === WebSocket.OPEN) { + spectator.ws.close(1000, "no heartbeats received"); + } + } else { + aliveSpectators.push(spectator); + } + } + this.spectators = aliveSpectators; if (now > this.createdAt + this.maxGameDuration) { this.log.warn("game past max duration", { gameID: this.id, From c34d3cc16f2d02f1c9bba5ceeef7bb52afa3a5e6 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Mon, 1 Dec 2025 17:32:51 -0500 Subject: [PATCH 03/12] fixed Inconsistent validation error handling for spectators --- src/server/GameServer.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 727209227c..11bab7bde7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -373,10 +373,21 @@ export class GameServer { try { const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); if (!parsed.success) { - this.log.warn("Spectator sent invalid message", { + const error = z.prettifyError(parsed.error); + this.log.error("Spectator sent invalid message, closing connection", { clientID: client.clientID, + persistentID: client.persistentID, + validationErrors: error, }); - return; + if (client.ws.readyState === WebSocket.OPEN) { + // 1002: protocol error / policy violation is acceptable for invalid payload + client.ws.close(1002, "invalid spectator message schema"); + } + // Remove spectator immediately (same as close handler) + this.spectators = this.spectators.filter( + (c) => c.clientID !== client.clientID, + ); + return; // stop further processing } const clientMsg = parsed.data; @@ -403,10 +414,17 @@ export class GameServer { } } } catch (error) { - this.log.warn("Error handling spectator message", { + this.log.error("Unhandled exception processing spectator message", { clientID: client.clientID, - error: String(error), + persistentID: client.persistentID, + error: String(error).substring(0, 250), }); + if (client.ws.readyState === WebSocket.OPEN) { + client.ws.close(1002, "spectator message handler exception"); + } + this.spectators = this.spectators.filter( + (c) => c.clientID !== client.clientID, + ); } }); From 1ad702184bdd187426526487606ad249315e3e47 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Mon, 1 Dec 2025 17:44:57 -0500 Subject: [PATCH 04/12] fixed: Missing persistentID validation for spectator reconnection --- src/server/GameServer.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 11bab7bde7..13615f0528 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -147,11 +147,22 @@ export class GameServer { clientIP: ipAnonymize(client.ip), }); - // Remove stale spectator if this is a reconnect + // Handle spectator reconnects: verify persistentID to prevent impersonation const existing = this.spectators.find( (c) => c.clientID === client.clientID, ); if (existing !== undefined) { + if (client.persistentID !== existing.persistentID) { + this.log.warn("spectator reconnect rejected: persistentID mismatch", { + clientID: client.clientID, + incomingPersistentID: client.persistentID, + existingPersistentID: existing.persistentID, + clientIP: ipAnonymize(client.ip), + }); + // Do not remove or replace existing spectator; reject join + return; + } + // Same account reconnect: remove stale spectator entry this.spectators = this.spectators.filter((c) => c !== existing); } From 1ac6e0002c99097fe3c5065c386132856a7a33bc Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Mon, 1 Dec 2025 23:20:53 -0500 Subject: [PATCH 05/12] fix --- src/server/GameServer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 78f61f208f..fe8ddd9c27 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -817,6 +817,10 @@ export class GameServer { const now = Date.now(); for (const [clientID, client] of this.allClients) { + // Spectators are view-only; do not emit mark_disconnected intents for them + if (client.isSpectator) { + continue; + } const isDisconnected = this.isClientDisconnected(clientID); if (!isDisconnected && now - client.lastPing > this.disconnectedTimeout) { this.markClientDisconnected(clientID, true); From 911a0962fa746eff3edd914a3c20d470b0e1d8c3 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Wed, 3 Dec 2025 23:46:39 -0500 Subject: [PATCH 06/12] made it simplfied --- src/client/ClientGameRunner.ts | 8 ++ src/client/Transport.ts | 2 +- src/core/Schemas.ts | 7 ++ src/server/GameServer.ts | 175 +++------------------------------ 4 files changed, 29 insertions(+), 163 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 76eab21aef..a06684b613 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -341,6 +341,14 @@ export class ClientGameRunner { this.hasJoined = true; console.log("starting game!"); + // Spectators send join_spectator intent instead of spawning + if (this.lobby.isSpectator) { + this.transport.sendIntent({ + type: "join_spectator", + clientID: this.lobby.clientID, + }); + } + if (this.gameView.config().isRandomSpawn()) { const goToPlayer = () => { const myPlayer = this.gameView.myPlayer(); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e128a511da..1afe8b57b8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -638,7 +638,7 @@ export class Transport { }); } - private sendIntent(intent: Intent) { + public sendIntent(intent: Intent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { type: "intent", diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 91e04461eb..3d91ad391a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -27,6 +27,7 @@ export type ClientID = string; export type Intent = | SpawnIntent + | JoinSpectatorIntent | AttackIntent | CancelAttackIntent | BoatAttackIntent @@ -52,6 +53,7 @@ export type Intent = export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; export type SpawnIntent = z.infer; +export type JoinSpectatorIntent = z.infer; export type BoatAttackIntent = z.infer; export type EmbargoAllIntent = z.infer; export type CancelBoatIntent = z.infer; @@ -242,6 +244,10 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({ tile: z.number(), }); +export const JoinSpectatorIntentSchema = BaseIntentSchema.extend({ + type: z.literal("join_spectator"), +}); + export const BoatAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("boat"), targetID: ID.nullable(), @@ -354,6 +360,7 @@ const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, SpawnIntentSchema, + JoinSpectatorIntentSchema, MarkDisconnectedIntentSchema, BoatAttackIntentSchema, CancelBoatIntentSchema, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index fe8ddd9c27..c4ababc461 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -40,7 +40,6 @@ export class GameServer { private turns: Turn[] = []; private intents: Intent[] = []; public activeClients: Client[] = []; - public spectators: Client[] = []; private allClients: Map = new Map(); private clientsDisconnectedStatus: Map = new Map(); private _hasStarted = false; @@ -139,42 +138,6 @@ export class GameServer { return; } - // Handle spectators separately - if (client.isSpectator) { - this.log.info("spectator joining game", { - clientID: client.clientID, - persistentID: client.persistentID, - clientIP: ipAnonymize(client.ip), - }); - - // Handle spectator reconnects: verify persistentID to prevent impersonation - const existing = this.spectators.find( - (c) => c.clientID === client.clientID, - ); - if (existing !== undefined) { - if (client.persistentID !== existing.persistentID) { - this.log.warn("spectator reconnect rejected: persistentID mismatch", { - clientID: client.clientID, - incomingPersistentID: client.persistentID, - existingPersistentID: existing.persistentID, - clientIP: ipAnonymize(client.ip), - }); - // Do not remove or replace existing spectator; reject join - return; - } - // Same account reconnect: remove stale spectator entry - this.spectators = this.spectators.filter((c) => c !== existing); - } - - this.spectators.push(client); - client.lastPing = Date.now(); - this.allClients.set(client.clientID, client); - - // Set up spectator message handlers (receive only, no intents) - this.setupSpectatorHandlers(client, lastTurn); - return; - } - // Log when lobby creator joins private game if (client.clientID === this.lobbyCreatorID) { this.log.info("Lobby creator joined", { @@ -395,89 +358,6 @@ export class GameServer { } } - private setupSpectatorHandlers(client: Client, lastTurn: number) { - client.ws.removeAllListeners("message"); - client.ws.on("message", async (message: string) => { - try { - const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - this.log.error("Spectator sent invalid message, closing connection", { - clientID: client.clientID, - persistentID: client.persistentID, - validationErrors: error, - }); - if (client.ws.readyState === WebSocket.OPEN) { - // 1002: protocol error / policy violation is acceptable for invalid payload - client.ws.close(1002, "invalid spectator message schema"); - } - // Remove spectator immediately (same as close handler) - this.spectators = this.spectators.filter( - (c) => c.clientID !== client.clientID, - ); - return; // stop further processing - } - const clientMsg = parsed.data; - - // Spectators can only ping, not send intents or other commands - switch (clientMsg.type) { - case "ping": { - this.lastPingUpdate = Date.now(); - client.lastPing = Date.now(); - break; - } - case "intent": { - this.log.warn("Spectator attempted to send intent, ignoring", { - clientID: client.clientID, - intentType: clientMsg.intent.type, - }); - break; - } - default: { - this.log.warn("Spectator sent non-ping message, ignoring", { - clientID: client.clientID, - messageType: (clientMsg as any).type, - }); - break; - } - } - } catch (error) { - this.log.error("Unhandled exception processing spectator message", { - clientID: client.clientID, - persistentID: client.persistentID, - error: String(error).substring(0, 250), - }); - if (client.ws.readyState === WebSocket.OPEN) { - client.ws.close(1002, "spectator message handler exception"); - } - this.spectators = this.spectators.filter( - (c) => c.clientID !== client.clientID, - ); - } - }); - - client.ws.on("close", () => { - this.log.info("spectator disconnected", { - clientID: client.clientID, - persistentID: client.persistentID, - }); - this.spectators = this.spectators.filter( - (c) => c.clientID !== client.clientID, - ); - }); - - client.ws.on("error", (error: Error) => { - if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { - client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1"); - } - }); - - // Send start message to spectator if game already started - if (this._hasStarted) { - this.sendStartGameMsg(client.ws, lastTurn); - } - } - public numClients(): number { return this.activeClients.length; } @@ -521,13 +401,6 @@ export class GameServer { }); c.ws.send(msg); }); - // Also send to spectators - this.spectators.forEach((c) => { - this.log.info("sending prestart message to spectator", { - clientID: c.clientID, - }); - c.ws.send(msg); - }); } public start() { @@ -568,13 +441,6 @@ export class GameServer { }); this.sendStartGameMsg(c.ws, 0); }); - // Also send to spectators - this.spectators.forEach((c) => { - this.log.info("sending start message to spectator", { - clientID: c.clientID, - }); - this.sendStartGameMsg(c.ws, 0); - }); } private addIntent(intent: Intent) { @@ -619,10 +485,6 @@ export class GameServer { this.activeClients.forEach((c) => { c.ws.send(msg); }); - // Also send to spectators - this.spectators.forEach((c) => { - c.ws.send(msg); - }); } async end() { @@ -694,21 +556,6 @@ export class GameServer { } } this.activeClients = alive; - // Clean up stale spectators - const aliveSpectators: Client[] = []; - for (const spectator of this.spectators) { - if (now - spectator.lastPing > 60_000) { - this.log.info("spectator no pings, terminating", { - clientID: spectator.clientID, - }); - if (spectator.ws.readyState === WebSocket.OPEN) { - spectator.ws.close(1000, "no heartbeats received"); - } - } else { - aliveSpectators.push(spectator); - } - } - this.spectators = aliveSpectators; if (now > this.createdAt + this.maxGameDuration) { this.log.warn("game past max duration", { gameID: this.id, @@ -759,15 +606,19 @@ export class GameServer { public gameInfo(): GameInfo { return { gameID: this.id, - clients: this.activeClients.map((c) => ({ - username: c.username, - clientID: c.clientID, - })), - spectators: this.spectators.map((c) => ({ - username: c.username, - clientID: c.clientID, - isSpectator: true, - })), + clients: this.activeClients + .filter((c) => !c.isSpectator) + .map((c) => ({ + username: c.username, + clientID: c.clientID, + })), + spectators: this.activeClients + .filter((c) => c.isSpectator) + .map((c) => ({ + username: c.username, + clientID: c.clientID, + isSpectator: true, + })), gameConfig: this.gameConfig, msUntilStart: this.isPublic() ? this.createdAt + this.config.gameCreationRate() From f218445b5b15d1d0f606ced8b3559927484e56f6 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Wed, 3 Dec 2025 23:51:03 -0500 Subject: [PATCH 07/12] Fix JSON syntax error in en.json --- resources/lang/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 23e6755bef..ef48ad4abe 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -288,7 +288,6 @@ "empty_team": "Empty", "remove_player": "Remove {{username}}", "spectators_count": "{count} spectator(s) watching" - "remove_player": "Remove {username}" }, "team_colors": { "red": "Red", From d89faf453d2299ed5966b15a86ad03eb6f272f9a Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Wed, 3 Dec 2025 23:53:39 -0500 Subject: [PATCH 08/12] Add publishIntent facade and revert sendIntent to private --- src/client/ClientGameRunner.ts | 2 +- src/client/Transport.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index a06684b613..6725a0e284 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -343,7 +343,7 @@ export class ClientGameRunner { // Spectators send join_spectator intent instead of spawning if (this.lobby.isSpectator) { - this.transport.sendIntent({ + this.transport.publishIntent({ type: "join_spectator", clientID: this.lobby.clientID, }); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1afe8b57b8..ab9552a851 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -638,7 +638,17 @@ export class Transport { }); } - public sendIntent(intent: Intent) { + /** + * Public facade for publishing intents directly (bypassing event bus). + * Use this when you need to send an intent programmatically, such as + * when spectators join the game. Validates client state before sending. + * @param intent The intent to publish to the server + */ + public publishIntent(intent: Intent) { + this.sendIntent(intent); + } + + private sendIntent(intent: Intent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { type: "intent", From 9458842ab3e0cc67d7a2682d745a01d6ca0d91c8 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 11 Dec 2025 17:28:10 -0500 Subject: [PATCH 09/12] Remove spectator comments --- src/client/ClientGameRunner.ts | 1 - src/client/Transport.ts | 6 ------ src/server/GameServer.ts | 1 - 3 files changed, 8 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 465288b49d..669b324205 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -347,7 +347,6 @@ export class ClientGameRunner { if (message.type === "start") { console.log("starting game! in client game runner"); - // Spectators send join_spectator intent instead of spawning if (this.lobby.isSpectator) { this.transport.publishIntent({ type: "join_spectator", diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e116074ab7..1029b619d9 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -659,12 +659,6 @@ export class Transport { }); } - /** - * Public facade for publishing intents directly (bypassing event bus). - * Use this when you need to send an intent programmatically, such as - * when spectators join the game. Validates client state before sending. - * @param intent The intent to publish to the server - */ public publishIntent(intent: Intent) { this.sendIntent(intent); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b119e795e9..0967db79e4 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -708,7 +708,6 @@ export class GameServer { const now = Date.now(); for (const [clientID, client] of this.allClients) { - // Spectators are view-only; do not emit mark_disconnected intents for them if (client.isSpectator) { continue; } From 2cf9a722f5b44a631c954b0f7c390947026fe63a Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 11 Dec 2025 17:39:00 -0500 Subject: [PATCH 10/12] Add isSpectator to LobbyConfig --- src/client/ClientGameRunner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 669b324205..14db48000d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -59,6 +59,7 @@ export interface LobbyConfig { gameID: GameID; token: string; turnstileToken: string | null; + isSpectator?: boolean; // GameStartInfo only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. From e6ad00531f1c730b79a98803334f2c53677106a7 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 11 Dec 2025 17:42:19 -0500 Subject: [PATCH 11/12] Add isSpectator to Client constructor --- src/server/Client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/Client.ts b/src/server/Client.ts index 9f879ddddb..5a14aea4ae 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -20,6 +20,7 @@ export class Client { public readonly username: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, + public readonly isSpectator: boolean = false, public readonly isRejoin: boolean = false, ) {} } From 4b95d535413f35613bcb13d98a2bae33fd8fd115 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 11 Dec 2025 17:44:38 -0500 Subject: [PATCH 12/12] Add isSpectator to ClientJoinMessageSchema --- src/core/Schemas.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index bcd6d2c729..23c9212402 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -544,6 +544,7 @@ export const ClientJoinMessageSchema = z.object({ // Server replaces the refs with the actual cosmetic data. cosmetics: PlayerCosmeticRefsSchema.optional(), turnstileToken: z.string().nullable(), + isSpectator: z.boolean().optional(), }); export const ClientRejoinMessageSchema = z.object({