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
+128
View File
@@ -0,0 +1,128 @@
// makeFakeCore builds a complete `Core` whose calc methods mirror
// `pkg/calc` exactly, for component and unit tests that must not boot the
// real WASM module. The committed `core.wasm` is rebuilt out-of-band
// (`make wasm`, needs TinyGo), so tests that exercise calculator math
// inject this fake instead of depending on a freshly built binary. The
// Go parity tests in `ui/core/calc` guarantee the real bridge agrees with
// `pkg/calc`, so a fake that also mirrors `pkg/calc` stays faithful.
//
// Pass `overrides` to replace individual methods — e.g. `vi.fn()` spies
// when a test wants to assert how the calc-model orchestrates the bridge.
import type { Core } from "../src/platform/core/index";
function weaponsBlockMass(weapons: number, armament: number): number | null {
if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) {
return null;
}
return (armament + 1) * (weapons / 2);
}
export function makeFakeCore(overrides: Partial<Core> = {}): Core {
const base: Core = {
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
driveEffective: ({ drive, driveTech }) => drive * driveTech,
emptyMass: ({ drive, weapons, armament, shields, cargo }) => {
const wb = weaponsBlockMass(weapons, armament);
if (wb === null) return null;
return drive + shields + cargo + wb;
},
weaponsBlockMass: ({ weapons, armament }) =>
weaponsBlockMass(weapons, armament),
fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass,
speed: ({ driveEffective, fullMass }) =>
fullMass <= 0 ? 0 : (driveEffective * 20) / fullMass,
cargoCapacity: ({ cargo, cargoTech }) =>
cargoTech * (cargo + (cargo * cargo) / 20),
carryingMass: ({ load, cargoTech }) => (load <= 0 ? 0 : load / cargoTech),
blockUpgradeCost: ({ blockMass, currentTech, targetTech }) =>
blockMass === 0 || targetTech <= currentTech
? 0
: (1 - currentTech / targetTech) * 10 * blockMass,
effectiveAttack: ({ weapons, weaponsTech }) => weapons * weaponsTech,
effectiveDefence: ({ shields, shieldsTech, fullMass }) =>
fullMass <= 0
? 0
: ((shields * shieldsTech) / Math.cbrt(fullMass)) * Math.cbrt(30),
bombingPower: ({ weapons, weaponsTech, armament, number }) =>
(Math.sqrt(weapons * weaponsTech) / 10 + 1) *
weapons *
weaponsTech *
armament *
number,
shipBuildCost: ({ shipMass, material, resources }) => {
const matNeed = Math.max(0, shipMass - material);
const matFarm = resources > 0 ? matNeed / resources : 0;
return shipMass * 10 + matFarm;
},
produceShipsInTurn: ({
productionAvailable,
material,
resources,
shipMass,
}) => {
if (productionAvailable <= 0 || shipMass <= 0) {
return {
ships: 0,
materialLeft: material,
productionUsed: 0,
progress: 0,
};
}
let pa = productionAvailable;
let mat = material;
let ships = 0;
for (;;) {
const matNeed = Math.max(0, shipMass - mat);
const cost = shipMass * 10 + (resources > 0 ? matNeed / resources : 0);
if (pa < cost) {
return {
ships,
materialLeft: mat,
productionUsed: pa,
progress: pa / cost,
};
}
pa -= cost;
mat = mat - shipMass + matNeed;
ships += 1;
}
},
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
const ceiling = 20 * driveTech;
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
return null;
}
return (targetSpeed * restMass) / (ceiling - targetSpeed);
},
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {
if (targetDefence <= 0 || shieldsTech <= 0) return null;
const def = (s: number) =>
((s * shieldsTech) / Math.cbrt(s + restMass)) * Math.cbrt(30);
let lo = 0;
let hi = 1;
while (def(hi) < targetDefence) {
hi *= 2;
if (hi > 1e12) return null;
}
for (let i = 0; i < 100; i++) {
const mid = (lo + hi) / 2;
if (def(mid) < targetDefence) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
},
cargoForEmptyMass: ({ targetEmptyMass, restMass }) =>
targetEmptyMass - restMass < 0 ? null : targetEmptyMass - restMass,
loadForFullMass: ({ targetFullMass, emptyMass, cargoTech }) =>
cargoTech <= 0 || targetFullMass < emptyMass
? null
: (targetFullMass - emptyMass) * cargoTech,
};
return { ...base, ...overrides };
}