Files
galaxy-game/ui/frontend/tests/e2e/cargo-routes.spec.ts
T
Ilia Denisov 7c8b5aeb23 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>
2026-05-09 20:01:34 +02:00

525 lines
15 KiB
TypeScript

// 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;
});