Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ async function createClientGame(
if (rendererDisposed) return;
rendererDisposed = true;
stopFrameLoop();
gameRenderer.destroy();
view.dispose();
glCanvas.remove();
inputOverlay.remove();
Expand Down
29 changes: 15 additions & 14 deletions src/client/LocalPersistantStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,30 @@ export interface LocalStatsData {
let _startTime: number;

function getStats(): LocalStatsData {
const statsStr = localStorage.getItem("game-records");
return statsStr ? JSON.parse(statsStr) : {};
try {
const statsStr = localStorage.getItem("game-records");
return statsStr ? JSON.parse(statsStr) : {};
} catch {
// Accessing localStorage throws in sandboxed iframes (e.g. gaming portals)
// or when storage is disabled; treat as empty rather than crashing.
return {};
}
}

function save(stats: LocalStatsData) {
// To execute asynchronously
setTimeout(
() => localStorage.setItem("game-records", JSON.stringify(stats, replacer)),
0,
);
setTimeout(() => {
try {
localStorage.setItem("game-records", JSON.stringify(stats, replacer));
} catch {
// Storage unavailable (sandboxed iframe / disabled) — skip persistence.
}
}, 0);
}

// The user can quit the game anytime so better save the lobby as soon as the
// game starts.
export function startGame(id: GameID, lobby: Partial<GameConfig>) {
if (localStorage === undefined) {
return;
}

_startTime = Date.now();
const stats = getStats();
stats[id] = { lobby };
Expand All @@ -42,10 +47,6 @@ export function startTime() {
}

export function endGame(gameRecord: PartialGameRecord) {
if (localStorage === undefined) {
return;
}

const stats = getStats();
const gameStat = stats[gameRecord.info.gameID];

Expand Down
14 changes: 10 additions & 4 deletions src/client/MultiTabDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ export class MultiTabDetector {
private punishmentCount = 0;
private startPenaltyCallback: (duration: number) => void = () => {};

// Bind once so addEventListener and removeEventListener share the same
// reference; otherwise stopMonitoring() can never remove the listeners.
private readonly boundStorageEvent = (e: StorageEvent) =>
this.onStorageEvent(e);
private readonly boundBeforeUnload = () => this.onBeforeUnload();

constructor() {
window.addEventListener("storage", this.onStorageEvent.bind(this));
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
window.addEventListener("storage", this.boundStorageEvent);
window.addEventListener("beforeunload", this.boundBeforeUnload);
}

public startMonitoring(startPenalty: (duration: number) => void): void {
Expand All @@ -33,8 +39,8 @@ export class MultiTabDetector {
if (lock?.owner === this.tabId) {
localStorage.removeItem(this.lockKey);
}
window.removeEventListener("storage", this.onStorageEvent.bind(this));
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
window.removeEventListener("storage", this.boundStorageEvent);
window.removeEventListener("beforeunload", this.boundBeforeUnload);
}

private heartbeat(): void {
Expand Down
14 changes: 11 additions & 3 deletions src/client/hud/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ export function createRenderer(
export class GameRenderer {
private layerTickState = new Map<Controller, { lastTickAtMs: number }>();

// Bound once so it can be removed on destroy(); a new GameRenderer is created
// per game (join without page reload), so an un-removed listener would pin
// this renderer and its transformHandler forever.
private readonly onResize = () =>
this.transformHandler.updateCanvasBoundingRect();

constructor(
public transformHandler: TransformHandler,
public uiState: UIState,
Expand All @@ -359,14 +365,16 @@ export class GameRenderer {

this.layers.forEach((l) => l.init?.());

window.addEventListener("resize", () =>
this.transformHandler.updateCanvasBoundingRect(),
);
window.addEventListener("resize", this.onResize);

//show whole map on startup
this.transformHandler.centerAll(0.9);
}

destroy() {
window.removeEventListener("resize", this.onResize);
}

tick() {
const nowMs = performance.now();
const shouldProfileTick = FrameProfiler.isEnabled();
Expand Down
7 changes: 5 additions & 2 deletions src/client/render/gl/passes/StructurePass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export class StructurePass {
private ghost: GhostPreviewData | null = null;
/** Scratch buffer for the single ghost instance (avoids allocation). */
private ghostBuf = new Float32Array(FLOATS_PER_INSTANCE);
/** Scratch per-structure uniform arrays, rebuilt each frame (avoids per-frame allocation). */
private readonly shapeScales = new Float32Array(ATLAS_COLS);
private readonly iconFills = new Float32Array(ATLAS_COLS);

constructor(
gl: WebGL2RenderingContext,
Expand Down Expand Up @@ -362,8 +365,8 @@ export class StructurePass {
gl.uniform1f(this.uIconGrowZoom, ss.iconGrowZoom);

// Build per-structure uniform arrays from settings, ordered by atlas column
const scales = new Float32Array(ATLAS_COLS);
const fills = new Float32Array(ATLAS_COLS);
const scales = this.shapeScales;
const fills = this.iconFills;
for (let i = 0; i < STRUCTURE_ORDER.length; i++) {
const cfg = ss.shapes[STRUCTURE_ORDER[i]];
scales[i] = cfg?.scale ?? 1.0;
Expand Down
7 changes: 6 additions & 1 deletion src/core/execution/SpawnExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export class SpawnExecution implements Execution {
}

player.tiles().forEach((t) => player.relinquish(t));
const spawn = this.getSpawn(this.tile);
// In random-spawn mode the location must be chosen by the simulation, never
// by a client-supplied tile. Ignore this.tile so a forged spawn intent
// can't pick its own coordinates.
const spawn = this.getSpawn(
this.mg.config().isRandomSpawn() ? undefined : this.tile,
);

if (!spawn) {
console.warn(`SpawnExecution: cannot spawn ${this.playerInfo.name}`);
Expand Down
21 changes: 21 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ export class GameServer {

// Close old WebSocket to prevent resource leaks
if (client.ws !== ws) {
this.websockets.delete(client.ws);
client.ws.removeAllListeners();
client.ws.close();
}
Expand Down Expand Up @@ -676,13 +677,22 @@ export class GameServer {
clientID: client.clientID,
persistentID: client.persistentID,
});
this.websockets.delete(client.ws);
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);

if (!this._hasStarted) {
// Remove persistentId if the game has not started to prevent going over max players
this.persistentIdToClientId.delete(client.persistentID);
// A player left before start: if we dropped back under capacity, clear
// the max-players latch so the lobby doesn't commit to a premature
// under-capacity start (notably small public lobbies / ranked 1v1).
if (
this.activeClients.length < (this.gameConfig.maxPlayers ?? Infinity)
) {
this.hasReachedMaxPlayerCount = false;
}
// Close lobby when host leaves before game starts
if (
!this.isPublic() &&
Expand Down Expand Up @@ -716,6 +726,11 @@ export class GameServer {
// Remove persistentId if the game has not started to prevent going over max players
if (!this._hasStarted) {
this.persistentIdToClientId.delete(client.persistentID);
if (
this.activeClients.length < (this.gameConfig.maxPlayers ?? Infinity)
) {
this.hasReachedMaxPlayerCount = false;
}
Comment on lines +729 to +733

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Mirror the websocket-set cleanup in this race path.

Line 723 removes the client from activeClients, but this branch still leaves client.ws inside this.websockets. If the socket was already closed before addListeners() ran, end() keeps iterating a dead entry and the set can grow with abandoned sockets. Add the same delete used in the normal "close" handler.

Proposed fix
     if (client.ws.readyState >= 2) {
       this.log.info("client WebSocket already closing/closed, removing", {
         clientID: client.clientID,
         readyState: client.ws.readyState,
       });
+      this.websockets.delete(client.ws);
       this.activeClients = this.activeClients.filter(
         (c) => c.clientID !== client.clientID,
       );
       // Remove persistentId if the game has not started to prevent going over max players
       if (!this._hasStarted) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
this.activeClients.length < (this.gameConfig.maxPlayers ?? Infinity)
) {
this.hasReachedMaxPlayerCount = false;
}
if (client.ws.readyState >= 2) {
this.log.info("client WebSocket already closing/closed, removing", {
clientID: client.clientID,
readyState: client.ws.readyState,
});
this.websockets.delete(client.ws);
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);
// Remove persistentId if the game has not started to prevent going over max players
if (!this._hasStarted) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/GameServer.ts` around lines 729 - 733, The race-path cleanup in
GameServer leaves a disconnected client’s socket in this.websockets even after
removing it from activeClients, which can leave dead entries behind. Update the
same cleanup branch in GameServer’s end/addListeners flow to also delete
client.ws from this.websockets, mirroring the normal "close" handler so
abandoned sockets are fully removed.

}
}
}
Expand Down Expand Up @@ -1016,6 +1031,12 @@ export class GameServer {
}

phase(): GamePhase {
// Once the game has been torn down (e.g. private-lobby host left, or end()
// was called) it must be reported Finished so GameManager stops scheduling
// and removes it, instead of lingering as a Lobby until max duration.
if (this._hasEnded) {
return GamePhase.Finished;
}
const now = Date.now();
const alive: Client[] = [];
for (const client of this.activeClients) {
Expand Down
17 changes: 12 additions & 5 deletions src/server/Master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,33 @@ export async function startMaster() {

log.info(`Instance ID: ${INSTANCE_ID}`);

// Track which WORKER_ID each cluster worker owns. `worker.process` is a
// ChildProcess and does NOT expose the forked env, so the exit handler can't
// recover WORKER_ID from worker.process.env — keep an explicit registry.
const workerIdByClusterId = new Map<number, number>();

// Fork workers
for (let i = 0; i < ServerEnv.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
INSTANCE_ID,
});

workerIdByClusterId.set(worker.id, i);
lobbyService.registerWorker(i, worker);
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
}

// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
const workerId = workerIdByClusterId.get(worker.id);
if (workerId === undefined) {
log.error(`worker crashed could not find id`);
log.error(`worker crashed could not find id for cluster id ${worker.id}`);
return;
}
workerIdByClusterId.delete(worker.id);

const workerIdNum = parseInt(workerId);
lobbyService.removeWorker(workerIdNum);
lobbyService.removeWorker(workerId);

log.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
Expand All @@ -123,7 +129,8 @@ export async function startMaster() {
INSTANCE_ID,
});

lobbyService.registerWorker(workerIdNum, newWorker);
workerIdByClusterId.set(newWorker.id, workerId);
lobbyService.registerWorker(workerId, newWorker);
log.info(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
Expand Down
12 changes: 11 additions & 1 deletion src/server/ServerEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const JwksSchema = z.object({
export class ServerEnv {
private static readonly gameEnv: GameEnv = parseGameEnv(process.env.GAME_ENV);
private static publicKey: JWK | null = null;
private static publicKeyFetchedAt = 0;
// Refresh the cached JWKS key periodically so an IdP signing-key rotation
// doesn't break all token verification until the process is restarted.
private static readonly PUBLIC_KEY_TTL_MS = 60 * 60 * 1000; // 1 hour

// Values that also flow to the client via index.html, but on the server
// are read from process.env directly. Server code never reaches into
Expand Down Expand Up @@ -90,7 +94,12 @@ export class ServerEnv {
: `https://api.${audience}`;
}
static async jwkPublicKey(): Promise<JWK> {
if (ServerEnv.publicKey) return ServerEnv.publicKey;
if (
ServerEnv.publicKey &&
Date.now() - ServerEnv.publicKeyFetchedAt < ServerEnv.PUBLIC_KEY_TTL_MS
) {
return ServerEnv.publicKey;
}
const jwksUrl = ServerEnv.jwtIssuer() + "/.well-known/jwks.json";
console.log(`Fetching JWKS from ${jwksUrl}`);
const response = await fetch(jwksUrl);
Expand All @@ -105,6 +114,7 @@ export class ServerEnv {
throw new Error("Invalid JWKS");
}
ServerEnv.publicKey = result.data.keys[0];
ServerEnv.publicKeyFetchedAt = Date.now();
return ServerEnv.publicKey;
}
static turnIntervalMs(): number {
Expand Down
26 changes: 26 additions & 0 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ export async function startWorker() {

registerAdminBotRoutes({ app, gm, workerId, log });

// SECURITY (known gap, intentionally unfixed for now): this endpoint is
// unauthenticated — it validates the schema then forwards the record to the
// central API with the server's privileged x-api-key, trusting the
// client-supplied players[].persistentID. A caller can submit forged/spam
// single-player records, and anyone who learns another account's persistentID
// could attribute a forged game to it. Recommended fix: require a Bearer JWT
// (client sends getAuthHeader()), verifyClientToken it, and stamp
// players[0].persistentID from auth.persistentId instead of trusting the body
// (so a leaked persistentID can't forge — only a real JWT can). Open question
// is how to treat logged-out singleplayer users (no JWT in prod).
app.post("/api/archive_singleplayer_game", async (req, res) => {
try {
const record = req.body;
Expand Down Expand Up @@ -269,6 +279,18 @@ export async function startWorker() {

// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
// Reap sockets that upgrade but never send a valid join/rejoin. Until a
// client authenticates, no GameServer tracks the socket, so nothing else
// would ever close it — without this, idle connections accumulate
// (Slowloris-style FD exhaustion).
let authenticated = false;
const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.close(1008, "join timeout");
}
}, 30_000);
ws.on("close", () => clearTimeout(authTimeout));

ws.on("message", async (message: string) => {
const ip = getClientIp(req);

Expand Down Expand Up @@ -321,6 +343,10 @@ export async function startWorker() {
}
const { persistentId, claims } = result;

// Token verified — the connection is authenticated; stop the reaper.
authenticated = true;
clearTimeout(authTimeout);

if (claims?.role === "banned") {
ws.close(1002, "Account Banned");
return;
Expand Down
45 changes: 45 additions & 0 deletions tests/RandomSpawnNoOverride.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";

const GAME_ID: GameID = "game_id";
const PLAYER_ID = "p_id";

async function spawnWith(
randomSpawn: boolean,
x: number,
y: number,
): Promise<{ game: Game; injected: TileRef; spawnTile: TileRef | undefined }> {
const game = await setup("plains", { randomSpawn });
game.addPlayer(new PlayerInfo("p", PlayerType.Human, null, PLAYER_ID));
const injected = game.map().ref(x, y);
game.addExecution(
new SpawnExecution(GAME_ID, game.player(PLAYER_ID).info(), injected),
);
game.executeNextTick(); // init the execution
game.executeNextTick(); // run the execution
return { game, injected, spawnTile: game.player(PLAYER_ID).spawnTile() };
}

describe("Random spawn cannot be overridden by a client-supplied tile", () => {
test("non-random mode honors the requested tile", async () => {
const { injected, spawnTile } = await spawnWith(false, 50, 50);
expect(spawnTile).toBe(injected);
});

test("random mode ignores the injected tile", async () => {
const a = await spawnWith(true, 50, 50);
const b = await spawnWith(true, 60, 60);

// The player still spawns on a valid land tile.
expect(a.spawnTile).toBeDefined();
expect(a.game.isLand(a.spawnTile!)).toBe(true);

// If the injected tile were honored, the two runs would spawn at the two
// distinct injected tiles. Because random spawn ignores it and uses the
// deterministic per-player seed instead, both runs land on the same tile.
expect(a.spawnTile).toBe(b.spawnTile);
});
});
Loading