feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
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:
@@ -1,573 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -31,6 +31,8 @@ preference the store already manages.
|
||||
} from "../../map/index";
|
||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||
import { computeReachCircles } from "../../map/reach-circles";
|
||||
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||
import {
|
||||
reportToWorld,
|
||||
type HitTarget,
|
||||
@@ -196,6 +198,11 @@ preference the store already manages.
|
||||
void toggles.bombingMarkers;
|
||||
void toggles.visibleHyperspace;
|
||||
|
||||
// Subscribe to the calculator's published reach so the rings
|
||||
// redraw as the design or the selected planet changes.
|
||||
void reachStore.origin;
|
||||
void reachStore.speedPerTurn;
|
||||
|
||||
// Phase 29 visibility derivation. Cargo routes and pending-
|
||||
// Send overlay are extras (no Pixi remount on flip); the
|
||||
// cascade-filtering happens here so the extras list shrinks
|
||||
@@ -219,8 +226,14 @@ preference the store already manages.
|
||||
// the visible set reliably triggers a push.
|
||||
const draftCommands = orderDraft?.commands ?? [];
|
||||
const draftStatuses = orderDraft?.statuses ?? {};
|
||||
const reachOrigin = reachStore.origin;
|
||||
const reachFingerprint =
|
||||
reachOrigin === null
|
||||
? ""
|
||||
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
||||
const extrasFingerprint =
|
||||
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
||||
`reach=${reachFingerprint}|` +
|
||||
computeRoutesFingerprint(report.routes) +
|
||||
"|" +
|
||||
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||
@@ -256,6 +269,7 @@ preference the store already manages.
|
||||
draftStatuses,
|
||||
toggles,
|
||||
hiddenPlanetNumbers,
|
||||
mode,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -289,6 +303,7 @@ preference the store already manages.
|
||||
draftStatuses: Readonly<Record<string, string>>,
|
||||
toggles: MapToggles,
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
mode: "torus" | "no-wrap",
|
||||
): import("../../map/world").Primitive[] {
|
||||
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
|
||||
const cargo = toggles.cargoRoutes
|
||||
@@ -300,7 +315,21 @@ preference the store already manages.
|
||||
draftStatuses,
|
||||
skip ? { skipPlanets: skip } : undefined,
|
||||
);
|
||||
return [...cargo, ...pending];
|
||||
// Reach circles published by the ship-class calculator. Empty
|
||||
// when no own planet is selected or the design is invalid, so
|
||||
// this is a no-op for the rest of the map.
|
||||
const reachOrigin = reachStore.origin;
|
||||
const reach =
|
||||
reachOrigin !== null && reachStore.speedPerTurn > 0
|
||||
? computeReachCircles(
|
||||
reachOrigin,
|
||||
reachStore.speedPerTurn,
|
||||
report.mapWidth,
|
||||
report.mapHeight,
|
||||
mode,
|
||||
)
|
||||
: [];
|
||||
return [...cargo, ...pending, ...reach];
|
||||
}
|
||||
|
||||
function applyVisibilityState(
|
||||
@@ -342,6 +371,7 @@ preference the store already manages.
|
||||
draftStatuses,
|
||||
toggles,
|
||||
hiddenPlanetNumbers,
|
||||
mode,
|
||||
),
|
||||
);
|
||||
lastExtrasFingerprint = extrasFingerprint;
|
||||
|
||||
@@ -14,8 +14,6 @@ data fetching is performed here — the layout is responsible.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } 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";
|
||||
@@ -27,6 +25,7 @@ data fetching is performed here — the layout is responsible.
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||
|
||||
type SortColumn =
|
||||
| "name"
|
||||
@@ -62,7 +61,6 @@ data fetching is performed here — the layout is responsible.
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
|
||||
let sortColumn: SortColumn = $state("name");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
@@ -111,14 +109,12 @@ data fetching is performed here — the layout is responsible.
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function openDesigner(name: string): void {
|
||||
void goto(
|
||||
`/games/${gameId}/designer/ship-class/${encodeURIComponent(name)}`,
|
||||
);
|
||||
function openInCalculator(name: string): void {
|
||||
calculatorLoadRequest.request(name);
|
||||
}
|
||||
|
||||
function newShipClass(): void {
|
||||
void goto(`/games/${gameId}/designer/ship-class`);
|
||||
calculatorLoadRequest.request(null);
|
||||
}
|
||||
|
||||
async function deleteShipClass(name: string): Promise<void> {
|
||||
@@ -194,7 +190,7 @@ data fetching is performed here — the layout is responsible.
|
||||
<tr
|
||||
data-testid="ship-classes-row"
|
||||
data-name={cls.name}
|
||||
ondblclick={() => openDesigner(cls.name)}
|
||||
ondblclick={() => openInCalculator(cls.name)}
|
||||
>
|
||||
<td data-testid="ship-classes-cell-name">{cls.name}</td>
|
||||
<td data-testid="ship-classes-cell-drive">{formatNumber(cls.drive)}</td>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// Pure orchestration for the ship-class calculator. The calculator
|
||||
// renders three areas — ship design, derived results, planet build — and
|
||||
// supports single-target "goal-seek": the player pins one derived result
|
||||
// and the model back-solves the single input it claims. All numeric math
|
||||
// lives in `pkg/calc` (reached through `Core`); this module only decides
|
||||
// which `Core` call to make, in what order, and how to fold the result
|
||||
// back into the field set. Keeping it a pure function of
|
||||
// `(CalculatorInput, Core)` makes the goal-seek logic unit-testable
|
||||
// without booting WASM or mounting a component.
|
||||
|
||||
import type { Core } from "../../platform/core/index";
|
||||
import {
|
||||
validateShipClassValues,
|
||||
type ShipClassValueInvalidReason,
|
||||
} from "../util/ship-class-validation";
|
||||
|
||||
/** LockableOutputId names every derived result the player may pin. */
|
||||
export type LockableOutputId =
|
||||
| "emptyMass"
|
||||
| "loadedMass"
|
||||
| "speedEmpty"
|
||||
| "speedLoaded"
|
||||
| "attack"
|
||||
| "defense";
|
||||
|
||||
/** ClaimedInput names every input a locked result can back-solve. */
|
||||
export type ClaimedInput = "drive" | "weapons" | "shields" | "cargo" | "load";
|
||||
|
||||
/**
|
||||
* CLAIM_MAP fixes which single input each lockable result back-solves.
|
||||
* The pairing is the natural lever for each result: attack rides on the
|
||||
* weapons block, defence on shields, both speeds on the drive block,
|
||||
* empty mass on the cargo block (the free filler), and loaded mass on the
|
||||
* cargo load.
|
||||
*/
|
||||
export const CLAIM_MAP: Record<LockableOutputId, ClaimedInput> = {
|
||||
emptyMass: "cargo",
|
||||
loadedMass: "load",
|
||||
speedEmpty: "drive",
|
||||
speedLoaded: "drive",
|
||||
attack: "weapons",
|
||||
defense: "shields",
|
||||
};
|
||||
|
||||
export type LoadMode = "empty" | "full" | "custom";
|
||||
|
||||
export interface DesignBlocks {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
export interface CalculatorInput {
|
||||
blocks: DesignBlocks;
|
||||
// Effective tech levels (the caller resolves default vs. override).
|
||||
driveTech: number;
|
||||
weaponsTech: number;
|
||||
shieldsTech: number;
|
||||
cargoTech: number;
|
||||
loadMode: LoadMode;
|
||||
customLoad: number;
|
||||
// The single pinned result, or null when nothing is locked.
|
||||
lock: { output: LockableOutputId; value: number } | null;
|
||||
}
|
||||
|
||||
export interface CalculatorOutputs {
|
||||
emptyMass: number;
|
||||
loadedMass: number;
|
||||
speedEmpty: number;
|
||||
speedLoaded: number;
|
||||
attack: number;
|
||||
defense: number;
|
||||
bombing: number;
|
||||
}
|
||||
|
||||
export interface CalculatorResult {
|
||||
/** Blocks after goal-seek may have overwritten the claimed one. */
|
||||
blocks: DesignBlocks;
|
||||
/** Which input the active lock drove, or null. */
|
||||
computedInput: ClaimedInput | null;
|
||||
/** False when the lock's target cannot be reached. */
|
||||
lockFeasible: boolean;
|
||||
/** Whether the resolved blocks pass the engine value rules. */
|
||||
valuesValid: boolean;
|
||||
valueReason: ShipClassValueInvalidReason | null;
|
||||
/** Resolved cargo load in cargo units. */
|
||||
load: number;
|
||||
cargoCapacity: number;
|
||||
/** Derived results, or null when invalid / no Core. */
|
||||
outputs: CalculatorOutputs | null;
|
||||
}
|
||||
|
||||
function resolveLoad(
|
||||
mode: LoadMode,
|
||||
customLoad: number,
|
||||
cargo: number,
|
||||
cargoTech: number,
|
||||
core: Core,
|
||||
): number {
|
||||
if (mode === "empty") return 0;
|
||||
if (mode === "custom") return customLoad > 0 ? customLoad : 0;
|
||||
return core.cargoCapacity({ cargo, cargoTech });
|
||||
}
|
||||
|
||||
// solveClaimedBlock back-solves the block claimed by a locked result
|
||||
// (everything except a `load` claim, which is resolved with the cargo
|
||||
// load). Returns null when the target is unreachable or the design's
|
||||
// weapons/armament pairing is invalid.
|
||||
function solveClaimedBlock(
|
||||
lock: { output: LockableOutputId; value: number },
|
||||
raw: DesignBlocks,
|
||||
input: CalculatorInput,
|
||||
prelimLoad: number,
|
||||
core: Core,
|
||||
): number | null {
|
||||
switch (lock.output) {
|
||||
case "attack":
|
||||
return core.weaponsForAttack({
|
||||
targetAttack: lock.value,
|
||||
weaponsTech: input.weaponsTech,
|
||||
});
|
||||
case "defense": {
|
||||
const restExclShields = core.emptyMass({ ...raw, shields: 0 });
|
||||
if (restExclShields === null) return null;
|
||||
const carrying = core.carryingMass({
|
||||
load: prelimLoad,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
return core.shieldsForDefence({
|
||||
targetDefence: lock.value,
|
||||
shieldsTech: input.shieldsTech,
|
||||
restMass: restExclShields + carrying,
|
||||
});
|
||||
}
|
||||
case "speedEmpty": {
|
||||
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
|
||||
if (restExclDrive === null) return null;
|
||||
return core.driveForSpeed({
|
||||
targetSpeed: lock.value,
|
||||
driveTech: input.driveTech,
|
||||
restMass: restExclDrive,
|
||||
});
|
||||
}
|
||||
case "speedLoaded": {
|
||||
const restExclDrive = core.emptyMass({ ...raw, drive: 0 });
|
||||
if (restExclDrive === null) return null;
|
||||
const carrying = core.carryingMass({
|
||||
load: prelimLoad,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
return core.driveForSpeed({
|
||||
targetSpeed: lock.value,
|
||||
driveTech: input.driveTech,
|
||||
restMass: restExclDrive + carrying,
|
||||
});
|
||||
}
|
||||
case "emptyMass": {
|
||||
const restExclCargo = core.emptyMass({ ...raw, cargo: 0 });
|
||||
if (restExclCargo === null) return null;
|
||||
return core.cargoForEmptyMass({
|
||||
targetEmptyMass: lock.value,
|
||||
restMass: restExclCargo,
|
||||
});
|
||||
}
|
||||
case "loadedMass":
|
||||
// Claims the cargo load, resolved alongside the load below.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* computeCalculator resolves the full calculator state for one input
|
||||
* snapshot: it applies the active goal-seek lock (if any), resolves the
|
||||
* cargo load, validates the blocks, and computes every derived result.
|
||||
* `outputs` is null when no `Core` is available or the blocks are
|
||||
* invalid, mirroring the Phase 18 designer rule of hiding the preview
|
||||
* until the design is sound.
|
||||
*/
|
||||
export function computeCalculator(
|
||||
input: CalculatorInput,
|
||||
core: Core | null,
|
||||
): CalculatorResult {
|
||||
const raw = input.blocks;
|
||||
if (core === null) {
|
||||
return {
|
||||
blocks: raw,
|
||||
computedInput: null,
|
||||
lockFeasible: true,
|
||||
valuesValid: false,
|
||||
valueReason: null,
|
||||
load: 0,
|
||||
cargoCapacity: 0,
|
||||
outputs: null,
|
||||
};
|
||||
}
|
||||
|
||||
const blocks: DesignBlocks = { ...raw };
|
||||
let computedInput: ClaimedInput | null = null;
|
||||
let lockFeasible = true;
|
||||
|
||||
// Preliminary load from the raw cargo, used by solvers that need the
|
||||
// carrying mass (speedLoaded, defence). It matches the final load for
|
||||
// every claim except `emptyMass` (which solves cargo without load) and
|
||||
// `loadedMass` (which solves the load itself).
|
||||
const prelimLoad = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
raw.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
|
||||
if (input.lock !== null) {
|
||||
const claimed = CLAIM_MAP[input.lock.output];
|
||||
if (claimed !== "load") {
|
||||
const solved = solveClaimedBlock(
|
||||
input.lock,
|
||||
raw,
|
||||
input,
|
||||
prelimLoad,
|
||||
core,
|
||||
);
|
||||
if (solved === null) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let load: number;
|
||||
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
||||
const emptyMass = core.emptyMass(blocks);
|
||||
const solvedLoad =
|
||||
emptyMass === null
|
||||
? null
|
||||
: core.loadForFullMass({
|
||||
targetFullMass: input.lock.value,
|
||||
emptyMass,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
if (solvedLoad === null) {
|
||||
lockFeasible = false;
|
||||
load = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
blocks.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
} else {
|
||||
load = solvedLoad;
|
||||
computedInput = "load";
|
||||
}
|
||||
} else {
|
||||
load = resolveLoad(
|
||||
input.loadMode,
|
||||
input.customLoad,
|
||||
blocks.cargo,
|
||||
input.cargoTech,
|
||||
core,
|
||||
);
|
||||
}
|
||||
|
||||
const valuesValidation = validateShipClassValues(blocks);
|
||||
const valuesValid = valuesValidation.ok;
|
||||
const valueReason = valuesValidation.ok ? null : valuesValidation.reason;
|
||||
const cargoCapacity = core.cargoCapacity({
|
||||
cargo: blocks.cargo,
|
||||
cargoTech: input.cargoTech,
|
||||
});
|
||||
|
||||
let outputs: CalculatorOutputs | null = null;
|
||||
if (valuesValid) {
|
||||
const emptyMass = core.emptyMass(blocks);
|
||||
if (emptyMass !== null) {
|
||||
const carrying = core.carryingMass({ load, cargoTech: input.cargoTech });
|
||||
const loadedMass = core.fullMass({ emptyMass, carryingMass: carrying });
|
||||
const driveEffective = core.driveEffective({
|
||||
drive: blocks.drive,
|
||||
driveTech: input.driveTech,
|
||||
});
|
||||
outputs = {
|
||||
emptyMass,
|
||||
loadedMass,
|
||||
speedEmpty: core.speed({ driveEffective, fullMass: emptyMass }),
|
||||
speedLoaded: core.speed({ driveEffective, fullMass: loadedMass }),
|
||||
attack: core.effectiveAttack({
|
||||
weapons: blocks.weapons,
|
||||
weaponsTech: input.weaponsTech,
|
||||
}),
|
||||
defense: core.effectiveDefence({
|
||||
shields: blocks.shields,
|
||||
shieldsTech: input.shieldsTech,
|
||||
fullMass: loadedMass,
|
||||
}),
|
||||
bombing: core.bombingPower({
|
||||
weapons: blocks.weapons,
|
||||
weaponsTech: input.weaponsTech,
|
||||
armament: blocks.armament,
|
||||
number: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
computedInput,
|
||||
lockFeasible,
|
||||
valuesValid,
|
||||
valueReason,
|
||||
load,
|
||||
cargoCapacity,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanetBuildInput {
|
||||
/** The designed ship's empty mass. */
|
||||
shipMass: number;
|
||||
/** Free industrial potential (the "L" parameter, FreeIndustry). */
|
||||
freeIndustry: number;
|
||||
/** Material stockpile (resolved: planet value or the player override). */
|
||||
material: number;
|
||||
/** Planet resources rating. */
|
||||
resources: number;
|
||||
}
|
||||
|
||||
export interface PlanetBuildResult {
|
||||
/** Whole ships plus fractional progress completable this turn. */
|
||||
shipsPerTurn: number;
|
||||
wholeShips: number;
|
||||
progress: number;
|
||||
/** Turns to finish one ship, or null when none can be produced. */
|
||||
turnsPerShip: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* computePlanetBuild folds one turn of ship production into the headline
|
||||
* "ships per turn" and "turns per ship" the planet area shows. It assumes
|
||||
* the planet keeps building this ship at the current (or overridden) MAT;
|
||||
* the realistic multi-turn forecast with population growth and CAP/COL
|
||||
* supply lands in Phase 34. Returns null without a `Core`.
|
||||
*/
|
||||
export function computePlanetBuild(
|
||||
input: PlanetBuildInput,
|
||||
core: Core | null,
|
||||
): PlanetBuildResult | null {
|
||||
if (core === null) return null;
|
||||
if (input.shipMass <= 0 || input.freeIndustry <= 0) {
|
||||
return { shipsPerTurn: 0, wholeShips: 0, progress: 0, turnsPerShip: null };
|
||||
}
|
||||
const r = core.produceShipsInTurn({
|
||||
productionAvailable: input.freeIndustry,
|
||||
material: input.material,
|
||||
resources: input.resources,
|
||||
shipMass: input.shipMass,
|
||||
});
|
||||
const shipsPerTurn = r.ships + r.progress;
|
||||
return {
|
||||
shipsPerTurn,
|
||||
wholeShips: r.ships,
|
||||
progress: r.progress,
|
||||
turnsPerShip: shipsPerTurn > 0 ? 1 / shipsPerTurn : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Shared signal that asks the sidebar calculator to open and load a ship
|
||||
// class. The ship-classes table (row activation, "new" button) and the
|
||||
// mobile bottom-tabs entry publish a request here; the in-game layout
|
||||
// watches it to flip the sidebar to the calculator tab, and the
|
||||
// calculator watches it to load the requested class. A module singleton
|
||||
// keeps these siblings decoupled, mirroring `reach.svelte`.
|
||||
//
|
||||
// `token` increments on every request so a repeat request for the same
|
||||
// class still re-triggers the watchers; each watcher records the last
|
||||
// token it handled to act exactly once per request.
|
||||
|
||||
class CalculatorLoadRequest {
|
||||
/** The class name to load, or null to start a fresh design. */
|
||||
name: string | null = $state(null);
|
||||
token = $state(0);
|
||||
|
||||
request(name: string | null): void {
|
||||
this.name = name;
|
||||
this.token += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export const calculatorLoadRequest = new CalculatorLoadRequest();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Shared bridge between the ship-class calculator (sidebar) and the map
|
||||
// view: the calculator publishes the selected planet's origin and the
|
||||
// current design's loaded speed here, and the map reads it to draw reach
|
||||
// circles. A module singleton keeps the two siblings decoupled — neither
|
||||
// imports the other — and survives sidebar tab switches. The store is
|
||||
// cleared whenever the calculator has no valid design or no selected
|
||||
// planet, which makes the map drop the rings.
|
||||
|
||||
class ReachStore {
|
||||
origin: { x: number; y: number } | null = $state(null);
|
||||
speedPerTurn = $state(0);
|
||||
|
||||
set(origin: { x: number; y: number }, speedPerTurn: number): void {
|
||||
this.origin = origin;
|
||||
this.speedPerTurn = speedPerTurn;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.origin = null;
|
||||
this.speedPerTurn = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const reachStore = new ReachStore();
|
||||
@@ -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>
|
||||
@@ -135,14 +135,6 @@ polishes microcopy.
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-designer-ship-class"
|
||||
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
|
||||
>
|
||||
{i18n.t("game.view.designer.ship_class")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -337,6 +337,65 @@ const en = {
|
||||
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
|
||||
"game.calculator.title": "ship class calculator",
|
||||
"game.calculator.mode.ship": "calculator",
|
||||
"game.calculator.mode.modernization": "modernization",
|
||||
"game.calculator.name.placeholder": "new class name",
|
||||
"game.calculator.name.existing": "your ship classes",
|
||||
"game.calculator.action.create": "create",
|
||||
"game.calculator.action.delete": "delete",
|
||||
"game.calculator.col.ship": "ship",
|
||||
"game.calculator.col.tech": "tech",
|
||||
"game.calculator.field.drive": "drive",
|
||||
"game.calculator.field.armament": "armament",
|
||||
"game.calculator.field.weapons": "weapons",
|
||||
"game.calculator.field.shields": "shields",
|
||||
"game.calculator.field.cargo": "cargo",
|
||||
"game.calculator.load.label": "load",
|
||||
"game.calculator.load.empty": "empty",
|
||||
"game.calculator.load.full": "full",
|
||||
"game.calculator.load.custom": "custom",
|
||||
"game.calculator.col.empty": "empty",
|
||||
"game.calculator.col.loaded": "loaded",
|
||||
"game.calculator.out.mass": "mass",
|
||||
"game.calculator.out.speed": "speed",
|
||||
"game.calculator.out.attack": "attack",
|
||||
"game.calculator.out.defense": "defense",
|
||||
"game.calculator.out.bombing": "bombing",
|
||||
"game.calculator.out.cargo_capacity": "cargo capacity",
|
||||
"game.calculator.planet.title": "planet",
|
||||
"game.calculator.planet.none": "select one of your planets on the map",
|
||||
"game.calculator.planet.label": "planet {name} (#{number})",
|
||||
"game.calculator.planet.mat": "MAT",
|
||||
"game.calculator.planet.ships_per_turn": "ships / turn",
|
||||
"game.calculator.planet.turns_per_ship": "turns / ship",
|
||||
"game.calculator.lock.reset": "locked — click to release to the computed value",
|
||||
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
|
||||
"game.calculator.lock.max": "release the locked result first — one result at a time",
|
||||
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
|
||||
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
|
||||
"game.calculator.modern.current": "current",
|
||||
"game.calculator.modern.target": "target",
|
||||
"game.calculator.modern.cost": "upgrade cost",
|
||||
"game.calculator.modern.total": "total upgrade cost",
|
||||
"game.calculator.unavailable": "—",
|
||||
"game.calculator.invalid.empty": "name cannot be empty",
|
||||
"game.calculator.invalid.too_long": "name is too long (30 characters max)",
|
||||
"game.calculator.invalid.starts_with_special": "name cannot start with a special character",
|
||||
"game.calculator.invalid.ends_with_special": "name cannot end with a special character",
|
||||
"game.calculator.invalid.consecutive_specials": "too many special characters in a row",
|
||||
"game.calculator.invalid.whitespace": "name cannot contain spaces",
|
||||
"game.calculator.invalid.disallowed_character": "name contains disallowed characters",
|
||||
"game.calculator.invalid.duplicate_name": "a ship class with this name already exists",
|
||||
"game.calculator.invalid.drive_value": "drive must be 0 or ≥ 1",
|
||||
"game.calculator.invalid.armament_value": "armament must be 0 or a positive integer",
|
||||
"game.calculator.invalid.armament_not_integer": "armament must be an integer",
|
||||
"game.calculator.invalid.weapons_value": "weapons must be 0 or ≥ 1",
|
||||
"game.calculator.invalid.shields_value": "shields must be 0 or ≥ 1",
|
||||
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
||||
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
||||
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
|
||||
|
||||
"game.table.sciences.title": "sciences",
|
||||
"game.table.sciences.column.name": "name",
|
||||
"game.table.sciences.column.drive": "drive %",
|
||||
|
||||
@@ -338,6 +338,65 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
|
||||
"game.calculator.title": "калькулятор классов кораблей",
|
||||
"game.calculator.mode.ship": "калькулятор",
|
||||
"game.calculator.mode.modernization": "модернизация",
|
||||
"game.calculator.name.placeholder": "имя нового класса",
|
||||
"game.calculator.name.existing": "ваши классы кораблей",
|
||||
"game.calculator.action.create": "создать",
|
||||
"game.calculator.action.delete": "удалить",
|
||||
"game.calculator.col.ship": "корабль",
|
||||
"game.calculator.col.tech": "технологии",
|
||||
"game.calculator.field.drive": "двигатель",
|
||||
"game.calculator.field.armament": "вооружённость",
|
||||
"game.calculator.field.weapons": "оружие",
|
||||
"game.calculator.field.shields": "защита",
|
||||
"game.calculator.field.cargo": "трюм",
|
||||
"game.calculator.load.label": "загрузка",
|
||||
"game.calculator.load.empty": "пусто",
|
||||
"game.calculator.load.full": "полная",
|
||||
"game.calculator.load.custom": "своя",
|
||||
"game.calculator.col.empty": "пустой",
|
||||
"game.calculator.col.loaded": "гружёный",
|
||||
"game.calculator.out.mass": "масса",
|
||||
"game.calculator.out.speed": "скорость",
|
||||
"game.calculator.out.attack": "атака",
|
||||
"game.calculator.out.defense": "защита",
|
||||
"game.calculator.out.bombing": "бомбардировка",
|
||||
"game.calculator.out.cargo_capacity": "грузоподъёмность",
|
||||
"game.calculator.planet.title": "планета",
|
||||
"game.calculator.planet.none": "выберите свою планету на карте",
|
||||
"game.calculator.planet.label": "планета {name} (#{number})",
|
||||
"game.calculator.planet.mat": "MAT",
|
||||
"game.calculator.planet.ships_per_turn": "кораблей / ход",
|
||||
"game.calculator.planet.turns_per_ship": "ходов / корабль",
|
||||
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
|
||||
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
|
||||
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
|
||||
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
|
||||
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
|
||||
"game.calculator.modern.current": "текущий",
|
||||
"game.calculator.modern.target": "целевой",
|
||||
"game.calculator.modern.cost": "стоимость апгрейда",
|
||||
"game.calculator.modern.total": "суммарная стоимость апгрейда",
|
||||
"game.calculator.unavailable": "—",
|
||||
"game.calculator.invalid.empty": "имя не может быть пустым",
|
||||
"game.calculator.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
|
||||
"game.calculator.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
|
||||
"game.calculator.invalid.ends_with_special": "имя не может заканчиваться спецсимволом",
|
||||
"game.calculator.invalid.consecutive_specials": "слишком много спецсимволов подряд",
|
||||
"game.calculator.invalid.whitespace": "имя не может содержать пробелы",
|
||||
"game.calculator.invalid.disallowed_character": "имя содержит недопустимые символы",
|
||||
"game.calculator.invalid.duplicate_name": "класс корабля с таким именем уже существует",
|
||||
"game.calculator.invalid.drive_value": "двигатель должен быть 0 или ≥ 1",
|
||||
"game.calculator.invalid.armament_value": "вооружённость должна быть 0 или положительным целым",
|
||||
"game.calculator.invalid.armament_not_integer": "вооружённость должна быть целым числом",
|
||||
"game.calculator.invalid.weapons_value": "оружие должно быть 0 или ≥ 1",
|
||||
"game.calculator.invalid.shields_value": "защита должна быть 0 или ≥ 1",
|
||||
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
|
||||
"game.table.sciences.title": "науки",
|
||||
"game.table.sciences.column.name": "название",
|
||||
"game.table.sciences.column.drive": "двигатель %",
|
||||
|
||||
@@ -179,14 +179,6 @@ destinations beats the duplication.
|
||||
>
|
||||
{i18n.t("game.view.mail")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-designer-ship-class"
|
||||
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
|
||||
>
|
||||
{i18n.t("game.view.designer.ship_class")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,29 +1,838 @@
|
||||
<!--
|
||||
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
|
||||
real ship/path calculator. Until then the stub renders a localised
|
||||
`coming soon` paragraph with a stable testid that later phases can
|
||||
replace without touching navigation.
|
||||
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
|
||||
designer: it fuses the ship design blocks, the live derived results
|
||||
(mass, speed, attack, defence, bombing), and a planet build-rate readout
|
||||
into one sidebar tool, and adds single-target goal-seek — the player pins
|
||||
one result and the model back-solves the single input it claims (see
|
||||
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
|
||||
price ship-class modernization. All math comes from `pkg/calc` through
|
||||
the `Core` WASM bridge; this component only holds input state and renders.
|
||||
|
||||
State is component-local: the sidebar keeps this tab mounted while the
|
||||
player navigates between active views, so inputs persist across view
|
||||
switches per the global state-preservation rule.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
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 { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
|
||||
import {
|
||||
validateShipClass,
|
||||
type ShipClassInvalidReason,
|
||||
} from "$lib/util/ship-class-validation";
|
||||
import {
|
||||
computeCalculator,
|
||||
computePlanetBuild,
|
||||
type LockableOutputId,
|
||||
type LoadMode,
|
||||
} from "$lib/calculator/calc-model";
|
||||
|
||||
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
|
||||
import ShipDesignArea, {
|
||||
type TechKey,
|
||||
} from "$lib/calculator/ship-design-area.svelte";
|
||||
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.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 selection = getContext<SelectionStore | undefined>(
|
||||
SELECTION_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
type Mode = "ship" | "modernization";
|
||||
let mode = $state<Mode>("ship");
|
||||
|
||||
let name = $state("");
|
||||
let blocks = $state({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 });
|
||||
let techValues = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
|
||||
let techOverridden = $state<Record<TechKey, boolean>>({
|
||||
drive: false,
|
||||
weapons: false,
|
||||
shields: false,
|
||||
cargo: false,
|
||||
});
|
||||
let targetTech = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
|
||||
let loadMode = $state<LoadMode>("full");
|
||||
let customLoad = $state(0);
|
||||
let lock = $state<LockableOutputId | null>(null);
|
||||
let lockValue = $state(0);
|
||||
let matOverridden = $state(false);
|
||||
let matValue = $state(0);
|
||||
let loadedExisting = $state<string | null>(null);
|
||||
|
||||
const core = $derived(coreHandle?.core ?? null);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const localShipClass = $derived<ShipClassSummary[]>(
|
||||
report?.localShipClass ?? [],
|
||||
);
|
||||
const existingNames = $derived(localShipClass.map((c) => c.name));
|
||||
|
||||
const playerTech = $derived({
|
||||
drive: report?.localPlayerDrive ?? 0,
|
||||
weapons: report?.localPlayerWeapons ?? 0,
|
||||
shields: report?.localPlayerShields ?? 0,
|
||||
cargo: report?.localPlayerCargo ?? 0,
|
||||
});
|
||||
|
||||
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
|
||||
|
||||
// Non-overridden tech levels track the player's current tech; the
|
||||
// effect resets them whenever the report (history snapshot included)
|
||||
// changes, so the calculator reflects the right turn's tech.
|
||||
$effect(() => {
|
||||
for (const k of techKeys) {
|
||||
if (!techOverridden[k]) techValues[k] = playerTech[k];
|
||||
}
|
||||
});
|
||||
// Seed the modernization target with the player's current tech once
|
||||
// the report has loaded; afterwards it is the player's to edit.
|
||||
let targetSeeded = false;
|
||||
$effect(() => {
|
||||
if (targetSeeded) return;
|
||||
if (
|
||||
playerTech.drive ||
|
||||
playerTech.weapons ||
|
||||
playerTech.shields ||
|
||||
playerTech.cargo
|
||||
) {
|
||||
targetTech = { ...playerTech };
|
||||
targetSeeded = true;
|
||||
}
|
||||
});
|
||||
|
||||
const result = $derived(
|
||||
computeCalculator(
|
||||
{
|
||||
blocks,
|
||||
driveTech: techValues.drive,
|
||||
weaponsTech: techValues.weapons,
|
||||
shieldsTech: techValues.shields,
|
||||
cargoTech: techValues.cargo,
|
||||
loadMode,
|
||||
customLoad,
|
||||
lock: lock === null ? null : { output: lock, value: lockValue },
|
||||
},
|
||||
core,
|
||||
),
|
||||
);
|
||||
|
||||
// Selected own planet (MVP: own planets only).
|
||||
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
|
||||
const sel = selection?.selected;
|
||||
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
|
||||
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
|
||||
if (planet === null || planet.kind !== "local") return null;
|
||||
return planet;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!matOverridden) {
|
||||
matValue = selectedPlanet?.materialsStockpile ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
const planetBuild = $derived.by(() => {
|
||||
if (selectedPlanet === null) return null;
|
||||
const emptyMass = result.outputs?.emptyMass;
|
||||
if (emptyMass === undefined) return null;
|
||||
return computePlanetBuild(
|
||||
{
|
||||
shipMass: emptyMass,
|
||||
freeIndustry: selectedPlanet.freeIndustry ?? 0,
|
||||
material: matValue,
|
||||
resources: selectedPlanet.resources ?? 0,
|
||||
},
|
||||
core,
|
||||
);
|
||||
});
|
||||
|
||||
// Publish the selected planet's reach (loaded speed) so the map view
|
||||
// can draw 1–3 reach circles. Cleared when the design is invalid, no
|
||||
// own planet is selected, or the calculator is in modernization mode.
|
||||
$effect(() => {
|
||||
const out = result.outputs;
|
||||
if (mode === "ship" && selectedPlanet !== null && out !== null) {
|
||||
reachStore.set(
|
||||
{ x: selectedPlanet.x, y: selectedPlanet.y },
|
||||
out.speedLoaded,
|
||||
);
|
||||
} else {
|
||||
reachStore.clear();
|
||||
}
|
||||
return () => reachStore.clear();
|
||||
});
|
||||
|
||||
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
|
||||
empty: "game.calculator.invalid.empty",
|
||||
too_long: "game.calculator.invalid.too_long",
|
||||
starts_with_special: "game.calculator.invalid.starts_with_special",
|
||||
ends_with_special: "game.calculator.invalid.ends_with_special",
|
||||
consecutive_specials: "game.calculator.invalid.consecutive_specials",
|
||||
whitespace: "game.calculator.invalid.whitespace",
|
||||
disallowed_character: "game.calculator.invalid.disallowed_character",
|
||||
duplicate_name: "game.calculator.invalid.duplicate_name",
|
||||
drive_value: "game.calculator.invalid.drive_value",
|
||||
armament_value: "game.calculator.invalid.armament_value",
|
||||
armament_not_integer: "game.calculator.invalid.armament_not_integer",
|
||||
weapons_value: "game.calculator.invalid.weapons_value",
|
||||
shields_value: "game.calculator.invalid.shields_value",
|
||||
cargo_value: "game.calculator.invalid.cargo_value",
|
||||
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
|
||||
all_zero: "game.calculator.invalid.all_zero",
|
||||
};
|
||||
|
||||
const nameValidation = $derived(
|
||||
validateShipClass({ name, ...result.blocks }, { existingNames }),
|
||||
);
|
||||
const createMessage = $derived(
|
||||
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
|
||||
);
|
||||
const canCreate = $derived(nameValidation.ok && draft !== undefined);
|
||||
const canDelete = $derived(
|
||||
loadedExisting !== null &&
|
||||
existingNames.includes(loadedExisting) &&
|
||||
draft !== undefined,
|
||||
);
|
||||
|
||||
// Per-block modernization upgrade cost (current tech → target tech).
|
||||
const modernCosts = $derived.by(() => {
|
||||
if (core === null) return null;
|
||||
const weaponsMass = core.weaponsBlockMass({
|
||||
weapons: blocks.weapons,
|
||||
armament: blocks.armament,
|
||||
});
|
||||
const rows: { key: TechKey; mass: number }[] = [
|
||||
{ key: "drive", mass: blocks.drive },
|
||||
{ key: "weapons", mass: weaponsMass ?? 0 },
|
||||
{ key: "shields", mass: blocks.shields },
|
||||
{ key: "cargo", mass: blocks.cargo },
|
||||
];
|
||||
const perBlock = rows.map((r) => ({
|
||||
key: r.key,
|
||||
cost: core.blockUpgradeCost({
|
||||
blockMass: r.mass,
|
||||
currentTech: techValues[r.key],
|
||||
targetTech: targetTech[r.key],
|
||||
}),
|
||||
}));
|
||||
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
|
||||
return { perBlock, total };
|
||||
});
|
||||
|
||||
function fmt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return i18n.t("game.calculator.unavailable");
|
||||
}
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function onTechInput(key: TechKey): void {
|
||||
techOverridden[key] = true;
|
||||
}
|
||||
function onResetTech(key: TechKey): void {
|
||||
techOverridden[key] = false;
|
||||
}
|
||||
function onMatInput(): void {
|
||||
matOverridden = true;
|
||||
}
|
||||
function resetMat(): void {
|
||||
matOverridden = false;
|
||||
}
|
||||
|
||||
function lockOutput(output: LockableOutputId): void {
|
||||
if (lock !== null) return;
|
||||
lockValue = result.outputs?.[output] ?? 0;
|
||||
lock = output;
|
||||
}
|
||||
function unlock(): void {
|
||||
lock = null;
|
||||
}
|
||||
|
||||
function loadExisting(clsName: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === clsName);
|
||||
if (cls === undefined) return;
|
||||
blocks = {
|
||||
drive: cls.drive,
|
||||
armament: cls.armament,
|
||||
weapons: cls.weapons,
|
||||
shields: cls.shields,
|
||||
cargo: cls.cargo,
|
||||
};
|
||||
name = cls.name;
|
||||
loadedExisting = cls.name;
|
||||
lock = null;
|
||||
}
|
||||
|
||||
function resetToNew(): void {
|
||||
blocks = { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||
name = "";
|
||||
loadedExisting = null;
|
||||
lock = null;
|
||||
}
|
||||
|
||||
// React to the ship-classes table / bottom-tabs asking to load a
|
||||
// class (or start a fresh design) into the calculator. The layout
|
||||
// flips the sidebar to this tab in parallel.
|
||||
let lastLoadToken = 0;
|
||||
$effect(() => {
|
||||
const token = calculatorLoadRequest.token;
|
||||
if (token === lastLoadToken) return;
|
||||
lastLoadToken = token;
|
||||
mode = "ship";
|
||||
if (calculatorLoadRequest.name === null) resetToNew();
|
||||
else loadExisting(calculatorLoadRequest.name);
|
||||
});
|
||||
|
||||
async function create(): Promise<void> {
|
||||
if (!nameValidation.ok || draft === undefined) return;
|
||||
// Capture the validated draft before awaiting: adding the command
|
||||
// re-projects `localShipClass`, which re-runs the `nameValidation`
|
||||
// derived into a `duplicate_name` failure (the class now exists),
|
||||
// leaving `nameValidation.value` undefined after the await.
|
||||
const created = nameValidation.value;
|
||||
await draft.add({
|
||||
kind: "createShipClass",
|
||||
id: crypto.randomUUID(),
|
||||
name: created.name,
|
||||
drive: created.drive,
|
||||
armament: created.armament,
|
||||
weapons: created.weapons,
|
||||
shields: created.shields,
|
||||
cargo: created.cargo,
|
||||
});
|
||||
loadedExisting = created.name;
|
||||
}
|
||||
|
||||
async function deleteClass(): Promise<void> {
|
||||
if (loadedExisting === null || draft === undefined) return;
|
||||
await draft.add({
|
||||
kind: "removeShipClass",
|
||||
id: crypto.randomUUID(),
|
||||
name: loadedExisting,
|
||||
});
|
||||
loadedExisting = null;
|
||||
}
|
||||
|
||||
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
||||
emptyMass: i18n.t("game.calculator.out.mass"),
|
||||
loadedMass: i18n.t("game.calculator.out.mass"),
|
||||
speedEmpty: i18n.t("game.calculator.out.speed"),
|
||||
speedLoaded: i18n.t("game.calculator.out.speed"),
|
||||
attack: i18n.t("game.calculator.out.attack"),
|
||||
defense: i18n.t("game.calculator.out.defense"),
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="tool" data-testid="sidebar-tool-calculator">
|
||||
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
|
||||
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
|
||||
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
|
||||
{#if lock === output}
|
||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
bind:value={lockValue}
|
||||
data-testid={`calculator-locked-${output}`}
|
||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
aria-label={i18n.t("game.calculator.lock.reset")}
|
||||
data-testid={`calculator-unlock-${output}`}
|
||||
onclick={unlock}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="cell">
|
||||
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
disabled={lock !== null || value === undefined}
|
||||
title={lock !== null
|
||||
? i18n.t("game.calculator.lock.max")
|
||||
: `${LOCK_LABELS[output]}`}
|
||||
aria-label={LOCK_LABELS[output]}
|
||||
data-testid={`calculator-lock-${output}`}
|
||||
onclick={() => lockOutput(output)}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<section class="calculator" data-testid="sidebar-tool-calculator">
|
||||
<div class="modes" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === "ship"}
|
||||
data-testid="calculator-mode-ship"
|
||||
onclick={() => (mode = "ship")}
|
||||
>
|
||||
{i18n.t("game.calculator.mode.ship")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === "modernization"}
|
||||
data-testid="calculator-mode-modernization"
|
||||
onclick={() => (mode = "modernization")}
|
||||
>
|
||||
{i18n.t("game.calculator.mode.modernization")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="namebar">
|
||||
<input
|
||||
type="text"
|
||||
class="name"
|
||||
list="calculator-existing-classes"
|
||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||
maxlength="30"
|
||||
bind:value={name}
|
||||
oninput={() => (loadedExisting = null)}
|
||||
onchange={() => loadExisting(name)}
|
||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||
data-testid="calculator-name"
|
||||
/>
|
||||
<datalist id="calculator-existing-classes">
|
||||
{#each localShipClass as cls (cls.name)}
|
||||
<option value={cls.name}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
{#if mode === "ship"}
|
||||
<button
|
||||
type="button"
|
||||
class="create"
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? "" : createMessage}
|
||||
data-testid="calculator-create"
|
||||
onclick={() => void create()}
|
||||
>
|
||||
{i18n.t("game.calculator.action.create")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mode === "ship" && canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="delete"
|
||||
data-testid="calculator-delete"
|
||||
onclick={() => void deleteClass()}
|
||||
>
|
||||
{i18n.t("game.calculator.action.delete")} {loadedExisting}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ShipDesignArea
|
||||
bind:blocks
|
||||
resolved={result.blocks}
|
||||
bind:techs={techValues}
|
||||
{techOverridden}
|
||||
computedInput={result.computedInput}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
/>
|
||||
|
||||
{#if mode === "ship"}
|
||||
<div class="load">
|
||||
<span class="label">{i18n.t("game.calculator.load.label")}</span>
|
||||
<div class="seg" role="group">
|
||||
{#each LOAD_MODES as m (m)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={loadMode === m}
|
||||
data-testid={`calculator-load-${m}`}
|
||||
onclick={() => (loadMode = m)}
|
||||
>
|
||||
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if loadMode === "custom"}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="custom-load"
|
||||
bind:value={customLoad}
|
||||
data-testid="calculator-custom-load"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="results" data-testid="calculator-results">
|
||||
<div class="rrow head">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
|
||||
{@render lockable("emptyMass", result.outputs?.emptyMass)}
|
||||
{@render lockable("loadedMass", result.outputs?.loadedMass)}
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
|
||||
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
|
||||
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
|
||||
{@render lockable("attack", result.outputs?.attack)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
|
||||
{@render lockable("defense", result.outputs?.defense)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-out-bombing">
|
||||
{fmt(result.outputs?.bombing)}
|
||||
</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-out-cargo-capacity">
|
||||
{fmt(result.outputs === null ? null : result.cargoCapacity)}
|
||||
</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="planet" data-testid="calculator-planet-area">
|
||||
{#if selectedPlanet === null}
|
||||
<p class="hint" data-testid="calculator-planet-none">
|
||||
{i18n.t("game.calculator.planet.none")}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="planet-name" data-testid="calculator-planet-name">
|
||||
{i18n.t("game.calculator.planet.label", {
|
||||
name: selectedPlanet.name,
|
||||
number: String(selectedPlanet.number),
|
||||
})}
|
||||
</p>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={matValue}
|
||||
oninput={onMatInput}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
{#if matOverridden}
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.mat.reset")}
|
||||
aria-label={i18n.t("game.calculator.mat.reset")}
|
||||
data-testid="calculator-mat-reset"
|
||||
onclick={resetMat}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<dl class="planet-stats">
|
||||
<div>
|
||||
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
|
||||
<dd data-testid="calculator-ships-per-turn">
|
||||
{fmt(planetBuild?.shipsPerTurn)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
|
||||
<dd data-testid="calculator-turns-per-ship">
|
||||
{fmt(planetBuild?.turnsPerShip ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="modern" data-testid="calculator-modernization">
|
||||
<div class="rrow head">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
|
||||
</div>
|
||||
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
bind:value={targetTech[row.key]}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
/>
|
||||
</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
|
||||
{fmt(row.cost)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="rrow total">
|
||||
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
|
||||
<span></span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-modern-total">
|
||||
{fmt(modernCosts?.total)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tool {
|
||||
padding: 1rem;
|
||||
.calculator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.tool h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool p {
|
||||
.modes button {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modes button.active {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
background: #11162a;
|
||||
}
|
||||
.namebar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.name[aria-invalid="true"] {
|
||||
border-color: #d97a7a;
|
||||
}
|
||||
.create,
|
||||
.delete {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.create:not(:disabled):hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete {
|
||||
color: #d97a7a;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.load {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.seg button {
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.active {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.custom-load {
|
||||
width: 4rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.results,
|
||||
.modern {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.rrow {
|
||||
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;
|
||||
}
|
||||
.label {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.cell .val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
.cell input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.cell.locked input {
|
||||
color: #9fb0ff;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.cell.infeasible input {
|
||||
border-color: #d97a7a;
|
||||
color: #f0a0a0;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.lock.active,
|
||||
.lock:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.lock:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.planet {
|
||||
border-top: 1px solid #20253a;
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.planet-name {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #cdd3f0;
|
||||
}
|
||||
.planet-stats {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
.planet-stats div {
|
||||
display: contents;
|
||||
}
|
||||
.planet-stats dt {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.planet-stats dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
.rrow.total .label {
|
||||
color: #cdd3f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// TS port of `pkg/calc/validator.go.ValidateShipTypeValues` plus a
|
||||
// thin wrapper that runs the entity-name rules and a duplicate-name
|
||||
// check against the live `localShipClass` projection. The validator
|
||||
// is reused by the ship-class designer (`active-view/designer-ship-class.svelte`)
|
||||
// is reused by the ship-class calculator (`sidebar/calculator-tab.svelte`)
|
||||
// for inline error messages and by `OrderDraftStore.validateCommand`
|
||||
// to gate auto-sync, so the local invariants match the engine's
|
||||
// (`game/internal/controller/ship_class.go.ShipClassCreate`).
|
||||
@@ -33,9 +33,12 @@ import {
|
||||
* translation keys for those branches and adds new keys only for
|
||||
* the value-derived ones.
|
||||
*/
|
||||
export type ShipClassInvalidReason =
|
||||
| EntityNameInvalidReason
|
||||
| "duplicate_name"
|
||||
/**
|
||||
* ShipClassValueInvalidReason enumerates the value-only refusals (no
|
||||
* name rules). The ship-class calculator validates the five blocks
|
||||
* independently of the name, so it consumes this narrower union.
|
||||
*/
|
||||
export type ShipClassValueInvalidReason =
|
||||
| "drive_value"
|
||||
| "armament_value"
|
||||
| "armament_not_integer"
|
||||
@@ -45,6 +48,11 @@ export type ShipClassInvalidReason =
|
||||
| "armament_weapons_pair"
|
||||
| "all_zero";
|
||||
|
||||
export type ShipClassInvalidReason =
|
||||
| EntityNameInvalidReason
|
||||
| "duplicate_name"
|
||||
| ShipClassValueInvalidReason;
|
||||
|
||||
/**
|
||||
* ShipClassDraft is the structural shape the designer composes. The
|
||||
* five numeric fields carry the player's typed values verbatim;
|
||||
@@ -60,10 +68,17 @@ export interface ShipClassDraft {
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
/** ShipClassValues is the five-block subset validated by value rules. */
|
||||
export type ShipClassValues = Omit<ShipClassDraft, "name">;
|
||||
|
||||
export type ShipClassValidation =
|
||||
| { ok: true; value: ShipClassDraft }
|
||||
| { ok: false; reason: ShipClassInvalidReason };
|
||||
|
||||
export type ShipClassValuesValidation =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: ShipClassValueInvalidReason };
|
||||
|
||||
/**
|
||||
* validateShipClass mirrors `ValidateShipTypeValues` plus the
|
||||
* entity-name rules. `existingNames` is the optimistic projection of
|
||||
@@ -84,38 +99,9 @@ export function validateShipClass(
|
||||
}
|
||||
const trimmedName = nameResult.value;
|
||||
|
||||
if (!isValidDWSC(draft.drive)) {
|
||||
return { ok: false, reason: "drive_value" };
|
||||
}
|
||||
if (!Number.isFinite(draft.armament) || draft.armament < 0) {
|
||||
return { ok: false, reason: "armament_value" };
|
||||
}
|
||||
if (!Number.isInteger(draft.armament)) {
|
||||
return { ok: false, reason: "armament_not_integer" };
|
||||
}
|
||||
if (!isValidDWSC(draft.weapons)) {
|
||||
return { ok: false, reason: "weapons_value" };
|
||||
}
|
||||
if (!isValidDWSC(draft.shields)) {
|
||||
return { ok: false, reason: "shields_value" };
|
||||
}
|
||||
if (!isValidDWSC(draft.cargo)) {
|
||||
return { ok: false, reason: "cargo_value" };
|
||||
}
|
||||
if (
|
||||
(draft.armament === 0 && draft.weapons !== 0) ||
|
||||
(draft.armament !== 0 && draft.weapons === 0)
|
||||
) {
|
||||
return { ok: false, reason: "armament_weapons_pair" };
|
||||
}
|
||||
if (
|
||||
draft.drive === 0 &&
|
||||
draft.armament === 0 &&
|
||||
draft.weapons === 0 &&
|
||||
draft.shields === 0 &&
|
||||
draft.cargo === 0
|
||||
) {
|
||||
return { ok: false, reason: "all_zero" };
|
||||
const valueResult = validateShipClassValues(draft);
|
||||
if (!valueResult.ok) {
|
||||
return { ok: false, reason: valueResult.reason };
|
||||
}
|
||||
|
||||
const existing = options.existingNames ?? [];
|
||||
@@ -129,6 +115,51 @@ export function validateShipClass(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* validateShipClassValues runs only the five-block value rules from
|
||||
* `pkg/calc/validator.go.ValidateShipTypeValues`, independent of the
|
||||
* name. The ship-class calculator gates its live previews on this so a
|
||||
* blank or in-progress name does not suppress the math.
|
||||
*/
|
||||
export function validateShipClassValues(
|
||||
values: ShipClassValues,
|
||||
): ShipClassValuesValidation {
|
||||
if (!isValidDWSC(values.drive)) {
|
||||
return { ok: false, reason: "drive_value" };
|
||||
}
|
||||
if (!Number.isFinite(values.armament) || values.armament < 0) {
|
||||
return { ok: false, reason: "armament_value" };
|
||||
}
|
||||
if (!Number.isInteger(values.armament)) {
|
||||
return { ok: false, reason: "armament_not_integer" };
|
||||
}
|
||||
if (!isValidDWSC(values.weapons)) {
|
||||
return { ok: false, reason: "weapons_value" };
|
||||
}
|
||||
if (!isValidDWSC(values.shields)) {
|
||||
return { ok: false, reason: "shields_value" };
|
||||
}
|
||||
if (!isValidDWSC(values.cargo)) {
|
||||
return { ok: false, reason: "cargo_value" };
|
||||
}
|
||||
if (
|
||||
(values.armament === 0 && values.weapons !== 0) ||
|
||||
(values.armament !== 0 && values.weapons === 0)
|
||||
) {
|
||||
return { ok: false, reason: "armament_weapons_pair" };
|
||||
}
|
||||
if (
|
||||
values.drive === 0 &&
|
||||
values.armament === 0 &&
|
||||
values.weapons === 0 &&
|
||||
values.shields === 0 &&
|
||||
values.cargo === 0
|
||||
) {
|
||||
return { ok: false, reason: "all_zero" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidDWSC mirrors `pkg/calc/validator.go.CheckShipTypeValueDWSC`:
|
||||
* a Drive / Weapons / Shields / Cargo value is acceptable only when
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Phase 30 reach circles. When the ship-class calculator has a planet
|
||||
// selected and a valid design, it publishes the design's loaded speed and
|
||||
// the planet origin to `lib/calculator/reach.svelte`; the map view reads
|
||||
// that store and feeds it through `computeReachCircles` to draw 1–3 thin
|
||||
// concentric rings showing how far the ship reaches in 1, 2, and 3 turns.
|
||||
//
|
||||
// The ring count is bounded by how soon a ring reaches the meaningful
|
||||
// extent of the map: half the shorter side on a torus (beyond that a
|
||||
// ring wraps onto itself), or the farthest corner on a bounded no-wrap
|
||||
// plane (beyond that the ring is entirely off-map). A fast ship that
|
||||
// clears the map in one turn therefore shows a single ring; a slow ship
|
||||
// shows all three.
|
||||
|
||||
import type { CirclePrim } from "./world";
|
||||
|
||||
export const REACH_CIRCLE_COLOR = 0x6d8cff;
|
||||
/** High-bit prefix so reach-circle ids never collide with planet
|
||||
* numbers, cargo-route lines, or battle/bombing markers. */
|
||||
export const REACH_CIRCLE_ID_PREFIX = 0xb0000000;
|
||||
const MAX_TURNS = 3;
|
||||
/** Reach rings sit below every interactive primitive so they never win
|
||||
* a click against a planet or ship group. */
|
||||
const REACH_CIRCLE_PRIORITY = 0;
|
||||
|
||||
/**
|
||||
* reachBound returns the largest ring radius worth drawing for the map.
|
||||
* On a torus it is half the shorter side (a larger ring overlaps itself);
|
||||
* on a bounded plane it is the distance from the origin to the farthest
|
||||
* corner (a larger ring is wholly off-map).
|
||||
*/
|
||||
export function reachBound(
|
||||
origin: { x: number; y: number },
|
||||
mapWidth: number,
|
||||
mapHeight: number,
|
||||
mode: "torus" | "no-wrap",
|
||||
): number {
|
||||
if (mode === "torus") {
|
||||
return Math.min(mapWidth, mapHeight) / 2;
|
||||
}
|
||||
const dx = Math.max(origin.x, mapWidth - origin.x);
|
||||
const dy = Math.max(origin.y, mapHeight - origin.y);
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* computeReachCircles produces up to three concentric ring primitives
|
||||
* centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for
|
||||
* turn `t` is included only when the previous ring still fits inside the
|
||||
* map's reach bound, so the count shrinks as the per-turn speed grows.
|
||||
* Returns an empty list when the speed is non-positive.
|
||||
*/
|
||||
export function computeReachCircles(
|
||||
origin: { x: number; y: number },
|
||||
speedPerTurn: number,
|
||||
mapWidth: number,
|
||||
mapHeight: number,
|
||||
mode: "torus" | "no-wrap",
|
||||
): CirclePrim[] {
|
||||
if (speedPerTurn <= 0) return [];
|
||||
const bound = reachBound(origin, mapWidth, mapHeight, mode);
|
||||
const circles: CirclePrim[] = [];
|
||||
for (let turn = 1; turn <= MAX_TURNS; turn++) {
|
||||
// Stop once the previous ring already reached the bound.
|
||||
if (turn > 1 && speedPerTurn * (turn - 1) >= bound) break;
|
||||
circles.push({
|
||||
kind: "circle",
|
||||
id: REACH_CIRCLE_ID_PREFIX + turn,
|
||||
priority: REACH_CIRCLE_PRIORITY,
|
||||
hitSlopPx: 0,
|
||||
x: origin.x,
|
||||
y: origin.y,
|
||||
radius: speedPerTurn * turn,
|
||||
style: {
|
||||
strokeColor: REACH_CIRCLE_COLOR,
|
||||
strokeAlpha: 0.55 - (turn - 1) * 0.12,
|
||||
strokeWidthPx: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
return circles;
|
||||
}
|
||||
@@ -79,6 +79,72 @@ export interface BlockUpgradeCostInput {
|
||||
targetTech: number;
|
||||
}
|
||||
|
||||
export interface EffectiveAttackInput {
|
||||
weapons: number;
|
||||
weaponsTech: number;
|
||||
}
|
||||
|
||||
export interface EffectiveDefenceInput {
|
||||
shields: number;
|
||||
shieldsTech: number;
|
||||
fullMass: number;
|
||||
}
|
||||
|
||||
export interface BombingPowerInput {
|
||||
weapons: number;
|
||||
weaponsTech: number;
|
||||
armament: number;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface ShipBuildCostInput {
|
||||
shipMass: number;
|
||||
material: number;
|
||||
resources: number;
|
||||
}
|
||||
|
||||
export interface ProduceShipsInTurnInput {
|
||||
productionAvailable: number;
|
||||
material: number;
|
||||
resources: number;
|
||||
shipMass: number;
|
||||
}
|
||||
|
||||
export interface ProduceShipsInTurnResult {
|
||||
ships: number;
|
||||
materialLeft: number;
|
||||
productionUsed: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface WeaponsForAttackInput {
|
||||
targetAttack: number;
|
||||
weaponsTech: number;
|
||||
}
|
||||
|
||||
export interface DriveForSpeedInput {
|
||||
targetSpeed: number;
|
||||
driveTech: number;
|
||||
restMass: number;
|
||||
}
|
||||
|
||||
export interface ShieldsForDefenceInput {
|
||||
targetDefence: number;
|
||||
shieldsTech: number;
|
||||
restMass: number;
|
||||
}
|
||||
|
||||
export interface CargoForEmptyMassInput {
|
||||
targetEmptyMass: number;
|
||||
restMass: number;
|
||||
}
|
||||
|
||||
export interface LoadForFullMassInput {
|
||||
targetFullMass: number;
|
||||
emptyMass: number;
|
||||
cargoTech: number;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
/**
|
||||
* signRequest returns the canonical signing input bytes for a v1
|
||||
@@ -174,6 +240,77 @@ export interface Core {
|
||||
* preview.
|
||||
*/
|
||||
blockUpgradeCost(input: BlockUpgradeCostInput): number;
|
||||
|
||||
/**
|
||||
* effectiveAttack wraps `pkg/calc/ship.go.EffectiveAttack`: combat
|
||||
* attack power = weapons block × weapons tech.
|
||||
*/
|
||||
effectiveAttack(input: EffectiveAttackInput): number;
|
||||
|
||||
/**
|
||||
* effectiveDefence wraps `pkg/calc/ship.go.EffectiveDefence`: combat
|
||||
* defence power = shields × shields tech, normalised by the cube root
|
||||
* of full mass; zero when fullMass ≤ 0.
|
||||
*/
|
||||
effectiveDefence(input: EffectiveDefenceInput): number;
|
||||
|
||||
/**
|
||||
* bombingPower wraps `pkg/calc/ship.go.BombingPower`: planet-bombing
|
||||
* power of `number` ships. The calculator passes number = 1 for a
|
||||
* per-ship reading.
|
||||
*/
|
||||
bombingPower(input: BombingPowerInput): number;
|
||||
|
||||
/**
|
||||
* shipBuildCost wraps `pkg/calc/planet.go.ShipBuildCost`: the per-turn
|
||||
* production cost of one ship of empty mass shipMass on a planet
|
||||
* holding `material` at the `resources` rating.
|
||||
*/
|
||||
shipBuildCost(input: ShipBuildCostInput): number;
|
||||
|
||||
/**
|
||||
* produceShipsInTurn wraps `pkg/calc/planet.go.ProduceShipsInTurn`:
|
||||
* one turn of ship production, returning whole ships completed, the
|
||||
* material left, the production spent on the next (incomplete) ship,
|
||||
* and that ship's progress fraction. Matches the engine's per-turn
|
||||
* build loop.
|
||||
*/
|
||||
produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult;
|
||||
|
||||
/**
|
||||
* weaponsForAttack wraps `pkg/calc/solve.go.WeaponsForAttack`: the
|
||||
* weapons block that yields the target attack power, or null when the
|
||||
* request is infeasible.
|
||||
*/
|
||||
weaponsForAttack(input: WeaponsForAttackInput): number | null;
|
||||
|
||||
/**
|
||||
* driveForSpeed wraps `pkg/calc/solve.go.DriveForSpeed`: the drive
|
||||
* block that yields the target speed given the rest of the ship's
|
||||
* mass, or null when the target is at/above the stripped-hull ceiling.
|
||||
*/
|
||||
driveForSpeed(input: DriveForSpeedInput): number | null;
|
||||
|
||||
/**
|
||||
* shieldsForDefence wraps `pkg/calc/solve.go.ShieldsForDefence`: the
|
||||
* shields block that yields the target defence given the rest of the
|
||||
* ship's mass (found by bisection), or null when infeasible.
|
||||
*/
|
||||
shieldsForDefence(input: ShieldsForDefenceInput): number | null;
|
||||
|
||||
/**
|
||||
* cargoForEmptyMass wraps `pkg/calc/solve.go.CargoForEmptyMass`: the
|
||||
* cargo block that brings empty mass to the target, or null when the
|
||||
* target is below the fixed block mass.
|
||||
*/
|
||||
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null;
|
||||
|
||||
/**
|
||||
* loadForFullMass wraps `pkg/calc/solve.go.LoadForFullMass`: the cargo
|
||||
* load that brings full mass to the target, or null when the target is
|
||||
* below the empty mass.
|
||||
*/
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||
}
|
||||
|
||||
export type CoreLoader = () => Promise<Core>;
|
||||
|
||||
@@ -10,17 +10,28 @@
|
||||
|
||||
import type {
|
||||
BlockUpgradeCostInput,
|
||||
BombingPowerInput,
|
||||
CargoCapacityInput,
|
||||
CargoForEmptyMassInput,
|
||||
CarryingMassInput,
|
||||
Core,
|
||||
DriveEffectiveInput,
|
||||
DriveForSpeedInput,
|
||||
EffectiveAttackInput,
|
||||
EffectiveDefenceInput,
|
||||
EventSigningFields,
|
||||
FullMassInput,
|
||||
LoadForFullMassInput,
|
||||
ProduceShipsInTurnInput,
|
||||
ProduceShipsInTurnResult,
|
||||
RequestSigningFields,
|
||||
ResponseSigningFields,
|
||||
ShipBlocksInput,
|
||||
ShipBuildCostInput,
|
||||
ShieldsForDefenceInput,
|
||||
SpeedInput,
|
||||
WeaponsBlockInput,
|
||||
WeaponsForAttackInput,
|
||||
} from "./index";
|
||||
|
||||
/**
|
||||
@@ -52,6 +63,16 @@ interface GalaxyCoreBridge {
|
||||
cargoCapacity(input: CargoCapacityInput): number;
|
||||
carryingMass(input: CarryingMassInput): number;
|
||||
blockUpgradeCost(input: BlockUpgradeCostInput): number;
|
||||
effectiveAttack(input: EffectiveAttackInput): number;
|
||||
effectiveDefence(input: EffectiveDefenceInput): number;
|
||||
bombingPower(input: BombingPowerInput): number;
|
||||
shipBuildCost(input: ShipBuildCostInput): number;
|
||||
produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult;
|
||||
weaponsForAttack(input: WeaponsForAttackInput): number | null;
|
||||
driveForSpeed(input: DriveForSpeedInput): number | null;
|
||||
shieldsForDefence(input: ShieldsForDefenceInput): number | null;
|
||||
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null;
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||
}
|
||||
|
||||
interface BridgeRequestFields {
|
||||
@@ -215,6 +236,36 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
||||
blockUpgradeCost(input: BlockUpgradeCostInput): number {
|
||||
return bridge.blockUpgradeCost(input);
|
||||
},
|
||||
effectiveAttack(input: EffectiveAttackInput): number {
|
||||
return bridge.effectiveAttack(input);
|
||||
},
|
||||
effectiveDefence(input: EffectiveDefenceInput): number {
|
||||
return bridge.effectiveDefence(input);
|
||||
},
|
||||
bombingPower(input: BombingPowerInput): number {
|
||||
return bridge.bombingPower(input);
|
||||
},
|
||||
shipBuildCost(input: ShipBuildCostInput): number {
|
||||
return bridge.shipBuildCost(input);
|
||||
},
|
||||
produceShipsInTurn(input: ProduceShipsInTurnInput): ProduceShipsInTurnResult {
|
||||
return bridge.produceShipsInTurn(input);
|
||||
},
|
||||
weaponsForAttack(input: WeaponsForAttackInput): number | null {
|
||||
return bridge.weaponsForAttack(input);
|
||||
},
|
||||
driveForSpeed(input: DriveForSpeedInput): number | null {
|
||||
return bridge.driveForSpeed(input);
|
||||
},
|
||||
shieldsForDefence(input: ShieldsForDefenceInput): number | null {
|
||||
return bridge.shieldsForDefence(input);
|
||||
},
|
||||
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null {
|
||||
return bridge.cargoForEmptyMass(input);
|
||||
},
|
||||
loadForFullMass(input: LoadForFullMassInput): number | null {
|
||||
return bridge.loadForFullMass(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ fresh.
|
||||
SelectionStore,
|
||||
SELECTION_CONTEXT_KEY,
|
||||
} from "$lib/selection.svelte";
|
||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||
import {
|
||||
createRenderedReportSource,
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
@@ -226,6 +227,17 @@ fresh.
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
// Reveal the calculator whenever the ship-classes table or the
|
||||
// bottom-tabs entry asks to load a class (or start a fresh design).
|
||||
let lastCalcLoadToken = 0;
|
||||
$effect(() => {
|
||||
const token = calculatorLoadRequest.token;
|
||||
if (token === lastCalcLoadToken) return;
|
||||
lastCalcLoadToken = token;
|
||||
activeTab = "calculator";
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
function toggleSidebar(): void {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DesignerShipClass from "$lib/active-view/designer-ship-class.svelte";
|
||||
</script>
|
||||
|
||||
<DesignerShipClass />
|
||||
Reference in New Issue
Block a user