mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:40:23 +00:00
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:
co-authored by
Claude Opus 4.8
parent
cb9cab9aca
commit
a7f992e9b0
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Gatekeeper
|
||||
|
||||
Security module for botting, rate limiting, fingerprinting, etc.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user