Merge pull request 'fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix' (#61) from feature/issue-49-calculator-polish into development
This commit was merged in pull request #61.
This commit is contained in:
+17
-4
@@ -22,12 +22,25 @@ func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
|
||||
// DriveForSpeed returns the drive block that yields targetSpeed for a
|
||||
// ship whose mass excluding the drive block is restMass, at drive tech
|
||||
// level driveTech, inverting [Speed] composed with [DriveEffective].
|
||||
// Speed approaches but never reaches the stripped-hull ceiling
|
||||
// 20*driveTech, so a target at or above the ceiling (or a non-positive
|
||||
// target or tech level) is infeasible.
|
||||
// With a positive restMass the speed approaches but never reaches the
|
||||
// stripped-hull ceiling 20*driveTech, so a target at or above the
|
||||
// ceiling is infeasible. With restMass==0 the drive block carries no
|
||||
// other mass: every positive drive yields exactly the ceiling speed, so
|
||||
// the ceiling target is the only feasible one and any positive drive
|
||||
// (canonically 1) solves it. Non-positive targetSpeed or driveTech are
|
||||
// always infeasible.
|
||||
func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
|
||||
if driveTech <= 0 || targetSpeed <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
ceiling := 20 * driveTech
|
||||
if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling {
|
||||
if restMass <= 0 {
|
||||
if targetSpeed != ceiling {
|
||||
return 0, false
|
||||
}
|
||||
return 1, true
|
||||
}
|
||||
if targetSpeed >= ceiling {
|
||||
return 0, false
|
||||
}
|
||||
return targetSpeed * restMass / (ceiling - targetSpeed), true
|
||||
|
||||
+19
-1
@@ -24,12 +24,30 @@ func TestDriveForSpeed(t *testing.T) {
|
||||
if !ok || math.Abs(got-drive) > 1e-9 {
|
||||
t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive)
|
||||
}
|
||||
// Speed can never reach the stripped-hull ceiling 20*driveTech.
|
||||
// With a positive restMass speed can never reach 20*driveTech.
|
||||
if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
|
||||
t.Error("DriveForSpeed at the speed ceiling should be infeasible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveForSpeedZeroRest(t *testing.T) {
|
||||
// With restMass==0 the only achievable speed is the stripped-hull
|
||||
// ceiling 20*driveTech; any positive drive reaches it. Off-ceiling
|
||||
// targets are infeasible.
|
||||
const driveTech = 1.5
|
||||
ceiling := 20 * driveTech
|
||||
got, ok := calc.DriveForSpeed(ceiling, driveTech, 0)
|
||||
if !ok || got <= 0 {
|
||||
t.Errorf("DriveForSpeed(ceiling, _, 0) = %v (ok=%v), want positive", got, ok)
|
||||
}
|
||||
if _, ok := calc.DriveForSpeed(ceiling/2, driveTech, 0); ok {
|
||||
t.Error("DriveForSpeed(below ceiling, _, 0) should be infeasible")
|
||||
}
|
||||
if _, ok := calc.DriveForSpeed(ceiling+1, driveTech, 0); ok {
|
||||
t.Error("DriveForSpeed(above ceiling, _, 0) should be infeasible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShieldsForDefence(t *testing.T) {
|
||||
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
|
||||
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
|
||||
|
||||
+86
-29
@@ -24,28 +24,60 @@ in as a per-ship result rather than a separate mode.
|
||||
|
||||
1. **Ship Class design area** — five blocks (drive, armament, weapons,
|
||||
shields, cargo) and four tech levels (drive, weapons, shields,
|
||||
cargo). Tech defaults to the player's current tech and shows a lock
|
||||
icon once overridden; clicking it resets to the default.
|
||||
cargo). Tech defaults to the player's current tech: the cell renders
|
||||
the inherited number with an open padlock; clicking the open lock
|
||||
activates an input (closed padlock), where the player may type an
|
||||
override at or above their current tech. Clicking the closed
|
||||
padlock resets to the default. The padlock slot is always reserved,
|
||||
so the column width does not shift as the lock state toggles. The
|
||||
inherited tech value reads through the same 3-decimal `Ceil3`
|
||||
formatter the report uses, so the column lines up with derived
|
||||
values. **Every numeric input in the calculator hides the native
|
||||
spinner and drives stepping through ArrowUp / ArrowDown.** This keeps
|
||||
the column widths stable, makes the inputs read consistently, and
|
||||
gives each row a step that matches its purpose. The four ship-class
|
||||
blocks (drive, weapons, shields, cargo) use a smart step that
|
||||
respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps
|
||||
straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0,
|
||||
otherwise −0.1, never producing an invalid value in `(0, 1)`.
|
||||
Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load,
|
||||
lock value, and modernization target tech each step by their natural
|
||||
grain (±0.001 for tech and lock values, ±0.01 for MAT and load).
|
||||
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
||||
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
||||
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
||||
units) that the loaded-column results use. At **full** the toggle
|
||||
shows the ship's cargo capacity; a **custom** load over that capacity
|
||||
is flagged as an error. With a zero cargo block there is no hold, so
|
||||
the load is pinned to empty and the toggle is disabled.
|
||||
the load is pinned to empty and the toggle is disabled. The bombing
|
||||
and cargo-capacity rows have no goal-seek lock, but they still
|
||||
reserve a hidden lock-slot placeholder so the value column stays
|
||||
vertically aligned with the lockable rows above.
|
||||
3. **Planet area** — when an own planet is selected on the map, shows
|
||||
its MAT (overridable) and the single-turn build rate (ships per turn,
|
||||
turns per ship). The realistic multi-turn forecast with CAP/COL
|
||||
supply is planned (see ../ROADMAP.md).
|
||||
turns per ship). The MAT follows the same lock idiom as the tech
|
||||
cells: the planet number renders with an open padlock, clicking
|
||||
opens an input with a closed padlock, and the closed padlock resets
|
||||
to the planet value. The MAT label reads through the same 3-decimal
|
||||
`Ceil3` formatter, matching the rest of the calculator's label
|
||||
values. The realistic multi-turn forecast with CAP/COL supply is
|
||||
planned (see ../ROADMAP.md).
|
||||
|
||||
## Locks and goal-seek
|
||||
|
||||
Two distinct lock semantics share one icon (a closed padlock; it only
|
||||
appears once a value is pinned, click to release):
|
||||
Two distinct lock semantics share one padlock affordance. Both follow
|
||||
the same idiom — an open padlock (🔓) means *value is inherited /
|
||||
derived, click to override*; a closed padlock (🔒) means *value is
|
||||
pinned by the player, click to reset*:
|
||||
|
||||
- **Override locks** on inputs that have a default — the four techs and
|
||||
the planet MAT. Editing one overrides the default; the lock resets it.
|
||||
Any number may be overridden at once.
|
||||
the planet MAT. By default the cell shows the inherited number plus
|
||||
an open padlock; clicking it switches to an input plus a closed
|
||||
padlock for typing the override. Closing (clicking the closed
|
||||
padlock) resets to the default. Any number may be overridden at once.
|
||||
Tech overrides are floored at the player's current tech on this
|
||||
turn — a lower value is flagged as invalid. The same floor applies
|
||||
to the modernization target tech.
|
||||
- **Goal-seek locks** on derived results. Pinning a result back-solves
|
||||
the single input it claims, which then renders read-only (computed):
|
||||
|
||||
@@ -60,12 +92,19 @@ appears once a value is pinned, click to release):
|
||||
|
||||
Only **one** result may be locked at a time (the others' lock
|
||||
affordances disable with a tooltip). An unreachable target — e.g. a
|
||||
speed at or above the stripped-hull ceiling `20 × driveTech`, or a
|
||||
solved block that fails the value rules — leaves the locked cell in a
|
||||
red error state and does not apply. Inverse solving lives in
|
||||
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
||||
non-analytic case. Locking a speed is disabled when the drive block is
|
||||
zero (a deliberately immobile ship has no speed to back-solve).
|
||||
speed above the stripped-hull ceiling `20 × driveTech`, or a solved
|
||||
block that fails the value rules (a DWSC value in the `(0, 1)` gap)
|
||||
— leaves the locked cell in a red error state and does not apply.
|
||||
When that happens the claimed block is **not** back-solved into the
|
||||
invalid range; the design preview keeps reading the user's typed
|
||||
values, so the row never silently shows a sub-1 block. Inverse
|
||||
solving lives in `pkg/calc/solve.go`; the bisection for defence →
|
||||
shields is the only non-analytic case. Locking a speed is disabled
|
||||
when the drive block is zero (a deliberately immobile ship has no
|
||||
speed to back-solve). With the drive block as the only non-zero mass
|
||||
the displayed speed equals the ceiling exactly (every positive drive
|
||||
gives the same speed), so the solver accepts that ceiling target as
|
||||
a feasible lock and any positive drive solves it.
|
||||
|
||||
## Validation and display
|
||||
|
||||
@@ -75,25 +114,43 @@ negative, the five blocks follow the engine value rules
|
||||
(`pkg/calc/validator.go`, surfaced per-field by
|
||||
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
|
||||
|
||||
Every displayed number — the derived results and the goal-seek
|
||||
back-solved input — is rounded **up** to three decimals through the
|
||||
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value
|
||||
is never shown lower than it is (a speed of 5.0003 reads 5.001). The
|
||||
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a
|
||||
display-only helper that lives in `pkg/calc` so the UI and Go share one
|
||||
implementation.
|
||||
Every displayed number — the derived results, the inherited tech /
|
||||
planet MAT labels, and the goal-seek back-solved input — is rounded
|
||||
**up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
|
||||
(bridged as `core.ceil3`) and always padded to three decimals so the
|
||||
column reads the same on integers and fractions alike (a speed of 20
|
||||
shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
|
||||
monospace stack from the design tokens (`--font-mono`) with
|
||||
right-aligned, tabular numerals so values line up vertically across
|
||||
rows. To match the display rule, every number input also refuses a
|
||||
fourth decimal as the user types: typing `1.2345` clamps the input to
|
||||
`1.234` on input. The engine keeps its own round-to-nearest
|
||||
`util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc`
|
||||
so the UI and Go share one implementation.
|
||||
|
||||
## Create / load / delete
|
||||
## Create / load
|
||||
|
||||
The name field is a combobox over the player's existing classes. Picking
|
||||
an existing class loads it as a template (so you can tweak and Create a
|
||||
new one); Create is disabled while the name is invalid or duplicate
|
||||
(reusing `lib/util/ship-class-validation.ts`). When a saved class is
|
||||
loaded, a Delete affordance appears. Create / Delete reuse the existing
|
||||
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic
|
||||
overlay reflects the change immediately. Ship classes are immutable after
|
||||
creation (per `game/rules.txt`), so there is no edit — only Create-new
|
||||
and Delete.
|
||||
(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
|
||||
`createShipClass` order-draft flow, so the optimistic overlay reflects
|
||||
the change immediately. Ship classes are immutable after creation (per
|
||||
`game/rules.txt`), so there is no edit — only Create-new. Delete-class
|
||||
lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
|
||||
not the calculator.
|
||||
|
||||
Selecting a class from the dropdown loads it **immediately**, the
|
||||
moment the option is clicked. (Native `change` only fires on blur in
|
||||
Firefox; switching the load trigger to `input` makes the load
|
||||
synchronous everywhere, since the `InputEvent.inputType` flags a
|
||||
datalist replacement as `"insertReplacementText"` in Chromium / WebKit
|
||||
or `undefined` in Firefox — keyboard typing always carries a typing
|
||||
`inputType`.) If the live blocks differ from the previously loaded
|
||||
class (or, when nothing is loaded, from the empty defaults), the
|
||||
calculator first asks `Discard unsaved changes and load class «…»?`
|
||||
through a `window.confirm`; declining reverts the name field and
|
||||
leaves the current blocks untouched.
|
||||
|
||||
## Reach circles
|
||||
|
||||
|
||||
@@ -92,6 +92,18 @@ export interface CalculatorResult {
|
||||
outputs: CalculatorOutputs | null;
|
||||
}
|
||||
|
||||
// isClaimedBlockValid checks that a solver result, before we apply it
|
||||
// to the resolved blocks, satisfies the same per-field rules the live
|
||||
// validator enforces on user-typed values (`pkg/calc/validator.go` /
|
||||
// `lib/util/ship-class-validation`). The four claimable blocks all
|
||||
// share the DWSC rule, so a single predicate suffices. Used to flag
|
||||
// a goal-seek target as infeasible when the only block that would
|
||||
// reach it falls in the (0, 1) gap.
|
||||
function isClaimedBlockValid(solved: number): boolean {
|
||||
if (!Number.isFinite(solved)) return false;
|
||||
return solved === 0 || solved >= 1;
|
||||
}
|
||||
|
||||
function resolveLoad(
|
||||
mode: LoadMode,
|
||||
customLoad: number,
|
||||
@@ -225,8 +237,18 @@ export function computeCalculator(
|
||||
if (solved === null) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
// The solver may produce a value that is mathematically
|
||||
// correct yet rejected by the ship-class value rules —
|
||||
// most commonly a DWSC block in the (0, 1) gap. Surface
|
||||
// that as an infeasible lock so the lock input flips
|
||||
// red and the outputs are suppressed, instead of
|
||||
// silently showing an invalid design.
|
||||
if (!isClaimedBlockValid(solved)) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<!--
|
||||
Reusable "Ship Class design area": the five design blocks (drive,
|
||||
armament, weapons, shields, cargo) plus the four tech levels they are
|
||||
built with. Each tech defaults to the player's current level and shows a
|
||||
lock icon once overridden; clicking the lock resets it. A block claimed
|
||||
by an active goal-seek lock renders read-only with its own lock marker.
|
||||
The component is presentational — the parent owns the state and the
|
||||
built with. Tech and MAT locks follow the same idiom as goal-seek
|
||||
locks below the design area — by default the value renders as plain
|
||||
text with an open padlock; clicking it overrides (input + closed
|
||||
padlock). Reserved space for the padlock keeps the column width
|
||||
stable as the lock state toggles. A block claimed by an active
|
||||
goal-seek lock renders read-only with its own lock marker. The
|
||||
component is presentational — the parent owns the state and the
|
||||
calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
shipClassFieldErrors,
|
||||
@@ -37,8 +41,17 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
resolved: DesignBlocksState;
|
||||
techs: TechState;
|
||||
techOverridden: Record<TechKey, boolean>;
|
||||
// Lower bound for the tech inputs: the player's current tech on
|
||||
// this turn. A design cannot be built with tech below the player's
|
||||
// own level, so we surface that as a per-field validation error.
|
||||
techFloor: TechState;
|
||||
computedInput?: ClaimedInput | null;
|
||||
blocksReadonly?: boolean;
|
||||
// Formatter applied to the read-only tech value and to the
|
||||
// resolved (goal-seek) ship-block value. Same `fmt` as the
|
||||
// rest of the calculator, passed in so the design area stays
|
||||
// presentational and the parent owns the rounding policy.
|
||||
formatNumber: (value: number) => string;
|
||||
onTechInput: (key: TechKey) => void;
|
||||
onResetTech: (key: TechKey) => void;
|
||||
};
|
||||
@@ -47,8 +60,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
resolved,
|
||||
techs = $bindable(),
|
||||
techOverridden,
|
||||
techFloor,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
formatNumber,
|
||||
onTechInput,
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
@@ -73,21 +88,93 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
|
||||
}
|
||||
function techError(key: TechKey): string {
|
||||
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
|
||||
const value = techs[key];
|
||||
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
|
||||
if (value < techFloor[key]) {
|
||||
return i18n.t("game.calculator.invalid.tech_below_current");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Smart step on the four ship-class blocks (drive, weapons, shields,
|
||||
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
|
||||
// native 0.01 step would produce invalid intermediates like 0.01.
|
||||
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
|
||||
// otherwise −0.1 down to 1, clamped at 0. Armament uses a plain
|
||||
// integer step (±1, clamped at 0) so it follows the same
|
||||
// JS-driven idiom and we can hide the native spinner uniformly.
|
||||
function bumpBlock(value: number, dir: 1 | -1): number {
|
||||
if (dir === 1) {
|
||||
if (value < 1) return 1;
|
||||
return Math.round((value + 0.1) * 10) / 10;
|
||||
}
|
||||
if (value <= 1) return 0;
|
||||
return Math.round((value - 0.1) * 10) / 10;
|
||||
}
|
||||
function bumpArmament(value: number, dir: 1 | -1): number {
|
||||
const next = Math.trunc(value) + dir;
|
||||
return next < 0 ? 0 : next;
|
||||
}
|
||||
function onBlockKey(
|
||||
event: KeyboardEvent,
|
||||
key: keyof DesignBlocksState,
|
||||
smart: boolean,
|
||||
): void {
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
blocks[key] = smart
|
||||
? bumpBlock(blocks[key], dir)
|
||||
: bumpArmament(blocks[key], dir);
|
||||
}
|
||||
// Tech / modernization-target inputs all use the same ±0.001 step
|
||||
// with a per-row floor; lifted into a helper so the parent can
|
||||
// reuse it (modernization area in `calculator-tab`).
|
||||
function bumpTech(event: KeyboardEvent, key: TechKey): void {
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
const current = techs[key];
|
||||
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
|
||||
const floor = techFloor[key];
|
||||
techs[key] = next < floor ? floor : next;
|
||||
}
|
||||
// Refuse a fourth decimal as typing happens: keeps the calculator
|
||||
// from ever displaying a >3-decimal fraction the user could not
|
||||
// have intended (the calculator math is `Ceil3`-rounded for display
|
||||
// anyway). Pairs with `bind:value` — `apply` overwrites the bound
|
||||
// state when Svelte's own bind handler has already read the
|
||||
// over-precise number.
|
||||
function capDecimals(event: Event, apply: (next: number) => void): void {
|
||||
const el = event.currentTarget as HTMLInputElement;
|
||||
const txt = el.value;
|
||||
const dot = txt.indexOf(".");
|
||||
if (dot < 0 || txt.length - dot - 1 <= 3) return;
|
||||
el.value = txt.slice(0, dot + 4);
|
||||
apply(el.valueAsNumber);
|
||||
}
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
smartStep: boolean;
|
||||
}[] = [
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
|
||||
];
|
||||
|
||||
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
||||
|
||||
async function activateTechOverride(key: TechKey): Promise<void> {
|
||||
onTechInput(key);
|
||||
await tick();
|
||||
techInputEls[key]?.focus();
|
||||
techInputEls[key]?.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="design" data-testid="calculator-design-area">
|
||||
@@ -102,46 +189,50 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
<span class="label">{row.label()}</span>
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
readonly
|
||||
value={resolved[row.key]}
|
||||
value={formatNumber(resolved[row.key])}
|
||||
data-computed="true"
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={blocks[row.key]}
|
||||
readonly={blocksReadonly}
|
||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||
title={blockError(row.key)}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||||
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
|
||||
/>
|
||||
{/if}
|
||||
{#if row.tech !== null}
|
||||
{@const techKey = row.tech}
|
||||
<span class="tech-cell">
|
||||
<input
|
||||
class="tech"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
bind:value={techs[techKey]}
|
||||
oninput={() => onTechInput(techKey)}
|
||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||
title={techError(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
/>
|
||||
{#if techOverridden[techKey]}
|
||||
<input
|
||||
bind:this={techInputEls[techKey]}
|
||||
class="tech no-spin"
|
||||
type="number"
|
||||
step="any"
|
||||
min={techFloor[techKey]}
|
||||
bind:value={techs[techKey]}
|
||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||
title={techError(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
onkeydown={(e) => bumpTech(e, techKey)}
|
||||
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.tech.reset")}
|
||||
aria-label={i18n.t("game.calculator.tech.reset")}
|
||||
data-testid={`calculator-tech-reset-${techKey}`}
|
||||
@@ -149,6 +240,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="tech-val"
|
||||
data-testid={`calculator-tech-value-${techKey}`}
|
||||
>
|
||||
{formatNumber(techs[techKey])}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.tech.override")}
|
||||
aria-label={i18n.t("game.calculator.tech.override")}
|
||||
data-testid={`calculator-tech-override-${techKey}`}
|
||||
onclick={() => void activateTechOverride(techKey)}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -182,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
input {
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -192,6 +300,19 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
/* Hide native spinners across the design area — the row drives
|
||||
every numeric edit through ArrowUp/ArrowDown so the column
|
||||
width stays stable and the inputs read consistently. */
|
||||
input.no-spin::-webkit-inner-spin-button,
|
||||
input.no-spin::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.no-spin {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
input[data-computed="true"],
|
||||
input[readonly] {
|
||||
@@ -206,6 +327,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.tech-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
@@ -214,5 +344,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.lock.active,
|
||||
.lock:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -364,7 +364,6 @@ const en = {
|
||||
"game.calculator.name.placeholder": "new class name",
|
||||
"game.calculator.name.existing": "your ship classes",
|
||||
"game.calculator.action.create": "create",
|
||||
"game.calculator.action.delete": "delete",
|
||||
"game.calculator.col.ship": "ship",
|
||||
"game.calculator.col.tech": "tech",
|
||||
"game.calculator.field.drive": "drive",
|
||||
@@ -393,7 +392,9 @@ const en = {
|
||||
"game.calculator.lock.reset": "locked — click to release to the computed value",
|
||||
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
|
||||
"game.calculator.lock.max": "release the locked result first — one result at a time",
|
||||
"game.calculator.tech.override": "click to override your current tech",
|
||||
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
|
||||
"game.calculator.mat.override": "click to override the planet value",
|
||||
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
|
||||
"game.calculator.modern.current": "current",
|
||||
"game.calculator.modern.target": "target",
|
||||
@@ -418,8 +419,10 @@ const en = {
|
||||
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
|
||||
"game.calculator.invalid.negative": "value cannot be negative",
|
||||
"game.calculator.invalid.tech_value": "tech level cannot be negative",
|
||||
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
|
||||
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
|
||||
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
||||
"game.calculator.confirm_reset_for_load": "Discard unsaved changes and load class «{name}»?",
|
||||
|
||||
"game.table.sciences.title": "sciences",
|
||||
"game.table.sciences.column.name": "name",
|
||||
|
||||
@@ -365,7 +365,6 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.name.placeholder": "имя нового класса",
|
||||
"game.calculator.name.existing": "ваши классы кораблей",
|
||||
"game.calculator.action.create": "создать",
|
||||
"game.calculator.action.delete": "удалить",
|
||||
"game.calculator.col.ship": "корабль",
|
||||
"game.calculator.col.tech": "технологии",
|
||||
"game.calculator.field.drive": "двигатель",
|
||||
@@ -394,7 +393,9 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
|
||||
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
|
||||
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
|
||||
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
|
||||
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
|
||||
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
|
||||
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
|
||||
"game.calculator.modern.current": "текущий",
|
||||
"game.calculator.modern.target": "целевой",
|
||||
@@ -419,8 +420,10 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
||||
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
||||
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
|
||||
|
||||
"game.table.sciences.title": "науки",
|
||||
"game.table.sciences.column.name": "название",
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 { getContext, tick } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -204,11 +204,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
|
||||
);
|
||||
const canCreate = $derived(nameValidation.ok && draft !== undefined);
|
||||
const canDelete = $derived(
|
||||
cs.loadedExisting !== null &&
|
||||
existingNames.includes(cs.loadedExisting) &&
|
||||
draft !== undefined,
|
||||
);
|
||||
|
||||
// Per-block modernization upgrade cost (current tech → target tech).
|
||||
const modernCosts = $derived.by(() => {
|
||||
@@ -237,12 +232,35 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
|
||||
// Display every computed number rounded up to three decimals via the
|
||||
// shared `Ceil3` bridge, so a value is never shown lower than it is.
|
||||
// Always three decimals (`1` → `1.000`) for column-aligned readability,
|
||||
// and without thousands grouping so the same string also embeds in the
|
||||
// read-only goal-seek `<input type="number">` cell.
|
||||
function fmt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return i18n.t("game.calculator.unavailable");
|
||||
}
|
||||
const rounded = core !== null ? core.ceil3({ value }) : value;
|
||||
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
return rounded.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Cap typed precision at three decimal digits. Number inputs use
|
||||
// `step="any"`, which lets the browser accept arbitrary precision; the
|
||||
// owner asked us to refuse a fourth decimal as typing happens so the
|
||||
// calculator never displays a longer-than-three-digit fraction. Pairs
|
||||
// with `bind:value`: if Svelte's bind handler has already read the
|
||||
// over-precise number, `apply` overwrites the state with the truncated
|
||||
// value so the next reactive flush does not undo our truncation.
|
||||
function capDecimals(event: Event, apply: (next: number) => void): void {
|
||||
const el = event.currentTarget as HTMLInputElement;
|
||||
const txt = el.value;
|
||||
const dot = txt.indexOf(".");
|
||||
if (dot < 0 || txt.length - dot - 1 <= 3) return;
|
||||
el.value = txt.slice(0, dot + 4);
|
||||
apply(el.valueAsNumber);
|
||||
}
|
||||
|
||||
// The goal-seek back-solved block, shown in its read-only cell, is
|
||||
@@ -273,6 +291,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
|
||||
);
|
||||
|
||||
// Modernization target tech mirrors the design-area floor: a target
|
||||
// below the player's current tech on this turn is meaningless (no
|
||||
// upgrade), so flag it the same way.
|
||||
function targetTechError(key: TechKey): string {
|
||||
const value = cs.targetTech[key];
|
||||
if (value < 0) return i18n.t("game.calculator.invalid.negative");
|
||||
if (value < playerTech[key]) {
|
||||
return i18n.t("game.calculator.invalid.tech_below_current");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Locking a speed back-solves the drive block; with a zero drive the
|
||||
// ship is deliberately immobile, so disallow it.
|
||||
function lockDisabledReason(output: LockableOutputId): string {
|
||||
@@ -291,8 +321,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
function onResetTech(key: TechKey): void {
|
||||
cs.techOverridden[key] = false;
|
||||
}
|
||||
function onMatInput(): void {
|
||||
const matInputRef: { el?: HTMLInputElement } = {};
|
||||
async function activateMatOverride(): Promise<void> {
|
||||
cs.matOverridden = true;
|
||||
await tick();
|
||||
matInputRef.el?.focus();
|
||||
matInputRef.el?.select();
|
||||
}
|
||||
function resetMat(): void {
|
||||
cs.matOverridden = false;
|
||||
@@ -307,6 +341,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.lock = null;
|
||||
}
|
||||
|
||||
// Generic ±step keyboard handler for the calculator's free-form
|
||||
// number inputs (MAT, custom-load, lock value, modernization
|
||||
// target tech). Pairs with `class="no-spin"` so the native spinner
|
||||
// is hidden everywhere and the column width is stable; ArrowUp /
|
||||
// ArrowDown is the only step affordance. The smart 0↔1 jump on
|
||||
// the ship-class blocks lives in `ship-design-area.svelte` —
|
||||
// these other inputs accept any non-negative number.
|
||||
function onStepKey(
|
||||
event: KeyboardEvent,
|
||||
current: number,
|
||||
step: number,
|
||||
min: number,
|
||||
apply: (next: number) => void,
|
||||
): void {
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
// Snap to the same fractional grid as `step` so 0.001 stays
|
||||
// at three decimals instead of drifting via float math.
|
||||
const inv = 1 / step;
|
||||
const next = Math.round((current + dir * step) * inv) / inv;
|
||||
apply(next < min ? min : next);
|
||||
}
|
||||
|
||||
function loadExisting(clsName: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === clsName);
|
||||
if (cls === undefined) return;
|
||||
@@ -322,6 +380,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.lock = null;
|
||||
}
|
||||
|
||||
// Compare the live blocks to the baseline they were last loaded
|
||||
// from — or to the empty defaults if no class has been loaded. The
|
||||
// dropdown selection flow uses this to ask before discarding manual
|
||||
// edits. Tech overrides are independent of class loading, so they
|
||||
// don't count as "dirty" here.
|
||||
function baselineBlocks(): {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
} {
|
||||
if (cs.loadedExisting !== null) {
|
||||
const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
|
||||
if (cls !== undefined) {
|
||||
return {
|
||||
drive: cls.drive,
|
||||
armament: cls.armament,
|
||||
weapons: cls.weapons,
|
||||
shields: cls.shields,
|
||||
cargo: cls.cargo,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||
}
|
||||
function isDesignDirty(): boolean {
|
||||
const base = baselineBlocks();
|
||||
return (
|
||||
cs.blocks.drive !== base.drive ||
|
||||
cs.blocks.armament !== base.armament ||
|
||||
cs.blocks.weapons !== base.weapons ||
|
||||
cs.blocks.shields !== base.shields ||
|
||||
cs.blocks.cargo !== base.cargo
|
||||
);
|
||||
}
|
||||
function tryLoadByName(name: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === name);
|
||||
if (cls === undefined) return;
|
||||
if (cs.loadedExisting === cls.name) return;
|
||||
if (isDesignDirty()) {
|
||||
const ok = window.confirm(
|
||||
i18n.t("game.calculator.confirm_reset_for_load", {
|
||||
name: cls.name,
|
||||
}),
|
||||
);
|
||||
if (!ok) {
|
||||
cs.name = cs.loadedExisting ?? "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadExisting(name);
|
||||
}
|
||||
// Catch the datalist option click immediately. Native `change` only
|
||||
// fires on blur in Firefox, which is what made dropdown selection
|
||||
// look delayed; `input` fires the moment the value is set. Typed
|
||||
// keystrokes carry an `inputType` ("insertText", "deleteContent…");
|
||||
// a datalist selection replaces the value in one shot, so its
|
||||
// `inputType` is undefined (Firefox) or "insertReplacementText"
|
||||
// (Chromium / WebKit). We treat that as a selection.
|
||||
function onNameInput(event: Event): void {
|
||||
const ev = event as InputEvent;
|
||||
const isSelection =
|
||||
ev.inputType === undefined ||
|
||||
ev.inputType === "insertReplacementText";
|
||||
if (!isSelection) {
|
||||
cs.loadedExisting = null;
|
||||
return;
|
||||
}
|
||||
tryLoadByName(cs.name);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -354,16 +484,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.loadedExisting = created.name;
|
||||
}
|
||||
|
||||
async function deleteClass(): Promise<void> {
|
||||
if (cs.loadedExisting === null || draft === undefined) return;
|
||||
await draft.add({
|
||||
kind: "removeShipClass",
|
||||
id: crypto.randomUUID(),
|
||||
name: cs.loadedExisting,
|
||||
});
|
||||
cs.loadedExisting = null;
|
||||
}
|
||||
|
||||
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
||||
emptyMass: i18n.t("game.calculator.out.mass"),
|
||||
loadedMass: i18n.t("game.calculator.out.mass"),
|
||||
@@ -378,9 +498,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.lock === output}
|
||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
bind:value={cs.lockValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
||||
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
|
||||
data-testid={`calculator-locked-${output}`}
|
||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||
/>
|
||||
@@ -446,8 +570,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||
maxlength="30"
|
||||
bind:value={cs.name}
|
||||
oninput={() => (cs.loadedExisting = null)}
|
||||
onchange={() => loadExisting(cs.name)}
|
||||
oninput={onNameInput}
|
||||
onchange={() => tryLoadByName(cs.name)}
|
||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||
data-testid="calculator-name"
|
||||
/>
|
||||
@@ -469,23 +593,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if cs.mode === "ship" && canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="delete"
|
||||
data-testid="calculator-delete"
|
||||
onclick={() => void deleteClass()}
|
||||
>
|
||||
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ShipDesignArea
|
||||
bind:blocks={cs.blocks}
|
||||
resolved={resolvedCeil}
|
||||
bind:techs={cs.techValues}
|
||||
techOverridden={cs.techOverridden}
|
||||
techFloor={playerTech}
|
||||
computedInput={result.computedInput}
|
||||
formatNumber={fmt}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
/>
|
||||
@@ -509,10 +625,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.loadMode === "custom"}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
class="custom-load"
|
||||
class="custom-load no-spin"
|
||||
bind:value={cs.customLoad}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
||||
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
|
||||
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||
title={customLoadError}
|
||||
data-testid="calculator-custom-load"
|
||||
@@ -560,6 +679,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="val" data-testid="calculator-out-bombing">
|
||||
{fmt(result.outputs?.bombing)}
|
||||
</span>
|
||||
<span class="lock-slot" aria-hidden="true">🔓</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@@ -569,6 +689,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="val" data-testid="calculator-out-cargo-capacity">
|
||||
{fmt(result.outputs === null ? null : result.cargoCapacity)}
|
||||
</span>
|
||||
<span class="lock-slot" aria-hidden="true">🔓</span>
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@@ -589,17 +710,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<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={cs.matValue}
|
||||
oninput={onMatInput}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
{#if cs.matOverridden}
|
||||
<input
|
||||
bind:this={matInputRef.el}
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={cs.matValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
||||
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
@@ -610,6 +735,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="mat-val"
|
||||
data-testid="calculator-planet-mat-value"
|
||||
>
|
||||
{fmt(cs.matValue)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.mat.override")}
|
||||
aria-label={i18n.t("game.calculator.mat.override")}
|
||||
data-testid="calculator-mat-override"
|
||||
onclick={() => void activateMatOverride()}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span></span>
|
||||
@@ -638,18 +780,28 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
|
||||
</div>
|
||||
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
||||
{@const targetError = targetTechError(row.key)}
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
step="any"
|
||||
min={playerTech[row.key]}
|
||||
bind:value={cs.targetTech[row.key]}
|
||||
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
|
||||
title={cs.targetTech[row.key] < 0
|
||||
? i18n.t("game.calculator.invalid.negative")
|
||||
: ""}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(
|
||||
e,
|
||||
cs.targetTech[row.key],
|
||||
0.001,
|
||||
playerTech[row.key],
|
||||
(v) => (cs.targetTech[row.key] = v),
|
||||
)}
|
||||
oninput={(e) =>
|
||||
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
|
||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||
title={targetError}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
/>
|
||||
</span>
|
||||
@@ -718,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
.name[aria-invalid="true"] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.create,
|
||||
.delete {
|
||||
.create {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
@@ -737,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete {
|
||||
color: var(--color-danger);
|
||||
align-self: flex-start;
|
||||
}
|
||||
.load {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -766,13 +913,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
}
|
||||
.custom-load {
|
||||
width: 4rem;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
text-align: right;
|
||||
}
|
||||
.results,
|
||||
.modern {
|
||||
@@ -802,6 +951,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.cell .val {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
@@ -809,7 +959,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
.cell input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: var(--color-bg);
|
||||
@@ -819,6 +969,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
/* Hide the native spinner on every calculator number input — the
|
||||
row drives every numeric edit through ArrowUp / ArrowDown so the
|
||||
column width is stable and the inputs read consistently with the
|
||||
ship-block row inside the design area. */
|
||||
input.no-spin::-webkit-inner-spin-button,
|
||||
input.no-spin::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.no-spin {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.cell.locked input {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
@@ -845,6 +1008,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.lock-slot {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
visibility: hidden;
|
||||
}
|
||||
.planet {
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding-top: 0.5rem;
|
||||
@@ -878,6 +1047,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
}
|
||||
.planet-stats dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
@@ -895,8 +1065,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.full-capacity {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
|
||||
design area) so the cell width stays the same whether the value is
|
||||
the inherited planet number or the player's override. */
|
||||
.mat-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -135,6 +135,56 @@ describe("computeCalculator goal-seek", () => {
|
||||
expect(result.blocks.drive).toBe(10);
|
||||
});
|
||||
|
||||
test("an attack target that back-solves to a (0, 1) weapons block is infeasible", () => {
|
||||
// weapons = targetAttack / weaponsTech; weaponsTech=1.5, a 0.5
|
||||
// target → weapons = 0.333…, which fails the DWSC rule (must be
|
||||
// 0 or ≥ 1). The lock is flagged infeasible so the UI shows the
|
||||
// red border, and the claimed block is left at its raw value so
|
||||
// the design preview keeps reading off the user's own design.
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "attack", value: 0.5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(false);
|
||||
expect(result.computedInput).toBeNull();
|
||||
// The claimed block stays at its raw value.
|
||||
expect(result.blocks.weapons).toBe(0);
|
||||
});
|
||||
|
||||
test("an empty-mass target that back-solves to a (0, 1) cargo block is infeasible", () => {
|
||||
// emptyMass = drive + shields + cargo; with drive=10 shields=5,
|
||||
// rest excluding cargo = 15. Target 15.5 → cargo = 0.5, in the
|
||||
// invalid gap, so the lock is flagged.
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "emptyMass", value: 15.5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(false);
|
||||
expect(result.computedInput).toBeNull();
|
||||
expect(result.blocks.cargo).toBe(5);
|
||||
});
|
||||
|
||||
test("speed lock is feasible at the ceiling when rest mass is zero", () => {
|
||||
// Regression for the D=1, W=A=S=C=0 case: every block except
|
||||
// drive is zero, so speed equals 20*driveTech (the ceiling); the
|
||||
// solver must accept that exact target instead of flagging it
|
||||
// as unreachable.
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({
|
||||
blocks: { drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 },
|
||||
driveTech: 1,
|
||||
lock: { output: "speedEmpty", value: 20 },
|
||||
}),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("drive");
|
||||
expect(result.outputs?.speedEmpty).toBeCloseTo(20, 9);
|
||||
});
|
||||
|
||||
test("calls the matching solver with the right context", () => {
|
||||
const weaponsForAttack = vi.fn(() => 7);
|
||||
const core = makeFakeCore({ weaponsForAttack });
|
||||
|
||||
@@ -278,4 +278,390 @@ describe("calculator-tab", () => {
|
||||
"15.273",
|
||||
);
|
||||
});
|
||||
|
||||
test("tech defaults render as a number with an open-lock affordance", () => {
|
||||
const ui = mount();
|
||||
// Default state: no override → number + open lock, no input.
|
||||
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
|
||||
"1.2",
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("calculator-tech-override-drive"),
|
||||
).toBeInTheDocument();
|
||||
expect(ui.queryByTestId("calculator-tech-drive")).toBeNull();
|
||||
});
|
||||
|
||||
test("clicking the open tech lock reveals the input + closed lock", async () => {
|
||||
const ui = mount();
|
||||
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
||||
// Now an input is rendered and the lock turned closed (reset).
|
||||
expect(ui.getByTestId("calculator-tech-drive")).toHaveValue(1.2);
|
||||
expect(ui.getByTestId("calculator-tech-reset-drive")).toBeInTheDocument();
|
||||
expect(ui.queryByTestId("calculator-tech-value-drive")).toBeNull();
|
||||
});
|
||||
|
||||
test("flags a tech override below the player's current tech", async () => {
|
||||
const ui = mount();
|
||||
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
||||
// Player drive is 1.2; setting 0.5 is below the floor.
|
||||
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
|
||||
target: { value: "0.5" },
|
||||
});
|
||||
expect(ui.getByTestId("calculator-tech-drive")).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("smart step jumps from 0 to 1 on ArrowUp for ship blocks", async () => {
|
||||
const ui = mount();
|
||||
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
|
||||
drive.focus();
|
||||
await fireEvent.keyDown(drive, { key: "ArrowUp" });
|
||||
expect(drive).toHaveValue(1);
|
||||
await fireEvent.keyDown(drive, { key: "ArrowUp" });
|
||||
expect(drive).toHaveValue(1.1);
|
||||
await fireEvent.keyDown(drive, { key: "ArrowDown" });
|
||||
expect(drive).toHaveValue(1);
|
||||
await fireEvent.keyDown(drive, { key: "ArrowDown" });
|
||||
expect(drive).toHaveValue(0);
|
||||
});
|
||||
|
||||
test("regression: speed lock works at the ceiling with all-zero non-drive blocks", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 1);
|
||||
// Override drive tech to 1 so the ceiling math is plain.
|
||||
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
||||
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
// With D=1, W=A=S=C=0 the only achievable speed is 20*driveTech=20.
|
||||
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent("20");
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
|
||||
const locked = ui.getByTestId("calculator-locked-speedEmpty");
|
||||
expect(locked).toHaveValue(20);
|
||||
// The lock is feasible — no infeasible title and no red error class.
|
||||
expect(locked).not.toHaveAttribute(
|
||||
"title",
|
||||
expect.stringMatching(/cannot be reached/i),
|
||||
);
|
||||
});
|
||||
|
||||
test("planet MAT defaults to a value + open lock and opens an input on click", async () => {
|
||||
const selection = new SelectionStore();
|
||||
selection.selectPlanet(17);
|
||||
const ui = mount({
|
||||
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||
selection,
|
||||
});
|
||||
// Initial state: the MAT shows the planet's value via the number cell
|
||||
// and an open lock; no input until the override is activated.
|
||||
expect(
|
||||
ui.getByTestId("calculator-planet-mat-value"),
|
||||
).toHaveTextContent("100");
|
||||
expect(
|
||||
ui.getByTestId("calculator-mat-override"),
|
||||
).toBeInTheDocument();
|
||||
expect(ui.queryByTestId("calculator-planet-mat")).toBeNull();
|
||||
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
|
||||
expect(ui.getByTestId("calculator-planet-mat")).toHaveValue(100);
|
||||
expect(ui.getByTestId("calculator-mat-reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("flags a modernization target below the player's current tech", async () => {
|
||||
const ui = mount();
|
||||
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
|
||||
// Player drive is 1.2; the target is seeded with the same value.
|
||||
await fireEvent.input(ui.getByTestId("calculator-target-drive"), {
|
||||
target: { value: "0.5" },
|
||||
});
|
||||
expect(ui.getByTestId("calculator-target-drive")).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("armament Arrow keys step the integer block by ±1 (clamped at 0)", async () => {
|
||||
const ui = mount();
|
||||
const armament = ui.getByTestId(
|
||||
"calculator-block-armament",
|
||||
) as HTMLInputElement;
|
||||
armament.focus();
|
||||
await fireEvent.keyDown(armament, { key: "ArrowUp" });
|
||||
expect(armament).toHaveValue(1);
|
||||
await fireEvent.keyDown(armament, { key: "ArrowUp" });
|
||||
expect(armament).toHaveValue(2);
|
||||
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
||||
expect(armament).toHaveValue(1);
|
||||
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
||||
expect(armament).toHaveValue(0);
|
||||
// Clamped at zero — another ArrowDown is a no-op.
|
||||
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
||||
expect(armament).toHaveValue(0);
|
||||
});
|
||||
|
||||
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
|
||||
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter,
|
||||
// always padded to three decimals (calculator labels are column-
|
||||
// aligned with the report).
|
||||
const ui = mount();
|
||||
const tech = ui.getByTestId("calculator-tech-value-drive");
|
||||
expect((tech.textContent ?? "").trim()).toBe("1.200");
|
||||
});
|
||||
|
||||
test("planet MAT label renders through the 3-decimal formatter", () => {
|
||||
const selection = new SelectionStore();
|
||||
selection.selectPlanet(17);
|
||||
const ui = mount({
|
||||
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||
selection,
|
||||
});
|
||||
// Planet MAT is 100 → "100.000" through the shared formatter; the
|
||||
// label is monospaced + right-aligned via the existing `.mat-val`
|
||||
// rule. Integer MAT pads to three decimals like every other label.
|
||||
const mat = ui.getByTestId("calculator-planet-mat-value");
|
||||
expect((mat.textContent ?? "").trim()).toBe("100.000");
|
||||
});
|
||||
|
||||
test("derived results pad to three decimals (integer empty mass)", async () => {
|
||||
// Integer-valued outputs read with the same trailing zeros as
|
||||
// fractional ones — column-aligned tabular display.
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
const mass = ui.getByTestId("calculator-out-emptyMass");
|
||||
expect((mass.textContent ?? "").trim()).toBe("20.000");
|
||||
});
|
||||
|
||||
test("number inputs refuse a fourth decimal as the user types", async () => {
|
||||
const selection = new SelectionStore();
|
||||
selection.selectPlanet(17);
|
||||
const ui = mount({
|
||||
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||
selection,
|
||||
});
|
||||
// MAT input: typed "12.3456" must clamp to "12.345" on input.
|
||||
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
|
||||
const mat = ui.getByTestId("calculator-planet-mat") as HTMLInputElement;
|
||||
await fireEvent.input(mat, { target: { value: "12.3456" } });
|
||||
expect(mat.value).toBe("12.345");
|
||||
expect(mat.valueAsNumber).toBeCloseTo(12.345, 9);
|
||||
|
||||
// Custom-load input on a ship with a non-zero cargo: typed
|
||||
// "1.2345" must clamp to "1.234".
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
|
||||
const load = ui.getByTestId("calculator-custom-load") as HTMLInputElement;
|
||||
await fireEvent.input(load, { target: { value: "1.2345" } });
|
||||
expect(load.value).toBe("1.234");
|
||||
});
|
||||
|
||||
test("tech and target-tech inputs cap at three decimals", async () => {
|
||||
const ui = mount();
|
||||
// Tech override input.
|
||||
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
||||
const tech = ui.getByTestId("calculator-tech-drive") as HTMLInputElement;
|
||||
await fireEvent.input(tech, { target: { value: "2.9999" } });
|
||||
expect(tech.value).toBe("2.999");
|
||||
|
||||
// Modernization target tech input.
|
||||
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
|
||||
const target = ui.getByTestId(
|
||||
"calculator-target-drive",
|
||||
) as HTMLInputElement;
|
||||
await fireEvent.input(target, { target: { value: "3.1416" } });
|
||||
expect(target.value).toBe("3.141");
|
||||
});
|
||||
|
||||
test("lock value input caps at three decimals", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
||||
const lock = ui.getByTestId(
|
||||
"calculator-locked-attack",
|
||||
) as HTMLInputElement;
|
||||
await fireEvent.input(lock, { target: { value: "0.1234" } });
|
||||
expect(lock.value).toBe("0.123");
|
||||
});
|
||||
|
||||
test("ship-block input caps at three decimals", async () => {
|
||||
const ui = mount();
|
||||
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
|
||||
await fireEvent.input(drive, { target: { value: "1.2345" } });
|
||||
expect(drive.value).toBe("1.234");
|
||||
});
|
||||
|
||||
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
||||
const locked = ui.getByTestId(
|
||||
"calculator-locked-attack",
|
||||
) as HTMLInputElement;
|
||||
// Lock value is seeded from outputs.attack (0 with no weapons).
|
||||
const start = Number(locked.value);
|
||||
locked.focus();
|
||||
await fireEvent.keyDown(locked, { key: "ArrowUp" });
|
||||
expect(Number(locked.value)).toBeCloseTo(start + 0.001, 9);
|
||||
await fireEvent.keyDown(locked, { key: "ArrowDown" });
|
||||
expect(Number(locked.value)).toBeCloseTo(start, 9);
|
||||
});
|
||||
|
||||
test("flags the lock as infeasible when the back-solved block falls in (0, 1)", async () => {
|
||||
// attack lock → weapons = targetAttack / weaponsTech. weaponsTech
|
||||
// is 1.5; a target of 0.5 would force weapons = 0.333… which
|
||||
// fails the DWSC rule (must be 0 or ≥ 1).
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "armament", 2);
|
||||
await setBlock(ui, "weapons", 5);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
||||
await fireEvent.input(ui.getByTestId("calculator-locked-attack"), {
|
||||
target: { value: "0.5" },
|
||||
});
|
||||
const locked = ui.getByTestId("calculator-locked-attack");
|
||||
expect(locked).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringMatching(/cannot be reached/i),
|
||||
);
|
||||
// The claimed block is not back-solved into the invalid (0, 1)
|
||||
// range — the weapons input keeps the user's typed value (5).
|
||||
expect(ui.getByTestId("calculator-block-weapons")).toHaveValue(5);
|
||||
});
|
||||
|
||||
test("dropdown selection loads the class immediately (no blur needed)", async () => {
|
||||
const ui = mount({
|
||||
report: makeReport({
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Scout",
|
||||
drive: 3,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 2,
|
||||
cargo: 1,
|
||||
},
|
||||
],
|
||||
} as unknown as GameReport),
|
||||
});
|
||||
// A datalist option click sets the whole value at once — Firefox
|
||||
// reports no `inputType`, Chromium reports "insertReplacementText".
|
||||
// Simulate the latter; the calculator should load before any
|
||||
// `change` event.
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Scout" },
|
||||
inputType: "insertReplacementText",
|
||||
});
|
||||
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
||||
expect(ui.getByTestId("calculator-block-shields")).toHaveValue(2);
|
||||
});
|
||||
|
||||
test("dropdown selection asks before discarding manual edits", async () => {
|
||||
const ui = mount({
|
||||
report: makeReport({
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Scout",
|
||||
drive: 3,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 2,
|
||||
cargo: 1,
|
||||
},
|
||||
],
|
||||
} as unknown as GameReport),
|
||||
});
|
||||
// The user has hand-edited the design.
|
||||
await setBlock(ui, "drive", 7);
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Scout" },
|
||||
inputType: "insertReplacementText",
|
||||
});
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
// The user said no — the manual edits stay.
|
||||
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(7);
|
||||
// The name field is reverted to the previously loaded class (or
|
||||
// empty), so the field does not pretend the load happened.
|
||||
expect(ui.getByTestId("calculator-name")).toHaveValue("");
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Scout" },
|
||||
inputType: "insertReplacementText",
|
||||
});
|
||||
// Confirmed — the class is now loaded.
|
||||
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
test("dropdown selection loads silently when the design is clean", async () => {
|
||||
const ui = mount({
|
||||
report: makeReport({
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Scout",
|
||||
drive: 3,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 2,
|
||||
cargo: 1,
|
||||
},
|
||||
],
|
||||
} as unknown as GameReport),
|
||||
});
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Scout" },
|
||||
inputType: "insertReplacementText",
|
||||
});
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
test("does not render a delete-class button after loading a class", async () => {
|
||||
const ui = mount({
|
||||
report: makeReport({
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Scout",
|
||||
drive: 3,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 2,
|
||||
cargo: 1,
|
||||
},
|
||||
],
|
||||
} as unknown as GameReport),
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Scout" },
|
||||
inputType: "insertReplacementText",
|
||||
});
|
||||
// The loaded class state used to render a `delete <name>` button;
|
||||
// the calculator no longer owns delete-class — issue #53 will.
|
||||
expect(ui.queryByTestId("calculator-delete")).toBeNull();
|
||||
});
|
||||
|
||||
test("bombing and cargo-capacity rows reserve the lock slot for column alignment", () => {
|
||||
const ui = mount();
|
||||
for (const id of ["calculator-out-bombing", "calculator-out-cargo-capacity"]) {
|
||||
const cell = ui.getByTestId(id).parentElement;
|
||||
expect(cell).not.toBeNull();
|
||||
// A hidden placeholder occupies the same width as the lock button
|
||||
// on the mass/speed/attack/defence rows, so the value column does
|
||||
// not drift right on the rows without a lock.
|
||||
expect(cell!.querySelector(".lock-slot")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,9 @@ test("calculator draws reach circles for the selected planet", async ({
|
||||
await calc.getByTestId("calculator-block-drive").fill("10");
|
||||
await calc.getByTestId("calculator-block-shields").fill("5");
|
||||
await calc.getByTestId("calculator-block-cargo").fill("5");
|
||||
// Tech defaults render as a number + open lock; click to reveal the
|
||||
// input before typing an override (the F8-06 unified lock idiom).
|
||||
await calc.getByTestId("calculator-tech-override-drive").click();
|
||||
await calc.getByTestId("calculator-tech-drive").fill("1.2");
|
||||
|
||||
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
|
||||
|
||||
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
|
||||
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
|
||||
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
|
||||
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
|
||||
if (driveTech <= 0 || targetSpeed <= 0) return null;
|
||||
const ceiling = 20 * driveTech;
|
||||
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
|
||||
return null;
|
||||
if (restMass <= 0) {
|
||||
if (targetSpeed !== ceiling) return null;
|
||||
return 1;
|
||||
}
|
||||
if (targetSpeed >= ceiling) return null;
|
||||
return (targetSpeed * restMass) / (ceiling - targetSpeed);
|
||||
},
|
||||
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {
|
||||
|
||||
Reference in New Issue
Block a user