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:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
computeReachCircles,
|
||||
reachBound,
|
||||
REACH_CIRCLE_ID_PREFIX,
|
||||
} from "../src/map/reach-circles";
|
||||
|
||||
const CENTER = { x: 500, y: 500 };
|
||||
|
||||
describe("computeReachCircles", () => {
|
||||
test("no circles for a non-positive speed", () => {
|
||||
expect(computeReachCircles(CENTER, 0, 1000, 1000, "torus")).toEqual([]);
|
||||
expect(computeReachCircles(CENTER, -5, 1000, 1000, "torus")).toEqual([]);
|
||||
});
|
||||
|
||||
test("torus: a slow ship shows all three rings", () => {
|
||||
// bound = min(1000,1000)/2 = 500; speed 100 keeps every ring inside.
|
||||
const circles = computeReachCircles(CENTER, 100, 1000, 1000, "torus");
|
||||
expect(circles.map((c) => c.radius)).toEqual([100, 200, 300]);
|
||||
expect(circles[0].id).toBe(REACH_CIRCLE_ID_PREFIX + 1);
|
||||
expect(circles[0].style.strokeColor).toBeDefined();
|
||||
});
|
||||
|
||||
test("torus: a ship reaching the wrap midpoint shows one ring", () => {
|
||||
// speed 500 hits the bound on turn 1, so turn 2 is dropped.
|
||||
const circles = computeReachCircles(CENTER, 500, 1000, 1000, "torus");
|
||||
expect(circles).toHaveLength(1);
|
||||
expect(circles[0].radius).toBe(500);
|
||||
});
|
||||
|
||||
test("torus: a mid-speed ship shows two rings", () => {
|
||||
// speed 300: ring 1 = 300 (< 500), ring 2 = 600; ring 3 dropped
|
||||
// because 2 × 300 = 600 ≥ 500.
|
||||
const circles = computeReachCircles(CENTER, 300, 1000, 1000, "torus");
|
||||
expect(circles.map((c) => c.radius)).toEqual([300, 600]);
|
||||
});
|
||||
|
||||
test("no-wrap: the bound is the farthest corner", () => {
|
||||
// origin at a corner → farthest corner is the diagonal.
|
||||
expect(reachBound({ x: 0, y: 0 }, 1000, 1000, "no-wrap")).toBeCloseTo(
|
||||
Math.hypot(1000, 1000),
|
||||
6,
|
||||
);
|
||||
const circles = computeReachCircles(
|
||||
{ x: 0, y: 0 },
|
||||
500,
|
||||
1000,
|
||||
1000,
|
||||
"no-wrap",
|
||||
);
|
||||
// bound ≈ 1414, so all three rings fit.
|
||||
expect(circles.map((c) => c.radius)).toEqual([500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user