feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s

Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 19:52:08 +02:00
parent 00159ddf7c
commit 9ae7b88b89
53 changed files with 3748 additions and 1298 deletions
@@ -227,3 +227,54 @@ test("clicking a planet on mobile raises the bottom-sheet, close clears it", asy
await page.getByTestId("inspector-planet-sheet-close").click();
await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0);
});
// Counts reach-circle primitives off the renderer debug surface. Reach
// circles use ids in [REACH_CIRCLE_ID_PREFIX, bombing-marker prefix) —
// 0xb0000000..0xc0000000 (see `map/reach-circles.ts`).
async function countReachCircles(page: Page): Promise<number> {
return page.evaluate(() => {
const surface = (
window as unknown as {
__galaxyDebug?: {
getMapPrimitives?: () => readonly { id: number; kind: string }[];
};
}
).__galaxyDebug;
const prims = surface?.getMapPrimitives?.() ?? [];
return prims.filter(
(p) => p.kind === "circle" && p.id >= 0xb0000000 && p.id < 0xc0000000,
).length;
});
}
test("calculator draws reach circles for the selected planet", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"calculator + reach circles are a desktop-sidebar flow",
);
await setupShell(page);
// No reach circles before a planet is selected and a design exists.
expect(await countReachCircles(page)).toBe(0);
// Select the planet, then switch the sidebar to the calculator.
await clickCanvasCentre(page);
await page.getByTestId("sidebar-tab-calculator").click();
const calc = page.getByTestId("sidebar-tool-calculator");
await expect(calc).toBeVisible();
// A valid design with a positive drive tech override yields a
// positive loaded speed, which the calculator publishes to the map.
await calc.getByTestId("calculator-block-drive").fill("10");
await calc.getByTestId("calculator-block-shields").fill("5");
await calc.getByTestId("calculator-block-cargo").fill("5");
await calc.getByTestId("calculator-tech-drive").fill("1.2");
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
// Leaving ship mode clears the published reach, so the rings drop.
await calc.getByTestId("calculator-mode-modernization").click();
await expect.poll(() => countReachCircles(page)).toBe(0);
});
-5
View File
@@ -54,11 +54,6 @@ test("header view-menu navigates to every active view", async ({ page }) => {
["view-menu-item-report", "active-view-report", "/report"],
["view-menu-item-mail", "active-view-mail", "/mail"],
["view-menu-item-battle", "active-view-battle", "/battle"],
[
"view-menu-item-designer-ship-class",
"active-view-designer-ship-class",
"/designer/ship-class",
],
[
"view-menu-item-designer-science",
"active-view-designer-science",
@@ -161,7 +161,6 @@ async function readPrimitiveCount(page: Page): Promise<number> {
const NON_MAP_VIEWS: ReadonlyArray<{ label: string; testid: string }> = [
{ label: "report", testid: "view-menu-item-report" },
{ label: "designer-ship-class", testid: "view-menu-item-designer-ship-class" },
{ label: "designer-science", testid: "view-menu-item-designer-science" },
{ label: "mail", testid: "view-menu-item-mail" },
];
+42 -47
View File
@@ -1,8 +1,8 @@
// Phase 17 end-to-end coverage for the ship-class CRUD flow. Boots
// Phase 30 end-to-end coverage for the ship-class CRUD flow. Boots
// an authenticated session, mocks the gateway with a single local
// planet plus an empty `localShipClass` projection, navigates to
// the ship-classes table, opens the designer, fills the form, and
// asserts that:
// the ship-classes table, opens the sidebar calculator, fills the
// design, and asserts that:
//
// 1. Save adds a `createShipClass` row to the local order draft,
// auto-syncs through `user.games.order`, and the new class
@@ -254,12 +254,12 @@ async function bootSession(page: Page): Promise<void> {
);
}
test("create / list / delete ship class via the table + designer", async ({
test("create / list / delete ship class via the table + calculator", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
"phase 30 spec covers desktop layout; mobile inherits the same store",
);
const handle = await mockGateway(page, { createOutcome: "applied" });
@@ -270,19 +270,18 @@ test("create / list / delete ship class via the table + designer", async ({
await expect(tableHost).toBeVisible();
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
// "New" opens the calculator in the sidebar with a fresh design.
await page.getByTestId("ship-classes-new").click();
await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute(
"data-mode",
"new",
);
const calc = page.getByTestId("sidebar-tool-calculator");
await expect(calc).toBeVisible();
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
const save = page.getByTestId("designer-ship-class-save");
await expect(save).toBeEnabled();
await save.click();
await calc.getByTestId("calculator-name").fill("Drone");
await calc.getByTestId("calculator-block-drive").fill("1");
const create = calc.getByTestId("calculator-create");
await expect(create).toBeEnabled();
await create.click();
// Returns to the table; the optimistic overlay shows the new class.
// The table's optimistic overlay shows the new class.
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
const row = page.getByTestId("ship-classes-row");
await expect(row).toHaveAttribute("data-name", "Drone");
@@ -312,38 +311,33 @@ test("create / list / delete ship class via the table + designer", async ({
expect(handle.lastRemove?.name).toBe("Drone");
});
test("designer keeps Save disabled while the form is invalid", async ({
test("calculator keeps Create disabled while the design is invalid", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
"phase 30 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator");
const create = calc.getByTestId("calculator-create");
const save = page.getByTestId("designer-ship-class-save");
await expect(save).toBeDisabled();
// Empty name + all-zero blocks: Create is disabled.
await expect(create).toBeDisabled();
// Empty name surfaces the entity-name error.
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
"name cannot be empty",
);
// Mismatched armament / weapons keeps it disabled (pair rule).
await calc.getByTestId("calculator-name").fill("Bad");
await calc.getByTestId("calculator-block-armament").fill("1");
await calc.getByTestId("calculator-block-drive").fill("1");
await expect(create).toBeDisabled();
// Mismatched armament / weapons triggers the pair rule.
await page.getByTestId("designer-ship-class-input-name").fill("Bad");
await page.getByTestId("designer-ship-class-input-armament").fill("1");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
"armament and weapons must be both zero or both nonzero",
);
await expect(save).toBeDisabled();
// Filling weapons resolves the pair rule.
await page.getByTestId("designer-ship-class-input-weapons").fill("1");
await expect(save).toBeEnabled();
// Filling weapons resolves the pair rule and enables Create.
await calc.getByTestId("calculator-block-weapons").fill("1");
await expect(create).toBeEnabled();
});
test("rejected createShipClass keeps the table empty and surfaces the failure", async ({
@@ -351,23 +345,24 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
"phase 30 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { createOutcome: "rejected" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator");
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
await page.getByTestId("designer-ship-class-save").click();
await calc.getByTestId("calculator-name").fill("Drone");
await calc.getByTestId("calculator-block-drive").fill("1");
await calc.getByTestId("calculator-create").click();
// Designer's save() calls SvelteKit `goto` to navigate back to
// the table. SPA navigation keeps the per-game `OrderDraftStore`
// alive so the auto-sync round-trip (which flips the status from
// `submitting` to `rejected`) lands while the table is showing.
// Order tab carries a `rejected` row; the optimistic overlay
// drops the class once the engine answers `cmdApplied=false`.
// Create stays in the table active view (the calculator is a
// sidebar tool). The per-game OrderDraftStore drives the auto-sync
// round-trip, which flips the status to `rejected`; the order tab
// carries a `rejected` row and the overlay drops the class once the
// engine answers cmdApplied=false.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(