fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s
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:
@@ -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();
|
||||||
@@ -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
|
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
|
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
|
||||||
price ship-class modernization. All math comes from `pkg/calc` through
|
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
|
Input state lives in the long-lived `calculatorState` singleton, not in
|
||||||
player navigates between active views, so inputs persist across view
|
the component, so it survives the sidebar unmounting this tab on a tab
|
||||||
switches per the global state-preservation rule.
|
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">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -40,13 +42,14 @@ switches per the global state-preservation rule.
|
|||||||
type LockableOutputId,
|
type LockableOutputId,
|
||||||
type LoadMode,
|
type LoadMode,
|
||||||
} from "$lib/calculator/calc-model";
|
} from "$lib/calculator/calc-model";
|
||||||
|
|
||||||
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
|
|
||||||
import ShipDesignArea, {
|
import ShipDesignArea, {
|
||||||
type TechKey,
|
type TechKey,
|
||||||
} from "$lib/calculator/ship-design-area.svelte";
|
} from "$lib/calculator/ship-design-area.svelte";
|
||||||
import { reachStore } from "$lib/calculator/reach.svelte";
|
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.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>(
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
@@ -59,26 +62,13 @@ switches per the global state-preservation rule.
|
|||||||
SELECTION_CONTEXT_KEY,
|
SELECTION_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
type Mode = "ship" | "modernization";
|
// The long-lived input state (survives tab unmount/remount).
|
||||||
let mode = $state<Mode>("ship");
|
const cs = calculatorState;
|
||||||
|
// Reset the design when the active game changes; a no-op otherwise, so
|
||||||
let name = $state("");
|
// the design persists across tab switches within a game.
|
||||||
let blocks = $state({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 });
|
$effect(() => {
|
||||||
let techValues = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
|
cs.ensureGame(page.params.id ?? "");
|
||||||
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 core = $derived(coreHandle?.core ?? null);
|
||||||
const report = $derived(rendered?.report ?? 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.
|
// changes, so the calculator reflects the right turn's tech.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const k of techKeys) {
|
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
|
// Seed the modernization target with the player's current tech once
|
||||||
// the report has loaded; afterwards it is the player's to edit.
|
// the report has loaded; afterwards it is the player's to edit.
|
||||||
let targetSeeded = false;
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (targetSeeded) return;
|
if (cs.targetSeeded) return;
|
||||||
if (
|
if (
|
||||||
playerTech.drive ||
|
playerTech.drive ||
|
||||||
playerTech.weapons ||
|
playerTech.weapons ||
|
||||||
playerTech.shields ||
|
playerTech.shields ||
|
||||||
playerTech.cargo
|
playerTech.cargo
|
||||||
) {
|
) {
|
||||||
targetTech = { ...playerTech };
|
cs.targetTech = { ...playerTech };
|
||||||
targetSeeded = true;
|
cs.targetSeeded = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = $derived(
|
const result = $derived(
|
||||||
computeCalculator(
|
computeCalculator(
|
||||||
{
|
{
|
||||||
blocks,
|
blocks: cs.blocks,
|
||||||
driveTech: techValues.drive,
|
driveTech: cs.techValues.drive,
|
||||||
weaponsTech: techValues.weapons,
|
weaponsTech: cs.techValues.weapons,
|
||||||
shieldsTech: techValues.shields,
|
shieldsTech: cs.techValues.shields,
|
||||||
cargoTech: techValues.cargo,
|
cargoTech: cs.techValues.cargo,
|
||||||
loadMode,
|
loadMode: cs.loadMode,
|
||||||
customLoad,
|
customLoad: cs.customLoad,
|
||||||
lock: lock === null ? null : { output: lock, value: lockValue },
|
lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
|
||||||
},
|
},
|
||||||
core,
|
core,
|
||||||
),
|
),
|
||||||
@@ -146,8 +135,8 @@ switches per the global state-preservation rule.
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!matOverridden) {
|
if (!cs.matOverridden) {
|
||||||
matValue = selectedPlanet?.materialsStockpile ?? 0;
|
cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +148,7 @@ switches per the global state-preservation rule.
|
|||||||
{
|
{
|
||||||
shipMass: emptyMass,
|
shipMass: emptyMass,
|
||||||
freeIndustry: selectedPlanet.freeIndustry ?? 0,
|
freeIndustry: selectedPlanet.freeIndustry ?? 0,
|
||||||
material: matValue,
|
material: cs.matValue,
|
||||||
resources: selectedPlanet.resources ?? 0,
|
resources: selectedPlanet.resources ?? 0,
|
||||||
},
|
},
|
||||||
core,
|
core,
|
||||||
@@ -171,7 +160,7 @@ switches per the global state-preservation rule.
|
|||||||
// own planet is selected, or the calculator is in modernization mode.
|
// own planet is selected, or the calculator is in modernization mode.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const out = result.outputs;
|
const out = result.outputs;
|
||||||
if (mode === "ship" && selectedPlanet !== null && out !== null) {
|
if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
|
||||||
reachStore.set(
|
reachStore.set(
|
||||||
{ x: selectedPlanet.x, y: selectedPlanet.y },
|
{ x: selectedPlanet.x, y: selectedPlanet.y },
|
||||||
out.speedLoaded,
|
out.speedLoaded,
|
||||||
@@ -202,15 +191,15 @@ switches per the global state-preservation rule.
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nameValidation = $derived(
|
const nameValidation = $derived(
|
||||||
validateShipClass({ name, ...result.blocks }, { existingNames }),
|
validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
|
||||||
);
|
);
|
||||||
const createMessage = $derived(
|
const createMessage = $derived(
|
||||||
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
|
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
|
||||||
);
|
);
|
||||||
const canCreate = $derived(nameValidation.ok && draft !== undefined);
|
const canCreate = $derived(nameValidation.ok && draft !== undefined);
|
||||||
const canDelete = $derived(
|
const canDelete = $derived(
|
||||||
loadedExisting !== null &&
|
cs.loadedExisting !== null &&
|
||||||
existingNames.includes(loadedExisting) &&
|
existingNames.includes(cs.loadedExisting) &&
|
||||||
draft !== undefined,
|
draft !== undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,21 +207,21 @@ switches per the global state-preservation rule.
|
|||||||
const modernCosts = $derived.by(() => {
|
const modernCosts = $derived.by(() => {
|
||||||
if (core === null) return null;
|
if (core === null) return null;
|
||||||
const weaponsMass = core.weaponsBlockMass({
|
const weaponsMass = core.weaponsBlockMass({
|
||||||
weapons: blocks.weapons,
|
weapons: cs.blocks.weapons,
|
||||||
armament: blocks.armament,
|
armament: cs.blocks.armament,
|
||||||
});
|
});
|
||||||
const rows: { key: TechKey; mass: number }[] = [
|
const rows: { key: TechKey; mass: number }[] = [
|
||||||
{ key: "drive", mass: blocks.drive },
|
{ key: "drive", mass: cs.blocks.drive },
|
||||||
{ key: "weapons", mass: weaponsMass ?? 0 },
|
{ key: "weapons", mass: weaponsMass ?? 0 },
|
||||||
{ key: "shields", mass: blocks.shields },
|
{ key: "shields", mass: cs.blocks.shields },
|
||||||
{ key: "cargo", mass: blocks.cargo },
|
{ key: "cargo", mass: cs.blocks.cargo },
|
||||||
];
|
];
|
||||||
const perBlock = rows.map((r) => ({
|
const perBlock = rows.map((r) => ({
|
||||||
key: r.key,
|
key: r.key,
|
||||||
cost: core.blockUpgradeCost({
|
cost: core.blockUpgradeCost({
|
||||||
blockMass: r.mass,
|
blockMass: r.mass,
|
||||||
currentTech: techValues[r.key],
|
currentTech: cs.techValues[r.key],
|
||||||
targetTech: targetTech[r.key],
|
targetTech: cs.targetTech[r.key],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
|
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 {
|
function onTechInput(key: TechKey): void {
|
||||||
techOverridden[key] = true;
|
cs.techOverridden[key] = true;
|
||||||
}
|
}
|
||||||
function onResetTech(key: TechKey): void {
|
function onResetTech(key: TechKey): void {
|
||||||
techOverridden[key] = false;
|
cs.techOverridden[key] = false;
|
||||||
}
|
}
|
||||||
function onMatInput(): void {
|
function onMatInput(): void {
|
||||||
matOverridden = true;
|
cs.matOverridden = true;
|
||||||
}
|
}
|
||||||
function resetMat(): void {
|
function resetMat(): void {
|
||||||
matOverridden = false;
|
cs.matOverridden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockOutput(output: LockableOutputId): void {
|
function lockOutput(output: LockableOutputId): void {
|
||||||
if (lock !== null) return;
|
if (cs.lock !== null) return;
|
||||||
lockValue = result.outputs?.[output] ?? 0;
|
cs.lockValue = result.outputs?.[output] ?? 0;
|
||||||
lock = output;
|
cs.lock = output;
|
||||||
}
|
}
|
||||||
function unlock(): void {
|
function unlock(): void {
|
||||||
lock = null;
|
cs.lock = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadExisting(clsName: string): void {
|
function loadExisting(clsName: string): void {
|
||||||
const cls = localShipClass.find((c) => c.name === clsName);
|
const cls = localShipClass.find((c) => c.name === clsName);
|
||||||
if (cls === undefined) return;
|
if (cls === undefined) return;
|
||||||
blocks = {
|
cs.blocks = {
|
||||||
drive: cls.drive,
|
drive: cls.drive,
|
||||||
armament: cls.armament,
|
armament: cls.armament,
|
||||||
weapons: cls.weapons,
|
weapons: cls.weapons,
|
||||||
shields: cls.shields,
|
shields: cls.shields,
|
||||||
cargo: cls.cargo,
|
cargo: cls.cargo,
|
||||||
};
|
};
|
||||||
name = cls.name;
|
cs.name = cls.name;
|
||||||
loadedExisting = cls.name;
|
cs.loadedExisting = cls.name;
|
||||||
lock = null;
|
cs.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
|
// React to the ship-classes table / bottom-tabs asking to load a
|
||||||
// class (or start a fresh design) into the calculator. The layout
|
// class (or start a fresh design) into the calculator. The layout
|
||||||
// flips the sidebar to this tab in parallel.
|
// flips the sidebar to this tab in parallel.
|
||||||
let lastLoadToken = 0;
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const token = calculatorLoadRequest.token;
|
const token = calculatorLoadRequest.token;
|
||||||
if (token === lastLoadToken) return;
|
if (token === cs.handledLoadToken) return;
|
||||||
lastLoadToken = token;
|
cs.handledLoadToken = token;
|
||||||
mode = "ship";
|
cs.mode = "ship";
|
||||||
if (calculatorLoadRequest.name === null) resetToNew();
|
if (calculatorLoadRequest.name === null) cs.resetDesign();
|
||||||
else loadExisting(calculatorLoadRequest.name);
|
else loadExisting(calculatorLoadRequest.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,17 +301,17 @@ switches per the global state-preservation rule.
|
|||||||
shields: created.shields,
|
shields: created.shields,
|
||||||
cargo: created.cargo,
|
cargo: created.cargo,
|
||||||
});
|
});
|
||||||
loadedExisting = created.name;
|
cs.loadedExisting = created.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClass(): Promise<void> {
|
async function deleteClass(): Promise<void> {
|
||||||
if (loadedExisting === null || draft === undefined) return;
|
if (cs.loadedExisting === null || draft === undefined) return;
|
||||||
await draft.add({
|
await draft.add({
|
||||||
kind: "removeShipClass",
|
kind: "removeShipClass",
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: loadedExisting,
|
name: cs.loadedExisting,
|
||||||
});
|
});
|
||||||
loadedExisting = null;
|
cs.loadedExisting = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
||||||
@@ -344,12 +325,12 @@ switches per the global state-preservation rule.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
|
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
|
||||||
{#if lock === output}
|
{#if cs.lock === output}
|
||||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="0.001"
|
||||||
bind:value={lockValue}
|
bind:value={cs.lockValue}
|
||||||
data-testid={`calculator-locked-${output}`}
|
data-testid={`calculator-locked-${output}`}
|
||||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||||
/>
|
/>
|
||||||
@@ -370,8 +351,8 @@ switches per the global state-preservation rule.
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lock"
|
class="lock"
|
||||||
disabled={lock !== null || value === undefined}
|
disabled={cs.lock !== null || value === undefined}
|
||||||
title={lock !== null
|
title={cs.lock !== null
|
||||||
? i18n.t("game.calculator.lock.max")
|
? i18n.t("game.calculator.lock.max")
|
||||||
: `${LOCK_LABELS[output]}`}
|
: `${LOCK_LABELS[output]}`}
|
||||||
aria-label={LOCK_LABELS[output]}
|
aria-label={LOCK_LABELS[output]}
|
||||||
@@ -388,17 +369,17 @@ switches per the global state-preservation rule.
|
|||||||
<div class="modes" role="tablist">
|
<div class="modes" role="tablist">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={mode === "ship"}
|
class:active={cs.mode === "ship"}
|
||||||
data-testid="calculator-mode-ship"
|
data-testid="calculator-mode-ship"
|
||||||
onclick={() => (mode = "ship")}
|
onclick={() => (cs.mode = "ship")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.calculator.mode.ship")}
|
{i18n.t("game.calculator.mode.ship")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={mode === "modernization"}
|
class:active={cs.mode === "modernization"}
|
||||||
data-testid="calculator-mode-modernization"
|
data-testid="calculator-mode-modernization"
|
||||||
onclick={() => (mode = "modernization")}
|
onclick={() => (cs.mode = "modernization")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.calculator.mode.modernization")}
|
{i18n.t("game.calculator.mode.modernization")}
|
||||||
</button>
|
</button>
|
||||||
@@ -411,9 +392,9 @@ switches per the global state-preservation rule.
|
|||||||
list="calculator-existing-classes"
|
list="calculator-existing-classes"
|
||||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||||
maxlength="30"
|
maxlength="30"
|
||||||
bind:value={name}
|
bind:value={cs.name}
|
||||||
oninput={() => (loadedExisting = null)}
|
oninput={() => (cs.loadedExisting = null)}
|
||||||
onchange={() => loadExisting(name)}
|
onchange={() => loadExisting(cs.name)}
|
||||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||||
data-testid="calculator-name"
|
data-testid="calculator-name"
|
||||||
/>
|
/>
|
||||||
@@ -422,7 +403,7 @@ switches per the global state-preservation rule.
|
|||||||
<option value={cls.name}></option>
|
<option value={cls.name}></option>
|
||||||
{/each}
|
{/each}
|
||||||
</datalist>
|
</datalist>
|
||||||
{#if mode === "ship"}
|
{#if cs.mode === "ship"}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="create"
|
class="create"
|
||||||
@@ -435,49 +416,49 @@ switches per the global state-preservation rule.
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if mode === "ship" && canDelete}
|
{#if cs.mode === "ship" && canDelete}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="delete"
|
class="delete"
|
||||||
data-testid="calculator-delete"
|
data-testid="calculator-delete"
|
||||||
onclick={() => void deleteClass()}
|
onclick={() => void deleteClass()}
|
||||||
>
|
>
|
||||||
{i18n.t("game.calculator.action.delete")} {loadedExisting}
|
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ShipDesignArea
|
<ShipDesignArea
|
||||||
bind:blocks
|
bind:blocks={cs.blocks}
|
||||||
resolved={result.blocks}
|
resolved={result.blocks}
|
||||||
bind:techs={techValues}
|
bind:techs={cs.techValues}
|
||||||
{techOverridden}
|
techOverridden={cs.techOverridden}
|
||||||
computedInput={result.computedInput}
|
computedInput={result.computedInput}
|
||||||
{onTechInput}
|
{onTechInput}
|
||||||
{onResetTech}
|
{onResetTech}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if mode === "ship"}
|
{#if cs.mode === "ship"}
|
||||||
<div class="load">
|
<div class="load">
|
||||||
<span class="label">{i18n.t("game.calculator.load.label")}</span>
|
<span class="label">{i18n.t("game.calculator.load.label")}</span>
|
||||||
<div class="seg" role="group">
|
<div class="seg" role="group">
|
||||||
{#each LOAD_MODES as m (m)}
|
{#each LOAD_MODES as m (m)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={loadMode === m}
|
class:active={cs.loadMode === m}
|
||||||
data-testid={`calculator-load-${m}`}
|
data-testid={`calculator-load-${m}`}
|
||||||
onclick={() => (loadMode = m)}
|
onclick={() => (cs.loadMode = m)}
|
||||||
>
|
>
|
||||||
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
|
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if loadMode === "custom"}
|
{#if cs.loadMode === "custom"}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="custom-load"
|
class="custom-load"
|
||||||
bind:value={customLoad}
|
bind:value={cs.customLoad}
|
||||||
data-testid="calculator-custom-load"
|
data-testid="calculator-custom-load"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -548,11 +529,11 @@ switches per the global state-preservation rule.
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={matValue}
|
bind:value={cs.matValue}
|
||||||
oninput={onMatInput}
|
oninput={onMatInput}
|
||||||
data-testid="calculator-planet-mat"
|
data-testid="calculator-planet-mat"
|
||||||
/>
|
/>
|
||||||
{#if matOverridden}
|
{#if cs.matOverridden}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lock active"
|
class="lock active"
|
||||||
@@ -598,7 +579,7 @@ switches per the global state-preservation rule.
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="0.001"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={targetTech[row.key]}
|
bind:value={cs.targetTech[row.key]}
|
||||||
data-testid={`calculator-target-${row.key}`}
|
data-testid={`calculator-target-${row.key}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function computeReachCircles(
|
|||||||
style: {
|
style: {
|
||||||
strokeColor: REACH_CIRCLE_COLOR,
|
strokeColor: REACH_CIRCLE_COLOR,
|
||||||
strokeAlpha: 0.55 - (turn - 1) * 0.12,
|
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.
|
fresh.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, setContext } from "svelte";
|
import { onDestroy, onMount, setContext, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Header from "$lib/header/header.svelte";
|
import Header from "$lib/header/header.svelte";
|
||||||
@@ -223,7 +223,17 @@ fresh.
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const sel = selection.selected;
|
const sel = selection.selected;
|
||||||
if (sel === null) return;
|
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;
|
sidebarOpen = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
// The calculator reads `page.params.id` to scope its long-lived state to
|
||||||
|
// the active game; stub a stable id so the component test has a router.
|
||||||
|
vi.mock("$app/state", () => ({ page: { params: { id: "calc-test-game" } } }));
|
||||||
|
|
||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
|
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
|
||||||
|
import { calculatorState } from "../src/lib/calculator/calc-state.svelte";
|
||||||
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
||||||
import {
|
import {
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
@@ -106,6 +111,8 @@ beforeEach(async () => {
|
|||||||
draft = new OrderDraftStore();
|
draft = new OrderDraftStore();
|
||||||
await draft.init({ cache: new IDBCache(db), gameId: GAME_ID });
|
await draft.init({ cache: new IDBCache(db), gameId: GAME_ID });
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
|
// The calculator state is a module singleton shared across cases.
|
||||||
|
calculatorState.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -278,3 +278,39 @@ test("calculator draws reach circles for the selected planet", async ({
|
|||||||
await calc.getByTestId("calculator-mode-modernization").click();
|
await calc.getByTestId("calculator-mode-modernization").click();
|
||||||
await expect.poll(() => countReachCircles(page)).toBe(0);
|
await expect.poll(() => countReachCircles(page)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("calculator stays put on a planet click and keeps state across tab switches", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name.startsWith("chromium-mobile"),
|
||||||
|
"calculator is a desktop-sidebar flow",
|
||||||
|
);
|
||||||
|
await setupShell(page);
|
||||||
|
|
||||||
|
// Open the calculator and enter a design.
|
||||||
|
await page.getByTestId("sidebar-tab-calculator").click();
|
||||||
|
await page.getByTestId("calculator-block-drive").fill("10");
|
||||||
|
|
||||||
|
// Clicking a planet must NOT eject us to the inspector; it feeds the
|
||||||
|
// calculator's planet area instead, and the design is untouched.
|
||||||
|
await clickCanvasCentre(page);
|
||||||
|
await expect(page.getByTestId("sidebar")).toHaveAttribute(
|
||||||
|
"data-active-tab",
|
||||||
|
"calculator",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("calculator-planet-name")).toContainText(
|
||||||
|
"Galactica",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
|
||||||
|
|
||||||
|
// Switching to the inspector and back keeps the design (long-lived
|
||||||
|
// tool state survives the tab unmount/remount).
|
||||||
|
await page.getByTestId("sidebar-tab-inspector").click();
|
||||||
|
await expect(page.getByTestId("sidebar")).toHaveAttribute(
|
||||||
|
"data-active-tab",
|
||||||
|
"inspector",
|
||||||
|
);
|
||||||
|
await page.getByTestId("sidebar-tab-calculator").click();
|
||||||
|
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user