Files
galaxy-game/ui/frontend/src/lib/inspectors/planet/ship-groups.svelte
T
Ilia Denisov 24c68e9846
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (pull_request) Successful in 1m51s
Tests · UI / test (pull_request) Successful in 3m53s
feat(model+ui): F8-05 — race on OtherGroup, real attribution + N×M label
Issue #48 п.32 ("Stationed ship groups") shipped with a fragile race
fallback: when a foreign group sat on a non-`other`-kind planet the
inspector printed a generic "foreign" label, which collapsed the
race dropdown to a single uninformative bucket. The engine FBS
contract did not carry per-group race either, so live games hit the
same gap. This patch carries race authoritatively from the engine
through every layer down to the inspector.

Wire format & engine
- `pkg/schema/fbs/report.fbs`: add `race:string` to `OtherGroup` and
  `LocalGroup` (additive — old clients ignore).
- `pkg/schema/fbs/report/`: regenerated Go bindings.
- `ui/frontend/src/proto/galaxy/fbs/report/`: regenerated TS bindings.
- `pkg/model/report.OtherGroup.Race`: new field; carried through
  `LocalGroup` via the embedded `OtherGroup`.
- `pkg/transcoder/report.go`: encode + decode `race` on both
  `LocalGroup` and `OtherGroup`.
- `game/internal/controller/report.go.otherGroup`: set `v.Race`
  from `c.g.Race[c.RaceIndex(sg.OwnerID)].Name` so every emitted
  group — own or foreign — carries the resolved race name.

Legacy parser
- `tools/local-dev/legacy-report/parser.go`: capture the
  `<Race> Groups` header into `pendingOtherGroup.race`, fill local
  group `Race` from `p.rep.Race`, propagate both into the
  `report.OtherGroup` rows.
- Tests + smoke counts updated; regenerated `KNNTS{039,041}.json`
  fixtures so the synthetic loader carries the new field.

UI
- `ui/frontend/src/api/`: `ReportShipGroupBase.race` field;
  synthetic loader + FBS decoder populate it.
- `ui/frontend/src/lib/inspectors/planet/ship-groups.svelte`: the
  stationed-groups inspector picks race directly from
  `group.race` (own falls back to `localRace`, both finally to the
  `race.unknown` placeholder). The planet-owner / "foreign"
  heuristic is gone.
- Row label changes from "N ships mass M" to a compact
  `<class>` | `<N ×>` | `<mass>` three-column layout: the count
  cell is right-aligned tabular, the mass cell is right-aligned
  monospace + tabular, matching the inspector / calculator number
  conventions. Stale i18n keys removed
  (`ship_groups.row.count`, `.row.mass`, `.race.foreign`).
- All affected unit tests (8 files) carry the new `race` field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:23:17 +02:00

