mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 05:36:09 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { AllianceRequestReplyExecution } from "src/core/execution/alliance/AllianceRequestReplyExecution";
|
||||
import { GameUpdateType } from "src/core/game/GameUpdates";
|
||||
import { NukeExecution } from "../src/core/execution/NukeExecution";
|
||||
import {
|
||||
Game,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "../src/core/game/Game";
|
||||
import { setup } from "./util/Setup";
|
||||
import { TestConfig } from "./util/TestConfig";
|
||||
|
||||
let game: Game;
|
||||
let player1: Player;
|
||||
let player2: Player;
|
||||
let player3: Player;
|
||||
|
||||
describe("Alliance acceptance immediately destroys in-flight nukes", () => {
|
||||
beforeEach(async () => {
|
||||
game = await setup(
|
||||
"plains",
|
||||
{
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
infiniteTroops: true,
|
||||
},
|
||||
[
|
||||
new PlayerInfo("player1", PlayerType.Human, "c1", "p1"),
|
||||
new PlayerInfo("player2", PlayerType.Human, "c2", "p2"),
|
||||
new PlayerInfo("player3", PlayerType.Human, "c3", "p3"),
|
||||
],
|
||||
);
|
||||
|
||||
(game.config() as TestConfig).nukeAllianceBreakThreshold = () => 0;
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
player1 = game.player("p1");
|
||||
player2 = game.player("p2");
|
||||
player3 = game.player("p3");
|
||||
|
||||
player1.conquer(game.ref(0, 0));
|
||||
player2.conquer(game.ref(5, 5));
|
||||
player3.conquer(game.ref(10, 10));
|
||||
|
||||
player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {});
|
||||
});
|
||||
|
||||
test("accepting alliance destroys in-flight nukes between the newly allied players", () => {
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.AtomBomb,
|
||||
player1,
|
||||
game.ref(5, 5),
|
||||
game.ref(0, 0),
|
||||
-1,
|
||||
5,
|
||||
),
|
||||
);
|
||||
|
||||
game.executeNextTick(); // init
|
||||
game.executeNextTick(); // spawn nuke
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(1);
|
||||
|
||||
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();
|
||||
|
||||
expect(player2.isAlliedWith(player1)).toBe(true);
|
||||
expect(player1.isFriendly(player2)).toBe(true);
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("accepting alliance destroys only nukes between allied players", () => {
|
||||
player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {});
|
||||
|
||||
game.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null),
|
||||
);
|
||||
game.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null),
|
||||
);
|
||||
|
||||
game.executeNextTick(); // init
|
||||
game.executeNextTick(); // spawn nukes
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(2);
|
||||
|
||||
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();
|
||||
|
||||
expect(player2.isAlliedWith(player1)).toBe(true);
|
||||
expect(player1.isFriendly(player2)).toBe(true);
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(1);
|
||||
|
||||
// Ensure remaining nuke targets player3
|
||||
const remainingNuke = game.units(UnitType.AtomBomb)[0];
|
||||
expect(remainingNuke.targetTile()).toBe(game.ref(10, 10));
|
||||
});
|
||||
|
||||
test("accepting alliance displays a nuke-cancellation display message", () => {
|
||||
game.addExecution(
|
||||
new NukeExecution(
|
||||
UnitType.AtomBomb,
|
||||
player1,
|
||||
game.ref(5, 5),
|
||||
game.ref(0, 0),
|
||||
-1,
|
||||
5,
|
||||
),
|
||||
);
|
||||
|
||||
game.executeNextTick(); // init
|
||||
game.executeNextTick(); // spawn nuke
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(1);
|
||||
|
||||
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();
|
||||
|
||||
expect(player2.isAlliedWith(player1)).toBe(true);
|
||||
expect(player1.isFriendly(player2)).toBe(true);
|
||||
|
||||
expect(game.units(UnitType.AtomBomb)).toHaveLength(0);
|
||||
|
||||
const messages =
|
||||
updates[GameUpdateType.DisplayEvent]?.map((e) => e.message) ?? [];
|
||||
|
||||
expect(
|
||||
messages.some(
|
||||
(m) =>
|
||||
m === "events_display.alliance_nukes_destroyed_outgoing" ||
|
||||
m === "events_display.alliance_nukes_destroyed_incoming",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+7
-25
@@ -21,10 +21,8 @@ let defender: Player;
|
||||
let defenderSpawn: TileRef;
|
||||
let attackerSpawn: TileRef;
|
||||
|
||||
function sendBoat(target: TileRef, source: TileRef, troops: number) {
|
||||
game.addExecution(
|
||||
new TransportShipExecution(defender, null, target, troops, source),
|
||||
);
|
||||
function sendBoat(target: TileRef, troops: number) {
|
||||
game.addExecution(new TransportShipExecution(defender, target, troops));
|
||||
}
|
||||
|
||||
const immunityPhaseTicks = 10;
|
||||
@@ -114,7 +112,7 @@ describe("Attack", () => {
|
||||
constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
|
||||
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
|
||||
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
|
||||
sendBoat(game.ref(15, 8), 100);
|
||||
|
||||
constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
|
||||
const nuke = defender.units(UnitType.AtomBomb)[0];
|
||||
@@ -133,7 +131,7 @@ describe("Attack", () => {
|
||||
const player_start_troops = defender.troops();
|
||||
const boat_troops = player_start_troops * 0.5;
|
||||
|
||||
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
|
||||
sendBoat(game.ref(15, 8), boat_troops);
|
||||
|
||||
game.executeNextTick();
|
||||
|
||||
@@ -357,7 +355,7 @@ describe("Attack immunity", () => {
|
||||
null,
|
||||
"playerB_id",
|
||||
);
|
||||
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11));
|
||||
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
@@ -412,15 +410,7 @@ describe("Attack immunity", () => {
|
||||
|
||||
test("Should not be able to send a boat during immunity phase", async () => {
|
||||
// Player A sends a boat targeting Player B
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
playerA,
|
||||
playerB.id(),
|
||||
game.ref(15, 8),
|
||||
10,
|
||||
game.ref(10, 5),
|
||||
),
|
||||
);
|
||||
game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10));
|
||||
game.executeNextTick();
|
||||
expect(playerA.units(UnitType.TransportShip)).toHaveLength(0);
|
||||
});
|
||||
@@ -428,15 +418,7 @@ describe("Attack immunity", () => {
|
||||
test("Should be able to send a boat after immunity phase", async () => {
|
||||
waitForImmunityToEnd();
|
||||
// Player A sends a boat targeting Player B
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
playerA,
|
||||
playerB.id(),
|
||||
game.ref(15, 8),
|
||||
10,
|
||||
game.ref(7, 0),
|
||||
),
|
||||
);
|
||||
game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10));
|
||||
game.executeNextTick();
|
||||
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -350,13 +350,7 @@ describe("Disconnected", () => {
|
||||
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
100,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
new TransportShipExecution(player2, enemyShoreTile, 100),
|
||||
);
|
||||
|
||||
executeTicks(game, 1);
|
||||
@@ -387,13 +381,7 @@ describe("Disconnected", () => {
|
||||
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
100,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
new TransportShipExecution(player2, enemyShoreTile, 100),
|
||||
);
|
||||
executeTicks(game, 1);
|
||||
|
||||
@@ -425,13 +413,7 @@ describe("Disconnected", () => {
|
||||
|
||||
const boatTroops = 100;
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
boatTroops,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
new TransportShipExecution(player2, enemyShoreTile, boatTroops),
|
||||
);
|
||||
executeTicks(game, 1);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { AnalyticsRecord } from "../src/core/Schemas";
|
||||
import {
|
||||
GOLD_INDEX_STEAL,
|
||||
GOLD_INDEX_TRADE,
|
||||
GOLD_INDEX_TRAIN_OTHER,
|
||||
GOLD_INDEX_TRAIN_SELF,
|
||||
GOLD_INDEX_WAR,
|
||||
} from "../src/core/StatsSchemas";
|
||||
|
||||
@@ -54,8 +56,8 @@ describe("Ranking class", () => {
|
||||
cosmetics: { flag: "USA" },
|
||||
stats: {
|
||||
units: { port: [2n, 0n, 0n, 2n] },
|
||||
conquests: 5n,
|
||||
gold: [0n, 100n, 20n, 0n], // total 120
|
||||
conquests: [5n],
|
||||
gold: [0n, 100n, 20n, 0n, 15n, 5n], // total 140
|
||||
bombs: {
|
||||
abomb: [1n],
|
||||
hbomb: [1n],
|
||||
@@ -69,8 +71,8 @@ describe("Ranking class", () => {
|
||||
username: "Bob",
|
||||
stats: {
|
||||
units: { city: [2n, 0n, 0n, 2n] },
|
||||
conquests: 8n,
|
||||
gold: [0n, 50n, 10n, 5n], // total 65
|
||||
conquests: [8n],
|
||||
gold: [0n, 50n, 10n, 5n], // total 65, no train trade
|
||||
bombs: {
|
||||
abomb: [0n],
|
||||
hbomb: [2n],
|
||||
@@ -84,9 +86,9 @@ describe("Ranking class", () => {
|
||||
username: "Charlie",
|
||||
stats: {
|
||||
// no units, but has conquests/killedAt to count as played
|
||||
conquests: 8n,
|
||||
conquests: [8n],
|
||||
killedAt: BigInt(600),
|
||||
gold: [0n, 10n, 2n, 10n], // total 22
|
||||
gold: [0n, 10n, 2n, 10n, 0n, 5n], // total 27
|
||||
bombs: {},
|
||||
},
|
||||
persistentID: null,
|
||||
@@ -108,21 +110,21 @@ describe("Ranking class", () => {
|
||||
|
||||
test("summarizes players correctly", () => {
|
||||
const r = new Ranking(makeSession());
|
||||
const players = r.sortedBy(RankType.Conquests);
|
||||
const players = r.sortedBy(RankType.ConquestHumans);
|
||||
|
||||
expect(players.length).toBe(3);
|
||||
|
||||
const p1 = players.find((p) => p.id === "p1")!;
|
||||
expect(p1.username).toBe("Alice");
|
||||
expect(p1.flag).toBe("USA");
|
||||
expect(p1.conquests).toBe(5);
|
||||
expect(p1.conquests).toStrictEqual([5n]);
|
||||
expect(p1.atoms).toBe(1);
|
||||
expect(p1.mirv).toBe(2);
|
||||
});
|
||||
|
||||
test("correctly identifies winner", () => {
|
||||
const r = new Ranking(makeSession());
|
||||
const p2 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p2")!;
|
||||
const p2 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p2")!;
|
||||
expect(p2.winner).toBe(true);
|
||||
});
|
||||
|
||||
@@ -155,7 +157,7 @@ describe("Ranking class", () => {
|
||||
|
||||
test("lifetime score is percentage of duration", () => {
|
||||
const r = new Ranking(makeSession());
|
||||
const p3 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p3")!;
|
||||
const p3 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p3")!;
|
||||
const expected = Number(BigInt(600)) / gameDuration;
|
||||
expect(r.score(p3, RankType.Lifetime)).toBe(expected);
|
||||
});
|
||||
@@ -168,7 +170,7 @@ describe("Ranking class", () => {
|
||||
|
||||
test("winners should be ahead of players with same score", () => {
|
||||
const r = new Ranking(makeSession());
|
||||
const sortedPlayers = r.sortedBy(RankType.Conquests);
|
||||
const sortedPlayers = r.sortedBy(RankType.ConquestHumans);
|
||||
expect(sortedPlayers[0].id).toBe("p2"); // p2 & p3 same score but winner first
|
||||
});
|
||||
|
||||
@@ -178,9 +180,14 @@ describe("Ranking class", () => {
|
||||
expect(r.score(p1, RankType.StolenGold)).toBe(
|
||||
Number(p1.gold[GOLD_INDEX_STEAL] ?? 0n),
|
||||
);
|
||||
expect(r.score(p1, RankType.TradedGold)).toBe(
|
||||
expect(r.score(p1, RankType.NavalTrade)).toBe(
|
||||
Number(p1.gold[GOLD_INDEX_TRADE] ?? 0n),
|
||||
);
|
||||
const ownTrain = p1.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n;
|
||||
const otherTrain = p1.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n;
|
||||
expect(r.score(p1, RankType.TrainTrade)).toBe(
|
||||
Number(ownTrain + otherTrain),
|
||||
);
|
||||
expect(r.score(p1, RankType.ConqueredGold)).toBe(
|
||||
Number(p1.gold[GOLD_INDEX_WAR] ?? 0n),
|
||||
);
|
||||
|
||||
@@ -45,6 +45,10 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
inputHandler.destroy();
|
||||
});
|
||||
|
||||
describe("Middle Mouse Button Handling", () => {
|
||||
test("should emit AutoUpgradeEvent on middle mouse button press", () => {
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "fs";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
|
||||
type Nation = {
|
||||
flag?: string;
|
||||
};
|
||||
|
||||
type Manifest = {
|
||||
nations?: Nation[];
|
||||
};
|
||||
|
||||
describe("Map manifests: nation flags exist", () => {
|
||||
test("All nations' flags reference existing SVG files", () => {
|
||||
const manifestPaths = globSync("resources/maps/**/manifest.json");
|
||||
|
||||
expect(manifestPaths.length).toBeGreaterThan(0);
|
||||
|
||||
const flagDir = path.join(__dirname, "../resources/flags");
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const manifestPath of manifestPaths) {
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(raw) as Manifest;
|
||||
|
||||
(manifest.nations ?? []).forEach((nation, idx) => {
|
||||
const flag = nation?.flag;
|
||||
if (flag === undefined || flag === null) return;
|
||||
if (typeof flag !== "string") {
|
||||
errors.push(
|
||||
`${manifestPath} -> nations[${idx}].flag is not a string`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flag.trim().length === 0) return;
|
||||
if (flag.startsWith("!")) return;
|
||||
|
||||
const svgFile = flag.endsWith(".svg") ? flag : `${flag}.svg`;
|
||||
const flagPath = path.join(flagDir, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(
|
||||
`${manifestPath} -> nations[${idx}].flag "${flag}" does not exist in resources/flags`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Failed to parse ${manifestPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
"Map manifest flag file violations:\n" + errors.join("\n"),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -162,14 +162,14 @@ describe("Stats", () => {
|
||||
expect(stats.stats()).toStrictEqual({
|
||||
client1: {
|
||||
gold: [0n, 1n],
|
||||
conquests: 1n,
|
||||
conquests: [1n],
|
||||
},
|
||||
});
|
||||
stats.goldWar(player1, player2, 1);
|
||||
expect(stats.stats()).toStrictEqual({
|
||||
client1: {
|
||||
gold: [0n, 2n],
|
||||
conquests: 2n,
|
||||
conquests: [2n],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
vi.mock("lit", () => ({
|
||||
html: (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings,
|
||||
values,
|
||||
}),
|
||||
LitElement: class extends EventTarget {
|
||||
requestUpdate() {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lit/decorators.js", () => ({
|
||||
customElement: () => (clazz: unknown) => clazz,
|
||||
state: () => () => {},
|
||||
property: () => () => {},
|
||||
query: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => key),
|
||||
renderDuration: vi.fn(),
|
||||
renderNumber: vi.fn(),
|
||||
renderTroops: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/client/components/ui/ActionButton", () => ({
|
||||
actionButton: vi.fn((props: unknown) => props),
|
||||
}));
|
||||
|
||||
import { actionButton } from "../../../../src/client/components/ui/ActionButton";
|
||||
import { PlayerModerationModal } from "../../../../src/client/graphics/layers/PlayerModerationModal";
|
||||
import { PlayerPanel } from "../../../../src/client/graphics/layers/PlayerPanel";
|
||||
import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport";
|
||||
import { PlayerType } from "../../../../src/core/game/Game";
|
||||
import { PlayerView } from "../../../../src/core/game/GameView";
|
||||
|
||||
describe("PlayerPanel - kick player moderation", () => {
|
||||
let panel: PlayerPanel;
|
||||
const originalConfirm = globalThis.confirm;
|
||||
|
||||
beforeEach(() => {
|
||||
panel = new PlayerPanel();
|
||||
(panel as any).requestUpdate = vi.fn();
|
||||
(panel as any).isVisible = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
test("renders moderation action only when allowed or already kicked", () => {
|
||||
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
|
||||
const other = {
|
||||
id: () => 2,
|
||||
name: () => "Other",
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-2",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).renderModeration(my, other);
|
||||
expect(actionButton).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0],
|
||||
).toMatchObject({
|
||||
label: "player_panel.moderation",
|
||||
title: "player_panel.moderation",
|
||||
type: "red",
|
||||
});
|
||||
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).kickedPlayerIDs.add("2");
|
||||
(panel as any).renderModeration(my, other);
|
||||
expect(actionButton).toHaveBeenCalledTimes(1);
|
||||
|
||||
const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView;
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).kickedPlayerIDs.clear();
|
||||
(panel as any).renderModeration(notCreator, other);
|
||||
expect(actionButton).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("opens moderation modal and hides after a kick", () => {
|
||||
const other = {
|
||||
id: () => 2,
|
||||
name: () => "Other",
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-2",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
(panel as any).openModeration({ stopPropagation: vi.fn() }, other);
|
||||
expect((panel as any).moderationTarget).toBe(other);
|
||||
expect((panel as any).suppressNextHide).toBe(true);
|
||||
|
||||
(panel as any).handleModerationKicked(
|
||||
new CustomEvent("kicked", { detail: { playerId: "2" } }),
|
||||
);
|
||||
|
||||
expect((panel as any).kickedPlayerIDs.has("2")).toBe(true);
|
||||
expect((panel as any).moderationTarget).toBe(null);
|
||||
expect((panel as any).isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerModerationModal - kick confirmation", () => {
|
||||
const originalConfirm = globalThis.confirm;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", () => {
|
||||
(globalThis as any).confirm = vi.fn(() => true);
|
||||
|
||||
const modal = new PlayerModerationModal();
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
|
||||
const other = {
|
||||
id: () => 2,
|
||||
name: () => "Other",
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-2",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
modal.eventBus = eventBus as any;
|
||||
modal.myPlayer = my;
|
||||
modal.target = other;
|
||||
|
||||
const kickedListener = vi.fn();
|
||||
modal.addEventListener("kicked", kickedListener as any);
|
||||
|
||||
(modal as any).handleKickClick({ stopPropagation: vi.fn() });
|
||||
|
||||
expect(eventBus.emit).toHaveBeenCalledTimes(1);
|
||||
const event = eventBus.emit.mock.calls[0][0] as SendKickPlayerIntentEvent;
|
||||
expect(event).toBeInstanceOf(SendKickPlayerIntentEvent);
|
||||
expect(event.target).toBe("client-2");
|
||||
|
||||
expect(kickedListener).toHaveBeenCalledTimes(1);
|
||||
const kickedEvent = kickedListener.mock.calls[0][0] as CustomEvent;
|
||||
expect(kickedEvent.detail).toEqual({ playerId: "2" });
|
||||
});
|
||||
|
||||
test("does not emit when confirmation is cancelled", () => {
|
||||
(globalThis as any).confirm = vi.fn(() => false);
|
||||
|
||||
const modal = new PlayerModerationModal();
|
||||
const eventBus = { emit: vi.fn() };
|
||||
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
|
||||
const other = {
|
||||
id: () => 2,
|
||||
name: () => "Other",
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-2",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
modal.eventBus = eventBus as any;
|
||||
modal.myPlayer = my;
|
||||
modal.target = other;
|
||||
|
||||
const kickedListener = vi.fn();
|
||||
modal.addEventListener("kicked", kickedListener as any);
|
||||
|
||||
(modal as any).handleKickClick({ stopPropagation: vi.fn() });
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
expect(kickedListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ describe("RailNetworkImpl", () => {
|
||||
trainStationMinRange: () => 10,
|
||||
railroadMaxSize: () => 100,
|
||||
}),
|
||||
x: vi.fn(() => 0),
|
||||
y: vi.fn(() => 0),
|
||||
};
|
||||
|
||||
network = new RailNetworkImpl(game, stationManager, pathService);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { PathFinding } from "../../../../src/core/pathfinding/PathFinder.js";
|
||||
import { SpatialQuery } from "../../../../src/core/pathfinding/spatial/SpatialQuery.js";
|
||||
import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.js";
|
||||
import { loadMap } from "./maps.js";
|
||||
|
||||
export interface SpatialQueryResult {
|
||||
selectedShore: [number, number] | null;
|
||||
path: Array<[number, number]> | null;
|
||||
shores: Array<[number, number]>;
|
||||
debug: {
|
||||
candidates: Array<[number, number]> | null;
|
||||
refinedPath: Array<[number, number]> | null;
|
||||
originalBestTile: [number, number] | null;
|
||||
newBestTile: [number, number] | null;
|
||||
timings: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract timings from DebugSpan hierarchy
|
||||
*/
|
||||
function extractTimings(span: {
|
||||
name: string;
|
||||
duration?: number;
|
||||
children: any[];
|
||||
}): Record<string, number> {
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
if (span.duration !== undefined) {
|
||||
timings[span.name] = span.duration;
|
||||
}
|
||||
|
||||
for (const child of span.children) {
|
||||
Object.assign(timings, extractTimings(child));
|
||||
}
|
||||
|
||||
return timings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TileRef to coordinate tuple
|
||||
*/
|
||||
function tileToCoord(tile: TileRef, game: any): [number, number] {
|
||||
return [game.x(tile), game.y(tile)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TileRef array to coordinate array
|
||||
*/
|
||||
function tilesToCoords(
|
||||
tiles: TileRef[] | null | undefined,
|
||||
game: any,
|
||||
): Array<[number, number]> | null {
|
||||
if (!tiles) return null;
|
||||
return tiles.map((tile) => tileToCoord(tile, game));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute spatial query for transport ship launch
|
||||
*/
|
||||
export async function computeSpatialQuery(
|
||||
mapName: string,
|
||||
ownedTiles: number[],
|
||||
target: [number, number],
|
||||
): Promise<SpatialQueryResult> {
|
||||
const { game } = await loadMap(mapName);
|
||||
|
||||
const targetRef = game.ref(target[0], target[1]) as TileRef;
|
||||
|
||||
// Validate target is water or shore
|
||||
if (!game.isWater(targetRef) && !game.isShore(targetRef)) {
|
||||
throw new Error(
|
||||
`Target (${target[0]}, ${target[1]}) must be water or shore`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert owned tile indices to TileRefs
|
||||
const ownedRefs = ownedTiles.map((idx) => {
|
||||
const x = idx % game.width();
|
||||
const y = Math.floor(idx / game.width());
|
||||
return game.ref(x, y) as TileRef;
|
||||
});
|
||||
|
||||
// Create mock player that returns owned tiles as border tiles
|
||||
// The SpatialQuery will filter to actual shore tiles
|
||||
const mockPlayer = {
|
||||
isPlayer: () => true,
|
||||
smallID: () => 999, // Arbitrary ID for visualization
|
||||
borderTiles: function* () {
|
||||
for (const tile of ownedRefs) {
|
||||
yield tile;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Get target water component for filtering
|
||||
const targetComponent = game.getWaterComponent(targetRef);
|
||||
|
||||
// Pre-compute all valid shore tiles for visualization
|
||||
const allShores: TileRef[] = [];
|
||||
for (const tile of ownedRefs) {
|
||||
if (game.isShore(tile) && game.isLand(tile)) {
|
||||
const tComponent = game.getWaterComponent(tile);
|
||||
if (tComponent === targetComponent) {
|
||||
allShores.push(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable DebugSpan to capture internal state
|
||||
DebugSpan.enable();
|
||||
|
||||
// Run spatial query
|
||||
const spatialQuery = new SpatialQuery(game);
|
||||
const selectedShore = spatialQuery.closestShoreByWater(
|
||||
mockPlayer as any,
|
||||
targetRef,
|
||||
);
|
||||
|
||||
// Get span data
|
||||
const span = DebugSpan.getLastSpan();
|
||||
DebugSpan.disable();
|
||||
|
||||
// Extract debug info from span
|
||||
let candidates: TileRef[] | null = null;
|
||||
let refinedPath: TileRef[] | null = null;
|
||||
let originalBestTile: TileRef | null = null;
|
||||
let newBestTile: TileRef | null = null;
|
||||
|
||||
if (span?.data) {
|
||||
candidates = (span.data.$candidates as TileRef[] | undefined) ?? null;
|
||||
refinedPath = (span.data.$refinedPath as TileRef[] | undefined) ?? null;
|
||||
originalBestTile =
|
||||
(span.data.$originalBestTile as TileRef | undefined) ?? null;
|
||||
newBestTile = (span.data.$newBestTile as TileRef | undefined) ?? null;
|
||||
}
|
||||
|
||||
// Compute full path if we have a selected shore
|
||||
let path: TileRef[] | null = null;
|
||||
if (selectedShore) {
|
||||
path = PathFinding.Water(game).findPath(selectedShore, targetRef);
|
||||
}
|
||||
|
||||
const timings = span ? extractTimings(span) : {};
|
||||
|
||||
return {
|
||||
selectedShore: selectedShore ? tileToCoord(selectedShore, game) : null,
|
||||
path: tilesToCoords(path, game),
|
||||
shores: allShores.map((t) => tileToCoord(t, game)),
|
||||
debug: {
|
||||
candidates: tilesToCoords(candidates, game),
|
||||
refinedPath: tilesToCoords(refinedPath, game),
|
||||
originalBestTile: originalBestTile
|
||||
? tileToCoord(originalBestTile, game)
|
||||
: null,
|
||||
newBestTile: newBestTile ? tileToCoord(newBestTile, game) : null,
|
||||
timings,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,11 @@ const state = {
|
||||
isMapLoading: false, // Loading state for map switching
|
||||
isHpaLoading: false, // Separate loading state for HPA*
|
||||
activeRefreshButton: null, // Track which refresh button is spinning
|
||||
// Transport Ship mode
|
||||
mode: "pathfinding", // "pathfinding" | "transport"
|
||||
paintedTiles: new Set(), // Set of tile indices (y * width + x)
|
||||
brushSize: 5,
|
||||
transportResult: null, // Result from spatial query
|
||||
};
|
||||
|
||||
// Colors for comparison paths
|
||||
@@ -36,6 +41,8 @@ let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let dragStartPanX = 0;
|
||||
let dragStartPanY = 0;
|
||||
let isPainting = false;
|
||||
let isErasing = false;
|
||||
|
||||
let mapCanvas, overlayCanvas, interactiveCanvas;
|
||||
let mapCtx, overlayCtx, interactiveCtx;
|
||||
@@ -203,6 +210,109 @@ function initializeControls() {
|
||||
document.getElementById("clearPoints").addEventListener("click", () => {
|
||||
clearPoints();
|
||||
});
|
||||
|
||||
// Mode switch buttons
|
||||
document.querySelectorAll(".mode-button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const newMode = btn.dataset.mode;
|
||||
if (newMode !== state.mode) {
|
||||
setMode(newMode);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transport controls
|
||||
const brushSizeInput = document.getElementById("brushSize");
|
||||
const brushSizeValue = document.getElementById("brushSizeValue");
|
||||
brushSizeInput.addEventListener("input", (e) => {
|
||||
state.brushSize = parseInt(e.target.value);
|
||||
brushSizeValue.textContent = state.brushSize;
|
||||
});
|
||||
|
||||
document.getElementById("clearTerritory").addEventListener("click", () => {
|
||||
state.paintedTiles.clear();
|
||||
state.transportResult = null;
|
||||
updateTransportInfo();
|
||||
renderInteractive();
|
||||
});
|
||||
}
|
||||
|
||||
// Set application mode
|
||||
function setMode(newMode) {
|
||||
state.mode = newMode;
|
||||
|
||||
// Update UI
|
||||
document.querySelectorAll(".mode-button").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.mode === newMode);
|
||||
});
|
||||
|
||||
const transportControls = document.getElementById("transportControls");
|
||||
const timingsPanel = document.getElementById("timingsPanel");
|
||||
const debugPanel = document.querySelector(".debug-panel");
|
||||
|
||||
if (newMode === "transport") {
|
||||
transportControls.style.display = "block";
|
||||
timingsPanel.style.top = "280px";
|
||||
debugPanel.style.display = "none";
|
||||
setStatus("Paint territory, then click water target");
|
||||
} else {
|
||||
transportControls.style.display = "none";
|
||||
timingsPanel.style.top = "280px";
|
||||
debugPanel.style.display = "flex";
|
||||
if (state.startPoint && state.endPoint) {
|
||||
setStatus("Path computed successfully");
|
||||
} else if (state.startPoint) {
|
||||
setStatus("Click on map to set end point");
|
||||
} else {
|
||||
setStatus("Click on map to set start point");
|
||||
}
|
||||
}
|
||||
|
||||
renderInteractive();
|
||||
}
|
||||
|
||||
// Update transport info display
|
||||
function updateTransportInfo() {
|
||||
const paintedCount = document.getElementById("paintedCount");
|
||||
const shoreCount = document.getElementById("shoreCount");
|
||||
|
||||
paintedCount.textContent = state.paintedTiles.size;
|
||||
|
||||
// Count shore tiles
|
||||
let shores = 0;
|
||||
if (state.mapData) {
|
||||
for (const idx of state.paintedTiles) {
|
||||
if (isLandShore(idx)) {
|
||||
shores++;
|
||||
}
|
||||
}
|
||||
}
|
||||
shoreCount.textContent = shores;
|
||||
}
|
||||
|
||||
// Check if tile is a land shore (land adjacent to water)
|
||||
function isLandShore(tileIdx) {
|
||||
const x = tileIdx % state.mapWidth;
|
||||
const y = Math.floor(tileIdx / state.mapWidth);
|
||||
|
||||
// Must be land
|
||||
if (state.mapData[tileIdx] !== 0) return false;
|
||||
|
||||
// Check 4 neighbors for water
|
||||
const neighbors = [
|
||||
[x - 1, y],
|
||||
[x + 1, y],
|
||||
[x, y - 1],
|
||||
[x, y + 1],
|
||||
];
|
||||
|
||||
for (const [nx, ny] of neighbors) {
|
||||
if (nx < 0 || nx >= state.mapWidth || ny < 0 || ny >= state.mapHeight)
|
||||
continue;
|
||||
const nIdx = ny * state.mapWidth + nx;
|
||||
if (state.mapData[nIdx] === 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper function to check if mouse is over a start/end point
|
||||
@@ -250,6 +360,20 @@ function schedulePathRecalc() {
|
||||
// If not enough time has passed, skip this call (throttle)
|
||||
}
|
||||
|
||||
// Throttled spatial query recalculation (max once per 50ms for heavier computation)
|
||||
let lastSpatialQueryTime = 0;
|
||||
function scheduleSpatialQueryRecalc() {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCall = now - lastSpatialQueryTime;
|
||||
|
||||
if (timeSinceLastCall >= 50) {
|
||||
lastSpatialQueryTime = now;
|
||||
if (state.endPoint && state.paintedTiles.size > 0) {
|
||||
requestSpatialQuery(state.endPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize drag and click controls
|
||||
function initializeDragControls() {
|
||||
const wrapper = document.getElementById("canvasWrapper");
|
||||
@@ -260,10 +384,46 @@ function initializeDragControls() {
|
||||
const canvasX = (e.clientX - rect.left - panX) / zoomLevel;
|
||||
const canvasY = (e.clientY - rect.top - panY) / zoomLevel;
|
||||
|
||||
// Check if clicking on a point
|
||||
// Transport mode: check for dragging end point first, then painting
|
||||
if (state.mode === "transport") {
|
||||
// Check if clicking on end point to drag it
|
||||
const pointAtMouse = getPointAtPosition(canvasX, canvasY);
|
||||
if (pointAtMouse === "end") {
|
||||
draggingPoint = "end";
|
||||
wrapper.style.cursor = "move";
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
return;
|
||||
}
|
||||
|
||||
const tileX = Math.floor(canvasX);
|
||||
const tileY = Math.floor(canvasY);
|
||||
|
||||
if (
|
||||
tileX >= 0 &&
|
||||
tileX < state.mapWidth &&
|
||||
tileY >= 0 &&
|
||||
tileY < state.mapHeight
|
||||
) {
|
||||
const tileIdx = tileY * state.mapWidth + tileX;
|
||||
const isLand = state.mapData[tileIdx] === 0;
|
||||
|
||||
if (isLand) {
|
||||
// Start painting (or erasing with ctrl/right-click)
|
||||
isErasing = e.ctrlKey || e.button === 2;
|
||||
isPainting = true;
|
||||
paintAtPosition(tileX, tileY, isErasing);
|
||||
wrapper.style.cursor = isErasing ? "crosshair" : "pointer";
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fall through to panning if not on land
|
||||
}
|
||||
|
||||
// Pathfinding mode: check if clicking on a point
|
||||
const pointAtMouse = getPointAtPosition(canvasX, canvasY);
|
||||
|
||||
if (pointAtMouse) {
|
||||
if (pointAtMouse && state.mode === "pathfinding") {
|
||||
// Start dragging the point
|
||||
draggingPoint = pointAtMouse;
|
||||
wrapper.style.cursor = "move";
|
||||
@@ -284,6 +444,53 @@ function initializeDragControls() {
|
||||
const canvasX = (e.clientX - rect.left - panX) / zoomLevel;
|
||||
const canvasY = (e.clientY - rect.top - panY) / zoomLevel;
|
||||
|
||||
// Transport mode: continue painting
|
||||
if (isPainting && state.mode === "transport") {
|
||||
const tileX = Math.floor(canvasX);
|
||||
const tileY = Math.floor(canvasY);
|
||||
paintAtPosition(tileX, tileY, isErasing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Transport mode: dragging end point
|
||||
if (draggingPoint === "end" && state.mode === "transport") {
|
||||
const tileX = Math.floor(canvasX);
|
||||
const tileY = Math.floor(canvasY);
|
||||
|
||||
if (
|
||||
tileX >= 0 &&
|
||||
tileX < state.mapWidth &&
|
||||
tileY >= 0 &&
|
||||
tileY < state.mapHeight
|
||||
) {
|
||||
const tileIndex = tileY * state.mapWidth + tileX;
|
||||
const isWater = state.mapData[tileIndex] === 1;
|
||||
|
||||
if (isWater) {
|
||||
draggingPointPosition = [tileX, tileY];
|
||||
state.endPoint = [tileX, tileY];
|
||||
renderInteractive();
|
||||
|
||||
// Throttled spatial query recomputation
|
||||
if (state.paintedTiles.size > 0) {
|
||||
scheduleSpatialQueryRecalc();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Transport mode: check hover over end point
|
||||
if (state.mode === "transport" && !isDragging) {
|
||||
const pointAtMouse = getPointAtPosition(canvasX, canvasY);
|
||||
if (pointAtMouse !== hoveredPoint) {
|
||||
hoveredPoint = pointAtMouse;
|
||||
renderInteractive();
|
||||
wrapper.style.cursor = hoveredPoint ? "move" : "grab";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggingPoint) {
|
||||
// Dragging a start/end point - snap to water tile
|
||||
const tileX = Math.floor(canvasX);
|
||||
@@ -395,6 +602,26 @@ function initializeDragControls() {
|
||||
const dx = Math.abs(e.clientX - dragStartX);
|
||||
const dy = Math.abs(e.clientY - dragStartY);
|
||||
|
||||
// Transport mode: finish painting
|
||||
if (isPainting) {
|
||||
isPainting = false;
|
||||
isErasing = false;
|
||||
wrapper.style.cursor = "grab";
|
||||
return;
|
||||
}
|
||||
|
||||
// Transport mode: finish dragging end point
|
||||
if (draggingPoint === "end" && state.mode === "transport") {
|
||||
if (state.endPoint && state.paintedTiles.size > 0) {
|
||||
requestSpatialQuery(state.endPoint);
|
||||
}
|
||||
draggingPoint = null;
|
||||
draggingPointPosition = null;
|
||||
renderInteractive();
|
||||
wrapper.style.cursor = "grab";
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggingPoint) {
|
||||
// Finished dragging a point
|
||||
// Request final path update to ensure we have the path for the final position
|
||||
@@ -408,7 +635,11 @@ function initializeDragControls() {
|
||||
updateURLState();
|
||||
} else if (isDragging && dx < 5 && dy < 5) {
|
||||
// Was panning but didn't move much - treat as click
|
||||
handleMapClick(e);
|
||||
if (state.mode === "transport") {
|
||||
handleTransportClick(e);
|
||||
} else {
|
||||
handleMapClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
@@ -418,13 +649,16 @@ function initializeDragControls() {
|
||||
const canvasX = (e.clientX - rect.left - panX) / zoomLevel;
|
||||
const canvasY = (e.clientY - rect.top - panY) / zoomLevel;
|
||||
const pointAtMouse = getPointAtPosition(canvasX, canvasY);
|
||||
wrapper.style.cursor = pointAtMouse ? "move" : "grab";
|
||||
wrapper.style.cursor =
|
||||
pointAtMouse && state.mode === "pathfinding" ? "move" : "grab";
|
||||
});
|
||||
|
||||
wrapper.addEventListener("mouseleave", () => {
|
||||
isDragging = false;
|
||||
draggingPoint = null;
|
||||
draggingPointPosition = null;
|
||||
isPainting = false;
|
||||
isErasing = false;
|
||||
tooltip.classList.remove("visible");
|
||||
wrapper.style.cursor = "grab";
|
||||
|
||||
@@ -437,6 +671,13 @@ function initializeDragControls() {
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent context menu on right-click (for erasing)
|
||||
wrapper.addEventListener("contextmenu", (e) => {
|
||||
if (state.mode === "transport") {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -446,7 +687,7 @@ function initializeDragControls() {
|
||||
|
||||
const oldZoom = zoomLevel;
|
||||
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
zoomLevel = Math.max(0.1, Math.min(5, zoomLevel * zoomDelta));
|
||||
zoomLevel = Math.max(0.1, Math.min(10, zoomLevel * zoomDelta));
|
||||
|
||||
panX = mouseX - (mouseX - panX) * (zoomLevel / oldZoom);
|
||||
panY = mouseY - (mouseY - panY) * (zoomLevel / oldZoom);
|
||||
@@ -535,6 +776,155 @@ function clearPoints() {
|
||||
renderInteractive();
|
||||
}
|
||||
|
||||
// Paint tiles in a brush area
|
||||
function paintAtPosition(centerX, centerY, erase = false) {
|
||||
const radius = Math.floor(state.brushSize / 2);
|
||||
let changed = false;
|
||||
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = centerX + dx;
|
||||
const y = centerY + dy;
|
||||
|
||||
if (x < 0 || x >= state.mapWidth || y < 0 || y >= state.mapHeight)
|
||||
continue;
|
||||
|
||||
const idx = y * state.mapWidth + x;
|
||||
const isLand = state.mapData[idx] === 0;
|
||||
|
||||
if (!isLand) continue;
|
||||
|
||||
if (erase) {
|
||||
if (state.paintedTiles.has(idx)) {
|
||||
state.paintedTiles.delete(idx);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (!state.paintedTiles.has(idx)) {
|
||||
state.paintedTiles.add(idx);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
updateTransportInfo();
|
||||
renderInteractive();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clicks in transport mode
|
||||
function handleTransportClick(e) {
|
||||
if (!state.currentMap || state.isMapLoading) return;
|
||||
|
||||
const wrapper = document.getElementById("canvasWrapper");
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
|
||||
const canvasX = (e.clientX - rect.left - panX) / zoomLevel;
|
||||
const canvasY = (e.clientY - rect.top - panY) / zoomLevel;
|
||||
const tileX = Math.floor(canvasX);
|
||||
const tileY = Math.floor(canvasY);
|
||||
|
||||
if (
|
||||
tileX < 0 ||
|
||||
tileX >= state.mapWidth ||
|
||||
tileY < 0 ||
|
||||
tileY >= state.mapHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = tileY * state.mapWidth + tileX;
|
||||
const isWater = state.mapData[idx] === 1;
|
||||
|
||||
if (!isWater) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked on water - run spatial query
|
||||
if (state.paintedTiles.size === 0) {
|
||||
showError("Paint some territory first");
|
||||
return;
|
||||
}
|
||||
|
||||
requestSpatialQuery([tileX, tileY]);
|
||||
}
|
||||
|
||||
// Request spatial query computation
|
||||
async function requestSpatialQuery(target) {
|
||||
setStatus("Computing spatial query...", true);
|
||||
|
||||
try {
|
||||
// Only send shore tiles (land adjacent to water) - much smaller payload
|
||||
const ownedTiles = Array.from(state.paintedTiles).filter((idx) =>
|
||||
isLandShore(idx),
|
||||
);
|
||||
|
||||
const response = await fetch("/api/spatial-query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
map: state.currentMap,
|
||||
ownedTiles,
|
||||
target,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || "Spatial query failed");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
state.transportResult = result;
|
||||
state.endPoint = target;
|
||||
|
||||
renderInteractive();
|
||||
updateTransportTimings(result);
|
||||
|
||||
if (result.selectedShore) {
|
||||
setStatus(
|
||||
`Shore selected: (${result.selectedShore[0]}, ${result.selectedShore[1]})`,
|
||||
);
|
||||
} else {
|
||||
setStatus("No valid shore found");
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Spatial query failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update timings panel for transport mode
|
||||
function updateTransportTimings(result) {
|
||||
const hpaTimeEl = document.getElementById("hpaTime");
|
||||
const hpaTilesEl = document.getElementById("hpaTiles");
|
||||
|
||||
if (result.path) {
|
||||
hpaTilesEl.textContent = `- ${result.path.length} tiles`;
|
||||
} else {
|
||||
hpaTilesEl.textContent = "";
|
||||
}
|
||||
|
||||
const totalTime =
|
||||
result.debug?.timings?.["SpatialQuery.closestShoreByWater"] ?? 0;
|
||||
if (totalTime > 0) {
|
||||
hpaTimeEl.textContent = `${totalTime.toFixed(2)}ms`;
|
||||
hpaTimeEl.classList.remove("faded");
|
||||
} else {
|
||||
hpaTimeEl.textContent = "0.00ms";
|
||||
hpaTimeEl.classList.add("faded");
|
||||
}
|
||||
|
||||
// Hide pathfinding-specific timing breakdown in transport mode
|
||||
document.getElementById("timingEarlyExit").style.display = "none";
|
||||
document.getElementById("timingFindNodes").style.display = "none";
|
||||
document.getElementById("timingAbstractPath").style.display = "none";
|
||||
document.getElementById("timingInitialPath").style.display = "none";
|
||||
document.getElementById("timingSmoothPath").style.display = "none";
|
||||
document.getElementById("comparisonsSection").style.display = "none";
|
||||
}
|
||||
|
||||
// Update transform for pan/zoom
|
||||
function updateTransform() {
|
||||
const transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`;
|
||||
@@ -1164,6 +1554,135 @@ function mapToScreen(mapX, mapY) {
|
||||
};
|
||||
}
|
||||
|
||||
// Render transport mode elements
|
||||
function renderTransportMode() {
|
||||
const tileSize = Math.max(1, zoomLevel);
|
||||
|
||||
// Draw painted territory
|
||||
if (state.paintedTiles.size > 0) {
|
||||
interactiveCtx.fillStyle = "rgba(66, 135, 245, 0.5)";
|
||||
|
||||
for (const idx of state.paintedTiles) {
|
||||
const x = idx % state.mapWidth;
|
||||
const y = Math.floor(idx / state.mapWidth);
|
||||
const screen = mapToScreen(x, y);
|
||||
interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw all shore tiles (dark blue squares)
|
||||
if (state.transportResult && state.transportResult.shores) {
|
||||
interactiveCtx.fillStyle = "#2a4a6a";
|
||||
|
||||
for (const [x, y] of state.transportResult.shores) {
|
||||
const screen = mapToScreen(x, y);
|
||||
interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw refinement candidates (muted yellow/gold squares)
|
||||
if (state.transportResult?.debug?.candidates) {
|
||||
interactiveCtx.fillStyle = "rgba(200, 170, 80, 0.7)";
|
||||
|
||||
for (const [x, y] of state.transportResult.debug.candidates) {
|
||||
const screen = mapToScreen(x, y);
|
||||
interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw refined path (magenta)
|
||||
if (state.transportResult?.debug?.refinedPath) {
|
||||
interactiveCtx.strokeStyle = "#ff00ff";
|
||||
interactiveCtx.lineWidth = Math.max(1, zoomLevel * 0.8);
|
||||
interactiveCtx.lineCap = "round";
|
||||
interactiveCtx.lineJoin = "round";
|
||||
interactiveCtx.beginPath();
|
||||
|
||||
for (let i = 0; i < state.transportResult.debug.refinedPath.length; i++) {
|
||||
const [x, y] = state.transportResult.debug.refinedPath[i];
|
||||
const screen = mapToScreen(x + 0.5, y + 0.5);
|
||||
if (i === 0) {
|
||||
interactiveCtx.moveTo(screen.x, screen.y);
|
||||
} else {
|
||||
interactiveCtx.lineTo(screen.x, screen.y);
|
||||
}
|
||||
}
|
||||
interactiveCtx.stroke();
|
||||
}
|
||||
|
||||
// Draw full path (cyan)
|
||||
if (state.transportResult && state.transportResult.path) {
|
||||
interactiveCtx.strokeStyle = "#00ffff";
|
||||
interactiveCtx.lineWidth = Math.max(1, zoomLevel);
|
||||
interactiveCtx.lineCap = "round";
|
||||
interactiveCtx.lineJoin = "round";
|
||||
interactiveCtx.beginPath();
|
||||
|
||||
for (let i = 0; i < state.transportResult.path.length; i++) {
|
||||
const [x, y] = state.transportResult.path[i];
|
||||
const screen = mapToScreen(x + 0.5, y + 0.5);
|
||||
if (i === 0) {
|
||||
interactiveCtx.moveTo(screen.x, screen.y);
|
||||
} else {
|
||||
interactiveCtx.lineTo(screen.x, screen.y);
|
||||
}
|
||||
}
|
||||
interactiveCtx.stroke();
|
||||
}
|
||||
|
||||
// Draw original best tile (orange square) if different from new best
|
||||
if (state.transportResult?.debug?.originalBestTile) {
|
||||
const [ox, oy] = state.transportResult.debug.originalBestTile;
|
||||
const newBest = state.transportResult.debug.newBestTile;
|
||||
|
||||
// Only show if different from new best
|
||||
if (!newBest || ox !== newBest[0] || oy !== newBest[1]) {
|
||||
const screen = mapToScreen(ox, oy);
|
||||
interactiveCtx.fillStyle = "#ff8800";
|
||||
interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selected shore (green square)
|
||||
if (state.transportResult && state.transportResult.selectedShore) {
|
||||
const [sx, sy] = state.transportResult.selectedShore;
|
||||
const screen = mapToScreen(sx, sy);
|
||||
interactiveCtx.fillStyle = "#44ff44";
|
||||
interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize);
|
||||
}
|
||||
|
||||
// Draw target point (red circle, matching pathfinding mode style)
|
||||
if (state.endPoint) {
|
||||
const markerSize = Math.max(4, 3 * zoomLevel);
|
||||
let mapX, mapY;
|
||||
if (draggingPoint === "end" && draggingPointPosition) {
|
||||
mapX = draggingPointPosition[0] + 0.5;
|
||||
mapY = draggingPointPosition[1] + 0.5;
|
||||
} else {
|
||||
mapX = state.endPoint[0] + 0.5;
|
||||
mapY = state.endPoint[1] + 0.5;
|
||||
}
|
||||
|
||||
const screen = mapToScreen(mapX, mapY);
|
||||
|
||||
// Highlight ring if hovered
|
||||
if (hoveredPoint === "end") {
|
||||
interactiveCtx.strokeStyle = "#ff4444";
|
||||
interactiveCtx.lineWidth = Math.max(2, zoomLevel * 0.5);
|
||||
interactiveCtx.globalAlpha = 0.5;
|
||||
interactiveCtx.beginPath();
|
||||
interactiveCtx.arc(screen.x, screen.y, markerSize + 3, 0, Math.PI * 2);
|
||||
interactiveCtx.stroke();
|
||||
interactiveCtx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
interactiveCtx.fillStyle = "#ff4444";
|
||||
interactiveCtx.beginPath();
|
||||
interactiveCtx.arc(screen.x, screen.y, markerSize, 0, Math.PI * 2);
|
||||
interactiveCtx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Render truly interactive/dynamic overlay (paths, points, highlights) at screen coordinates
|
||||
function renderInteractive() {
|
||||
// Clear viewport-sized canvas (super fast!)
|
||||
@@ -1178,6 +1697,12 @@ function renderInteractive() {
|
||||
|
||||
const markerSize = Math.max(4, 3 * zoomLevel);
|
||||
|
||||
// Transport mode: render painted territory and results
|
||||
if (state.mode === "transport") {
|
||||
renderTransportMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check what to show
|
||||
const showUsedNodes =
|
||||
document.getElementById("showUsedNodes").dataset.active === "true";
|
||||
|
||||
@@ -118,11 +118,88 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
class="mode-button active"
|
||||
id="modePathfinding"
|
||||
data-mode="pathfinding"
|
||||
>
|
||||
Pathfinding
|
||||
</button>
|
||||
<button class="mode-button" id="modeTransport" data-mode="transport">
|
||||
Transport Ship
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<span id="status">Select a scenario to begin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport Ship controls (only visible in transport mode) -->
|
||||
<div
|
||||
class="transport-controls"
|
||||
id="transportControls"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="transport-legend">
|
||||
<div class="transport-legend-item">
|
||||
<div
|
||||
class="transport-legend-color"
|
||||
style="background: rgba(66, 135, 245, 0.7)"
|
||||
></div>
|
||||
<span>Territory</span>
|
||||
</div>
|
||||
<div class="transport-legend-item">
|
||||
<div class="transport-legend-color" style="background: #2a4a6a"></div>
|
||||
<span>Shores</span>
|
||||
</div>
|
||||
<div class="transport-legend-item">
|
||||
<div
|
||||
class="transport-legend-color"
|
||||
style="background: rgba(200, 170, 80, 0.9)"
|
||||
></div>
|
||||
<span>Candidates</span>
|
||||
</div>
|
||||
<div class="transport-legend-item">
|
||||
<div class="transport-legend-color" style="background: #ff8800"></div>
|
||||
<span>Original</span>
|
||||
</div>
|
||||
<div class="transport-legend-item">
|
||||
<div class="transport-legend-color" style="background: #44ff44"></div>
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
<div class="transport-legend-item">
|
||||
<div
|
||||
class="transport-legend-color"
|
||||
style="background: #00ffff; height: 2px"
|
||||
></div>
|
||||
<span>Path</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-control-row">
|
||||
<label>Brush Size:</label>
|
||||
<input
|
||||
type="range"
|
||||
id="brushSize"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
value="5"
|
||||
/>
|
||||
<span id="brushSizeValue">5</span>
|
||||
</div>
|
||||
<div class="transport-control-row">
|
||||
<button id="clearTerritory" class="clear-button">
|
||||
Clear Territory
|
||||
</button>
|
||||
</div>
|
||||
<div class="transport-info">
|
||||
<span>Painted tiles: <strong id="paintedCount">0</strong></span>
|
||||
<span>Shores: <strong id="shoreCount">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug controls panel (left) -->
|
||||
<div class="debug-panel">
|
||||
<div class="debug-panel-row">
|
||||
@@ -149,7 +226,7 @@
|
||||
<!-- View controls panel (right) -->
|
||||
<div class="view-panel">
|
||||
<div class="zoom-control">
|
||||
<input type="range" id="zoom" min="0.1" max="5" step="0.1" value="1" />
|
||||
<input type="range" id="zoom" min="0.1" max="10" step="0.1" value="1" />
|
||||
<span id="zoomValue">1.0x</span>
|
||||
</div>
|
||||
<button class="toggle-button" id="showColoredMap" data-active="false">
|
||||
|
||||
@@ -369,7 +369,7 @@ canvas {
|
||||
/* Timings panel (left side) */
|
||||
.timings-panel {
|
||||
position: fixed;
|
||||
top: 250px;
|
||||
top: 280px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -858,3 +858,121 @@ button:disabled {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode switch */
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #404040;
|
||||
}
|
||||
|
||||
.mode-button {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
color: #888;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mode-button:hover {
|
||||
background: #2a2a2a;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.mode-button.active {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-button.active:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
/* Transport Ship controls - positioned at bottom left like debug panel */
|
||||
.transport-controls {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.transport-control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.transport-control-row label {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.transport-control-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.transport-control-row span {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.transport-control-row .clear-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transport-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #404040;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.transport-info strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.transport-legend {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #404040;
|
||||
}
|
||||
|
||||
.transport-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.transport-legend-color {
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listMaps,
|
||||
} from "./api/maps.js";
|
||||
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
|
||||
import { computeSpatialQuery } from "./api/spatialQuery.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 5555;
|
||||
@@ -156,6 +157,58 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/spatial-query
|
||||
* Compute spatial query for transport ship (closestShoreByWater)
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* map: string,
|
||||
* ownedTiles: number[], // Array of tile indices (y * width + x)
|
||||
* target: [x, y]
|
||||
* }
|
||||
*/
|
||||
app.post("/api/spatial-query", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, ownedTiles, target } = req.body;
|
||||
|
||||
if (!map || !ownedTiles || !target) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid request",
|
||||
message: "Missing required fields: map, ownedTiles, target",
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(ownedTiles)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid ownedTiles",
|
||||
message: "ownedTiles must be an array of tile indices",
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(target) || target.length !== 2) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid target",
|
||||
message: "target must be [x, y] coordinate array",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await computeSpatialQuery(
|
||||
map,
|
||||
ownedTiles,
|
||||
target as [number, number],
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error computing spatial query:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to compute spatial query",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cache/clear
|
||||
* Clear all caches (useful for development)
|
||||
|
||||
@@ -75,6 +75,12 @@ const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
const findAllyBreak = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break");
|
||||
|
||||
const findAllyBreakConfirm = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_confirm");
|
||||
|
||||
const findAllyBreakCancel = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_cancel");
|
||||
|
||||
describe("RadialMenuElements ally break", () => {
|
||||
test("shows break option with correct color when allied", () => {
|
||||
const params = makeParams();
|
||||
@@ -85,12 +91,29 @@ describe("RadialMenuElements ally break", () => {
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("action calls handleBreakAlliance and closes menu", () => {
|
||||
test("break option opens confirmation submenu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
|
||||
ally.action!(params);
|
||||
expect(ally.subMenu).toBeDefined();
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
expect(subMenuItems.length).toBe(2);
|
||||
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems);
|
||||
expect(confirmItem).toBeTruthy();
|
||||
expect(cancelItem).toBeTruthy();
|
||||
});
|
||||
|
||||
test("confirm action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems)!;
|
||||
|
||||
confirmItem.action!(params);
|
||||
|
||||
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
|
||||
params.myPlayer,
|
||||
@@ -98,4 +121,19 @@ describe("RadialMenuElements ally break", () => {
|
||||
);
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cancel action closes menu without breaking alliance", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems)!;
|
||||
|
||||
cancelItem.action!(params);
|
||||
|
||||
expect(
|
||||
params.playerActionHandler.handleBreakAlliance,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
|
||||
getServerConfigFromServer: () => ({
|
||||
otelEnabled: () => false,
|
||||
otelAuthHeader: () => "",
|
||||
otelEndpoint: () => "",
|
||||
env: () => 0, // GameEnv.Dev
|
||||
}),
|
||||
getServerConfig: () => ({
|
||||
otelEnabled: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/core/Schemas", async () => {
|
||||
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
|
||||
return {
|
||||
...actual,
|
||||
GameStartInfoSchema: {
|
||||
safeParse: (data: any) => ({ success: true, data: data }),
|
||||
},
|
||||
ServerPrestartMessageSchema: {
|
||||
safeParse: (data: any) => ({ success: true, data: data }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { GameEnv } from "../../src/core/configuration/Config";
|
||||
import { GameType } from "../../src/core/game/Game";
|
||||
import { GameServer } from "../../src/server/GameServer";
|
||||
|
||||
describe("GameLifecycle", () => {
|
||||
let mockLogger: any;
|
||||
let mockConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockLogger = {
|
||||
child: vi.fn().mockReturnThis(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
mockConfig = {
|
||||
turnIntervalMs: () => 100,
|
||||
gameCreationRate: () => 1000,
|
||||
env: () => GameEnv.Dev,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("should not start turn interval if game has ended", async () => {
|
||||
const game = new GameServer(
|
||||
"test-game",
|
||||
mockLogger,
|
||||
Date.now(),
|
||||
mockConfig,
|
||||
{ gameType: GameType.Private } as any,
|
||||
);
|
||||
|
||||
// Call end() first - this should set _hasEnded
|
||||
await game.end();
|
||||
|
||||
// Now call start() - this should be a no-op due to our fix
|
||||
game.start();
|
||||
|
||||
// Check if the interval ID is set (it shouldn't be)
|
||||
expect((game as any).endTurnIntervalID).toBeUndefined();
|
||||
|
||||
// Check if _hasStarted remained false (or at least no interval was created)
|
||||
expect(game.hasStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("should clear turn interval and set _hasEnded on end()", async () => {
|
||||
// We need to initialize the game such that start() can succeed
|
||||
const game = new GameServer(
|
||||
"test-game",
|
||||
mockLogger,
|
||||
Date.now(),
|
||||
mockConfig,
|
||||
{
|
||||
gameType: GameType.Private,
|
||||
gameMap: "plains",
|
||||
gameMapSize: 100,
|
||||
} as any,
|
||||
);
|
||||
|
||||
// Manually trigger prestart to fulfill some internal checks if necessary
|
||||
game.prestart();
|
||||
|
||||
// start() should create the interval
|
||||
game.start();
|
||||
expect((game as any).endTurnIntervalID).toBeDefined();
|
||||
|
||||
// end() should clear it
|
||||
await game.end();
|
||||
expect((game as any).endTurnIntervalID).toBeUndefined();
|
||||
expect((game as any)._hasEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("should be resilient to multiple end() calls", async () => {
|
||||
const game = new GameServer(
|
||||
"test-game",
|
||||
mockLogger,
|
||||
Date.now(),
|
||||
mockConfig,
|
||||
{ gameType: GameType.Private } as any,
|
||||
);
|
||||
|
||||
await game.end();
|
||||
expect((game as any)._hasEnded).toBe(true);
|
||||
|
||||
// Should not throw or crash
|
||||
await expect(game.end()).resolves.toBeUndefined();
|
||||
expect((game as any)._hasEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startPolling } from "../../src/server/PollingLoop";
|
||||
|
||||
vi.mock("../../src/server/Logger", () => ({
|
||||
logger: {
|
||||
child: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PollingLoop", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should not start the next task until the previous one completes", async () => {
|
||||
let taskCallCount = 0;
|
||||
let resolveTask: ((value?: void) => void) | undefined;
|
||||
|
||||
const task = vi.fn().mockImplementation(() => {
|
||||
taskCallCount++;
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
startPolling(task, 100);
|
||||
|
||||
// Initial call
|
||||
expect(taskCallCount).toBe(1);
|
||||
|
||||
// Advance time past the interval - should NOT trigger next call yet
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(taskCallCount).toBe(1);
|
||||
|
||||
// Resolve the first task
|
||||
if (resolveTask) resolveTask();
|
||||
|
||||
// Wait for microtasks (promise callbacks, finally block) to run
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// NOW advance time to trigger the scheduled continuation
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(taskCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should continue polling even if a task fails", async () => {
|
||||
let taskCallCount = 0;
|
||||
const task = vi.fn().mockImplementation(async () => {
|
||||
taskCallCount++;
|
||||
if (taskCallCount === 1) {
|
||||
throw new Error("Task failed");
|
||||
}
|
||||
});
|
||||
|
||||
startPolling(task, 100);
|
||||
|
||||
// First call
|
||||
expect(taskCallCount).toBe(1);
|
||||
|
||||
// Wait for rejection and finally block
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// Advance time
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Second call
|
||||
expect(taskCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export class TestServerConfig implements ServerConfig {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getRandomPublicGameModifiers(): PublicGameModifiers {
|
||||
return { isCompact: false, isRandomSpawn: false };
|
||||
return { isCompact: false, isRandomSpawn: false, isCrowded: false };
|
||||
}
|
||||
async supportsCompactMapForTeams(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
||||
Reference in New Issue
Block a user