refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)

Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.

Structure (packages/):
  core-public  public API/wire schemas + shared enums (clean leaf)
  shared       framework-agnostic helpers (clean leaf)
  engine       deterministic simulation (was src/core)
  client       rendering/UI (was src/client)
  server       coordination (was src/server)

Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.

- npm workspaces: root package.json workspaces + per-package package.json;
  tsconfig.base.json holds shared options + path aliases
  (core-public/* shared/* engine/* client/* server/*) resolved uniformly by
  tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
  ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
  (GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
  Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
  - renderNumber/renderTroops -> shared/format
  - NameBoxCalculator moved into engine
  - username validation returns translation key + params; client translates
  - applyStateUpdate moved to client (operates on the render-only PlayerState)
  - Config/UnitGrid/execution-Util/GameImpl now use structural read
    interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
    of importing client view classes; client imports view classes from a new
    client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
  (copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.

Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-10 16:51:03 +00:00
co-authored by Claude Opus 4.8
parent cb9cab9aca
commit a7f992e9b0
674 changed files with 1637 additions and 1370 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@openfront/server",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/Server.ts",
"dependencies": {
"@openfront/core-public": "*",
"@openfront/engine": "*"
}
}
+117
View File
@@ -0,0 +1,117 @@
import z from "zod";
import { GameType } from "engine/game/Game";
import {
GameID,
GameRecord,
GameRecordSchema,
ID,
PartialGameRecord,
} from "core-public/Schemas";
import { replacer } from "engine/Util";
import { logger } from "./Logger";
import { ServerEnv } from "./ServerEnv";
const log = logger.child({ component: "Archive" });
export async function archive(
gameRecord: GameRecord,
trustedCosmeticFlagUrls: Set<string> = new Set(),
) {
try {
if (gameRecord.info.config.gameType === GameType.Singleplayer) {
stripUntrustedFlagUrls(gameRecord, trustedCosmeticFlagUrls);
}
const parsed = GameRecordSchema.safeParse(gameRecord);
if (!parsed.success) {
log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, {
gameID: gameRecord.info.gameID,
});
return;
}
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": ServerEnv.apiKey(),
},
});
if (!response.ok) {
log.error(`error archiving game record: ${response.statusText}`, {
gameID: gameRecord.info.gameID,
});
return;
}
} catch (error) {
log.error(`error archiving game record: ${error}`, {
gameID: gameRecord.info.gameID,
});
return;
}
}
export async function readGameRecord(
gameId: GameID,
): Promise<GameRecord | null> {
try {
if (!ID.safeParse(gameId).success) {
log.error(`invalid game ID: ${gameId}`);
return null;
}
const url = `${ServerEnv.jwtIssuer()}/game/${gameId}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-api-key": ServerEnv.apiKey(),
},
});
const record = await response.json();
if (!response.ok) {
log.error(`error reading game record: ${response.statusText}`, {
gameID: gameId,
});
return null;
}
return GameRecordSchema.parse(record);
} catch (error) {
log.error(`error reading game record: ${error}`, {
gameID: gameId,
});
return null;
}
}
export function finalizeGameRecord(
clientRecord: PartialGameRecord,
): GameRecord {
return {
...clientRecord,
gitCommit: ServerEnv.gitCommit(),
subdomain: ServerEnv.subdomain(),
domain: ServerEnv.domain(),
};
}
function stripUntrustedFlagUrls(
gameRecord: GameRecord,
trustedCosmeticFlagUrls: Set<string>,
): void {
for (const player of gameRecord.info.players) {
const flag = player.cosmetics?.flag;
if (
flag === undefined ||
!/^https?:\/\//i.test(flag) ||
trustedCosmeticFlagUrls.has(flag)
) {
continue;
}
log.warn("dropping untrusted singleplayer replay flag", {
gameID: gameRecord.info.gameID,
clientID: player.clientID,
});
player.cosmetics!.flag = undefined;
}
}
+27
View File
@@ -0,0 +1,27 @@
import WebSocket from "ws";
import { TokenPayload } from "core-public/ApiSchemas";
import { Tick } from "engine/game/Game";
import { ClientID, PlayerCosmetics, Winner } from "core-public/Schemas";
export class Client {
public lastPing: number = Date.now();
public hashes: Map<Tick, number> = new Map();
public reportedWinner: Winner | null = null;
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly claims: TokenPayload | null,
public readonly role: string | null,
public readonly flares: string[] | undefined,
public readonly ip: string,
public username: string,
public clanTag: string | null,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
public readonly publicId: string | undefined,
public readonly friends: string[],
) {}
}
@@ -0,0 +1,64 @@
import { RateLimiter } from "limiter";
import { ClientID } from "core-public/Schemas";
const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_INTENT_SIZE = 2000;
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
export type RateLimitResult = "ok" | "limit" | "kick";
interface ClientBucket {
perSecond: RateLimiter;
perMinute: RateLimiter;
totalBytes: number;
}
export class ClientMsgRateLimiter {
private buckets = new Map<ClientID, ClientBucket>();
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
const bucket = this.getOrCreate(clientID);
bucket.totalBytes += bytes;
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
if (type === "intent") {
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
// Intents are also sent to all players, so it increase outgoing
// data.
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
if (bytes > MAX_INTENT_SIZE) {
return "kick";
}
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
) {
return "limit";
}
}
return "ok";
}
private getOrCreate(clientID: ClientID): ClientBucket {
const existing = this.buckets.get(clientID);
if (existing) {
return existing;
}
const bucket = {
perSecond: new RateLimiter({
tokensPerInterval: INTENTS_PER_SECOND,
interval: "second",
}),
perMinute: new RateLimiter({
tokensPerInterval: INTENTS_PER_MINUTE,
interval: "minute",
}),
totalBytes: 0,
};
this.buckets.set(clientID, bucket);
return bucket;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { Logger } from "winston";
import WebSocket from "ws";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "engine/game/Game";
import { GameConfig, GameID, PublicGameType } from "core-public/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
export class GameManager {
private games: Map<GameID, GameServer> = new Map();
constructor(private log: Logger) {
setInterval(() => this.tick(), 1000);
}
public game(id: GameID): GameServer | null {
return this.games.get(id) ?? null;
}
public publicLobbies(): GameServer[] {
return Array.from(this.games.values()).filter(
(g) => g.phase() === GamePhase.Lobby && g.isPublic(),
);
}
joinClient(
client: Client,
gameID: GameID,
): "joined" | "kicked" | "rejected" | "not_found" {
const game = this.games.get(gameID);
if (!game) return "not_found";
return game.joinClient(client);
}
rejoinClient(
ws: WebSocket,
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
}
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
creatorPersistentID?: string,
startsAt?: number,
publicGameType?: PublicGameType,
): GameServer | null {
if (this.games.has(id)) {
this.log.warn("cannot create game, id already exists", { gameID: id });
return null;
}
const game = new GameServer(
id,
this.log,
Date.now(),
{
donateGold: false,
donateTroops: false,
gameMap: GameMapType.World,
gameType: GameType.Private,
gameMapSize: GameMapSize.Normal,
difficulty: Difficulty.Easy,
nations: "default",
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
},
creatorPersistentID,
startsAt,
publicGameType,
);
this.games.set(id, game);
return game;
}
activeGames(): number {
return this.games.size;
}
activeClients(): number {
let totalClients = 0;
this.games.forEach((game: GameServer) => {
totalClients += game.activeClients.length;
});
return totalClients;
}
desyncCount(): number {
return [...this.games.values()].reduce(
(acc, game) => acc + game.numDesyncedClients(),
0,
);
}
tick() {
const active = new Map<GameID, GameServer>();
for (const [id, game] of this.games) {
const phase = game.phase();
if (phase === GamePhase.Active) {
if (!game.hasStarted()) {
// Prestart tells clients to start loading the game.
game.prestart();
// Start game on delay to allow time for clients to connect.
setTimeout(() => {
try {
game.start();
} catch (error) {
this.log.error(`error starting game ${id}: ${error}`);
}
}, 2000);
}
}
if (phase === GamePhase.Finished) {
try {
game.end();
} catch (error) {
this.log.error(`error ending game ${id}: ${error}`);
}
} else {
active.set(id, game);
}
}
this.games = active;
}
}
+283
View File
@@ -0,0 +1,283 @@
import { z } from "zod";
import { buildAssetUrl } from "engine/AssetUrls";
import { ClanTagSchema, GameInfo, UsernameSchema } from "core-public/Schemas";
import { formatPlayerDisplayName } from "engine/Util";
import { GameMode } from "engine/game/Game";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
import { ServerEnv } from "./ServerEnv";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
username: UsernameSchema.optional(),
clanTag: ClanTagSchema,
stats: z.unknown().optional(),
});
export type PlayerInfo = z.infer<typeof PlayerInfoSchema>;
export const ExternalGameInfoSchema = z.object({
info: z
.object({
config: z
.object({
gameMap: z.string().optional(),
gameMode: z.string().optional(),
gameType: z.string().optional(),
maxPlayers: z.number().optional(),
playerTeams: z.union([z.number(), z.string()]).optional(),
})
.optional(),
players: z.array(PlayerInfoSchema).optional(),
winner: z.array(z.string()).optional(),
duration: z.number().optional(),
start: z.number().optional(),
end: z.number().optional(),
lobbyCreatedAt: z.number().optional(),
})
.optional(),
});
export type ExternalGameInfo = z.infer<typeof ExternalGameInfoSchema>;
export type PreviewMeta = {
title: string;
description: string;
image: string;
joinUrl: string;
};
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "Unknown";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const hours = Math.floor(mins / 60);
const minutes = mins % 60;
if (hours) return `${hours}h ${minutes}m ${secs}s`;
if (minutes) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
function normalizeTimestamp(timestamp: number): number {
return timestamp < 1e12 ? timestamp * 1000 : timestamp;
}
function formatDateTimeParts(timestamp: number): {
date: string;
time: string;
} {
const date = new Date(normalizeTimestamp(timestamp));
const dateLabel = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
const timeLabel = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "UTC",
}).format(date);
return { date: dateLabel, time: `${timeLabel} UTC` };
}
type WinnerInfo = { names: string; count: number };
function parseWinner(
winnerArray: string[] | undefined,
players: PlayerInfo[] | undefined,
): WinnerInfo | undefined {
if (!winnerArray || winnerArray.length < 2) return undefined;
const idToName = new Map(
(players ?? []).map((p) => [
p.clientID,
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
]),
);
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
const playerIds = winnerArray.slice(2);
const names = playerIds.map((id) => idToName.get(id) ?? id).filter(Boolean);
return names.length > 0
? { names: names.join(", "), count: names.length }
: undefined;
}
if (winnerArray[0] === "player" && winnerArray.length >= 2) {
const clientId = winnerArray[1];
const name = idToName.get(clientId) ?? clientId;
return { names: name, count: 1 };
}
// Unknown winner format - don't display confusing output
return undefined;
}
function countActivePlayers(players: PlayerInfo[] | undefined): number {
return (players ?? []).filter((p) => {
if (!p || p.stats === null || p.stats === undefined) return false;
// Count only when `stats` has at least one property.
if (typeof p.stats === "object") {
return Object.keys(p.stats as Record<string, unknown>).length > 0;
}
return false;
}).length;
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function buildPreview(
gameID: string,
origin: string,
workerPath: string,
lobby: GameInfo | null,
publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = ServerEnv.cdnBase();
const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest, cdnBase), origin).toString();
const isFinished = !!publicInfo?.info?.end;
const isPrivate = lobby?.gameConfig?.gameType === "Private";
// route directly to the correct worker.
const joinUrl = `${origin}/${workerPath}/game/${gameID}`;
const config = publicInfo?.info?.config ?? {};
const players = publicInfo?.info?.players ?? [];
let activePlayers: number;
if (isFinished) {
activePlayers = countActivePlayers(players);
} else {
activePlayers =
countActivePlayers(players) || (lobby?.clients?.length ?? 0);
}
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
const playerTeams = lobby?.gameConfig?.playerTeams ?? config.playerTeams;
const numericTeamCount =
typeof playerTeams === "number" && playerTeams > 0
? playerTeams
: undefined;
// For finished games, show "x teams of y". For lobbies, just show "x teams"
const teamBreakdownLabel = numericTeamCount
? isFinished
? `${numericTeamCount} teams of ${Math.max(
1,
Math.ceil(activePlayers / numericTeamCount),
)}`
: `${numericTeamCount} teams`
: undefined;
// Format team mode display
if (mode === "Team" && playerTeams) {
if (typeof playerTeams === "string") {
mode = playerTeams; // e.g., "Quads"
} else if (typeof playerTeams === "number") {
mode = teamBreakdownLabel ?? `${playerTeams} Teams`;
}
}
const winner = parseWinner(publicInfo?.info?.winner, players);
const duration = publicInfo?.info?.duration;
// Normalize map name to match filesystem (lowercase, no spaces or special chars)
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
const mapThumbnail = normalizedMap
? buildAbsoluteAssetUrl(
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
)
: null;
const image =
mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png");
const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
const gameTypeLabel = gameType ? ` (${gameType})` : "";
const title = isFinished
? `${mode ?? "Game"} on ${map ?? "Unknown Map"}${gameTypeLabel}`
: mode && map
? `${mode} on ${map}${gameTypeLabel}`
: "OpenFront Game";
let description: string;
if (isFinished) {
const parts: string[] = [];
if (winner) {
parts.push(`${winner.count > 1 ? "Winners" : "Winner"}: ${winner.names}`);
parts.push(""); // Extra line break after winner
}
const matchTimestamp =
publicInfo?.info?.start ??
publicInfo?.info?.end ??
publicInfo?.info?.lobbyCreatedAt;
const detailParts: string[] = [];
const playerCountLabel = `${activePlayers} ${activePlayers === 1 ? "player" : "players"}`;
detailParts.push(playerCountLabel);
if (duration !== undefined) detailParts.push(`${formatDuration(duration)}`);
if (matchTimestamp !== undefined) {
const dateTime = formatDateTimeParts(matchTimestamp);
detailParts.push(`${dateTime.date}`);
detailParts.push(`${dateTime.time}`);
}
parts.push(detailParts.join(" • "));
description = parts.join("\n");
} else if (lobby) {
const gc = lobby.gameConfig;
if (isPrivate) {
// Private lobby: show detailed game settings
const sections: string[] = [];
// Show host
const hostClient = lobby.clients?.[0];
if (hostClient?.username) {
sections.push(
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
);
}
const gameOptions: string[] = [];
if (gc?.gameMapSize && gc.gameMapSize !== "Normal") {
gameOptions.push(`${gc.gameMapSize} Map`);
}
if (gc?.infiniteGold) gameOptions.push("Infinite Gold");
if (gc?.infiniteTroops) gameOptions.push("Infinite Troops");
if (gc?.instantBuild) gameOptions.push("Instant Build");
if (gc?.randomSpawn) gameOptions.push("Random Spawn");
if (gc?.nations === "disabled") gameOptions.push("Nations Disabled");
if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled");
if (gameOptions.length > 0) {
sections.push(`Game Options: ${gameOptions.join(" | ")}`);
}
if (Array.isArray(gc?.disabledUnits) && gc.disabledUnits.length > 0) {
sections.push(
`Disabled Units: ${gc.disabledUnits.map(String).join(" | ")}`,
);
}
description = sections.join("\n\n");
} else {
// Public lobby: basic info
description = "";
}
} else {
description = `Game ${gameID}`;
}
return { title, description, image, joinUrl };
}
+166
View File
@@ -0,0 +1,166 @@
import type { Express, Request } from "express";
import fsPromises from "fs/promises";
import { parse } from "node-html-parser";
import path from "path";
import type { Logger } from "winston";
import { z } from "zod";
import { GAME_ID_REGEX, GameInfo } from "core-public/Schemas";
import { replacer } from "engine/Util";
import type { GameManager } from "./GameManager";
import {
buildPreview,
escapeHtml,
ExternalGameInfo,
ExternalGameInfoSchema,
} from "./GamePreviewBuilder";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml";
import { ServerEnv } from "./ServerEnv";
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") ?? `${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 = ServerEnv.domain().toLowerCase();
const forceHttps = hostname === domain || hostname.endsWith(`.${domain}`);
return `${forceHttps ? "https" : proto}://${host}`;
};
export function registerGamePreviewRoute(opts: {
app: Express;
gm: GameManager;
workerId: number;
log: Logger;
baseDir: string;
}) {
const { app, gm, log, baseDir } = opts;
const gameIDSchema = z.string().regex(GAME_ID_REGEX);
const fetchPublicGameInfo = async (
gameID: string,
): Promise<ExternalGameInfo | null> => {
if (!gameIDSchema.safeParse(gameID).success) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const apiDomain = ServerEnv.jwtIssuer();
const encodedID = encodeURIComponent(gameID);
const response = await fetch(`${apiDomain}/game/${encodedID}`, {
headers: {
"x-api-key": ServerEnv.apiKey(),
},
signal: controller.signal,
});
if (!response.ok) return null;
const data = await response.json();
const parsed = ExternalGameInfoSchema.safeParse(data);
if (!parsed.success) {
log.warn("Invalid ExternalGameInfo from API", {
gameID,
issues: parsed.error.issues,
});
return null;
}
return parsed.data;
} catch (error) {
log.warn("failed to fetch public game info", { gameID, error });
return null;
} finally {
clearTimeout(timeout);
}
};
app.get("/game/:id", async (req, res) => {
const gameID = req.params.id;
// Validate gameID format
if (!GAME_ID_REGEX.test(gameID)) {
return res.status(400).json({ error: "Invalid game ID format" });
}
const game = gm.game(gameID);
const lobby: GameInfo | null = game ? game.gameInfo() : null;
try {
const publicInfo = await fetchPublicGameInfo(gameID); // Fetch from central API (DB/Auth)
// If we have neither live lobby info nor archived public info, we can't show anything
if (!lobby && !publicInfo) {
return res.redirect(302, "/");
}
const origin = requestOrigin(req);
const meta = await buildPreview(
gameID,
origin,
ServerEnv.workerPath(gameID),
lobby,
publicInfo,
);
// Always serve HTML with meta tags for /game/:id route
const staticHtml = path.join(baseDir, "../../static/index.html");
const rootHtml = path.join(baseDir, "../../index.html");
let filePath: string | null = null;
try {
await fsPromises.access(staticHtml);
filePath = staticHtml;
} catch {
try {
await fsPromises.access(rootHtml);
filePath = rootHtml;
} catch {
// Neither file exists
}
}
if (filePath) {
const html = await getAppShellContent(filePath);
const root = parse(html);
const head = root.querySelector("head");
if (head) {
head
.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"]')
.forEach((el) => el.remove());
const tagsToInject = [
`<meta property="og:title" content="${escapeHtml(meta.title)}" />`,
`<meta property="og:description" content="${escapeHtml(meta.description || meta.title)}" />`,
`<meta property="og:url" content="${escapeHtml(meta.joinUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(meta.image)}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(meta.title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(meta.description || meta.title)}" />`,
`<meta name="twitter:image" content="${escapeHtml(meta.image)}" />`,
];
tagsToInject.forEach((tag) =>
head.insertAdjacentHTML("beforeend", tag),
);
}
setHtmlNoCacheHeaders(res);
return res.status(200).send(root.toString());
}
// Fallback to JSON if HTML file not found
setNoStoreHeaders(res);
res.setHeader("Content-Type", "application/json");
return res.send(JSON.stringify(lobby ?? publicInfo, replacer));
} catch (error) {
log.error("failed to render join preview", { error });
return res.status(500).send("Unable to render lobby preview");
}
});
}
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
import {
GameConfigSchema,
PublicGameInfoSchema,
PublicGamesSchema,
PublicGameTypeSchema,
} from "core-public/Schemas";
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
export type WorkerReady = z.infer<typeof WorkerReadySchema>;
export type MasterLobbiesBroadcast = z.infer<
typeof MasterLobbiesBroadcastSchema
>;
export type MasterUpdateGame = z.infer<typeof MasterUpdateGameSchema>;
export type MasterCreateGame = z.infer<typeof MasterCreateGameSchema>;
export type WorkerMessage = z.infer<typeof WorkerMessageSchema>;
export type MasterMessage = z.infer<typeof MasterMessageSchema>;
// --- Worker Messages ---
// Worker tells the master about its lobbies.
const WorkerLobbyListSchema = z.object({
type: z.literal("lobbyList"),
lobbies: z.array(PublicGameInfoSchema),
});
const WorkerReadySchema = z.object({
type: z.literal("workerReady"),
workerId: z.number(),
});
export const WorkerMessageSchema = z.discriminatedUnion("type", [
WorkerLobbyListSchema,
WorkerReadySchema,
]);
// --- Master Messages ---
const MasterUpdateGameSchema = z.object({
type: z.literal("updateLobby"),
gameID: z.string(),
startsAt: z.number(),
});
// Broadcasts all public game info to all workers.
// Workers need information on all public lobbies so
// it can send it to the client.
const MasterLobbiesBroadcastSchema = z.object({
type: z.literal("lobbiesBroadcast"),
publicGames: PublicGamesSchema,
});
// Master sends a message to worker to schedule a new public game/lobby.
const MasterCreateGameSchema = z.object({
type: z.literal("createGame"),
gameID: z.string(),
gameConfig: GameConfigSchema,
publicGameType: PublicGameTypeSchema,
});
export const MasterMessageSchema = z.discriminatedUnion("type", [
MasterLobbiesBroadcastSchema,
MasterCreateGameSchema,
MasterUpdateGameSchema,
]);
+68
View File
@@ -0,0 +1,68 @@
import * as logsAPI from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
LoggerProvider,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { getOtelResource } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
const resource = getOtelResource();
if (ServerEnv.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers: Record<string, string> = {};
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${ServerEnv.otelEndpoint()}/v1/logs`,
headers,
});
// Initialize the OpenTelemetry Logger Provider
const loggerProvider = new LoggerProvider({
resource,
processors: [new SimpleLogRecordProcessor(logExporter)],
});
// Set as the global logger provider
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
} else {
console.log(
"No OTLP endpoint and credentials provided, remote logging disabled",
);
}
// Custom format to add severity tag based on log level
const addSeverityFormat = winston.format((info) => {
return {
...info,
severity: info.level,
};
});
// Define your base/parent logger
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
addSeverityFormat(),
winston.format.json(),
),
defaultMeta: {
service: "openfront",
environment: ServerEnv.gameEnvName(),
},
transports: [
new winston.transports.Console(),
new OpenTelemetryTransportV3(),
],
});
// Export both the main logger and the child logger factory
export { logger };
+59
View File
@@ -0,0 +1,59 @@
import fs from "fs/promises";
import path from "path";
import { normalizeAssetPath } from "engine/AssetUrls";
import { GameMapType } from "engine/game/Game";
import { fileURLToPath } from "url";
import { logger } from "./Logger";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
const log = logger.child({ component: "MapLandTiles" });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const staticDir = path.join(__dirname, "../../static");
const resourcesDir = path.join(__dirname, "../../resources");
const landTilesCache = new Map<GameMapType, number>();
function mapDirName(map: GameMapType): string {
const key = (
Object.keys(GameMapType) as Array<keyof typeof GameMapType>
).find((k) => GameMapType[k] === map);
if (!key) throw new Error(`Unknown map: ${map}`);
return key.toLowerCase();
}
async function readManifestFile(map: GameMapType): Promise<string> {
const relativePath = `maps/${mapDirName(map)}/manifest.json`;
// Production: resolve via the asset manifest to the hashed file under static/_assets/.
const assetManifest = await getRuntimeAssetManifest();
const hashedUrl = assetManifest[relativePath];
if (hashedUrl) {
return fs.readFile(
path.join(staticDir, normalizeAssetPath(hashedUrl)),
"utf8",
);
}
// Dev: read directly from resources/. The Dockerfile deletes resources/maps in
// production, so this branch only runs locally.
return fs.readFile(path.join(resourcesDir, relativePath), "utf8");
}
// Gets the number of land tiles for a map.
export async function getMapLandTiles(map: GameMapType): Promise<number> {
const cached = landTilesCache.get(map);
if (cached !== undefined) return cached;
try {
const raw = await readManifestFile(map);
const tiles = (JSON.parse(raw) as { map: { num_land_tiles: number } }).map
.num_land_tiles;
landTilesCache.set(map, tiles);
return tiles;
} catch (error) {
log.error(`Failed to load manifest for ${map}: ${error}`, { map });
return 1_000_000; // Default fallback
}
}
+821
View File
@@ -0,0 +1,821 @@
import { SAM_CONSTRUCTION_TICKS } from "engine/configuration/Config";
import {
Difficulty,
Duos,
GameMapName,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
PublicGameModifiers,
Quads,
RankedType,
Trios,
UnitType,
mapCategories,
} from "engine/game/Game";
import { PseudoRandom } from "engine/PseudoRandom";
import { GameConfig, PublicGameType, TeamCountConfig } from "core-public/Schemas";
import { logger } from "./Logger";
import { getMapLandTiles } from "./MapLandTiles";
const log = logger.child({});
const ARCADE_MAPS = new Set(mapCategories.arcade);
const SPECIAL_ONLY_MAPS = new Set<GameMapType>([GameMapType.ArchipelagoSea]);
// Hard cap on player count for performance. Applied after compact-map reduction.
const MAX_PLAYER_COUNT = 125;
// How many times each map should appear in the playlist.
// Note: The Partial should eventually be removed for better type safety.
const FREQUENCY: Partial<Record<GameMapName, number>> = {
Achiran: 5,
Aegean: 6,
Africa: 7,
Alps: 4,
AmazonRiver: 3,
Antarctica: 1,
ArchipelagoSea: 3,
Arctic: 6,
Asia: 6,
Australia: 4,
Baikal: 5,
BajaCalifornia: 4,
Balkans: 6,
BeringSea: 5,
BeringStrait: 2,
BetweenTwoSeas: 5,
BlackSea: 6,
BosphorusStraits: 3,
Britannia: 5,
Caribbean: 5,
Caucasus: 5,
ChoppingBlock: 5,
Conakry: 3,
DanishStraits: 5,
DeglaciatedAntarctica: 4,
Didier: 1,
DidierFrance: 1,
Dyslexdria: 8,
EastAsia: 5,
Europe: 7,
FalklandIslands: 4,
FaroeIslands: 4,
FourIslands: 4,
GatewayToTheAtlantic: 5,
GreatLakes: 6,
GulfOfStLawrence: 4,
Halkidiki: 4,
Hawaii: 4,
HongKong: 6,
Iceland: 4,
IndianSubcontinent: 8,
Italia: 6,
Japan: 6,
Korea: 5,
Labyrinth: 6,
Lemnos: 3,
Lisbon: 4,
LosAngeles: 8,
Luna: 6,
Manicouagan: 4,
MareNostrum: 6,
Mars: 3,
Mena: 6,
MiddleEast: 8,
MilkyWay: 8,
MississippiRiver: 3,
Montreal: 6,
NewYorkCity: 3,
NileDelta: 4,
NorthAmerica: 5,
NorthwestPassage: 5,
Pangaea: 5,
Passage: 4,
Onion: 2,
Pluto: 6,
SanFrancisco: 3,
Sierpinski: 10,
SouthAmerica: 5,
SoutheastAsia: 5,
StraitOfGibraltar: 5,
StraitOfHormuz: 4,
StraitOfMalacca: 4,
Surrounded: 4,
Svalmel: 8,
TaiwanStrait: 5,
TheBox: 3,
TradersDream: 4,
TwoLakes: 6,
Venice: 6,
World: 20,
WorldInverted: 8,
YellowSea: 5,
Yenisei: 6,
};
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ config: 2, weight: 10 },
{ config: 3, weight: 10 },
{ config: 4, weight: 10 },
{ config: 5, weight: 10 },
{ config: 6, weight: 10 },
{ config: 7, weight: 10 },
{ config: Duos, weight: 5 },
{ config: Trios, weight: 7.5 },
{ config: Quads, weight: 7.5 },
{ config: HumansVsNations, weight: 20 },
];
// Maps with a preferred team count in team / special games.
// For these maps: team-playlist frequency is doubled, and the preferred
// team count overrides the random TEAM_WEIGHTS roll with SPECIAL_TEAM_FORCE_CHANCE.
const SPECIAL_TEAM_FORCE_CHANCE = 0.75;
const SPECIAL_TEAM_FREQ_MULTIPLIER = 2;
const SPECIAL_TEAM_MAPS: ReadonlyMap<GameMapType, TeamCountConfig> = new Map([
[GameMapType.Baikal, 2],
[GameMapType.FourIslands, 4],
[GameMapType.Luna, 2],
[GameMapType.StraitOfGibraltar, 2],
[GameMapType.StraitOfHormuz, 2],
[GameMapType.Aegean, 2],
[GameMapType.BeringSea, 2],
[GameMapType.BeringStrait, 2],
[GameMapType.BosphorusStraits, 2],
[GameMapType.Conakry, 2],
[GameMapType.Pluto, 2],
[GameMapType.FalklandIslands, 2],
[GameMapType.TradersDream, 2],
[GameMapType.Surrounded, 4],
[GameMapType.GulfOfStLawrence, 3],
[GameMapType.ChoppingBlock, 4],
]);
type ModifierKey =
| "isRandomSpawn"
| "isCompact"
| "isCrowded"
| "isHardNations"
| "startingGold1M"
| "startingGold5M"
| "startingGold25M"
| "goldMultiplier"
| "isAlliancesDisabled"
| "isPortsDisabled"
| "isNukesDisabled"
| "isSAMsDisabled"
| "isPeaceTime"
| "isWaterNukes";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
// Weights are roughly informed by the community "favorite modifier" poll.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(4).fill("isCompact"),
...Array<ModifierKey>(2).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(2).fill("startingGold1M"),
...Array<ModifierKey>(4).fill("startingGold5M"),
...Array<ModifierKey>(3).fill("startingGold25M"),
...Array<ModifierKey>(6).fill("goldMultiplier"),
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
...Array<ModifierKey>(1).fill("isPortsDisabled"),
...Array<ModifierKey>(1).fill("isNukesDisabled"),
...Array<ModifierKey>(1).fill("isSAMsDisabled"),
...Array<ModifierKey>(1).fill("isPeaceTime"),
...Array<ModifierKey>(4).fill("isWaterNukes"),
];
// Maps where water nukes have a higher chance on top of the normal pool
// Water nukes are especially fun here
const WATER_NUKES_BOOSTED_MAPS: ReadonlySet<GameMapType> = new Set([
GameMapType.FourIslands,
GameMapType.Baikal,
GameMapType.Luna,
GameMapType.ArchipelagoSea,
GameMapType.ChoppingBlock,
]);
// Maps that are entirely land.
// - Water nukes forced on 75% of the time (overrides WATER_NUKES_BOOSTED_MAPS)
// - The "ports disabled" modifier is only allowed when water nukes is on
const FULL_LAND_MAPS: ReadonlySet<GameMapType> = new Set([
GameMapType.TheBox,
GameMapType.Alps,
]);
// Modifiers that cannot be active at the same time.
const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["startingGold5M", "startingGold25M"],
["startingGold5M", "startingGold1M"],
["startingGold25M", "startingGold1M"],
["isHardNations", "startingGold25M"],
["isNukesDisabled", "isSAMsDisabled"],
["isNukesDisabled", "isWaterNukes"],
];
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
special: [],
team: [],
};
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
if (type === "special") {
return this.getSpecialConfig();
}
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap(type);
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
let isCompact: boolean | undefined =
this.playlists[type].length % 3 === 0 || undefined;
if (
isCompact &&
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map, playerTeams!))
) {
isCompact = undefined;
}
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: {
isCompact,
},
difficulty:
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
nations:
mode === GameMode.Team && playerTeams !== HumansVsNations
? "disabled"
: "default",
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(playerTeams),
disabledUnits: [],
disableClanTags: mode === GameMode.FFA ? true : undefined,
} satisfies GameConfig;
}
private async getSpecialConfig(): Promise<GameConfig> {
const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap("special");
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const excludedModifiers: ModifierKey[] = [];
// Check if compact map would leave every team with at least 2 players
const supportsCompact =
mode !== GameMode.Team ||
(await this.supportsCompactMapForTeams(map, playerTeams!));
if (!supportsCompact) {
excludedModifiers.push("isCompact");
}
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
playerTeams === Quads
) {
excludedModifiers.push("isRandomSpawn");
}
// No gold multi on FourIslands team games - Too high chance of 3h long stalemates
if (map === GameMapType.FourIslands && mode === GameMode.Team) {
excludedModifiers.push("goldMultiplier");
}
// Hard nations modifier only applies when nations are present (not HvN, which is always hard)
if (mode === GameMode.Team) {
excludedModifiers.push("isHardNations");
}
// On special team maps nukes-disabled makes cross-water attacks
// nearly impossible (extreme warship spam).
if (mode === GameMode.Team && SPECIAL_TEAM_MAPS.has(map)) {
excludedModifiers.push("isNukesDisabled");
}
if (playerTeams === HumansVsNations) {
excludedModifiers.push("startingGold25M"); // Nations are disabled if that modifier is active (Because of PVP immunity)
excludedModifiers.push("isPeaceTime"); // Nations don't have PVP immunity
}
// Boost water nukes chance
// When boosted, water nukes is forced on and takes one modifier slot.
const waterNukesBoostChance = FULL_LAND_MAPS.has(map)
? 0.75
: WATER_NUKES_BOOSTED_MAPS.has(map)
? 0.5
: 0;
const boostWaterNukes = Math.random() < waterNukesBoostChance;
if (boostWaterNukes) {
excludedModifiers.push("isWaterNukes", "isNukesDisabled");
}
// On full-land maps, ports-disabled is only allowed alongside water nukes
if (FULL_LAND_MAPS.has(map) && !boostWaterNukes) {
excludedModifiers.push("isPortsDisabled");
}
const poolResult = this.getRandomSpecialGameModifiers(
excludedModifiers,
undefined,
boostWaterNukes ? 1 : 0,
);
let {
isCrowded,
startingGold,
isCompact,
isRandomSpawn,
goldMultiplier,
isAlliancesDisabled,
isHardNations,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
} = poolResult;
if (boostWaterNukes) {
isWaterNukes = true;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, !!isCompact);
if (crowdedMaxPlayers !== undefined) {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
} else {
// Map doesn't support crowded. Drop it and pick one replacement only
// if it was the sole modifier, so the lobby always has at least one.
isCrowded = undefined;
if (
!isRandomSpawn &&
!isCompact &&
!isHardNations &&
startingGold === undefined &&
goldMultiplier === undefined &&
!isAlliancesDisabled &&
!isPortsDisabled &&
!isNukesDisabled &&
!isSAMsDisabled &&
!isPeaceTime &&
!isWaterNukes
) {
excludedModifiers.push("isCrowded");
const fallback = this.getRandomSpecialGameModifiers(
excludedModifiers,
1,
);
({
isRandomSpawn,
isCompact,
startingGold,
goldMultiplier,
isAlliancesDisabled,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
} = fallback);
({ isHardNations } = fallback);
}
}
}
const maxPlayers = Math.max(
2,
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
);
const nations: GameConfig["nations"] =
(mode === GameMode.Team && playerTeams !== HumansVsNations) ||
// Nations don't have PVP immunity, so 25M starting gold wouldn't work well with them
(startingGold !== undefined && startingGold >= 25_000_000)
? "disabled"
: "default";
// Build disabledUnits from modifiers
const disabledUnits: UnitType[] = [];
if (isPortsDisabled) {
disabledUnits.push(UnitType.Port);
}
if (isNukesDisabled) {
disabledUnits.push(
UnitType.MissileSilo,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.SAMLauncher,
);
}
if (isSAMsDisabled) {
disabledUnits.push(UnitType.SAMLauncher);
}
// 4min peace = 240s = 2400 ticks
const peaceTimeDuration = isPeaceTime ? 240 * 10 : undefined;
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers,
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
goldMultiplier,
isAlliancesDisabled,
isPortsDisabled,
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
isWaterNukes,
},
startingGold,
goldMultiplier,
disableAlliances: isAlliancesDisabled ? true : undefined,
difficulty:
isHardNations || playerTeams === HumansVsNations
? Difficulty.Hard
: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn ? true : false,
nations,
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration:
peaceTimeDuration ??
this.getSpawnImmunityDuration(playerTeams, startingGold),
disabledUnits,
waterNukes: isWaterNukes ? true : undefined,
disableClanTags: mode === GameMode.FFA ? true : undefined,
} satisfies GameConfig;
}
public get1v1Config(): GameConfig {
const maps = [
GameMapType.Australia, // 40%
GameMapType.Australia,
GameMapType.Iceland, // 20%
GameMapType.Asia, // 20%
GameMapType.EuropeClassic, // 20%
];
const isCompact = Math.random() < 0.5;
return {
donateGold: false,
donateTroops: false,
gameMap: maps[Math.floor(Math.random() * maps.length)],
maxPlayers: 2,
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
difficulty: Difficulty.Medium, // Doesn't matter, nations are disabled
rankedType: RankedType.OneVOne,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: isCompact ? 10 : 15,
instantBuild: false,
randomSpawn: false,
nations: "disabled",
gameMode: GameMode.FFA,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: 30 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
private getNextMap(type: PublicGameType): GameMapType {
const playlist = this.playlists[type];
if (playlist.length === 0) {
playlist.push(...this.generateNewPlaylist(type));
}
return playlist.shift()!;
}
private generateNewPlaylist(type: PublicGameType): GameMapType[] {
const maps = this.buildMapsList(type);
const rand = new PseudoRandom(Date.now());
const playlist: GameMapType[] = [];
const numAttempts = 10000;
for (let attempt = 0; attempt < numAttempts; attempt++) {
playlist.length = 0;
// Re-shuffle every attempt so retries can explore different orderings.
const source = rand.shuffleArray([...maps]);
let success = true;
while (source.length > 0) {
if (!this.addNextMapNonConsecutive(playlist, source)) {
success = false;
break;
}
}
if (success) {
log.info(`Generated map playlist in ${attempt} attempts`);
return playlist;
}
}
log.warn(
`Failed to generate non-consecutive playlist after ${numAttempts} attempts, falling back to shuffle`,
);
return rand.shuffleArray([...maps]);
}
private addNextMapNonConsecutive(
playlist: GameMapType[],
source: GameMapType[],
): boolean {
const nonConsecutiveNum = 5;
const lastMaps = playlist.slice(-nonConsecutiveNum);
for (let i = 0; i < source.length; i++) {
const map = source[i];
if (!lastMaps.includes(map)) {
source.splice(i, 1);
playlist.push(map);
return true;
}
}
return false;
}
private buildMapsList(type: PublicGameType): GameMapType[] {
const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
const map = GameMapType[key];
if (
type !== "special" &&
(ARCADE_MAPS.has(map) || SPECIAL_ONLY_MAPS.has(map))
) {
return;
}
let freq = FREQUENCY[key] ?? 0;
// Boost frequency for special team maps in the team playlist
if (type === "team" && SPECIAL_TEAM_MAPS.has(map)) {
freq *= SPECIAL_TEAM_FREQ_MULTIPLIER;
}
for (let i = 0; i < freq; i++) {
maps.push(map);
}
});
return maps;
}
private getTeamCount(map: GameMapType): TeamCountConfig {
// Override team count for specific maps
const forcedTeamCount = SPECIAL_TEAM_MAPS.get(map);
if (
forcedTeamCount !== undefined &&
Math.random() < SPECIAL_TEAM_FORCE_CHANCE
) {
return forcedTeamCount;
}
const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0);
const roll = Math.random() * totalWeight;
let cumulativeWeight = 0;
for (const { config, weight } of TEAM_WEIGHTS) {
cumulativeWeight += weight;
if (roll < cumulativeWeight) {
return config;
}
}
return TEAM_WEIGHTS[0].config;
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
countReduction: number = 0,
): PublicGameModifiers {
// Roll how many modifiers to pick: 30% → 1, 50% → 2, 20% → 3
const modifierCounts = [1, 1, 1, 2, 2, 2, 2, 2, 3, 3];
const rolled =
modifierCounts[Math.floor(Math.random() * modifierCounts.length)];
const k = Math.max(0, (count ?? rolled) - countReduction);
// Shuffle the pool, then pick the first k unique modifier keys.
const pool = SPECIAL_MODIFIER_POOL.filter(
(key) => !excludedModifiers.includes(key),
).sort(() => Math.random() - 0.5);
const selected = new Set<ModifierKey>();
for (const key of pool) {
if (selected.size >= k) break;
// Skip if a mutually exclusive modifier is already selected
const blocked = MUTUALLY_EXCLUSIVE_MODIFIERS.some(
([a, b]) =>
(key === a && selected.has(b)) || (key === b && selected.has(a)),
);
if (!blocked) selected.add(key);
}
return {
isRandomSpawn: selected.has("isRandomSpawn") || undefined,
isCompact: selected.has("isCompact") || undefined,
isCrowded: selected.has("isCrowded") || undefined,
isHardNations: selected.has("isHardNations") || undefined,
startingGold: selected.has("startingGold25M")
? 25_000_000
: selected.has("startingGold5M")
? 5_000_000
: selected.has("startingGold1M")
? 1_000_000
: undefined,
goldMultiplier: selected.has("goldMultiplier") ? 2 : undefined,
isAlliancesDisabled: selected.has("isAlliancesDisabled") || undefined,
isPortsDisabled: selected.has("isPortsDisabled") || undefined,
isNukesDisabled: selected.has("isNukesDisabled") || undefined,
isSAMsDisabled: selected.has("isSAMsDisabled") || undefined,
isPeaceTime: selected.has("isPeaceTime") || undefined,
isWaterNukes: selected.has("isWaterNukes") || undefined,
};
}
// Check whether a compact map still gives every team at least 2 players,
// using the worst-case player tier (smallest) from lobbyMaxPlayers.
private async supportsCompactMapForTeams(
map: GameMapType,
playerTeams: TeamCountConfig,
): Promise<boolean> {
const landTiles = await getMapLandTiles(map);
const [l, , s] = this.calculateMapPlayerCounts(landTiles);
// Worst case: smallest tier with team mode 1.5x multiplier, capped at l
let p = Math.min(Math.ceil(s * 1.5), l);
// Apply compact 75% player reduction, then cap for performance
p = Math.min(Math.max(3, Math.floor(p * 0.25)), MAX_PLAYER_COUNT);
// Apply team adjustment
p = this.adjustForTeams(p, playerTeams);
// Check at least 2 players per team AND at least 2 teams
return (
this.playersPerTeam(p, playerTeams) >= 2 &&
this.numberOfTeams(p, playerTeams) >= 2
);
}
private playersPerTeam(
adjustedPlayerCount: number,
playerTeams: TeamCountConfig,
): number {
switch (playerTeams) {
case Duos:
return Math.min(2, adjustedPlayerCount);
case Trios:
return Math.min(3, adjustedPlayerCount);
case Quads:
return Math.min(4, adjustedPlayerCount);
case HumansVsNations:
return adjustedPlayerCount; // adjustedPlayerCount is the human count
default:
return Math.floor(adjustedPlayerCount / playerTeams);
}
}
private numberOfTeams(
adjustedPlayerCount: number,
playerTeams: TeamCountConfig,
): number {
switch (playerTeams) {
case Duos:
return Math.floor(adjustedPlayerCount / 2);
case Trios:
return Math.floor(adjustedPlayerCount / 3);
case Quads:
return Math.floor(adjustedPlayerCount / 4);
case HumansVsNations:
return 2; // always 2 teams
default:
return playerTeams; // numeric value IS the team count
}
}
/**
* Centralised spawn-immunity duration logic.
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
* - 25M starting gold: 2:30min (extra time to compensate for high gold)
* - 5M starting gold: SAM build time + 15s (enough to build a SAM)
* - Default: 5s
*/
private getSpawnImmunityDuration(
playerTeams?: TeamCountConfig,
startingGold?: number,
): number {
if (playerTeams === HumansVsNations) return 5 * 10;
if (startingGold !== undefined && startingGold >= 25_000_000)
return 150 * 10;
if (startingGold !== undefined && startingGold >= 5_000_000)
return SAM_CONSTRUCTION_TICKS + 15 * 10;
return 5 * 10;
}
private async getCrowdedMaxPlayers(
map: GameMapType,
isCompact: boolean,
): Promise<number | undefined> {
const landTiles = await getMapLandTiles(map);
const [rawFirstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
const firstPlayerCount = Math.min(rawFirstPlayerCount, MAX_PLAYER_COUNT);
if (firstPlayerCount <= 60) {
return isCompact ? 60 : MAX_PLAYER_COUNT;
}
return undefined;
}
private async lobbyMaxPlayers(
map: GameMapType,
mode: GameMode,
numPlayerTeams: TeamCountConfig | undefined,
isCompactMap?: boolean,
): Promise<number> {
const landTiles = await getMapLandTiles(map);
const [l, m, s] = this.calculateMapPlayerCounts(landTiles);
const r = Math.random();
const base = r < 0.3 ? l : r < 0.6 ? m : s;
let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l);
// Apply compact map 75% player reduction
if (isCompactMap) {
p = Math.max(3, Math.floor(p * 0.25));
}
// Cap for performance
p = Math.min(p, MAX_PLAYER_COUNT);
return this.adjustForTeams(p, numPlayerTeams);
}
private adjustForTeams(
playerCount: number,
numPlayerTeams: TeamCountConfig | undefined,
): number {
if (numPlayerTeams === undefined) return playerCount;
let p = playerCount;
switch (numPlayerTeams) {
case Duos:
p -= p % 2;
break;
case Trios:
p -= p % 3;
break;
case Quads:
p -= p % 4;
break;
case HumansVsNations:
// Half the slots are for humans, the other half will get filled with nations
p = Math.floor(p / 2);
break;
default:
p -= p % numPlayerTeams;
break;
}
return p;
}
/**
* Calculate player counts from land tiles
* For every 1,000,000 land tiles, take 50 players
* Second value is 75% of calculated value, third is 50%
* All values are rounded to the nearest 5
*/
private calculateMapPlayerCounts(
landTiles: number,
): [number, number, number] {
const roundToNearest5 = (n: number) => Math.round(n / 5) * 5;
const base = Math.max(roundToNearest5((landTiles / 1_000_000) * 50), 5);
return [base, roundToNearest5(base * 0.75), roundToNearest5(base * 0.5)];
}
}
+162
View File
@@ -0,0 +1,162 @@
import cluster from "cluster";
import crypto from "crypto";
import express from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { GameEnv } from "engine/configuration/Config";
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 playlist = new MapPlaylist();
let lobbyService: MasterLobbyService;
const app = express();
const server = http.createServer(app);
const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Serve the shared app shell for the root document.
app.use(async (req, res, next) => {
if (req.path === "/") {
try {
await renderAppShell(
res,
path.join(__dirname, "../../static/index.html"),
);
} catch (error) {
log.error("Error rendering index.html:", error);
res.status(500).send("Internal Server Error");
}
} else {
next();
}
});
app.use(
express.static(path.join(__dirname, "../../static"), {
maxAge: "1y", // Set max-age to 1 year for all static assets
setHeaders: (res) => {
applyStaticAssetCacheControl(
res.setHeader.bind(res),
res.req.originalUrl,
);
},
}),
);
app.set("trust proxy", 3);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
app.use("/api", (_req, res, next) => {
setNoStoreHeaders(res);
next();
});
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
throw new Error(
"startMaster() should only be called in the primary process",
);
}
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${ServerEnv.numWorkers()} workers...`);
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 =
ServerEnv.env() === GameEnv.Dev
? "DEV_ID"
: crypto.randomBytes(4).toString("hex");
process.env.INSTANCE_ID = INSTANCE_ID;
log.info(`Instance ID: ${INSTANCE_ID}`);
// Fork workers
for (let i = 0; i < ServerEnv.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
ADMIN_TOKEN,
INSTANCE_ID,
});
lobbyService.registerWorker(i, worker);
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
}
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (workerId === undefined) {
log.error(`worker crashed could not find id`);
return;
}
const workerIdNum = parseInt(workerId);
lobbyService.removeWorker(workerIdNum);
log.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
log.info(`Restarting worker ${workerId}...`);
// Restart the worker with the same ID
const newWorker = cluster.fork({
WORKER_ID: workerId,
ADMIN_TOKEN,
INSTANCE_ID,
});
lobbyService.registerWorker(workerIdNum, newWorker);
log.info(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
});
const PORT = 3000;
server.listen(PORT, () => {
log.info(`Master HTTP server listening on port ${PORT}`);
});
}
app.get("/api/health", (_req, res) => {
const ready = lobbyService?.isHealthy() ?? false;
if (ready) {
res.json({ status: "ok" });
} else {
res.status(503).json({ status: "unavailable" });
}
});
// SPA fallback route
app.get("/{*splat}", async function (_req, res) {
try {
const htmlPath = path.join(__dirname, "../../static/index.html");
await renderAppShell(res, htmlPath);
} catch (error) {
log.error("Error rendering SPA fallback:", error);
res.status(500).send("Internal Server Error");
}
});
+180
View File
@@ -0,0 +1,180 @@
import { Worker } from "cluster";
import winston from "winston";
import { PublicGameInfo, PublicGameType } from "core-public/Schemas";
import { generateID } from "engine/Util";
import {
MasterCreateGame,
MasterLobbiesBroadcast,
MasterUpdateGame,
WorkerMessageSchema,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { ServerEnv } from "./ServerEnv";
export interface MasterLobbyServiceOptions {
playlist: MapPlaylist;
log: typeof logger;
}
export class MasterLobbyService {
private readonly workers = new Map<number, Worker>();
// Worker id => the lobbies it owns.
private readonly workerLobbies = new Map<number, PublicGameInfo[]>();
private readonly readyWorkers = new Set<number>();
private started = false;
constructor(
private playlist: MapPlaylist,
private log: winston.Logger,
) {}
registerWorker(workerId: number, worker: Worker) {
this.workers.set(workerId, worker);
worker.on("message", (raw: unknown) => {
const result = WorkerMessageSchema.safeParse(raw);
if (!result.success) {
this.log.error("Invalid IPC message from worker:", raw);
return;
}
const msg = result.data;
switch (msg.type) {
case "workerReady":
this.handleWorkerReady(msg.workerId);
break;
case "lobbyList":
this.workerLobbies.set(workerId, msg.lobbies);
break;
}
});
}
removeWorker(workerId: number) {
this.workers.delete(workerId);
this.workerLobbies.delete(workerId);
this.readyWorkers.delete(workerId);
}
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(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}/${ServerEnv.numWorkers()} ready)`,
);
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);
startPolling(async () => await this.maybeScheduleLobby(), 1000);
}
}
private getAllLobbies(): Record<PublicGameType, PublicGameInfo[]> {
const lobbies = Array.from(this.workerLobbies.values()).flat();
const result: Record<PublicGameType, PublicGameInfo[]> = {
ffa: [],
team: [],
special: [],
};
for (const lobby of lobbies) {
result[lobby.publicGameType].push(lobby);
}
for (const type of Object.keys(result) as PublicGameType[]) {
result[type].sort((a, b) => {
if (a.startsAt === undefined && b.startsAt === undefined) {
// Sort by game id for stability.
return a.gameID > b.gameID ? 1 : -1;
}
// If a lobby has startsAt set, we assume it's the active one.
if (a.startsAt === undefined) return 1;
if (b.startsAt === undefined) return -1;
return a.startsAt - b.startsAt;
});
}
return result;
}
private broadcastLobbies() {
const msg = {
type: "lobbiesBroadcast",
publicGames: {
serverTime: Date.now(),
games: this.getAllLobbies(),
},
} satisfies MasterLobbiesBroadcast;
for (const [workerId, worker] of this.workers.entries()) {
worker.send(msg, (e) => {
if (e) {
this.log.error(
`Failed to send lobbies broadcast to worker ${workerId}, killing worker:`,
e,
);
worker.kill();
}
});
}
}
private async maybeScheduleLobby() {
const lobbiesByType = this.getAllLobbies();
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) {
const lobbies = lobbiesByType[type];
// Always ensure the next lobby has a timer, even if we already have 2+
// lobbies. This prevents a race where two lobbies are created before
// either receives a startsAt (IPC round-trip delay), leaving both stuck
// without a countdown.
const nextLobby = lobbies[0];
if (nextLobby && nextLobby.startsAt === undefined) {
this.sendMessageToWorker({
type: "updateLobby",
gameID: nextLobby.gameID,
startsAt: Date.now() + ServerEnv.gameCreationRate(),
});
}
if (lobbies.length >= 2) {
continue;
}
this.sendMessageToWorker({
type: "createGame",
gameID: generateID(),
gameConfig: await this.playlist.gameConfig(type),
publicGameType: type,
} satisfies MasterCreateGame);
}
}
private sendMessageToWorker(msg: MasterCreateGame | MasterUpdateGame): void {
const workerId = ServerEnv.workerIndex(msg.gameID);
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
return;
}
worker.send(msg, (e) => {
if (e) {
this.log.error(
`Failed to send message to worker ${workerId}, killing worker:`,
e,
);
worker.kill();
}
});
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Response } from "express";
export function setNoStoreHeaders(res: Response): void {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
+27
View File
@@ -0,0 +1,27 @@
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { ServerEnv } from "./ServerEnv";
export function getOtelResource() {
return resourceFromAttributes({
[ATTR_SERVICE_NAME]: "openfront",
[ATTR_SERVICE_VERSION]: "1.0.0",
...getPromLabels(),
});
}
export function getPromLabels() {
const workerId = ServerEnv.workerId();
return {
"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",
};
}
+24
View File
@@ -0,0 +1,24 @@
import { logger } from "./Logger";
const log = logger.child({ comp: "polling" });
/**
* Starts a polling loop that executes the given async task effectively recursively using setTimeout.
* This guarantees that the next execution only starts after the previous one has completed (or failed),
* preventing request pile-ups.
*
* @param task The async function to execute.
* @param intervalMs The delay in milliseconds before the next execution.
*/
export function startPolling(task: () => Promise<void>, intervalMs: number) {
const runLoop = () => {
task()
.catch((error) => {
log.error("Error in polling loop:", error);
})
.finally(() => {
setTimeout(runLoop, intervalMs);
});
};
runLoop();
}
+384
View File
@@ -0,0 +1,384 @@
import {
DataSet,
RegExpMatcher,
collapseDuplicatesTransformer,
englishDataset,
pattern,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import countries from "resources/countries.json";
import { Cosmetics } from "core-public/CosmeticSchemas";
import { decodePatternData } from "core-public/PatternDecoder";
import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
PlayerSkin,
} from "core-public/Schemas";
import { simpleHash } from "engine/Util";
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const shadowNames = [
"UnhuggedToday",
"DaddysLilChamp",
"BunnyKisses67",
"SnugglePuppy",
"CuddleMonster67",
"DaddysLilStar",
"SnuggleMuffin",
"PeesALittle",
"PleaseFullSendMe",
"NanasLilMan",
"NoAlliances",
"TryingTooHard67",
"MommysLilStinker",
"NeedHugs",
"MommysLilPeanut",
"IWillBetrayU",
"DaddysLilTater",
"PreciousBubbles",
"67 Cringelord",
"Peace And Love",
"AlmostPottyTrained",
];
function buildDataset(bannedWords: string[], dedup: boolean) {
const dataset = new DataSet<{ originalWord: string }>().addAll(
englishDataset,
);
for (const word of bannedWords) {
try {
const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word;
dataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`),
);
} catch (e) {
console.error(`Invalid banned word pattern "${word}": ${e}`);
}
}
return dataset.build();
}
export function createMatcher(bannedWords: string[]): RegExpMatcher {
const baseTransformers = [
toAsciiLowerCaseTransformer(),
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
];
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
// skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses
// like "n.i.g.g.e.r".
const substringMatcher = new RegExpMatcher({
...buildDataset(bannedWords, false),
blacklistMatcherTransformers: [
...baseTransformers,
skipNonAlphabeticTransformer(),
],
});
const collapseMatcher = new RegExpMatcher({
...buildDataset(bannedWords, true),
blacklistMatcherTransformers: [
...baseTransformers,
collapseDuplicatesTransformer(),
skipNonAlphabeticTransformer(),
],
});
return {
hasMatch: (input: string) =>
input.toLowerCase().includes("kkk") ||
substringMatcher.hasMatch(input) ||
collapseMatcher.hasMatch(input),
getAllMatches: (input: string, sorted?: boolean) => [
...substringMatcher.getAllMatches(input, sorted),
...collapseMatcher.getAllMatches(input, sorted),
],
} as unknown as RegExpMatcher;
}
/**
* Sanitizes and censors profane usernames and clan tags separately.
* Profane username is overwritten, profane clan tag is removed.
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
* - full name including clan tag was overwritten in the past, if any part of name was bad
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
*/
function censorWithMatcher(
username: string,
clanTag: string | null,
matcher: RegExpMatcher,
): { username: string; clanTag: string | null } {
const usernameIsProfane = matcher.hasMatch(username);
const clanTagIsProfane = clanTag
? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss"
: false;
// Catch slurs split across clan tag and username (e.g. clanTag="HIT", username="LER")
// by looking for a match that spans the clan/name boundary.
const combinedSlurAcrossBoundary = clanTag
? matcher.getAllMatches(clanTag + username).some(
(match) =>
// Match must start in the clan and extend into the name — otherwise
// it's already handled by the clan-only or name-only checks above.
match.startIndex < clanTag.length && match.endIndex >= clanTag.length,
)
: false;
const censoredName =
usernameIsProfane || combinedSlurAcrossBoundary
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
const censoredClanTag =
clanTag && !clanTagIsProfane && !combinedSlurAcrossBoundary
? clanTag.toUpperCase()
: null;
return { username: censoredName, clanTag: censoredClanTag };
}
export type ClanTagResolution = {
tag: string | null;
dropped: boolean;
};
/**
* The clan-tag ownership rule, shared by every PrivilegeChecker:
* - member of the clan -> keep the tag
* - not a member, tag not reserved -> fictional tag, keep it
* - otherwise -> drop it (impersonation)
* `reservedTags` is every registered tag (uppercase); null means the reserved
* list is unavailable (cosmetics infra still loading), in which case an
* unverifiable tag counts as reserved and is dropped fail-closed.
*/
function decideClanTag(
censoredTag: string | null,
ownedClanTags: string[],
reservedTags: Set<string> | null,
): ClanTagResolution {
if (censoredTag === null) return { tag: null, dropped: false };
const tag = censoredTag.toUpperCase();
const isMember = ownedClanTags.some((t) => t.toUpperCase() === tag);
const isReserved = reservedTags === null || reservedTags.has(tag);
if (isMember || !isReserved) return { tag: censoredTag, dropped: false };
return { tag: null, dropped: true };
}
type CosmeticResult =
| { type: "allowed"; cosmetics: PlayerCosmetics }
| { type: "forbidden"; reason: string };
export interface PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null };
/**
* Decide whether a player may wear the given (already-censored) clan tag.
* Members keep their tag; impersonated or unverifiable tags are dropped.
* `ownedClanTags` are the tags the player belongs to.
*/
resolveClanTag(
censoredTag: string | null,
ownedClanTags: string[],
): ClanTagResolution;
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
private matcher: RegExpMatcher;
constructor(
private cosmetics: Cosmetics,
private b64urlDecode: (base64: string) => Uint8Array,
bannedWords: string[],
// Every registered clan tag (uppercase). Polled by PrivilegeRefresher so
// ownership is resolved in memory — no per-join existence probe.
private reservedClanTags: Set<string> = new Set(),
) {
this.matcher = createMatcher(bannedWords);
}
resolveClanTag(
censoredTag: string | null,
ownedClanTags: string[],
): ClanTagResolution {
return decideClanTag(censoredTag, ownedClanTags, this.reservedClanTags);
}
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
const cosmetics: PlayerCosmetics = {};
if (refs.patternName) {
try {
cosmetics.pattern = this.isPatternAllowed(
flares,
refs.patternName,
refs.patternColorPaletteName ?? null,
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid pattern: " + message };
}
}
if (refs.color) {
try {
cosmetics.color = this.isColorAllowed(flares, refs.color);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid color: " + message };
}
}
if (refs.flag) {
try {
cosmetics.flag = this.isFlagAllowed(flares, refs.flag);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid flag: " + message };
}
}
if (refs.skinName) {
try {
cosmetics.skin = this.isSkinAllowed(flares, refs.skinName);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid skin: " + message };
}
}
return { type: "allowed", cosmetics };
}
isSkinAllowed(flares: string[], name: string): PlayerSkin {
const found = this.cosmetics.skins?.[name];
if (!found) throw new Error(`Skin ${name} not found`);
if (flares.includes("skin:*") || flares.includes(`skin:${found.name}`)) {
return { name: found.name, url: found.url };
}
throw new Error(`No flares for skin ${name}`);
}
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PlayerPattern {
// Look for the pattern in the cosmetics.json config
const found = this.cosmetics.patterns[name];
if (!found) throw new Error(`Pattern ${name} not found`);
try {
decodePatternData(found.pattern, this.b64urlDecode);
} catch (e) {
// can be enabled once we can use {cause: error} in Error constructor starting with ES2022
// eslint-disable-next-line preserve-caught-error
throw new Error(`Invalid pattern ${name}`);
}
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
if (flares.includes("pattern:*")) {
return {
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
}
const flareName =
`pattern:${found.name}` +
(colorPaletteName ? `:${colorPaletteName}` : "");
if (flares.includes(flareName)) {
// Player has a flare for this pattern
return {
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
} else {
throw new Error(`No flares for pattern ${name}`);
}
}
isFlagAllowed(flares: string[], flagRef: string): string {
if (flagRef.startsWith("flag:")) {
const key = flagRef.slice("flag:".length);
const found = this.cosmetics.flags[key];
if (!found) throw new Error(`Flag ${key} not found`);
if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) {
return found.url;
}
throw new Error(`No flares for flag ${key}`);
} else if (flagRef.startsWith("country:")) {
const code = flagRef.slice("country:".length);
if (!countryCodes.includes(code)) {
throw new Error(`invalid country code`);
}
return `/flags/${code}.svg`;
} else {
throw new Error(`invalid flag prefix`);
}
}
isColorAllowed(flares: string[], color: string): PlayerColor {
const allowedColors = flares
.filter((flare) => flare.startsWith("color:"))
.map((flare) => flare.split(":")[1]);
if (!allowedColors.includes(color)) {
throw new Error(`Color ${color} not allowed`);
}
return { color };
}
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, this.matcher);
}
}
// Words the englishDataset misses or only catches as standalone tokens.
// These are always enforced even when the remote banned-words list is unavailable.
const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"];
const defaultMatcher = createMatcher(baselineBannedWords);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
return { type: "allowed", cosmetics: {} };
}
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, defaultMatcher);
}
// No reserved-tag list while cosmetics infra is unavailable (null), so a
// non-member's tag is treated as reserved and dropped fail-closed to block
// impersonation. Members are still known from their own tag list.
resolveClanTag(
censoredTag: string | null,
ownedClanTags: string[],
): ClanTagResolution {
return decideClanTag(censoredTag, ownedClanTags, null);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { base64url } from "jose";
import { Logger } from "winston";
import { ReservedClanTagsResponseSchema } from "core-public/ClanApiSchemas";
import { CosmeticsSchema } from "core-public/CosmeticSchemas";
import { startPolling } from "./PollingLoop";
import {
FailOpenPrivilegeChecker,
PrivilegeChecker,
PrivilegeCheckerImpl,
} from "./Privilege";
// Refreshes the privilege checker every 3 minutes.
// WARNING: This fails open if cosmetics.json is not available.
export class PrivilegeRefresher {
private privilegeChecker: PrivilegeChecker | null = null;
private failOpenPrivilegeChecker: PrivilegeChecker =
new FailOpenPrivilegeChecker();
private cosmeticFlagUrls: Set<string> = new Set();
private log: Logger;
constructor(
private cosmeticsEndpoint: string,
private profaneWordsEndpoint: string,
private apiKey: string,
private reservedClanTagsEndpoint: string,
parentLog: Logger,
private refreshInterval: number = 1000 * 60 * 3,
) {
this.log = parentLog.child({ comp: "privilege-refresher" });
}
public async start() {
this.log.info(
`Starting privilege refresher with interval ${this.refreshInterval}`,
);
startPolling(() => this.loadPrivilegeChecker(), this.refreshInterval);
}
public get(): PrivilegeChecker {
return this.privilegeChecker ?? this.failOpenPrivilegeChecker;
}
public getCosmeticFlagUrls(): Set<string> {
return this.cosmeticFlagUrls;
}
private async loadPrivilegeChecker(): Promise<void> {
this.log.info(`Loading privilege checker`);
try {
const fetchWithTimeout = async (url: string) => {
try {
return await fetch(url, {
signal: AbortSignal.timeout(5000),
headers: { "x-api-key": this.apiKey },
});
} catch (error) {
this.log.warn(`Failed to fetch ${url}: ${error}`);
return null;
}
};
const [
cosmeticsResponse,
profaneWordsResponse,
reservedClanTagsResponse,
] = await Promise.all([
fetchWithTimeout(this.cosmeticsEndpoint),
fetchWithTimeout(this.profaneWordsEndpoint),
fetchWithTimeout(this.reservedClanTagsEndpoint),
]);
if (!cosmeticsResponse || !cosmeticsResponse.ok) {
throw new Error(
`Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`,
);
}
const cosmeticsData = await cosmeticsResponse.json();
const result = CosmeticsSchema.safeParse(cosmeticsData);
if (!result.success) {
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
}
// Reserved clan tags are critical: a missing or malformed list would
// make every non-member tag look fictional and let impersonation
// through. Throw so the previous (good) checker is retained instead.
if (!reservedClanTagsResponse || !reservedClanTagsResponse.ok) {
throw new Error(
`Reserved clan tags HTTP error! status: ${reservedClanTagsResponse?.status ?? "network error"}`,
);
}
const reservedClanTagsData = await reservedClanTagsResponse.json();
const reservedClanTagsResult =
ReservedClanTagsResponseSchema.safeParse(reservedClanTagsData);
if (!reservedClanTagsResult.success) {
throw new Error(
`Invalid reserved clan tags data: ${reservedClanTagsResult.error.message}`,
);
}
const reservedClanTags = new Set(
reservedClanTagsResult.data.map((tag) => tag.toUpperCase()),
);
let bannedWords: string[] = [];
if (profaneWordsResponse && profaneWordsResponse.ok) {
try {
bannedWords = await profaneWordsResponse.json();
this.log.info(
`Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`,
);
} catch (error) {
this.log.warn(`Failed to parse profane words JSON, using empty list`);
}
} else {
this.log.warn(
`Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`,
);
}
this.privilegeChecker = new PrivilegeCheckerImpl(
result.data,
base64url.decode,
bannedWords,
reservedClanTags,
);
this.cosmeticFlagUrls = new Set(
Object.values(result.data.flags).map((f) => f.url),
);
this.log.info(
`Privilege checker loaded successfully (${reservedClanTags.size} reserved clan tags)`,
);
} catch (error) {
this.log.error(`Failed to load privilege checker:`, error);
throw error;
}
}
}
+395
View File
@@ -0,0 +1,395 @@
import { createHash } from "crypto";
import fs from "fs";
import { globSync } from "glob";
import path from "path";
import {
type AssetManifest,
encodeAssetPath,
normalizeAssetPath,
// Relative (not the `engine/*` alias) so vite.config.ts can import this
// module at config-load time, which runs outside tsconfig path resolution.
} from "../../engine/src/AssetUrls";
const HASHED_PUBLIC_ASSET_GLOBS = [
"changelog.md",
"manifest.json",
"atlases/**/*",
"cosmetics/**/*",
"flags/**/*",
"fonts/**/*",
"icons/**/*",
"images/**/*",
"lang/**/*",
"maps/**/*",
"sounds/**/*",
"sprites/**/*",
] as const;
const ROOT_PUBLIC_FILES = new Set([
"LICENSE",
"ads.txt",
"privacy-policy.html",
"robots.txt",
"terms-of-service.html",
"version.txt",
]);
const manifestCache = new Map<string, AssetManifest>();
// Bump this to force-invalidate all CDN-cached assets (e.g. after a bad deploy with wrong cache headers).
const CACHE_BUST_VERSION = "3";
type DerivedPublicAssetRenderContext = {
resourcesDir: string;
relativePath: string;
assetManifest: AssetManifest;
};
type DerivedPublicAssetRenderer = {
matches: (relativePath: string) => boolean;
render: (context: DerivedPublicAssetRenderContext) => string;
};
function toPosixPath(filePath: string): string {
return filePath.split(path.sep).join(path.posix.sep);
}
function createContentHash(filePath: string): string {
const content = fs.readFileSync(filePath);
return createHash("sha256")
.update(CACHE_BUST_VERSION)
.update(content)
.digest("hex")
.slice(0, 12);
}
function createStringHash(content: string): string {
return createHash("sha256")
.update(CACHE_BUST_VERSION)
.update(content)
.digest("hex")
.slice(0, 12);
}
function createHashedAssetUrl(relativePath: string, hash: string): string {
const parsed = path.posix.parse(toPosixPath(relativePath));
const hashedFileName = `${parsed.name}.${hash}${parsed.ext}`;
const hashedRelativePath = path.posix.join(
"_assets",
parsed.dir,
hashedFileName,
);
return `/${encodeAssetPath(hashedRelativePath)}`;
}
function readPublicAssetText(
resourcesDir: string,
relativePath: string,
): string {
const sourcePath = path.join(resourcesDir, relativePath);
return fs.readFileSync(sourcePath, "utf8");
}
function resolveDerivedAssetReference(
relativePath: string,
referencePath: string,
): string {
const baseDir = path.posix.dirname(toPosixPath(relativePath));
return normalizeAssetPath(path.posix.join(baseDir, referencePath));
}
function getEmittedAssetRelativePath(
fromRelativePath: string,
targetHashedUrl: string,
): string {
const emittedFromDir = path.posix.join(
"_assets",
path.posix.dirname(toPosixPath(fromRelativePath)),
);
const emittedTargetPath = normalizeAssetPath(targetHashedUrl);
return path.posix.relative(emittedFromDir, emittedTargetPath);
}
function isExternalAssetReference(referencePath: string): boolean {
return (
/^[a-z][a-z0-9+.-]*:/i.test(referencePath) || referencePath.startsWith("//")
);
}
function renderWebManifestAsset({
resourcesDir,
assetManifest,
}: DerivedPublicAssetRenderContext): string {
const sourcePath = path.join(resourcesDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as {
icons?: Array<{ src?: string }>;
};
manifest.icons = manifest.icons?.map((icon) => {
const src = icon.src;
if (src === undefined) {
return icon;
}
if (src.trim().length === 0) {
throw new Error(
"Derived asset manifest.json contains an icon with a blank src",
);
}
if (isExternalAssetReference(src)) {
return icon;
}
const referencedAssetPath = resolveDerivedAssetReference(
"manifest.json",
src,
);
const referencedHashedUrl = assetManifest[referencedAssetPath];
if (!referencedHashedUrl) {
throw new Error(
`Derived asset manifest.json references ${referencedAssetPath}, but it is missing from the asset manifest`,
);
}
return {
...icon,
src: referencedHashedUrl,
};
});
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function renderBitmapFontAsset({
resourcesDir,
relativePath,
assetManifest,
}: DerivedPublicAssetRenderContext): string {
const sourceXml = readPublicAssetText(resourcesDir, relativePath);
return sourceXml.replace(
/(<page\b[^>]*\bfile=)(["'])([^"']+)(["'])/g,
(
match,
prefix: string,
openQuote: string,
filePath: string,
closeQuote: string,
) => {
if (openQuote !== closeQuote) {
return match;
}
const referencedAssetPath = resolveDerivedAssetReference(
relativePath,
filePath,
);
const referencedHashedUrl = assetManifest[referencedAssetPath];
if (!referencedHashedUrl) {
throw new Error(
`Derived asset ${relativePath} references ${referencedAssetPath}, but it is missing from the asset manifest`,
);
}
const rewrittenFilePath = getEmittedAssetRelativePath(
relativePath,
referencedHashedUrl,
);
return `${prefix}${openQuote}${rewrittenFilePath}${closeQuote}`;
},
);
}
const DERIVED_PUBLIC_ASSET_RENDERERS: DerivedPublicAssetRenderer[] = [
{
matches: (relativePath) => relativePath === "manifest.json",
render: renderWebManifestAsset,
},
{
matches: (relativePath) =>
relativePath.startsWith("fonts/") && relativePath.endsWith(".xml"),
render: renderBitmapFontAsset,
},
];
function getDerivedPublicAssetRenderer(
relativePath: string,
): DerivedPublicAssetRenderer | undefined {
return DERIVED_PUBLIC_ASSET_RENDERERS.find((renderer) =>
renderer.matches(relativePath),
);
}
export function isDerivedPublicAsset(relativePath: string): boolean {
return (
getDerivedPublicAssetRenderer(normalizeAssetPath(relativePath)) !==
undefined
);
}
function renderDerivedPublicAsset(
resourcesDir: string,
relativePath: string,
assetManifest: AssetManifest,
): string | null {
const normalizedPath = normalizeAssetPath(relativePath);
const renderer = getDerivedPublicAssetRenderer(normalizedPath);
if (!renderer) {
return null;
}
return renderer.render({
resourcesDir,
relativePath: normalizedPath,
assetManifest,
});
}
export function getResourcesDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "resources");
}
export function getProprietaryDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "proprietary");
}
// Scans directories with synchronous fs.existsSync — assumes a small number of sourceDirs.
function resolveSourceDir(relativePath: string, sourceDirs: string[]): string {
for (const dir of sourceDirs) {
const candidate = path.join(dir, relativePath);
if (fs.existsSync(candidate)) {
return dir;
}
}
throw new Error(
`Asset ${relativePath} not found in any source directory: ${sourceDirs.join(", ")}`,
);
}
function resolveSourceFile(relativePath: string, sourceDirs: string[]): string {
return path.join(resolveSourceDir(relativePath, sourceDirs), relativePath);
}
export function shouldKeepRootPublicFile(relativePath: string): boolean {
return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath));
}
export function listHashedPublicAssetPaths(sourceDirs: string[]): string[] {
const files = new Set<string>();
for (const dir of sourceDirs) {
if (!fs.existsSync(dir)) continue;
for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
for (const file of globSync(pattern, {
cwd: dir,
nodir: true,
dot: false,
posix: true,
})) {
files.add(normalizeAssetPath(file));
}
}
}
return [...files].sort();
}
export function listRootPublicFiles(resourcesDir: string): string[] {
return globSync("**/*", {
cwd: resourcesDir,
nodir: true,
dot: false,
posix: true,
})
.map((file) => normalizeAssetPath(file))
.filter((file) => shouldKeepRootPublicFile(file))
.sort();
}
export function buildPublicAssetManifest(sourceDirs: string[]): AssetManifest {
const cacheKey = sourceDirs.join("\0");
const cached = manifestCache.get(cacheKey);
if (cached) {
return cached;
}
const hashedPublicAssetPaths = listHashedPublicAssetPaths(sourceDirs);
const rawAssetPaths = hashedPublicAssetPaths.filter(
(relativePath) => !isDerivedPublicAsset(relativePath),
);
const derivedAssetPaths = hashedPublicAssetPaths.filter((relativePath) =>
isDerivedPublicAsset(relativePath),
);
const manifest: AssetManifest = {};
for (const relativePath of rawAssetPaths) {
const absolutePath = resolveSourceFile(relativePath, sourceDirs);
const hash = createContentHash(absolutePath);
manifest[relativePath] = createHashedAssetUrl(relativePath, hash);
}
for (const relativePath of derivedAssetPaths) {
const renderedAsset = renderDerivedPublicAsset(
resolveSourceDir(relativePath, sourceDirs),
relativePath,
manifest,
);
if (renderedAsset === null) {
throw new Error(`Missing derived asset renderer for ${relativePath}`);
}
manifest[relativePath] = createHashedAssetUrl(
relativePath,
createStringHash(renderedAsset),
);
}
manifestCache.set(cacheKey, manifest);
return manifest;
}
export function clearPublicAssetManifestCache(): void {
manifestCache.clear();
}
export function createHashedPublicAssetFiles(
sourceDirs: string[],
outDir: string,
assetManifest: AssetManifest,
): void {
for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) {
const sourceDir = resolveSourceDir(relativePath, sourceDirs);
const sourcePath = path.join(sourceDir, relativePath);
const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const renderedAsset = renderDerivedPublicAsset(
sourceDir,
relativePath,
assetManifest,
);
if (renderedAsset !== null) {
fs.writeFileSync(outputPath, renderedAsset);
continue;
}
fs.copyFileSync(sourcePath, outputPath);
}
}
export function copyRootPublicFiles(
resourcesDir: string,
outDir: string,
): void {
for (const relativePath of listRootPublicFiles(resourcesDir)) {
const sourcePath = path.join(resourcesDir, relativePath);
const outputPath = path.join(outDir, relativePath);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.copyFileSync(sourcePath, outputPath);
}
}
export function writePublicAssetManifest(
outDir: string,
assetManifest: AssetManifest,
): void {
const manifestPath = path.join(outDir, "asset-manifest.json");
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(assetManifest, null, 2)}\n`);
}
+3
View File
@@ -0,0 +1,3 @@
# Gatekeeper
Security module for botting, rate limiting, fingerprinting, etc.
+87
View File
@@ -0,0 +1,87 @@
import ejs from "ejs";
import type { Response } from "express";
import fs from "fs/promises";
import { buildAssetUrl } from "engine/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, stale-if-error=86400";
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 = ServerEnv.cdnBase();
return ejs.render(htmlContent, {
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,
// e.g. <script src="<%- cdnBaseRaw %>/assets/index-XXX.js">. The Vite
// build plugin inject-cdn-base-template rewrites Vite's emitted /assets/
// refs to use this placeholder.
cdnBaseRaw: cdnBase,
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(
"images/GameplayScreenshot.png",
assetManifest,
cdnBase,
),
backgroundImageUrl: buildAssetUrl(
"images/background.webp",
assetManifest,
cdnBase,
),
desktopLogoImageUrl: buildAssetUrl(
"images/OpenFront.png",
assetManifest,
cdnBase,
),
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest, cdnBase),
});
}
export async function getAppShellContent(htmlPath: string): Promise<string> {
let cachedContent = appShellContentCache.get(htmlPath);
if (!cachedContent) {
cachedContent = renderHtmlContent(htmlPath).catch((error: unknown) => {
appShellContentCache.delete(htmlPath);
throw error;
});
appShellContentCache.set(htmlPath, cachedContent);
}
return cachedContent;
}
export function clearAppShellContentCache(): void {
appShellContentCache.clear();
}
export function setAppShellCacheHeaders(res: Response): void {
res.setHeader("Cache-Control", APP_SHELL_CACHE_CONTROL);
res.setHeader("Content-Type", "text/html");
}
export function setHtmlNoCacheHeaders(res: Response): void {
setNoStoreHeaders(res);
res.setHeader("ETag", "");
res.setHeader("Content-Type", "text/html");
}
export async function renderAppShell(
res: Response,
htmlPath: string,
): Promise<void> {
const rendered = await getAppShellContent(htmlPath);
setAppShellCacheHeaders(res);
res.send(rendered);
}
@@ -0,0 +1,34 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import type { AssetManifest } from "engine/AssetUrls";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const staticDir = path.join(__dirname, "../../static");
const manifestPath = path.join(staticDir, "asset-manifest.json");
let cachedManifest: AssetManifest | null = null;
export async function getRuntimeAssetManifest(): Promise<AssetManifest> {
if (cachedManifest !== null) {
return cachedManifest;
}
if (!fs.existsSync(manifestPath)) {
cachedManifest = {};
return cachedManifest;
}
try {
cachedManifest = JSON.parse(
fs.readFileSync(manifestPath, "utf8"),
) as AssetManifest;
} catch (err) {
console.error(`Failed to parse asset manifest at ${manifestPath}:`, err);
cachedManifest = {};
}
return cachedManifest;
}
export function clearRuntimeAssetManifestCache(): void {
cachedManifest = null;
}
+26
View File
@@ -0,0 +1,26 @@
import cluster from "cluster";
import * as dotenv from "dotenv";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
// Load environment variables before we read configuration values derived from them.
dotenv.config();
// Main entry point of the application
async function main() {
// Check if this is the primary (master) process
if (cluster.isPrimary) {
console.log("Starting master process...");
await startMaster();
} else {
// This is a worker process
console.log("Starting worker process...");
await startWorker();
}
}
// Start the application
main().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
+177
View File
@@ -0,0 +1,177 @@
import { JWK } from "jose";
import { z } from "zod";
import { GameEnv, parseGameEnv } from "engine/configuration/Config";
import { GameID } from "core-public/Schemas";
import { simpleHash } from "engine/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);
}
}
+33
View File
@@ -0,0 +1,33 @@
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
function stripQueryString(urlPath: string): string {
return urlPath.split("?", 1)[0];
}
export function getStaticAssetCacheControl(
urlPath: string | undefined,
): string | undefined {
if (!urlPath) {
return undefined;
}
const normalizedPath = stripQueryString(urlPath);
if (
normalizedPath.startsWith("/assets/") ||
normalizedPath.startsWith("/_assets/")
) {
return IMMUTABLE_CACHE_CONTROL;
}
return undefined;
}
export function applyStaticAssetCacheControl(
setHeader: (name: string, value: string) => void,
urlPath: string | undefined,
): void {
const cacheControl = getStaticAssetCacheControl(urlPath);
if (cacheControl) {
setHeader("Cache-Control", cacheControl);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
import { ServerEnv } from "./ServerEnv";
const TurnstileVerdictSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("approved") }),
z.object({ status: z.literal("rejected"), reason: z.string() }),
]);
type TurnstileVerdict = z.infer<typeof TurnstileVerdictSchema>;
export type TurnstileResponse =
| TurnstileVerdict
| { status: "error"; reason: string };
export async function verifyTurnstileToken(
ip: string,
turnstileToken: string | null,
): Promise<TurnstileResponse> {
if (!turnstileToken) {
return { status: "rejected", reason: "No turnstile token provided" };
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${ServerEnv.jwtIssuer()}/turnstile`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({ ip, token: turnstileToken }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
status: "error",
reason: `api-worker returned ${response.status}`,
};
}
const parsed = TurnstileVerdictSchema.safeParse(await response.json());
if (!parsed.success) {
return {
status: "error",
reason: `api-worker returned malformed response: ${parsed.error.message}`,
};
}
return parsed.data;
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return {
status: "error",
reason: "Turnstile token validation timed out after 5 seconds",
};
}
return {
status: "error",
reason: `Turnstile token validation failed, ${e}`,
};
}
}
+640
View File
@@ -0,0 +1,640 @@
import compression from "compression";
import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { GameEnv } from "engine/configuration/Config";
import { GameType } from "engine/game/Game";
import {
ClientMessageSchema,
GameID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "core-public/Schemas";
import { generateID, replacer } from "engine/Util";
import { CreateGameInputSchema } from "core-public/WorkerSchemas";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { registerGamePreviewRoute } from "./GamePreviewRoute";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
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 workerId = ServerEnv.workerId() ?? 0;
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist();
// Worker setup
export async function startWorker() {
log.info(`Worker starting...`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json({ limit: "5mb" }));
const server = http.createServer(app);
const wss = new WebSocketServer({
noServer: true,
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(log);
// Initialize lobby service (handles WebSocket upgrade routing)
const lobbyService = new WorkerLobbyService(server, wss, gm, log);
setTimeout(
() => {
startMatchmakingPolling(gm);
},
1000 + Math.random() * 2000,
);
if (ServerEnv.otelEnabled()) {
initWorkerMetrics(gm);
}
const privilegeRefresher = new PrivilegeRefresher(
ServerEnv.jwtIssuer() + "/cosmetics.json",
ServerEnv.jwtIssuer() + "/profane_words_game_server",
ServerEnv.apiKey(),
ServerEnv.jwtIssuer() + "/reserved_clan_tags",
log,
);
privilegeRefresher.start();
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
// Extract the original path without the worker prefix
const originalPath = req.url;
const match = originalPath.match(/^\/w(\d+)(.*)$/);
if (match) {
const pathWorkerId = parseInt(match[1]);
const actualPath = match[2] || "/";
// Verify this request is for the correct worker
if (pathWorkerId !== workerId) {
return res.status(404).json({
error: "Worker mismatch",
message: `This is worker ${workerId}, but you requested worker ${pathWorkerId}`,
});
}
// Update the URL to remove the worker prefix
req.url = actualPath;
}
next();
});
app.set("trust proxy", 3);
app.use(compression());
app.use(
express.static(path.join(__dirname, "../../out"), {
setHeaders: (res) => {
applyStaticAssetCacheControl(
res.setHeader.bind(res),
res.req.originalUrl,
);
},
}),
);
app.use(
"/maps",
express.static(path.join(__dirname, "../../static/maps"), {
maxAge: "1y",
setHeaders: (res, filePath) => {
if (filePath.endsWith(".webp")) {
res.setHeader("Content-Type", "image/webp");
}
},
}),
);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
app.use("/api", (_req, res, next) => {
setNoStoreHeaders(res);
next();
});
app.post("/api/create_game/:id", async (req, res) => {
const id = req.params.id;
// Extract persistentID from Authorization header token
// Never accept persistentID directly from client
let creatorPersistentID: string | undefined;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length);
const result = await verifyClientToken(token);
if (result.type === "success") {
creatorPersistentID = result.persistentId;
} else {
log.warn(`Invalid creator token: ${result.message}`);
return res.status(401).json({ error: "Invalid creator token" });
}
} else if (
!req.headers[ServerEnv.adminHeader()] // Public games use admin token instead
) {
return res
.status(400)
.json({ error: "Authorization header required to create a game" });
}
if (!id) {
log.warn(`cannot create game, id not found`);
return res.status(400).json({ error: "Game ID is required" });
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const result = CreateGameInputSchema.safeParse(req.body);
if (!result.success) {
const error = z.prettifyError(result.error);
return res.status(400).json({ error });
}
const gc = result.data;
if (
gc?.gameType === GameType.Public &&
req.headers[ServerEnv.adminHeader()] !== ServerEnv.adminToken()
) {
log.warn(
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
);
return res.status(401).send("Unauthorized");
}
// Double-check this worker should host this game
const expectedWorkerId = ServerEnv.workerIndex(id);
if (expectedWorkerId !== workerId) {
log.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400).json({ error: "Worker, game id mismatch" });
}
// Pass creatorPersistentID to createGame
const game = gm.createGame(id, gc, creatorPersistentID);
if (game === null) {
log.warn(`cannot create game, id ${id} already exists`);
return res.status(409).json({ error: "Game ID already exists" });
}
log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? GameType.Public : GameType.Private}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorPersistentID ? `, creator: ${creatorPersistentID.substring(0, 8)}...` : ""}`,
);
res.json(game.gameInfo());
});
app.get("/api/game/:id/exists", async (req, res) => {
const lobbyId = req.params.id;
res.json({
exists: gm.game(lobbyId) !== null,
});
});
app.get("/api/game/:id", async (req, res) => {
const game = gm.game(req.params.id);
if (game === null) {
log.info(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json(game.gameInfo());
});
registerGamePreviewRoute({
app,
gm,
workerId,
log,
baseDir: __dirname,
});
app.post("/api/archive_singleplayer_game", async (req, res) => {
try {
const record = req.body;
const result = PartialGameRecordSchema.safeParse(record);
if (!result.success) {
const error = z.prettifyError(result.error);
log.info(error);
return res.status(400).json({ error });
}
const gameRecord = result.data;
if (gameRecord.info.config.gameType !== GameType.Singleplayer) {
log.warn(
`cannot archive singleplayer with game type ${gameRecord.info.config.gameType}`,
{
gameID: gameRecord.info.gameID,
},
);
return res.status(400).json({ error: "Invalid request" });
}
if (result.data.info.players.length !== 1) {
log.warn(`cannot archive singleplayer game multiple players`, {
gameID: gameRecord.info.gameID,
});
return res.status(400).json({ error: "Invalid request" });
}
log.info("archiving singleplayer game", {
gameID: gameRecord.info.gameID,
});
archive(
finalizeGameRecord(gameRecord),
privilegeRefresher.getCosmeticFlagUrls(),
);
res.json({
success: true,
});
} catch (error) {
log.error("Error processing archive request:", error);
res.status(500).json({ error: "Internal server error" });
}
});
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on("message", async (message: string) => {
const ip = getClientIp(req);
try {
// Parse and handle client messages
const parsed = ClientMessageSchema.safeParse(
JSON.parse(message.toString()),
);
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
log.warn("Error parsing client message", error);
ws.send(
JSON.stringify({
type: "error",
error: error.toString(),
} satisfies ServerErrorMessage),
);
ws.close(1002, "ClientJoinMessageSchema");
return;
}
const clientMsg = parsed.data;
if (clientMsg.type === "ping") {
// Ignore ping
return;
} else if (clientMsg.type !== "join" && clientMsg.type !== "rejoin") {
log.warn(
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
);
return;
}
// Verify this worker should handle this game
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}`,
);
return;
}
// Verify token signature
const result = await verifyClientToken(clientMsg.token);
if (result.type === "error") {
log.warn(`Invalid token: ${result.message}`, {
gameID: clientMsg.gameID,
});
ws.close(1002, `Unauthorized: invalid token`);
return;
}
const { persistentId, claims } = result;
if (claims?.role === "banned") {
ws.close(1002, "Account Banned");
return;
}
if (clientMsg.type === "rejoin") {
log.info("rejoining game", {
gameID: clientMsg.gameID,
persistentID: persistentId,
});
const wasFound = gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
log.warn(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
ws.close(1002, "Game not found");
}
return;
}
// Normalize username and clan tag before any rejoin/join handling.
// If this connection maps to an existing lobby client, we still want
// the latest pre-join identity to be reflected.
const { clanTag: censoredClanTag, username: censoredUsername } =
privilegeRefresher
.get()
.censor(clientMsg.username, clientMsg.clanTag ?? null);
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization
if (
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: censoredClanTag,
})
) {
return;
}
let flares: string[] | undefined;
let publicId: string | undefined;
let friends: string[] = [];
let ownedClanTags: string[] = [];
const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
ws.close(1002, "Unauthorized");
return;
}
} else {
// Verify token and get player permissions
const result = await getUserMe(clientMsg.token);
if (result.type === "error") {
log.warn(`Unauthorized: ${result.message}`, {
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(1002, "Unauthorized: user me fetch failed");
return;
}
flares = result.response.player.flares;
publicId = result.response.player.publicId;
friends = result.response.player.friends;
ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? [];
if (allowedFlares !== undefined) {
const allowed =
allowedFlares.length === 0 ||
allowedFlares.some((f) => flares?.includes(f));
if (!allowed) {
log.warn(
"Forbidden: player without an allowed flare attempted to join game",
);
ws.close(1002, "Forbidden");
return;
}
}
}
// Enforce clan tag ownership: a player can wear a tag only if they're
// a member; a real clan they're not in (or an unverifiable tag) is
// dropped to prevent impersonation. Fictional tags pass through.
const resolution = privilegeRefresher
.get()
.resolveClanTag(censoredClanTag, ownedClanTags);
if (resolution.dropped) {
log.warn("Dropped clan tag: player is not a member", {
persistentID: persistentId,
gameID: clientMsg.gameID,
clanTag: censoredClanTag,
});
}
const resolvedClanTag = resolution.tag;
const cosmeticResult = privilegeRefresher
.get()
.isAllowed(flares ?? [], clientMsg.cosmetics ?? {});
if (cosmeticResult.type === "forbidden") {
log.warn(`Forbidden: ${cosmeticResult.reason}`, {
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(1002, cosmeticResult.reason);
return;
}
if (ServerEnv.env() !== GameEnv.Dev) {
const turnstileResult = await verifyTurnstileToken(
ip,
clientMsg.turnstileToken,
);
switch (turnstileResult.status) {
case "approved":
break;
case "rejected":
log.warn("Unauthorized: Turnstile token rejected", {
persistentID: persistentId,
gameID: clientMsg.gameID,
reason: turnstileResult.reason,
});
ws.close(1002, "Unauthorized: Turnstile token rejected");
return;
case "error":
// Fail open, allow the client to join.
log.error("Turnstile token error", {
persistentID: persistentId,
gameID: clientMsg.gameID,
reason: turnstileResult.reason,
});
}
}
// Create client and add to game
const client = new Client(
generateID(),
persistentId,
claims,
claims?.role ?? null,
flares,
ip,
censoredUsername,
resolvedClanTag,
ws,
cosmeticResult.cosmetics,
publicId,
friends,
);
const joinResult = gm.joinClient(client, clientMsg.gameID);
if (joinResult === "not_found") {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
ws.close(1002, "Game not found");
} else if (joinResult === "kicked") {
log.warn(`kicked client tried to join game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Cannot join game");
} else if (joinResult === "rejected") {
log.info(`client rejected from game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Lobby full");
}
// Handle other message types
} catch (error) {
ws.close(1011, "Internal server error");
log.warn(
`error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring(
0,
250,
),
);
}
});
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
ws.on("close", () => {
ws.removeAllListeners();
});
});
// The load balancer will handle routing to this server based on path
const PORT = ServerEnv.workerPortByIndex(workerId);
server.listen(PORT, () => {
log.info(`running on http://localhost:${PORT}`);
log.info(`Handling requests with path prefix /w${workerId}/`);
// Signal to the master process that this worker is ready
lobbyService.sendReady(workerId);
log.info(`signaled ready state to master`);
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
log.error(`Error in ${req.method} ${req.path}:`, err);
res.status(500).json({ error: "An unexpected error occurred" });
});
// Process-level error handlers
process.on("uncaughtException", (err) => {
log.error(`uncaught exception:`, err);
});
process.on("unhandledRejection", (reason, promise) => {
log.error(`unhandled rejection at:`, promise, "reason:", reason);
});
}
async function startMatchmakingPolling(gm: GameManager) {
startPolling(
async () => {
try {
const url = `${ServerEnv.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({
id: workerId,
gameId: gameId,
ccu: gm.activeClients(),
instanceId: process.env.INSTANCE_ID,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
log.warn(
`Failed to poll lobby: ${response.status} ${response.statusText}`,
);
return;
}
const data = await response.json();
log.info(`Lobby poll successful:`, data);
if (data.assignment) {
const game = gm.createGame(
gameId,
playlist.get1v1Config(),
undefined,
Date.now() + 7000,
);
if (game === null) {
log.warn(`Failed to create matchmaking game ${gameId}`);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Abort is expected if no game is scheduled on this worker.
return;
}
log.error(`Error polling lobby:`, error);
}
},
5000 + Math.random() * 1000,
);
}
// TODO: This is a hack to generate a game ID for the worker.
// It should be replaced with a more robust solution.
function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === ServerEnv.workerIndex(gameId)) {
return gameId;
}
attempts--;
}
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
function getClientIp(req: http.IncomingMessage): string {
const cfIp = req.headers["cf-connecting-ip"];
if (typeof cfIp === "string" && cfIp) return cfIp;
return req.socket.remoteAddress ?? "unknown";
}
+216
View File
@@ -0,0 +1,216 @@
import http from "http";
import { WebSocket, WebSocketServer } from "ws";
import {
PublicGameInfo,
PublicGames,
PublicLobbyMessage,
} from "core-public/Schemas";
import { GameManager } from "./GameManager";
import {
MasterMessageSchema,
WorkerLobbyList,
WorkerReady,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
export class WorkerLobbyService {
private readonly lobbiesWss: WebSocketServer;
private readonly lobbyClients: Set<WebSocket> = new Set();
// Most recent snapshot from master, serialized on demand for new
// connections so they don't have to wait for the next broadcast.
private lastPublicGames: PublicGames | null = null;
// Sorted gameIDs of the last full we broadcast, or null if we've never
// broadcast one. When the set changes we send a fresh full; otherwise a
// counts-only delta is enough. This relies on master creating a new lobby
// whenever it sets startsAt on the previous one, so structural state
// (startsAt, gameConfig) rides along with a gameID change. Null (not "")
// is used so that an empty-lobby first broadcast still emits a full.
private lastFullGameIds: string | null = null;
constructor(
private readonly server: http.Server,
private readonly gameWss: WebSocketServer,
private readonly gm: GameManager,
private readonly log: typeof logger,
) {
this.lobbiesWss = new WebSocketServer({
noServer: true,
maxPayload: 256 * 1024,
});
this.setupUpgradeHandler();
this.setupLobbiesWebSocket();
this.setupIPCListener();
}
private setupIPCListener() {
process.on("message", (raw: unknown) => {
const result = MasterMessageSchema.safeParse(raw);
if (!result.success) {
this.log.error("Invalid IPC message from master:", raw);
return;
}
const msg = result.data;
switch (msg.type) {
case "lobbiesBroadcast":
this.lastPublicGames = msg.publicGames;
// Forward message to all clients
this.broadcastLobbiesToClients(msg.publicGames);
// Update master with my lobby info
this.sendMyLobbiesToMaster();
break;
case "createGame": {
if (this.gm.game(msg.gameID) !== null) {
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
return;
}
this.log.info(`Creating public game ${msg.gameID} from master`);
const game = this.gm.createGame(
msg.gameID,
msg.gameConfig,
undefined,
undefined,
msg.publicGameType,
);
if (game === null) {
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
}
break;
}
case "updateLobby": {
const game = this.gm.game(msg.gameID);
if (!game) {
this.log.warn("cannot update game, not found", {
gameID: msg.gameID,
});
return;
}
game.setStartsAt(msg.startsAt);
break;
}
}
});
}
sendReady(workerId: number) {
const msg: WorkerReady = { type: "workerReady", workerId };
process.send?.(msg);
}
private sendMyLobbiesToMaster() {
const lobbies = this.gm
.publicLobbies()
.map((g) => g.gameInfo())
.map((gi) => {
return {
gameID: gi.gameID,
numClients: gi.clients?.length ?? 0,
startsAt: gi.startsAt,
gameConfig: gi.gameConfig,
publicGameType: gi.publicGameType!,
} satisfies PublicGameInfo;
});
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
}
private setupUpgradeHandler() {
this.server.on("upgrade", (request, socket, head) => {
const pathname = request.url ?? "";
if (pathname === "/lobbies" || pathname.endsWith("/lobbies")) {
this.lobbiesWss.handleUpgrade(request, socket, head, (ws) => {
this.lobbiesWss.emit("connection", ws, request);
});
} else {
this.gameWss.handleUpgrade(request, socket, head, (ws) => {
this.gameWss.emit("connection", ws, request);
});
}
});
}
private setupLobbiesWebSocket() {
this.lobbiesWss.on("connection", (ws: WebSocket) => {
this.lobbyClients.add(ws);
// Prime the new client with the most recent snapshot — otherwise it
// would only see counts-only deltas (which it can't apply without a
// base) until the next structural change.
if (this.lastPublicGames !== null) {
const fullJson = JSON.stringify({
type: "full",
serverTime: this.lastPublicGames.serverTime,
games: this.lastPublicGames.games,
} satisfies PublicLobbyMessage);
ws.send(fullJson);
}
ws.on("message", () => {
ws.terminate();
});
ws.on("close", () => {
this.lobbyClients.delete(ws);
});
ws.on("error", (error) => {
this.log.error(`Lobbies WebSocket error:`, error);
this.lobbyClients.delete(ws);
try {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
ws.close(1011, "WebSocket internal error");
}
} catch (closeError) {
this.log.error("Error closing lobbies WebSocket:", closeError);
}
});
});
}
private broadcastLobbiesToClients(publicGames: PublicGames) {
const gameIds: string[] = [];
for (const list of Object.values(publicGames.games)) {
for (const lobby of list) {
gameIds.push(lobby.gameID);
}
}
gameIds.sort();
const fingerprint = gameIds.join(",");
const shouldSendFull = fingerprint !== this.lastFullGameIds;
let payload: PublicLobbyMessage;
if (shouldSendFull) {
payload = {
type: "full",
serverTime: publicGames.serverTime,
games: publicGames.games,
};
this.lastFullGameIds = fingerprint;
} else {
const counts: Record<string, number> = {};
for (const list of Object.values(publicGames.games)) {
for (const lobby of list) {
counts[lobby.gameID] = lobby.numClients;
}
}
payload = {
type: "counts",
serverTime: publicGames.serverTime,
counts,
};
}
const json = JSON.stringify(payload);
const clientsToRemove: WebSocket[] = [];
this.lobbyClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(json);
} else {
clientsToRemove.push(client);
}
});
clientsToRemove.forEach((client) => {
this.lobbyClients.delete(client);
});
}
}
+91
View File
@@ -0,0 +1,91 @@
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import {
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import * as dotenv from "dotenv";
import { GameManager } from "./GameManager";
import { getOtelResource, getPromLabels } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
export function initWorkerMetrics(gameManager: GameManager): void {
// Create resource with worker information
const resource = getOtelResource();
// Configure auth headers
const headers: Record<string, string> = {};
if (ServerEnv.otelEnabled()) {
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
}
// Create metrics exporter
const metricExporter = new OTLPMetricExporter({
url: `${ServerEnv.otelEndpoint()}/v1/metrics`,
headers,
});
// Configure the metric reader
const metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 15000, // Export metrics every 15 seconds
});
// Create a meter provider
const meterProvider = new MeterProvider({
resource,
readers: [metricReader],
});
// Get meter for creating metrics
const meter = meterProvider.getMeter("worker-metrics");
// Create observable gauges
const activeGamesGauge = meter.createObservableGauge(
"openfront.active_games.gauge",
{
description: "Number of active games on this worker",
},
);
const connectedClientsGauge = meter.createObservableGauge(
"openfront.connected_clients.gauge",
{
description: "Number of connected clients on this worker",
},
);
const desyncsGauge = meter.createObservableGauge("openfront.desyncs.gauge", {
description: "Number of detected desyncs on active games on this worker",
});
const memoryUsageGauge = meter.createObservableGauge(
"openfront.memory_usage.bytes",
{
description: "Current memory usage of the worker process in bytes",
},
);
activeGamesGauge.addCallback((result) => {
const count = gameManager.activeGames();
result.observe(count, getPromLabels());
});
connectedClientsGauge.addCallback((result) => {
const count = gameManager.activeClients();
result.observe(count, getPromLabels());
});
desyncsGauge.addCallback((result) => {
const count = gameManager.desyncCount();
result.observe(count, getPromLabels());
});
memoryUsageGauge.addCallback((result) => {
const memoryUsage = process.memoryUsage();
result.observe(memoryUsage.heapUsed, getPromLabels());
});
console.log("Metrics initialized with GameManager");
}
+100
View File
@@ -0,0 +1,100 @@
import { jwtVerify } from "jose";
import { z } from "zod";
import {
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "core-public/ApiSchemas";
import { GameEnv } from "engine/configuration/Config";
import { PersistentIdSchema } from "core-public/Schemas";
import { ServerEnv } from "./ServerEnv";
type TokenVerificationResult =
| {
type: "success";
persistentId: string;
claims: TokenPayload | null;
}
| { type: "error"; message: string };
export async function verifyClientToken(
token: string,
): Promise<TokenVerificationResult> {
if (PersistentIdSchema.safeParse(token).success) {
if (ServerEnv.env() === GameEnv.Dev) {
return { type: "success", persistentId: token, claims: null };
} else {
return {
type: "error",
message: "persistent ID not allowed in production",
};
}
}
try {
const issuer = ServerEnv.jwtIssuer();
const audience = ServerEnv.jwtAudience();
const key = await ServerEnv.jwkPublicKey();
const { payload } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer,
audience,
});
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
return {
type: "error",
message: z.prettifyError(result.error),
};
}
const claims = result.data;
const persistentId = claims.sub;
return { type: "success", persistentId, claims };
} catch (e) {
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An unknown error occurred";
return { type: "error", message };
}
}
export async function getUserMe(
token: string,
): Promise<
| { type: "success"; response: UserMeResponse }
| { type: "error"; message: string }
> {
try {
// Get the user object
const response = await fetch(ServerEnv.jwtIssuer() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
"x-api-key": ServerEnv.apiKey(),
},
});
if (response.status !== 200) {
return {
type: "error",
message: `Failed to fetch user me: ${response.statusText}`,
};
}
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
return {
type: "error",
message: `Invalid response: ${z.prettifyError(result.error)}`,
};
}
return { type: "success", response: result.data };
} catch (e) {
return {
type: "error",
message: `Failed to fetch user me: ${e}`,
};
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}