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
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>
294 lines
8.0 KiB
Svelte
294 lines
8.0 KiB
Svelte
<!--
|
||
"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>
|