e4dc0ce029
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.
574 lines
15 KiB
Svelte
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>
|