mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:20:44 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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\"",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,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,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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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,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,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
@@ -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__ ?? "";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+906
-166
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
// ];
|
||||
}
|
||||
})();
|
||||
@@ -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";
|
||||
}
|
||||
})();
|
||||
@@ -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,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
@@ -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 +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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
|
||||
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
|
||||
+5
-12
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
@@ -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();
|
||||
|
||||
@@ -128,12 +128,6 @@ export function authMockFactory() {
|
||||
};
|
||||
}
|
||||
|
||||
export function configLoaderMockFactory() {
|
||||
return {
|
||||
getRuntimeClientServerConfig: vi.fn(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
export function crazyGamesSdkMockFactory() {
|
||||
return {
|
||||
crazyGamesSDK: { isAvailable: false },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user