ui/phase-18: ship-class calc bridge with live designer preview

Wires pkg/calc/ship.go into the WASM Core boundary as seven thin
wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass,
Speed, CargoCapacity, CarryingMass). The ship-class designer reads
Core through a new CORE_CONTEXT_KEY populated by the in-game layout
and renders a five-row preview pane (mass, full-load mass, max
speed, range at full load, cargo capacity) that updates reactively
on every form edit and on the player's localPlayer{Drive,Weapons,
Shields,Cargo} tech levels — three of which are now decoded from
the report's Player block alongside the existing localPlayerDrive.

CarryingMass is the seventh wrapper added to the original six-function
list so that "full-load mass" composes through pkg/calc/ functions
without putting math in TypeScript.
This commit is contained in:
Ilia Denisov
2026-05-09 23:14:40 +02:00
parent 721fa2172d
commit e4dc0ce029
25 changed files with 1056 additions and 64 deletions
@@ -18,9 +18,12 @@ Phase 17 ship-class designer. Two modes driven by the optional
referenced by active production / ship groups) and a Back
button.
Phase 18 wires `pkg/calc/` into the form for live mass / speed /
range / cargo previews; the markup keeps a placeholder slot near
the value fields so the diff in Phase 18 stays minimal.
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
new-mode form: an `<aside class="preview">` block recomputes mass,
full-load mass, max speed, range at full load, and cargo capacity
on every form change, using the local player's tech levels off the
rendered report. The preview hides itself until the form passes
validation, so it never displays half-cooked numbers.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
@@ -41,6 +44,10 @@ the value fields so the diff in Phase 18 stays minimal.
validateShipClass,
type ShipClassInvalidReason,
} from "$lib/util/ship-class-validation";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
@@ -48,6 +55,7 @@ the value fields so the diff in Phase 18 stays minimal.
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const gameId = $derived(page.params.id ?? "");
const classId = $derived(page.params.classId ?? "");
@@ -105,6 +113,54 @@ the value fields so the diff in Phase 18 stays minimal.
);
const canSave = $derived(validation.ok && draft !== undefined);
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
interface PreviewValues {
mass: number;
fullLoadMass: number;
maxSpeed: number;
rangeAtFull: number;
cargoCapacity: number;
}
const preview = $derived.by<PreviewValues | null>(() => {
const core = coreHandle?.core;
if (core === undefined || core === null) return null;
if (!validation.ok) return null;
const v = validation.value;
const mass = core.emptyMass({
drive: v.drive,
weapons: v.weapons,
armament: v.armament,
shields: v.shields,
cargo: v.cargo,
});
if (mass === null) return null;
const cargoCapacity = core.cargoCapacity({
cargo: v.cargo,
cargoTech,
});
const carryAtFull =
cargoTech > 0
? core.carryingMass({ load: cargoCapacity, cargoTech })
: 0;
const fullLoadMass = core.fullMass({
emptyMass: mass,
carryingMass: carryAtFull,
});
const driveEffective = core.driveEffective({
drive: v.drive,
driveTech,
});
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
const rangeAtFull = core.speed({
driveEffective,
fullMass: fullLoadMass,
});
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
});
$effect(() => {
if (!isViewMode) {
void tick().then(() => nameInputEl?.focus());
@@ -309,6 +365,52 @@ the value fields so the diff in Phase 18 stays minimal.
{invalidMessage}
</p>
{/if}
{#if preview !== null}
<aside
class="preview"
data-testid="designer-ship-class-preview"
>
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
<dl>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
<dd data-testid="designer-ship-class-preview-mass">
{formatNumber(preview.mass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
</dt>
<dd data-testid="designer-ship-class-preview-full-load-mass">
{formatNumber(preview.fullLoadMass)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.max_speed")}
</dt>
<dd data-testid="designer-ship-class-preview-max-speed">
{formatNumber(preview.maxSpeed)}
</dd>
</div>
<div class="row">
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
<dd data-testid="designer-ship-class-preview-range">
{formatNumber(preview.rangeAtFull)}
</dd>
</div>
<div class="row">
<dt>
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
</dt>
<dd data-testid="designer-ship-class-preview-cargo-capacity">
{formatNumber(preview.cargoCapacity)}
</dd>
</div>
</dl>
</aside>
{/if}
<div class="actions">
<button
type="button"
@@ -383,6 +485,42 @@ the value fields so the diff in Phase 18 stays minimal.
font-size: 0.8rem;
color: #d97a7a;
}
.preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 0.85rem;
background: #0a0e1a;
border: 1px solid #2a3150;
border-radius: 4px;
max-width: 30rem;
}
.preview h3 {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.preview dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.2rem;
column-gap: 0.75rem;
}
.preview .row {
display: contents;
}
.preview dt {
color: #aab;
font-size: 0.85rem;
}
.preview dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
text-align: right;
}
.fields {
margin: 0;
display: grid;