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>
This commit is contained in:
Danny Asmussen
2025-08-23 04:24:37 +02:00
committed by GitHub
parent 14c14e59f2
commit add81b9c04
11 changed files with 94 additions and 16 deletions
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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";
+4 -2
View File
@@ -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,
},
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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",
+7
View File
@@ -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);
+1 -4
View File
@@ -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);
+15
View File
@@ -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);
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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";
+55
View File
@@ -0,0 +1,55 @@
import { getClientID } from "../../src/core/Util";
describe("Util", () => {
class InMemoryLocalStorage {
private readonly store = new Map<string, string>();
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);
});
});