From 2be858869cb6ded07912b080d0bfc40d23295c99 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:54:39 +0100 Subject: [PATCH] Split runtime and game logic env loading (#3505) ## Description: This refactors client configuration loading to make the environment split explicit. The app currently has two different env concerns: - the browser main thread needs the live runtime env to select API / Turnstile / JWT settings - the worker and game-logic path need a build-time env to select game config behavior Before this change, both responsibilities were hidden behind the same loader, which made the intent unclear and caused confusion around the worker fallback behavior. This PR separates those paths explicitly: - main-thread browser code now uses `getRuntimeClientServerConfig()` - game creation and worker/game-logic code now uses `getGameLogicConfig()` - the build-time game-logic env is represented explicitly as `GameLogicEnv` ## What Changed - Added `GameLogicEnv` to model the build-time game config choice explicitly. - Added `getRuntimeClientServerConfig()` for live runtime browser config from `window.BOOTSTRAP_CONFIG`. - Added `getBuildTimeGameLogicEnv()` and `getServerConfigForGameLogicEnv()` for build-time worker/game-logic config. - Renamed game config loading from `getConfig()` to `getGameLogicConfig()` to reflect what it actually does. - Updated browser call sites to use the runtime client config loader. - Updated worker/game creation paths to use the game-logic config loader. - Updated config loader tests to cover both paths. ## Behavior This keeps the current intended behavior, but makes it explicit: - Runtime client env: - comes from `window.BOOTSTRAP_CONFIG` - controls live browser integration settings such as API origin, Turnstile, and JWT audience/issuer - Build-time game-logic env: - comes from bundled `process.env.GAME_ENV` - maps: - `dev` -> dev game config - `staging` -> default/prod game config - `prod` -> default/prod game config That means preprod/staging deployments can continue using prod game logic while still using staging API/auth settings on the main thread. ## Why The previous setup worked, but the naming and loader boundaries were misleading: - the same function was used for both runtime browser config and worker/game config - the worker fallback looked like an implementation detail instead of an intentional architectural split This change makes that intent visible in code without changing the desired deployment behavior. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: DISCORD_USERNAME --- src/client/AccountModal.ts | 4 +- src/client/ClientGameRunner.ts | 4 +- src/client/GameModeSelector.ts | 4 +- src/client/HostLobbyModal.ts | 8 +- src/client/JoinLobbyModal.ts | 4 +- src/client/LobbySocket.ts | 4 +- src/client/Main.ts | 10 +- src/client/Matchmaking.ts | 6 +- src/client/components/CopyButton.ts | 4 +- src/core/GameRunner.ts | 4 +- src/core/configuration/ConfigLoader.ts | 91 ++++++++++++++----- tests/core/configuration/ConfigLoader.test.ts | 34 +++++-- 12 files changed, 122 insertions(+), 55 deletions(-) diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 0536c0a58..e723be4c3 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -6,7 +6,7 @@ import { UserMeResponse, } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { fetchPlayerById, getUserMe } from "./Api"; import { discordLogin, logOut, sendMagicLink } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; @@ -217,7 +217,7 @@ export class AccountModal extends BaseModal { private async viewGame(gameId: string): Promise { this.close(); - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const encodedGameId = encodeURIComponent(gameId); const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`; diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 7b483373c..68794eedd 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,7 @@ import { } from "../core/Schemas"; import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; -import { getConfig } from "../core/configuration/ConfigLoader"; +import { getGameLogicConfig } from "../core/configuration/ConfigLoader"; import { BuildableUnit, Structures, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { GameMapLoader } from "../core/game/GameMapLoader"; @@ -214,7 +214,7 @@ async function createClientGame( if (lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } - const config = await getConfig( + const config = await getGameLogicConfig( lobbyConfig.gameStartInfo.config, userSettings, lobbyConfig.gameRecord !== undefined, diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index e80047a6c..112578e6c 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -1,6 +1,6 @@ import { html, LitElement, nothing, type TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { getServerConfigFromClient } from "src/core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "src/core/configuration/ConfigLoader"; import { Duos, GameMapType, @@ -58,7 +58,7 @@ export class GameModeSelector extends LitElement { connectedCallback() { super.connectedCallback(); this.lobbySocket.start(); - getServerConfigFromClient().then((config) => { + getRuntimeClientServerConfig().then((config) => { this.defaultLobbyTime = config.gameCreationRate() / 1000; }); } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 401097317..6436cc04d 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,7 +1,7 @@ import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { EventBus } from "../core/EventBus"; import { Difficulty, @@ -113,7 +113,7 @@ export class HostLobbyModal extends BaseModal { return link; } } - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; } @@ -823,7 +823,7 @@ export class HostLobbyModal extends BaseModal { // If the modal closes as part of starting the game, do not leave the lobby this.leaveLobbyOnClose = false; - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const response = await fetch( `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`, { @@ -871,7 +871,7 @@ export class HostLobbyModal extends BaseModal { } async function createLobby(gameID: string): Promise { - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); // Send JWT token for creator identification - server extracts persistentID from it // persistentID should never be exposed to other clients const token = await getPlayToken(); diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 38b41ec69..c389ceb06 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -19,7 +19,7 @@ import { LobbyInfoEvent, PublicGameInfo, } from "../core/Schemas"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { Difficulty, GameMapSize, @@ -897,7 +897,7 @@ export class JoinLobbyModal extends BaseModal { } private async checkActiveLobby(lobbyId: string): Promise { - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; const response = await fetch(url, { diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index 4e4922508..e664329b7 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -1,4 +1,4 @@ -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { PublicGames, PublicGamesSchema } from "../core/Schemas"; interface LobbySocketOptions { @@ -35,7 +35,7 @@ export class PublicLobbySocket { this.stopped = false; this.wsConnectionAttempts = 0; // Get config to determine number of workers, then pick a random one - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); this.workerPath = getRandomWorkerPath(config.numWorkers()); this.connectWebSocket(); } diff --git a/src/client/Main.ts b/src/client/Main.ts index b4ed449af..d6759412a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -9,7 +9,7 @@ import { PublicGameInfo, } from "../core/Schemas"; import { GameEnv } from "../core/configuration/Config"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; @@ -749,7 +749,7 @@ class Client { if (lobby.source === "public") { this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo); } - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); // Only update URL immediately for private lobbies, not public ones if (lobby.source !== "public") { this.updateJoinUrlForShare(lobby.gameID, config); @@ -857,7 +857,7 @@ class Client { private updateJoinUrlForShare( lobbyId: string, - config: Awaited>, + config: Awaited>, ) { const lobbyIdHidden = !this.userSettings.lobbyIdVisibility(); const targetUrl = lobbyIdHidden @@ -930,7 +930,7 @@ class Client { private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); if ( config.env() === GameEnv.Dev || lobby.gameStartInfo?.config.gameType === GameType.Singleplayer @@ -1008,7 +1008,7 @@ async function getTurnstileToken(): Promise<{ throw new Error("Failed to load Turnstile script"); } - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const widgetId = window.turnstile.render("#turnstile-container", { sitekey: config.turnstileSiteKey(), size: "normal", diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 79ddc3da9..904ee266b 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -1,7 +1,7 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader"; import { getUserMe, hasLinkedAccount } from "./Api"; import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; @@ -87,7 +87,7 @@ export class MatchmakingModal extends BaseModal { } private async connect() { - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const instanceId = await MatchmakingModal.getInstanceId(); this.socket = new WebSocket( @@ -210,7 +210,7 @@ export class MatchmakingModal extends BaseModal { if (this.gameID === null) { return; } - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`; const response = await fetch(url, { diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts index 5a7b7fbec..dea21f618 100644 --- a/src/client/components/CopyButton.ts +++ b/src/client/components/CopyButton.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; +import { getRuntimeClientServerConfig } from "../../core/configuration/ConfigLoader"; import { UserSettings } from "../../core/game/UserSettings"; import { crazyGamesSDK } from "../CrazyGamesSDK"; import { copyToClipboard, translateText } from "../Utils"; @@ -66,7 +66,7 @@ export class CopyButton extends LitElement { } private async buildCopyUrl(): Promise { - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`; if (this.includeLobbyQuery) { url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`; diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ecc311112..7732a6eb3 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,5 +1,5 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; -import { getConfig } from "./configuration/ConfigLoader"; +import { getGameLogicConfig } from "./configuration/ConfigLoader"; import { Executor } from "./execution/ExecutionManager"; import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; @@ -35,7 +35,7 @@ export async function createGameRunner( mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, ): Promise { - const config = await getConfig(gameStart.config, null); + const config = await getGameLogicConfig(gameStart.config, null); const gameMap = await loadGameMap( gameStart.config.gameMap, gameStart.config.gameMapSize, diff --git a/src/core/configuration/ConfigLoader.ts b/src/core/configuration/ConfigLoader.ts index 4987da1c3..abb8d3fbc 100644 --- a/src/core/configuration/ConfigLoader.ts +++ b/src/core/configuration/ConfigLoader.ts @@ -1,13 +1,18 @@ import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; -import { Config, GameEnv, ServerConfig } from "./Config"; +import { Config, ServerConfig } from "./Config"; import { DefaultConfig } from "./DefaultConfig"; import { DevConfig, DevServerConfig } from "./DevConfig"; import { Env } from "./Env"; import { preprodConfig } from "./PreprodConfig"; import { prodConfig } from "./ProdConfig"; -export let cachedSC: ServerConfig | null = null; +export enum GameLogicEnv { + Dev = "dev", + Default = "default", +} + +export let cachedRuntimeClientServerConfig: ServerConfig | null = null; declare global { interface Window { @@ -17,35 +22,77 @@ declare global { } } -export async function getConfig( +export async function getGameLogicConfig( gameConfig: GameConfig, userSettings: UserSettings | null, isReplay: boolean = false, ): Promise { - const sc = await getServerConfigFromClient(); - switch (sc.env()) { - case GameEnv.Dev: - return new DevConfig(sc, gameConfig, userSettings, isReplay); - case GameEnv.Preprod: - case GameEnv.Prod: - console.log("using prod config"); - return new DefaultConfig(sc, gameConfig, userSettings, isReplay); + const gameLogicEnv = getBuildTimeGameLogicEnv(); + const serverConfig = getServerConfigForGameLogicEnv(gameLogicEnv); + + switch (gameLogicEnv) { + case GameLogicEnv.Dev: + return new DevConfig(serverConfig, gameConfig, userSettings, isReplay); + case GameLogicEnv.Default: + return new DefaultConfig( + serverConfig, + gameConfig, + userSettings, + isReplay, + ); default: - throw Error(`unsupported server configuration: ${Env.GAME_ENV}`); + throw Error(`unsupported game logic environment: ${gameLogicEnv}`); } } -export async function getServerConfigFromClient(): Promise { - if (cachedSC) { - return cachedSC; + +export function getBuildTimeGameLogicEnv(): GameLogicEnv { + const bundledGameEnv = process.env.GAME_ENV; + + switch (bundledGameEnv) { + case "dev": + return GameLogicEnv.Dev; + case "staging": + case "prod": + return GameLogicEnv.Default; + case undefined: + throw new Error("Missing bundled game logic env"); + default: + throw Error(`unsupported bundled game logic env: ${bundledGameEnv}`); + } +} + +export function getServerConfigForGameLogicEnv( + gameLogicEnv: GameLogicEnv, +): ServerConfig { + switch (gameLogicEnv) { + case GameLogicEnv.Dev: + return new DevServerConfig(); + case GameLogicEnv.Default: + console.log("using default game logic config"); + return prodConfig; + default: + throw Error(`unsupported game logic environment: ${gameLogicEnv}`); + } +} + +export async function getRuntimeClientServerConfig(): Promise { + if (cachedRuntimeClientServerConfig) { + return cachedRuntimeClientServerConfig; } - const bootstrapGameEnv = window.BOOTSTRAP_CONFIG?.gameEnv; - if (!bootstrapGameEnv) { - throw new Error("Missing bootstrap server config"); + if (typeof window === "undefined") { + throw new Error( + "Runtime client server config is only available on the browser main thread", + ); } - cachedSC = getServerConfig(bootstrapGameEnv); - return cachedSC; + const runtimeClientEnv = window.BOOTSTRAP_CONFIG?.gameEnv; + if (!runtimeClientEnv) { + throw new Error("Missing runtime client server config"); + } + + cachedRuntimeClientServerConfig = getServerConfig(runtimeClientEnv); + return cachedRuntimeClientServerConfig; } export function getServerConfigFromServer(): ServerConfig { const gameEnv = Env.GAME_ENV; @@ -67,6 +114,6 @@ export function getServerConfig(gameEnv: string) { } } -export function clearCachedServerConfig(): void { - cachedSC = null; +export function clearCachedRuntimeClientServerConfig(): void { + cachedRuntimeClientServerConfig = null; } diff --git a/tests/core/configuration/ConfigLoader.test.ts b/tests/core/configuration/ConfigLoader.test.ts index 65fd81757..e0e1a0629 100644 --- a/tests/core/configuration/ConfigLoader.test.ts +++ b/tests/core/configuration/ConfigLoader.test.ts @@ -1,24 +1,44 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { GameEnv } from "../../../src/core/configuration/Config"; import { - clearCachedServerConfig, - getServerConfigFromClient, + clearCachedRuntimeClientServerConfig, + GameLogicEnv, + getBuildTimeGameLogicEnv, + getGameLogicConfig, + getRuntimeClientServerConfig, + getServerConfigForGameLogicEnv, } from "../../../src/core/configuration/ConfigLoader"; describe("ConfigLoader", () => { + const originalGameEnv = process.env.GAME_ENV; + beforeEach(() => { vi.restoreAllMocks(); window.BOOTSTRAP_CONFIG = undefined; - clearCachedServerConfig(); + process.env.GAME_ENV = originalGameEnv; + clearCachedRuntimeClientServerConfig(); }); - test("uses bootstrap config without fetching /api/env", async () => { - window.BOOTSTRAP_CONFIG = { gameEnv: "prod" }; + test("uses runtime bootstrap config without fetching /api/env", async () => { + window.BOOTSTRAP_CONFIG = { gameEnv: "staging" }; const fetchSpy = vi.spyOn(globalThis, "fetch"); - const config = await getServerConfigFromClient(); + const config = await getRuntimeClientServerConfig(); - expect(config.env()).toBe(GameEnv.Prod); + expect(config.env()).toBe(GameEnv.Preprod); expect(fetchSpy).not.toHaveBeenCalled(); }); + + test("maps staging builds to the default game logic config", async () => { + process.env.GAME_ENV = "staging"; + + expect(getBuildTimeGameLogicEnv()).toBe(GameLogicEnv.Default); + expect(getServerConfigForGameLogicEnv(GameLogicEnv.Default).env()).toBe( + GameEnv.Prod, + ); + + const config = await getGameLogicConfig({} as any, null); + + expect(config.serverConfig().env()).toBe(GameEnv.Prod); + }); });