mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 01:56:11 +00:00
Merge branch 'main' into embeddedurlfix
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
},
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
// Add global mocks or configuration here if needed
|
||||
import "vitest-canvas-mock";
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user