Files
galaxy-game/ui/frontend/src/lib/inspectors/ship-group.svelte
T
Ilia Denisov b31d9f4c45
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run
fix(ui): F8-08 unified number format — mono, fixed 3-decimal, no separators
Engine emits Floats at Fixed3 quantisation; UI now renders them as 3-decimal
fixed-point strings without thousand separators, monospaced via var(--font-mono)
on .numeric cells, and right-aligned in tables so columns line up on the
decimal point. Integer counts render with 0 decimals and no separators;
science fractions render as 1-decimal percent (matches the engine's third
decimal of precision).

Bug fixes from #51 (umbrella #43):
  - Player Status drive/weapons/shields/cargo: were tech LEVELS rendered
    through formatPercent (x100) — now use formatFloat (raw level).
  - Races table: same bug, same fix.

Style/UX cleanups:
  - Inspector field labels lose "stockpile" word ($ / M suffix carries it).
  - Coordinates drop the parentheses (just "x, y").
  - Inspector + report tables unify font sizes with calculator-tab
    (values 0.85rem mono, labels 0.8rem).

Files:
  - new util: ui/frontend/src/lib/util/number-format.ts
  - report/format.ts becomes a thin re-export to keep section imports compact
  - inspector planet / ship-group / actions: drop inline formatNumber,
    mark numeric <dd> with class="numeric"
  - table-races (+ bug fix), table-sciences, table-ship-classes,
    designer-science: drop inline formatters, switch to util, add
    class="numeric" on numeric <th>/<td>
  - 17 report section files: class="numeric" on numeric th/td +
    scoped CSS rule for mono+right-align
  - i18n en/ru: drop "stockpile" word, drop "%" from tech-level column
    headers in races + player_status (the "%" was the misleading bit
    from the bug)
  - tests/inspector-planet + tests/table-races: update assertions to
    match the new format

Verification: pnpm test (814 passed), pnpm check (0 errors/warnings),
pnpm build clean.

Refs: #51 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:08:22 +02:00

