From 3ea29cf8b5b8165c94bf665797da5dbdf93f01ea Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 21 May 2026 20:29:08 +0200 Subject: [PATCH] fix(ui-calculator): keep calculator state long-lived; don't eject on planet click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/calculator/calc-state.svelte.ts | 121 +++++++++++ .../src/lib/sidebar/calculator-tab.svelte | 191 ++++++++---------- ui/frontend/src/map/reach-circles.ts | 2 +- .../src/routes/games/[id]/+layout.svelte | 14 +- ui/frontend/tests/calculator-tab.test.ts | 9 +- .../tests/e2e/game-shell-inspector.spec.ts | 36 ++++ 6 files changed, 264 insertions(+), 109 deletions(-) create mode 100644 ui/frontend/src/lib/calculator/calc-state.svelte.ts diff --git a/ui/frontend/src/lib/calculator/calc-state.svelte.ts b/ui/frontend/src/lib/calculator/calc-state.svelte.ts new file mode 100644 index 0000000..e22369e --- /dev/null +++ b/ui/frontend/src/lib/calculator/calc-state.svelte.ts @@ -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(null); + mode = $state("ship"); + name = $state(""); + blocks = $state(freshBlocks()); + techValues = $state(freshTech()); + techOverridden = $state>({ + drive: false, + weapons: false, + shields: false, + cargo: false, + }); + targetTech = $state(freshTech()); + targetSeeded = $state(false); + loadMode = $state("full"); + customLoad = $state(0); + lock = $state(null); + lockValue = $state(0); + matOverridden = $state(false); + matValue = $state(0); + loadedExisting = $state(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(); diff --git a/ui/frontend/src/lib/sidebar/calculator-tab.svelte b/ui/frontend/src/lib/sidebar/calculator-tab.svelte index c70a651..0b64afc 100644 --- a/ui/frontend/src/lib/sidebar/calculator-tab.svelte +++ b/ui/frontend/src/lib/sidebar/calculator-tab.svelte @@ -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. --> {#snippet lockable(output: LockableOutputId, value: number | undefined)} - {#if lock === output} + {#if cs.lock === output} @@ -370,8 +351,8 @@ switches per the global state-preservation rule. @@ -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. {/each} - {#if mode === "ship"} + {#if cs.mode === "ship"} {/if} - {#if mode === "ship"} + {#if cs.mode === "ship"}
{i18n.t("game.calculator.load.label")}
{#each LOAD_MODES as m (m)} {/each}
- {#if loadMode === "custom"} + {#if cs.loadMode === "custom"} {/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}