ui/phase-21: sciences CRUD list, designer, and production-picker integration

Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 21:32:37 +02:00
parent 0509f2cde2
commit 7bea22b0b5
31 changed files with 2751 additions and 71 deletions
+41 -1
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
@@ -82,13 +84,29 @@ export interface RemoveShipClassResultFixture extends CommandResultFixtureBase {
name: string;
}
export interface CreateScienceResultFixture extends CommandResultFixtureBase {
kind: "createScience";
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export interface RemoveScienceResultFixture extends CommandResultFixtureBase {
kind: "removeScience";
name: string;
}
export type CommandResultFixture =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture
| SetCargoRouteResultFixture
| RemoveCargoRouteResultFixture
| CreateShipClassResultFixture
| RemoveShipClassResultFixture;
| RemoveShipClassResultFixture
| CreateScienceResultFixture
| RemoveScienceResultFixture;
export function buildOrderResponsePayload(
gameId: string,
@@ -215,6 +233,28 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
payloadType = CommandPayload.CommandShipClassRemove;
break;
}
case "createScience": {
const nameOffset = builder.createString(c.name);
inner = CommandScienceCreate.createCommandScienceCreate(
builder,
nameOffset,
c.drive,
c.weapons,
c.shields,
c.cargo,
);
payloadType = CommandPayload.CommandScienceCreate;
break;
}
case "removeScience": {
const nameOffset = builder.createString(c.name);
inner = CommandScienceRemove.createCommandScienceRemove(
builder,
nameOffset,
);
payloadType = CommandPayload.CommandScienceRemove;
break;
}
}
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
+30 -2
View File
@@ -11,8 +11,9 @@
// against realistic values. Phase 15 adds a minimal `LocalShipClass`
// projection so the planet inspector's Build-Ship sub-picker has data
// in e2e specs (`name` only — Phase 17 widens this when ship-class
// CRUD lands). Later phases extend the helper as fleets, sciences,
// etc. land.
// CRUD lands). Phase 21 adds a `LocalScience` projection so the
// sciences table and the planet production picker's Research sub-row
// have data in e2e specs.
import { Builder } from "flatbuffers";
@@ -23,6 +24,7 @@ import {
Report,
Route,
RouteEntry,
Science,
ShipClass,
UnidentifiedPlanet,
UninhabitedPlanet,
@@ -60,6 +62,14 @@ export interface ShipClassFixture {
cargo?: number;
}
export interface ScienceFixture {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
}
export interface PlayerFixture {
name: string;
drive?: number;
@@ -84,6 +94,7 @@ export interface ReportFixture {
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[];
localScience?: ScienceFixture[];
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
@@ -178,6 +189,17 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return ShipClass.endShipClass(builder);
});
const localScienceOffsets = (fixture.localScience ?? []).map((sci) => {
const name = builder.createString(sci.name);
Science.startScience(builder);
Science.addName(builder, name);
Science.addDrive(builder, sci.drive ?? 0);
Science.addWeapons(builder, sci.weapons ?? 0);
Science.addShields(builder, sci.shields ?? 0);
Science.addCargo(builder, sci.cargo ?? 0);
return Science.endScience(builder);
});
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
Player.startPlayer(builder);
@@ -221,6 +243,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
localShipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
const localScienceVec =
localScienceOffsets.length === 0
? null
: Report.createLocalScienceVector(builder, localScienceOffsets);
const playerVec =
playerOffsets.length === 0
? null
@@ -251,6 +277,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec);
if (localScienceVec !== null)
Report.addLocalScience(builder, localScienceVec);
if (routeVec !== null) Report.addRoute(builder, routeVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
+423
View File
@@ -0,0 +1,423 @@
// Phase 21 end-to-end coverage for the science CRUD flow + the
// production-picker integration. Boots an authenticated session,
// mocks the gateway with a single local planet plus an empty
// `localScience` projection, navigates to the sciences table, opens
// the designer, fills the form (four percentages summing to 100),
// and asserts that:
//
// 1. Save adds a `createScience` row to the local order draft,
// auto-syncs through `user.games.order`, and the new science
// appears in the table immediately (overlay) and in the sidebar
// order tab as `applied`;
// 2. invalid input keeps the Save button disabled and surfaces
// the localised reason (sum-not-100 + duplicate name);
// 3. setting a planet's production to the new science via the
// Research sub-row of the planet inspector dispatches
// `setProductionType("SCIENCE", <name>)` and the optimistic
// overlay flips the planet's production string immediately;
// 4. Delete on a row adds a `removeScience` and the science
// disappears from the table; the order tab reflects both rows.
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 {
CommandPayload,
CommandPlanetProduce,
CommandScienceCreate,
CommandScienceRemove,
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,
type ScienceFixture,
} from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-21-sciences-session";
const GAME_ID = "21212121-2121-2121-2121-212121212121";
interface MockOpts {
createOutcome: "applied" | "rejected";
initialSciences?: ScienceFixture[];
}
interface MockHandle {
get lastCreate(): {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
} | null;
get lastRemove(): { name: string } | null;
get lastProduce(): {
planetNumber: number;
productionType: string;
subject: string;
} | null;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 21 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 lastCreate: MockHandle["lastCreate"] = null;
let lastRemove: MockHandle["lastRemove"] = null;
let lastProduce: MockHandle["lastProduce"] = null;
const reportSciences: ScienceFixture[] = [...(opts.initialSciences ?? [])];
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",
players: [{ name: "Earthlings", drive: 1 }],
localPlanets: [
{
number: 1,
name: "Earth",
x: 2000,
y: 2000,
size: 1000,
resources: 5,
population: 800,
industry: 600,
},
],
localScience: reportSciences,
});
break;
}
case "user.games.order": {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
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.CommandScienceCreate) {
const inner = new CommandScienceCreate();
item.payload(inner);
lastCreate = {
name: inner.name() ?? "",
drive: inner.drive(),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
const applied = opts.createOutcome === "applied";
fixtures.push({
kind: "createScience",
cmdId,
name: lastCreate.name,
drive: lastCreate.drive,
weapons: lastCreate.weapons,
shields: lastCreate.shields,
cargo: lastCreate.cargo,
applied,
errorCode: applied ? null : 1,
});
continue;
}
if (payloadType === CommandPayload.CommandScienceRemove) {
const inner = new CommandScienceRemove();
item.payload(inner);
lastRemove = { name: inner.name() ?? "" };
fixtures.push({
kind: "removeScience",
cmdId,
name: lastRemove.name,
applied: true,
errorCode: null,
});
continue;
}
if (payloadType === CommandPayload.CommandPlanetProduce) {
const inner = new CommandPlanetProduce();
item.payload(inner);
lastProduce = {
planetNumber: Number(inner.number()),
productionType: String(inner.production()),
subject: inner.subject() ?? "",
};
fixtures.push({
kind: "setProductionType",
cmdId,
planetNumber: Number(inner.number()),
productionType: "SCIENCE",
subject: inner.subject() ?? "",
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 lastCreate() {
return lastCreate;
},
get lastRemove() {
return lastRemove;
},
get lastProduce() {
return lastProduce;
},
};
}
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("create / list / delete science via the table + designer", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 21 spec covers desktop layout; mobile inherits the same store",
);
const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/sciences`);
const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible();
await expect(page.getByTestId("sciences-empty")).toBeVisible();
await page.getByTestId("sciences-new").click();
await expect(page.getByTestId("active-view-designer-science")).toHaveAttribute(
"data-mode",
"new",
);
await page.getByTestId("designer-science-input-name").fill("FirstStep");
await page.getByTestId("designer-science-input-drive").fill("25");
await page.getByTestId("designer-science-input-weapons").fill("25");
await page.getByTestId("designer-science-input-shields").fill("25");
await page.getByTestId("designer-science-input-cargo").fill("25");
const save = page.getByTestId("designer-science-save");
await expect(save).toBeEnabled();
await save.click();
// Returns to the table; the optimistic overlay shows the new science.
await expect(page.getByTestId("sciences-table")).toBeVisible();
const row = page.getByTestId("sciences-row");
await expect(row).toHaveAttribute("data-name", "FirstStep");
await expect(page.getByTestId("sciences-cell-drive")).toHaveText("25");
// The auto-sync round-trip lands as applied.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"FirstStep",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied",
);
expect(handle.lastCreate?.name).toBe("FirstStep");
expect(handle.lastCreate?.drive).toBeCloseTo(0.25, 12);
// Delete the science through the table action; the row disappears
// and the order tab gets a second row.
await page.getByTestId("sidebar-tab-inspector").click();
await page.getByTestId("sciences-delete").click();
await expect(page.getByTestId("sciences-empty")).toBeVisible();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-command-label-1")).toContainText(
"FirstStep",
);
expect(handle.lastRemove?.name).toBe("FirstStep");
});
test("designer keeps Save disabled while the form is invalid", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 21 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/science`);
const save = page.getByTestId("designer-science-save");
await expect(save).toBeDisabled();
// Empty name surfaces the entity-name error.
await expect(page.getByTestId("designer-science-error")).toHaveText(
"name cannot be empty",
);
// Sum off — error stays visible and Save remains disabled.
await page.getByTestId("designer-science-input-name").fill("Bad");
await page.getByTestId("designer-science-input-drive").fill("50");
await expect(page.getByTestId("designer-science-error")).toHaveText(
"the four percentages must sum to exactly 100",
);
await expect(save).toBeDisabled();
// Filling the rest to total 100 enables Save.
await page.getByTestId("designer-science-input-weapons").fill("50");
await expect(save).toBeEnabled();
});
test("planet production picker exposes user sciences in the Research sub-row", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 21 spec covers desktop layout; mobile inherits the same store",
);
const handle = await mockGateway(page, {
createOutcome: "applied",
initialSciences: [
{ name: "FirstStep", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25 },
],
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
// Click the planet on the map canvas to seed the inspector
// selection — the Phase 11 map auto-centres on the single planet.
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
if (box === null) throw new Error("canvas has no bounding box");
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Expand the Research segment.
await sidebar
.getByTestId("inspector-planet-production-segment-research")
.click();
// Tech buttons + the user's science button are both rendered.
await expect(
sidebar.getByTestId("inspector-planet-production-research-drive"),
).toBeVisible();
const scienceButton = sidebar.getByTestId(
"inspector-planet-production-science-FirstStep",
);
await expect(scienceButton).toBeVisible();
// Click the science → setProductionType("SCIENCE", "FirstStep")
// lands in the draft and auto-syncs.
await scienceButton.click();
await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep");
expect(handle.lastProduce?.planetNumber).toBe(1);
});