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,182 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
computeCalculator,
|
||||
computePlanetBuild,
|
||||
type CalculatorInput,
|
||||
} from "../src/lib/calculator/calc-model";
|
||||
import { makeFakeCore } from "./fake-core";
|
||||
|
||||
function input(overrides: Partial<CalculatorInput> = {}): CalculatorInput {
|
||||
return {
|
||||
blocks: { drive: 10, armament: 0, weapons: 0, shields: 5, cargo: 5 },
|
||||
driveTech: 1.2,
|
||||
weaponsTech: 1.5,
|
||||
shieldsTech: 1,
|
||||
cargoTech: 1,
|
||||
loadMode: "full",
|
||||
customLoad: 0,
|
||||
lock: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeCalculator forward", () => {
|
||||
test("returns null outputs without a Core", () => {
|
||||
const result = computeCalculator(input(), null);
|
||||
expect(result.outputs).toBeNull();
|
||||
expect(result.valuesValid).toBe(false);
|
||||
});
|
||||
|
||||
test("computes outputs for a valid design", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(input(), core);
|
||||
expect(result.valuesValid).toBe(true);
|
||||
expect(result.outputs).not.toBeNull();
|
||||
// empty mass = drive + shields + cargo = 20 (no weapons block).
|
||||
expect(result.outputs?.emptyMass).toBeCloseTo(20, 9);
|
||||
// cargo capacity = 1 * (5 + 25/20) = 6.25, full load mass = 26.25.
|
||||
expect(result.load).toBeCloseTo(6.25, 9);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(26.25, 9);
|
||||
});
|
||||
|
||||
test("hides outputs when blocks are invalid (armament without weapons)", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 3, weapons: 0, shields: 5, cargo: 5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.valuesValid).toBe(false);
|
||||
expect(result.valueReason).toBe("armament_weapons_pair");
|
||||
expect(result.outputs).toBeNull();
|
||||
});
|
||||
|
||||
test("empty load mode yields loaded mass equal to empty mass", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(input({ loadMode: "empty" }), core);
|
||||
expect(result.load).toBe(0);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(
|
||||
result.outputs?.emptyMass ?? -1,
|
||||
9,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCalculator goal-seek", () => {
|
||||
test("attack lock back-solves the weapons block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("weapons");
|
||||
// weapons = 30 / weaponsTech(1.5) = 20.
|
||||
expect(result.blocks.weapons).toBeCloseTo(20, 9);
|
||||
expect(result.outputs?.attack).toBeCloseTo(30, 6);
|
||||
});
|
||||
|
||||
test("loaded-speed lock back-solves the drive block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "speedLoaded", value: 5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("drive");
|
||||
expect(result.outputs?.speedLoaded).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
test("defence lock back-solves the shields block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "defense", value: 4 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("shields");
|
||||
expect(result.outputs?.defense).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
test("empty-mass lock back-solves the cargo block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "emptyMass", value: 25 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.computedInput).toBe("cargo");
|
||||
// cargo = 25 - (drive 10 + shields 5) = 10.
|
||||
expect(result.blocks.cargo).toBeCloseTo(10, 9);
|
||||
expect(result.outputs?.emptyMass).toBeCloseTo(25, 9);
|
||||
});
|
||||
|
||||
test("loaded-mass lock back-solves the cargo load", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "loadedMass", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.computedInput).toBe("load");
|
||||
// load = (30 - emptyMass 20) * cargoTech 1 = 10.
|
||||
expect(result.load).toBeCloseTo(10, 9);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(30, 9);
|
||||
});
|
||||
|
||||
test("an unreachable speed marks the lock infeasible", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
// ceiling is 20 * driveTech = 24; 100 is unreachable.
|
||||
input({ lock: { output: "speedEmpty", value: 100 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(false);
|
||||
expect(result.computedInput).toBeNull();
|
||||
// the claimed block keeps its raw value.
|
||||
expect(result.blocks.drive).toBe(10);
|
||||
});
|
||||
|
||||
test("calls the matching solver with the right context", () => {
|
||||
const weaponsForAttack = vi.fn(() => 7);
|
||||
const core = makeFakeCore({ weaponsForAttack });
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(weaponsForAttack).toHaveBeenCalledWith({
|
||||
targetAttack: 30,
|
||||
weaponsTech: 1.5,
|
||||
});
|
||||
expect(result.blocks.weapons).toBe(7);
|
||||
expect(result.computedInput).toBe("weapons");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePlanetBuild", () => {
|
||||
test("returns null without a Core", () => {
|
||||
expect(computePlanetBuild({ shipMass: 10, freeIndustry: 100, material: 0, resources: 10 }, null)).toBeNull();
|
||||
});
|
||||
|
||||
test("derives ships-per-turn from the per-turn build loop", () => {
|
||||
const core = makeFakeCore();
|
||||
// shipMass 1, ample material: 100 production / (10 per ship) = 10 ships.
|
||||
const result = computePlanetBuild(
|
||||
{ shipMass: 1, freeIndustry: 100, material: 100, resources: 10 },
|
||||
core,
|
||||
);
|
||||
expect(result?.wholeShips).toBe(10);
|
||||
expect(result?.shipsPerTurn).toBeCloseTo(10, 9);
|
||||
expect(result?.turnsPerShip).toBeCloseTo(0.1, 9);
|
||||
});
|
||||
|
||||
test("reports turns-per-ship when under one ship per turn", () => {
|
||||
const core = makeFakeCore();
|
||||
// shipMass 10, no material, resources 0.5: cost 120, 60 production → 0.5 ship.
|
||||
const result = computePlanetBuild(
|
||||
{ shipMass: 10, freeIndustry: 60, material: 0, resources: 0.5 },
|
||||
core,
|
||||
);
|
||||
expect(result?.wholeShips).toBe(0);
|
||||
expect(result?.shipsPerTurn).toBeCloseTo(0.5, 9);
|
||||
expect(result?.turnsPerShip).toBeCloseTo(2, 9);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user