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:
Ilia Denisov
2026-05-09 20:01:34 +02:00
parent 5fd67ed958
commit 7c8b5aeb23
43 changed files with 4559 additions and 98 deletions
+524
View File
@@ -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;
});
+54 -1
View File
@@ -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"
+234
View File
@@ -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);
});
});
+45 -26
View File
@@ -4,6 +4,12 @@
// ui/docs/renderer.md. Worlds are kept tiny (15 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);
});
});
+179
View File
@@ -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,
);
});
});
+98
View File
@@ -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, [
+131
View File
@@ -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) => {
+111
View File
@@ -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", () => {
+26
View File
@@ -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);
});
});
+86
View File
@@ -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;