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
@@ -0,0 +1,184 @@
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import type { ClaimedInput } from "./calc-model";
export interface DesignBlocksState {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface TechState {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export type TechKey = keyof TechState;
type Props = {
blocks: DesignBlocksState;
// Blocks after goal-seek: the claimed block carries its solved
// value, which is what the read-only computed cell displays.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
let {
blocks = $bindable(),
resolved,
techs = $bindable(),
techOverridden,
computedInput = null,
blocksReadonly = false,
onTechInput,
onResetTech,
}: Props = $props();
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
step: string;
tech: TechKey | null;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
];
</script>
<div class="design" data-testid="calculator-design-area">
<div class="cols">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
</div>
{#each BLOCK_ROWS as row (row.key)}
{@const isComputed = computedInput === row.key}
<div class="row">
<span class="label">{row.label()}</span>
{#if isComputed}
<input
class="ship"
type="number"
step={row.step}
readonly
value={resolved[row.key]}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship"
type="number"
step={row.step}
min="0"
bind:value={blocks[row.key]}
readonly={blocksReadonly}
data-testid={`calculator-block-${row.key}`}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
onclick={() => onResetTech(techKey)}
>
🔒
</button>
{/if}
</span>
{:else}
<span></span>
{/if}
</div>
{/each}
</div>
<style>
.design {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.cols,
.row {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
text-transform: lowercase;
}
.label {
color: #aab;
font-size: 0.8rem;
}
input {
font: inherit;
font-size: 0.8rem;
width: 100%;
min-width: 0;
padding: 0.2rem 0.35rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
input[data-computed="true"],
input[readonly] {
color: #9fb0ff;
background: #11162a;
}
.tech-cell {
display: flex;
align-items: center;
gap: 0.2rem;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
}
</style>