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;
@@ -0,0 +1,31 @@
// Exposes the WASM `Core` instance through a Svelte context so views
// that need its math bridge (Phase 18 ship-class preview, future
// inspector calculators) can read it without re-booting the module.
// The layout populates `core` after `loadCore()` resolves; consumers
// observe `null` while the boot is in flight and the live `Core`
// once the runtime is ready.
import type { Core } from "../platform/core/index";
/**
* CORE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its booted `Core` to descendants such as
* the ship-class designer preview pane.
*/
export const CORE_CONTEXT_KEY = Symbol("core");
export interface CoreHandle {
readonly core: Core | null;
}
export class CoreHolder implements CoreHandle {
#core: Core | null = $state(null);
get core(): Core | null {
return this.#core;
}
set(core: Core | null): void {
this.#core = core;
}
}
+7
View File
@@ -238,6 +238,13 @@ const en = {
"game.designer.ship_class.invalid.cargo_value": "cargo must be 0 or ≥ 1",
"game.designer.ship_class.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
"game.designer.ship_class.invalid.all_zero": "at least one value must be nonzero",
"game.designer.ship_class.preview.title": "preview at your tech levels",
"game.designer.ship_class.preview.mass": "mass",
"game.designer.ship_class.preview.full_load_mass": "full-load mass",
"game.designer.ship_class.preview.max_speed": "max speed (ly/turn)",
"game.designer.ship_class.preview.range": "range at full load (ly/turn)",
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
"game.designer.ship_class.preview.unavailable": "—",
} as const;
export default en;
+7
View File
@@ -239,6 +239,13 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
"game.designer.ship_class.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.designer.ship_class.preview.title": "превью при ваших технологиях",
"game.designer.ship_class.preview.mass": "масса",
"game.designer.ship_class.preview.full_load_mass": "масса с полной загрузкой",
"game.designer.ship_class.preview.max_speed": "максимальная скорость (св.лет/ход)",
"game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)",
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
"game.designer.ship_class.preview.unavailable": "—",
};
export default ru;