294 lines
8.0 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.
<!--
"Ship groups stationed here" subsection of the planet inspector.
The map deliberately hides on-planet groups (rendering them as
offset points crowds the canvas), so this list is the player's
view of which fleets sit in this orbit. Race attribution is
best-effort:
- LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside
battle rosters.
Phase 20 made own-ship rows interactive: clicking a row pivots
the inspector to the corresponding ship-group inspector through
the shared `SelectionStore`. The actions panel mounts on top of
the existing ship-group inspector, so the row is the on-planet
entry point for Send / Load / Modernize / etc. Foreign rows stay
non-interactive — there are no actions to drive against another
race's fleet.
F8-05 (issue #48 п.32) moved the race column into a dropdown
above the table: the previous "race | class | count | mass"
layout overflowed horizontally on narrow viewports. The dropdown
seeds with the player's own race when local groups are stationed
here, otherwise with the first race alphabetically; both cases
are after sorting `availableRaces` alphabetically so the picker
is stable across re-mounts. When a single race is in orbit the
dropdown is hidden — there is nothing to choose — and the table
renders straight through. The race column is dropped in both
modes because the dropdown already names the active race.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import { formatFloat } from "$lib/util/number-format";
type Props = {
planet: ReportPlanet;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
};
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
interface StationedRow {
key: string;
race: string;
class: string;
count: number;
mass: number;
selectable: boolean;
groupId: string | null;
}
// F8-05 owner-feedback: the row carries the authoritative `race`
// field projected by the engine (and by the legacy parser via the
// `<Race> Groups` header), so every stationed group surfaces its
// real owner. The planet-owner / "foreign" fallback is gone — when
// the wire carries no race the row falls back to the i18n
// `race.unknown` placeholder, matching how the local-race column
// degrades if `localRace` is missing.
const unknownRace = $derived(
i18n.t("game.inspector.planet.ship_groups.race.unknown"),
);
const stationedRows: StationedRow[] = $derived.by(() => {
const rows: StationedRow[] = [];
for (const g of localShipGroups) {
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `local:${g.id}`,
race: g.race || localRace || unknownRace,
class: g.class,
count: g.count,
mass: g.mass,
selectable: true,
groupId: g.id,
});
}
for (let i = 0; i < otherShipGroups.length; i++) {
const g = otherShipGroups[i]!;
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `other:${i}`,
race: g.race || unknownRace,
class: g.class,
count: g.count,
mass: g.mass,
selectable: false,
groupId: null,
});
}
return rows;
});
const ownStationedHere = $derived(
stationedRows.some((r) => r.selectable),
);
const availableRaces = $derived.by(() => {
const set = new Set<string>();
for (const row of stationedRows) set.add(row.race);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const ownRaceLabel = $derived(
localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
);
function defaultRace(races: ReadonlyArray<string>): string {
if (races.length === 0) return "";
if (ownStationedHere && races.includes(ownRaceLabel)) return ownRaceLabel;
return races[0]!;
}
let selectedRace = $state<string>("");
$effect(() => {
// Re-seed whenever the inspector switches planets or the
// stationed roster changes (new arrivals after a turn).
// Preserve the player's pick if it is still represented;
// otherwise fall back to the documented default.
void planet.number;
const races = availableRaces;
if (races.length === 0) {
selectedRace = "";
return;
}
if (selectedRace === "" || !races.includes(selectedRace)) {
selectedRace = defaultRace(races);
}
});
const showFilter = $derived(availableRaces.length > 1);
const filteredRows = $derived(
showFilter
? stationedRows.filter((r) => r.race === selectedRace)
: stationedRows,
);
function pickRace(event: Event): void {
selectedRace = (event.target as HTMLSelectElement).value;
}
function selectLocalGroup(groupId: string): void {
if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId });
}
</script>
{#if stationedRows.length > 0}
<section class="ship-groups" data-testid="inspector-planet-ship-groups">
<div class="head">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
{#if showFilter}
<select
class="race-select"
data-testid="inspector-planet-ship-groups-race-filter"
aria-label={i18n.t(
"game.inspector.planet.ship_groups.race_filter.aria",
)}
value={selectedRace}
onchange={pickRace}
>
{#each availableRaces as race (race)}
<option value={race}>{race}</option>
{/each}
</select>
{/if}
</div>
<ul class="rows">
{#each filteredRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row">
{#if row.selectable && row.groupId !== null}
{@const groupId = row.groupId}
<button
type="button"
class="cells select"
data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)}
>
<span class="class">{row.class}</span>
<span class="count">{row.count} ×</span>
<span class="mass">{formatFloat(row.mass)}</span>
</button>
{:else}
<div class="cells">
<span class="class">{row.class}</span>
<span class="count">{row.count} ×</span>
<span class="mass">{formatFloat(row.mass)}</span>
</div>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<style>
.ship-groups {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
h4 {
margin: 0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.race-select {
flex: 1 1 auto;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row {
display: block;
font-size: 0.85rem;
}
.cells {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.5rem;
align-items: baseline;
}
.select {
width: 100%;
font: inherit;
text-align: left;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.3rem;
cursor: pointer;
}
.select:hover {
border-color: var(--color-border);
background: var(--color-surface-hover);
}
.class {
color: var(--color-text-muted);
}
.count {
color: var(--color-text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.mass {
color: var(--color-text-muted);
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
</style>