Merge branch 'main' into team-names

This commit is contained in:
Mattia Migliorini
2026-04-13 15:09:58 +02:00
committed by GitHub
216 changed files with 8924 additions and 2717 deletions
+96 -6
View File
@@ -6,13 +6,17 @@ import {
import { UIState } from "../src/client/graphics/UIState";
import { EventBus } from "../src/core/EventBus";
import { UnitType } from "../src/core/game/Game";
import { GameView } from "../src/core/game/GameView";
class MockPointerEvent {
button: number;
clientX: number;
clientY: number;
x: number;
y: number;
pointerId: number;
type: string;
pointerType: string;
preventDefault: () => void;
constructor(type: string, init: any) {
@@ -20,7 +24,10 @@ class MockPointerEvent {
this.button = init.button;
this.clientX = init.clientX;
this.clientY = init.clientY;
this.x = init.x ?? init.clientX;
this.y = init.y ?? init.clientY;
this.pointerId = init.pointerId;
this.pointerType = init.pointerType ?? "mouse";
this.preventDefault = vi.fn();
}
}
@@ -29,10 +36,12 @@ global.PointerEvent = MockPointerEvent as any;
describe("InputHandler AutoUpgrade", () => {
let inputHandler: InputHandler;
let mockGameView: GameView;
let eventBus: EventBus;
let mockCanvas: HTMLCanvasElement;
beforeEach(() => {
mockGameView = { inSpawnPhase: () => false } as GameView;
mockCanvas = document.createElement("canvas");
mockCanvas.width = 800;
mockCanvas.height = 600;
@@ -40,6 +49,7 @@ describe("InputHandler AutoUpgrade", () => {
eventBus = new EventBus();
inputHandler = new InputHandler(
mockGameView,
{
attackRatio: 20,
ghostStructure: null,
@@ -218,6 +228,56 @@ describe("InputHandler AutoUpgrade", () => {
});
});
describe("Spawn Phase Handling", () => {
test("should emit MouseUpEvent and not ContextMenuEvent on left click release during spawn phase", () => {
mockGameView.inSpawnPhase = () => true;
const mockEmit = vi.spyOn(eventBus, "emit");
inputHandler["userSettings"].leftClickOpensMenu = () => true;
const pointerEvent = new PointerEvent("pointerup", {
button: 0,
clientX: 150,
clientY: 250,
});
inputHandler["lastPointerDownX"] = 149;
inputHandler["lastPointerDownY"] = 249;
inputHandler["onPointerUp"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).toContain("MouseUpEvent");
expect(emittedTypes).not.toContain("ContextMenuEvent");
});
test("should suppress/ignore context menu events during spawn phase", () => {
mockGameView.inSpawnPhase = () => true;
const mockEmit = vi.spyOn(eventBus, "emit");
const mouseEvent = new MouseEvent("contextmenu", {
clientX: 150,
clientY: 250,
});
const preventDefaultSpy = vi.spyOn(mouseEvent, "preventDefault");
inputHandler["onContextMenu"](mouseEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).not.toContain("ContextMenuEvent");
});
});
describe("Pointer Event Handling", () => {
test("should handle pointer events with different pointer IDs", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
@@ -481,7 +541,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
@@ -533,7 +598,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
@@ -570,7 +640,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
@@ -590,7 +665,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
@@ -616,7 +696,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
@@ -639,7 +724,12 @@ describe("InputHandler AutoUpgrade", () => {
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
+341
View File
@@ -0,0 +1,341 @@
import fs from "fs";
import path from "path";
import { GameMapName, GameMapType, mapCategories } from "../src/core/game/Game";
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Converts a GameMapName enum key to its folder name (lowercase key). */
function toFolderName(key: GameMapName): string {
return key.toLowerCase();
}
const ROOT = path.resolve(__dirname, "..");
const MAP_GEN_MAPS = path.join(ROOT, "map-generator", "assets", "maps");
const RESOURCES_MAPS = path.join(ROOT, "resources", "maps");
const MAIN_GO = path.join(ROOT, "map-generator", "main.go");
const EN_JSON = path.join(ROOT, "resources", "lang", "en.json");
const MAP_PLAYLIST = path.join(ROOT, "src", "server", "MapPlaylist.ts");
const allMapKeys = Object.keys(GameMapType) as GameMapName[];
// Maps excluded from the frequency requirement (not part of regular playlists).
const FREQUENCY_EXEMPTIONS: Set<GameMapName> = new Set([
"GiantWorldMap",
"Oceania",
"BaikalNukeWars",
"Tourney1",
"Tourney2",
"Tourney3",
"Tourney4",
"EuropeClassic",
]);
/** Parse the main.go maps registry and return the set of non-test map folder names. */
function getMainGoMaps(): Set<string> {
const content = fs.readFileSync(MAIN_GO, "utf8");
const names = new Set<string>();
// Match lines like {Name: "africa"} or {Name: "africa", IsTest: true}
const re = /\{Name:\s*"([^"]+)"(?:,\s*IsTest:\s*true)?\}/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
// Check if it's a test map
if (!m[0].includes("IsTest: true")) {
names.add(m[1]);
}
}
return names;
}
/** Get the en.json map translation keys. */
function getEnJsonMapKeys(): Set<string> {
const content = JSON.parse(fs.readFileSync(EN_JSON, "utf8"));
const mapSection = content.map as Record<string, string>;
// Exclude meta keys that aren't actual maps.
const metaKeys = new Set(["map", "featured", "all", "random"]);
return new Set(Object.keys(mapSection).filter((k) => !metaKeys.has(k)));
}
/** Get all maps listed in the mapCategories from Game.ts. */
function getCategorizedMaps(): Set<string> {
const result = new Set<string>();
for (const maps of Object.values(mapCategories)) {
for (const map of maps) {
result.add(map as string);
}
}
return result;
}
/** Parse the frequency record keys from MapPlaylist.ts. */
function getFrequencyKeys(): Set<string> {
const content = fs.readFileSync(MAP_PLAYLIST, "utf8");
// Extract the frequency block
const freqMatch = content.match(/const frequency[\s\S]*?\{([\s\S]*?)\};/);
if (!freqMatch) {
throw new Error(
`Failed to parse frequency record from MapPlaylist.ts (first 200 chars: ${content.slice(0, 200)})`,
);
}
const keys = new Set<string>();
const re = /(\w+):/g;
let m: RegExpExecArray | null;
while ((m = re.exec(freqMatch[1])) !== null) {
keys.add(m[1]);
}
return keys;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("Map consistency", () => {
test("Every GameMapType is registered in main.go", () => {
const mainGoMaps = getMainGoMaps();
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
if (!mainGoMaps.has(folder)) {
errors.push(`${key} (folder "${folder}") is missing from main.go`);
}
}
if (errors.length > 0) {
throw new Error("Maps missing from main.go:\n" + errors.join("\n"));
}
});
test("Every main.go map has a GameMapType entry", () => {
const mainGoMaps = getMainGoMaps();
const folderToKey = new Map(allMapKeys.map((k) => [toFolderName(k), k]));
const errors: string[] = [];
for (const folder of mainGoMaps) {
if (!folderToKey.has(folder)) {
errors.push(
`main.go map "${folder}" has no matching GameMapType entry`,
);
}
}
if (errors.length > 0) {
throw new Error(
"main.go maps missing from GameMapType:\n" + errors.join("\n"),
);
}
});
test("Every GameMapType has map-generator assets (image.png + info.json only)", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const dir = path.join(MAP_GEN_MAPS, folder);
if (!fs.existsSync(dir)) {
errors.push(
`${key}: directory "${folder}" missing in map-generator/assets/maps/`,
);
continue;
}
const files = fs.readdirSync(dir).sort();
const expected = ["image.png", "info.json"];
if (
files.length !== expected.length ||
!files.every((f, i) => f === expected[i])
) {
errors.push(
`${key}: expected [${expected.join(", ")}] but found [${files.join(", ")}]`,
);
}
}
if (errors.length > 0) {
throw new Error("Map generator asset violations:\n" + errors.join("\n"));
}
});
test("Every GameMapType is listed in at least one mapCategories group", () => {
const categorized = getCategorizedMaps();
const errors: string[] = [];
for (const key of allMapKeys) {
const value = GameMapType[key];
if (!categorized.has(value)) {
errors.push(
`${key} ("${value}") is not listed in any mapCategories group`,
);
}
}
if (errors.length > 0) {
throw new Error("Maps missing from mapCategories:\n" + errors.join("\n"));
}
});
test("Every GameMapType (except exemptions) has a frequency entry", () => {
const freqKeys = getFrequencyKeys();
const errors: string[] = [];
for (const key of allMapKeys) {
if (FREQUENCY_EXEMPTIONS.has(key)) continue;
if (!freqKeys.has(key)) {
errors.push(
`${key} is missing from the frequency record in MapPlaylist.ts`,
);
}
}
if (errors.length > 0) {
throw new Error(
"Maps missing from frequency (not exempted):\n" + errors.join("\n"),
);
}
});
test("No unknown keys in frequency record", () => {
const freqKeys = getFrequencyKeys();
const validKeys = new Set(allMapKeys);
const errors: string[] = [];
for (const key of freqKeys) {
if (!validKeys.has(key as GameMapName)) {
errors.push(`"${key}" in frequency is not a valid GameMapName`);
}
}
if (errors.length > 0) {
throw new Error(
"Unknown keys in frequency record:\n" + errors.join("\n"),
);
}
});
test("Every GameMapType is registered in en.json map translations", () => {
const enKeys = getEnJsonMapKeys();
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
if (!enKeys.has(folder)) {
errors.push(
`${key} (key "${folder}") is missing from en.json map translations`,
);
}
}
if (errors.length > 0) {
throw new Error("Maps missing from en.json:\n" + errors.join("\n"));
}
});
test("Every GameMapType has resources/maps/ with thumbnail.webp, bin files, and manifest.json", () => {
const errors: string[] = [];
const requiredFiles = [
"manifest.json",
"map.bin",
"map4x.bin",
"map16x.bin",
"thumbnail.webp",
];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const dir = path.join(RESOURCES_MAPS, folder);
if (!fs.existsSync(dir)) {
errors.push(`${key}: directory "${folder}" missing in resources/maps/`);
continue;
}
const files = fs.readdirSync(dir);
for (const req of requiredFiles) {
if (!files.includes(req)) {
errors.push(`${key}: missing "${req}" in resources/maps/${folder}/`);
}
}
}
if (errors.length > 0) {
throw new Error("Resource map file violations:\n" + errors.join("\n"));
}
});
test("No excess folders in resources/maps/ or map-generator/assets/maps/", () => {
const expectedFolders = new Set(allMapKeys.map((k) => toFolderName(k)));
const errors: string[] = [];
const resourceDirs = fs
.readdirSync(RESOURCES_MAPS, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const dir of resourceDirs) {
if (!expectedFolders.has(dir)) {
errors.push(`resources/maps/${dir}/ has no matching GameMapType entry`);
}
}
const genDirs = fs
.readdirSync(MAP_GEN_MAPS, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const dir of genDirs) {
if (!expectedFolders.has(dir)) {
errors.push(
`map-generator/assets/maps/${dir}/ has no matching GameMapType entry`,
);
}
}
if (errors.length > 0) {
throw new Error("Excess map folders:\n" + errors.join("\n"));
}
});
test("Nations in info.json and manifest.json should match", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const infoPath = path.join(MAP_GEN_MAPS, folder, "info.json");
const manifestPath = path.join(RESOURCES_MAPS, folder, "manifest.json");
if (!fs.existsSync(infoPath) || !fs.existsSync(manifestPath)) {
continue; // Other tests catch missing files.
}
try {
const info = JSON.parse(fs.readFileSync(infoPath, "utf8"));
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
type NationEntry = { name: string; coordinates: [number, number] };
const infoNations: NationEntry[] = (info.nations ?? []).map(
(n: NationEntry) => ({ name: n.name, coordinates: n.coordinates }),
);
const manifestNations: NationEntry[] = (manifest.nations ?? []).map(
(n: NationEntry) => ({ name: n.name, coordinates: n.coordinates }),
);
if (infoNations.length !== manifestNations.length) {
errors.push(
`${key}: nation count mismatch — info.json has ${infoNations.length}, manifest.json has ${manifestNations.length}`,
);
continue;
}
// Compare nations by index (order must match; names can be duplicated).
for (let i = 0; i < infoNations.length; i++) {
const inf = infoNations[i];
const man = manifestNations[i];
if (inf.name !== man.name) {
errors.push(
`${key}: nations[${i}] name mismatch — info.json "${inf.name}" vs manifest.json "${man.name}"`,
);
continue;
}
const [ix, iy] = inf.coordinates;
const [mx, my] = man.coordinates;
if (ix !== mx || iy !== my) {
errors.push(
`${key}: nation "${inf.name}" (index ${i}) coordinates differ — info.json [${ix}, ${iy}] vs manifest.json [${mx}, ${my}]`,
);
}
}
} catch (err) {
errors.push(`${key}: failed to parse JSON — ${(err as Error).message}`);
}
}
if (errors.length > 0) {
throw new Error(
"Nation data mismatches between info.json and manifest.json:\n" +
errors.join("\n"),
);
}
});
});
+116 -37
View File
@@ -12,6 +12,13 @@ const bannedWords = [
"auschwitz",
"whitepower",
"heil",
"nigger",
"nigga",
"chink",
"spic",
"kike",
"faggot",
"retard",
"chair", // Test word to verify custom banned words work
];
@@ -32,10 +39,12 @@ const flagCosmetics = {
colorPalettes: {},
flags: {
cool_flag: {
type: "flag" as const,
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
rarity: "common",
},
},
};
@@ -51,45 +60,84 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("hitler")).toBe(true);
expect(matcher.hasMatch("nazi")).toBe(true);
expect(matcher.hasMatch("auschwitz")).toBe(true);
});
test("detects custom banned words like 'chair'", () => {
expect(matcher.hasMatch("chair")).toBe(true);
expect(matcher.hasMatch("Chair")).toBe(true);
expect(matcher.hasMatch("CHAIR")).toBe(true);
expect(matcher.hasMatch("MyChairName")).toBe(true);
expect(matcher.hasMatch("nigger")).toBe(true);
expect(matcher.hasMatch("nigga")).toBe(true);
expect(matcher.hasMatch("chink")).toBe(true);
expect(matcher.hasMatch("spic")).toBe(true);
expect(matcher.hasMatch("kike")).toBe(true);
expect(matcher.hasMatch("faggot")).toBe(true);
expect(matcher.hasMatch("retard")).toBe(true);
});
test("detects banned words case-insensitively", () => {
expect(matcher.hasMatch("Hitler")).toBe(true);
expect(matcher.hasMatch("NAZI")).toBe(true);
expect(matcher.hasMatch("Adolf")).toBe(true);
expect(matcher.hasMatch("NIGGER")).toBe(true);
expect(matcher.hasMatch("Nigga")).toBe(true);
expect(matcher.hasMatch("FAGGOT")).toBe(true);
expect(matcher.hasMatch("Retard")).toBe(true);
});
test("detects banned words with leet speak", () => {
expect(matcher.hasMatch("h1tl3r")).toBe(true);
expect(matcher.hasMatch("4d0lf")).toBe(true);
expect(matcher.hasMatch("n4z1")).toBe(true);
expect(matcher.hasMatch("n1gg3r")).toBe(true);
expect(matcher.hasMatch("f4gg0t")).toBe(true);
expect(matcher.hasMatch("r3t4rd")).toBe(true);
});
test("detects banned words with duplicated characters", () => {
expect(matcher.hasMatch("hiiitler")).toBe(true);
expect(matcher.hasMatch("naazzii")).toBe(true);
expect(matcher.hasMatch("niiiigger")).toBe(true);
expect(matcher.hasMatch("faaggot")).toBe(true);
});
test("detects banned words with accented characters", () => {
test("detects banned words with accented/confusable characters", () => {
expect(matcher.hasMatch("Adölf")).toBe(true);
expect(matcher.hasMatch("nïgger")).toBe(true);
});
test("detects banned words as substrings", () => {
expect(matcher.hasMatch("xhitlerx")).toBe(true);
expect(matcher.hasMatch("IloveNazi")).toBe(true);
// Regression: slur + suffix / prefix must be caught
expect(matcher.hasMatch("niggertesting")).toBe(true);
expect(matcher.hasMatch("testingnigger")).toBe(true);
expect(matcher.hasMatch("xnazix")).toBe(true);
expect(matcher.hasMatch("faggotry")).toBe(true);
expect(matcher.hasMatch("retarded")).toBe(true);
expect(matcher.hasMatch("MyChairName")).toBe(true);
});
test("detects banned words with underscores/dots/numbers mixed in", () => {
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
// Words separated by non-alpha chars are treated as separate tokens
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
});
test("allows clean usernames", () => {
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
expect(matcher.hasMatch("GameMaster")).toBe(false);
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
expect(matcher.hasMatch("ProGamer123")).toBe(false);
expect(matcher.hasMatch("NightOwl")).toBe(false);
expect(matcher.hasMatch("DragonSlayer")).toBe(false);
});
test("does not false-positive on words containing banned substrings legitimately", () => {
// "snigger" is whitelisted in englishDataset
expect(matcher.hasMatch("snigger")).toBe(false);
});
test("catches kkk as substring", () => {
expect(matcher.hasMatch("kkk")).toBe(true);
expect(matcher.hasMatch("KKK")).toBe(true);
expect(matcher.hasMatch("kkklover")).toBe(true);
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
});
});
@@ -115,40 +163,71 @@ describe("UsernameCensor", () => {
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag but keeps clean username", () => {
const result = checker.censor("CoolPlayer", "NAZI");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
describe("clan tag censoring", () => {
test("removes profane clan tag, keeps clean username", () => {
expect(checker.censor("CoolPlayer", "NAZI").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "ADOLF").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "HEIL").clanTag).toBeNull();
});
test("removes clan tag with leet speak profanity", () => {
const result = checker.censor("CoolPlayer", "N4Z1");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag that is a slur abbreviation", () => {
expect(checker.censor("CoolPlayer", "NIG").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "NIGG").clanTag).toBeNull();
});
test("removes clan tag with uppercased banned word", () => {
const result = checker.censor("CoolPlayer", "ADOLF");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag containing full slur (≤5 chars)", () => {
expect(checker.censor("CoolPlayer", "NIGGA").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "CHINK").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "SPIC").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "KIKE").clanTag).toBeNull();
});
test("removes clan tag containing banned word substring", () => {
const result = checker.censor("CoolPlayer", "JEWS");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag with leet speak profanity (≤5 chars)", () => {
expect(checker.censor("CoolPlayer", "N4Z1").clanTag).toBeNull();
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censor("hitler", "NAZI");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("removes clan tag containing banned word as substring (≤5 chars)", () => {
expect(checker.censor("CoolPlayer", "JEWS").clanTag).toBeNull();
expect(checker.censor("CoolPlayer", "NAZI").clanTag).toBeNull();
});
test("removes leet speak profane clan tag and censors leet speak username", () => {
const result = checker.censor("h1tl3r", "N4Z1");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
test("removes [SS] clan tag", () => {
expect(checker.censor("Player", "SS").clanTag).toBeNull();
expect(checker.censor("Player", "ss").clanTag).toBeNull();
});
test("removes [KKK] clan tag", () => {
expect(checker.censor("Player", "KKK").clanTag).toBeNull();
});
test("keeps clean clan tag when username is clean", () => {
expect(checker.censor("Player", "COOL").clanTag).toBe("COOL");
expect(checker.censor("Player", "PRO").clanTag).toBe("PRO");
});
test("keeps clean clan tag, censors profane username", () => {
const result = checker.censor("nigger", "COOL");
expect(result.clanTag).toBe("COOL");
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censor("hitler", "NAZI");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag and censors leet speak username", () => {
const result = checker.censor("h1tl3r", "N4Z1");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag with slur, censors profane username", () => {
const result = checker.censor("nigger", "NIG");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
});
test("returns deterministic shadow name for same input", () => {
+320
View File
@@ -0,0 +1,320 @@
import { resolveCosmetics } from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
import { Cosmetics } from "../src/core/CosmeticSchemas";
const product = { productId: "prod_1", priceId: "price_1", price: "$4.99" };
function makeCosmetics(overrides: Partial<Cosmetics> = {}): Cosmetics {
return {
patterns: {},
flags: {},
colorPalettes: {},
...overrides,
} as Cosmetics;
}
function makeUserMe(flares: string[] = []): UserMeResponse {
return {
user: {},
player: {
publicId: "test",
flares,
achievements: { singleplayerMap: [] },
},
} as UserMeResponse;
}
describe("resolveCosmetics", () => {
test("returns empty array for null cosmetics", () => {
expect(resolveCosmetics(null, false, null)).toEqual([]);
});
test("always includes default pattern as first item, owned", () => {
const result = resolveCosmetics(makeCosmetics(), false, null);
expect(result[0]).toEqual({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
});
describe("patterns", () => {
const pattern = {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
rarity: "common",
colorPalettes: [
{ name: "red", isArchived: false },
{ name: "blue", isArchived: false },
],
};
const colorPalettes = {
red: { name: "red", primaryColor: "#ff0000", secondaryColor: "#000000" },
blue: {
name: "blue",
primaryColor: "#0000ff",
secondaryColor: "#ffffff",
},
};
test("expands pattern × colorPalettes + null palette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
// default + red + blue + null-palette
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(3);
expect(patternItems.map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
"pattern:stripes",
]);
});
test("resolves color palette from cosmetics.colorPalettes", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.colorPalette).toEqual(colorPalettes.red);
});
test("null palette entry has null colorPalette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const nullPaletteItem = result.find((r) => r.key === "pattern:stripes");
expect(nullPaletteItem?.colorPalette).toBeNull();
});
test("pattern with no colorPalettes produces single null-palette entry", () => {
const noPalettePattern = { ...pattern, colorPalettes: undefined };
const cosmetics = makeCosmetics({
patterns: { stripes: noPalettePattern as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(1);
expect(patternItems[0].key).toBe("pattern:stripes");
});
test("purchasable when user has no flares and product exists", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("owned when user has specific flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:red"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("owned when user has wildcard flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:*"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("blocked when affiliate code mismatch", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("blocked");
});
test("purchasable when affiliate code matches", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), "partner1");
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("archived palette is blocked unless owned", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("blocked");
});
test("archived palette is owned when user has specific flare", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:old"]),
null,
);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("owned");
});
});
describe("flags", () => {
const flag = {
type: "flag" as const,
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product,
rarity: "rare",
};
test("includes flags with correct key", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem).toBeDefined();
expect(flagItem?.cosmetic).toEqual(flag);
expect(flagItem?.colorPalette).toBeNull();
});
test("purchasable when not logged in and product exists", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("purchasable");
});
test("owned with wildcard flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(["flag:*"]), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("owned with specific flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["flag:cool_flag"]),
null,
);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("blocked with no product", () => {
const freeFlag = { ...flag, product: null };
const cosmetics = makeCosmetics({
flags: { cool_flag: freeFlag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("blocked");
});
});
describe("mixed cosmetics", () => {
test("returns all types in order: default, patterns, flags", () => {
const cosmetics = makeCosmetics({
patterns: {
stripes: {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
rarity: "common",
} as any,
},
flags: {
heart: {
type: "flag" as const,
name: "heart",
url: "/flags/heart.svg",
affiliateCode: null,
product,
rarity: "common",
} as any,
},
});
const result = resolveCosmetics(cosmetics, false, null);
const keys = result.map((r) => r.key);
expect(keys[0]).toBe("pattern:default");
expect(keys).toContain("pattern:stripes");
expect(keys).toContain("flag:heart");
// patterns come before flags
const patternIdx = keys.indexOf("pattern:stripes");
const flagIdx = keys.indexOf("flag:heart");
expect(patternIdx).toBeLessThan(flagIdx);
});
});
});
+88
View File
@@ -0,0 +1,88 @@
import newsItems from "../../../resources/news.json";
import {
getVisibleNewsItems,
NewsItem,
} from "../../../src/client/components/NewsBox";
const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const allItems = newsItems as NewsItem[];
function createMockLocalStorage(): Storage {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
get length() {
return Object.keys(store).length;
},
key: (index: number) => Object.keys(store)[index] ?? null,
};
}
describe("NewsBox", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createMockLocalStorage());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("getVisibleNewsItems", () => {
it("returns all items when none are dismissed", () => {
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(newsItems.length);
});
it("filters out dismissed items", () => {
const items = getVisibleNewsItems(allItems);
const firstId = items[0].id;
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([firstId]));
const filtered = getVisibleNewsItems(allItems);
expect(filtered.find((i) => i.id === firstId)).toBeUndefined();
expect(filtered.length).toBe(items.length - 1);
});
it("returns empty when all items are dismissed", () => {
const allIds = allItems.map((i) => i.id);
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify(allIds));
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(0);
});
});
describe("news items structure", () => {
it("each item has required fields", () => {
const items = getVisibleNewsItems(allItems);
for (const item of items) {
expect(item.id).toBeDefined();
expect(typeof item.id).toBe("string");
expect(item.title).toBeDefined();
expect(typeof item.title).toBe("string");
expect(item.description).toBeDefined();
expect(typeof item.description).toBe("string");
expect(item.type).toBeDefined();
expect(["tournament", "tutorial", "announcement"]).toContain(item.type);
}
});
it("each item has a unique id", () => {
const items = getVisibleNewsItems(allItems);
const ids = items.map((i) => i.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("contains a tournament entry", () => {
const items = getVisibleNewsItems(allItems);
expect(items.some((i) => i.type === "tournament")).toBe(true);
});
});
});
@@ -557,7 +557,11 @@ describe("RadialMenuElements", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBe(COLORS.building);
expect(
(cityElement!.color as (params: MenuElementParams) => string)(
mockParams,
),
).toBe(COLORS.building);
});
it("should use correct colors for attack elements", () => {
@@ -572,16 +576,24 @@ describe("RadialMenuElements", () => {
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement!.color).toBe(COLORS.attack);
expect(
(atomBombElement!.color as (params: MenuElementParams) => string)(
mockParams,
),
).toBe(COLORS.attack);
});
it("should not set color when element is disabled", () => {
it("should use disabled color when element is disabled", () => {
mockBuildMenu.canBuildOrUpgrade = vi.fn(() => false);
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBeUndefined();
expect(
(cityElement!.color as (params: MenuElementParams) => string)(
mockParams,
),
).toBe(COLORS.building);
});
});
+303
View File
@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock howler before importing SoundManager
const howlCtor = vi.fn();
const howlInstances: any[] = [];
let nextPlayId = 1;
vi.mock("howler", () => {
class MockHowl {
play = vi.fn(() => nextPlayId++);
stop = vi.fn((id?: number) => {
if (id !== undefined) {
this._fireEvent("stop", id);
}
});
volume = vi.fn();
playing = vi.fn().mockReturnValue(false);
unload = vi.fn();
once = vi.fn((event: string, callback: () => void, id?: number) => {
if (id !== undefined) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Map());
}
this._listeners.get(event)!.set(id, callback);
}
});
_listeners: Map<string, Map<number, () => void>> = new Map();
_fireEvent(event: string, id: number) {
const cb = this._listeners.get(event)?.get(id);
if (cb) {
cb();
this._listeners.get(event)?.delete(id);
}
}
constructor(_opts: any) {
howlCtor(_opts);
howlInstances.push(this);
}
}
return { Howl: MockHowl };
});
// Mock music imports
vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({
default: "of4.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({
default: "openfront.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({
default: "war.mp3",
}));
// Mock the Sounds module so tests don't depend on actual asset paths
vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/client/sound/Sounds")>();
return {
...actual,
soundEffectUrls: new Map([
["click", "mock/click.mp3"],
["atom-hit", "mock/atom-hit.mp3"],
["atom-launch", "mock/atom-launch.mp3"],
["hydrogen-hit", "mock/hydrogen-hit.mp3"],
["hydrogen-launch", "mock/hydrogen-launch.mp3"],
["mirv-launch", "mock/mirv-launch.mp3"],
["ka-ching", "mock/ka-ching.mp3"],
["message", "mock/message.mp3"],
["build-city", "mock/build-city.mp3"],
]),
};
});
import {
MAX_CONCURRENT_SOUNDS,
SoundManager,
} from "../../../src/client/sound/SoundManager";
import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../../src/client/sound/Sounds";
import { EventBus } from "../../../src/core/EventBus";
import { UserSettings } from "../../../src/core/game/UserSettings";
function createUserSettings(musicVolume = 0, sfxVolume = 1): UserSettings {
const settings = new UserSettings();
settings.setBackgroundMusicVolume(musicVolume);
settings.setSoundEffectsVolume(sfxVolume);
return settings;
}
describe("SoundManager", () => {
let eventBus: EventBus;
let userSettings: UserSettings;
let soundManager: SoundManager;
beforeEach(() => {
howlCtor.mockClear();
howlInstances.length = 0;
nextPlayId = 1;
eventBus = new EventBus();
userSettings = createUserSettings();
soundManager = new SoundManager(eventBus, userSettings);
});
it("lazy-loads a sound effect once and reuses it", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent("click"));
// 3 background music Howls + 1 Click Howl = 4
expect(howlCtor).toHaveBeenCalledTimes(4);
});
it("plays a sound effect when PlaySoundEffectEvent is emitted", () => {
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
const effectHowl = howlInstances[howlInstances.length - 1];
expect(effectHowl.play).toHaveBeenCalledTimes(1);
});
it("applies bootstrap volume from UserSettings to background music", () => {
const settings = createUserSettings(0.5, 1);
const bus = new EventBus();
howlCtor.mockClear();
howlInstances.length = 0;
new SoundManager(bus, settings);
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0.5);
});
});
it("applies current sfx volume to lazily-loaded sounds", () => {
const settings = createUserSettings(0, 0.3);
const bus = new EventBus();
howlCtor.mockClear();
howlInstances.length = 0;
new SoundManager(bus, settings);
bus.emit(new PlaySoundEffectEvent("click"));
expect(howlCtor).toHaveBeenLastCalledWith(
expect.objectContaining({ volume: 0.3 }),
);
});
it("responds to SetBackgroundMusicVolumeEvent", () => {
eventBus.emit(new SetBackgroundMusicVolumeEvent(0.7));
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0.7);
});
});
it("responds to SetSoundEffectsVolumeEvent", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.volume.mockClear();
eventBus.emit(new SetSoundEffectsVolumeEvent(0.4));
expect(clickHowl.volume).toHaveBeenCalledWith(0.4);
});
it("clamps volume values between 0 and 1", () => {
eventBus.emit(new SetBackgroundMusicVolumeEvent(2));
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(1);
});
bgHowls.forEach((h) => h.volume.mockClear());
eventBus.emit(new SetBackgroundMusicVolumeEvent(-0.5));
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0);
});
});
it("dispose() unsubscribes from EventBus so events no longer play sounds", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(1);
soundManager.dispose();
eventBus.emit(new PlaySoundEffectEvent("click"));
expect(clickHowl.play).toHaveBeenCalledTimes(1);
});
it("dispose() stops and unloads all loaded sound effects", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
soundManager.dispose();
expect(clickHowl.stop).toHaveBeenCalled();
expect(clickHowl.unload).toHaveBeenCalled();
});
it("dispose() stops and unloads background music", () => {
const bgHowls = howlInstances.slice(0, 3);
soundManager.dispose();
bgHowls.forEach((h) => {
expect(h.stop).toHaveBeenCalled();
expect(h.unload).toHaveBeenCalled();
});
});
it("does not throw when playSoundEffect is called directly", () => {
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
});
it("does not throw when playBackgroundMusic and stopBackgroundMusic are called", () => {
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
});
it("swallows errors from Howler and does not propagate", () => {
howlInstances.forEach((h) => {
h.play.mockImplementation(() => {
throw new Error("audio backend failure");
});
h.stop.mockImplementation(() => {
throw new Error("audio backend failure");
});
h.volume.mockImplementation(() => {
throw new Error("audio backend failure");
});
});
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.play.mockImplementation(() => {
throw new Error("audio backend failure");
});
clickHowl.stop.mockImplementation(() => {
throw new Error("audio backend failure");
});
clickHowl.volume.mockImplementation(() => {
throw new Error("audio backend failure");
});
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
expect(() => soundManager.setBackgroundMusicVolume(0.5)).not.toThrow();
expect(() => soundManager.setSoundEffectsVolume(0.5)).not.toThrow();
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
expect(() => soundManager.stopSoundEffect("click")).not.toThrow();
});
});
describe("Sound channel management", () => {
let eventBus: EventBus;
beforeEach(() => {
howlCtor.mockClear();
howlInstances.length = 0;
nextPlayId = 1;
eventBus = new EventBus();
new SoundManager(eventBus, createUserSettings());
});
it("new sound always plays even when at channel cap", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
const atomHowl = howlInstances[howlInstances.length - 1];
expect(atomHowl.play).toHaveBeenCalled();
});
it("stops the oldest sound when at channel cap", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
// The first play had id=1. Playing one more should stop id=1.
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
expect(clickHowl.stop).toHaveBeenCalledWith(1);
});
it("frees a channel when a sound ends naturally", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
// Simulate first sound ending naturally
clickHowl._fireEvent("end", 1);
// Next sound should play without stopping anything
clickHowl.stop.mockClear();
eventBus.emit(new PlaySoundEffectEvent("click"));
expect(clickHowl.stop).not.toHaveBeenCalled();
});
it("allows up to MAX_CONCURRENT_SOUNDS without stopping any", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(8);
// No stop calls with specific IDs (only general stop might be called)
expect(clickHowl.stop).not.toHaveBeenCalled();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { NukeExecution } from "../../src/core/execution/NukeExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../../src/core/game/Game";
import { TileRef } from "../../src/core/game/GameMap";
import { GameID } from "../../src/core/Schemas";
import { setup } from "../util/Setup";
import { constructionExecution } from "../util/utils";
const gameID: GameID = "game_id";
function launchNukeAt(game: Game, player: Player, target: TileRef): void {
game.addExecution(new NukeExecution(UnitType.AtomBomb, player, target, null));
// init + build
game.executeNextTick();
game.executeNextTick();
}
function tickUntilNukeLands(game: Game, maxTicks = 50): void {
for (let i = 0; i < maxTicks; i++) {
game.executeNextTick();
}
}
describe("Water Nukes", () => {
let game: Game;
let player: Player;
describe("when waterNukes is enabled", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
player = game.player(info.id);
// Build a missile silo
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
});
test("nuke converts land tiles to water instead of fallout", () => {
const target = game.ref(10, 10);
// Confirm target is land before nuke
expect(game.isLand(target)).toBe(true);
launchNukeAt(game, player, target);
tickUntilNukeLands(game);
// Target should now be water, not land
expect(game.isLand(target)).toBe(false);
expect(game.isWater(target)).toBe(true);
// Should NOT have fallout
expect(game.hasFallout(target)).toBe(false);
});
test("converted tiles get shoreline bits updated", () => {
const target = game.ref(10, 10);
launchNukeAt(game, player, target);
tickUntilNukeLands(game);
// With nukeMagnitudes { inner: 1, outer: 1 }, the target and its
// cardinal neighbors (dist² <= 1) are all converted to water.
// Shoreline tiles are the land tiles just outside the blast radius.
const x = game.x(target);
const y = game.y(target);
// 2 tiles away should still be land and now be shoreline
const outerNeighbors: TileRef[] = [];
if (game.isValidCoord(x - 2, y)) outerNeighbors.push(game.ref(x - 2, y));
if (game.isValidCoord(x + 2, y)) outerNeighbors.push(game.ref(x + 2, y));
if (game.isValidCoord(x, y - 2)) outerNeighbors.push(game.ref(x, y - 2));
if (game.isValidCoord(x, y + 2)) outerNeighbors.push(game.ref(x, y + 2));
for (const n of outerNeighbors) {
expect(game.isLand(n)).toBe(true);
expect(game.isShoreline(n)).toBe(true);
}
});
test("queueWaterConversion skips tiles conquered before flush", () => {
// Pick an unowned land tile and queue it for water conversion directly
const target = game.ref(10, 10);
expect(game.isLand(target)).toBe(true);
expect(game.hasOwner(target)).toBe(false);
// Queue the tile for water conversion (simulates nuke queueing)
game.queueWaterConversion(target);
// Another actor conquers the tile before the tick flushes the queue
player.conquer(target);
expect(game.hasOwner(target)).toBe(true);
// Flush: the pending conversion should be skipped because the tile is now owned
game.executeNextTick();
// Tile should remain land and owned
expect(game.isLand(target)).toBe(true);
expect(game.hasOwner(target)).toBe(true);
expect(game.isWater(target)).toBe(false);
});
test("waterGraphVersion increments after water conversion", async () => {
// Need a game with nav mesh enabled for graph rebuilds
const navGame = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
disableNavMesh: false,
});
const info2 = new PlayerInfo("p2", PlayerType.Human, null, "p2");
navGame.addPlayer(info2);
navGame.addExecution(
new SpawnExecution(gameID, info2, navGame.ref(1, 1)),
);
while (navGame.inSpawnPhase()) navGame.executeNextTick();
const player2 = navGame.player(info2.id);
constructionExecution(navGame, player2, 1, 1, UnitType.MissileSilo);
const versionBefore = navGame.waterGraphVersion();
// Launch multiple nukes in a cluster to ensure enough tiles convert
// for at least one minimap tile to flip (need >= 3 of 4 source tiles)
const target = navGame.ref(50, 50);
navGame.addExecution(
new NukeExecution(UnitType.AtomBomb, player2, target, null),
);
// Tick enough for nuke to land + graph rebuild throttle (20 ticks)
for (let i = 0; i < 80; i++) navGame.executeNextTick();
expect(navGame.waterGraphVersion()).toBeGreaterThan(versionBefore);
});
});
describe("when waterNukes is disabled (default)", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: false,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
player = game.player(info.id);
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
});
test("nuke applies fallout instead of converting to water", () => {
const target = game.ref(10, 10);
expect(game.isLand(target)).toBe(true);
launchNukeAt(game, player, target);
tickUntilNukeLands(game);
// Should remain land with fallout
expect(game.isLand(target)).toBe(true);
expect(game.hasFallout(target)).toBe(true);
});
test("waterGraphVersion does not change", () => {
const versionBefore = game.waterGraphVersion();
const target = game.ref(10, 10);
launchNukeAt(game, player, target);
tickUntilNukeLands(game);
expect(game.waterGraphVersion()).toBe(versionBefore);
});
});
describe("updateTile terrain byte round-trip", () => {
test("terrain byte is packed and unpacked correctly", async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
player = game.player(info.id);
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
const target = game.ref(10, 10);
const terrainBefore = game.terrainByte(target);
expect(game.isLand(target)).toBe(true);
launchNukeAt(game, player, target);
tickUntilNukeLands(game);
const terrainAfter = game.terrainByte(target);
// Terrain should have changed (was land, now water)
expect(terrainAfter).not.toBe(terrainBefore);
expect(game.isWater(target)).toBe(true);
});
});
});
+48 -38
View File
@@ -44,50 +44,56 @@ app.get("/api/maps", (req: Request, res: Response) => {
* GET /api/maps/:name
* Get map metadata (map data, dimensions)
*/
app.get("/api/maps/:name", async (req: Request, res: Response) => {
try {
const { name } = req.params;
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);
app.get(
"/api/maps/:name",
async (req: Request<{ name: string }>, res: Response) => {
try {
const { name } = req.params;
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);
if (error instanceof Error && error.message.includes("ENOENT")) {
res.status(404).json({
error: "Map not found",
message: `Map "${req.params.name}" does not exist`,
});
} else {
res.status(500).json({
error: "Failed to load map",
message: error instanceof Error ? error.message : String(error),
});
if (error instanceof Error && error.message.includes("ENOENT")) {
res.status(404).json({
error: "Map not found",
message: `Map "${req.params.name}" does not exist`,
});
} else {
res.status(500).json({
error: "Failed to load map",
message: error instanceof Error ? error.message : String(error),
});
}
}
}
});
},
);
/**
* GET /api/maps/:name/thumbnail
* Get map thumbnail image
*/
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
try {
const { name } = req.params;
const thumbnailPath = join(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
name,
"thumbnail.webp",
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
res.status(404).json({
error: "Thumbnail not found",
message: error instanceof Error ? error.message : String(error),
});
}
});
app.get(
"/api/maps/:name/thumbnail",
(req: Request<{ name: string }>, res: Response) => {
try {
const { name } = req.params;
const thumbnailPath = join(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
name,
"thumbnail.webp",
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
res.status(404).json({
error: "Thumbnail not found",
message: error instanceof Error ? error.message : String(error),
});
}
},
);
/**
* POST /api/pathfind
@@ -237,7 +243,11 @@ app.use((err: Error, req: Request, res: Response, next: any) => {
});
// Start server
app.listen(PORT, () => {
app.listen(PORT, (error?: Error) => {
if (error) {
console.error("Failed to start server", error);
process.exit(1);
}
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Pathfinding Playground Server ║
+6 -5
View File
@@ -101,11 +101,12 @@ export function getAdapter(
originalGame._stats,
);
(clonedGame as any)._miniWaterHPA = new AStarWaterHierarchical(
clonedGame.miniMap(),
(clonedGame as any)._miniWaterGraph!,
{ cachePaths: false },
);
(clonedGame as any)._waterManager._miniWaterHPA =
new AStarWaterHierarchical(
clonedGame.miniMap(),
(clonedGame as any)._waterManager._miniWaterGraph!,
{ cachePaths: false },
);
return PathFinding.Water(clonedGame);
}
+223 -14
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import { normalizeAssetPath } from "../../src/core/AssetUrls";
import {
buildPublicAssetManifest,
clearPublicAssetManifestCache,
@@ -11,6 +12,80 @@ import {
describe("PublicAssetManifest", () => {
let tempDir: string | null = null;
type TempResources = {
resourcesDir: string;
outDir: string;
};
async function createTempResources(): Promise<TempResources> {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-"));
const resourcesDir = path.join(tempDir, "resources");
const outDir = path.join(tempDir, "static");
await fs.mkdir(resourcesDir, { recursive: true });
await fs.writeFile(path.join(resourcesDir, "manifest.json"), "{}\n");
return { resourcesDir, outDir };
}
function getExpectedRelativeEmittedPath(
fromAssetHref: string,
targetAssetHref: string,
): string {
const fromDir = path.posix.dirname(normalizeAssetPath(fromAssetHref));
const targetPath = normalizeAssetPath(targetAssetHref);
return path.posix.relative(fromDir, targetPath);
}
async function writeBitmapFontFixture(
resourcesDir: string,
xmlRelativePath: string,
pageFilePath: string,
pageContent: string = "png-v1",
): Promise<void> {
const xmlPath = path.join(resourcesDir, xmlRelativePath);
const pagePath = path.join(path.dirname(xmlPath), pageFilePath);
const xmlPageFilePath = pageFilePath.split(path.sep).join(path.posix.sep);
await fs.mkdir(path.dirname(pagePath), { recursive: true });
await fs.writeFile(
xmlPath,
[
'<?xml version="1.0"?>',
"<font>",
` <pages><page id="0" file="${xmlPageFilePath}"/></pages>`,
"</font>",
"",
].join("\n"),
);
await fs.writeFile(pagePath, pageContent);
}
async function emitHashedAsset(
outDir: string,
assetHref: string,
): Promise<string> {
return fs.readFile(
path.join(outDir, normalizeAssetPath(assetHref)),
"utf8",
);
}
async function writeWebManifestFixture(
resourcesDir: string,
icons: Array<{ src?: string }>,
): Promise<void> {
await fs.writeFile(
path.join(resourcesDir, "manifest.json"),
JSON.stringify(
{
name: "OpenFront",
icons,
},
null,
2,
),
);
}
afterEach(async () => {
clearPublicAssetManifestCache();
if (tempDir) {
@@ -20,22 +95,12 @@ describe("PublicAssetManifest", () => {
});
test("hashes manifest.json from its rewritten content", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-"));
const resourcesDir = path.join(tempDir, "resources");
const outDir = path.join(tempDir, "static");
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await fs.writeFile(
path.join(resourcesDir, "manifest.json"),
JSON.stringify(
{
name: "OpenFront",
icons: [{ src: "icons/app-icon.png" }],
},
null,
2,
),
);
await writeWebManifestFixture(resourcesDir, [
{ src: "icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
@@ -68,4 +133,148 @@ describe("PublicAssetManifest", () => {
expect(firstOutput).toContain(firstIconHref);
expect(firstOutput).not.toContain(secondIconHref);
});
test("rewrites root-relative web manifest icon paths to hashed URLs", async () => {
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await writeWebManifestFixture(resourcesDir, [
{ src: "/icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
"utf8",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain(assetManifest["icons/app-icon.png"]);
expect(emittedManifest).not.toContain('"/icons/app-icon.png"');
});
test("fails when web manifest references a missing local icon", async () => {
const { resourcesDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]);
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
/manifest\.json references icons\/missing\.png/i,
);
});
test("leaves external and data web manifest icon refs unchanged", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [
{ src: "https://cdn.example.com/app-icon.png" },
{ src: "data:image/png;base64,AAA" },
]);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain("https://cdn.example.com/app-icon.png");
expect(emittedManifest).toContain("data:image/png;base64,AAA");
});
test("rewrites BMFont XML page filenames to hashed relative paths", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const xmlHref = assetManifest["fonts/test.xml"];
const pngHref = assetManifest["fonts/test.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="test.png"');
});
test("BMFont XML hash changes when a referenced page image changes", async () => {
const { resourcesDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const firstManifest = buildPublicAssetManifest(resourcesDir);
await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2");
clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest(resourcesDir);
expect(firstManifest["fonts/test.png"]).not.toBe(
secondManifest["fonts/test.png"],
);
expect(firstManifest["fonts/test.xml"]).not.toBe(
secondManifest["fonts/test.xml"],
);
});
test("fails when BMFont XML references a missing page image", async () => {
const { resourcesDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "fonts"), { recursive: true });
await fs.writeFile(
path.join(resourcesDir, "fonts", "broken.xml"),
[
'<?xml version="1.0"?>',
"<font>",
' <pages><page id="0" file="missing.png"/></pages>',
"</font>",
"",
].join("\n"),
);
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
/missing from the asset manifest/i,
);
});
test("rewrites nested BMFont page references to the correct relative hashed path", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "nested", "atlas.xml"),
path.join("pages", "p0.png"),
"nested-png",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const xmlHref = assetManifest["fonts/nested/atlas.xml"];
const pngHref = assetManifest["fonts/nested/pages/p0.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="pages/p0.png"');
});
});