Merge branch 'main' into embeddedurlfix

This commit is contained in:
Ryan
2026-03-06 16:45:26 +00:00
committed by GitHub
308 changed files with 17928 additions and 6135 deletions
+14 -19
View File
@@ -1,4 +1,4 @@
import { AllianceRequestReplyExecution } from "src/core/execution/alliance/AllianceRequestReplyExecution";
import { AllianceRequestExecution } from "src/core/execution/alliance/AllianceRequestExecution";
import { GameUpdateType } from "src/core/game/GameUpdates";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import {
@@ -69,12 +69,10 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player2.createAllianceRequest(player1);
game.addExecution(
new AllianceRequestReplyExecution(player2.id(), player1, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick(); // creates request
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick(); // counter-request auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
@@ -100,12 +98,11 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player1.createAllianceRequest(player2);
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
// Both requests added in same tick so the nuke tick can't revoke the first
// before the counter-request sees it.
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick(); // both init: first creates request, second auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
@@ -137,12 +134,10 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player2.createAllianceRequest(player1);
game.addExecution(
new AllianceRequestReplyExecution(player2.id(), player1, true),
);
const updates = game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick(); // creates request
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
const updates = game.executeNextTick(); // counter-request auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
+3 -10
View File
@@ -1,5 +1,4 @@
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution";
import { Game, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -44,9 +43,7 @@ describe("Alliance Donation", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
@@ -65,9 +62,7 @@ describe("Alliance Donation", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
@@ -121,9 +116,7 @@ describe("Alliance Donation", () => {
game.executeNextTick();
const goldBefore = player2.gold();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.addExecution(new DonateGoldExecution(player1, player2.id(), 100));
game.executeNextTick();
+6 -16
View File
@@ -1,6 +1,5 @@
import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { Game, MessageType, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -36,17 +35,14 @@ describe("AllianceExtensionExecution", () => {
test("Successfully extends existing alliance between Humans", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeTruthy();
@@ -83,17 +79,14 @@ describe("AllianceExtensionExecution", () => {
test("Successfully extends existing alliance between Human and non-Human", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player3, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player3, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(player1, player3.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player3, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player3, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player3)).toBeTruthy();
@@ -121,18 +114,15 @@ describe("AllianceExtensionExecution", () => {
test("Sends message to other player when one player requests renewal", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
// Create alliance between player1 and player2
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeTruthy();
+14 -16
View File
@@ -1,5 +1,5 @@
import { AllianceRejectExecution } from "../src/core/execution/alliance/AllianceRejectExecution";
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { Game, Player, PlayerType, UnitType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -36,21 +36,7 @@ describe("AllianceRequestExecution", () => {
}
});
test("Can create alliance by replying", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
});
test("Can create alliance by sending alliance request back", () => {
test("Can create alliance by counter-request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
@@ -61,6 +47,18 @@ describe("AllianceRequestExecution", () => {
expect(player2.isAlliedWith(player1)).toBeTruthy();
});
test("Can reject alliance request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(new AllianceRejectExecution(player1.id(), player2));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeFalsy();
expect(player2.isAlliedWith(player1)).toBeFalsy();
expect(player1.outgoingAllianceRequests().length).toBe(0);
});
test("Alliance request expires", () => {
game.config().allianceRequestDuration = () => 5;
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+27 -6
View File
@@ -183,7 +183,7 @@ describe("Attack race condition with alliance requests", () => {
null,
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10));
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11));
while (game.inSpawnPhase()) {
game.executeNextTick();
@@ -224,6 +224,9 @@ describe("Attack race condition with alliance requests", () => {
game.executeNextTick();
}
expect(playerA.isAlive()).toBe(true);
expect(playerB.isAlive()).toBe(true);
// Player A should not be marked as traitor because the alliance was formed after the attack started
expect(playerA.isTraitor()).toBe(false);
@@ -391,17 +394,17 @@ describe("Attack immunity", () => {
test("Ensure a player can't attack during all the immunity phase", async () => {
// Execute a few ticks but stop right before the immunity phase is over
for (let i = 0; i < immunityPhaseTicks - 1; i++) {
for (let i = 0; i < immunityPhaseTicks - 2; i++) {
game.executeNextTick();
}
// Player A attacks Player B
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick(); // ticks === immunityPhaseTicks here
game.executeNextTick(); // ticks === immunityPhaseTicks - 1 here
// Attack is not possible during immunity
expect(playerA.outgoingAttacks()).toHaveLength(0);
// Retry after the immunity is over
game.executeNextTick(); // ticks === immunityPhaseTicks + 1
game.executeNextTick(); // ticks === immunityPhaseTicks
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick();
// Attack is now possible right after
@@ -423,11 +426,29 @@ describe("Attack immunity", () => {
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
});
test("Should be able to attack nations during immunity phase", async () => {
test("Should not be able to attack nations during nation immunity phase", async () => {
(game.config() as TestConfig).setNationSpawnImmunityDuration(
immunityPhaseTicks,
);
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
// Player A attacks the nation
// Player A attacks the nation during nation immunity
const attackExecution = new AttackExecution(null, playerA, nationId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(0);
});
test("Should be able to attack nations after nation immunity phase", async () => {
(game.config() as TestConfig).setNationSpawnImmunityDuration(
immunityPhaseTicks,
);
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
waitForImmunityToEnd();
// Player A attacks the nation after immunity
const attackExecution = new AttackExecution(null, playerA, nationId, null);
game.addExecution(attackExecution);
game.executeNextTick();
-69
View File
@@ -1,30 +1,3 @@
// Mocking the obscenity library to control its behavior in tests.
vi.mock("obscenity", () => {
return {
RegExpMatcher: class {
private dummy: string[] = ["foo", "bar", "leet", "code"];
constructor(_opts: any) {}
hasMatch(input: string): boolean {
const lower = input.toLowerCase();
const decoded = lower
.replace(/4/g, "a")
.replace(/3/g, "e")
.replace(/1/g, "i")
.replace(/0/g, "o")
.replace(/5/g, "s")
.replace(/7/g, "t");
return this.dummy.some((token) => decoded.includes(token));
}
},
collapseDuplicatesTransformer: () => ({}),
englishRecommendedTransformers: {},
englishDataset: { build: () => ({}) },
resolveConfusablesTransformer: () => ({}),
resolveLeetSpeakTransformer: () => ({}),
skipNonAlphabeticTransformer: () => ({}),
};
});
// Mocks the output of translation functions to return predictable values.
vi.mock("../src/client/Utils", () => ({
translateText: (key: string, vars?: any) =>
@@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({
}));
import {
fixProfaneUsername,
isProfaneUsername,
MAX_USERNAME_LENGTH,
validateUsername,
} from "../src/core/validations/username";
describe("username.ts functions", () => {
const shadowNames = [
"NicePeopleOnly",
"BeKindPlz",
"LearningManners",
"StayClassy",
"BeNicer",
"NeedHugs",
"MakeFriends",
];
describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => {
test.each([
{ username: "l33t", profane: true }, // decodes to "leet"
{ username: "L33T", profane: true },
{ username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code"
{ username: "L33TC0DE", profane: true },
{ username: "foo123", profane: true }, // contains "foo"
{ username: "b4r", profane: true }, // decodes to "bar"
{ username: "safeName", profane: false },
{ username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list
])('isProfaneUsername("%s") → %s', ({ username, profane }) => {
expect(isProfaneUsername(username)).toBe(profane);
});
test.each([
{ username: "safeName" },
{ username: "l33t" },
{ username: "b4rUser" },
])('fixProfaneUsername("%s") behavior', ({ username }) => {
const profane = isProfaneUsername(username);
const fixed = fixProfaneUsername(username);
if (!profane) {
expect(fixed).toBe(username);
} else {
// When profane: result should be one of shadowNames
expect(shadowNames).toContain(fixed);
}
});
});
describe("validateUsername", () => {
test("rejects non-string", () => {
// @ts-expect-error: Testing non-string input to validateUsername on purpose
+7 -1
View File
@@ -373,7 +373,7 @@ describe("Disconnected", () => {
expect(game.owner(enemyShoreTile)).toBe(player1);
});
test("Captured transport ship should retreat to owner's shore tile", () => {
test("Captured transport ship should retreat to closest owner shore tile", () => {
player1.conquer(game.map().ref(coastX, 4));
player2.conquer(game.map().ref(coastX, 1));
@@ -397,9 +397,15 @@ describe("Disconnected", () => {
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
const expectedRetreatTile = player1.bestTransportShipSpawn(
transportShip.tile(),
);
expect(expectedRetreatTile).not.toBe(false);
transportShip.orderBoatRetreat();
executeTicks(game, 2);
expect(transportShip.targetTile()).toBe(expectedRetreatTile);
expect(transportShip.targetTile()).not.toBe(enemyShoreTile);
expect(game.owner(transportShip.targetTile()!)).toBe(player1);
});
+3 -3
View File
@@ -9,7 +9,7 @@ import {
GameMode,
GameType,
} from "../src/core/game/Game";
import { AnalyticsRecord } from "../src/core/Schemas";
import { AnalyticsRecord, GameConfig } from "../src/core/Schemas";
import {
GOLD_INDEX_STEAL,
GOLD_INDEX_TRADE,
@@ -19,7 +19,7 @@ import {
} from "../src/core/StatsSchemas";
describe("Ranking class", () => {
const mockConfig = {
const mockConfig: GameConfig = {
gameMap: GameMapType.Montreal,
difficulty: Difficulty.Medium,
donateGold: false,
@@ -27,7 +27,7 @@ describe("Ranking class", () => {
gameType: GameType.Public,
gameMode: GameMode.FFA,
gameMapSize: GameMapSize.Normal,
disableNations: true,
nations: "disabled",
bots: 0,
infiniteGold: false,
infiniteTroops: false,
+1
View File
@@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => {
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
},
mockCanvas,
eventBus,
-62
View File
@@ -1,62 +0,0 @@
import fs from "fs";
import path from "path";
describe("Lang Metadata Check", () => {
const langDir = path.join(__dirname, "../resources/lang");
const flagDir = path.join(__dirname, "../resources/flags");
const metadataFile = path.join(langDir, "metadata.json");
test("metadata languages point to existing lang json and flag files", () => {
if (!fs.existsSync(metadataFile)) {
console.log(
"No resources/lang/metadata.json file found. Skipping check.",
);
return;
}
const metadata = JSON.parse(fs.readFileSync(metadataFile, "utf-8"));
if (!Array.isArray(metadata) || metadata.length === 0) {
console.log(
"No language entries found in metadata.json. Skipping check.",
);
return;
}
const errors: string[] = [];
for (const entry of metadata) {
const code = entry?.code;
const svg = entry?.svg;
if (typeof code !== "string" || code.length === 0) {
errors.push(
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
);
continue;
}
if (typeof svg !== "string" || svg.length === 0) {
errors.push(
`[${code}]: metadata svg is missing or not a non-empty string`,
);
continue;
}
const langFilePath = path.join(langDir, `${code}.json`);
if (!fs.existsSync(langFilePath)) {
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
}
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
const flagPath = path.join(flagDir, svgFile);
if (!fs.existsSync(flagPath)) {
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
}
}
if (errors.length > 0) {
console.error(
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
);
expect(errors).toEqual([]);
}
});
});
+16 -1
View File
@@ -51,6 +51,10 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
player,
new NationEmojiBehavior(random, game, player),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
function setupAllianceRequest({
@@ -59,6 +63,7 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
numTilesPlayer = 10,
numTilesRequestor = 10,
alliancesCount = 0,
createdAtTick = game.ticks() + 1,
} = {}) {
if (isTraitor) requestor.markTraitor();
@@ -82,7 +87,7 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
const mockRequest = {
requestor: () => requestor,
recipient: () => player,
createdAt: () => 0 as unknown as Tick,
createdAt: () => createdAtTick as unknown as Tick,
accept: vi.fn(),
reject: vi.fn(),
} as unknown as AllianceRequest;
@@ -92,6 +97,16 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
return mockRequest;
}
test("should reject alliance created on first post-spawn tick", () => {
const cutoff = game.config().numSpawnPhaseTurns() + 1;
const request = setupAllianceRequest({ createdAtTick: cutoff });
allianceBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should accept alliance when all conditions are met", () => {
const request = setupAllianceRequest({});
+29
View File
@@ -1,4 +1,5 @@
import { MirvExecution } from "../src/core/execution/MIRVExecution";
import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution";
import { NationExecution } from "../src/core/execution/NationExecution";
import {
Cell,
@@ -64,6 +65,11 @@ describe("Nation MIRV Retaliation", () => {
}
}
nation.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {});
// Register MissileSiloExecution so the silo can reload after firing
const nationSilo = nation.units(UnitType.MissileSilo)[0];
if (nationSilo) {
game.addExecution(new MissileSiloExecution(nationSilo));
}
// Give both players enough gold for MIRVs
attacker.addGold(1_000_000_000n);
@@ -85,6 +91,8 @@ describe("Nation MIRV Retaliation", () => {
let retaliationAttempted = false;
for (const gameId of gameIds) {
// Advance game to clear any silo cooldowns from previous iteration
executeTicks(game, 100);
const testExecution = new NationExecution(gameId, testExecutionNation);
testExecution.init(game);
@@ -197,6 +205,11 @@ describe("Nation MIRV Retaliation", () => {
const nationTile = Array.from(nation.tiles())[0];
if (nationTile) {
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
// Register MissileSiloExecution so the silo can reload after firing
const silo = nation.units(UnitType.MissileSilo)[0];
if (silo) {
game.addExecution(new MissileSiloExecution(silo));
}
}
// Then give dominant player a large amount of territory
@@ -253,6 +266,8 @@ describe("Nation MIRV Retaliation", () => {
let victoryDenialSuccessful = false;
for (const gameId of gameIds) {
// Advance game to clear any silo cooldowns from previous iteration
executeTicks(game, 100);
const testExecution = new NationExecution(gameId, testExecutionNation);
testExecution.init(game);
@@ -347,6 +362,11 @@ describe("Nation MIRV Retaliation", () => {
const nationTile = Array.from(nation.tiles())[0];
if (nationTile) {
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
// Register MissileSiloExecution so the silo can reload after firing
const silo = nation.units(UnitType.MissileSilo)[0];
if (silo) {
game.addExecution(new MissileSiloExecution(silo));
}
}
// Give second player some territory and cities
@@ -406,6 +426,8 @@ describe("Nation MIRV Retaliation", () => {
let steamrollStopSuccessful = false;
for (const gameId of gameIds) {
// Advance game to clear any silo cooldowns from previous iteration
executeTicks(game, 100);
const testExecution = new NationExecution(gameId, testExecutionNation);
testExecution.init(game);
@@ -631,6 +653,11 @@ describe("Nation MIRV Retaliation", () => {
const nationTile = Array.from(nation.tiles())[0];
if (nationTile) {
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
// Register MissileSiloExecution so the silo can reload after firing
const silo = nation.units(UnitType.MissileSilo)[0];
if (silo) {
game.addExecution(new MissileSiloExecution(silo));
}
}
// Give team players a large amount of territory to exceed team threshold,
@@ -691,6 +718,8 @@ describe("Nation MIRV Retaliation", () => {
let teamVictoryDenialSuccessful = false;
for (const gameId of gameIds) {
// Advance game to clear any silo cooldowns from previous iteration
executeTicks(game, 100);
const testExecution = new NationExecution(gameId, testExecutionNation);
testExecution.init(game);
+128
View File
@@ -0,0 +1,128 @@
import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution";
import { NationExecution } from "../src/core/execution/NationExecution";
import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution";
import {
Cell,
Difficulty,
Nation,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { setup } from "./util/Setup";
import { executeTicks } from "./util/utils";
describe("NationNukeBehavior - maybeDestroyEnemySam", () => {
test("nation overwhelms enemy SAM with atom bomb salvo on Impossible difficulty", async () => {
// Impossible difficulty with 2 players forces findBestNukeTarget to
// return the human. The SAM covers all human territory so every nuke
// trajectory is interceptable, keeping bestValue ≤ 0 and triggering
// maybeDestroyEnemySam.
const game = await setup("big_plains", {
difficulty: Difficulty.Impossible,
infiniteGold: true,
instantBuild: true,
});
const nationInfo = new PlayerInfo(
"nation",
PlayerType.Nation,
null,
"nation_id",
);
const humanInfo = new PlayerInfo(
"human",
PlayerType.Human,
null,
"human_id",
);
game.addPlayer(nationInfo);
game.addPlayer(humanInfo);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const nation = game.player("nation_id");
const human = game.player("human_id");
// Assign territory blocks (30×30 each, well separated)
for (let x = 10; x < 40; x++) {
for (let y = 10; y < 40; y++) {
const tile = game.ref(x, y);
if (game.map().isLand(tile)) nation.conquer(tile);
}
}
for (let x = 60; x < 90; x++) {
for (let y = 60; y < 90; y++) {
const tile = game.ref(x, y);
if (game.map().isLand(tile)) human.conquer(tile);
}
}
// Level-1 SAM at center of human territory (samRange = 20 in TestConfig,
// covering the entire 60-90 block and intercepting all trajectories).
const samTile = game.ref(75, 75);
const sam = human.buildUnit(UnitType.SAMLauncher, samTile, {});
game.addExecution(new SAMLauncherExecution(human, null, sam));
// 3 level-1 missile silos (1 slot each). Overwhelming a level-1 SAM
// requires 2 bombs (1 intercepted + 1 passes through).
for (const [x, y] of [
[20, 20],
[25, 25],
[30, 30],
] as const) {
const silo = nation.buildUnit(UnitType.MissileSilo, game.ref(x, y), {});
game.addExecution(new MissileSiloExecution(silo));
}
// infiniteGold only applies to Human players, so the nation needs gold
nation.addGold(1_000_000_000n);
nation.addTroops(100_000);
human.addTroops(100_000);
expect(nation.units(UnitType.MissileSilo)).toHaveLength(3);
expect(human.units(UnitType.SAMLauncher)).toHaveLength(1);
expect(nation.units(UnitType.AtomBomb)).toHaveLength(0);
// Try multiple game IDs to account for random attack-tick alignment
// (attackRate ∈ [30,50] on Impossible). 150 inner ticks guarantees ≥2
// attack ticks for the worst-case seed: 1st initializes behaviors, 2nd
// fires maybeSendNuke → maybeDestroyEnemySam.
const testNation = new Nation(new Cell(25, 25), nation.info());
let salvoLaunched = false;
for (let i = 0; i < 10 && !salvoLaunched; i++) {
// Let any executions from a prior iteration settle
if (i > 0) executeTicks(game, 50);
const exec = new NationExecution(`game_${i}`, testNation);
exec.init(game);
for (let tick = 0; tick < 150; tick++) {
exec.tick(tick);
// Advance the game sparingly so NukeExecution creates atom-bomb units
// but they don't complete their flight before we detect them.
if (tick % 10 === 0) game.executeNextTick();
if (nation.units(UnitType.AtomBomb).length > 0) {
salvoLaunched = true;
break;
}
}
}
expect(salvoLaunched).toBe(true);
// At least 2 atom bombs to overwhelm the level-1 SAM
const atomBombs = nation.units(UnitType.AtomBomb);
expect(atomBombs.length).toBeGreaterThanOrEqual(2);
// All bombs should target the SAM tile
for (const bomb of atomBombs) {
expect(bomb.targetTile()).toBe(samTile);
}
});
});
+357
View File
@@ -0,0 +1,357 @@
import { vi } from "vitest";
import { NationStructureBehavior } from "../src/core/execution/nation/NationStructureBehavior";
import { Difficulty, PlayerType } from "../src/core/game/Game";
import { Cluster } from "../src/core/game/TrainStation";
import { PseudoRandom } from "../src/core/PseudoRandom";
// ── Fixed trade-gold values matching DefaultConfig ──────────────────────────
const TRAIN_GOLD: Record<string, bigint> = {
self: 10_000n,
team: 25_000n,
ally: 35_000n,
other: 25_000n,
};
const MAX_TRADE_GOLD = Number(TRAIN_GOLD.ally); // denominator
// ── Factory helpers ──────────────────────────────────────────────────────────
function makeUnit(tile: number): any {
return { tile: () => tile };
}
function makeStation(unit: any, cluster: Cluster | null = null): any {
return { unit, getCluster: () => cluster };
}
function makeGame(stations: any[] = []): any {
return {
config: () => ({
trainGold: (rel: string) => TRAIN_GOLD[rel] ?? 0n,
}),
railNetwork: () => ({
stationManager: () => ({ getAll: () => new Set(stations) }),
}),
};
}
function makePlayer(
ownUnits: any[],
neighborList: any[],
opts: {
canTrade?: (n: any) => boolean;
isOnSameTeam?: (n: any) => boolean;
isAlliedWith?: (n: any) => boolean;
} = {},
): any {
return {
units: vi.fn(() => ownUnits),
neighbors: vi.fn(() => neighborList),
canTrade: vi.fn((n: any) => opts.canTrade?.(n) ?? true),
isOnSameTeam: vi.fn((n: any) => opts.isOnSameTeam?.(n) ?? false),
isAlliedWith: vi.fn((n: any) => opts.isAlliedWith?.(n) ?? false),
};
}
function makeNeighbor(
opts: {
isPlayer?: boolean;
type?: PlayerType;
units?: any[];
} = {},
): any {
return {
isPlayer: () => opts.isPlayer ?? true,
type: () => opts.type ?? PlayerType.Human,
units: vi.fn(() => opts.units ?? []),
};
}
function makeBehavior(
game: any,
player: any,
random: PseudoRandom = new PseudoRandom(0),
): NationStructureBehavior {
return new NationStructureBehavior(random, game, player);
}
// ── shouldUseConnectivityScore ───────────────────────────────────────────────
describe("NationStructureBehavior.shouldUseConnectivityScore", () => {
afterEach(() => {
vi.restoreAllMocks();
});
function behaviorWithNextInt(returnValue: number): {
behavior: NationStructureBehavior;
random: PseudoRandom;
} {
const random = new PseudoRandom(0);
vi.spyOn(random, "nextInt").mockReturnValue(returnValue);
const behavior = makeBehavior(makeGame(), makePlayer([], []), random);
return { behavior, random };
}
it("always returns false for Easy (randomChance = 0)", () => {
for (const v of [0, 50, 99]) {
const { behavior, random } = behaviorWithNextInt(v);
vi.spyOn(random, "nextInt").mockReturnValue(v);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Easy),
).toBe(false);
}
});
it("returns true for Medium when nextInt < 60", () => {
const { behavior } = behaviorWithNextInt(59);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
).toBe(true);
});
it("returns false for Medium when nextInt === 60 (boundary)", () => {
const { behavior } = behaviorWithNextInt(60);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
).toBe(false);
});
it("returns true for Hard when nextInt < 75", () => {
const { behavior } = behaviorWithNextInt(74);
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
true,
);
});
it("returns false for Hard when nextInt === 75 (boundary)", () => {
const { behavior } = behaviorWithNextInt(75);
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
false,
);
});
it("always returns true for Impossible (randomChance = 100)", () => {
for (const v of [0, 50, 99]) {
const { behavior, random } = behaviorWithNextInt(v);
vi.spyOn(random, "nextInt").mockReturnValue(v);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Impossible),
).toBe(true);
}
});
});
// ── buildReachableStations ───────────────────────────────────────────────────
describe("NationStructureBehavior.buildReachableStations", () => {
const selfWeight = Number(TRAIN_GOLD.self) / MAX_TRADE_GOLD;
const allyWeight = Number(TRAIN_GOLD.ally) / MAX_TRADE_GOLD;
const teamWeight = Number(TRAIN_GOLD.team) / MAX_TRADE_GOLD;
const otherWeight = Number(TRAIN_GOLD.other) / MAX_TRADE_GOLD;
it("includes own registered units with self weight and correct cluster", () => {
const cluster = new Cluster();
const unit = makeUnit(10);
const station = makeStation(unit, cluster);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].tile).toBe(10);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(selfWeight);
});
it("assigns null cluster when own unit is a station with no cluster", () => {
const unit = makeUnit(11);
const station = makeStation(unit, null);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].cluster).toBeNull();
expect(result[0].weight).toBeCloseTo(selfWeight);
});
it("excludes own units not registered in the station manager", () => {
const unit = makeUnit(20);
// No stations in station manager
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes bot neighbors", () => {
const unit = makeUnit(30);
const station = makeStation(unit, null);
const bot = makeNeighbor({ type: PlayerType.Bot, units: [unit] });
const player = makePlayer([], [bot]);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes non-player neighbors", () => {
const unit = makeUnit(40);
const station = makeStation(unit, null);
const nonPlayer = makeNeighbor({ isPlayer: false, units: [unit] });
const player = makePlayer([], [nonPlayer]);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes embargoed (canTrade = false) neighbors", () => {
const unit = makeUnit(50);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], { canTrade: () => false });
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("includes non-embargoed neutral neighbor with 'other' weight", () => {
const unit = makeUnit(60);
const cluster = new Cluster();
const station = makeStation(unit, cluster);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: () => false,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].tile).toBe(60);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(otherWeight);
});
it("uses 'ally' weight for allied neighbor", () => {
const unit = makeUnit(70);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: () => false,
isAlliedWith: (n) => n === neighbor,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(allyWeight);
});
it("uses 'team' weight for team neighbor (team check precedes ally)", () => {
const unit = makeUnit(80);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: (n) => n === neighbor,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(teamWeight);
});
it("excludes neighbor units not registered in the station manager", () => {
const unit = makeUnit(90);
// Station manager has no stations, so unit is unknown
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor]);
const behavior = makeBehavior(makeGame([]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("collects own and neighbor units together", () => {
const ownUnit = makeUnit(100);
const ownStation = makeStation(ownUnit, null);
const neighborUnit = makeUnit(200);
const neighborStation = makeStation(neighborUnit, null);
const neighbor = makeNeighbor({ units: [neighborUnit] });
const player = makePlayer([ownUnit], [neighbor]);
const behavior = makeBehavior(
makeGame([ownStation, neighborStation]),
player,
);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(2);
const tiles = result.map((r: any) => r.tile).sort();
expect(tiles).toEqual([100, 200]);
});
});
// ── getOrBuildReachableStations cache behaviour ──────────────────────────────
describe("NationStructureBehavior.getOrBuildReachableStations", () => {
let behavior: NationStructureBehavior;
let buildSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
const player = makePlayer([], []);
behavior = makeBehavior(makeGame(), player);
buildSpy = vi.spyOn(behavior as any, "buildReachableStations");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls buildReachableStations exactly once on first access", () => {
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(1);
});
it("returns the same array instance on repeated calls", () => {
const first = (behavior as any).getOrBuildReachableStations();
const second = (behavior as any).getOrBuildReachableStations();
expect(first).toBe(second);
});
it("does not call buildReachableStations a second time when cache is warm", () => {
(behavior as any).getOrBuildReachableStations();
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(1);
});
it("rebuilds after the cache is reset to null", () => {
(behavior as any).getOrBuildReachableStations();
(behavior as any).reachableStationsCache = null;
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(2);
});
});
+4 -1
View File
@@ -29,9 +29,12 @@ describe("PlayerImpl", () => {
}
player = game.player("player_id");
player.addGold(BigInt(1000000));
other = game.player("other_id");
player.conquer(game.ref(0, 0));
other.conquer(game.ref(50, 50));
player.addGold(BigInt(1000000));
game.config().structureMinDist = () => 10;
});
+147
View File
@@ -0,0 +1,147 @@
import {
createMatcher,
PrivilegeCheckerImpl,
shadowNames,
} from "../src/server/Privilege";
const bannedWords = [
"hitler",
"adolf",
"nazi",
"jew",
"auschwitz",
"whitepower",
"heil",
"chair", // Test word to verify custom banned words work
];
const matcher = createMatcher(bannedWords);
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
const mockCosmetics = { patterns: {}, colorPalettes: {} };
const mockDecoder = () => new Uint8Array();
const checker = new PrivilegeCheckerImpl(
mockCosmetics,
mockDecoder,
bannedWords,
);
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
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);
});
test("detects banned words case-insensitively", () => {
expect(matcher.hasMatch("Hitler")).toBe(true);
expect(matcher.hasMatch("NAZI")).toBe(true);
expect(matcher.hasMatch("Adolf")).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);
});
test("detects banned words with duplicated characters", () => {
expect(matcher.hasMatch("hiiitler")).toBe(true);
expect(matcher.hasMatch("naazzii")).toBe(true);
});
test("detects banned words with accented characters", () => {
expect(matcher.hasMatch("Adölf")).toBe(true);
});
test("detects banned words as substrings", () => {
expect(matcher.hasMatch("xhitlerx")).toBe(true);
expect(matcher.hasMatch("IloveNazi")).toBe(true);
});
test("allows clean usernames", () => {
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
expect(matcher.hasMatch("GameMaster")).toBe(false);
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
});
});
describe("censorUsername", () => {
test("returns clean usernames unchanged", () => {
expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("GameMaster")).toBe("GameMaster");
});
test("replaces profane usernames with a shadow name", () => {
const result = checker.censorUsername("hitler");
expect(shadowNames).toContain(result);
});
test("replaces leet speak profane usernames with a shadow name", () => {
const result = checker.censorUsername("h1tl3r");
expect(shadowNames).toContain(result);
});
test("preserves clean clan tag when username is profane", () => {
const result = checker.censorUsername("[COOL]hitler");
expect(result).toMatch(/^\[COOL\] /);
const nameAfterTag = result.replace("[COOL] ", "");
expect(shadowNames).toContain(nameAfterTag);
});
test("removes profane clan tag but keeps clean username", () => {
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag with leet speak profanity", () => {
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag with uppercased banned word", () => {
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag containing banned word substring", () => {
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censorUsername("[NAZI]hitler");
// No clan tag prefix, just a shadow name
expect(shadowNames).toContain(result);
});
test("removes leet speak profane clan tag and censors leet speak username", () => {
const result = checker.censorUsername("[N4Z1]h1tl3r");
// No clan tag prefix, just a shadow name
expect(shadowNames).toContain(result);
});
test("returns deterministic shadow name for same input", () => {
const a = checker.censorUsername("hitler");
const b = checker.censorUsername("hitler");
expect(a).toBe(b);
});
test("handles username with no clan tag", () => {
expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer");
});
test("empty banned words list still catches englishDataset profanity", () => {
// The emptyChecker still uses englishDataset, so common profanity is caught
expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
// Verify a known english profanity gets censored even without custom banned words
const result = emptyChecker.censorUsername("fuck");
expect(shadowNames).toContain(result);
});
});
});
@@ -1,7 +1,13 @@
import fs from "fs";
import IntlMessageFormat from "intl-messageformat";
import path from "path";
import ts from "typescript";
const PROJECT_ROOT = path.join(__dirname, "..");
const LANGUAGE_DIR = path.join(PROJECT_ROOT, "resources", "lang");
const FLAG_DIR = path.join(PROJECT_ROOT, "resources", "flags");
const METADATA_FILE = path.join(LANGUAGE_DIR, "metadata.json");
/**
* Regex patterns for keys that are intentionally generated dynamically.
* This keeps dynamic handling explicit and reviewable.
@@ -29,6 +35,13 @@ const IGNORED_UNUSED_KEY_PATTERNS: RegExp[] = [
/^lang\./, // language metadata, not a UI translation key
];
type NestedTranslations = Record<string, unknown>;
type ParsedTranslationFile = {
file: string;
flatMessages: Record<string, string>;
};
type ScanResult = {
usedKeys: Set<string>;
referencedStaticKeys: Set<string>;
@@ -48,6 +61,90 @@ function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
return keys;
}
function flattenTranslations(
obj: NestedTranslations,
file: string,
parentKey = "",
result: Record<string, string> = {},
errors: string[] = [],
): Record<string, string> {
for (const [key, value] of Object.entries(obj)) {
const fullKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof value === "string") {
result[fullKey] = value;
continue;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
flattenTranslations(
value as NestedTranslations,
file,
fullKey,
result,
errors,
);
continue;
}
errors.push(
`${file}:${fullKey} has invalid type ${Array.isArray(value) ? "array" : typeof value}`,
);
}
return result;
}
function listLanguageJsonFiles(): string[] {
return fs
.readdirSync(LANGUAGE_DIR)
.filter((file) => file.endsWith(".json") && file !== "metadata.json")
.sort();
}
function loadTranslationFiles(): {
files: ParsedTranslationFile[];
errors: string[];
} {
const errors: string[] = [];
const files: ParsedTranslationFile[] = [];
for (const file of listLanguageJsonFiles()) {
const fullPath = path.join(LANGUAGE_DIR, file);
let raw: string;
try {
raw = fs.readFileSync(fullPath, "utf-8");
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
errors.push(`${file}: failed to read file (${details})`);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
errors.push(`${file}: invalid JSON (${details})`);
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
errors.push(`${file}: root must be an object`);
continue;
}
files.push({
file,
flatMessages: flattenTranslations(
parsed as NestedTranslations,
file,
"",
{},
errors,
),
});
}
return { files, errors };
}
function getAllFiles(
dir: string,
extensions: string[],
@@ -137,7 +234,7 @@ function extractDataI18nKeys(content: string): Set<string> {
function extractTranslationKeyLikeAttrs(content: string): Set<string> {
const keys = new Set<string>();
const keyLikeAttrRegex =
/\b(?:translationKey|labelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g;
/\b(?:translationKey|labelKey|defaultLabelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = keyLikeAttrRegex.exec(content)) !== null) {
keys.add(match[1]);
@@ -335,15 +432,92 @@ function scanTsFile(
return result;
}
describe("Unused Translation Keys", () => {
describe("Translation System", () => {
test("metadata languages point to existing lang json and flag files", () => {
if (!fs.existsSync(METADATA_FILE)) {
console.log(
"No resources/lang/metadata.json file found. Skipping check.",
);
return;
}
const metadata = JSON.parse(fs.readFileSync(METADATA_FILE, "utf-8"));
if (!Array.isArray(metadata) || metadata.length === 0) {
console.log(
"No language entries found in metadata.json. Skipping check.",
);
return;
}
const knownLanguageFiles = new Set(listLanguageJsonFiles());
const errors: string[] = [];
for (const entry of metadata) {
const code = entry?.code;
const svg = entry?.svg;
if (typeof code !== "string" || code.length === 0) {
errors.push(
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
);
continue;
}
if (typeof svg !== "string" || svg.length === 0) {
errors.push(
`[${code}]: metadata svg is missing or not a non-empty string`,
);
continue;
}
if (!knownLanguageFiles.has(`${code}.json`)) {
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
}
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
const flagPath = path.join(FLAG_DIR, svgFile);
if (!fs.existsSync(flagPath)) {
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
}
}
if (errors.length > 0) {
console.error(
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
);
}
expect(errors).toEqual([]);
});
test("all translation strings are valid ICU messages", () => {
const { files, errors } = loadTranslationFiles();
for (const { file, flatMessages } of files) {
for (const [key, message] of Object.entries(flatMessages)) {
try {
new IntlMessageFormat(message, "en");
} catch (error) {
const details =
error instanceof Error ? error.message : String(error);
errors.push(`${file}:${key} has invalid ICU syntax (${details})`);
}
}
}
if (errors.length > 0) {
console.error("ICU translation validation failed:\n" + errors.join("\n"));
}
expect(errors).toEqual([]);
});
test("en.json keys stay in sync with source usage", () => {
const enJsonPath = path.join(__dirname, "../resources/lang/en.json");
const enJsonPath = path.join(LANGUAGE_DIR, "en.json");
const enJson = JSON.parse(fs.readFileSync(enJsonPath, "utf-8"));
const allKeys = flattenKeys(enJson);
const enKeySet = new Set(allKeys);
const rootKeys = new Set(Object.keys(enJson as Record<string, unknown>));
const srcDir = path.join(__dirname, "../src");
const srcDir = path.join(PROJECT_ROOT, "src");
const sourceFiles = getAllFiles(srcDir, [".ts", ".tsx", ".js", ".jsx"]);
const usedKeys = new Set<string>();
@@ -353,12 +527,13 @@ describe("Unused Translation Keys", () => {
for (const file of sourceFiles) {
const scan = scanTsFile(file, rootKeys, enKeySet);
for (const key of scan.usedKeys) usedKeys.add(key);
for (const key of scan.referencedStaticKeys)
for (const key of scan.referencedStaticKeys) {
referencedStaticKeys.add(key);
}
for (const prefix of scan.dynamicPrefixes) dynamicPrefixes.add(prefix);
}
const indexHtmlPath = path.join(__dirname, "../index.html");
const indexHtmlPath = path.join(PROJECT_ROOT, "index.html");
if (fs.existsSync(indexHtmlPath)) {
const htmlContent = fs.readFileSync(indexHtmlPath, "utf-8");
const htmlDataI18nKeys = extractDataI18nKeys(htmlContent);
@@ -412,7 +587,6 @@ describe("Unused Translation Keys", () => {
}
const hasFailing = missingKeys.length > 0 || unusedKeys.length > 0;
if (hasFailing) {
if (derivedDynamicPatterns.length > 0) {
console.log(
+1
View File
@@ -17,6 +17,7 @@ vi.mock("../../src/client/Utils", () => ({
"leaderboard_modal.title": "Leaderboard",
"leaderboard_modal.ranked_tab": "Ranked",
"leaderboard_modal.clans_tab": "Clans",
"leaderboard_modal.refresh_time": "Refreshed every 1 hour",
"leaderboard_modal.error": "Something went wrong",
"leaderboard_modal.rank": "Rank",
"leaderboard_modal.clan": "Clan",
+49
View File
@@ -0,0 +1,49 @@
import { normalizeNewsMarkdown } from "../../src/client/NewsMarkdown";
describe("normalizeNewsMarkdown", () => {
it("converts openfront pull request URLs to short markdown links", () => {
const input =
"Fix attack logic in https://github.com/openfrontio/OpenFrontIO/pull/1234";
const result = normalizeNewsMarkdown(input);
expect(result).toContain(
"[#1234](https://github.com/openfrontio/OpenFrontIO/pull/1234)",
);
});
it("converts openfront compare URLs to markdown links", () => {
const input =
"Full Changelog: https://github.com/openfrontio/OpenFrontIO/compare/v1.0.0...v1.1.0";
const result = normalizeNewsMarkdown(input);
expect(result).toContain(
"[v1.0.0...v1.1.0](https://github.com/openfrontio/OpenFrontIO/compare/v1.0.0...v1.1.0)",
);
});
it("converts github @mentions to profile links", () => {
const input = "- Feature by @evanpelle in release notes";
const result = normalizeNewsMarkdown(input);
expect(result).toContain("[@evanpelle](https://github.com/evanpelle)");
});
it("does not convert existing markdown-linked mentions", () => {
const input = "Credit [@evanpelle](https://github.com/evanpelle)";
const result = normalizeNewsMarkdown(input);
expect(result).toBe(input);
});
it("does not convert email addresses", () => {
const input = "Contact support@openfront.io for help";
const result = normalizeNewsMarkdown(input);
expect(result).toBe(input);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type NavigatorOverride = {
userAgent: string;
userAgentData?: { platform?: string };
maxTouchPoints?: number;
};
const setInnerWidth = (value: number) => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value,
});
};
const loadPlatform = async ({
userAgent,
userAgentData,
maxTouchPoints,
}: NavigatorOverride) => {
vi.resetModules();
vi.stubGlobal("navigator", {
userAgent,
userAgentData,
maxTouchPoints,
});
const { Platform } = await import("../../src/client/Platform");
return Platform;
};
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("Platform", () => {
it("detects iOS before macOS for iPhone-like user agents", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
});
expect(platform.os).toBe("iOS");
expect(platform.isIOS).toBe(true);
expect(platform.isMac).toBe(false);
});
it("detects macOS for Macintosh user agents", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
});
expect(platform.os).toBe("macOS");
expect(platform.isMac).toBe(true);
expect(platform.isIOS).toBe(false);
});
it("detects iOS for iPad desktop-mode user agents with touch support", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
maxTouchPoints: 5,
});
expect(platform.os).toBe("iOS");
expect(platform.isIOS).toBe(true);
expect(platform.isMac).toBe(false);
});
it("uses userAgentData platform when available", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15",
userAgentData: { platform: "Android" },
});
expect(platform.os).toBe("Android");
expect(platform.isAndroid).toBe(true);
});
it("normalizes non-canonical userAgentData platform values", async () => {
const macPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "Macintosh" },
});
expect(macPlatform.os).toBe("macOS");
expect(macPlatform.isMac).toBe(true);
const chromeOsPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "Chrome OS" },
});
expect(chromeOsPlatform.os).toBe("Linux");
expect(chromeOsPlatform.isLinux).toBe(true);
const unknownPlatform = await loadPlatform({
userAgent: "Mozilla/5.0",
userAgentData: { platform: "PlayStation" },
});
expect(unknownPlatform.os).toBe("Unknown");
expect(unknownPlatform.isMac).toBe(false);
expect(unknownPlatform.isWindows).toBe(false);
expect(unknownPlatform.isIOS).toBe(false);
expect(unknownPlatform.isAndroid).toBe(false);
expect(unknownPlatform.isLinux).toBe(false);
});
it("reports viewport breakpoint helpers from window.innerWidth", async () => {
const platform = await loadPlatform({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15",
});
setInnerWidth(767);
expect(platform.isMobileWidth).toBe(true);
expect(platform.isTabletWidth).toBe(false);
expect(platform.isDesktopWidth).toBe(false);
setInnerWidth(768);
expect(platform.isMobileWidth).toBe(false);
expect(platform.isTabletWidth).toBe(true);
expect(platform.isDesktopWidth).toBe(false);
setInnerWidth(1024);
expect(platform.isMobileWidth).toBe(false);
expect(platform.isTabletWidth).toBe(false);
expect(platform.isDesktopWidth).toBe(true);
});
});
@@ -92,6 +92,8 @@ describe("RadialMenuElements", () => {
id: () => 1,
isAlliedWith: vi.fn(() => false),
isPlayer: vi.fn(() => true),
isTraitor: vi.fn(() => false),
isDisconnected: vi.fn(() => false),
} as unknown as PlayerView;
mockGame = {
@@ -339,6 +341,8 @@ describe("RadialMenuElements", () => {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
isTraitor: vi.fn(() => false),
isDisconnected: vi.fn(() => false),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
@@ -348,6 +352,112 @@ describe("RadialMenuElements", () => {
expect(allyMenu).toBeDefined();
});
it("should show extend element when inAllianceExtensionWindow is true", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
mockPlayerActions.interaction = {
...mockPlayerActions.interaction,
canBreakAlliance: true,
allianceInfo: {
expiresAt: 100,
inExtensionWindow: true,
myPlayerAgreedToExtend: true,
otherAgreedToExtend: false,
canExtend: false,
},
};
const subMenu = rootMenuElement.subMenu!(mockParams);
const extendMenu = subMenu.find((item) => item.id === "ally_extend");
expect(extendMenu).toBeDefined();
});
it("should not show extend element when inAllianceExtensionWindow is false", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
mockPlayerActions.interaction = {
...mockPlayerActions.interaction,
canBreakAlliance: true,
allianceInfo: {
expiresAt: 100,
inExtensionWindow: false,
myPlayerAgreedToExtend: false,
otherAgreedToExtend: false,
canExtend: false,
},
};
const subMenu = rootMenuElement.subMenu!(mockParams);
const extendMenu = subMenu.find((item) => item.id === "ally_extend");
expect(extendMenu).toBeUndefined();
});
it("should show extend element as disabled when canExtend is false", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
mockPlayerActions.interaction = {
...mockPlayerActions.interaction,
canBreakAlliance: true,
allianceInfo: {
expiresAt: 100,
inExtensionWindow: true,
myPlayerAgreedToExtend: true,
otherAgreedToExtend: false,
canExtend: false,
},
};
const subMenu = rootMenuElement.subMenu!(mockParams);
const extendMenu = subMenu.find((item) => item.id === "ally_extend");
expect(extendMenu).toBeDefined();
expect(extendMenu!.disabled(mockParams)).toBe(true);
});
it("should show extend element as enabled when canExtend is true", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
mockPlayerActions.interaction = {
...mockPlayerActions.interaction,
canBreakAlliance: true,
allianceInfo: {
expiresAt: 100,
inExtensionWindow: true,
myPlayerAgreedToExtend: false,
otherAgreedToExtend: false,
canExtend: true,
},
};
const subMenu = rootMenuElement.subMenu!(mockParams);
const extendMenu = subMenu.find((item) => item.id === "ally_extend");
expect(extendMenu).toBeDefined();
expect(extendMenu!.disabled(mockParams)).toBe(false);
});
});
describe("Menu element actions", () => {
@@ -1,3 +1,5 @@
import { GameUpdateType } from "../../../../src/core/game/GameUpdates";
vi.mock("lit", () => ({
html: () => {},
LitElement: class {},
@@ -102,6 +104,72 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
expect(remaining[0].allianceID).toBe(allianceCD);
});
test("onAllianceExtensionEvent removes renewal when playerID matches myPlayer", () => {
const display = new EventsDisplay();
const allianceID = 42;
const mySmallID = 7;
(display as any).game = {
myPlayer: () => ({ smallID: () => mySmallID }),
};
(display as any).requestUpdate = () => {};
(display as any).events = [makeRenewal(allianceID, mySmallID)];
(display as any).onAllianceExtensionEvent({
type: GameUpdateType.AllianceExtension,
playerID: mySmallID,
allianceID,
});
const remaining = (display as any).events;
expect(remaining.some((e: any) => e.allianceID === allianceID)).toBe(false);
});
test("onAllianceExtensionEvent keeps renewal when playerID does not match myPlayer", () => {
const display = new EventsDisplay();
const allianceID = 42;
const mySmallID = 7;
const otherSmallID = 9;
(display as any).game = {
myPlayer: () => ({ smallID: () => mySmallID }),
};
(display as any).requestUpdate = () => {};
(display as any).events = [makeRenewal(allianceID, mySmallID)];
(display as any).onAllianceExtensionEvent({
type: "AllianceExtension",
playerID: otherSmallID,
allianceID,
});
const remaining = (display as any).events;
expect(remaining.some((e: any) => e.allianceID === allianceID)).toBe(true);
});
test("onAllianceExtensionEvent keeps renewal when myPlayer is null", () => {
const display = new EventsDisplay();
const allianceID = 42;
(display as any).game = {
myPlayer: () => null,
};
(display as any).requestUpdate = () => {};
(display as any).events = [makeRenewal(allianceID, 1)];
(display as any).onAllianceExtensionEvent({
type: "AllianceExtension",
playerID: 1,
allianceID,
});
const remaining = (display as any).events;
expect(remaining.some((e: any) => e.allianceID === allianceID)).toBe(true);
});
test("does not affect non-RENEW_ALLIANCE events", () => {
const display = new EventsDisplay();
+4 -4
View File
@@ -98,15 +98,15 @@ describe("Spawn execution", () => {
const game = await setup("half_land_half_ocean", undefined, [playerInfo]);
game.addExecution(new SpawnExecution("game_id", playerInfo, 50));
game.addExecution(new SpawnExecution("game_id", playerInfo, 60));
game.addExecution(new SpawnExecution("game_id", playerInfo, 10));
game.addExecution(new SpawnExecution("game_id", playerInfo, 20));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
expect(game.playerByClientID("client_id")?.spawnTile()).toBe(60);
expect(game.playerByClientID("client_id")?.spawnTile()).toBe(20);
// Previous territory from first spawn should be relinquished
expect(game.owner(50).isPlayer()).toBe(false);
expect(game.owner(10).isPlayer()).toBe(false);
});
});
@@ -74,9 +74,11 @@ describe("TradeShipExecution", () => {
tradeShip = {
isActive: vi.fn(() => true),
owner: vi.fn(() => origOwner),
id: vi.fn(() => 123),
move: vi.fn(),
setTargetUnit: vi.fn(),
setSafeFromPirates: vi.fn(),
touch: vi.fn(),
delete: vi.fn(),
tile: vi.fn(() => 2001),
} as any;
@@ -85,6 +87,7 @@ describe("TradeShipExecution", () => {
tradeShipExecution.init(game, 0);
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
findPath: vi.fn((from: number) => [from]),
} as any;
tradeShipExecution["tradeShip"] = tradeShip;
});
@@ -116,6 +119,7 @@ describe("TradeShipExecution", () => {
it("should complete trade and award gold", () => {
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
findPath: vi.fn((from: number) => [from]),
} as any;
tradeShipExecution.tick(1);
expect(tradeShip.delete).toHaveBeenCalledWith(false);
+4 -15
View File
@@ -3,7 +3,6 @@ import { AttackExecution } from "../../../src/core/execution/AttackExecution";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
//import { TransportShipExecution } from "../../../src/core/execution/TransportShipExecution";
import { AllianceRequestExecution } from "../../../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../../../src/core/execution/alliance/AllianceRequestReplyExecution";
import {
Game,
Player,
@@ -68,16 +67,11 @@ describe("GameImpl", () => {
test("Don't become traitor when betraying inactive player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(attacker.id(), defender, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
@@ -107,16 +101,11 @@ describe("GameImpl", () => {
test("Do become traitor when betraying active player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(attacker.id(), defender, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
+164
View File
@@ -65,6 +65,7 @@ describe("RailNetworkImpl", () => {
findStationsPath: vi.fn(() => [0]),
};
game = {
hasUnitNearby: vi.fn(() => true),
nearbyUnits: vi.fn(() => []),
addExecution: vi.fn(),
config: () => ({
@@ -166,4 +167,167 @@ describe("RailNetworkImpl", () => {
expect(station.setCluster).toHaveBeenCalled();
expect(neighborStation.setCluster).toHaveBeenCalled();
});
describe("computeGhostRailPaths", () => {
test("returns empty when snappable rails exist nearby", () => {
const tile = 42 as any;
// Accessing private railGrid via any to set up mock
const railGridMock = { query: vi.fn(() => new Set([{}])) };
(network as any).railGrid = railGridMock;
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
expect(railGridMock.query).toHaveBeenCalledWith(tile, 3);
});
test("returns empty when no nearby stations found", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
game.nearbyUnits.mockReturnValue([]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
});
test("returns paths to nearby stations within range", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighborStation = createMockStation(1);
neighborStation.tile.mockReturnValue(100);
stationManager.findStation.mockReturnValue(neighborStation);
const mockPath = [42, 50, 60, 100];
pathService.findTilePath.mockReturnValue(mockPath);
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 400 },
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([mockPath]);
expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100);
});
test("skips neighbors within min range", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighborStation = createMockStation(1);
neighborStation.tile.mockReturnValue(43);
stationManager.findStation.mockReturnValue(neighborStation);
// distSquared = 50 <= minRange^2 (10^2 = 100)
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 50 },
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
});
test("skips neighbors without train stations", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
stationManager.findStation.mockReturnValue(null);
game.nearbyUnits.mockReturnValue([{ unit: { id: 1 }, distSquared: 400 }]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
});
test("skips paths that exceed max railroad size", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighborStation = createMockStation(1);
neighborStation.tile.mockReturnValue(100);
stationManager.findStation.mockReturnValue(neighborStation);
// Path length >= railroadMaxSize (100)
pathService.findTilePath.mockReturnValue(new Array(100));
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 400 },
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
});
test("limits to at most 5 paths", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighbors: Array<{ unit: any; distSquared: number }> = [];
for (let i = 0; i < 7; i++) {
const station = createMockStation(i);
station.tile.mockReturnValue(100 + i);
neighbors.push({ unit: station.unit, distSquared: 400 + i });
}
stationManager.findStation.mockImplementation((unit: any) => {
const station = createMockStation(unit.id);
station.tile.mockReturnValue(100 + unit.id);
return station;
});
pathService.findTilePath.mockImplementation((_from: any, to: any) => [
_from,
to,
]);
game.nearbyUnits.mockReturnValue(neighbors);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result.length).toBe(5);
});
test("skips stations reachable through already-connected stations", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
// Create two neighbor stations where B is reachable from A
const stationA = createMockStation(1);
stationA.tile.mockReturnValue(100);
const stationB = createMockStation(2);
stationB.tile.mockReturnValue(200);
// Make A and B neighbors of each other (1 hop apart)
stationA.neighbors.mockReturnValue([stationB]);
stationB.neighbors.mockReturnValue([stationA]);
stationManager.findStation.mockImplementation((unit: any) => {
if (unit.id === 1) return stationA;
if (unit.id === 2) return stationB;
return null;
});
pathService.findTilePath.mockImplementation((_from: any, to: any) => [
_from,
to,
]);
// Station A is closer, station B is farther
game.nearbyUnits.mockReturnValue([
{ unit: stationA.unit, distSquared: 400 },
{ unit: stationB.unit, distSquared: 900 },
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
// Only station A should get a path; B is reachable from A within maxConnectionDistance - 1
expect(result.length).toBe(1);
expect(pathService.findTilePath).toHaveBeenCalledTimes(1);
expect(pathService.findTilePath).toHaveBeenCalledWith(tile, 100);
});
});
});
+3 -2
View File
@@ -11,6 +11,7 @@ import {
import { createGame as createGameImpl } from "../../../src/core/game/GameImpl";
import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
@@ -131,13 +132,13 @@ export function createGame(data: TestMapData): Game {
);
const serverConfig = new TestServerConfig();
const gameConfig = {
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
+1 -1
View File
@@ -263,7 +263,7 @@ export async function setupFromPath(
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
+109
View File
@@ -0,0 +1,109 @@
import Benchmark from "benchmark";
const STRUCTURE_COUNT = 50000;
const LOOKUP_COUNT = 50000;
const UPGRADE_LOOKUP_COUNT = 5000;
interface StructureRenderSample {
unitId: number;
ownerId: number;
level: number;
}
const rendersArray: StructureRenderSample[] = Array.from(
{ length: STRUCTURE_COUNT },
(_, index) => ({
unitId: index + 1,
ownerId: (index % 5) + 1,
level: (index % 4) + 1,
}),
);
const rendersMap = new Map<number, StructureRenderSample>();
for (const render of rendersArray) {
rendersMap.set(render.unitId, render);
}
const activeLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 97) % STRUCTURE_COUNT) + 1,
);
const inactiveLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 193) % STRUCTURE_COUNT) + 1,
);
const canUpgradeIds = Array.from(
{ length: UPGRADE_LOOKUP_COUNT },
(_, index) => ((index * 389) % STRUCTURE_COUNT) + 1,
);
const myOwnerId = 3;
const results: string[] = [];
new Benchmark.Suite()
.add("StructureIconsLayer BEFORE (array O(n) lookup/delete)", () => {
const localRenders = rendersArray.map((render) => ({ ...render }));
for (const unitId of activeLookupIds) {
const render = localRenders.find((entry) => entry.unitId === unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.find(
(entry) => entry.unitId === canUpgradeId && entry.ownerId === myOwnerId,
);
if (potentialUpgrade) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
const index = localRenders.findIndex((entry) => entry.unitId === unitId);
if (index !== -1) {
localRenders.splice(index, 1);
}
}
})
.add("StructureIconsLayer AFTER (unit-id map O(1) lookup/delete)", () => {
const localRenders = new Map<number, StructureRenderSample>();
for (const [unitId, render] of rendersMap) {
localRenders.set(unitId, { ...render });
}
for (const unitId of activeLookupIds) {
const render = localRenders.get(unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.get(canUpgradeId);
if (potentialUpgrade && potentialUpgrade.ownerId === myOwnerId) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
localRenders.delete(unitId);
}
})
.on("cycle", (event: Benchmark.Event) => {
results.push(String(event.target));
})
.on("complete", function () {
console.log("\n=== StructureIconsLayer Lookup Benchmark Results ===");
for (const result of results) {
console.log(result);
}
const fastest = this.filter("fastest").map("name");
console.log(`\nFastest implementation: ${fastest.join(", ")}`);
})
.run({ async: true });
+9
View File
@@ -0,0 +1,9 @@
import { execSync } from "child_process";
import { globSync } from "glob";
// "perf": "npx tsx tests/perf/*.ts" doesn't work on Windows
const files = globSync("tests/perf/*.ts").filter((f) => !f.includes("run-all"));
for (const file of files) {
console.log(`\nRunning ${file}...`);
execSync(`tsx "${file}"`, { stdio: "inherit" });
}
+26 -2
View File
@@ -19,13 +19,18 @@ import {
} from "../src/client/graphics/layers/RadialMenuElements";
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
const makePlayer = (id: string) =>
const makePlayer = (
id: string,
opts?: { isTraitor?: boolean; isDisconnected?: boolean },
) =>
({
id: () => id,
isAlliedWith: (other: any) =>
other && typeof other.id === "function" && other.id() !== id
? true
: true,
isTraitor: () => opts?.isTraitor ?? false,
isDisconnected: () => opts?.isDisconnected ?? false,
}) as unknown as import("../src/core/game/GameView").PlayerView;
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
@@ -82,7 +87,26 @@ describe("RadialMenuElements ally break", () => {
const ally = findAllyBreak(items)!;
expect(ally).toBeTruthy();
expect(ally.name).toBe("break");
expect(ally.color).toBe(COLORS.breakAlly);
expect(typeof ally.color).toBe("function");
expect(ally.color(params)).toBe(COLORS.breakAlly);
});
test("shows break option with orange color when allied to traitor", () => {
const params = makeParams({
selected: makePlayer("p2", { isTraitor: true }),
});
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
expect(ally.color(params)).toBe(COLORS.breakAllyNoDebuff);
});
test("shows boat button instead of break when allied to disconnected player", () => {
const params = makeParams({
selected: makePlayer("p2", { isDisconnected: true }),
});
const items = rootMenuElement.subMenu!(params);
expect(findAllyBreak(items)).toBeUndefined();
expect(items.find((i) => i.id === "boat")).toBeDefined();
});
test("break action calls handleBreakAlliance and closes menu", () => {
@@ -0,0 +1,119 @@
import EventEmitter from "events";
import { describe, expect, it, vi } from "vitest";
import { MasterLobbyService } from "../../src/server/MasterLobbyService";
import { TestServerConfig } from "../util/TestServerConfig";
vi.mock("../../src/server/Logger", () => ({
logger: {
child: () => ({
error: vi.fn(),
info: vi.fn(),
}),
},
}));
vi.mock("../../src/server/PollingLoop", () => ({
startPolling: vi.fn(),
}));
function createMockWorker(): EventEmitter {
const emitter = new EventEmitter();
(emitter as any).send = vi.fn();
return emitter;
}
function sendWorkerReady(worker: EventEmitter, workerId: number) {
worker.emit("message", { type: "workerReady", workerId });
}
function createService(numWorkers: number): MasterLobbyService {
const config = new TestServerConfig();
vi.spyOn(config, "numWorkers").mockReturnValue(numWorkers);
const log = { info: vi.fn(), error: vi.fn() } as any;
return new MasterLobbyService(config, {} as any, log);
}
function startAllWorkers(
service: MasterLobbyService,
count: number,
): { id: number; w: EventEmitter }[] {
const workers = Array.from({ length: count }, (_, i) => {
const id = i + 1;
const w = createMockWorker();
service.registerWorker(id, w as any);
return { id, w };
});
for (const { w, id } of workers) {
sendWorkerReady(w, id);
}
return workers;
}
describe("MasterLobbyService.isHealthy", () => {
it("unhealthy before any workers register", () => {
const service = createService(4);
expect(service.isHealthy()).toBe(false);
});
it("unhealthy when workers registered but not ready", () => {
const service = createService(2);
service.registerWorker(1, createMockWorker() as any);
expect(service.isHealthy()).toBe(false);
});
it("unhealthy when only some workers are ready (server not started)", () => {
const service = createService(4);
// 1 of 4 ready -- not enough to flip `started`
const w1 = createMockWorker();
service.registerWorker(1, w1 as any);
sendWorkerReady(w1, 1);
expect(service.isHealthy()).toBe(false);
});
it("healthy once all workers are ready", () => {
const service = createService(2);
startAllWorkers(service, 2);
expect(service.isHealthy()).toBe(true);
});
it("stays healthy after a single worker crash", () => {
const service = createService(4);
startAllWorkers(service, 4);
service.removeWorker(4); // 3 of 4 left, threshold is 2
expect(service.isHealthy()).toBe(true);
});
it("goes unhealthy when too many workers crash", () => {
const service = createService(4);
startAllWorkers(service, 4);
service.removeWorker(2);
service.removeWorker(3);
service.removeWorker(4); // 1 of 4 left, threshold is 2
expect(service.isHealthy()).toBe(false);
});
it("single-worker setup goes unhealthy on crash", () => {
const service = createService(1);
startAllWorkers(service, 1);
expect(service.isHealthy()).toBe(true);
service.removeWorker(1);
expect(service.isHealthy()).toBe(false);
});
it("odd worker count: threshold rounds up (3 workers)", () => {
const service = createService(3);
startAllWorkers(service, 3);
// min = 3/2 = 1.5, so 2 ready is enough, 1 is not
service.removeWorker(3);
expect(service.isHealthy()).toBe(true);
service.removeWorker(2);
expect(service.isHealthy()).toBe(false);
});
});
+1
View File
@@ -1 +1,2 @@
// Add global mocks or configuration here if needed
import "vitest-canvas-mock";
+1 -1
View File
@@ -61,7 +61,7 @@ export async function setup(
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
+9
View File
@@ -13,6 +13,7 @@ export class TestConfig extends DefaultConfig {
private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
private _nationSpawnImmunityDuration: number = 0;
disableNavMesh(): boolean {
return this.gameConfig().disableNavMesh ?? true;
@@ -67,6 +68,14 @@ export class TestConfig extends DefaultConfig {
return this._spawnImmunityDuration;
}
setNationSpawnImmunityDuration(duration: Tick) {
this._nationSpawnImmunityDuration = duration;
}
nationSpawnImmunityDuration(): Tick {
return this._nationSpawnImmunityDuration;
}
attackLogic(
gm: Game,
attackTroops: number,
+6 -1
View File
@@ -86,7 +86,12 @@ export class TestServerConfig implements ServerConfig {
throw new Error("Method not implemented.");
}
getRandomPublicGameModifiers(): PublicGameModifiers {
return { isCompact: false, isRandomSpawn: false, isCrowded: false };
return {
isCompact: false,
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
};
}
async supportsCompactMapForTeams(): Promise<boolean> {
throw new Error("Method not implemented.");