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:
@@ -179,14 +179,6 @@ destinations beats the duplication.
|
||||
>
|
||||
{i18n.t("game.view.mail")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-designer-ship-class"
|
||||
onclick={() => go(`/games/${gameId}/designer/ship-class`)}
|
||||
>
|
||||
{i18n.t("game.view.designer.ship_class")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,29 +1,838 @@
|
||||
<!--
|
||||
Phase 10 stub for the Calculator sidebar tool. Phase 30 wires the
|
||||
real ship/path calculator. Until then the stub renders a localised
|
||||
`coming soon` paragraph with a stable testid that later phases can
|
||||
replace without touching navigation.
|
||||
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
|
||||
designer: it fuses the ship design blocks, the live derived results
|
||||
(mass, speed, attack, defence, bombing), and a planet build-rate readout
|
||||
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.
|
||||
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
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 { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
|
||||
import {
|
||||
validateShipClass,
|
||||
type ShipClassInvalidReason,
|
||||
} from "$lib/util/ship-class-validation";
|
||||
import {
|
||||
computeCalculator,
|
||||
computePlanetBuild,
|
||||
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";
|
||||
|
||||
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 selection = getContext<SelectionStore | undefined>(
|
||||
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,
|
||||
});
|
||||
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);
|
||||
const localShipClass = $derived<ShipClassSummary[]>(
|
||||
report?.localShipClass ?? [],
|
||||
);
|
||||
const existingNames = $derived(localShipClass.map((c) => c.name));
|
||||
|
||||
const playerTech = $derived({
|
||||
drive: report?.localPlayerDrive ?? 0,
|
||||
weapons: report?.localPlayerWeapons ?? 0,
|
||||
shields: report?.localPlayerShields ?? 0,
|
||||
cargo: report?.localPlayerCargo ?? 0,
|
||||
});
|
||||
|
||||
const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];
|
||||
|
||||
// Non-overridden tech levels track the player's current tech; the
|
||||
// effect resets them whenever the report (history snapshot included)
|
||||
// changes, so the calculator reflects the right turn's tech.
|
||||
$effect(() => {
|
||||
for (const k of techKeys) {
|
||||
if (!techOverridden[k]) 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 (
|
||||
playerTech.drive ||
|
||||
playerTech.weapons ||
|
||||
playerTech.shields ||
|
||||
playerTech.cargo
|
||||
) {
|
||||
targetTech = { ...playerTech };
|
||||
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 },
|
||||
},
|
||||
core,
|
||||
),
|
||||
);
|
||||
|
||||
// Selected own planet (MVP: own planets only).
|
||||
const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
|
||||
const sel = selection?.selected;
|
||||
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
|
||||
const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
|
||||
if (planet === null || planet.kind !== "local") return null;
|
||||
return planet;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!matOverridden) {
|
||||
matValue = selectedPlanet?.materialsStockpile ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
const planetBuild = $derived.by(() => {
|
||||
if (selectedPlanet === null) return null;
|
||||
const emptyMass = result.outputs?.emptyMass;
|
||||
if (emptyMass === undefined) return null;
|
||||
return computePlanetBuild(
|
||||
{
|
||||
shipMass: emptyMass,
|
||||
freeIndustry: selectedPlanet.freeIndustry ?? 0,
|
||||
material: matValue,
|
||||
resources: selectedPlanet.resources ?? 0,
|
||||
},
|
||||
core,
|
||||
);
|
||||
});
|
||||
|
||||
// Publish the selected planet's reach (loaded speed) so the map view
|
||||
// can draw 1–3 reach circles. Cleared when the design is invalid, no
|
||||
// own planet is selected, or the calculator is in modernization mode.
|
||||
$effect(() => {
|
||||
const out = result.outputs;
|
||||
if (mode === "ship" && selectedPlanet !== null && out !== null) {
|
||||
reachStore.set(
|
||||
{ x: selectedPlanet.x, y: selectedPlanet.y },
|
||||
out.speedLoaded,
|
||||
);
|
||||
} else {
|
||||
reachStore.clear();
|
||||
}
|
||||
return () => reachStore.clear();
|
||||
});
|
||||
|
||||
const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
|
||||
empty: "game.calculator.invalid.empty",
|
||||
too_long: "game.calculator.invalid.too_long",
|
||||
starts_with_special: "game.calculator.invalid.starts_with_special",
|
||||
ends_with_special: "game.calculator.invalid.ends_with_special",
|
||||
consecutive_specials: "game.calculator.invalid.consecutive_specials",
|
||||
whitespace: "game.calculator.invalid.whitespace",
|
||||
disallowed_character: "game.calculator.invalid.disallowed_character",
|
||||
duplicate_name: "game.calculator.invalid.duplicate_name",
|
||||
drive_value: "game.calculator.invalid.drive_value",
|
||||
armament_value: "game.calculator.invalid.armament_value",
|
||||
armament_not_integer: "game.calculator.invalid.armament_not_integer",
|
||||
weapons_value: "game.calculator.invalid.weapons_value",
|
||||
shields_value: "game.calculator.invalid.shields_value",
|
||||
cargo_value: "game.calculator.invalid.cargo_value",
|
||||
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
|
||||
all_zero: "game.calculator.invalid.all_zero",
|
||||
};
|
||||
|
||||
const nameValidation = $derived(
|
||||
validateShipClass({ 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) &&
|
||||
draft !== undefined,
|
||||
);
|
||||
|
||||
// Per-block modernization upgrade cost (current tech → target tech).
|
||||
const modernCosts = $derived.by(() => {
|
||||
if (core === null) return null;
|
||||
const weaponsMass = core.weaponsBlockMass({
|
||||
weapons: blocks.weapons,
|
||||
armament: blocks.armament,
|
||||
});
|
||||
const rows: { key: TechKey; mass: number }[] = [
|
||||
{ key: "drive", mass: blocks.drive },
|
||||
{ key: "weapons", mass: weaponsMass ?? 0 },
|
||||
{ key: "shields", mass: blocks.shields },
|
||||
{ key: "cargo", mass: blocks.cargo },
|
||||
];
|
||||
const perBlock = rows.map((r) => ({
|
||||
key: r.key,
|
||||
cost: core.blockUpgradeCost({
|
||||
blockMass: r.mass,
|
||||
currentTech: techValues[r.key],
|
||||
targetTech: targetTech[r.key],
|
||||
}),
|
||||
}));
|
||||
const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
|
||||
return { perBlock, total };
|
||||
});
|
||||
|
||||
function fmt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return i18n.t("game.calculator.unavailable");
|
||||
}
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function onTechInput(key: TechKey): void {
|
||||
techOverridden[key] = true;
|
||||
}
|
||||
function onResetTech(key: TechKey): void {
|
||||
techOverridden[key] = false;
|
||||
}
|
||||
function onMatInput(): void {
|
||||
matOverridden = true;
|
||||
}
|
||||
function resetMat(): void {
|
||||
matOverridden = false;
|
||||
}
|
||||
|
||||
function lockOutput(output: LockableOutputId): void {
|
||||
if (lock !== null) return;
|
||||
lockValue = result.outputs?.[output] ?? 0;
|
||||
lock = output;
|
||||
}
|
||||
function unlock(): void {
|
||||
lock = null;
|
||||
}
|
||||
|
||||
function loadExisting(clsName: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === clsName);
|
||||
if (cls === undefined) return;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
else loadExisting(calculatorLoadRequest.name);
|
||||
});
|
||||
|
||||
async function create(): Promise<void> {
|
||||
if (!nameValidation.ok || draft === undefined) return;
|
||||
// Capture the validated draft before awaiting: adding the command
|
||||
// re-projects `localShipClass`, which re-runs the `nameValidation`
|
||||
// derived into a `duplicate_name` failure (the class now exists),
|
||||
// leaving `nameValidation.value` undefined after the await.
|
||||
const created = nameValidation.value;
|
||||
await draft.add({
|
||||
kind: "createShipClass",
|
||||
id: crypto.randomUUID(),
|
||||
name: created.name,
|
||||
drive: created.drive,
|
||||
armament: created.armament,
|
||||
weapons: created.weapons,
|
||||
shields: created.shields,
|
||||
cargo: created.cargo,
|
||||
});
|
||||
loadedExisting = created.name;
|
||||
}
|
||||
|
||||
async function deleteClass(): Promise<void> {
|
||||
if (loadedExisting === null || draft === undefined) return;
|
||||
await draft.add({
|
||||
kind: "removeShipClass",
|
||||
id: crypto.randomUUID(),
|
||||
name: loadedExisting,
|
||||
});
|
||||
loadedExisting = null;
|
||||
}
|
||||
|
||||
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
||||
emptyMass: i18n.t("game.calculator.out.mass"),
|
||||
loadedMass: i18n.t("game.calculator.out.mass"),
|
||||
speedEmpty: i18n.t("game.calculator.out.speed"),
|
||||
speedLoaded: i18n.t("game.calculator.out.speed"),
|
||||
attack: i18n.t("game.calculator.out.attack"),
|
||||
defense: i18n.t("game.calculator.out.defense"),
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="tool" data-testid="sidebar-tool-calculator">
|
||||
<h3>{i18n.t("game.sidebar.tab.calculator")}</h3>
|
||||
<p>{i18n.t("game.sidebar.empty.calculator")}</p>
|
||||
{#snippet lockable(output: LockableOutputId, value: number | undefined)}
|
||||
{#if lock === output}
|
||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
bind:value={lockValue}
|
||||
data-testid={`calculator-locked-${output}`}
|
||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
aria-label={i18n.t("game.calculator.lock.reset")}
|
||||
data-testid={`calculator-unlock-${output}`}
|
||||
onclick={unlock}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="cell">
|
||||
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
disabled={lock !== null || value === undefined}
|
||||
title={lock !== null
|
||||
? i18n.t("game.calculator.lock.max")
|
||||
: `${LOCK_LABELS[output]}`}
|
||||
aria-label={LOCK_LABELS[output]}
|
||||
data-testid={`calculator-lock-${output}`}
|
||||
onclick={() => lockOutput(output)}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<section class="calculator" data-testid="sidebar-tool-calculator">
|
||||
<div class="modes" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === "ship"}
|
||||
data-testid="calculator-mode-ship"
|
||||
onclick={() => (mode = "ship")}
|
||||
>
|
||||
{i18n.t("game.calculator.mode.ship")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={mode === "modernization"}
|
||||
data-testid="calculator-mode-modernization"
|
||||
onclick={() => (mode = "modernization")}
|
||||
>
|
||||
{i18n.t("game.calculator.mode.modernization")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="namebar">
|
||||
<input
|
||||
type="text"
|
||||
class="name"
|
||||
list="calculator-existing-classes"
|
||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||
maxlength="30"
|
||||
bind:value={name}
|
||||
oninput={() => (loadedExisting = null)}
|
||||
onchange={() => loadExisting(name)}
|
||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||
data-testid="calculator-name"
|
||||
/>
|
||||
<datalist id="calculator-existing-classes">
|
||||
{#each localShipClass as cls (cls.name)}
|
||||
<option value={cls.name}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
{#if mode === "ship"}
|
||||
<button
|
||||
type="button"
|
||||
class="create"
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? "" : createMessage}
|
||||
data-testid="calculator-create"
|
||||
onclick={() => void create()}
|
||||
>
|
||||
{i18n.t("game.calculator.action.create")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mode === "ship" && canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="delete"
|
||||
data-testid="calculator-delete"
|
||||
onclick={() => void deleteClass()}
|
||||
>
|
||||
{i18n.t("game.calculator.action.delete")} {loadedExisting}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ShipDesignArea
|
||||
bind:blocks
|
||||
resolved={result.blocks}
|
||||
bind:techs={techValues}
|
||||
{techOverridden}
|
||||
computedInput={result.computedInput}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
/>
|
||||
|
||||
{#if 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}
|
||||
data-testid={`calculator-load-${m}`}
|
||||
onclick={() => (loadMode = m)}
|
||||
>
|
||||
{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if loadMode === "custom"}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="custom-load"
|
||||
bind:value={customLoad}
|
||||
data-testid="calculator-custom-load"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="results" data-testid="calculator-results">
|
||||
<div class="rrow head">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.mass")}</span>
|
||||
{@render lockable("emptyMass", result.outputs?.emptyMass)}
|
||||
{@render lockable("loadedMass", result.outputs?.loadedMass)}
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.speed")}</span>
|
||||
{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
|
||||
{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.attack")}</span>
|
||||
{@render lockable("attack", result.outputs?.attack)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.defense")}</span>
|
||||
{@render lockable("defense", result.outputs?.defense)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-out-bombing">
|
||||
{fmt(result.outputs?.bombing)}
|
||||
</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-out-cargo-capacity">
|
||||
{fmt(result.outputs === null ? null : result.cargoCapacity)}
|
||||
</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="planet" data-testid="calculator-planet-area">
|
||||
{#if selectedPlanet === null}
|
||||
<p class="hint" data-testid="calculator-planet-none">
|
||||
{i18n.t("game.calculator.planet.none")}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="planet-name" data-testid="calculator-planet-name">
|
||||
{i18n.t("game.calculator.planet.label", {
|
||||
name: selectedPlanet.name,
|
||||
number: String(selectedPlanet.number),
|
||||
})}
|
||||
</p>
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={matValue}
|
||||
oninput={onMatInput}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
{#if matOverridden}
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.mat.reset")}
|
||||
aria-label={i18n.t("game.calculator.mat.reset")}
|
||||
data-testid="calculator-mat-reset"
|
||||
onclick={resetMat}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<dl class="planet-stats">
|
||||
<div>
|
||||
<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
|
||||
<dd data-testid="calculator-ships-per-turn">
|
||||
{fmt(planetBuild?.shipsPerTurn)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
|
||||
<dd data-testid="calculator-turns-per-ship">
|
||||
{fmt(planetBuild?.turnsPerShip ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="modern" data-testid="calculator-modernization">
|
||||
<div class="rrow head">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
|
||||
</div>
|
||||
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
bind:value={targetTech[row.key]}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
/>
|
||||
</span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
|
||||
{fmt(row.cost)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="rrow total">
|
||||
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
|
||||
<span></span>
|
||||
<span class="cell">
|
||||
<span class="val" data-testid="calculator-modern-total">
|
||||
{fmt(modernCosts?.total)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tool {
|
||||
padding: 1rem;
|
||||
.calculator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.tool h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool p {
|
||||
.modes button {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modes button.active {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
background: #11162a;
|
||||
}
|
||||
.namebar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.name[aria-invalid="true"] {
|
||||
border-color: #d97a7a;
|
||||
}
|
||||
.create,
|
||||
.delete {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.create:not(:disabled):hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete {
|
||||
color: #d97a7a;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.load {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.seg button {
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.active {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.custom-load {
|
||||
width: 4rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.results,
|
||||
.modern {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.rrow {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.col-head {
|
||||
color: #8890b0;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.label {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.cell .val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
.cell input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.cell.locked input {
|
||||
color: #9fb0ff;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.cell.infeasible input {
|
||||
border-color: #d97a7a;
|
||||
color: #f0a0a0;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.lock.active,
|
||||
.lock:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.lock:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.planet {
|
||||
border-top: 1px solid #20253a;
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.planet-name {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #cdd3f0;
|
||||
}
|
||||
.planet-stats {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
.planet-stats div {
|
||||
display: contents;
|
||||
}
|
||||
.planet-stats dt {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.planet-stats dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
.rrow.total .label {
|
||||
color: #cdd3f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user