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); + }); +});