mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
Merge branch 'main' into team-names
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ║
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user