304 lines
9.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Phase 19 read-only ship-group inspector. Renders the documented field
set per group variant (local / other / incoming / unidentified). The
inspector is mounted by the sidebar `inspector-tab.svelte` when the
selection has `kind === "shipGroup"`, and on the mobile breakpoint by
the `ship-group-sheet.svelte` overlay.
Phase 20 will fold action buttons (split / send / load / unload /
modernize / dismantle / transfer / assign-to-fleet) onto the local
variant — for Phase 19 the inspector is intentionally read-only.
-->
<script lang="ts">
import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { formatFloat } from "$lib/util/number-format";
import Actions from "./ship-group/actions.svelte";
export type ShipGroupSelection =
| { variant: "local"; group: ReportLocalShipGroup }
| { variant: "other"; group: ReportOtherShipGroup }
| { variant: "incoming"; group: ReportIncomingShipGroup }
| { variant: "unidentified"; group: ReportUnidentifiedShipGroup };
type Props = {
selection: ShipGroupSelection;
planets: ReportPlanet[];
localShipClass?: ShipClassSummary[];
localFleets?: ReportLocalFleet[];
otherRaces?: string[];
mapWidth?: number;
mapHeight?: number;
localPlayerDrive?: number;
localPlayerWeapons?: number;
localPlayerShields?: number;
localPlayerCargo?: number;
};
let {
selection,
planets,
localShipClass = [],
localFleets = [],
otherRaces = [],
mapWidth = 1,
mapHeight = 1,
localPlayerDrive = 0,
localPlayerWeapons = 0,
localPlayerShields = 0,
localPlayerCargo = 0,
}: Props = $props();
const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = {
local: "game.inspector.ship_group.kind.local",
other: "game.inspector.ship_group.kind.other",
incoming: "game.inspector.ship_group.kind.incoming",
unidentified: "game.inspector.ship_group.kind.unidentified",
};
const cargoLoadKeyMap = {
COL: "game.inspector.ship_group.cargo.col",
CAP: "game.inspector.ship_group.cargo.cap",
MAT: "game.inspector.ship_group.cargo.mat",
EMP: "game.inspector.ship_group.cargo.emp",
} as const;
const planetIndex = $derived.by(() => {
const m = new Map<number, ReportPlanet>();
for (const p of planets) m.set(p.number, p);
return m;
});
function planetLabel(number: number): string {
const planet = planetIndex.get(number);
if (planet === undefined) return `#${number}`;
if (planet.name === "" || planet.name === undefined) return `#${number}`;
return planet.name;
}
function cargoLabel(cargo: "NONE" | "COL" | "CAP" | "MAT" | "EMP"): string {
if (cargo === "NONE") {
return i18n.t("game.inspector.ship_group.cargo.none");
}
return i18n.t(cargoLoadKeyMap[cargo]);
}
const kindLabel = $derived(i18n.t(kindKeyMap[selection.variant]));
</script>
<section
class="inspector"
data-testid="inspector-ship-group"
data-variant={selection.variant}
>
<header>
<p class="kind" data-testid="inspector-ship-group-kind">{kindLabel}</p>
{#if selection.variant === "local" || selection.variant === "other"}
<h3 class="name" data-testid="inspector-ship-group-class">
{selection.group.class}
</h3>
{/if}
</header>
{#if selection.variant === "local"}
<Actions
group={selection.group}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{/if}
{#if selection.variant === "local" || selection.variant === "other"}
{@const g = selection.group}
{@const onPlanet = g.origin === null || g.range === null}
<dl class="fields">
<div class="field" data-testid="inspector-ship-group-field-count">
<dt>{i18n.t("game.inspector.ship_group.field.count")}</dt>
<dd class="numeric">{g.count}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-drive">
<dt>{i18n.t("game.inspector.ship_group.field.drive")}</dt>
<dd class="numeric">{formatFloat(g.tech.drive)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-weapons">
<dt>{i18n.t("game.inspector.ship_group.field.weapons")}</dt>
<dd class="numeric">{formatFloat(g.tech.weapons)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-shields">
<dt>{i18n.t("game.inspector.ship_group.field.shields")}</dt>
<dd class="numeric">{formatFloat(g.tech.shields)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-tech">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_tech")}</dt>
<dd class="numeric">{formatFloat(g.tech.cargo)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd class="numeric">{formatFloat(g.mass)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-load">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_load")}</dt>
<dd>
{#if g.cargo === "NONE"}
{cargoLabel(g.cargo)}
{:else}
{cargoLabel(g.cargo)} × <span class="numeric">{formatFloat(g.load)}</span>
{/if}
</dd>
</div>
{#if onPlanet}
<div class="field" data-testid="inspector-ship-group-field-location">
<dt>{i18n.t("game.inspector.ship_group.field.location")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
{:else}
<div class="field" data-testid="inspector-ship-group-field-from">
<dt>{i18n.t("game.inspector.ship_group.field.from")}</dt>
<dd>{planetLabel(g.origin!)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-to">
<dt>{i18n.t("game.inspector.ship_group.field.to")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd class="numeric">{formatFloat(g.range!)}</dd>
</div>
{/if}
{#if selection.variant === "local"}
<div class="field" data-testid="inspector-ship-group-field-fleet">
<dt>{i18n.t("game.inspector.ship_group.field.fleet")}</dt>
<dd>
{selection.group.fleet ?? i18n.t("game.inspector.ship_group.fleet.none")}
</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-state">
<dt>{i18n.t("game.inspector.ship_group.field.state")}</dt>
<dd>{selection.group.state}</dd>
</div>
{/if}
</dl>
{:else if selection.variant === "incoming"}
{@const g = selection.group}
{@const eta = g.speed > 0 ? Math.ceil(g.distance / g.speed) : null}
<dl class="fields">
<div class="field" data-testid="inspector-ship-group-field-from">
<dt>{i18n.t("game.inspector.ship_group.field.from")}</dt>
<dd>{planetLabel(g.origin)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-to">
<dt>{i18n.t("game.inspector.ship_group.field.to")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd class="numeric">{formatFloat(g.distance)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-speed">
<dt>{i18n.t("game.inspector.ship_group.field.speed")}</dt>
<dd class="numeric">{formatFloat(g.speed)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-eta">
<dt>{i18n.t("game.inspector.ship_group.field.eta")}</dt>
<dd>
{#if eta === null}
{i18n.t("game.designer.ship_class.preview.unavailable")}
{:else}
<span class="numeric">{eta}</span>
{/if}
</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd class="numeric">{formatFloat(g.mass)}</dd>
</div>
</dl>
{:else}
<dl class="fields">
<div
class="field"
data-testid="inspector-ship-group-field-coordinates"
>
<dt>{i18n.t("game.inspector.ship_group.field.coordinates")}</dt>
<dd class="numeric">
{formatFloat(selection.group.x)}, {formatFloat(selection.group.y)}
</dd>
</div>
</dl>
<p class="hint" data-testid="inspector-ship-group-no-data">
{i18n.t("game.inspector.ship_group.unidentified_no_data")}
</p>
{/if}
</section>
<style>
.inspector {
padding: 1rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.kind {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.name {
margin: 0;
font-size: 1.05rem;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
}
.field {
display: contents;
}
.field dt {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}
.field dd.numeric,
.field dd .numeric {
font-family: var(--font-mono);
}
.hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
</style>