diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 0536c0a582..e723be4c36 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 7b483373c5..68794eedd6 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 e80047a6cc..112578e6c6 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 4010973176..6436cc04de 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 38b41ec694..c389ceb06b 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 4e4922508a..e664329b7d 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 b4ed449af7..d6759412ac 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 79ddc3da95..904ee266b5 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 5a7b7fbeca..dea21f618d 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 ecc311112f..7732a6eb3f 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 4987da1c30..abb8d3fbcf 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 game logic environment: ${gameLogicEnv}`); + } +} + +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 server configuration: ${Env.GAME_ENV}`); + throw Error(`unsupported game logic environment: ${gameLogicEnv}`); } } -export async function getServerConfigFromClient(): Promise { - if (cachedSC) { - return cachedSC; + +export async function getRuntimeClientServerConfig(): Promise { + if (cachedRuntimeClientServerConfig) { + return cachedRuntimeClientServerConfig; + } + + if (typeof window === "undefined") { + throw new Error( + "Runtime client server config is only available on the browser main thread", + ); } - const bootstrapGameEnv = window.BOOTSTRAP_CONFIG?.gameEnv; - if (!bootstrapGameEnv) { - throw new Error("Missing bootstrap server config"); + const runtimeClientEnv = window.BOOTSTRAP_CONFIG?.gameEnv; + if (!runtimeClientEnv) { + throw new Error("Missing runtime client server config"); } - cachedSC = getServerConfig(bootstrapGameEnv); - return cachedSC; + 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 65fd817577..e0e1a06297 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); + }); });