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>
|
||||
|
||||
Reference in New Issue
Block a user