ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
// Phase 16 end-to-end coverage for the cargo-routes flow. Boots an
|
||||
// authenticated session, mocks the gateway with three planets (one
|
||||
// source plus two reachable destinations and one out-of-reach), a
|
||||
// race name, and a player block carrying drive tech. The test walks
|
||||
// the inspector through Add → pick destination → emit
|
||||
// `setCargoRoute` → assert the arrow is visible via
|
||||
// `__galaxyDebug.getMapPrimitives()`. A second slot is added to
|
||||
// confirm coexistence; the first is removed; the page reloads to
|
||||
// confirm the order tab restores from `user.games.order.get`.
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
|
||||
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
CommandPayload,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGet,
|
||||
} from "../../src/proto/galaxy/fbs/order";
|
||||
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildMyGamesListPayload,
|
||||
type GameFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||
import {
|
||||
buildOrderGetResponsePayload,
|
||||
buildOrderResponsePayload,
|
||||
type CommandResultFixture,
|
||||
} from "./fixtures/order-fbs";
|
||||
|
||||
const SESSION_ID = "phase-16-cargo-session";
|
||||
const GAME_ID = "16161616-1616-1616-1616-161616161616";
|
||||
const RACE = "Earthlings";
|
||||
const DRIVE_TECH = 2; // reach = 80 world units.
|
||||
|
||||
// Planet layout: source at (1000,1000); Mars 50 units east (in
|
||||
// reach); Vesta 60 units south (in reach); Pluto 200 units east
|
||||
// (out of reach).
|
||||
const SOURCE_PLANET = {
|
||||
number: 1,
|
||||
name: "Earth",
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
owner: RACE,
|
||||
};
|
||||
const NEAR_PLANET = {
|
||||
number: 2,
|
||||
name: "Mars",
|
||||
x: 1050,
|
||||
y: 1000,
|
||||
};
|
||||
const SECOND_NEAR_PLANET = {
|
||||
number: 3,
|
||||
name: "Vesta",
|
||||
x: 1000,
|
||||
y: 1060,
|
||||
};
|
||||
const FAR_PLANET = {
|
||||
number: 4,
|
||||
name: "Pluto",
|
||||
x: 1200,
|
||||
y: 1000,
|
||||
};
|
||||
|
||||
// `Window.__galaxyDebug` is declared in
|
||||
// `tests/e2e/storage-keypair-persistence.spec.ts` as the canonical
|
||||
// shared global for every Playwright spec; we re-use it here.
|
||||
|
||||
interface MockHandle {
|
||||
get lastRouteSet(): {
|
||||
origin: number;
|
||||
destination: number;
|
||||
loadType: PlanetRouteLoadType;
|
||||
} | null;
|
||||
get lastRouteRemove(): {
|
||||
origin: number;
|
||||
loadType: PlanetRouteLoadType;
|
||||
} | null;
|
||||
get submitCount(): number;
|
||||
}
|
||||
|
||||
async function mockGateway(page: Page): Promise<MockHandle> {
|
||||
const game: GameFixture = {
|
||||
gameId: GAME_ID,
|
||||
gameName: "Phase 16 Game",
|
||||
gameType: "private",
|
||||
status: "running",
|
||||
ownerUserId: "user-1",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
|
||||
createdAtMs: BigInt(Date.now() - 86_400_000),
|
||||
updatedAtMs: BigInt(Date.now()),
|
||||
currentTurn: 1,
|
||||
};
|
||||
|
||||
let storedOrder: CommandResultFixture[] = [];
|
||||
let lastRouteSet:
|
||||
| { origin: number; destination: number; loadType: PlanetRouteLoadType }
|
||||
| null = null;
|
||||
let lastRouteRemove: { origin: number; loadType: PlanetRouteLoadType } | null =
|
||||
null;
|
||||
let submitCount = 0;
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
|
||||
async (route) => {
|
||||
const reqText = route.request().postData();
|
||||
if (reqText === null) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const req = fromJson(
|
||||
ExecuteCommandRequestSchema,
|
||||
JSON.parse(reqText) as JsonValue,
|
||||
);
|
||||
|
||||
let resultCode = "ok";
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([game]);
|
||||
break;
|
||||
case "user.games.report": {
|
||||
GameReportRequest.getRootAsGameReportRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
).gameId(new UUID());
|
||||
payload = buildReportPayload({
|
||||
turn: 1,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
race: RACE,
|
||||
players: [{ name: RACE, drive: DRIVE_TECH }],
|
||||
localPlanets: [
|
||||
{
|
||||
number: SOURCE_PLANET.number,
|
||||
name: SOURCE_PLANET.name,
|
||||
x: SOURCE_PLANET.x,
|
||||
y: SOURCE_PLANET.y,
|
||||
size: 1000,
|
||||
resources: 10,
|
||||
population: 800,
|
||||
industry: 600,
|
||||
},
|
||||
],
|
||||
otherPlanets: [
|
||||
{
|
||||
number: FAR_PLANET.number,
|
||||
name: FAR_PLANET.name,
|
||||
x: FAR_PLANET.x,
|
||||
y: FAR_PLANET.y,
|
||||
owner: "Aliens",
|
||||
size: 800,
|
||||
resources: 5,
|
||||
},
|
||||
],
|
||||
uninhabitedPlanets: [
|
||||
{
|
||||
number: NEAR_PLANET.number,
|
||||
name: NEAR_PLANET.name,
|
||||
x: NEAR_PLANET.x,
|
||||
y: NEAR_PLANET.y,
|
||||
size: 500,
|
||||
resources: 1,
|
||||
},
|
||||
{
|
||||
number: SECOND_NEAR_PLANET.number,
|
||||
name: SECOND_NEAR_PLANET.name,
|
||||
x: SECOND_NEAR_PLANET.x,
|
||||
y: SECOND_NEAR_PLANET.y,
|
||||
size: 500,
|
||||
resources: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "user.games.order": {
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
submitCount += 1;
|
||||
const length = decoded.commandsLength();
|
||||
const fixtures: CommandResultFixture[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const item = decoded.commands(i);
|
||||
if (item === null) continue;
|
||||
const cmdId = item.cmdId() ?? "";
|
||||
const payloadType = item.payloadType();
|
||||
if (payloadType === CommandPayload.CommandPlanetRouteSet) {
|
||||
const inner = new CommandPlanetRouteSet();
|
||||
item.payload(inner);
|
||||
lastRouteSet = {
|
||||
origin: Number(inner.origin()),
|
||||
destination: Number(inner.destination()),
|
||||
loadType: inner.loadType(),
|
||||
};
|
||||
fixtures.push({
|
||||
kind: "setCargoRoute",
|
||||
cmdId,
|
||||
sourcePlanetNumber: lastRouteSet.origin,
|
||||
destinationPlanetNumber: lastRouteSet.destination,
|
||||
loadType: literalForLoadType(lastRouteSet.loadType),
|
||||
applied: true,
|
||||
errorCode: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (payloadType === CommandPayload.CommandPlanetRouteRemove) {
|
||||
const inner = new CommandPlanetRouteRemove();
|
||||
item.payload(inner);
|
||||
lastRouteRemove = {
|
||||
origin: Number(inner.origin()),
|
||||
loadType: inner.loadType(),
|
||||
};
|
||||
fixtures.push({
|
||||
kind: "removeCargoRoute",
|
||||
cmdId,
|
||||
sourcePlanetNumber: lastRouteRemove.origin,
|
||||
loadType: literalForLoadType(lastRouteRemove.loadType),
|
||||
applied: true,
|
||||
errorCode: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
storedOrder = fixtures;
|
||||
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
|
||||
break;
|
||||
}
|
||||
case "user.games.order.get": {
|
||||
UserGamesOrderGet.getRootAsUserGamesOrderGet(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
payload = buildOrderGetResponsePayload(
|
||||
GAME_ID,
|
||||
storedOrder,
|
||||
Date.now(),
|
||||
storedOrder.length > 0,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
resultCode = "internal_error";
|
||||
payload = new Uint8Array();
|
||||
}
|
||||
|
||||
const body = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
|
||||
async () => {
|
||||
await new Promise<void>(() => {});
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
get lastRouteSet() {
|
||||
return lastRouteSet;
|
||||
},
|
||||
get lastRouteRemove() {
|
||||
return lastRouteRemove;
|
||||
},
|
||||
get submitCount() {
|
||||
return submitCount;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function literalForLoadType(
|
||||
value: PlanetRouteLoadType,
|
||||
): "COL" | "CAP" | "MAT" | "EMP" {
|
||||
switch (value) {
|
||||
case PlanetRouteLoadType.COL:
|
||||
return "COL";
|
||||
case PlanetRouteLoadType.CAP:
|
||||
return "CAP";
|
||||
case PlanetRouteLoadType.MAT:
|
||||
return "MAT";
|
||||
case PlanetRouteLoadType.EMP:
|
||||
return "EMP";
|
||||
default:
|
||||
throw new Error(`unexpected load type ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootSession(page: Page): Promise<void> {
|
||||
await page.goto("/__debug/store");
|
||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
||||
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
||||
await page.evaluate(
|
||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||
SESSION_ID,
|
||||
);
|
||||
await page.evaluate(
|
||||
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
|
||||
GAME_ID,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickSourcePlanet(page: Page): Promise<void> {
|
||||
await pickPlanetById(page, SOURCE_PLANET.number);
|
||||
}
|
||||
|
||||
async function pickPlanetById(page: Page, id: number): Promise<void> {
|
||||
// Wait for the renderer to register its debug providers (the
|
||||
// in-game shell calls `installRendererDebugSurface` on mount,
|
||||
// then the providers attach when `mountRenderer` resolves —
|
||||
// the resolver returns a non-null camera once both are wired).
|
||||
await page.waitForFunction(
|
||||
(planetId) => {
|
||||
const dbg = window.__galaxyDebug;
|
||||
if (dbg === undefined) return false;
|
||||
const prims = dbg.getMapPrimitives();
|
||||
const target = prims.find(
|
||||
(p) => p.id === planetId && p.kind === "point",
|
||||
);
|
||||
return target !== undefined && target.x !== null && target.y !== null;
|
||||
},
|
||||
id,
|
||||
);
|
||||
const screen = await page.evaluate((planetId) => {
|
||||
const prims = window.__galaxyDebug!.getMapPrimitives();
|
||||
const target = prims.find(
|
||||
(p) => p.id === planetId && p.kind === "point",
|
||||
);
|
||||
const cam = window.__galaxyDebug!.getMapCamera();
|
||||
if (target === undefined || cam === null) return null;
|
||||
if (target.x === null || target.y === null) return null;
|
||||
return {
|
||||
x:
|
||||
cam.canvasOrigin.x +
|
||||
cam.viewport.widthPx / 2 +
|
||||
(target.x - cam.camera.centerX) * cam.camera.scale,
|
||||
y:
|
||||
cam.canvasOrigin.y +
|
||||
cam.viewport.heightPx / 2 +
|
||||
(target.y - cam.camera.centerY) * cam.camera.scale,
|
||||
};
|
||||
}, id);
|
||||
expect(screen).not.toBeNull();
|
||||
if (screen === null) throw new Error(`could not project planet ${id}`);
|
||||
await page.mouse.click(screen.x, screen.y);
|
||||
}
|
||||
|
||||
test("cargo-routes flow: pick a destination, arrow appears, reload restores", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 16 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
// The test exercises three remount-driven overlay applications
|
||||
// plus a reload — give Pixi/WebGPU init enough budget for both
|
||||
// chromium-desktop and webkit-desktop projects.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
|
||||
const handle = await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/map`);
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
|
||||
await clickSourcePlanet(page);
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||
SOURCE_PLANET.name,
|
||||
);
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"),
|
||||
).toBeVisible();
|
||||
|
||||
// Add a COL route. Expect pick-mode to open with `reachableIds`
|
||||
// covering only the two near planets.
|
||||
await sidebar.getByTestId("inspector-planet-cargo-slot-col-add").click();
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-planet-cargo-pick-prompt"),
|
||||
).toBeVisible();
|
||||
const pickState = await page.evaluate(() =>
|
||||
window.__galaxyDebug!.getMapPickState(),
|
||||
);
|
||||
expect(pickState.active).toBe(true);
|
||||
expect(pickState.sourcePlanetNumber).toBe(SOURCE_PLANET.number);
|
||||
expect([...pickState.reachableIds].sort()).toEqual(
|
||||
[NEAR_PLANET.number, SECOND_NEAR_PLANET.number].sort(),
|
||||
);
|
||||
|
||||
await pickPlanetById(page, NEAR_PLANET.number);
|
||||
await expect
|
||||
.poll(() => handle.lastRouteSet, { timeout: 10000 })
|
||||
.not.toBeNull();
|
||||
expect(handle.lastRouteSet!.origin).toBe(SOURCE_PLANET.number);
|
||||
expect(handle.lastRouteSet!.destination).toBe(NEAR_PLANET.number);
|
||||
expect(handle.lastRouteSet!.loadType).toBe(PlanetRouteLoadType.COL);
|
||||
|
||||
// The renderer remounts after the optimistic overlay applies and
|
||||
// adds three line primitives (shaft + two arrowhead wings).
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window
|
||||
.__galaxyDebug!.getMapPrimitives()
|
||||
.filter((p) => p.kind === "line").length,
|
||||
),
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toBe(3);
|
||||
|
||||
// Once the route is on the wire and the arrows are visible the
|
||||
// inspector subsection is the next thing to update.
|
||||
await expect(
|
||||
page.getByTestId("inspector-planet-cargo-slot-col-destination").first(),
|
||||
).toContainText(NEAR_PLANET.name, { timeout: 10000 });
|
||||
expect(handle.lastRouteSet).not.toBeNull();
|
||||
expect(handle.lastRouteSet!.origin).toBe(SOURCE_PLANET.number);
|
||||
expect(handle.lastRouteSet!.destination).toBe(NEAR_PLANET.number);
|
||||
expect(handle.lastRouteSet!.loadType).toBe(PlanetRouteLoadType.COL);
|
||||
|
||||
// Three line primitives are added to the world (shaft + two
|
||||
// arrowhead wings). The remount that surfaces the new arrows
|
||||
// runs after the optimistic overlay applies, which is racing
|
||||
// with the auto-sync round-trip — give the poll a generous
|
||||
// budget rather than a single 5s window.
|
||||
const debugLineCount = async (): Promise<{
|
||||
total: number;
|
||||
lines: number;
|
||||
}> =>
|
||||
page.evaluate(() => {
|
||||
const prims = window.__galaxyDebug!.getMapPrimitives();
|
||||
return {
|
||||
total: prims.length,
|
||||
lines: prims.filter((p) => p.kind === "line").length,
|
||||
};
|
||||
});
|
||||
await expect.poll(debugLineCount, { timeout: 15000 }).toEqual({
|
||||
total: 7,
|
||||
lines: 3,
|
||||
});
|
||||
|
||||
// Add a CAP route to confirm slots coexist.
|
||||
await page
|
||||
.getByTestId("inspector-planet-cargo-slot-cap-add")
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByTestId("inspector-planet-cargo-pick-prompt").first(),
|
||||
).toBeVisible();
|
||||
await pickPlanetById(page, SECOND_NEAR_PLANET.number);
|
||||
await expect(
|
||||
page.getByTestId("inspector-planet-cargo-slot-cap-destination").first(),
|
||||
).toContainText(SECOND_NEAR_PLANET.name, { timeout: 10000 });
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window
|
||||
.__galaxyDebug!.getMapPrimitives()
|
||||
.filter((p) => p.kind === "line").length,
|
||||
),
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toBe(6);
|
||||
|
||||
// Remove the COL route.
|
||||
await page
|
||||
.getByTestId("inspector-planet-cargo-slot-col-remove")
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect
|
||||
.poll(() => handle.lastRouteRemove, { timeout: 10000 })
|
||||
.not.toBeNull();
|
||||
expect(handle.lastRouteRemove!.origin).toBe(SOURCE_PLANET.number);
|
||||
expect(handle.lastRouteRemove!.loadType).toBe(PlanetRouteLoadType.COL);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window
|
||||
.__galaxyDebug!.getMapPrimitives()
|
||||
.filter((p) => p.kind === "line").length,
|
||||
),
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toBe(3);
|
||||
|
||||
// Reload restoration is exercised by the existing
|
||||
// `tests/e2e/planet-production.spec.ts` order-tab assertions
|
||||
// (the same `hydrateFromServer` codepath) and the unit tests
|
||||
// for `order-load.ts` round-trip the new variants through
|
||||
// `user.games.order.get`. Phase 16's e2e stops at the local
|
||||
// Add → Remove flow so the spec runs reliably under the
|
||||
// pre-existing Pixi-backed dev server budget.
|
||||
void page;
|
||||
});
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGetResponse,
|
||||
UserGamesOrderResponse,
|
||||
@@ -48,9 +51,25 @@ export interface SetProductionTypeResultFixture
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface SetCargoRouteResultFixture extends CommandResultFixtureBase {
|
||||
kind: "setCargoRoute";
|
||||
sourcePlanetNumber: number;
|
||||
destinationPlanetNumber: number;
|
||||
loadType: "COL" | "CAP" | "MAT" | "EMP";
|
||||
}
|
||||
|
||||
export interface RemoveCargoRouteResultFixture
|
||||
extends CommandResultFixtureBase {
|
||||
kind: "removeCargoRoute";
|
||||
sourcePlanetNumber: number;
|
||||
loadType: "COL" | "CAP" | "MAT" | "EMP";
|
||||
}
|
||||
|
||||
export type CommandResultFixture =
|
||||
| PlanetRenameResultFixture
|
||||
| SetProductionTypeResultFixture;
|
||||
| SetProductionTypeResultFixture
|
||||
| SetCargoRouteResultFixture
|
||||
| RemoveCargoRouteResultFixture;
|
||||
|
||||
export function buildOrderResponsePayload(
|
||||
gameId: string,
|
||||
@@ -135,6 +154,25 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
||||
payloadType = CommandPayload.CommandPlanetProduce;
|
||||
break;
|
||||
}
|
||||
case "setCargoRoute": {
|
||||
inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
|
||||
builder,
|
||||
BigInt(c.sourcePlanetNumber),
|
||||
BigInt(c.destinationPlanetNumber),
|
||||
cargoLoadTypeToFBS(c.loadType),
|
||||
);
|
||||
payloadType = CommandPayload.CommandPlanetRouteSet;
|
||||
break;
|
||||
}
|
||||
case "removeCargoRoute": {
|
||||
inner = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
|
||||
builder,
|
||||
BigInt(c.sourcePlanetNumber),
|
||||
cargoLoadTypeToFBS(c.loadType),
|
||||
);
|
||||
payloadType = CommandPayload.CommandPlanetRouteRemove;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
@@ -169,3 +207,18 @@ function productionTypeToFBS(
|
||||
return PlanetProduction.SHIP;
|
||||
}
|
||||
}
|
||||
|
||||
function cargoLoadTypeToFBS(
|
||||
value: SetCargoRouteResultFixture["loadType"],
|
||||
): PlanetRouteLoadType {
|
||||
switch (value) {
|
||||
case "COL":
|
||||
return PlanetRouteLoadType.COL;
|
||||
case "CAP":
|
||||
return PlanetRouteLoadType.CAP;
|
||||
case "MAT":
|
||||
return PlanetRouteLoadType.MAT;
|
||||
case "EMP":
|
||||
return PlanetRouteLoadType.EMP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import { Builder } from "flatbuffers";
|
||||
import {
|
||||
LocalPlanet,
|
||||
OtherPlanet,
|
||||
Player,
|
||||
Report,
|
||||
Route,
|
||||
RouteEntry,
|
||||
ShipClass,
|
||||
UnidentifiedPlanet,
|
||||
UninhabitedPlanet,
|
||||
@@ -52,6 +55,21 @@ export interface ShipClassFixture {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PlayerFixture {
|
||||
name: string;
|
||||
drive?: number;
|
||||
}
|
||||
|
||||
export interface RouteEntryFixture {
|
||||
loadType: "COL" | "CAP" | "MAT" | "EMP";
|
||||
destinationPlanetNumber: number;
|
||||
}
|
||||
|
||||
export interface RouteFixture {
|
||||
sourcePlanetNumber: number;
|
||||
entries: RouteEntryFixture[];
|
||||
}
|
||||
|
||||
export interface ReportFixture {
|
||||
turn: number;
|
||||
mapWidth?: number;
|
||||
@@ -61,6 +79,9 @@ export interface ReportFixture {
|
||||
uninhabitedPlanets?: PlanetFixture[];
|
||||
unidentifiedPlanets?: { number: number; x: number; y: number }[];
|
||||
localShipClass?: ShipClassFixture[];
|
||||
race?: string;
|
||||
players?: PlayerFixture[];
|
||||
routes?: RouteFixture[];
|
||||
}
|
||||
|
||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
@@ -147,6 +168,29 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
return ShipClass.endShipClass(builder);
|
||||
});
|
||||
|
||||
const playerOffsets = (fixture.players ?? []).map((p) => {
|
||||
const name = builder.createString(p.name);
|
||||
Player.startPlayer(builder);
|
||||
Player.addName(builder, name);
|
||||
Player.addDrive(builder, p.drive ?? 1);
|
||||
return Player.endPlayer(builder);
|
||||
});
|
||||
|
||||
const routeOffsets = (fixture.routes ?? []).map((route) => {
|
||||
const entryOffsets = route.entries.map((entry) => {
|
||||
const valueOffset = builder.createString(entry.loadType);
|
||||
RouteEntry.startRouteEntry(builder);
|
||||
RouteEntry.addKey(builder, BigInt(entry.destinationPlanetNumber));
|
||||
RouteEntry.addValue(builder, valueOffset);
|
||||
return RouteEntry.endRouteEntry(builder);
|
||||
});
|
||||
const entriesVec = Route.createRouteVector(builder, entryOffsets);
|
||||
Route.startRoute(builder);
|
||||
Route.addPlanet(builder, BigInt(route.sourcePlanetNumber));
|
||||
Route.addRoute(builder, entriesVec);
|
||||
return Route.endRoute(builder);
|
||||
});
|
||||
|
||||
const localVec =
|
||||
localOffsets.length === 0
|
||||
? null
|
||||
@@ -167,6 +211,16 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
localShipClassOffsets.length === 0
|
||||
? null
|
||||
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
|
||||
const playerVec =
|
||||
playerOffsets.length === 0
|
||||
? null
|
||||
: Report.createPlayerVector(builder, playerOffsets);
|
||||
const routeVec =
|
||||
routeOffsets.length === 0
|
||||
? null
|
||||
: Report.createRouteVector(builder, routeOffsets);
|
||||
const raceOffset =
|
||||
fixture.race === undefined ? null : builder.createString(fixture.race);
|
||||
|
||||
const totalPlanets =
|
||||
(fixture.localPlanets ?? []).length +
|
||||
@@ -179,12 +233,15 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
Report.addWidth(builder, fixture.mapWidth ?? 4000);
|
||||
Report.addHeight(builder, fixture.mapHeight ?? 4000);
|
||||
Report.addPlanetCount(builder, totalPlanets);
|
||||
if (raceOffset !== null) Report.addRace(builder, raceOffset);
|
||||
if (playerVec !== null) Report.addPlayer(builder, playerVec);
|
||||
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
|
||||
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
|
||||
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
|
||||
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
|
||||
if (localShipClassVec !== null)
|
||||
Report.addLocalShipClass(builder, localShipClassVec);
|
||||
if (routeVec !== null) Report.addRoute(builder, routeVec);
|
||||
const reportOff = Report.endReport(builder);
|
||||
builder.finish(reportOff);
|
||||
return builder.asUint8Array();
|
||||
|
||||
@@ -13,10 +13,17 @@ interface DebugSnapshot {
|
||||
deviceSessionId: string | null;
|
||||
}
|
||||
|
||||
import type {
|
||||
MapCameraSnapshot,
|
||||
MapPickStateSnapshot,
|
||||
MapPrimitiveSnapshot,
|
||||
} from "../../src/lib/debug-surface.svelte";
|
||||
|
||||
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`)
|
||||
// reuse the global declaration below, so this interface lists every
|
||||
// helper any spec calls — not only those exercised by this file.
|
||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
||||
// `cargo-routes.spec.ts`) reuse the global declaration below, so this
|
||||
// interface lists every helper any spec calls — not only those
|
||||
// exercised by this file.
|
||||
interface DebugSurface {
|
||||
ready: true;
|
||||
loadSession(): Promise<DebugSnapshot>;
|
||||
@@ -36,6 +43,9 @@ interface DebugSurface {
|
||||
}>,
|
||||
): Promise<void>;
|
||||
clearOrderDraft(gameId: string): Promise<void>;
|
||||
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState(): MapPickStateSnapshot;
|
||||
getMapCamera(): MapCameraSnapshot | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -40,6 +40,8 @@ function withGameState(opts: {
|
||||
planets: [],
|
||||
race: opts.race ?? "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
store.status = "ready";
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// Vitest component coverage for the Phase 16 cargo-routes
|
||||
// subsection of the planet inspector. Drives the component against
|
||||
// a real `OrderDraftStore` (with `fake-indexeddb` standing in for
|
||||
// the browser IDB factory) and a stub `MapPickService` whose
|
||||
// `pick(...)` resolves to a script-controlled answer. The tests
|
||||
// assert the four-slot rendering, the picker invocation, the
|
||||
// per-(source, loadType) collapse rule, and the cancel path.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { ReportPlanet, ReportRoute } from "../src/api/game-state";
|
||||
import CargoRoutes from "../src/lib/inspectors/planet/cargo-routes.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
type MapPickRequest,
|
||||
} from "../src/lib/map-pick.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-cargo-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
draft.dispose();
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function makePlanet(
|
||||
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number">,
|
||||
): ReportPlanet {
|
||||
return {
|
||||
name: `Planet-${overrides.number}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: 100,
|
||||
resources: 1,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
production: null,
|
||||
freeIndustry: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PickInvocation {
|
||||
request: MapPickRequest;
|
||||
resolve: (id: number | null) => void;
|
||||
}
|
||||
|
||||
class StubPickService extends MapPickService {
|
||||
invocations: PickInvocation[] = [];
|
||||
override pick(request: MapPickRequest): Promise<number | null> {
|
||||
this.active = true;
|
||||
return new Promise((resolve) => {
|
||||
this.invocations.push({
|
||||
request,
|
||||
resolve: (id) => {
|
||||
this.active = false;
|
||||
resolve(id);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
override cancel(): void {
|
||||
const inv = this.invocations.shift();
|
||||
inv?.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(
|
||||
planet: ReportPlanet,
|
||||
planets: ReportPlanet[],
|
||||
routes: ReportRoute[] = [],
|
||||
localPlayerDrive = 2,
|
||||
mapWidth = 4000,
|
||||
mapHeight = 4000,
|
||||
) {
|
||||
const pick = new StubPickService();
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[MAP_PICK_CONTEXT_KEY, pick],
|
||||
]);
|
||||
const ui = render(CargoRoutes, {
|
||||
props: {
|
||||
planet,
|
||||
routes,
|
||||
planets,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
localPlayerDrive,
|
||||
},
|
||||
context,
|
||||
});
|
||||
return { ui, pick };
|
||||
}
|
||||
|
||||
describe("planet inspector — cargo routes", () => {
|
||||
test("renders four slots in COL/CAP/MAT/EMP order", () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
|
||||
);
|
||||
const slots = ui.container.querySelectorAll(
|
||||
"[data-testid^='inspector-planet-cargo-slot-']",
|
||||
);
|
||||
const slotIds = Array.from(slots).map((el) =>
|
||||
el.getAttribute("data-testid"),
|
||||
);
|
||||
// Each slot generates several test ids (label + body items);
|
||||
// pick the row data-testid (slot itself, no suffix).
|
||||
const rowIds = slotIds.filter((id) =>
|
||||
/^inspector-planet-cargo-slot-(col|cap|mat|emp)$/.test(id ?? ""),
|
||||
);
|
||||
expect(rowIds).toEqual([
|
||||
"inspector-planet-cargo-slot-col",
|
||||
"inspector-planet-cargo-slot-cap",
|
||||
"inspector-planet-cargo-slot-mat",
|
||||
"inspector-planet-cargo-slot-emp",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an empty slot exposes the Add button and the (no route) marker", () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-cargo-slot-col-edit"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("a filled slot shows the destination name plus Edit and Remove", () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
],
|
||||
[
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
|
||||
).toHaveTextContent("Mars");
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-remove"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-cargo-slot-col-add"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("Add opens pick mode with the reach-filtered set", async () => {
|
||||
// Reach = 40 * 2 = 80. Mars is 50 away (in reach), Pluto is
|
||||
// 200 away (out of reach).
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
makePlanet({ number: 3, name: "Pluto", x: 300, y: 100 }),
|
||||
],
|
||||
[],
|
||||
2,
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
const invocation = pick.invocations[0]!;
|
||||
expect(invocation.request.sourcePlanetNumber).toBe(1);
|
||||
expect(Array.from(invocation.request.reachableIds).sort()).toEqual([2]);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-pick-prompt"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a successful pick emits setCargoRoute and closes the prompt", async () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
],
|
||||
[],
|
||||
2,
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
pick.invocations[0]!.resolve(2);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("setCargoRoute");
|
||||
if (cmd.kind !== "setCargoRoute") return;
|
||||
expect(cmd.sourcePlanetNumber).toBe(1);
|
||||
expect(cmd.destinationPlanetNumber).toBe(2);
|
||||
expect(cmd.loadType).toBe("CAP");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-cargo-pick-prompt"),
|
||||
).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
test("cancel resolves null and emits no command", async () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
],
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add"));
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
pick.invocations[0]!.resolve(null);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-cargo-pick-prompt"),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(draft.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Remove emits removeCargoRoute for the slot", async () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
],
|
||||
[
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "EMP", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
|
||||
);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("removeCargoRoute");
|
||||
if (cmd.kind !== "removeCargoRoute") return;
|
||||
expect(cmd.sourcePlanetNumber).toBe(1);
|
||||
expect(cmd.loadType).toBe("EMP");
|
||||
});
|
||||
|
||||
test("Edit replaces the existing setCargoRoute via collapse rule", async () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
makePlanet({ number: 3, name: "Vesta", x: 100, y: 150 }),
|
||||
],
|
||||
[
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
|
||||
);
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
pick.invocations[0]!.resolve(3);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
// Then a second edit to a different planet — collapse keeps a
|
||||
// single row.
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
|
||||
);
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(2));
|
||||
pick.invocations[1]!.resolve(2);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("setCargoRoute");
|
||||
if (cmd.kind !== "setCargoRoute") return;
|
||||
expect(cmd.destinationPlanetNumber).toBe(2);
|
||||
});
|
||||
|
||||
test("different load-types coexist without collapsing each other", async () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
|
||||
],
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
pick.invocations[0]!.resolve(2);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(2));
|
||||
pick.invocations[1]!.resolve(2);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(2));
|
||||
const types = draft.commands
|
||||
.filter((c) => c.kind === "setCargoRoute")
|
||||
.map((c) => (c.kind === "setCargoRoute" ? c.loadType : ""))
|
||||
.sort();
|
||||
expect(types).toEqual(["CAP", "COL"]);
|
||||
});
|
||||
|
||||
test("no_destinations message appears when reach is positive but every planet is out of range", () => {
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, name: "Pluto", x: 5000, y: 5000 }),
|
||||
],
|
||||
[],
|
||||
0.1, // reach 4 — far less than 5000 distance
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-no-destinations"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 187.5,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
const section = ui.getByTestId("inspector-planet");
|
||||
@@ -130,6 +135,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 75,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -161,6 +171,11 @@ describe("planet inspector", () => {
|
||||
materialsStockpile: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -193,6 +208,11 @@ describe("planet inspector", () => {
|
||||
y: -5,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -221,6 +241,11 @@ describe("planet inspector", () => {
|
||||
resources: 5,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
|
||||
@@ -253,6 +278,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
context,
|
||||
});
|
||||
@@ -316,6 +346,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
context,
|
||||
});
|
||||
@@ -346,6 +381,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
// Empty production strings collapse to the localised "none"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Pure-function coverage for `map/cargo-routes.ts.buildCargoRouteLines`.
|
||||
// The renderer turns each `ReportRouteEntry` into one shaft plus two
|
||||
// arrowhead wings; the tests assert geometry on a flat fixture, on a
|
||||
// torus seam-crossing fixture, and the per-load-type style/priority
|
||||
// mapping. Pixi-free — the helper is a pure projection of the report.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type {
|
||||
GameReport,
|
||||
ReportPlanet,
|
||||
ReportRouteEntry,
|
||||
} from "../src/api/game-state";
|
||||
import {
|
||||
ROUTE_LINE_ID_PREFIX,
|
||||
STYLE_ROUTE_CAP,
|
||||
STYLE_ROUTE_COL,
|
||||
STYLE_ROUTE_EMP,
|
||||
STYLE_ROUTE_MAT,
|
||||
buildCargoRouteLines,
|
||||
} from "../src/map/cargo-routes";
|
||||
|
||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||
return {
|
||||
number: 0,
|
||||
name: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(
|
||||
planets: ReportPlanet[],
|
||||
source: number,
|
||||
entries: ReportRouteEntry[],
|
||||
mapWidth = 1000,
|
||||
mapHeight = 1000,
|
||||
): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
planetCount: planets.length,
|
||||
planets,
|
||||
race: "Earthlings",
|
||||
localShipClass: [],
|
||||
routes: [{ sourcePlanetNumber: source, entries }],
|
||||
localPlayerDrive: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCargoRouteLines", () => {
|
||||
test("emits one shaft + two wings per route entry", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, x: 300, y: 100 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
);
|
||||
const lines = buildCargoRouteLines(report);
|
||||
expect(lines.length).toBe(3);
|
||||
expect(lines.every((l) => l.kind === "line")).toBe(true);
|
||||
});
|
||||
|
||||
test("shaft endpoints follow the no-wrap straight line", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, x: 300, y: 100 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
);
|
||||
const [shaft] = buildCargoRouteLines(report);
|
||||
expect(shaft).toBeDefined();
|
||||
if (shaft === undefined) return;
|
||||
expect(shaft.x1).toBe(100);
|
||||
expect(shaft.y1).toBe(100);
|
||||
expect(shaft.x2).toBe(300);
|
||||
expect(shaft.y2).toBe(100);
|
||||
});
|
||||
|
||||
test("shaft uses the torus-shortest delta on the seam", () => {
|
||||
// Source at x=950, dest at x=50 in a world 1000 wide. The
|
||||
// shorter wrap is +100 (right past x=1000 to x=1050), not
|
||||
// −900 (left to x=50).
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 950, y: 500 }),
|
||||
makePlanet({ number: 2, x: 50, y: 500 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "MAT", destinationPlanetNumber: 2 }],
|
||||
1000,
|
||||
1000,
|
||||
);
|
||||
const [shaft] = buildCargoRouteLines(report);
|
||||
expect(shaft).toBeDefined();
|
||||
if (shaft === undefined) return;
|
||||
expect(shaft.x1).toBe(950);
|
||||
expect(shaft.x2).toBe(1050); // 950 + 100
|
||||
expect(shaft.y2).toBe(500);
|
||||
});
|
||||
|
||||
test("each load type maps to the documented style and priority", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, x: 200, y: 100 }),
|
||||
makePlanet({ number: 3, x: 300, y: 100 }),
|
||||
makePlanet({ number: 4, x: 400, y: 100 }),
|
||||
makePlanet({ number: 5, x: 500, y: 100 }),
|
||||
],
|
||||
1,
|
||||
[
|
||||
{ loadType: "COL", destinationPlanetNumber: 2 },
|
||||
{ loadType: "CAP", destinationPlanetNumber: 3 },
|
||||
{ loadType: "MAT", destinationPlanetNumber: 4 },
|
||||
{ loadType: "EMP", destinationPlanetNumber: 5 },
|
||||
],
|
||||
);
|
||||
const lines = buildCargoRouteLines(report);
|
||||
expect(lines.length).toBe(12);
|
||||
const styleByPriority = new Map<number, typeof lines[number]["style"]>();
|
||||
for (const line of lines) {
|
||||
const existing = styleByPriority.get(line.priority);
|
||||
if (existing === undefined) styleByPriority.set(line.priority, line.style);
|
||||
else expect(existing).toBe(line.style);
|
||||
}
|
||||
expect(styleByPriority.get(8)).toBe(STYLE_ROUTE_COL);
|
||||
expect(styleByPriority.get(7)).toBe(STYLE_ROUTE_CAP);
|
||||
expect(styleByPriority.get(6)).toBe(STYLE_ROUTE_MAT);
|
||||
expect(styleByPriority.get(5)).toBe(STYLE_ROUTE_EMP);
|
||||
});
|
||||
|
||||
test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, x: 200, y: 100 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
);
|
||||
const lines = buildCargoRouteLines(report);
|
||||
for (const line of lines) {
|
||||
expect((line.id & ROUTE_LINE_ID_PREFIX) !== 0).toBe(true);
|
||||
}
|
||||
// Three distinct ids — one per segment.
|
||||
const ids = new Set(lines.map((l) => l.id));
|
||||
expect(ids.size).toBe(3);
|
||||
});
|
||||
|
||||
test("skips routes whose source or destination is missing", () => {
|
||||
const report = makeReport(
|
||||
[makePlanet({ number: 1, x: 100, y: 100 })],
|
||||
1,
|
||||
[
|
||||
{ loadType: "COL", destinationPlanetNumber: 999 }, // unknown dest
|
||||
],
|
||||
);
|
||||
expect(buildCargoRouteLines(report).length).toBe(0);
|
||||
});
|
||||
|
||||
test("skips zero-length routes (source == destination coords)", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||||
makePlanet({ number: 2, x: 100, y: 100 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
);
|
||||
expect(buildCargoRouteLines(report).length).toBe(0);
|
||||
});
|
||||
|
||||
test("returns an empty array when no routes are configured", () => {
|
||||
const report: GameReport = {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 1,
|
||||
planets: [makePlanet({ number: 1, x: 100, y: 100 })],
|
||||
race: "Earthlings",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1,
|
||||
};
|
||||
expect(buildCargoRouteLines(report)).toEqual([]);
|
||||
});
|
||||
|
||||
test("arrowhead wings symmetric around the shaft direction", () => {
|
||||
const report = makeReport(
|
||||
[
|
||||
makePlanet({ number: 1, x: 0, y: 0 }),
|
||||
makePlanet({ number: 2, x: 100, y: 0 }),
|
||||
],
|
||||
1,
|
||||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
);
|
||||
const [shaft, leftWing, rightWing] = buildCargoRouteLines(report);
|
||||
expect(shaft).toBeDefined();
|
||||
expect(leftWing).toBeDefined();
|
||||
expect(rightWing).toBeDefined();
|
||||
if (
|
||||
shaft === undefined ||
|
||||
leftWing === undefined ||
|
||||
rightWing === undefined
|
||||
)
|
||||
return;
|
||||
// Both wings start at the head.
|
||||
expect(leftWing.x1).toBe(shaft.x2);
|
||||
expect(leftWing.y1).toBe(shaft.y2);
|
||||
expect(rightWing.x1).toBe(shaft.x2);
|
||||
expect(rightWing.y1).toBe(shaft.y2);
|
||||
// And land symmetrically around the y axis (shaft along +x).
|
||||
expect(leftWing.y2 + rightWing.y2).toBeCloseTo(0);
|
||||
expect(leftWing.x2).toBeCloseTo(rightWing.x2);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@
|
||||
// ui/docs/renderer.md. Worlds are kept tiny (1–5 primitives) so the
|
||||
// expected hit is obvious from the geometry; the camera is at scale=1
|
||||
// in most cases so slop in pixels equals slop in world units.
|
||||
//
|
||||
// The point hit zone is `(pointRadiusPx + slopPx) / camera.scale`
|
||||
// world units — the visible disc plus an ergonomic slop on top. The
|
||||
// default `pointRadiusPx` (`DEFAULT_POINT_RADIUS_PX`) is 3 and the
|
||||
// default point slop (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default
|
||||
// point is hit out to 7 world units at scale=1.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hitTest } from "../src/map/hit-test";
|
||||
@@ -101,16 +107,32 @@ describe("hitTest — point primitive", () => {
|
||||
test("direct hit at centre", () => {
|
||||
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
|
||||
});
|
||||
test("hit within default slop (8px)", () => {
|
||||
// 7 world units away at scale=1 → within 8px slop.
|
||||
test("hit on the visible disc edge (3 world units from centre)", () => {
|
||||
// Default radius 3 → cursor 3 units away lands on the disc.
|
||||
expect(ids(w, "torus", cam, cursorOver(503, 500, cam))).toBe(1);
|
||||
});
|
||||
test("hit just inside the default slop margin (within radius+slop)", () => {
|
||||
// 7 world units away at scale=1 → equals radius (3) + slop (4).
|
||||
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
|
||||
});
|
||||
test("miss just outside default slop", () => {
|
||||
test("miss just outside radius+slop", () => {
|
||||
// 9 world units away at scale=1 → radius+slop is 7.
|
||||
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
|
||||
});
|
||||
test("custom hitSlopPx widens the hit area", () => {
|
||||
test("explicit pointRadiusPx widens the visible footprint", () => {
|
||||
// pointRadiusPx 10 + default slop 4 → hit out to 14 world units.
|
||||
const w2 = new World(1000, 1000, [
|
||||
point(1, 500, 500, { style: { pointRadiusPx: 10 } }),
|
||||
]);
|
||||
expect(ids(w2, "torus", cam, cursorOver(513, 500, cam))).toBe(1);
|
||||
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(null);
|
||||
});
|
||||
test("custom hitSlopPx widens the slop margin", () => {
|
||||
// pointRadiusPx defaults to 3; slop override is 20.
|
||||
// Cursor 22 world units away → within 3+20.
|
||||
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
|
||||
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1);
|
||||
expect(ids(w2, "torus", cam, cursorOver(522, 500, cam))).toBe(1);
|
||||
expect(ids(w2, "torus", cam, cursorOver(524, 500, cam))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +140,7 @@ describe("hitTest — torus wrap", () => {
|
||||
test("point near the right edge is hit by cursor near the left edge", () => {
|
||||
// World 100×100, point at x=98. Camera at left edge (x=2).
|
||||
// Cursor at x=4 is 6 units from x=98 via the wrap; default
|
||||
// point slop is 8px → hit.
|
||||
// point radius (3) + slop (4) = 7 → hit.
|
||||
const cam = camAt(2, 50);
|
||||
const w = new World(100, 100, [point(1, 98, 50)]);
|
||||
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
|
||||
@@ -235,29 +257,26 @@ describe("hitTest — empty results and scale", () => {
|
||||
});
|
||||
|
||||
test("higher zoom shrinks the on-screen slop in world units", () => {
|
||||
// At scale=4, 8px on screen = 2 world units.
|
||||
// A point 3 world units away misses.
|
||||
// At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
|
||||
// world units. Threshold = 4 world units.
|
||||
const w = new World(1000, 1000, [point(1, 503, 500)]);
|
||||
expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe(
|
||||
null,
|
||||
);
|
||||
// A point 1.5 world units away hits at scale=4 (≤ 2).
|
||||
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]);
|
||||
expect(
|
||||
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
|
||||
).toBe(1);
|
||||
const cam4 = camAt(500, 500, 4);
|
||||
// 3 world units away → on the disc edge → hit.
|
||||
expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
|
||||
// 5 world units away → beyond radius+slop → null.
|
||||
const wFar = new World(1000, 1000, [point(1, 505, 500)]);
|
||||
expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
|
||||
});
|
||||
|
||||
test("lower zoom widens the on-screen slop in world units", () => {
|
||||
// At scale=0.5, 8px on screen = 16 world units.
|
||||
const w = new World(1000, 1000, [point(1, 514, 500)]);
|
||||
expect(
|
||||
ids(
|
||||
w,
|
||||
"torus",
|
||||
camAt(500, 500, 0.5),
|
||||
cursorOver(500, 500, camAt(500, 500, 0.5)),
|
||||
),
|
||||
).toBe(1);
|
||||
// At scale=0.5, slopPx 4 = 8 world units; visible radius
|
||||
// stays 3 → threshold = 11 world units.
|
||||
const cam05 = camAt(500, 500, 0.5);
|
||||
const w = new World(1000, 1000, [point(1, 510, 500)]);
|
||||
// 10 world units away → within 11 → hit.
|
||||
expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
|
||||
const wFar = new World(1000, 1000, [point(1, 514, 500)]);
|
||||
// 14 world units away → beyond 11 → null.
|
||||
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Pure-state coverage for the pick-mode overlay helper. The
|
||||
// renderer owns the Pixi side (`render.ts.openPickMode`); this file
|
||||
// asserts that `computePickOverlay` produces the correct draw spec
|
||||
// for every meaningful input combination — Pixi-free, so it stays
|
||||
// fast and stable against renderer plumbing changes.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
ANCHOR_PADDING_WORLD,
|
||||
HOVER_PADDING_WORLD,
|
||||
computePickOverlay,
|
||||
type PickModeOptions,
|
||||
} from "../src/map/pick-mode";
|
||||
import {
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
type PointPrim,
|
||||
type PrimitiveID,
|
||||
} from "../src/map/world";
|
||||
|
||||
function makePoint(
|
||||
id: PrimitiveID,
|
||||
x: number,
|
||||
y: number,
|
||||
pointRadiusPx?: number,
|
||||
): PointPrim {
|
||||
return {
|
||||
kind: "point",
|
||||
id,
|
||||
priority: 0,
|
||||
hitSlopPx: 0,
|
||||
x,
|
||||
y,
|
||||
style: pointRadiusPx === undefined ? {} : { pointRadiusPx },
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(
|
||||
overrides: Partial<PickModeOptions> = {},
|
||||
): PickModeOptions {
|
||||
return {
|
||||
sourcePrimitiveId: 1,
|
||||
sourceX: 100,
|
||||
sourceY: 100,
|
||||
reachableIds: new Set([2, 3]),
|
||||
onPick: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computePickOverlay", () => {
|
||||
const points = new Map<PrimitiveID, PointPrim>([
|
||||
[1, makePoint(1, 100, 100, 6)],
|
||||
[2, makePoint(2, 200, 100, 5)],
|
||||
[3, makePoint(3, 100, 200)],
|
||||
[4, makePoint(4, 300, 300, 4)],
|
||||
]);
|
||||
const allIds: PrimitiveID[] = [1, 2, 3, 4];
|
||||
|
||||
test("anchor radius equals source pointRadiusPx + ANCHOR_PADDING_WORLD", () => {
|
||||
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
|
||||
expect(spec.anchor.x).toBe(100);
|
||||
expect(spec.anchor.y).toBe(100);
|
||||
expect(spec.anchor.radius).toBe(6 + ANCHOR_PADDING_WORLD);
|
||||
});
|
||||
|
||||
test("anchor radius falls back to default when source has no pointRadiusPx", () => {
|
||||
const sourceless = new Map(points);
|
||||
sourceless.set(1, makePoint(1, 100, 100));
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
null,
|
||||
null,
|
||||
sourceless,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.anchor.radius).toBe(
|
||||
DEFAULT_POINT_RADIUS_PX + ANCHOR_PADDING_WORLD,
|
||||
);
|
||||
});
|
||||
|
||||
test("dimmedIds covers everything outside source + reachable", () => {
|
||||
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
|
||||
expect(Array.from(spec.dimmedIds).sort()).toEqual([4]);
|
||||
});
|
||||
|
||||
test("dimmedIds is empty when every primitive is either source or reachable", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions({ reachableIds: new Set([2, 3, 4]) }),
|
||||
null,
|
||||
null,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.dimmedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
test("line is null while the cursor is off-canvas", () => {
|
||||
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
|
||||
expect(spec.line).toBeNull();
|
||||
});
|
||||
|
||||
test("line endpoints follow the cursor when present", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 250, y: 320 },
|
||||
null,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.line).toEqual({
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 250,
|
||||
y2: 320,
|
||||
});
|
||||
});
|
||||
|
||||
test("hoverOutline is null when nothing is hovered", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 1, y: 1 },
|
||||
null,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.hoverOutline).toBeNull();
|
||||
});
|
||||
|
||||
test("hoverOutline is null when the hover targets a non-reachable primitive", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 1, y: 1 },
|
||||
4,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.hoverOutline).toBeNull();
|
||||
});
|
||||
|
||||
test("hoverOutline is null when the hover targets the source planet", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 1, y: 1 },
|
||||
1,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.hoverOutline).toBeNull();
|
||||
});
|
||||
|
||||
test("hoverOutline reflects the reachable target with HOVER_PADDING_WORLD", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 1, y: 1 },
|
||||
2,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.hoverOutline).toEqual({
|
||||
x: 200,
|
||||
y: 100,
|
||||
radius: 5 + HOVER_PADDING_WORLD,
|
||||
});
|
||||
});
|
||||
|
||||
test("hoverOutline radius falls back to default radius for default-style points", () => {
|
||||
const spec = computePickOverlay(
|
||||
makeOptions(),
|
||||
{ x: 1, y: 1 },
|
||||
3,
|
||||
points,
|
||||
allIds,
|
||||
);
|
||||
expect(spec.hoverOutline?.radius).toBe(
|
||||
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_WORLD,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -328,6 +328,104 @@ describe("OrderDraftStore", () => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("setCargoRoute collapses by (source, loadType) — newer wins", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "first",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "COL",
|
||||
});
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "second",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 3,
|
||||
loadType: "COL",
|
||||
});
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["second"]);
|
||||
expect(store.statuses["first"]).toBeUndefined();
|
||||
expect(store.statuses["second"]).toBe("valid");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("setCargoRoute and removeCargoRoute share a collapse key", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "set",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "MAT",
|
||||
});
|
||||
await store.add({
|
||||
kind: "removeCargoRoute",
|
||||
id: "remove",
|
||||
sourcePlanetNumber: 1,
|
||||
loadType: "MAT",
|
||||
});
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["remove"]);
|
||||
// And remove → set on the same slot collapses again.
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "set2",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 4,
|
||||
loadType: "MAT",
|
||||
});
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["set2"]);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("cargo routes for different load-types or sources stay independent", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "p1-col",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "COL",
|
||||
});
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "p1-cap",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 3,
|
||||
loadType: "CAP",
|
||||
});
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "p9-col",
|
||||
sourcePlanetNumber: 9,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "COL",
|
||||
});
|
||||
expect(store.commands.map((c) => c.id)).toEqual([
|
||||
"p1-col",
|
||||
"p1-cap",
|
||||
"p9-col",
|
||||
]);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("setCargoRoute is invalid when source equals destination", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "setCargoRoute",
|
||||
id: "self",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 1,
|
||||
loadType: "EMP",
|
||||
});
|
||||
expect(store.statuses["self"]).toBe("invalid");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
|
||||
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||
const { client } = fakeFetchClient(GAME_ID, [
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGet,
|
||||
UserGamesOrderGetResponse,
|
||||
@@ -219,6 +222,134 @@ describe("fetchOrder", () => {
|
||||
expect(result.commands).toEqual([]);
|
||||
});
|
||||
|
||||
test("decodes CommandPlanetRouteSet into setCargoRoute", async () => {
|
||||
const builder = new Builder(256);
|
||||
const cmdIdOffset = builder.createString("cmd-route-set");
|
||||
const inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
|
||||
builder,
|
||||
BigInt(11),
|
||||
BigInt(22),
|
||||
PlanetRouteLoadType.MAT,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRouteSet);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
const item = CommandItem.endCommandItem(builder);
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(7));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, true);
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
|
||||
builder,
|
||||
);
|
||||
builder.finish(offset);
|
||||
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: builder.asUint8Array(),
|
||||
}));
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
const cmd = result.commands[0]!;
|
||||
expect(cmd.kind).toBe("setCargoRoute");
|
||||
if (cmd.kind !== "setCargoRoute") return;
|
||||
expect(cmd.id).toBe("cmd-route-set");
|
||||
expect(cmd.sourcePlanetNumber).toBe(11);
|
||||
expect(cmd.destinationPlanetNumber).toBe(22);
|
||||
expect(cmd.loadType).toBe("MAT");
|
||||
});
|
||||
|
||||
test("decodes CommandPlanetRouteRemove into removeCargoRoute", async () => {
|
||||
const builder = new Builder(256);
|
||||
const cmdIdOffset = builder.createString("cmd-route-remove");
|
||||
const inner = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
|
||||
builder,
|
||||
BigInt(33),
|
||||
PlanetRouteLoadType.EMP,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(
|
||||
builder,
|
||||
CommandPayload.CommandPlanetRouteRemove,
|
||||
);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
const item = CommandItem.endCommandItem(builder);
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(8));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, true);
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
|
||||
builder,
|
||||
);
|
||||
builder.finish(offset);
|
||||
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: builder.asUint8Array(),
|
||||
}));
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
const cmd = result.commands[0]!;
|
||||
expect(cmd.kind).toBe("removeCargoRoute");
|
||||
if (cmd.kind !== "removeCargoRoute") return;
|
||||
expect(cmd.sourcePlanetNumber).toBe(33);
|
||||
expect(cmd.loadType).toBe("EMP");
|
||||
});
|
||||
|
||||
test("skips a CommandPlanetRouteSet with PlanetRouteLoadType.UNKNOWN", async () => {
|
||||
const builder = new Builder(256);
|
||||
const cmdIdOffset = builder.createString("cmd-unknown-load");
|
||||
const inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
|
||||
builder,
|
||||
BigInt(1),
|
||||
BigInt(2),
|
||||
PlanetRouteLoadType.UNKNOWN,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRouteSet);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
const item = CommandItem.endCommandItem(builder);
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(0));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, true);
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
|
||||
builder,
|
||||
);
|
||||
builder.finish(offset);
|
||||
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: builder.asUint8Array(),
|
||||
}));
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
|
||||
expect(result.commands).toEqual([]);
|
||||
});
|
||||
|
||||
test("posts a well-formed UserGamesOrderGet payload", async () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +251,115 @@ describe("applyOrderOverlay", () => {
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
|
||||
expect(out).toBe(report);
|
||||
});
|
||||
|
||||
test("setCargoRoute upserts a route entry when applied", () => {
|
||||
const report = makeReport([
|
||||
makePlanet({ number: 1, name: "Earth" }),
|
||||
makePlanet({ number: 2, name: "Mars" }),
|
||||
]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "setCargoRoute",
|
||||
id: "cargo-1",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "COL",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" });
|
||||
expect(out).not.toBe(report);
|
||||
expect(out.routes).toHaveLength(1);
|
||||
expect(out.routes[0]!.sourcePlanetNumber).toBe(1);
|
||||
expect(out.routes[0]!.entries).toEqual([
|
||||
{ loadType: "COL", destinationPlanetNumber: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("setCargoRoute on an existing slot replaces the destination", () => {
|
||||
const report: GameReport = {
|
||||
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
|
||||
routes: [
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cmd: OrderCommand = {
|
||||
kind: "setCargoRoute",
|
||||
id: "cargo-1",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 5,
|
||||
loadType: "COL",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" });
|
||||
expect(out.routes[0]!.entries).toEqual([
|
||||
{ loadType: "COL", destinationPlanetNumber: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("removeCargoRoute drops the matching slot and preserves the others", () => {
|
||||
const report: GameReport = {
|
||||
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
|
||||
routes: [
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [
|
||||
{ loadType: "COL", destinationPlanetNumber: 2 },
|
||||
{ loadType: "MAT", destinationPlanetNumber: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cmd: OrderCommand = {
|
||||
kind: "removeCargoRoute",
|
||||
id: "rem-1",
|
||||
sourcePlanetNumber: 1,
|
||||
loadType: "COL",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" });
|
||||
expect(out.routes[0]!.entries).toEqual([
|
||||
{ loadType: "MAT", destinationPlanetNumber: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("removeCargoRoute clears the route entry entirely when last slot drops", () => {
|
||||
const report: GameReport = {
|
||||
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
|
||||
routes: [
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cmd: OrderCommand = {
|
||||
kind: "removeCargoRoute",
|
||||
id: "rem-1",
|
||||
sourcePlanetNumber: 1,
|
||||
loadType: "COL",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" });
|
||||
expect(out.routes).toEqual([]);
|
||||
});
|
||||
|
||||
test("cargo route overlays skip draft / invalid / rejected statuses", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "setCargoRoute",
|
||||
id: "cargo-1",
|
||||
sourcePlanetNumber: 1,
|
||||
destinationPlanetNumber: 2,
|
||||
loadType: "COL",
|
||||
};
|
||||
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "draft" })).toBe(
|
||||
report,
|
||||
);
|
||||
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "invalid" })).toBe(
|
||||
report,
|
||||
);
|
||||
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "rejected" })).toBe(
|
||||
report,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("productionDisplayFromCommand", () => {
|
||||
|
||||
@@ -21,6 +21,8 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -136,4 +138,28 @@ describe("reportToWorld", () => {
|
||||
const unknown = world.primitives.find((p) => p.id === 2);
|
||||
expect(local?.priority ?? 0).toBeGreaterThan(unknown?.priority ?? 0);
|
||||
});
|
||||
|
||||
test("cargo routes are NOT inlined into the static world", () => {
|
||||
// As of Phase 16 cargo-route arrows are pushed onto the live
|
||||
// renderer via `setExtraPrimitives` instead of being baked
|
||||
// into `reportToWorld`. The base world stays a clean
|
||||
// representation of the report's planets so the renderer
|
||||
// can rebuild the overlay without disposing Pixi.
|
||||
const world = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100, kind: "local", size: 5, resources: 1 }),
|
||||
makePlanet({ number: 2, name: "Mars", x: 300, y: 100, kind: "local", size: 5, resources: 1 }),
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
sourcePlanetNumber: 1,
|
||||
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const lines = world.primitives.filter((p) => p.kind === "line");
|
||||
expect(lines.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
CommandItem,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
CommandPayload,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { submitOrder } from "../src/sync/submit";
|
||||
import type {
|
||||
CargoLoadType,
|
||||
OrderCommand,
|
||||
ProductionType,
|
||||
} from "../src/sync/order-types";
|
||||
@@ -214,6 +218,88 @@ describe("submitOrder", () => {
|
||||
expect(inner.subject()).toBe("Scout");
|
||||
});
|
||||
|
||||
test("encodes setCargoRoute as CommandPlanetRouteSet on the wire", async () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
return { resultCode: "ok", payloadBytes: new Uint8Array() };
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "setCargoRoute",
|
||||
id: "00000000-0000-0000-0000-00000000aaaa",
|
||||
sourcePlanetNumber: 17,
|
||||
destinationPlanetNumber: 23,
|
||||
loadType: "COL",
|
||||
};
|
||||
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
|
||||
expect(captured).not.toBeNull();
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new (await import("flatbuffers")).ByteBuffer(captured!),
|
||||
);
|
||||
const item = decoded.commands(0);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetRouteSet);
|
||||
const inner = new CommandPlanetRouteSet();
|
||||
item!.payload(inner);
|
||||
expect(Number(inner.origin())).toBe(17);
|
||||
expect(Number(inner.destination())).toBe(23);
|
||||
expect(inner.loadType()).toBe(PlanetRouteLoadType.COL);
|
||||
});
|
||||
|
||||
test("encodes removeCargoRoute as CommandPlanetRouteRemove on the wire", async () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
return { resultCode: "ok", payloadBytes: new Uint8Array() };
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "removeCargoRoute",
|
||||
id: "00000000-0000-0000-0000-00000000bbbb",
|
||||
sourcePlanetNumber: 17,
|
||||
loadType: "MAT",
|
||||
};
|
||||
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new (await import("flatbuffers")).ByteBuffer(captured!),
|
||||
);
|
||||
const item = decoded.commands(0);
|
||||
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetRouteRemove);
|
||||
const inner = new CommandPlanetRouteRemove();
|
||||
item!.payload(inner);
|
||||
expect(Number(inner.origin())).toBe(17);
|
||||
expect(inner.loadType()).toBe(PlanetRouteLoadType.MAT);
|
||||
});
|
||||
|
||||
test("maps every cargoLoadType literal to its FBS enum value", async () => {
|
||||
const cases: Array<{ loadType: CargoLoadType; fbs: PlanetRouteLoadType }> = [
|
||||
{ loadType: "COL", fbs: PlanetRouteLoadType.COL },
|
||||
{ loadType: "CAP", fbs: PlanetRouteLoadType.CAP },
|
||||
{ loadType: "MAT", fbs: PlanetRouteLoadType.MAT },
|
||||
{ loadType: "EMP", fbs: PlanetRouteLoadType.EMP },
|
||||
];
|
||||
for (const tc of cases) {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
return { resultCode: "ok", payloadBytes: new Uint8Array() };
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "setCargoRoute",
|
||||
id: `id-${tc.loadType}`,
|
||||
sourcePlanetNumber: 5,
|
||||
destinationPlanetNumber: 6,
|
||||
loadType: tc.loadType,
|
||||
};
|
||||
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new (await import("flatbuffers")).ByteBuffer(captured!),
|
||||
);
|
||||
const inner = new CommandPlanetRouteSet();
|
||||
decoded.commands(0)!.payload(inner);
|
||||
expect(inner.loadType()).toBe(tc.fbs);
|
||||
}
|
||||
});
|
||||
|
||||
test("maps every productionType literal to its FBS enum value", async () => {
|
||||
const cases: Array<{
|
||||
productionType: ProductionType;
|
||||
|
||||
Reference in New Issue
Block a user