fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s

Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.

While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 20:29:08 +02:00
parent 9ae7b88b89
commit 3ea29cf8b5
6 changed files with 264 additions and 109 deletions
@@ -0,0 +1,121 @@
// Long-lived state for the ship-class calculator. The sidebar unmounts
// the calculator tab when another tab is active, so component-local state
// would be lost on every tab switch (the inspector auto-opens on a planet
// click, for instance). The calculator is a long-lived planning tool, so
// its inputs live here — a page-level singleton that survives tab
// unmount/remount — and the component renders this store rather than its
// own `$state`.
//
// `ensureGame` resets the design when the active game changes so a draft
// from a previous game does not leak across games. `reset` is for tests,
// which share the module instance across cases.
import type { LoadMode, LockableOutputId } from "./calc-model";
interface Blocks {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
interface Tech {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
type Mode = "ship" | "modernization";
function freshBlocks(): Blocks {
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
}
function freshTech(): Tech {
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}
class CalculatorState {
gameId = $state<string | null>(null);
mode = $state<Mode>("ship");
name = $state("");
blocks = $state<Blocks>(freshBlocks());
techValues = $state<Tech>(freshTech());
techOverridden = $state<Record<keyof Tech, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
});
targetTech = $state<Tech>(freshTech());
targetSeeded = $state(false);
loadMode = $state<LoadMode>("full");
customLoad = $state(0);
lock = $state<LockableOutputId | null>(null);
lockValue = $state(0);
matOverridden = $state(false);
matValue = $state(0);
loadedExisting = $state<string | null>(null);
// The last calculatorLoadRequest token this state has applied. Held
// here (not in the component) so a tab-switch remount does not
// re-apply the previous load request and clobber the kept design.
handledLoadToken = $state(0);
/** Clears the design back to a blank new-class form. */
resetDesign(): void {
this.blocks = freshBlocks();
this.name = "";
this.loadedExisting = null;
this.lock = null;
}
/** Full reset to defaults; used by tests sharing the singleton. */
reset(): void {
this.gameId = null;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
this.handledLoadToken = 0;
}
/**
* Resets the per-game design when the active game changes, so a draft
* from one game does not surface in another. A no-op while the game is
* unchanged, which is what makes the design survive tab switches.
* `handledLoadToken` is intentionally preserved across games.
*/
ensureGame(gameId: string): void {
if (this.gameId === gameId) return;
this.gameId = gameId;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
}
}
export const calculatorState = new CalculatorState();
+86 -105
View File
@@ -6,14 +6,16 @@ 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.
the `Core` WASM bridge; this component only renders + orchestrates.
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.
Input state lives in the long-lived `calculatorState` singleton, not in
the component, so it survives the sidebar unmounting this tab on a tab
switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { page } from "$app/state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
@@ -40,13 +42,14 @@ switches per the global state-preservation rule.
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";
import { calculatorState } from "$lib/calculator/calc-state.svelte";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
@@ -59,26 +62,13 @@ switches per the global state-preservation rule.
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,
// The long-lived input state (survives tab unmount/remount).
const cs = calculatorState;
// Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game.
$effect(() => {
cs.ensureGame(page.params.id ?? "");
});
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);
@@ -101,36 +91,35 @@ switches per the global state-preservation rule.
// changes, so the calculator reflects the right turn's tech.
$effect(() => {
for (const k of techKeys) {
if (!techOverridden[k]) techValues[k] = playerTech[k];
if (!cs.techOverridden[k]) cs.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 (cs.targetSeeded) return;
if (
playerTech.drive ||
playerTech.weapons ||
playerTech.shields ||
playerTech.cargo
) {
targetTech = { ...playerTech };
targetSeeded = true;
cs.targetTech = { ...playerTech };
cs.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 },
blocks: cs.blocks,
driveTech: cs.techValues.drive,
weaponsTech: cs.techValues.weapons,
shieldsTech: cs.techValues.shields,
cargoTech: cs.techValues.cargo,
loadMode: cs.loadMode,
customLoad: cs.customLoad,
lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
},
core,
),
@@ -146,8 +135,8 @@ switches per the global state-preservation rule.
});
$effect(() => {
if (!matOverridden) {
matValue = selectedPlanet?.materialsStockpile ?? 0;
if (!cs.matOverridden) {
cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
}
});
@@ -159,7 +148,7 @@ switches per the global state-preservation rule.
{
shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: matValue,
material: cs.matValue,
resources: selectedPlanet.resources ?? 0,
},
core,
@@ -171,7 +160,7 @@ switches per the global state-preservation rule.
// own planet is selected, or the calculator is in modernization mode.
$effect(() => {
const out = result.outputs;
if (mode === "ship" && selectedPlanet !== null && out !== null) {
if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded,
@@ -202,15 +191,15 @@ switches per the global state-preservation rule.
};
const nameValidation = $derived(
validateShipClass({ name, ...result.blocks }, { existingNames }),
validateShipClass({ name: cs.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) &&
cs.loadedExisting !== null &&
existingNames.includes(cs.loadedExisting) &&
draft !== undefined,
);
@@ -218,21 +207,21 @@ switches per the global state-preservation rule.
const modernCosts = $derived.by(() => {
if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({
weapons: blocks.weapons,
armament: blocks.armament,
weapons: cs.blocks.weapons,
armament: cs.blocks.armament,
});
const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: blocks.drive },
{ key: "drive", mass: cs.blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: blocks.shields },
{ key: "cargo", mass: blocks.cargo },
{ key: "shields", mass: cs.blocks.shields },
{ key: "cargo", mass: cs.blocks.cargo },
];
const perBlock = rows.map((r) => ({
key: r.key,
cost: core.blockUpgradeCost({
blockMass: r.mass,
currentTech: techValues[r.key],
targetTech: targetTech[r.key],
currentTech: cs.techValues[r.key],
targetTech: cs.targetTech[r.key],
}),
}));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
@@ -247,59 +236,51 @@ switches per the global state-preservation rule.
}
function onTechInput(key: TechKey): void {
techOverridden[key] = true;
cs.techOverridden[key] = true;
}
function onResetTech(key: TechKey): void {
techOverridden[key] = false;
cs.techOverridden[key] = false;
}
function onMatInput(): void {
matOverridden = true;
cs.matOverridden = true;
}
function resetMat(): void {
matOverridden = false;
cs.matOverridden = false;
}
function lockOutput(output: LockableOutputId): void {
if (lock !== null) return;
lockValue = result.outputs?.[output] ?? 0;
lock = output;
if (cs.lock !== null) return;
cs.lockValue = result.outputs?.[output] ?? 0;
cs.lock = output;
}
function unlock(): void {
lock = null;
cs.lock = null;
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
blocks = {
cs.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;
cs.name = cls.name;
cs.loadedExisting = cls.name;
cs.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();
if (token === cs.handledLoadToken) return;
cs.handledLoadToken = token;
cs.mode = "ship";
if (calculatorLoadRequest.name === null) cs.resetDesign();
else loadExisting(calculatorLoadRequest.name);
});
@@ -320,17 +301,17 @@ switches per the global state-preservation rule.
shields: created.shields,
cargo: created.cargo,
});
loadedExisting = created.name;
cs.loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (loadedExisting === null || draft === undefined) return;
if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: loadedExisting,
name: cs.loadedExisting,
});
loadedExisting = null;
cs.loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
@@ -344,12 +325,12 @@ switches per the global state-preservation rule.
</script>
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if lock === output}
{#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
type="number"
step="0.001"
bind:value={lockValue}
bind:value={cs.lockValue}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
@@ -370,8 +351,8 @@ switches per the global state-preservation rule.
<button
type="button"
class="lock"
disabled={lock !== null || value === undefined}
title={lock !== null
disabled={cs.lock !== null || value === undefined}
title={cs.lock !== null
? i18n.t("game.calculator.lock.max")
: `${LOCK_LABELS[output]}`}
aria-label={LOCK_LABELS[output]}
@@ -388,17 +369,17 @@ switches per the global state-preservation rule.
<div class="modes" role="tablist">
<button
type="button"
class:active={mode === "ship"}
class:active={cs.mode === "ship"}
data-testid="calculator-mode-ship"
onclick={() => (mode = "ship")}
onclick={() => (cs.mode = "ship")}
>
{i18n.t("game.calculator.mode.ship")}
</button>
<button
type="button"
class:active={mode === "modernization"}
class:active={cs.mode === "modernization"}
data-testid="calculator-mode-modernization"
onclick={() => (mode = "modernization")}
onclick={() => (cs.mode = "modernization")}
>
{i18n.t("game.calculator.mode.modernization")}
</button>
@@ -411,9 +392,9 @@ switches per the global state-preservation rule.
list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={name}
oninput={() => (loadedExisting = null)}
onchange={() => loadExisting(name)}
bind:value={cs.name}
oninput={() => (cs.loadedExisting = null)}
onchange={() => loadExisting(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
@@ -422,7 +403,7 @@ switches per the global state-preservation rule.
<option value={cls.name}></option>
{/each}
</datalist>
{#if mode === "ship"}
{#if cs.mode === "ship"}
<button
type="button"
class="create"
@@ -435,49 +416,49 @@ switches per the global state-preservation rule.
</button>
{/if}
</div>
{#if mode === "ship" && canDelete}
{#if cs.mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {loadedExisting}
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks
bind:blocks={cs.blocks}
resolved={result.blocks}
bind:techs={techValues}
{techOverridden}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
/>
{#if mode === "ship"}
{#if cs.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}
class:active={cs.loadMode === m}
data-testid={`calculator-load-${m}`}
onclick={() => (loadMode = m)}
onclick={() => (cs.loadMode = m)}
>
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button>
{/each}
</div>
{#if loadMode === "custom"}
{#if cs.loadMode === "custom"}
<input
type="number"
step="0.01"
min="0"
class="custom-load"
bind:value={customLoad}
bind:value={cs.customLoad}
data-testid="calculator-custom-load"
/>
{/if}
@@ -548,11 +529,11 @@ switches per the global state-preservation rule.
type="number"
step="0.01"
min="0"
bind:value={matValue}
bind:value={cs.matValue}
oninput={onMatInput}
data-testid="calculator-planet-mat"
/>
{#if matOverridden}
{#if cs.matOverridden}
<button
type="button"
class="lock active"
@@ -598,7 +579,7 @@ switches per the global state-preservation rule.
type="number"
step="0.001"
min="0"
bind:value={targetTech[row.key]}
bind:value={cs.targetTech[row.key]}
data-testid={`calculator-target-${row.key}`}
/>
</span>
+1 -1
View File
@@ -73,7 +73,7 @@ export function computeReachCircles(
style: {
strokeColor: REACH_CIRCLE_COLOR,
strokeAlpha: 0.55 - (turn - 1) * 0.12,
strokeWidthPx: 1,
strokeWidthPx: 0.5,
},
});
}
@@ -43,7 +43,7 @@ the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { onDestroy, onMount, setContext } from "svelte";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.svelte";
@@ -223,7 +223,17 @@ fresh.
$effect(() => {
const sel = selection.selected;
if (sel === null) return;
activeTab = "inspector";
// Stay in the calculator when a planet is picked: the calculator
// consumes the selection in its planet area + reach circles, and
// it is a long-lived workspace the user should not be ejected
// from. `activeTab` is read untracked so a manual tab switch does
// not re-fire this effect. Any other case (including a ship-group
// selection, which the calculator does not use) reveals the
// inspector as before.
const tab = untrack(() => activeTab);
if (!(tab === "calculator" && sel.kind === "planet")) {
activeTab = "inspector";
}
sidebarOpen = true;
});