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
+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(