75a4211373
* state-binding.ts: normalise planet size by the engine's typical mid-range (`SIZE_NORMALIZER = 100`) so legacy fixtures recording Size in the hundreds do not blow up the world-unit disc and start overlapping neighbouring planets. The cube-root growth stays; Size-800 reads twice as big as Size-100. * cargo-routes.spec.ts: retire the selection-ring CirclePrim from the expected primitive count (4 planets + 3 cargo arrow lines = 7). * map-toggles.spec.ts: bombing-rings → planet outlines (the high-bit 0xc… range is permanently empty); planet-names persist test waits for the renderer's debug providers and for the IndexedDB write to flush before reload. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
535 lines
15 KiB
TypeScript
535 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/edge/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(
|
|
"**/edge.v1.Gateway/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(
|
|
"**/edge.v1.Gateway/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("/");
|
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
|
await page.evaluate(
|
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
|
GAME_ID,
|
|
);
|
|
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,
|
|
);
|
|
const typeSelect = sidebar.getByTestId("inspector-planet-cargo-type");
|
|
await typeSelect.selectOption("COL");
|
|
|
|
// 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,
|
|
};
|
|
});
|
|
// F8-12 / #30 retired the selection-ring CirclePrim: selection is
|
|
// now drawn as an outline overlay around the planet disc, outside
|
|
// the primitive surface. Expected total = 4 planets + 3 cargo
|
|
// arrow lines.
|
|
await expect.poll(debugLineCount, { timeout: 15000 }).toEqual({
|
|
total: 7,
|
|
lines: 3,
|
|
});
|
|
|
|
// Add a CAP route to confirm slots coexist.
|
|
await typeSelect.selectOption("CAP");
|
|
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 typeSelect.selectOption("COL");
|
|
await page
|
|
.getByTestId("inspector-planet-cargo-slot-col-remove")
|
|
.first()
|
|
.click();
|
|
await expect(
|
|
page.getByTestId("inspector-planet-cargo-slot-col-add").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;
|
|
});
|