feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user