From add81b9c04e2192c6206f3b602f14c53b20a3cb8 Mon Sep 17 00:00:00 2001 From: Danny Asmussen Date: Sat, 23 Aug 2025 04:24:37 +0200 Subject: [PATCH] Reloading the page during a game should rejoin with the same clientID (#1836) ## Description: This PR will fix #1204 Reloading the page during a game will rejoin with the same clientID, so the player can resume, even if they have to catch up from the start. It will use the localStorage to remember the clientID. ## 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: WoodyDRN --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/JoinPrivateLobbyModal.ts | 6 ++-- src/client/LocalPersistantStats.ts | 2 +- src/client/Main.ts | 6 ++-- src/client/PublicLobby.ts | 4 +-- src/client/SinglePlayerModal.ts | 4 +-- src/core/BaseSchemas.ts | 7 ++++ src/core/Schemas.ts | 5 +-- src/core/Util.ts | 15 ++++++++ src/server/Master.ts | 3 +- src/server/Worker.ts | 3 +- tests/core/Util.test.ts | 55 +++++++++++++++++++++++++++++ 11 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 src/core/BaseSchemas.ts create mode 100644 tests/core/Util.test.ts diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 4f4aa2989..ccedb0bd1 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -8,7 +8,7 @@ import { } from "../core/WorkerSchemas"; import { customElement, query, state } from "lit/decorators.js"; import { JoinLobbyEvent } from "./Main"; -import { generateID } from "../core/Util"; +import { getClientID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { translateText } from "../client/Utils"; @@ -220,7 +220,7 @@ export class JoinPrivateLobbyModal extends LitElement { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: generateID(), + clientID: getClientID(lobbyId), } as JoinLobbyEvent, bubbles: true, composed: true, @@ -265,7 +265,7 @@ export class JoinPrivateLobbyModal extends LitElement { detail: { gameID: lobbyId, gameRecord: archiveData.gameRecord, - clientID: generateID(), + clientID: getClientID(lobbyId), } as JoinLobbyEvent, bubbles: true, composed: true, diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index e060a8b91..051a3a9d7 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -4,8 +4,8 @@ import { GameID, GameRecord, GameRecordSchema, - ID, } from "../core/Schemas"; +import { ID } from "../core/BaseSchemas"; import { replacer } from "../core/Util"; import { z } from "zod"; diff --git a/src/client/Main.ts b/src/client/Main.ts index 1378d20e8..b5bcd23d2 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -8,7 +8,7 @@ import "./components/NewsButton"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./styles.css"; -import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; +import { GameRecord, GameStartInfo } from "../core/Schemas"; import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt"; import { generateCryptoRandomUUID, incrementGamesPlayed, translateText } from "./Utils"; import { DarkModeButton } from "./DarkModeButton"; @@ -19,6 +19,7 @@ import { GameStartingModal } from "./GameStartingModal"; import { GameType } from "../core/game/Game"; import { HelpModal } from "./HelpModal"; import { HostLobbyModal } from "./HostLobbyModal"; +import { ID } from "../core/BaseSchemas"; import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; @@ -34,6 +35,7 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { UserSettingModal } from "./UserSettingModal"; import { UserSettings } from "../core/game/UserSettings"; import { UsernameInput } from "./UsernameInput"; +import { getClientID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { joinLobby } from "./ClientGameRunner"; import version from "../../resources/version.txt"; @@ -474,7 +476,7 @@ class Client { : this.flagInput.getCurrentFlag(), playerName: this.usernameInput?.getCurrentUsername() ?? "", token: getPlayToken(), - clientID: lobby.clientID, + clientID: getClientID(lobby.gameID), gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c194d0c2e..cd98d36b9 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -4,7 +4,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ApiPublicLobbiesResponseSchema } from "../core/ExpressSchemas"; import { JoinLobbyEvent } from "./Main"; -import { generateID } from "../core/Util"; +import { getClientID } from "../core/Util"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { translateText } from "../client/Utils"; @@ -205,7 +205,7 @@ export class PublicLobby extends LitElement { new CustomEvent("join-lobby", { detail: { gameID: lobby.gameID, - clientID: generateID(), + clientID: getClientID(lobby.gameID), } as JoinLobbyEvent, bubbles: true, composed: true, diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 7d6497474..577a9257f 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -15,13 +15,13 @@ import { } from "../core/game/Game"; import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; +import { generateID, getClientID } from "../core/Util"; import { DifficultyDescription } from "./components/Difficulties"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { TeamCountConfig } from "../core/Schemas"; import { UserSettings } from "../core/game/UserSettings"; import { UsernameInput } from "./UsernameInput"; -import { generateID } from "../core/Util"; import randomMap from "../../resources/images/RandomMap.webp"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; import { translateText } from "../client/Utils"; @@ -413,8 +413,8 @@ export class SinglePlayerModal extends LitElement { `Starting single player game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType] }${this.useRandomMap ? " (Randomly selected)" : ""}`, ); - const clientID = generateID(); const gameID = generateID(); + const clientID = getClientID(gameID); const usernameInput = document.querySelector( "username-input", diff --git a/src/core/BaseSchemas.ts b/src/core/BaseSchemas.ts new file mode 100644 index 000000000..86f6b09d7 --- /dev/null +++ b/src/core/BaseSchemas.ts @@ -0,0 +1,7 @@ +// This file contains shared schemas +import { z } from "zod"; + +export const ID = z + .string() + .regex(/^[a-zA-Z0-9]+$/) + .length(8); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index b4a06bd2e..e70b9c960 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -10,6 +10,7 @@ import { Trios, UnitType, } from "./game/Game"; +import { ID } from "./BaseSchemas"; import { PatternDecoder } from "./PatternDecoder"; import { PlayerStatsSchema } from "./StatsSchemas"; import { base64url } from "jose"; @@ -176,10 +177,6 @@ const EmojiSchema = z .number() .nonnegative() .max(flattenedEmojiTable.length - 1); -export const ID = z - .string() - .regex(/^[a-zA-Z0-9]+$/) - .length(8); export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); diff --git a/src/core/Util.ts b/src/core/Util.ts index 8a35a43f1..cb919dd72 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -4,6 +4,7 @@ import { } from "./execution/utils/BotNames"; import { Cell, Unit } from "./game/Game"; import { + ClientID, GameConfig, GameID, GameRecord, @@ -13,6 +14,7 @@ import { } from "./Schemas"; import { GameMap, TileRef } from "./game/GameMap"; import DOMPurify from "dompurify"; +import { ID } from "./BaseSchemas"; import { ServerConfig } from "./configuration/Config"; import { customAlphabet } from "nanoid"; @@ -227,6 +229,19 @@ export function generateID(): GameID { return nanoid(); } +export function getClientID(gameID: GameID): ClientID { + const cachedGame = localStorage.getItem("game_id"); + const cachedClient = localStorage.getItem("client_id"); + + if (gameID === cachedGame && cachedClient && ID.safeParse(cachedClient).success) return cachedClient; + + const clientId = generateID(); + localStorage.setItem("game_id", gameID); + localStorage.setItem("client_id", clientId); + + return clientId; +} + export function toInt(num: number): bigint { if (num === Infinity) { return BigInt(Number.MAX_SAFE_INTEGER); diff --git a/src/server/Master.ts b/src/server/Master.ts index 7c860b355..b33ac5241 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { ApiEnvResponse, ApiPublicLobbiesResponse } from "../core/ExpressSchemas"; -import { GameInfo, ID } from "../core/Schemas"; import { LimiterType, gatekeeper } from "./Gatekeeper"; +import { GameInfo } from "../core/Schemas"; +import { ID } from "../core/BaseSchemas"; import { MapPlaylist } from "./MapPlaylist"; import cluster from "cluster"; import express from "express"; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 43ffe9d7f..202dd184a 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -3,7 +3,7 @@ import { GameInputSchema, WorkerApiGameIdExists, } from "../core/WorkerSchemas"; -import { GameRecord, GameRecordSchema, ID } from "../core/Schemas"; +import { GameRecord, GameRecordSchema } from "../core/Schemas"; import { LimiterType, gatekeeper } from "./Gatekeeper"; import { WebSocket, WebSocketServer } from "ws"; import { archive, readGameRecord } from "./Archive"; @@ -11,6 +11,7 @@ import express, { NextFunction, Request, Response } from "express"; import { GameEnv } from "../core/configuration/Config"; import { GameManager } from "./GameManager"; import { GameType } from "../core/game/Game"; +import { ID } from "../core/BaseSchemas"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { fileURLToPath } from "url"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; diff --git a/tests/core/Util.test.ts b/tests/core/Util.test.ts new file mode 100644 index 000000000..a2028ce83 --- /dev/null +++ b/tests/core/Util.test.ts @@ -0,0 +1,55 @@ +import { getClientID } from "../../src/core/Util"; + +describe("Util", () => { + class InMemoryLocalStorage { + private readonly store = new Map(); + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + removeItem(key: string): void { + this.store.delete(key); + } + clear(): void { + this.store.clear(); + } + } + + beforeEach(() => { + (globalThis as any).localStorage = new InMemoryLocalStorage(); + }); + + test("creates and persists a new client", () => { + expect((globalThis as any).localStorage.getItem("client_id")).toBeNull(); + + const id = getClientID("testGameID"); + + expect(typeof id).toBe("string"); + expect(id).toMatch(/^[0-9a-zA-Z]{8}$/); + + const stored = (globalThis as any).localStorage.getItem("client_id"); + expect(stored).toBe(id); + }); + + test("creates two games and make sure only last one is updated", () => { + const id1 = getClientID("testGameID1"); + const id2 = getClientID("testGameID2"); + + expect(id1).not.toBe(id2); + + const stored = (globalThis as any).localStorage.getItem("client_id"); + expect(stored).toBe(id2); + }); + + test("creates two games with same game id, make sure the id stays the same", () => { + const id1 = getClientID("testGameID1"); + const id2 = getClientID("testGameID1"); + + expect(id1).toBe(id2); + + const stored = (globalThis as any).localStorage.getItem("client_id"); + expect(stored).toBe(id1); + }); +});