feat(model+ui): F8-05 — race on OtherGroup, real attribution + N×M label
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>
This commit is contained in:
Ilia Denisov
2026-05-27 16:23:17 +02:00
parent cc4bc3c2b7
commit 24c68e9846
26 changed files with 3601 additions and 1544 deletions
+11
View File
@@ -193,6 +193,15 @@ export interface ReportShipGroupBase {
range: number | null;
speed: number;
mass: number;
/**
* Owning race for the group. The engine fills this from
* `sg.OwnerID` (and the legacy parser from the section header
* that introduced the row) so the inspector can name foreign
* owners directly instead of falling back to a "foreign"
* placeholder when the planet kind does not carry an `owner`
* heuristic. Empty when the source report predates the field.
*/
race: string;
}
/**
@@ -869,6 +878,7 @@ function decodeLocalShipGroups(report: Report): ReportLocalShipGroup[] {
mass: g.mass(),
state: g.state() ?? "",
fleet: g.fleet(),
race: g.race() ?? "",
});
}
return out;
@@ -895,6 +905,7 @@ function decodeOtherShipGroups(report: Report): ReportOtherShipGroup[] {
range,
speed: g.speed(),
mass: g.mass(),
race: g.race() ?? "",
});
}
return out;
+3
View File
@@ -186,6 +186,7 @@ interface SyntheticShipGroup {
mass?: number;
state?: string;
fleet?: string;
race?: string;
}
interface SyntheticIncomingGroup {
@@ -344,6 +345,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
mass: numOr0(g.mass),
state: typeof g.state === "string" ? g.state : "",
fleet: typeof g.fleet === "string" ? g.fleet : null,
race: typeof g.race === "string" ? g.race : race,
}),
);
const otherShipGroups: ReportOtherShipGroup[] = (root.otherGroup ?? []).map(
@@ -358,6 +360,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
range: typeof g.range === "number" ? g.range : null,
speed: numOr0(g.speed),
mass: numOr0(g.mass),
race: typeof g.race === "string" ? g.race : "",
}),
);
const incomingShipGroups: ReportIncomingShipGroup[] = (
-3
View File
@@ -600,10 +600,7 @@ const en = {
"game.inspector.planet.ship_groups.race_filter.aria": "stationed race",
"game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign",
"game.report.loading": "loading report…",
"game.report.back_to_map": "back to map",
-3
View File
@@ -601,10 +601,7 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.ship_groups.race_filter.aria": "раса в орбите",
"game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие",
"game.report.loading": "загрузка отчёта…",
"game.report.back_to_map": "назад к карте",
@@ -66,6 +66,17 @@ modes because the dropdown already names the active race.
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) {
@@ -73,7 +84,7 @@ modes because the dropdown already names the active race.
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `local:${g.id}`,
race: localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
race: g.race || localRace || unknownRace,
class: g.class,
count: g.count,
mass: g.mass,
@@ -81,16 +92,13 @@ modes because the dropdown already names the active race.
groupId: g.id,
});
}
const foreignRace =
planet.owner ??
i18n.t("game.inspector.planet.ship_groups.race.foreign");
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: foreignRace,
race: g.race || unknownRace,
class: g.class,
count: g.count,
mass: g.mass,
@@ -183,34 +191,20 @@ modes because the dropdown already names the active race.
{@const groupId = row.groupId}
<button
type="button"
class="select"
class="cells select"
data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)}
>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatFloat(row.mass),
})}
</span>
<span class="count">{row.count} ×</span>
<span class="mass">{formatFloat(row.mass)}</span>
</button>
{:else}
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatFloat(row.mass),
})}
</span>
<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}
@@ -260,13 +254,12 @@ modes because the dropdown already names the active race.
.row {
display: block;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.row > span,
.row > .select {
.cells {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.5rem;
align-items: baseline;
}
.select {
width: 100%;
@@ -286,8 +279,15 @@ modes because the dropdown already names the active race.
.class {
color: var(--color-text-muted);
}
.count,
.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>
@@ -104,8 +104,15 @@ fleet(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
race():string|null
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
race(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 30);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startLocalGroup(builder:flatbuffers.Builder) {
builder.startObject(13);
builder.startObject(14);
}
static addNumber(builder:flatbuffers.Builder, number:bigint) {
@@ -172,6 +179,10 @@ static addFleet(builder:flatbuffers.Builder, fleetOffset:flatbuffers.Offset) {
builder.addFieldOffset(12, fleetOffset, 0);
}
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, raceOffset, 0);
}
static endLocalGroup(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
builder.requiredField(offset, 24) // id
@@ -193,7 +204,8 @@ unpack(): LocalGroupT {
this.mass(),
(this.id() !== null ? this.id()!.unpack() : null),
this.state(),
this.fleet()
this.fleet(),
this.race()
);
}
@@ -212,6 +224,7 @@ unpackTo(_o: LocalGroupT): void {
_o.id = (this.id() !== null ? this.id()!.unpack() : null);
_o.state = this.state();
_o.fleet = this.fleet();
_o.race = this.race();
}
}
@@ -229,7 +242,8 @@ constructor(
public mass: number = 0.0,
public id: UUIDT|null = null,
public state: string|Uint8Array|null = null,
public fleet: string|Uint8Array|null = null
public fleet: string|Uint8Array|null = null,
public race: string|Uint8Array|null = null
){}
@@ -239,6 +253,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0);
const state = (this.state !== null ? builder.createString(this.state!) : 0);
const fleet = (this.fleet !== null ? builder.createString(this.fleet!) : 0);
const race = (this.race !== null ? builder.createString(this.race!) : 0);
LocalGroup.startLocalGroup(builder);
LocalGroup.addNumber(builder, this.number);
@@ -256,6 +271,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
LocalGroup.addId(builder, (this.id !== null ? this.id!.pack(builder) : 0));
LocalGroup.addState(builder, state);
LocalGroup.addFleet(builder, fleet);
LocalGroup.addRace(builder, race);
return LocalGroup.endLocalGroup(builder);
}
@@ -84,8 +84,15 @@ mass():number {
return offset ? this.bb!.readFloat32(this.bb_pos + offset) : 0.0;
}
race():string|null
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
race(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startOtherGroup(builder:flatbuffers.Builder) {
builder.startObject(10);
builder.startObject(11);
}
static addNumber(builder:flatbuffers.Builder, number:bigint) {
@@ -140,12 +147,16 @@ static addMass(builder:flatbuffers.Builder, mass:number) {
builder.addFieldFloat32(9, mass, 0.0);
}
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
builder.addFieldOffset(10, raceOffset, 0);
}
static endOtherGroup(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset:flatbuffers.Offset, techOffset:flatbuffers.Offset, cargoOffset:flatbuffers.Offset, load:number, destination:bigint, origin:bigint|null, range:number|null, speed:number, mass:number):flatbuffers.Offset {
static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset:flatbuffers.Offset, techOffset:flatbuffers.Offset, cargoOffset:flatbuffers.Offset, load:number, destination:bigint, origin:bigint|null, range:number|null, speed:number, mass:number, raceOffset:flatbuffers.Offset):flatbuffers.Offset {
OtherGroup.startOtherGroup(builder);
OtherGroup.addNumber(builder, number);
OtherGroup.addClass(builder, class_Offset);
@@ -159,6 +170,7 @@ static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset
OtherGroup.addRange(builder, range);
OtherGroup.addSpeed(builder, speed);
OtherGroup.addMass(builder, mass);
OtherGroup.addRace(builder, raceOffset);
return OtherGroup.endOtherGroup(builder);
}
@@ -173,7 +185,8 @@ unpack(): OtherGroupT {
this.origin(),
this.range(),
this.speed(),
this.mass()
this.mass(),
this.race()
);
}
@@ -189,6 +202,7 @@ unpackTo(_o: OtherGroupT): void {
_o.range = this.range();
_o.speed = this.speed();
_o.mass = this.mass();
_o.race = this.race();
}
}
@@ -203,7 +217,8 @@ constructor(
public origin: bigint|null = null,
public range: number|null = null,
public speed: number = 0.0,
public mass: number = 0.0
public mass: number = 0.0,
public race: string|Uint8Array|null = null
){}
@@ -211,6 +226,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const class_ = (this.class_ !== null ? builder.createString(this.class_!) : 0);
const tech = OtherGroup.createTechVector(builder, builder.createObjectOffsetList(this.tech));
const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0);
const race = (this.race !== null ? builder.createString(this.race!) : 0);
return OtherGroup.createOtherGroup(builder,
this.number,
@@ -222,7 +238,8 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
this.origin,
this.range,
this.speed,
this.mass
this.mass,
race
);
}
}
@@ -69,6 +69,7 @@ function localGroup(
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -87,6 +88,7 @@ function otherGroup(
range: null,
speed: 0,
mass: 25,
race: "Klingons",
...overrides,
};
}
@@ -108,6 +108,7 @@ function localGroup(
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -119,6 +119,7 @@ function group(
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -104,6 +104,7 @@ function group(
mass: 25,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -79,6 +79,7 @@ function localGroup(
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -158,6 +159,7 @@ describe("ship-group inspector", () => {
range: null,
speed: 0,
mass: 50,
race: "Klingons",
};
const selection: ShipGroupSelection = { variant: "other", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
@@ -45,6 +45,7 @@ function localGroup(overrides: Partial<ReportLocalShipGroup> & Pick<ReportLocalS
mass: 1,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -79,6 +79,7 @@ function makeLocalShipGroup(
mass: 0,
state: "InOrbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
@@ -97,6 +98,7 @@ function makeOtherShipGroup(
range: null,
speed: 1,
mass: 0,
race: "Klingons",
...overrides,
};
}
@@ -78,6 +78,7 @@ describe("reportToWorld — ship groups", () => {
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
},
],
}),
@@ -112,6 +113,7 @@ describe("reportToWorld — ship groups", () => {
mass: 50,
state: "In_Space",
fleet: null,
race: "Earthlings",
},
],
}),
@@ -237,6 +239,7 @@ describe("reportToWorld — ship groups", () => {
origin: null,
range: null,
speed: 0,
race: "Earthlings",
mass: 1,
state: "In_Orbit",
fleet: null,