Merge branch 'main' into local-attack

This commit is contained in:
Aotumuri
2026-01-29 12:45:45 +09:00
committed by GitHub
161 changed files with 9232 additions and 2455 deletions
+163
View File
@@ -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
View File
@@ -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);
});
+3 -21
View File
@@ -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);
+19 -12
View File
@@ -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),
);
+4
View File
@@ -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");
+61
View File
@@ -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
View File
@@ -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();
});
});
+2
View File
@@ -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,
},
};
}
+530 -5
View File
@@ -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";
+78 -1
View File
@@ -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">
+119 -1
View File
@@ -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;
}
+53
View File
@@ -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();
});
});
+121
View File
@@ -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);
});
});
+77
View File
@@ -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);
});
});
+1 -1
View File
@@ -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.");