Split runtime and game logic env loading

This commit is contained in:
scamiv
2026-03-24 16:43:15 +01:00
parent 496f1008bb
commit d6cbf36d94
12 changed files with 122 additions and 55 deletions
+2 -2
View File
@@ -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<void> {
this.close();
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const encodedGameId = encodeURIComponent(gameId);
const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`;
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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;
});
}
+4 -4
View File
@@ -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<GameInfo> {
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();
+2 -2
View File
@@ -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<boolean> {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const response = await fetch(url, {
+2 -2
View File
@@ -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();
}
+5 -5
View File
@@ -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<ReturnType<typeof getServerConfigFromClient>>,
config: Awaited<ReturnType<typeof getRuntimeClientServerConfig>>,
) {
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
const targetUrl = lobbyIdHidden
@@ -930,7 +930,7 @@ class Client {
private async getTurnstileToken(
lobby: JoinLobbyEvent,
): Promise<string | null> {
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",
+3 -3
View File
@@ -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, {
+2 -2
View File
@@ -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<string> {
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)}`;
+2 -2
View File
@@ -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<GameRunner> {
const config = await getConfig(gameStart.config, null);
const config = await getGameLogicConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
+69 -22
View File
@@ -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<Config> {
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<ServerConfig> {
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<ServerConfig> {
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;
}
+27 -7
View File
@@ -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);
});
});