Files
galaxy-game/ui/frontend/src/lib/active-view/designer-ship-class.svelte
T
Ilia Denisov e4dc0ce029 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.
2026-05-09 23:14:40 +02:00

574 lines
15 KiB
Svelte

<!--
Phase 17 ship-class designer. Two modes driven by the optional
`classId` URL segment:
- **new (no classId)** — empty form with five numeric fields
plus name. Save is disabled until `validateShipClass` returns
`ok`; the localised tooltip mirrors `validateEntityName`'s
invalid-reason messages and the value-rule mirrors of
`pkg/calc/validator.go.ValidateShipTypeValues`. Save adds a
`createShipClass` to the local order draft and returns to the
table.
- **view (classId set)** — read-only render of the matching row
from the optimistic overlay. Ship classes are designed once
and cannot be modified after creation (per
`game/rules.txt`); the in-game upgrade story lives elsewhere
(`CommandShipGroupUpgrade`, Phase 19/20). The view exposes a
Delete affordance (engine-side checks ensure the class is not
referenced by active production / ship groups) and a Back
button.
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";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ShipClassSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
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,
);
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 ?? "");
const isViewMode = $derived(classId !== "");
const localShipClass = $derived<ShipClassSummary[]>(
rendered?.report?.localShipClass ?? [],
);
const existingNames = $derived(localShipClass.map((cls) => cls.name));
const viewing = $derived(
isViewMode
? localShipClass.find((cls) => cls.name === classId) ?? null
: null,
);
let name = $state("");
let drive = $state(0);
let armament = $state(0);
let weapons = $state(0);
let shields = $state(0);
let cargo = $state(0);
let nameInputEl: HTMLInputElement | null = $state(null);
const invalidReasonKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
empty: "game.designer.ship_class.invalid.empty",
too_long: "game.designer.ship_class.invalid.too_long",
starts_with_special: "game.designer.ship_class.invalid.starts_with_special",
ends_with_special: "game.designer.ship_class.invalid.ends_with_special",
consecutive_specials:
"game.designer.ship_class.invalid.consecutive_specials",
whitespace: "game.designer.ship_class.invalid.whitespace",
disallowed_character:
"game.designer.ship_class.invalid.disallowed_character",
duplicate_name: "game.designer.ship_class.invalid.duplicate_name",
drive_value: "game.designer.ship_class.invalid.drive_value",
armament_value: "game.designer.ship_class.invalid.armament_value",
armament_not_integer:
"game.designer.ship_class.invalid.armament_not_integer",
weapons_value: "game.designer.ship_class.invalid.weapons_value",
shields_value: "game.designer.ship_class.invalid.shields_value",
cargo_value: "game.designer.ship_class.invalid.cargo_value",
armament_weapons_pair:
"game.designer.ship_class.invalid.armament_weapons_pair",
all_zero: "game.designer.ship_class.invalid.all_zero",
};
const validation = $derived(
validateShipClass(
{ name, drive, armament, weapons, shields, cargo },
{ existingNames },
),
);
const invalidMessage = $derived(
validation.ok ? "" : i18n.t(invalidReasonKeyMap[validation.reason]),
);
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());
}
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function backToTable(): void {
void goto(`/games/${gameId}/table/ship-classes`);
}
async function save(): Promise<void> {
if (!validation.ok || draft === undefined) return;
await draft.add({
kind: "createShipClass",
id: crypto.randomUUID(),
name: validation.value.name,
drive: validation.value.drive,
armament: validation.value.armament,
weapons: validation.value.weapons,
shields: validation.value.shields,
cargo: validation.value.cargo,
});
backToTable();
}
async function deleteThis(): Promise<void> {
if (viewing === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: viewing.name,
});
backToTable();
}
</script>
<section
class="active-view"
data-testid="active-view-designer-ship-class"
data-mode={isViewMode ? "view" : "new"}
>
{#if isViewMode}
{#if viewing === null}
<h2>{i18n.t("game.view.designer.ship_class")}</h2>
<p class="not-found" data-testid="designer-ship-class-not-found">
{i18n.t("game.designer.ship_class.not_found", { name: classId })}
</p>
<div class="actions">
<button
type="button"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
</div>
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.view", { name: viewing.name })}
</h2>
<p class="notice" data-testid="designer-ship-class-notice">
{i18n.t("game.designer.ship_class.read_only_notice")}
</p>
<dl class="fields">
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.name")}</dt>
<dd data-testid="designer-ship-class-view-name">{viewing.name}</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.drive")}</dt>
<dd data-testid="designer-ship-class-view-drive">
{formatNumber(viewing.drive)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.armament")}</dt>
<dd data-testid="designer-ship-class-view-armament">
{viewing.armament}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.weapons")}</dt>
<dd data-testid="designer-ship-class-view-weapons">
{formatNumber(viewing.weapons)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.shields")}</dt>
<dd data-testid="designer-ship-class-view-shields">
{formatNumber(viewing.shields)}
</dd>
</div>
<div class="field">
<dt>{i18n.t("game.designer.ship_class.field.cargo")}</dt>
<dd data-testid="designer-ship-class-view-cargo">
{formatNumber(viewing.cargo)}
</dd>
</div>
</dl>
<div class="actions">
<button
type="button"
class="back"
data-testid="designer-ship-class-back"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.back")}
</button>
<button
type="button"
class="delete"
data-testid="designer-ship-class-delete"
disabled={draft === undefined}
onclick={() => void deleteThis()}
>
{i18n.t("game.designer.ship_class.action.delete")}
</button>
</div>
{/if}
{:else}
<h2 data-testid="designer-ship-class-title">
{i18n.t("game.designer.ship_class.title.new")}
</h2>
<p class="hint" data-testid="designer-ship-class-hint">
{i18n.t("game.designer.ship_class.hint.values")}
</p>
<form
class="form"
data-testid="designer-ship-class-form"
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.name")}</span>
<input
type="text"
bind:this={nameInputEl}
bind:value={name}
data-testid="designer-ship-class-input-name"
maxlength="30"
aria-invalid={validation.ok ? "false" : "true"}
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.drive")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={drive}
data-testid="designer-ship-class-input-drive"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.armament")}</span>
<input
type="number"
step="1"
min="0"
bind:value={armament}
data-testid="designer-ship-class-input-armament"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.weapons")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={weapons}
data-testid="designer-ship-class-input-weapons"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.shields")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={shields}
data-testid="designer-ship-class-input-shields"
/>
</label>
<label class="row">
<span>{i18n.t("game.designer.ship_class.field.cargo")}</span>
<input
type="number"
step="0.01"
min="0"
bind:value={cargo}
data-testid="designer-ship-class-input-cargo"
/>
</label>
{#if !validation.ok}
<p class="error" data-testid="designer-ship-class-error">
{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"
class="cancel"
data-testid="designer-ship-class-cancel"
onclick={backToTable}
>
{i18n.t("game.designer.ship_class.action.cancel")}
</button>
<button
type="submit"
class="save"
data-testid="designer-ship-class-save"
disabled={!canSave}
title={canSave ? "" : invalidMessage}
>
{i18n.t("game.designer.ship_class.action.save")}
</button>
</div>
</form>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.active-view h2 {
margin: 0;
font-size: 1.1rem;
}
.notice,
.hint,
.not-found {
margin: 0;
color: #888;
font-size: 0.85rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.55rem;
max-width: 30rem;
}
.row {
display: grid;
grid-template-columns: 8rem 1fr;
align-items: center;
gap: 0.6rem;
}
.row span {
color: #aab;
font-size: 0.85rem;
}
.row input {
font: inherit;
padding: 0.3rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.row input[aria-invalid="true"] {
border-color: #d97a7a;
}
.error {
margin: 0;
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;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
max-width: 30rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions button {
font: inherit;
font-size: 0.9rem;
padding: 0.3rem 0.7rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.actions button:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.actions .delete {
color: #d97a7a;
}
.actions .delete:not(:disabled):hover {
border-color: #d97a7a;
color: #f0a0a0;
}
</style>