7c8b5aeb23
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>
525 lines
15 KiB
TypeScript
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;
|
|
});
|