Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build) #24
@@ -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
|
||||
`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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
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 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 {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
@@ -106,6 +111,8 @@ beforeEach(async () => {
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache: new IDBCache(db), gameId: GAME_ID });
|
||||
i18n.resetForTests("en");
|
||||
// The calculator state is a module singleton shared across cases.
|
||||
calculatorState.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -278,3 +278,39 @@ test("calculator draws reach circles for the selected planet", async ({
|
||||
await calc.getByTestId("calculator-mode-modernization").click();
|
||||
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