ui/phase-23: turn-report view with twenty sections and TOC

Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 14:33:56 +02:00
parent 81d8be08b2
commit c58027c034
48 changed files with 5368 additions and 103 deletions
@@ -17,15 +17,20 @@
import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
Bombing,
LocalPlanet,
OtherPlanet,
OtherScience,
OthersShipClass,
Player,
Report,
Route,
RouteEntry,
Science,
ShipClass,
ShipProduction,
UnidentifiedPlanet,
UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report";
@@ -94,6 +99,39 @@ export interface RouteFixture {
entries: RouteEntryFixture[];
}
export interface OtherScienceFixture extends ScienceFixture {
race: string;
}
export interface OtherShipClassFixture extends ShipClassFixture {
race: string;
mass?: number;
}
export interface BombingFixture {
planetNumber: number;
planet: string;
owner: string;
attacker: string;
production?: string;
industry?: number;
population?: number;
colonists?: number;
capital?: number;
material?: number;
attackPower?: number;
wiped?: boolean;
}
export interface ShipProductionFixture {
planet: number;
class: string;
cost?: number;
prodUsed?: number;
percent?: number;
free?: number;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
@@ -109,6 +147,11 @@ export interface ReportFixture {
routes?: RouteFixture[];
myVotes?: number;
myVoteFor?: string;
otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[];
battles?: string[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -245,6 +288,67 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return Route.endRoute(builder);
});
const otherScienceOffsets = (fixture.otherScience ?? []).map((sci) => {
const race = builder.createString(sci.race);
const name = builder.createString(sci.name);
OtherScience.startOtherScience(builder);
OtherScience.addRace(builder, race);
OtherScience.addName(builder, name);
OtherScience.addDrive(builder, sci.drive ?? 0);
OtherScience.addWeapons(builder, sci.weapons ?? 0);
OtherScience.addShields(builder, sci.shields ?? 0);
OtherScience.addCargo(builder, sci.cargo ?? 0);
return OtherScience.endOtherScience(builder);
});
const otherShipClassOffsets = (fixture.otherShipClass ?? []).map((cls) => {
const race = builder.createString(cls.race);
const name = builder.createString(cls.name);
OthersShipClass.startOthersShipClass(builder);
OthersShipClass.addRace(builder, race);
OthersShipClass.addName(builder, name);
OthersShipClass.addDrive(builder, cls.drive ?? 0);
OthersShipClass.addArmament(builder, BigInt(cls.armament ?? 0));
OthersShipClass.addWeapons(builder, cls.weapons ?? 0);
OthersShipClass.addShields(builder, cls.shields ?? 0);
OthersShipClass.addCargo(builder, cls.cargo ?? 0);
OthersShipClass.addMass(builder, cls.mass ?? 0);
return OthersShipClass.endOthersShipClass(builder);
});
const bombingOffsets = (fixture.bombings ?? []).map((b) => {
const planet = builder.createString(b.planet);
const owner = builder.createString(b.owner);
const attacker = builder.createString(b.attacker);
const production = builder.createString(b.production ?? "");
Bombing.startBombing(builder);
Bombing.addNumber(builder, BigInt(b.planetNumber));
Bombing.addPlanet(builder, planet);
Bombing.addOwner(builder, owner);
Bombing.addAttacker(builder, attacker);
Bombing.addProduction(builder, production);
Bombing.addIndustry(builder, b.industry ?? 0);
Bombing.addPopulation(builder, b.population ?? 0);
Bombing.addColonists(builder, b.colonists ?? 0);
Bombing.addCapital(builder, b.capital ?? 0);
Bombing.addMaterial(builder, b.material ?? 0);
Bombing.addAttackPower(builder, b.attackPower ?? 0);
Bombing.addWiped(builder, b.wiped ?? false);
return Bombing.endBombing(builder);
});
const shipProductionOffsets = (fixture.shipProductions ?? []).map((sp) => {
const className = builder.createString(sp.class);
ShipProduction.startShipProduction(builder);
ShipProduction.addPlanet(builder, BigInt(sp.planet));
ShipProduction.addClass(builder, className);
ShipProduction.addCost(builder, sp.cost ?? 0);
ShipProduction.addProdUsed(builder, sp.prodUsed ?? 0);
ShipProduction.addPercent(builder, sp.percent ?? 0);
ShipProduction.addFree(builder, sp.free ?? 0);
return ShipProduction.endShipProduction(builder);
});
const localVec =
localOffsets.length === 0
? null
@@ -277,6 +381,36 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
routeOffsets.length === 0
? null
: Report.createRouteVector(builder, routeOffsets);
const otherScienceVec =
otherScienceOffsets.length === 0
? null
: Report.createOtherScienceVector(builder, otherScienceOffsets);
const otherShipClassVec =
otherShipClassOffsets.length === 0
? null
: Report.createOtherShipClassVector(builder, otherShipClassOffsets);
const bombingVec =
bombingOffsets.length === 0
? null
: Report.createBombingVector(builder, bombingOffsets);
const shipProductionVec =
shipProductionOffsets.length === 0
? null
: Report.createShipProductionVector(builder, shipProductionOffsets);
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so
// it uses the start/inline-write/end pattern rather than a typical
// offset-list helper. Iterating in reverse matches the FlatBuffers
// convention that the vector is built end-to-start.
const battleVec = (() => {
const ids = fixture.battles ?? [];
if (ids.length === 0) return null;
Report.startBattleVector(builder, ids.length);
for (let i = ids.length - 1; i >= 0; i--) {
const [hi, lo] = uuidToHiLo(ids[i]!);
UUID.createUUID(builder, hi, lo);
}
return builder.endVector();
})();
const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race);
const voteForOffset =
@@ -308,7 +442,25 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (localScienceVec !== null)
Report.addLocalScience(builder, localScienceVec);
if (routeVec !== null) Report.addRoute(builder, routeVec);
if (otherScienceVec !== null)
Report.addOtherScience(builder, otherScienceVec);
if (otherShipClassVec !== null)
Report.addOtherShipClass(builder, otherShipClassVec);
if (battleVec !== null) Report.addBattle(builder, battleVec);
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
if (shipProductionVec !== null)
Report.addShipProduction(builder, shipProductionVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
}
function uuidToHiLo(value: string): [bigint, bigint] {
const hex = value.replace(/-/g, "").toLowerCase();
if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) {
throw new Error(`buildReportPayload: invalid battle uuid ${value}`);
}
const hi = BigInt(`0x${hex.slice(0, 16)}`);
const lo = BigInt(`0x${hex.slice(16, 32)}`);
return [hi, lo];
}
@@ -0,0 +1,365 @@
// Phase 23 end-to-end coverage for the Report View. Mocks the
// gateway with a single seeded report that fills every wire field
// the orchestrator's sections render, then drives the page through
// the targeted-test contract:
//
// 1. Every TOC anchor click scrolls the matching section into view
// and the section is present in the DOM with at least one row
// (or its empty-state copy when it is intentionally empty).
// 2. Snapshot save/restore on the active-view-host scroll
// container survives a /map navigation round-trip.
// 3. The "back to map" button navigates to the map URL.
// 4. The mobile <select> fallback scrolls a section into view on
// a narrow viewport.
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 { 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-23-report-session";
const GAME_ID = "23232323-2323-2323-2323-232323232323";
const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
// SECTIONS lists every TOC slug paired with a row-presence hook.
// `expectRow` is null for sections that the seeded report
// intentionally leaves empty so the empty-state copy is asserted
// instead. The orchestrator's section order must match this
// list — the spec relies on each slug having a `report-toc-<slug>`
// and a `report-section-<slug>` testid.
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
{ slug: "votes", expectRow: "votes-mine" },
{ slug: "player-status", expectRow: "player-status-row" },
{ slug: "my-sciences", expectRow: "my-sciences-row" },
{ slug: "foreign-sciences", expectRow: "foreign-sciences-row" },
{ slug: "my-ship-classes", expectRow: "my-ship-classes-row" },
{ slug: "foreign-ship-classes", expectRow: "foreign-ship-classes-row" },
{ slug: "battles", expectRow: "report-battle-row" },
{ slug: "bombings", expectRow: "report-bombing-row" },
// `incomingShipGroups` cannot be seeded through the current
// e2e fixture (no builder); the orchestrator surfaces the
// empty-state copy and that is sufficient coverage here.
{ slug: "approaching-groups", expectRow: null },
{ slug: "my-planets", expectRow: "my-planets-row" },
{ slug: "ships-in-production", expectRow: "ships-in-production-row" },
// `cargo-routes` is empty in the seeded report — no route fixtures.
// The orchestrator surfaces the empty-state copy instead.
{ slug: "cargo-routes", expectRow: null },
{ slug: "foreign-planets", expectRow: "foreign-planets-row" },
{ slug: "uninhabited-planets", expectRow: "uninhabited-planets-row" },
{ slug: "unknown-planets", expectRow: "unknown-planets-row" },
// `my-fleets`, `my-ship-groups`, `foreign-ship-groups`,
// `unidentified-groups` are also empty — seeding them would
// require a parallel name-resolution pipeline for the fixture
// builder; the empty-state coverage is sufficient for the
// acceptance criterion.
{ slug: "my-fleets", expectRow: null },
{ slug: "my-ship-groups", expectRow: null },
{ slug: "foreign-ship-groups", expectRow: null },
{ slug: "unidentified-groups", expectRow: null },
];
async function mockGateway(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 23 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,
};
const storedOrder: CommandResultFixture[] = [];
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: "Earthlings",
myVotes: 4,
myVoteFor: "Andori",
players: [
{ name: "Earthlings", drive: 1, weapons: 1, shields: 1, cargo: 1, population: 4000, industry: 3000, planets: 2, relation: "-", votes: 4 },
{ name: "Andori", drive: 0.8, weapons: 0.6, shields: 0.5, cargo: 0.5, population: 3000, industry: 2500, planets: 2, relation: "PEACE", votes: 3 },
{ name: "Bajori", drive: 0.3, weapons: 0.2, shields: 0.2, cargo: 0.3, population: 2000, industry: 1500, planets: 1, relation: "WAR", votes: 2 },
{ name: "Cardassian", drive: 0, weapons: 0, shields: 0, cargo: 0, population: 0, industry: 0, planets: 0, relation: "PEACE", votes: 0, extinct: true },
],
localPlanets: [
{ number: 1, name: "Earth", x: 2000, y: 2000, size: 1000, resources: 5, population: 4000, industry: 3000, capital: 0, material: 0, colonists: 100, freeIndustry: 800, production: "Cruiser" },
],
otherPlanets: [
{ number: 2, name: "Andoria", owner: "Andori", x: 2500, y: 2000, size: 800, resources: 4, population: 3000, industry: 2500, capital: 12, material: 7, colonists: 80, freeIndustry: 600, production: "Capital" },
],
uninhabitedPlanets: [
{ number: 3, name: "Rock-1", x: 1800, y: 2300, size: 200, resources: 3, capital: 0, material: 25 },
],
unidentifiedPlanets: [{ number: 4, x: 2900, y: 1800 }],
localShipClass: [
{ name: "Cruiser", drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 2 },
],
localScience: [
{ name: "DriveResearch", drive: 1, weapons: 0, shields: 0, cargo: 0 },
],
otherScience: [
{ race: "Andori", name: "AnDrive", drive: 1 },
{ race: "Bajori", name: "BjMix", drive: 0.5, cargo: 0.5 },
],
otherShipClass: [
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
],
battles: [BATTLE_ID],
bombings: [
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
],
shipProductions: [
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
],
});
break;
}
case "user.games.order": {
payload = buildOrderResponsePayload(GAME_ID, storedOrder, Date.now());
break;
}
case "user.games.order.get": {
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
false,
);
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>(() => {});
},
);
// Approaching groups: the wire decoder filters incoming-group
// rows whose origin/destination names don't resolve against the
// planet tables — but the FBS builder takes raw numbers, so we
// inject one row directly through the buildReportPayload helper
// extension by re-routing the call in `mockGateway`. The fixture
// currently lacks an `incoming` builder; the seed above already
// fills bombings/ship-production, so approaching-groups stays
// empty here. The orchestrator surfaces the empty-state copy and
// the spec records that explicitly via `SECTIONS[].expectRow`.
}
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,
);
}
test.describe("Phase 23 report view", () => {
test("every TOC anchor lands its section in view", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"mobile coverage is the dedicated test below",
);
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(page.getByTestId("report-toc")).toBeVisible();
// Wait for the report to land. `galaxy-summary-field-turn`
// only mounts once `RenderedReportSource.report !== null`, so
// observing it confirms the gateway round-trip completed.
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
for (const entry of SECTIONS) {
const anchor = page.getByTestId(`report-toc-${entry.slug}`);
await anchor.click();
const section = page.getByTestId(`report-section-${entry.slug}`);
await expect(section).toBeInViewport();
if (entry.expectRow !== null) {
const row = section.getByTestId(entry.expectRow).first();
await expect(row).toBeVisible();
} else {
// Empty-state copy is rendered as a single status
// paragraph; the section still has visible content.
await expect(section).toBeVisible();
}
}
});
test("scroll position survives a /map round-trip via Snapshot", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"snapshot mechanism is the same on mobile; one project is enough",
);
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
// Scroll the window. The report's host expands to fit
// content rather than constraining its own height, so the
// document body is the real scroll container. SvelteKit's
// default scroll-restoration tracks `window.scrollY` on
// history navigation, which is what the acceptance criterion
// — "scroll position resets when switching to another view
// and is restored on return" — requires.
const target = 600;
await page.evaluate((value) => {
window.scrollTo(0, value);
}, target);
const savedScrollY = await page.evaluate(() => window.scrollY);
expect(savedScrollY).toBeGreaterThan(0);
// Programmatically click the back-to-map button. Driving the
// click through `evaluate` rather than the Playwright locator
// skips its built-in scrollIntoViewIfNeeded(), which would
// otherwise scroll the sticky TOC button into view and reset
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
// fires.
await page.evaluate(() => {
const button = document.querySelector(
"[data-testid='report-back-to-map']",
) as HTMLButtonElement | null;
button?.click();
});
await page.waitForURL(`**/games/${GAME_ID}/map`);
await page.goBack();
await page.waitForURL(`**/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.scrollY), {
timeout: 5_000,
intervals: [100, 200, 400],
})
.toBeGreaterThan(savedScrollY / 2);
});
test("back-to-map button navigates to the map URL", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"navigation is identical on mobile",
);
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await page.getByTestId("report-back-to-map").click();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`));
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
test("mobile select scrolls to the chosen section", async ({
page,
}, testInfo) => {
test.skip(
!testInfo.project.name.startsWith("chromium-mobile"),
"desktop branches are covered by the other tests above",
);
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
const mobileSelect = page.getByTestId("report-toc-mobile");
await expect(mobileSelect).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
await mobileSelect.selectOption("bombings");
await expect(
page.getByTestId("report-section-bombings"),
).toBeInViewport();
});
});