Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build) #24
@@ -0,0 +1,19 @@
|
|||||||
|
package calc
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// Ceil3 rounds num UP to three decimal places. The ship-class
|
||||||
|
// calculator displays every computed value (and every goal-seek
|
||||||
|
// back-solved input) through this so a result is never shown lower than
|
||||||
|
// it really is — e.g. a speed of 5.0003 reads as 5.001, not 5.000, which
|
||||||
|
// matters when a fraction of a light-year decides whether a ship clears
|
||||||
|
// the gap to a planet. It is display-only and lives here (rather than in
|
||||||
|
// the engine's round-to-nearest util.Fixed*) so the UI bridge can reach
|
||||||
|
// the one implementation through WASM.
|
||||||
|
//
|
||||||
|
// num is pre-rounded to nine decimals before the ceil so float64
|
||||||
|
// representation noise does not push an exact value up a step (e.g. a
|
||||||
|
// computed 5.0 stored as 5.0000000002 stays 5.0).
|
||||||
|
func Ceil3(num float64) float64 {
|
||||||
|
return math.Ceil(math.Round(num*1e9)/1e6) / 1000
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package calc
|
||||||
|
|
||||||
|
import "galaxy/calc"
|
||||||
|
|
||||||
|
// Ceil3 wraps `calc.Ceil3` (`pkg/calc/number.go`): round up to three
|
||||||
|
// decimal places. The calculator formats every displayed number through
|
||||||
|
// this bridge so the UI and the canonical Go implementation agree.
|
||||||
|
func Ceil3(num float64) float64 {
|
||||||
|
return calc.Ceil3(num)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package calc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
source "galaxy/calc"
|
||||||
|
bridge "galaxy/core/calc"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCeil3Parity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []float64{0, 5, 5.0003, 4.2761, 139.29503, 0.0001, 1.9999999998}
|
||||||
|
for _, c := range cases {
|
||||||
|
assert.Equal(t, source.Ceil3(c), bridge.Ceil3(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeil3Values(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assert.Equal(t, 5.0, source.Ceil3(5.0))
|
||||||
|
assert.Equal(t, 5.001, source.Ceil3(5.0003))
|
||||||
|
assert.Equal(t, 4.277, source.Ceil3(4.2761))
|
||||||
|
// Float noise just above an exact step stays put.
|
||||||
|
assert.Equal(t, 5.0, source.Ceil3(5.0000000002))
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ on the JS-side `globalThis.galaxyCore` (registered in
|
|||||||
| `shieldsForDefence` | `calc.ShieldsForDefence(targetDefence, sTech, restMass)` | `number\|null` | calculator goal-seek (defence → shields) |
|
| `shieldsForDefence` | `calc.ShieldsForDefence(targetDefence, sTech, restMass)` | `number\|null` | calculator goal-seek (defence → shields) |
|
||||||
| `cargoForEmptyMass` | `calc.CargoForEmptyMass(targetEmptyMass, restMass)` | `number\|null` | calculator goal-seek (mass → cargo) |
|
| `cargoForEmptyMass` | `calc.CargoForEmptyMass(targetEmptyMass, restMass)` | `number\|null` | calculator goal-seek (mass → cargo) |
|
||||||
| `loadForFullMass` | `calc.LoadForFullMass(targetFullMass, emptyMass, cTech)` | `number\|null` | calculator goal-seek (loaded mass → load)|
|
| `loadForFullMass` | `calc.LoadForFullMass(targetFullMass, emptyMass, cTech)` | `number\|null` | calculator goal-seek (loaded mass → load)|
|
||||||
|
| `ceil3` | `calc.Ceil3(value)` (`pkg/calc/number.go`) | `number` | calculator display rounding (round up to 3 dp) |
|
||||||
|
|
||||||
`BombingPower` and the per-turn build loop are no longer engine-only:
|
`BombingPower` and the per-turn build loop are no longer engine-only:
|
||||||
Phase 30 extracted `BombingPower` from
|
Phase 30 extracted `BombingPower` from
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ in as a per-ship result rather than a separate mode.
|
|||||||
icon once overridden; clicking it resets to the default.
|
icon once overridden; clicking it resets to the default.
|
||||||
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
||||||
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
||||||
A load toggle (empty / full / custom) sets the cargo load that the
|
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
||||||
loaded-column results use.
|
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.
|
||||||
3. **Planet area** — when an own planet is selected on the map, shows
|
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,
|
its MAT (overridable) and the single-turn build rate (ships per turn,
|
||||||
turns per ship). The realistic multi-turn forecast with CAP/COL
|
turns per ship). The realistic multi-turn forecast with CAP/COL
|
||||||
@@ -61,7 +64,24 @@ appears once a value is pinned, click to release):
|
|||||||
solved block that fails the value rules — leaves the locked cell in 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
|
red error state and does not apply. Inverse solving lives in
|
||||||
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
||||||
non-analytic case.
|
non-analytic case. Locking a speed is disabled when the drive block is
|
||||||
|
zero (a deliberately immobile ship has no speed to back-solve).
|
||||||
|
|
||||||
|
## Validation and display
|
||||||
|
|
||||||
|
Every numeric input is validated independently and an offending one gets
|
||||||
|
a red border and a hover/tap tooltip with the reason: no value may be
|
||||||
|
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.
|
||||||
|
|
||||||
## Create / load / delete
|
## Create / load / delete
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ The component is presentational — the parent owns the state and the
|
|||||||
calculator math — so the ship-group upgrade flow can reuse it later.
|
calculator math — so the ship-group upgrade flow can reuse it later.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
shipClassFieldErrors,
|
||||||
|
type ShipClassValueInvalidReason,
|
||||||
|
} from "$lib/util/ship-class-validation";
|
||||||
import type { ClaimedInput } from "./calc-model";
|
import type { ClaimedInput } from "./calc-model";
|
||||||
|
|
||||||
export interface DesignBlocksState {
|
export interface DesignBlocksState {
|
||||||
@@ -49,6 +53,29 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
onResetTech,
|
onResetTech,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const VALUE_REASON_KEY: Record<ShipClassValueInvalidReason, TranslationKey> = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-block validity (independent of which one failed first) so every
|
||||||
|
// invalid input is highlighted, not only the first.
|
||||||
|
const blockErrors = $derived(shipClassFieldErrors(blocks));
|
||||||
|
|
||||||
|
function blockError(key: keyof DesignBlocksState): string {
|
||||||
|
const reason = blockErrors[key];
|
||||||
|
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 BLOCK_ROWS: {
|
const BLOCK_ROWS: {
|
||||||
key: keyof DesignBlocksState;
|
key: keyof DesignBlocksState;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
@@ -92,6 +119,8 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
min="0"
|
min="0"
|
||||||
bind:value={blocks[row.key]}
|
bind:value={blocks[row.key]}
|
||||||
readonly={blocksReadonly}
|
readonly={blocksReadonly}
|
||||||
|
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||||
|
title={blockError(row.key)}
|
||||||
data-testid={`calculator-block-${row.key}`}
|
data-testid={`calculator-block-${row.key}`}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -105,6 +134,8 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
min="0"
|
min="0"
|
||||||
bind:value={techs[techKey]}
|
bind:value={techs[techKey]}
|
||||||
oninput={() => onTechInput(techKey)}
|
oninput={() => onTechInput(techKey)}
|
||||||
|
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||||
|
title={techError(techKey)}
|
||||||
data-testid={`calculator-tech-${techKey}`}
|
data-testid={`calculator-tech-${techKey}`}
|
||||||
/>
|
/>
|
||||||
{#if techOverridden[techKey]}
|
{#if techOverridden[techKey]}
|
||||||
@@ -167,6 +198,9 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
color: #9fb0ff;
|
color: #9fb0ff;
|
||||||
background: #11162a;
|
background: #11162a;
|
||||||
}
|
}
|
||||||
|
input[aria-invalid="true"] {
|
||||||
|
border-color: #d97a7a;
|
||||||
|
}
|
||||||
.tech-cell {
|
.tech-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -395,6 +395,10 @@ const en = {
|
|||||||
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
"game.calculator.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
||||||
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
"game.calculator.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
||||||
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
|
"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.load_over_capacity": "load exceeds the ship's cargo capacity",
|
||||||
|
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
||||||
|
|
||||||
"game.table.sciences.title": "sciences",
|
"game.table.sciences.title": "sciences",
|
||||||
"game.table.sciences.column.name": "name",
|
"game.table.sciences.column.name": "name",
|
||||||
|
|||||||
@@ -396,6 +396,10 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
"game.calculator.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||||
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
"game.calculator.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||||
|
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
||||||
|
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
||||||
|
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||||
|
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||||
|
|
||||||
"game.table.sciences.title": "науки",
|
"game.table.sciences.title": "науки",
|
||||||
"game.table.sciences.column.name": "название",
|
"game.table.sciences.column.name": "название",
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// With no cargo block there is no hold to load: pin the load to empty
|
||||||
|
// and disable the toggle.
|
||||||
|
const cargoEmpty = $derived(cs.blocks.cargo === 0);
|
||||||
|
$effect(() => {
|
||||||
|
if (cargoEmpty && cs.loadMode !== "empty") cs.loadMode = "empty";
|
||||||
|
});
|
||||||
|
|
||||||
const planetBuild = $derived.by(() => {
|
const planetBuild = $derived.by(() => {
|
||||||
if (selectedPlanet === null) return null;
|
if (selectedPlanet === null) return null;
|
||||||
const emptyMass = result.outputs?.emptyMass;
|
const emptyMass = result.outputs?.emptyMass;
|
||||||
@@ -228,11 +235,54 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
return { perBlock, total };
|
return { perBlock, total };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Display every computed number rounded up to three decimals via the
|
||||||
|
// shared `Ceil3` bridge, so a value is never shown lower than it is.
|
||||||
function fmt(value: number | null | undefined): string {
|
function fmt(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return i18n.t("game.calculator.unavailable");
|
return i18n.t("game.calculator.unavailable");
|
||||||
}
|
}
|
||||||
return value.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
const rounded = core !== null ? core.ceil3({ value }) : value;
|
||||||
|
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The goal-seek back-solved block, shown in its read-only cell, is
|
||||||
|
// ceiled the same way (only the claimed block's cell is displayed).
|
||||||
|
const resolvedCeil = $derived.by(() => {
|
||||||
|
if (core === null) return result.blocks;
|
||||||
|
const c = (v: number) => core.ceil3({ value: v });
|
||||||
|
return {
|
||||||
|
drive: c(result.blocks.drive),
|
||||||
|
armament: result.blocks.armament,
|
||||||
|
weapons: c(result.blocks.weapons),
|
||||||
|
shields: c(result.blocks.shields),
|
||||||
|
cargo: c(result.blocks.cargo),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// A custom load must stay within [0, cargo capacity]; beyond that the
|
||||||
|
// ship cannot hold it.
|
||||||
|
const customLoadError = $derived.by(() => {
|
||||||
|
if (cs.loadMode !== "custom") return "";
|
||||||
|
if (cs.customLoad < 0) return i18n.t("game.calculator.invalid.negative");
|
||||||
|
if (cs.customLoad > result.cargoCapacity) {
|
||||||
|
return i18n.t("game.calculator.invalid.load_over_capacity");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const matError = $derived(
|
||||||
|
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if (
|
||||||
|
(output === "speedEmpty" || output === "speedLoaded") &&
|
||||||
|
cs.blocks.drive === 0
|
||||||
|
) {
|
||||||
|
return i18n.t("game.calculator.lock.no_drive");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTechInput(key: TechKey): void {
|
function onTechInput(key: TechKey): void {
|
||||||
@@ -346,15 +396,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const extra = lockDisabledReason(output)}
|
||||||
<span class="cell">
|
<span class="cell">
|
||||||
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
|
<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lock"
|
class="lock"
|
||||||
disabled={cs.lock !== null || value === undefined}
|
disabled={cs.lock !== null || value === undefined || extra !== ""}
|
||||||
title={cs.lock !== null
|
title={cs.lock !== null
|
||||||
? i18n.t("game.calculator.lock.max")
|
? i18n.t("game.calculator.lock.max")
|
||||||
: `${LOCK_LABELS[output]}`}
|
: extra !== ""
|
||||||
|
? extra
|
||||||
|
: LOCK_LABELS[output]}
|
||||||
aria-label={LOCK_LABELS[output]}
|
aria-label={LOCK_LABELS[output]}
|
||||||
data-testid={`calculator-lock-${output}`}
|
data-testid={`calculator-lock-${output}`}
|
||||||
onclick={() => lockOutput(output)}
|
onclick={() => lockOutput(output)}
|
||||||
@@ -429,7 +482,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
|
|
||||||
<ShipDesignArea
|
<ShipDesignArea
|
||||||
bind:blocks={cs.blocks}
|
bind:blocks={cs.blocks}
|
||||||
resolved={result.blocks}
|
resolved={resolvedCeil}
|
||||||
bind:techs={cs.techValues}
|
bind:techs={cs.techValues}
|
||||||
techOverridden={cs.techOverridden}
|
techOverridden={cs.techOverridden}
|
||||||
computedInput={result.computedInput}
|
computedInput={result.computedInput}
|
||||||
@@ -445,6 +498,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={cs.loadMode === m}
|
class:active={cs.loadMode === m}
|
||||||
|
disabled={cargoEmpty}
|
||||||
data-testid={`calculator-load-${m}`}
|
data-testid={`calculator-load-${m}`}
|
||||||
onclick={() => (cs.loadMode = m)}
|
onclick={() => (cs.loadMode = m)}
|
||||||
>
|
>
|
||||||
@@ -459,8 +513,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
min="0"
|
min="0"
|
||||||
class="custom-load"
|
class="custom-load"
|
||||||
bind:value={cs.customLoad}
|
bind:value={cs.customLoad}
|
||||||
|
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||||
|
title={customLoadError}
|
||||||
data-testid="calculator-custom-load"
|
data-testid="calculator-custom-load"
|
||||||
/>
|
/>
|
||||||
|
{:else if cs.loadMode === "full"}
|
||||||
|
<span
|
||||||
|
class="full-capacity"
|
||||||
|
title={i18n.t("game.calculator.out.cargo_capacity")}
|
||||||
|
data-testid="calculator-full-capacity"
|
||||||
|
>
|
||||||
|
{fmt(result.cargoCapacity)}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -531,6 +595,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
min="0"
|
min="0"
|
||||||
bind:value={cs.matValue}
|
bind:value={cs.matValue}
|
||||||
oninput={onMatInput}
|
oninput={onMatInput}
|
||||||
|
aria-invalid={matError !== "" ? "true" : "false"}
|
||||||
|
title={matError}
|
||||||
data-testid="calculator-planet-mat"
|
data-testid="calculator-planet-mat"
|
||||||
/>
|
/>
|
||||||
{#if cs.matOverridden}
|
{#if cs.matOverridden}
|
||||||
@@ -580,6 +646,10 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
step="0.001"
|
step="0.001"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={cs.targetTech[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")
|
||||||
|
: ""}
|
||||||
data-testid={`calculator-target-${row.key}`}
|
data-testid={`calculator-target-${row.key}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -592,7 +662,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
{/each}
|
{/each}
|
||||||
<div class="rrow total">
|
<div class="rrow total">
|
||||||
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
|
<span class="label">{i18n.t("game.calculator.modern.total")}</span>
|
||||||
<span></span>
|
|
||||||
<span class="cell">
|
<span class="cell">
|
||||||
<span class="val" data-testid="calculator-modern-total">
|
<span class="val" data-testid="calculator-modern-total">
|
||||||
{fmt(modernCosts?.total)}
|
{fmt(modernCosts?.total)}
|
||||||
@@ -814,6 +883,20 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.rrow.total .label {
|
.rrow.total .label {
|
||||||
|
grid-column: 1 / 3;
|
||||||
color: #cdd3f0;
|
color: #cdd3f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
input[aria-invalid="true"] {
|
||||||
|
border-color: #d97a7a;
|
||||||
|
}
|
||||||
|
.seg button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.full-capacity {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9fb0ff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -170,3 +170,35 @@ function isValidDWSC(value: number): boolean {
|
|||||||
if (!Number.isFinite(value)) return false;
|
if (!Number.isFinite(value)) return false;
|
||||||
return value === 0 || value >= 1;
|
return value === 0 || value >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shipClassFieldErrors returns the invalid reason for each offending
|
||||||
|
* block, independently, so the calculator can mark every bad input
|
||||||
|
* (not just the first failure `validateShipClassValues` reports). The
|
||||||
|
* weapons/armament pairing rule flags both fields. The all-zero rule is
|
||||||
|
* a whole-design condition and is left to `validateShipClassValues`.
|
||||||
|
*/
|
||||||
|
export function shipClassFieldErrors(
|
||||||
|
values: ShipClassValues,
|
||||||
|
): Partial<Record<keyof ShipClassValues, ShipClassValueInvalidReason>> {
|
||||||
|
const errors: Partial<
|
||||||
|
Record<keyof ShipClassValues, ShipClassValueInvalidReason>
|
||||||
|
> = {};
|
||||||
|
if (!isValidDWSC(values.drive)) errors.drive = "drive_value";
|
||||||
|
if (!Number.isFinite(values.armament) || values.armament < 0) {
|
||||||
|
errors.armament = "armament_value";
|
||||||
|
} else if (!Number.isInteger(values.armament)) {
|
||||||
|
errors.armament = "armament_not_integer";
|
||||||
|
}
|
||||||
|
if (!isValidDWSC(values.weapons)) errors.weapons = "weapons_value";
|
||||||
|
if (!isValidDWSC(values.shields)) errors.shields = "shields_value";
|
||||||
|
if (!isValidDWSC(values.cargo)) errors.cargo = "cargo_value";
|
||||||
|
if (
|
||||||
|
(values.armament === 0 && values.weapons !== 0) ||
|
||||||
|
(values.armament !== 0 && values.weapons === 0)
|
||||||
|
) {
|
||||||
|
errors.weapons ??= "armament_weapons_pair";
|
||||||
|
errors.armament ??= "armament_weapons_pair";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ export interface LoadForFullMassInput {
|
|||||||
cargoTech: number;
|
cargoTech: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Ceil3Input {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Core {
|
export interface Core {
|
||||||
/**
|
/**
|
||||||
* signRequest returns the canonical signing input bytes for a v1
|
* signRequest returns the canonical signing input bytes for a v1
|
||||||
@@ -311,6 +315,13 @@ export interface Core {
|
|||||||
* below the empty mass.
|
* below the empty mass.
|
||||||
*/
|
*/
|
||||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ceil3 wraps `pkg/calc/number.go.Ceil3`: round a value up to three
|
||||||
|
* decimal places, for display so a computed result is never shown
|
||||||
|
* lower than it is.
|
||||||
|
*/
|
||||||
|
ceil3(input: Ceil3Input): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoreLoader = () => Promise<Core>;
|
export type CoreLoader = () => Promise<Core>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BlockUpgradeCostInput,
|
BlockUpgradeCostInput,
|
||||||
BombingPowerInput,
|
BombingPowerInput,
|
||||||
|
Ceil3Input,
|
||||||
CargoCapacityInput,
|
CargoCapacityInput,
|
||||||
CargoForEmptyMassInput,
|
CargoForEmptyMassInput,
|
||||||
CarryingMassInput,
|
CarryingMassInput,
|
||||||
@@ -73,6 +74,7 @@ interface GalaxyCoreBridge {
|
|||||||
shieldsForDefence(input: ShieldsForDefenceInput): number | null;
|
shieldsForDefence(input: ShieldsForDefenceInput): number | null;
|
||||||
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null;
|
cargoForEmptyMass(input: CargoForEmptyMassInput): number | null;
|
||||||
loadForFullMass(input: LoadForFullMassInput): number | null;
|
loadForFullMass(input: LoadForFullMassInput): number | null;
|
||||||
|
ceil3(input: Ceil3Input): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BridgeRequestFields {
|
interface BridgeRequestFields {
|
||||||
@@ -266,6 +268,9 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
|||||||
loadForFullMass(input: LoadForFullMassInput): number | null {
|
loadForFullMass(input: LoadForFullMassInput): number | null {
|
||||||
return bridge.loadForFullMass(input);
|
return bridge.loadForFullMass(input);
|
||||||
},
|
},
|
||||||
|
ceil3(input: Ceil3Input): number {
|
||||||
|
return bridge.ceil3(input);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -211,4 +211,71 @@ describe("calculator-tab", () => {
|
|||||||
ui.getByTestId("calculator-ships-per-turn"),
|
ui.getByTestId("calculator-ships-per-turn"),
|
||||||
).not.toHaveTextContent("—");
|
).not.toHaveTextContent("—");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("zero cargo disables the load toggle", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 10);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 0);
|
||||||
|
expect(ui.getByTestId("calculator-load-full")).toBeDisabled();
|
||||||
|
expect(ui.getByTestId("calculator-load-custom")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full load shows the cargo capacity", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 10);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 5);
|
||||||
|
// A fresh design starts with cargo 0, which pins load to empty;
|
||||||
|
// pick full now that there is a hold.
|
||||||
|
await fireEvent.click(ui.getByTestId("calculator-load-full"));
|
||||||
|
// capacity = cargoTech(1) * (5 + 25/20) = 6.25.
|
||||||
|
expect(ui.getByTestId("calculator-full-capacity")).toHaveTextContent("6.25");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flags a custom load above cargo capacity", 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-load-custom"));
|
||||||
|
await fireEvent.input(ui.getByTestId("calculator-custom-load"), {
|
||||||
|
target: { value: "100" },
|
||||||
|
});
|
||||||
|
expect(ui.getByTestId("calculator-custom-load")).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks an invalid block value with aria-invalid", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
// 0.5 is neither 0 nor ≥ 1.
|
||||||
|
await setBlock(ui, "drive", 0.5);
|
||||||
|
expect(ui.getByTestId("calculator-block-drive")).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables the speed lock when drive is zero", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 0);
|
||||||
|
await setBlock(ui, "shields", 5);
|
||||||
|
await setBlock(ui, "cargo", 5);
|
||||||
|
expect(ui.getByTestId("calculator-lock-speedEmpty")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays computed values rounded up to three decimals", async () => {
|
||||||
|
const ui = mount();
|
||||||
|
await setBlock(ui, "drive", 7);
|
||||||
|
await setBlock(ui, "shields", 3);
|
||||||
|
await setBlock(ui, "cargo", 1);
|
||||||
|
// empty mass = 11; max speed = 11 * driveTech... use a value that is
|
||||||
|
// not already 3-decimal: speedEmpty = 20*7*1.2 / 11 = 15.2727…
|
||||||
|
// ceil to 3 → 15.273.
|
||||||
|
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent(
|
||||||
|
"15.273",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
|
|||||||
cargoTech <= 0 || targetFullMass < emptyMass
|
cargoTech <= 0 || targetFullMass < emptyMass
|
||||||
? null
|
? null
|
||||||
: (targetFullMass - emptyMass) * cargoTech,
|
: (targetFullMass - emptyMass) * cargoTech,
|
||||||
|
ceil3: ({ value }) => Math.ceil(Math.round(value * 1e9) / 1e6) / 1000,
|
||||||
};
|
};
|
||||||
return { ...base, ...overrides };
|
return { ...base, ...overrides };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,10 @@ describe("WasmCore calculator bridge (Phase 30)", () => {
|
|||||||
core.driveForSpeed({ targetSpeed: 100, driveTech: 1.2, restMass: 35 }),
|
core.driveForSpeed({ targetSpeed: 100, driveTech: 1.2, restMass: 35 }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ceil3 rounds up to three decimals", () => {
|
||||||
|
expect(core.ceil3({ value: 5.0003 })).toBeCloseTo(5.001, 9);
|
||||||
|
expect(core.ceil3({ value: 4.2761 })).toBeCloseTo(4.277, 9);
|
||||||
|
expect(core.ceil3({ value: 5 })).toBeCloseTo(5, 9);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ func main() {
|
|||||||
"shieldsForDefence": js.FuncOf(shieldsForDefence),
|
"shieldsForDefence": js.FuncOf(shieldsForDefence),
|
||||||
"cargoForEmptyMass": js.FuncOf(cargoForEmptyMass),
|
"cargoForEmptyMass": js.FuncOf(cargoForEmptyMass),
|
||||||
"loadForFullMass": js.FuncOf(loadForFullMass),
|
"loadForFullMass": js.FuncOf(loadForFullMass),
|
||||||
|
"ceil3": js.FuncOf(ceil3),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Block forever so the Go runtime stays alive while JS keeps calling
|
// Block forever so the Go runtime stays alive while JS keeps calling
|
||||||
@@ -420,6 +421,15 @@ func loadForFullMass(_ js.Value, args []js.Value) any {
|
|||||||
return js.ValueOf(v)
|
return js.ValueOf(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ceil3 bridges `calc.Ceil3`. Input `{ value }`, output a JS number
|
||||||
|
// rounded up to three decimal places.
|
||||||
|
func ceil3(_ js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return js.Null()
|
||||||
|
}
|
||||||
|
return js.ValueOf(calc.Ceil3(args[0].Get("value").Float()))
|
||||||
|
}
|
||||||
|
|
||||||
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
|
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
|
||||||
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
|
||||||
// because TinyGo's implementation panics on values it does not
|
// because TinyGo's implementation panics on values it does not
|
||||||
|
|||||||
Reference in New Issue
Block a user