refactor: collapse per-env Configs into ClientEnv + ServerEnv (#3906)

## Description:

This is a refactor to simplify config handling.

Replaces the per-environment DevConfig/PreprodConfig/ProdConfig class
hierarchy with two static classes: ClientEnv (browser main thread, reads
from window.BOOTSTRAP_CONFIG) and ServerEnv (Node server, reads from
process.env). The four config classes are deleted, the abstract
DefaultServerConfig is gone, and DefaultConfig is renamed to Config.

The values that flow server → client (gameEnv, numWorkers,
turnstileSiteKey, jwtAudience, instanceId) used to be baked into the
hardcoded per-env classes. They're now real env vars on the server,
embedded into a single window.BOOTSTRAP_CONFIG object in index.html at
request time (alongside the existing gitCommit/assetManifest/cdnBase
globals, which moved into the same object), and read back by ClientEnv
on the client. The dev defaults previously hidden inside DevServerConfig
are now explicit in start:server-dev (NUM_WORKERS=2,
TURNSTILE_SITE_KEY=1x..., JWT_AUDIENCE=localhost, etc.) and in
vite.config.ts's html plugin inject.data. Production deploys plumb
NUM_WORKERS and TURNSTILE_SITE_KEY through deploy.yml (GitHub vars) into
the remote env file; JWT_AUDIENCE is derived from DOMAIN in deploy.sh.
The dynamic /api/instance endpoint is gone — INSTANCE_ID rides along in
BOOTSTRAP_CONFIG now.

ServerEnv is the only thing server code touches; ClientEnv is
browser-only. The two classes have intentional overlap (env, numWorkers,
jwtIssuer, gameCreationRate, workerIndex, etc.) since they derive
identical logic from different sources — there's a TODO in each to
consolidate via a shared helper later. The game-logic Config no longer
stores a ServerConfig/ClientEnv reference and its serverConfig() getter
is gone; the one caller (MultiTabModal) now reads ClientEnv.env()
directly. Worker init no longer carries server-config values since
nothing in the worker actually reads them.

## 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:

evan
This commit is contained in:
Evan
2026-05-11 19:24:01 -07:00
committed by GitHub
parent a597262af9
commit 275fd0dccc
74 changed files with 1627 additions and 1956 deletions
+2
View File
@@ -139,6 +139,8 @@ jobs:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
API_KEY: ${{ secrets.API_KEY }}
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
+7
View File
@@ -66,6 +66,11 @@ else
GHCR_IMAGE="${GHCR_USERNAME}/${GHCR_REPO}:${VERSION_TAG}"
fi
if [ -z "$DOMAIN" ]; then
echo "Error: DOMAIN not defined in .env file or environment"
exit 1
fi
if [ "$HOST" == "staging" ]; then
print_header "DEPLOYING TO STAGING HOST"
SERVER_HOST=$SERVER_HOST_STAGING
@@ -138,6 +143,8 @@ API_KEY=$API_KEY
DOMAIN=$DOMAIN
SUBDOMAIN=$SUBDOMAIN
CDN_BASE=$CDN_BASE
NUM_WORKERS=$NUM_WORKERS
TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY
OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT
OTEL_AUTH_HEADER=$OTEL_AUTH_HEADER
EOL
+7 -3
View File
@@ -60,11 +60,15 @@
<!-- Injected from Server env -->
<script>
window.GIT_COMMIT = <%- gitCommit %>;
window.ASSET_MANIFEST = <%- assetManifest %>;
window.CDN_BASE = <%- cdnBase %>;
window.BOOTSTRAP_CONFIG = {
gitCommit: <%- gitCommit %>,
assetManifest: <%- assetManifest %>,
cdnBase: <%- cdnBase %>,
gameEnv: <%- gameEnv %>,
numWorkers: <%- numWorkers %>,
turnstileSiteKey: <%- turnstileSiteKey %>,
jwtAudience: <%- jwtAudience %>,
instanceId: <%- instanceId %>,
};
document.documentElement.style.setProperty(
"--background-image-url",
+1 -1
View File
@@ -5,7 +5,7 @@
"build-prod": "concurrently --kill-others-on-fail \"tsc --noEmit\" \"vite build\"",
"start:client": "vite",
"start:server": "tsx src/server/Server.ts",
"start:server-dev": "cross-env GAME_ENV=dev tsx src/server/Server.ts",
"start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts",
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"",
+2 -3
View File
@@ -1,12 +1,12 @@
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
PlayerGame,
PlayerStatsTree,
UserMeResponse,
} from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
@@ -229,9 +229,8 @@ export class AccountModal extends BaseModal {
private async viewGame(gameId: string): Promise<void> {
this.close();
const config = await getRuntimeClientServerConfig();
const encodedGameId = encodeURIComponent(gameId);
const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`;
const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`;
history.pushState({ join: gameId }, "", newUrl);
window.dispatchEvent(
+120
View File
@@ -0,0 +1,120 @@
import { JWK } from "jose";
import { z } from "zod";
import { GameID } from "../core/Schemas";
import { simpleHash } from "../core/Util";
import {
GameEnv,
JwksSchema,
parseGameEnv,
} from "../core/configuration/Config";
export class ClientEnv {
private static values: ClientEnvValues | null = null;
private static publicKey: JWK | null = null;
/** Test-only. */
static reset(): void {
ClientEnv.values = null;
ClientEnv.publicKey = null;
}
private static get(): ClientEnvValues {
if (ClientEnv.values) return ClientEnv.values;
if (typeof window === "undefined") {
throw new Error("ClientEnv is only available on the browser main thread");
}
const bc = window.BOOTSTRAP_CONFIG;
if (
!bc ||
bc.gameEnv === undefined ||
bc.numWorkers === undefined ||
bc.turnstileSiteKey === undefined ||
bc.jwtAudience === undefined ||
bc.instanceId === undefined ||
bc.gitCommit === undefined
) {
throw new Error("Missing BOOTSTRAP_CONFIG");
}
ClientEnv.values = {
gameEnv: parseGameEnv(bc.gameEnv),
numWorkers: bc.numWorkers,
turnstileSiteKey: bc.turnstileSiteKey,
jwtAudience: bc.jwtAudience,
instanceId: bc.instanceId,
gitCommit: bc.gitCommit,
};
return ClientEnv.values;
}
// TODO: the following methods are duplicated on ServerEnv. The two classes
// read from different sources (window.BOOTSTRAP_CONFIG vs process.env) but
// the derived logic is identical. Consolidate into a shared helper that
// takes a source so we don't have to keep them in sync by hand.
static env(): GameEnv {
return ClientEnv.get().gameEnv;
}
static numWorkers(): number {
return ClientEnv.get().numWorkers;
}
static turnstileSiteKey(): string {
return ClientEnv.get().turnstileSiteKey;
}
static jwtAudience(): string {
return ClientEnv.get().jwtAudience;
}
static instanceId(): string {
return ClientEnv.get().instanceId;
}
static gitCommit(): string {
return ClientEnv.get().gitCommit;
}
static jwtIssuer(): string {
const audience = ClientEnv.jwtAudience();
return audience === "localhost"
? "http://localhost:8787"
: `https://api.${audience}`;
}
static async jwkPublicKey(): Promise<JWK> {
if (ClientEnv.publicKey) return ClientEnv.publicKey;
const jwksUrl = ClientEnv.jwtIssuer() + "/.well-known/jwks.json";
console.log(`Fetching JWKS from ${jwksUrl}`);
const response = await fetch(jwksUrl);
if (!response.ok) {
const body = await response.text();
throw new Error(`JWKS fetch failed: ${response.status} ${body}`);
}
const result = JwksSchema.safeParse(await response.json());
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Error parsing JWKS", error);
throw new Error("Invalid JWKS");
}
ClientEnv.publicKey = result.data.keys[0];
return ClientEnv.publicKey;
}
static turnIntervalMs(): number {
return 100;
}
static gameCreationRate(): number {
return ClientEnv.env() === GameEnv.Dev ? 5 * 1000 : 2 * 60 * 1000;
}
static workerIndex(gameID: GameID): number {
return simpleHash(gameID) % ClientEnv.numWorkers();
}
static workerPath(gameID: GameID): string {
return `w${ClientEnv.workerIndex(gameID)}`;
}
}
/**
* Values that flow from server → client via index.html. Set on the server from
* process.env, then re-hydrated on the client from window.BOOTSTRAP_CONFIG.
*/
export interface ClientEnvValues {
gameEnv: GameEnv;
numWorkers: number;
turnstileSiteKey: string;
jwtAudience: string;
instanceId: string;
gitCommit: string;
}
+2 -4
View File
@@ -1,3 +1,4 @@
import { Config } from "src/core/configuration/Config";
import { translateText } from "../client/Utils";
import { EventBus } from "../core/EventBus";
import {
@@ -11,8 +12,6 @@ import {
ServerMessage,
} from "../core/Schemas";
import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getGameLogicConfig } from "../core/configuration/ConfigLoader";
import {
BuildableUnit,
PlayerType,
@@ -63,7 +62,6 @@ import { GoToPlayerEvent } from "./graphics/TransformHandler";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
@@ -238,7 +236,7 @@ async function createClientGame(
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const config = await getGameLogicConfig(
const config = new Config(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord !== undefined,
+2 -4
View File
@@ -1,6 +1,6 @@
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getRuntimeClientServerConfig } from "src/core/configuration/ConfigLoader";
import { ClientEnv } from "src/client/ClientEnv";
import {
Duos,
GameMapType,
@@ -59,9 +59,7 @@ export class GameModeSelector extends LitElement {
connectedCallback() {
super.connectedCallback();
this.lobbySocket.start();
getRuntimeClientServerConfig().then((config) => {
this.defaultLobbyTime = config.gameCreationRate() / 1000;
});
this.defaultLobbyTime = ClientEnv.gameCreationRate() / 1000;
}
disconnectedCallback() {
+3 -5
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { translateText } from "../client/Utils";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
@@ -121,8 +121,7 @@ export class HostLobbyModal extends BaseModal {
return link;
}
}
const config = await getRuntimeClientServerConfig();
return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
return `${window.location.origin}/${ClientEnv.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
}
private async constructUrl(): Promise<string> {
@@ -1050,13 +1049,12 @@ export class HostLobbyModal extends BaseModal {
}
async function createLobby(gameID: string): Promise<GameInfo> {
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();
try {
const response = await fetch(
`/${config.workerPath(gameID)}/api/create_game/${gameID}`,
`/${ClientEnv.workerPath(gameID)}/api/create_game/${gameID}`,
{
method: "POST",
headers: {
+4 -7
View File
@@ -1,5 +1,6 @@
import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
calculateServerTimeOffset,
getMapName,
@@ -19,7 +20,6 @@ import {
LobbyInfoEvent,
PublicGameInfo,
} from "../core/Schemas";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import {
Difficulty,
GameMapSize,
@@ -967,8 +967,7 @@ export class JoinLobbyModal extends BaseModal {
}
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
const config = await getRuntimeClientServerConfig();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const url = `/${ClientEnv.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const response = await fetch(url, {
method: "GET",
@@ -1037,10 +1036,8 @@ export class JoinLobbyModal extends BaseModal {
return "version_mismatch";
}
if (
window.GIT_COMMIT !== "DEV" &&
parsed.data.gitCommit !== window.GIT_COMMIT
) {
const gitCommit = ClientEnv.gitCommit();
if (gitCommit !== "DEV" && parsed.data.gitCommit !== gitCommit) {
const safeLobbyId = this.sanitizeForLog(lobbyId);
console.warn(
`Git commit hash mismatch for game ${safeLobbyId}`,
+2 -3
View File
@@ -1,4 +1,4 @@
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { ClientEnv } from "src/client/ClientEnv";
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
interface LobbySocketOptions {
@@ -35,8 +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 getRuntimeClientServerConfig();
this.workerPath = getRandomWorkerPath(config.numWorkers());
this.workerPath = getRandomWorkerPath(ClientEnv.numWorkers());
this.connectWebSocket();
}
+3 -3
View File
@@ -1,3 +1,4 @@
import { ClientEnv } from "src/client/ClientEnv";
import { z } from "zod";
import { EventBus } from "../core/EventBus";
import {
@@ -81,8 +82,7 @@ export class LocalServer {
console.log("local server starting");
this.turnCheckInterval = setInterval(() => {
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
this.replaySpeedMultiplier;
ClientEnv.turnIntervalMs() * this.replaySpeedMultiplier;
const backlog = Math.max(0, this.turns.length - this.turnsExecuted);
const allowReplayBacklog =
this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest &&
@@ -297,7 +297,7 @@ export class LocalServer {
console.error("Error parsing game record", error);
return;
}
const workerPath = this.lobbyConfig.serverConfig.workerPath(
const workerPath = ClientEnv.workerPath(
this.lobbyConfig.gameStartInfo.gameID,
);
+7 -15
View File
@@ -1,4 +1,5 @@
import version from "resources/version.txt?raw";
import { ClientEnv } from "src/client/ClientEnv";
import { UserMeResponse } from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { EventBus } from "../core/EventBus";
@@ -10,7 +11,6 @@ import {
PublicGameInfo,
} from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
DARK_MODE_KEY,
@@ -169,7 +169,6 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
declare global {
interface Window {
GIT_COMMIT: string;
turnstile: any;
adsEnabled: boolean;
PageOS: {
@@ -770,16 +769,14 @@ class Client {
if (lobby.source === "public") {
this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo);
}
const config = await getRuntimeClientServerConfig();
// Only update URL immediately for private lobbies, not public ones
if (lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
this.updateJoinUrlForShare(lobby.gameID);
}
const auth = await userAuth();
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
const newLobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
@@ -881,7 +878,7 @@ class Client {
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
: `/${ClientEnv.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
@@ -889,14 +886,11 @@ class Client {
});
}
private updateJoinUrlForShare(
lobbyId: string,
config: Awaited<ReturnType<typeof getRuntimeClientServerConfig>>,
) {
private updateJoinUrlForShare(lobbyId: string) {
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
const targetUrl = lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
: `/${ClientEnv.workerPath(lobbyId)}/game/${lobbyId}`;
const currentUrl = window.location.pathname;
if (currentUrl !== targetUrl) {
@@ -970,9 +964,8 @@ class Client {
private async getTurnstileToken(
lobby: JoinLobbyEvent,
): Promise<string | null> {
const config = await getRuntimeClientServerConfig();
if (
config.env() === GameEnv.Dev ||
ClientEnv.env() === GameEnv.Dev ||
lobby.gameStartInfo?.config.gameType === GameType.Singleplayer
) {
return null;
@@ -1048,9 +1041,8 @@ async function getTurnstileToken(): Promise<{
throw new Error("Failed to load Turnstile script");
}
const config = await getRuntimeClientServerConfig();
const widgetId = window.turnstile.render("#turnstile-container", {
sitekey: config.turnstileSiteKey(),
sitekey: ClientEnv.turnstileSiteKey(),
size: "normal",
appearance: "interaction-only",
theme: "light",
+3 -34
View File
@@ -1,7 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { UserMeResponse } from "../core/ApiSchemas";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
@@ -12,7 +12,6 @@ import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends BaseModal {
private static instanceIdPromise: Promise<string> | null = null;
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
@state() private connected = false;
@@ -86,11 +85,8 @@ export class MatchmakingModal extends BaseModal {
}
private async connect() {
const config = await getRuntimeClientServerConfig();
const instanceId = await MatchmakingModal.getInstanceId();
this.socket = new WebSocket(
`${config.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(instanceId)}`,
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`,
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
@@ -131,32 +127,6 @@ export class MatchmakingModal extends BaseModal {
};
}
private static async getInstanceId(): Promise<string> {
MatchmakingModal.instanceIdPromise ??= fetch("/api/instance", {
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) {
throw new Error(
`Failed to load instance id: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as { instanceId?: string };
if (!data.instanceId) {
throw new Error("Missing instance id");
}
return data.instanceId;
})
.catch((error: unknown) => {
MatchmakingModal.instanceIdPromise = null;
throw error;
});
return MatchmakingModal.instanceIdPromise;
}
protected async onOpen(): Promise<void> {
const userMe = await getUserMe();
// Early return if modal was closed during async operation
@@ -209,8 +179,7 @@ export class MatchmakingModal extends BaseModal {
if (this.gameID === null) {
return;
}
const config = await getRuntimeClientServerConfig();
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const url = `/${ClientEnv.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const response = await fetch(url, {
method: "GET",
+2 -3
View File
@@ -1,3 +1,4 @@
import { ClientEnv } from "src/client/ClientEnv";
import { z } from "zod";
import { EventBus, GameEvent } from "../core/EventBus";
import {
@@ -330,9 +331,7 @@ export class Transport {
this.killExistingSocket();
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const workerPath = this.lobbyConfig.serverConfig.workerPath(
this.lobbyConfig.gameID,
);
const workerPath = ClientEnv.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
+2 -3
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getRuntimeClientServerConfig } from "../../core/configuration/ConfigLoader";
import { ClientEnv } from "src/client/ClientEnv";
import { UserSettings } from "../../core/game/UserSettings";
import { crazyGamesSDK } from "../CrazyGamesSDK";
import { copyToClipboard, translateText } from "../Utils";
@@ -63,8 +63,7 @@ export class CopyButton extends LitElement {
}
private async buildCopyUrl(): Promise<string> {
const config = await getRuntimeClientServerConfig();
let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`;
let url = `${window.location.origin}/${ClientEnv.workerPath(this.lobbyId)}/game/${this.lobbyId}`;
if (this.includeLobbyQuery) {
url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`;
}
+1 -1
View File
@@ -1,3 +1,4 @@
import { Theme } from "src/core/configuration/Theme";
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
import conquestSword from "../../../resources/sprites/conquestSword.png";
@@ -10,7 +11,6 @@ import sinkingShip from "../../../resources/sprites/sinkingShip.png";
import miniSmoke from "../../../resources/sprites/smoke.png";
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
import { Theme } from "../../core/configuration/Config";
import { PlayerView } from "../../core/game/GameView";
import { AnimatedSprite } from "./AnimatedSprite";
import { FxType } from "./fx/Fx";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { assetUrl } from "../../core/AssetUrls";
import { Theme } from "../../core/configuration/Config";
import { TrainType, UnitType } from "../../core/game/Game";
import { UnitView } from "../../core/game/GameView";
const atomBombSprite = assetUrl("sprites/atombomb.png");
+1 -1
View File
@@ -1,4 +1,4 @@
import { Theme } from "../../../core/configuration/Config";
import { Theme } from "src/core/configuration/Theme";
import { PlayerView } from "../../../core/game/GameView";
import { AnimatedSprite } from "../AnimatedSprite";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Theme } from "../../../core/configuration/Config";
import { Theme } from "src/core/configuration/Theme";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
+2 -1
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { GameEnv } from "../../../core/configuration/Config";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
@@ -31,7 +32,7 @@ export class MultiTabModal extends LitElement implements Layer {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
this.game.config().serverConfig().env() === GameEnv.Dev ||
ClientEnv.env() === GameEnv.Dev ||
this.game.config().isReplay()
) {
return;
+2 -1
View File
@@ -1,7 +1,8 @@
import { assetUrl } from "src/core/AssetUrls";
import { Theme } from "src/core/configuration/Theme";
import { EventBus } from "../../../core/EventBus";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { Config, Theme } from "../../../core/configuration/Config";
import { Config } from "../../../core/configuration/Config";
import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
@@ -1,6 +1,6 @@
import * as PIXI from "pixi.js";
import { Theme } from "src/core/configuration/Theme";
import { assetUrl } from "../../../core/AssetUrls";
import { Theme } from "../../../core/configuration/Config";
import {
Cell,
PlayerBuildableUnitType,
@@ -2,8 +2,8 @@ import { extend } from "colord";
import a11yPlugin from "colord/plugins/a11y";
import { OutlineFilter } from "pixi-filters";
import * as PIXI from "pixi.js";
import { Theme } from "src/core/configuration/Theme";
import { assetUrl } from "../../../core/AssetUrls";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
import {
+1 -1
View File
@@ -1,6 +1,6 @@
import { colord, Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { assetUrl } from "../../../core/AssetUrls";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
+2 -1
View File
@@ -1,4 +1,5 @@
import { Config, Theme } from "../../../core/configuration/Config";
import { Theme } from "src/core/configuration/Theme";
import { Config } from "../../../core/configuration/Config";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
+1 -1
View File
@@ -1,6 +1,6 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { Theme } from "src/core/configuration/Theme";
import { EventBus } from "../../../core/EventBus";
import {
Cell,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
+1 -1
View File
@@ -1,6 +1,6 @@
import { colord, Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { Cell, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, UnitView } from "../../../core/game/GameView";
+10 -9
View File
@@ -70,16 +70,14 @@ export function buildAssetUrl(
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
}
}
export function getAssetManifest(): AssetManifest {
if (typeof window !== "undefined" && window.ASSET_MANIFEST !== undefined) {
return window.ASSET_MANIFEST;
if (
typeof window !== "undefined" &&
window.BOOTSTRAP_CONFIG?.assetManifest !== undefined
) {
return window.BOOTSTRAP_CONFIG.assetManifest;
}
return globalThis.__ASSET_MANIFEST__ ?? {};
}
@@ -89,8 +87,11 @@ export function getAssetManifest(): AssetManifest {
// Without this fallback, asset fetches inside workers (e.g. map binaries)
// would silently bypass the CDN.
export function getCdnBase(): string {
if (typeof window !== "undefined" && window.CDN_BASE !== undefined) {
return window.CDN_BASE;
if (
typeof window !== "undefined" &&
window.BOOTSTRAP_CONFIG?.cdnBase !== undefined
) {
return window.BOOTSTRAP_CONFIG.cdnBase;
}
return globalThis.__CDN_BASE__ ?? "";
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getGameLogicConfig } from "./configuration/ConfigLoader";
import { Config } from "./configuration/Config";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
@@ -37,7 +37,7 @@ export async function createGameRunner(
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getGameLogicConfig(gameStart.config, null);
const config = new Config(gameStart.config, null, false);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
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 enum GameLogicEnv {
Dev = "dev",
Default = "default",
}
export let cachedRuntimeClientServerConfig: ServerConfig | null = null;
declare global {
interface Window {
BOOTSTRAP_CONFIG?: {
gameEnv?: string;
};
}
}
export async function getGameLogicConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
isReplay: boolean = false,
): Promise<Config> {
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 game logic environment: ${gameLogicEnv}`);
}
}
export async function getRuntimeClientServerConfig(): Promise<ServerConfig> {
if (cachedRuntimeClientServerConfig) {
return cachedRuntimeClientServerConfig;
}
if (typeof window === "undefined") {
throw new Error(
"Runtime client server config is only available on the browser main thread",
);
}
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;
return getServerConfig(gameEnv);
}
export function getServerConfig(gameEnv: string) {
switch (gameEnv) {
case "dev":
console.log("using dev server config");
return new DevServerConfig();
case "staging":
console.log("using preprod server config");
return preprodConfig;
case "prod":
console.log("using prod server config");
return prodConfig;
default:
throw Error(`unsupported server configuration: ${gameEnv}`);
}
}
export function clearCachedRuntimeClientServerConfig(): void {
cachedRuntimeClientServerConfig = null;
}
File diff suppressed because it is too large Load Diff
-55
View File
@@ -1,55 +0,0 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
turnstileSiteKey(): string {
return "1x00000000000000000000AA";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
apiKey(): string {
return "WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION";
}
env(): GameEnv {
return GameEnv.Dev;
}
gameCreationRate(): number {
return 5 * 1000;
}
numWorkers(): number {
return 2;
}
jwtAudience(): string {
return "localhost";
}
gitCommit(): string {
return "DEV";
}
domain(): string {
return "localhost";
}
subdomain(): string {
return "";
}
}
export class DevConfig extends DefaultConfig {
constructor(
sc: ServerConfig,
gc: GameConfig,
us: UserSettings | null,
isReplay: boolean,
) {
super(sc, gc, us, isReplay);
}
}
-89
View File
@@ -1,89 +0,0 @@
/**
* Safely access environment variables in both Node.js and Vite environments.
* - In Vite (Browser), it uses `import.meta.env`.
* - In Node.js (Server), it uses `process.env`.
*/
declare global {
interface ImportMetaEnv {
[key: string]: string | boolean | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}
function getEnv(key: string, viteKey?: string): string | undefined {
const vKey = viteKey ?? key;
// Try import.meta.env (Vite/Browser)
// We use a try-catch block or check existence to avoid ReferenceErrors
try {
if (typeof import.meta !== "undefined" && import.meta.env) {
const val = import.meta.env[vKey] ?? import.meta.env[key];
if (val !== undefined) {
return String(val);
}
}
} catch {
// Ignore errors accessing import.meta
}
// Try process.env (Node.js)
try {
if (typeof process !== "undefined" && process.env) {
const val = process.env[key];
if (val !== undefined) {
return val;
}
}
} catch {
// Ignore errors accessing process
}
return undefined;
}
export const Env = {
get GAME_ENV(): string {
// Check MODE for Vite, GAME_ENV for Node
try {
if (
typeof import.meta !== "undefined" &&
import.meta.env &&
import.meta.env.MODE
) {
return import.meta.env.MODE;
}
} catch {
// Ignore errors accessing import.meta
}
return getEnv("GAME_ENV") ?? "dev";
},
get STRIPE_PUBLISHABLE_KEY() {
return getEnv("STRIPE_PUBLISHABLE_KEY");
},
get DOMAIN() {
return getEnv("DOMAIN");
},
get SUBDOMAIN() {
return getEnv("SUBDOMAIN");
},
get OTEL_EXPORTER_OTLP_ENDPOINT() {
return getEnv("OTEL_EXPORTER_OTLP_ENDPOINT");
},
get OTEL_AUTH_HEADER() {
return getEnv("OTEL_AUTH_HEADER");
},
get GIT_COMMIT() {
return getEnv("GIT_COMMIT");
},
get API_KEY() {
return getEnv("API_KEY");
},
get ADMIN_TOKEN() {
return getEnv("ADMIN_TOKEN");
},
};
+1 -1
View File
@@ -5,7 +5,7 @@ import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { ColorAllocator } from "./ColorAllocator";
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
import { Theme } from "./Config";
import { Theme } from "./Theme";
export class PastelTheme implements Theme {
private rand = new PseudoRandom(123);
-26
View File
@@ -1,26 +0,0 @@
import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new (class extends DefaultServerConfig {
env(): GameEnv {
return GameEnv.Preprod;
}
numWorkers(): number {
return 2;
}
turnstileSiteKey(): string {
return "0x4AAAAAAB7QetxHwRCKw-aP";
}
jwtAudience(): string {
return "openfront.dev";
}
allowedFlares(): string[] | undefined {
return undefined;
// TODO: Uncomment this after testing.
// Allow access without login for now to test
// the new login flow.
// return [
// // "access:openfront.dev"
// ];
}
})();
-17
View File
@@ -1,17 +0,0 @@
import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
numWorkers(): number {
return 20;
}
env(): GameEnv {
return GameEnv.Prod;
}
jwtAudience(): string {
return "openfront.io";
}
turnstileSiteKey(): string {
return "0x4AAAAAACFLkaecN39lS8sk";
}
})();
+31
View File
@@ -0,0 +1,31 @@
import { Colord } from "colord";
import { Team } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
export interface Theme {
teamColor(team: Team): Colord;
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
font(): string;
textColor(playerInfo: PlayerView): string;
// unit color for alternate view
selfColor(): Colord;
allyColor(): Colord;
neutralColor(): Colord;
enemyColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
spawnHighlightEnemyColor(): Colord;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { assetUrl } from "../AssetUrls";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { createGameRunner, GameRunner } from "../GameRunner";
import {
AttackClusteredPositionsResultMessage,
InitializedMessage,
+8 -10
View File
@@ -1,5 +1,4 @@
import z from "zod";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
GameID,
@@ -10,8 +9,7 @@ import {
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { logger } from "./Logger";
const config = getServerConfigFromServer();
import { ServerEnv } from "./ServerEnv";
const log = logger.child({ component: "Archive" });
@@ -31,13 +29,13 @@ export async function archive(
});
return;
}
const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const url = `${ServerEnv.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(gameRecord, replacer),
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
if (!response.ok) {
@@ -62,12 +60,12 @@ export async function readGameRecord(
log.error(`invalid game ID: ${gameId}`);
return null;
}
const url = `${config.jwtIssuer()}/game/${gameId}`;
const url = `${ServerEnv.jwtIssuer()}/game/${gameId}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
const record = await response.json();
@@ -91,9 +89,9 @@ export function finalizeGameRecord(
): GameRecord {
return {
...clientRecord,
gitCommit: config.gitCommit(),
subdomain: config.subdomain(),
domain: config.domain(),
gitCommit: ServerEnv.gitCommit(),
subdomain: ServerEnv.subdomain(),
domain: ServerEnv.domain(),
};
}
+1 -6
View File
@@ -1,6 +1,5 @@
import { Logger } from "winston";
import WebSocket from "ws";
import { ServerConfig } from "../core/configuration/Config";
import {
Difficulty,
GameMapSize,
@@ -15,10 +14,7 @@ import { GamePhase, GameServer } from "./GameServer";
export class GameManager {
private games: Map<GameID, GameServer> = new Map();
constructor(
private config: ServerConfig,
private log: Logger,
) {
constructor(private log: Logger) {
setInterval(() => this.tick(), 1000);
}
@@ -69,7 +65,6 @@ export class GameManager {
id,
this.log,
Date.now(),
this.config,
{
donateGold: false,
donateTroops: false,
+2 -1
View File
@@ -4,6 +4,7 @@ import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
import { ServerEnv } from "./ServerEnv";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
@@ -141,7 +142,7 @@ export async function buildPreview(
publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const cdnBase = ServerEnv.cdnBase();
const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest, cdnBase), origin).toString();
const isFinished = !!publicInfo?.info?.end;
+10 -10
View File
@@ -4,7 +4,6 @@ import { parse } from "node-html-parser";
import path from "path";
import type { Logger } from "winston";
import { z } from "zod";
import type { ServerConfig } from "../core/configuration/Config";
import { GAME_ID_REGEX, GameInfo } from "../core/Schemas";
import { replacer } from "../core/Util";
import type { GameManager } from "./GameManager";
@@ -16,17 +15,19 @@ import {
} from "./GamePreviewBuilder";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml";
import { ServerEnv } from "./ServerEnv";
const requestOrigin = (req: Request, config: ServerConfig): string => {
const requestOrigin = (req: Request): string => {
const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? "";
const proto = protoHeader.split(",")[0]?.trim() || req.protocol || "https";
const host = req.get("host") ?? `${config.subdomain()}.${config.domain()}`;
const host =
req.get("host") ?? `${ServerEnv.subdomain()}.${ServerEnv.domain()}`;
// Force https only for the configured public domain (and its subdomains).
// This avoids hardcoding hostnames while ensuring we don't force https on
// localhost or arbitrary custom hosts.
const hostname = host.split(":")[0].toLowerCase();
const domain = config.domain().toLowerCase();
const domain = ServerEnv.domain().toLowerCase();
const forceHttps = hostname === domain || hostname.endsWith(`.${domain}`);
return `${forceHttps ? "https" : proto}://${host}`;
@@ -35,12 +36,11 @@ const requestOrigin = (req: Request, config: ServerConfig): string => {
export function registerGamePreviewRoute(opts: {
app: Express;
gm: GameManager;
config: ServerConfig;
workerId: number;
log: Logger;
baseDir: string;
}) {
const { app, gm, config, log, baseDir } = opts;
const { app, gm, log, baseDir } = opts;
const gameIDSchema = z.string().regex(GAME_ID_REGEX);
@@ -52,11 +52,11 @@ export function registerGamePreviewRoute(opts: {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const apiDomain = config.jwtIssuer();
const apiDomain = ServerEnv.jwtIssuer();
const encodedID = encodeURIComponent(gameID);
const response = await fetch(`${apiDomain}/game/${encodedID}`, {
headers: {
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
signal: controller.signal,
});
@@ -99,11 +99,11 @@ export function registerGamePreviewRoute(opts: {
return res.redirect(302, "/");
}
const origin = requestOrigin(req, config);
const origin = requestOrigin(req);
const meta = await buildPreview(
gameID,
origin,
config.workerPath(gameID),
ServerEnv.workerPath(gameID),
lobby,
publicInfo,
);
+4 -4
View File
@@ -3,7 +3,7 @@ import { Logger } from "winston";
import WebSocket from "ws";
import { z } from "zod";
import { isAdminRole } from "../core/ApiSchemas";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientID,
@@ -28,6 +28,7 @@ import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
import { ServerEnv } from "./ServerEnv";
export enum GamePhase {
Lobby = "LOBBY",
Active = "ACTIVE",
@@ -96,7 +97,6 @@ export class GameServer {
public readonly id: string,
readonly log_: Logger,
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
private creatorPersistentID?: string,
private startsAt?: number,
@@ -236,7 +236,7 @@ export class GameServer {
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
if (ServerEnv.env() === GameEnv.Prod) {
// Prevent multiple clients from using the same account in prod
const conflicting = this.activeClients.find(
(c) =>
@@ -751,7 +751,7 @@ export class GameServer {
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs(),
ServerEnv.turnIntervalMs(),
);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
+5 -7
View File
@@ -7,22 +7,20 @@ import {
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
const config = getServerConfigFromServer();
const resource = getOtelResource();
if (config.otelEnabled()) {
if (ServerEnv.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers: Record<string, string> = {};
headers["Authorization"] = "Basic " + config.otelAuthHeader();
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
url: `${ServerEnv.otelEndpoint()}/v1/logs`,
headers,
});
@@ -58,7 +56,7 @@ const logger = winston.createLogger({
),
defaultMeta: {
service: "openfront",
environment: process.env.GAME_ENV ?? "prod",
environment: ServerEnv.gameEnvName(),
},
transports: [
new winston.transports.Console(),
+1 -1
View File
@@ -1,4 +1,4 @@
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config";
import {
Difficulty,
Duos,
+5 -12
View File
@@ -6,15 +6,14 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { MasterLobbyService } from "./MasterLobbyService";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { renderAppShell } from "./RenderHtml";
import { ServerEnv } from "./ServerEnv";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
let lobbyService: MasterLobbyService;
@@ -79,16 +78,16 @@ export async function startMaster() {
}
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${config.numWorkers()} workers...`);
log.info(`Setting up ${ServerEnv.numWorkers()} workers...`);
lobbyService = new MasterLobbyService(config, playlist, log);
lobbyService = new MasterLobbyService(playlist, log);
// Generate admin token for worker authentication
const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
process.env.ADMIN_TOKEN = ADMIN_TOKEN;
const INSTANCE_ID =
config.env() === GameEnv.Dev
ServerEnv.env() === GameEnv.Dev
? "DEV_ID"
: crypto.randomBytes(4).toString("hex");
process.env.INSTANCE_ID = INSTANCE_ID;
@@ -96,7 +95,7 @@ export async function startMaster() {
log.info(`Instance ID: ${INSTANCE_ID}`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
for (let i = 0; i < ServerEnv.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
ADMIN_TOKEN,
@@ -151,12 +150,6 @@ app.get("/api/health", (_req, res) => {
}
});
app.get("/api/instance", (_req, res) => {
res.json({
instanceId: process.env.INSTANCE_ID ?? "undefined",
});
});
// SPA fallback route
app.get("/{*splat}", async function (_req, res) {
try {
+6 -8
View File
@@ -1,6 +1,5 @@
import { Worker } from "cluster";
import winston from "winston";
import { ServerConfig } from "../core/configuration/Config";
import { PublicGameInfo, PublicGameType } from "../core/Schemas";
import { generateID } from "../core/Util";
import {
@@ -12,9 +11,9 @@ import {
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { ServerEnv } from "./ServerEnv";
export interface MasterLobbyServiceOptions {
config: ServerConfig;
playlist: MapPlaylist;
log: typeof logger;
}
@@ -27,7 +26,6 @@ export class MasterLobbyService {
private started = false;
constructor(
private config: ServerConfig,
private playlist: MapPlaylist,
private log: winston.Logger,
) {}
@@ -63,16 +61,16 @@ export class MasterLobbyService {
isHealthy(): boolean {
// We consider the lobby service healthy if at least half of the workers are ready.
// This allows for some leeway if a worker crashes.
const minWorkers = Math.max(this.config.numWorkers() / 2, 1);
const minWorkers = Math.max(ServerEnv.numWorkers() / 2, 1);
return this.started && this.readyWorkers.size >= minWorkers;
}
private handleWorkerReady(workerId: number) {
this.readyWorkers.add(workerId);
this.log.info(
`Worker ${workerId} is ready. (${this.readyWorkers.size}/${this.config.numWorkers()} ready)`,
`Worker ${workerId} is ready. (${this.readyWorkers.size}/${ServerEnv.numWorkers()} ready)`,
);
if (this.readyWorkers.size === this.config.numWorkers() && !this.started) {
if (this.readyWorkers.size === ServerEnv.numWorkers() && !this.started) {
this.started = true;
this.log.info("All workers ready, starting game scheduling");
startPolling(async () => this.broadcastLobbies(), 500);
@@ -145,7 +143,7 @@ export class MasterLobbyService {
this.sendMessageToWorker({
type: "updateLobby",
gameID: nextLobby.gameID,
startsAt: Date.now() + this.config.gameCreationRate(),
startsAt: Date.now() + ServerEnv.gameCreationRate(),
});
}
@@ -163,7 +161,7 @@ export class MasterLobbyService {
}
private sendMessageToWorker(msg: MasterCreateGame | MasterUpdateGame): void {
const workerId = this.config.workerIndex(msg.gameID);
const workerId = ServerEnv.workerIndex(msg.gameID);
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
+9 -11
View File
@@ -3,9 +3,7 @@ import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
import { ServerEnv } from "./ServerEnv";
export function getOtelResource() {
return resourceFromAttributes({
@@ -16,14 +14,14 @@ export function getOtelResource() {
}
export function getPromLabels() {
const workerId = ServerEnv.workerId();
return {
"service.instance.id": process.env.HOSTNAME,
"openfront.environment": config.env(),
"openfront.host": process.env.HOST,
"openfront.domain": process.env.DOMAIN,
"openfront.subdomain": process.env.SUBDOMAIN,
"openfront.component": process.env.WORKER_ID
? "Worker " + process.env.WORKER_ID
: "Master",
"service.instance.id": ServerEnv.hostname(),
"openfront.environment": ServerEnv.env(),
"openfront.host": ServerEnv.host(),
"openfront.domain": ServerEnv.domain(),
"openfront.subdomain": ServerEnv.subdomain(),
"openfront.component":
workerId !== undefined ? "Worker " + workerId : "Master",
};
}
+8 -3
View File
@@ -4,6 +4,7 @@ import fs from "fs/promises";
import { buildAssetUrl } from "../core/AssetUrls";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
import { ServerEnv } from "./ServerEnv";
const APP_SHELL_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
@@ -13,9 +14,9 @@ const appShellContentCache = new Map<string, Promise<string>>();
export async function renderHtmlContent(htmlPath: string): Promise<string> {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const cdnBase = ServerEnv.cdnBase();
return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
gitCommit: JSON.stringify(ServerEnv.gitCommit()),
assetManifest: JSON.stringify(assetManifest),
cdnBase: JSON.stringify(cdnBase),
// Raw (unquoted) value for use as a URL prefix in the index.html template,
@@ -23,7 +24,11 @@ export async function renderHtmlContent(htmlPath: string): Promise<string> {
// build plugin inject-cdn-base-template rewrites Vite's emitted /assets/
// refs to use this placeholder.
cdnBaseRaw: cdnBase,
gameEnv: JSON.stringify(process.env.GAME_ENV ?? "dev"),
gameEnv: JSON.stringify(ServerEnv.gameEnvName()),
numWorkers: JSON.stringify(ServerEnv.numWorkers()),
turnstileSiteKey: JSON.stringify(ServerEnv.turnstileSiteKey()),
jwtAudience: JSON.stringify(ServerEnv.jwtAudience()),
instanceId: JSON.stringify(ServerEnv.instanceId()),
manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl(
+177
View File
@@ -0,0 +1,177 @@
import { JWK } from "jose";
import { z } from "zod";
import { GameEnv, parseGameEnv } from "../core/configuration/Config";
import { GameID } from "../core/Schemas";
import { simpleHash } from "../core/Util";
const JwksSchema = z.object({
keys: z
.object({
alg: z.literal("EdDSA"),
crv: z.literal("Ed25519"),
kty: z.literal("OKP"),
x: z.string(),
})
.array()
.min(1),
});
export class ServerEnv {
private static readonly gameEnv: GameEnv = parseGameEnv(process.env.GAME_ENV);
private static publicKey: JWK | null = null;
// Values that also flow to the client via index.html, but on the server
// are read from process.env directly. Server code never reaches into
// ClientEnv — that's reserved for the browser/worker hydrated path.
//
// TODO: the following methods are duplicated on ClientEnv. The two classes
// read from different sources (process.env vs window.BOOTSTRAP_CONFIG) but
// the derived logic is identical. Consolidate into a shared helper that
// takes a source so we don't have to keep them in sync by hand.
static env(): GameEnv {
return ServerEnv.gameEnv;
}
static gameEnvName(): string {
switch (ServerEnv.gameEnv) {
case GameEnv.Dev:
return "dev";
case GameEnv.Preprod:
return "staging";
case GameEnv.Prod:
return "prod";
}
}
static numWorkers(): number {
const raw = process.env.NUM_WORKERS;
if (!raw) {
throw new Error("NUM_WORKERS not set");
}
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`Invalid NUM_WORKERS: ${raw}`);
}
return n;
}
static turnstileSiteKey(): string {
const v = process.env.TURNSTILE_SITE_KEY;
if (!v) {
throw new Error("TURNSTILE_SITE_KEY not set");
}
return v;
}
static jwtAudience(): string {
const v = process.env.DOMAIN;
if (!v) {
throw new Error("DOMAIN not set");
}
return v;
}
static instanceId(): string {
return process.env.INSTANCE_ID ?? "";
}
static workerId(): number | undefined {
const raw = process.env.WORKER_ID;
if (raw === undefined) return undefined;
return parseInt(raw, 10);
}
static hostname(): string {
return process.env.HOSTNAME ?? "";
}
static host(): string {
return process.env.HOST ?? "";
}
static cdnBase(): string {
return process.env.CDN_BASE ?? "";
}
static jwtIssuer(): string {
const audience = ServerEnv.jwtAudience();
return audience === "localhost"
? "http://localhost:8787"
: `https://api.${audience}`;
}
static async jwkPublicKey(): Promise<JWK> {
if (ServerEnv.publicKey) return ServerEnv.publicKey;
const jwksUrl = ServerEnv.jwtIssuer() + "/.well-known/jwks.json";
console.log(`Fetching JWKS from ${jwksUrl}`);
const response = await fetch(jwksUrl);
if (!response.ok) {
const body = await response.text();
throw new Error(`JWKS fetch failed: ${response.status} ${body}`);
}
const result = JwksSchema.safeParse(await response.json());
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Error parsing JWKS", error);
throw new Error("Invalid JWKS");
}
ServerEnv.publicKey = result.data.keys[0];
return ServerEnv.publicKey;
}
static turnIntervalMs(): number {
return 100;
}
static gameCreationRate(): number {
return ServerEnv.gameEnv === GameEnv.Dev ? 5 * 1000 : 2 * 60 * 1000;
}
static workerIndex(gameID: GameID): number {
return simpleHash(gameID) % ServerEnv.numWorkers();
}
static workerPath(gameID: GameID): string {
return `w${ServerEnv.workerIndex(gameID)}`;
}
static workerPort(gameID: GameID): number {
return ServerEnv.workerPortByIndex(ServerEnv.workerIndex(gameID));
}
static workerPortByIndex(index: number): number {
return 3001 + index;
}
// Server-only env values
static domain(): string {
return process.env.DOMAIN ?? "";
}
static subdomain(): string {
return process.env.SUBDOMAIN ?? "";
}
static otelEnabled(): boolean {
return (
ServerEnv.gameEnv !== GameEnv.Dev &&
Boolean(ServerEnv.otelEndpoint()) &&
Boolean(ServerEnv.otelAuthHeader())
);
}
static otelEndpoint(): string {
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "";
}
static otelAuthHeader(): string {
return process.env.OTEL_AUTH_HEADER ?? "";
}
static gitCommit(): string {
const v = process.env.GIT_COMMIT;
if (!v) {
throw new Error("GIT_COMMIT not set");
}
return v;
}
static apiKey(): string {
return process.env.API_KEY ?? "";
}
static adminHeader(): string {
return "x-admin-key";
}
static adminToken(): string {
const token = process.env.ADMIN_TOKEN;
if (!token) {
throw new Error("ADMIN_TOKEN not set");
}
return token;
}
static allowedFlares(): string[] | undefined {
const raw = process.env.ALLOWED_FLARES;
if (!raw) return undefined;
return raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ServerConfig } from "../core/configuration/Config";
import { ServerEnv } from "./ServerEnv";
const TurnstileVerdictSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("approved") }),
@@ -15,7 +15,6 @@ export type TurnstileResponse =
export async function verifyTurnstileToken(
ip: string,
turnstileToken: string | null,
config: ServerConfig,
): Promise<TurnstileResponse> {
if (!turnstileToken) {
return { status: "rejected", reason: "No turnstile token provided" };
@@ -25,11 +24,11 @@ export async function verifyTurnstileToken(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${config.jwtIssuer()}/turnstile`, {
const response = await fetch(`${ServerEnv.jwtIssuer()}/turnstile`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({ ip, token: turnstileToken }),
signal: controller.signal,
+21 -25
View File
@@ -7,7 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
@@ -24,19 +24,17 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { GameEnv } from "../core/configuration/Config";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { startPolling } from "./PollingLoop";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { ServerEnv } from "./ServerEnv";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
import { verifyTurnstileToken } from "./Turnstile";
import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const workerId = ServerEnv.workerId() ?? 0;
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist();
@@ -55,7 +53,7 @@ export async function startWorker() {
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(config, log);
const gm = new GameManager(log);
// Initialize lobby service (handles WebSocket upgrade routing)
const lobbyService = new WorkerLobbyService(server, wss, gm, log);
@@ -67,14 +65,14 @@ export async function startWorker() {
1000 + Math.random() * 2000,
);
if (config.otelEnabled()) {
if (ServerEnv.otelEnabled()) {
initWorkerMetrics(gm);
}
const privilegeRefresher = new PrivilegeRefresher(
config.jwtIssuer() + "/cosmetics.json",
config.jwtIssuer() + "/profane_words_game_server",
config.apiKey(),
ServerEnv.jwtIssuer() + "/cosmetics.json",
ServerEnv.jwtIssuer() + "/profane_words_game_server",
ServerEnv.apiKey(),
log,
);
privilegeRefresher.start();
@@ -150,7 +148,7 @@ export async function startWorker() {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length);
const result = await verifyClientToken(token, config);
const result = await verifyClientToken(token);
if (result.type === "success") {
creatorPersistentID = result.persistentId;
} else {
@@ -158,7 +156,7 @@ export async function startWorker() {
return res.status(401).json({ error: "Invalid creator token" });
}
} else if (
!req.headers[config.adminHeader()] // Public games use admin token instead
!req.headers[ServerEnv.adminHeader()] // Public games use admin token instead
) {
return res
.status(400)
@@ -180,7 +178,7 @@ export async function startWorker() {
const gc = result.data;
if (
gc?.gameType === GameType.Public &&
req.headers[config.adminHeader()] !== config.adminToken()
req.headers[ServerEnv.adminHeader()] !== ServerEnv.adminToken()
) {
log.warn(
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
@@ -189,7 +187,7 @@ export async function startWorker() {
}
// Double-check this worker should host this game
const expectedWorkerId = config.workerIndex(id);
const expectedWorkerId = ServerEnv.workerIndex(id);
if (expectedWorkerId !== workerId) {
log.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
@@ -229,7 +227,6 @@ export async function startWorker() {
registerGamePreviewRoute({
app,
gm,
config,
workerId,
log,
baseDir: __dirname,
@@ -316,7 +313,7 @@ export async function startWorker() {
}
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
const expectedWorkerId = ServerEnv.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
log.warn(
`Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
@@ -325,7 +322,7 @@ export async function startWorker() {
}
// Verify token signature
const result = await verifyClientToken(clientMsg.token, config);
const result = await verifyClientToken(clientMsg.token);
if (result.type === "error") {
log.warn(`Invalid token: ${result.message}`, {
gameID: clientMsg.gameID,
@@ -381,7 +378,7 @@ export async function startWorker() {
let flares: string[] | undefined;
const allowedFlares = config.allowedFlares();
const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
@@ -390,7 +387,7 @@ export async function startWorker() {
}
} else {
// Verify token and get player permissions
const result = await getUserMe(clientMsg.token, config);
const result = await getUserMe(clientMsg.token);
if (result.type === "error") {
log.warn(`Unauthorized: ${result.message}`, {
persistentID: persistentId,
@@ -428,11 +425,10 @@ export async function startWorker() {
return;
}
if (config.env() !== GameEnv.Dev) {
if (ServerEnv.env() !== GameEnv.Dev) {
const turnstileResult = await verifyTurnstileToken(
ip,
clientMsg.turnstileToken,
config,
);
switch (turnstileResult.status) {
case "approved":
@@ -511,7 +507,7 @@ export async function startWorker() {
});
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
const PORT = ServerEnv.workerPortByIndex(workerId);
server.listen(PORT, () => {
log.info(`running on http://localhost:${PORT}`);
log.info(`Handling requests with path prefix /w${workerId}/`);
@@ -540,7 +536,7 @@ async function startMatchmakingPolling(gm: GameManager) {
startPolling(
async () => {
try {
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
const url = `${ServerEnv.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
@@ -553,7 +549,7 @@ async function startMatchmakingPolling(gm: GameManager) {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({
id: workerId,
@@ -605,7 +601,7 @@ function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === config.workerIndex(gameId)) {
if (workerId === ServerEnv.workerIndex(gameId)) {
return gameId;
}
attempts--;
+4 -7
View File
@@ -4,28 +4,25 @@ import {
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import * as dotenv from "dotenv";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameManager } from "./GameManager";
import { getOtelResource, getPromLabels } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
export function initWorkerMetrics(gameManager: GameManager): void {
// Get server configuration
const config = getServerConfigFromServer();
// Create resource with worker information
const resource = getOtelResource();
// Configure auth headers
const headers: Record<string, string> = {};
if (config.otelEnabled()) {
headers["Authorization"] = "Basic " + config.otelAuthHeader();
if (ServerEnv.otelEnabled()) {
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
}
// Create metrics exporter
const metricExporter = new OTLPMetricExporter({
url: `${config.otelEndpoint()}/v1/metrics`,
url: `${ServerEnv.otelEndpoint()}/v1/metrics`,
headers,
});
+8 -9
View File
@@ -6,8 +6,9 @@ import {
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameEnv } from "../core/configuration/Config";
import { PersistentIdSchema } from "../core/Schemas";
import { ServerEnv } from "./ServerEnv";
type TokenVerificationResult =
| {
@@ -19,10 +20,9 @@ type TokenVerificationResult =
export async function verifyClientToken(
token: string,
config: ServerConfig,
): Promise<TokenVerificationResult> {
if (PersistentIdSchema.safeParse(token).success) {
if (config.env() === GameEnv.Dev) {
if (ServerEnv.env() === GameEnv.Dev) {
return { type: "success", persistentId: token, claims: null };
} else {
return {
@@ -32,9 +32,9 @@ export async function verifyClientToken(
}
}
try {
const issuer = config.jwtIssuer();
const audience = config.jwtAudience();
const key = await config.jwkPublicKey();
const issuer = ServerEnv.jwtIssuer();
const audience = ServerEnv.jwtAudience();
const key = await ServerEnv.jwkPublicKey();
const { payload } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer,
@@ -64,17 +64,16 @@ export async function verifyClientToken(
export async function getUserMe(
token: string,
config: ServerConfig,
): Promise<
| { type: "success"; response: UserMeResponse }
| { type: "error"; message: string }
> {
try {
// Get the user object
const response = await fetch(config.jwtIssuer() + "/users/@me", {
const response = await fetch(ServerEnv.jwtIssuer() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
if (response.status !== 200) {
@@ -4,7 +4,6 @@ import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
flushAsync,
getElState,
@@ -22,9 +21,6 @@ vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
@@ -4,7 +4,6 @@ import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
getElState,
makeClan,
@@ -20,9 +19,6 @@ vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
-6
View File
@@ -128,12 +128,6 @@ export function authMockFactory() {
};
}
export function configLoaderMockFactory() {
return {
getRuntimeClientServerConfig: vi.fn(() => ({})),
};
}
export function crazyGamesSdkMockFactory() {
return {
crazyGamesSDK: { isAvailable: false },
+69 -26
View File
@@ -1,44 +1,87 @@
import { ClientEnv } from "src/client/ClientEnv";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { GameEnv } from "../../../src/core/configuration/Config";
import {
clearCachedRuntimeClientServerConfig,
GameLogicEnv,
getBuildTimeGameLogicEnv,
getGameLogicConfig,
getRuntimeClientServerConfig,
getServerConfigForGameLogicEnv,
} from "../../../src/core/configuration/ConfigLoader";
import { GameEnv, parseGameEnv } from "../../../src/core/configuration/Config";
describe("ConfigLoader", () => {
const originalGameEnv = process.env.GAME_ENV;
describe("parseGameEnv", () => {
test("maps 'dev' to GameEnv.Dev", () => {
expect(parseGameEnv("dev")).toBe(GameEnv.Dev);
});
test("maps 'staging' to GameEnv.Preprod", () => {
expect(parseGameEnv("staging")).toBe(GameEnv.Preprod);
});
test("maps 'prod' to GameEnv.Prod", () => {
expect(parseGameEnv("prod")).toBe(GameEnv.Prod);
});
test("throws on undefined", () => {
expect(() => parseGameEnv(undefined)).toThrow(/unsupported game env/);
});
test("throws on unknown value", () => {
expect(() => parseGameEnv("production")).toThrow(/unsupported game env/);
});
});
describe("ClientEnv", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.BOOTSTRAP_CONFIG = undefined;
process.env.GAME_ENV = originalGameEnv;
clearCachedRuntimeClientServerConfig();
ClientEnv.reset();
});
test("uses runtime bootstrap config without fetching /api/env", async () => {
window.BOOTSTRAP_CONFIG = { gameEnv: "staging" };
test("reads from window.BOOTSTRAP_CONFIG without fetching", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "staging",
numWorkers: 4,
turnstileSiteKey: "test-key",
jwtAudience: "openfront.dev",
instanceId: "TEST_ID",
gitCommit: "abc123",
};
const fetchSpy = vi.spyOn(globalThis, "fetch");
const config = await getRuntimeClientServerConfig();
expect(config.env()).toBe(GameEnv.Preprod);
expect(ClientEnv.env()).toBe(GameEnv.Preprod);
expect(ClientEnv.numWorkers()).toBe(4);
expect(ClientEnv.turnstileSiteKey()).toBe("test-key");
expect(ClientEnv.jwtAudience()).toBe("openfront.dev");
expect(ClientEnv.instanceId()).toBe("TEST_ID");
expect(fetchSpy).not.toHaveBeenCalled();
});
test("maps staging builds to the default game logic config", async () => {
process.env.GAME_ENV = "staging";
test("throws when BOOTSTRAP_CONFIG is undefined", () => {
expect(() => ClientEnv.env()).toThrow(/Missing BOOTSTRAP_CONFIG/);
});
expect(getBuildTimeGameLogicEnv()).toBe(GameLogicEnv.Default);
expect(getServerConfigForGameLogicEnv(GameLogicEnv.Default).env()).toBe(
GameEnv.Prod,
);
test("throws when a required field is missing", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "dev",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "localhost",
// instanceId missing
};
expect(() => ClientEnv.instanceId()).toThrow(/Missing BOOTSTRAP_CONFIG/);
});
const config = await getGameLogicConfig({} as any, null);
test("jwtIssuer maps 'localhost' to http://localhost:8787", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "dev",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "localhost",
instanceId: "x",
gitCommit: "DEV",
};
expect(ClientEnv.jwtIssuer()).toBe("http://localhost:8787");
});
expect(config.serverConfig().env()).toBe(GameEnv.Prod);
test("jwtIssuer derives api.<audience> for non-localhost", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "prod",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "openfront.io",
instanceId: "x",
gitCommit: "abc123",
};
expect(ClientEnv.jwtIssuer()).toBe("https://api.openfront.io");
});
});
+4 -11
View File
@@ -1,6 +1,6 @@
import { GameUpdateType } from "src/core/game/GameUpdates";
import { vi, type Mocked } from "vitest";
import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig";
import { Config } from "../../../src/core/configuration/Config";
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
import {
Difficulty,
@@ -16,7 +16,6 @@ import {
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestServerConfig } from "../../util/TestServerConfig";
vi.mock("../../../src/core/game/Game");
vi.mock("../../../src/core/execution/TrainExecution");
@@ -206,12 +205,11 @@ describe("TrainStation", () => {
});
});
describe("DefaultConfig.trainGold trade stop penalty", () => {
let config: DefaultConfig;
describe("Config.trainGold trade stop penalty", () => {
let config: Config;
let mockPlayer: Player;
beforeEach(() => {
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -228,12 +226,7 @@ describe("DefaultConfig.trainGold trade stop penalty", () => {
disableNavMesh: false,
randomSpawn: false,
};
config = new DefaultConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
config = new Config(gameConfig, new UserSettings(), false);
mockPlayer = { isLobbyCreator: () => false } as unknown as Player;
});
+1 -8
View File
@@ -13,7 +13,6 @@ import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
export const W = "W"; // Water
export const L = "L"; // Land
@@ -131,7 +130,6 @@ export function createGame(data: TestMapData): Game {
miniNumLand,
);
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -148,12 +146,7 @@ export function createGame(data: TestMapData): Game {
disableNavMesh: false,
randomSpawn: false,
};
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
const config = new TestConfig(gameConfig, new UserSettings(), false);
return createGameImpl([], [], gameMap, miniGameMap, config);
}
-2
View File
@@ -255,9 +255,7 @@ export async function setupFromPath(
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
// Configure the game
const config = new TestConfig(
new (await import("../util/TestServerConfig")).TestServerConfig(),
{
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
+3 -3
View File
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
vi.mock("../../src/server/ServerEnv", () => ({
ServerEnv: {
jwtIssuer: () => "https://archive.test.invalid",
apiKey: () => "test-key",
gitCommit: () => "DEV",
subdomain: () => "test",
domain: () => "test",
}),
},
}));
vi.mock("../../src/server/Logger", () => ({
+8 -41
View File
@@ -1,17 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
otelEnabled: () => false,
otelAuthHeader: () => "",
otelEndpoint: () => "",
env: () => 0, // GameEnv.Dev
}),
getServerConfig: () => ({
otelEnabled: () => false,
}),
}));
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
@@ -25,13 +13,11 @@ vi.mock("../../src/core/Schemas", async () => {
};
});
import { GameEnv } from "../../src/core/configuration/Config";
import { GameType } from "../../src/core/game/Game";
import { GameServer } from "../../src/server/GameServer";
describe("GameLifecycle", () => {
let mockLogger: any;
let mockConfig: any;
beforeEach(() => {
vi.useFakeTimers();
@@ -41,11 +27,6 @@ describe("GameLifecycle", () => {
warn: vi.fn(),
error: vi.fn(),
};
mockConfig = {
turnIntervalMs: () => 100,
gameCreationRate: () => 1000,
env: () => GameEnv.Dev,
};
});
afterEach(() => {
@@ -54,13 +35,9 @@ describe("GameLifecycle", () => {
});
it("should not start turn interval if game has ended", async () => {
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
);
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
// Call end() first - this should set _hasEnded
await game.end();
@@ -77,17 +54,11 @@ describe("GameLifecycle", () => {
it("should clear turn interval and set _hasEnded on end()", async () => {
// We need to initialize the game such that start() can succeed
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
gameMap: "plains",
gameMapSize: 100,
} as any,
);
} as any);
// Manually trigger prestart to fulfill some internal checks if necessary
game.prestart();
@@ -103,13 +74,9 @@ describe("GameLifecycle", () => {
});
it("should be resilient to multiple end() calls", async () => {
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
);
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
await game.end();
expect((game as any)._hasEnded).toBe(true);
@@ -1,17 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
otelEnabled: () => false,
otelAuthHeader: () => "",
otelEndpoint: () => "",
env: () => 0, // GameEnv.Dev
}),
getServerConfig: () => ({
otelEnabled: () => false,
}),
}));
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
@@ -69,7 +57,6 @@ function makeClient(
describe("GameServer - kick_player authorization", () => {
let mockLogger: any;
let mockConfig: any;
beforeEach(() => {
vi.useFakeTimers();
@@ -79,11 +66,6 @@ describe("GameServer - kick_player authorization", () => {
warn: vi.fn(),
error: vi.fn(),
};
mockConfig = {
turnIntervalMs: () => 100,
gameCreationRate: () => 1000,
env: () => 0,
};
});
afterEach(() => {
@@ -96,7 +78,6 @@ describe("GameServer - kick_player authorization", () => {
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
creatorPersistentID,
);
@@ -1,7 +1,7 @@
import EventEmitter from "events";
import { describe, expect, it, vi } from "vitest";
import { MasterLobbyService } from "../../src/server/MasterLobbyService";
import { TestServerConfig } from "../util/TestServerConfig";
import { ServerEnv } from "../../src/server/ServerEnv";
vi.mock("../../src/server/Logger", () => ({
logger: {
@@ -27,10 +27,9 @@ function sendWorkerReady(worker: EventEmitter, workerId: number) {
}
function createService(numWorkers: number): MasterLobbyService {
const config = new TestServerConfig();
vi.spyOn(config, "numWorkers").mockReturnValue(numWorkers);
vi.spyOn(ServerEnv, "numWorkers").mockReturnValue(numWorkers);
const log = { info: vi.fn(), error: vi.fn() } as any;
return new MasterLobbyService(config, {} as any, log);
return new MasterLobbyService({} as any, log);
}
function startAllWorkers(
+8 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAppShellContentCache,
getAppShellContent,
@@ -12,7 +12,14 @@ describe("RenderHtml", () => {
const originalGitCommit = process.env.GIT_COMMIT;
let tempDir: string | null = null;
beforeEach(() => {
vi.stubEnv("NUM_WORKERS", "1");
vi.stubEnv("TURNSTILE_SITE_KEY", "test-key");
vi.stubEnv("DOMAIN", "localhost");
});
afterEach(async () => {
vi.unstubAllEnvs();
process.env.GIT_COMMIT = originalGitCommit;
clearAppShellContentCache();
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { ServerEnv } from "../../src/server/ServerEnv";
describe("ServerEnv.numWorkers", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns parsed value when valid", () => {
vi.stubEnv("NUM_WORKERS", "4");
expect(ServerEnv.numWorkers()).toBe(4);
});
test("throws when unset", () => {
vi.stubEnv("NUM_WORKERS", "");
expect(() => ServerEnv.numWorkers()).toThrow(/NUM_WORKERS not set/);
});
test("throws on non-numeric", () => {
vi.stubEnv("NUM_WORKERS", "abc");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
test("throws on zero", () => {
vi.stubEnv("NUM_WORKERS", "0");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
test("throws on negative", () => {
vi.stubEnv("NUM_WORKERS", "-2");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
});
describe("ServerEnv.turnstileSiteKey", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns value when set", () => {
vi.stubEnv("TURNSTILE_SITE_KEY", "site-key");
expect(ServerEnv.turnstileSiteKey()).toBe("site-key");
});
test("throws when unset", () => {
vi.stubEnv("TURNSTILE_SITE_KEY", "");
expect(() => ServerEnv.turnstileSiteKey()).toThrow(
/TURNSTILE_SITE_KEY not set/,
);
});
});
describe("ServerEnv.jwtAudience", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns DOMAIN when set", () => {
vi.stubEnv("DOMAIN", "openfront.io");
expect(ServerEnv.jwtAudience()).toBe("openfront.io");
});
test("throws when DOMAIN unset", () => {
vi.stubEnv("DOMAIN", "");
expect(() => ServerEnv.jwtAudience()).toThrow(/DOMAIN not set/);
});
});
describe("ServerEnv.jwtIssuer", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("maps 'localhost' to http://localhost:8787", () => {
vi.stubEnv("DOMAIN", "localhost");
expect(ServerEnv.jwtIssuer()).toBe("http://localhost:8787");
});
test("derives api.<audience> for non-localhost", () => {
vi.stubEnv("DOMAIN", "openfront.io");
expect(ServerEnv.jwtIssuer()).toBe("https://api.openfront.io");
});
});
describe("ServerEnv.allowedFlares", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns undefined when unset", () => {
vi.stubEnv("ALLOWED_FLARES", "");
expect(ServerEnv.allowedFlares()).toBeUndefined();
});
test("parses a single value", () => {
vi.stubEnv("ALLOWED_FLARES", "admin");
expect(ServerEnv.allowedFlares()).toEqual(["admin"]);
});
test("parses CSV", () => {
vi.stubEnv("ALLOWED_FLARES", "admin,beta,internal");
expect(ServerEnv.allowedFlares()).toEqual(["admin", "beta", "internal"]);
});
test("trims whitespace and drops empties", () => {
vi.stubEnv("ALLOWED_FLARES", " admin , , beta ");
expect(ServerEnv.allowedFlares()).toEqual(["admin", "beta"]);
});
});
+1 -9
View File
@@ -18,7 +18,6 @@ import {
import { UserSettings } from "../../src/core/game/UserSettings";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "./TestConfig";
import { TestServerConfig } from "./TestServerConfig";
export async function setup(
mapName: string,
@@ -54,8 +53,6 @@ export async function setup(
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
// Configure the game
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -72,12 +69,7 @@ export async function setup(
randomSpawn: false,
..._gameConfig,
};
const config = new ConfigClass(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
const config = new ConfigClass(gameConfig, new UserSettings(), false);
const game = createGame(humans, [], gameMap, miniGameMap, config);
if (autoEndSpawnPhase) game.endSpawnPhase();
+3 -5
View File
@@ -1,5 +1,4 @@
import { NukeMagnitude } from "../../src/core/configuration/Config";
import { DefaultConfig } from "../../src/core/configuration/DefaultConfig";
import { Config, NukeMagnitude } from "../../src/core/configuration/Config";
import {
Game,
Player,
@@ -9,7 +8,7 @@ import {
} from "../../src/core/game/Game";
import { TileRef } from "../../src/core/game/GameMap";
export class TestConfig extends DefaultConfig {
export class TestConfig extends Config {
private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
@@ -100,7 +99,6 @@ export class TestConfig extends DefaultConfig {
}
}
export class UseRealAttackLogic extends TestConfig {
// Override to use DefaultConfig's real attackLogic
attackLogic(
gm: Game,
attackTroops: number,
@@ -112,7 +110,7 @@ export class UseRealAttackLogic extends TestConfig {
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
return DefaultConfig.prototype.attackLogic.call(
return Config.prototype.attackLogic.call(
this,
gm,
attackTroops,
-91
View File
@@ -1,91 +0,0 @@
import { JWK } from "jose";
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
import { PublicGameModifiers } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
turnstileSiteKey(): string {
throw new Error("Method not implemented.");
}
apiKey(): string {
throw new Error("Method not implemented.");
}
allowedFlares(): string[] | undefined {
throw new Error("Method not implemented.");
}
stripePublishableKey(): string {
throw new Error("Method not implemented.");
}
domain(): string {
throw new Error("Method not implemented.");
}
subdomain(): string {
throw new Error("Method not implemented.");
}
jwtAudience(): string {
throw new Error("Method not implemented.");
}
jwtIssuer(): string {
throw new Error("Method not implemented.");
}
jwkPublicKey(): Promise<JWK> {
throw new Error("Method not implemented.");
}
otelEnabled(): boolean {
throw new Error("Method not implemented.");
}
otelEndpoint(): string {
throw new Error("Method not implemented.");
}
otelAuthHeader(): string {
throw new Error("Method not implemented.");
}
turnIntervalMs(): number {
throw new Error("Method not implemented.");
}
gameCreationRate(): number {
throw new Error("Method not implemented.");
}
async lobbyMaxPlayers(): Promise<number> {
throw new Error("Method not implemented.");
}
numWorkers(): number {
throw new Error("Method not implemented.");
}
workerIndex(gameID: GameID): number {
throw new Error("Method not implemented.");
}
workerPath(gameID: GameID): string {
throw new Error("Method not implemented.");
}
workerPort(gameID: GameID): number {
throw new Error("Method not implemented.");
}
workerPortByIndex(workerID: number): number {
throw new Error("Method not implemented.");
}
env(): GameEnv {
throw new Error("Method not implemented.");
}
adminToken(): string {
throw new Error("Method not implemented.");
}
adminHeader(): string {
throw new Error("Method not implemented.");
}
gitCommit(): string {
throw new Error("Method not implemented.");
}
getRandomPublicGameModifiers(): PublicGameModifiers {
return {
isCompact: false,
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
isAlliancesDisabled: false,
};
}
async supportsCompactMapForTeams(): Promise<boolean> {
throw new Error("Method not implemented.");
}
}
+6
View File
@@ -65,6 +65,12 @@ export default defineConfig(({ mode }) => {
assetManifest: JSON.stringify(assetManifest),
cdnBase: JSON.stringify(cdnBase),
gameEnv: JSON.stringify(env.GAME_ENV ?? "dev"),
numWorkers: JSON.stringify(parseInt(env.NUM_WORKERS ?? "2", 10)),
turnstileSiteKey: JSON.stringify(
env.TURNSTILE_SITE_KEY ?? "1x00000000000000000000AA",
),
jwtAudience: JSON.stringify(env.DOMAIN ?? "localhost"),
instanceId: JSON.stringify(env.INSTANCE_ID ?? "DEV_ID"),
manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl(