ui/phase-23: turn-report view with twenty sections and TOC

Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 14:33:56 +02:00
parent 81d8be08b2
commit c58027c034
48 changed files with 5368 additions and 103 deletions
+317
View File
@@ -38,12 +38,16 @@ import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client";
import { UUID } from "../proto/galaxy/fbs/common";
import {
Bombing,
GameReportRequest,
IncomingGroup,
LocalFleet,
LocalGroup,
OtherGroup,
OtherScience,
OthersShipClass,
Report,
ShipProduction,
UnidentifiedGroup,
} from "../proto/galaxy/fbs/report";
import type {
@@ -280,6 +284,113 @@ export interface ReportOtherRace {
votesReceived: number;
}
/**
* ReportPlayer is the per-player projection consumed by the Phase 23
* Report View's Player Status section. Unlike `ReportOtherRace`, this
* row carries the local player and extinct rows too: the section is a
* status overview, not a diplomacy surface. Sorted alphabetically by
* name (case-insensitive); `isLocal` flags the calling player's row so
* the section can highlight it. The wire `relation` field is
* intentionally omitted — the self row carries the engine's "-"
* sentinel and the other-race rows already expose it via
* `GameReport.races`.
*/
export interface ReportPlayer {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
population: number;
industry: number;
planets: number;
votesReceived: number;
extinct: boolean;
isLocal: boolean;
}
/**
* ReportOtherScience is a single row in the Phase 23 Report View's
* Foreign Sciences section. Mirrors the wire `OtherScience` (carries
* the owning `race` alongside the four tech proportions). Stable
* order: sorted by `(race, name)` so the report's per-race sub-tables
* render deterministically.
*/
export interface ReportOtherScience {
race: string;
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
}
/**
* ReportOtherShipClass is a single row in the Phase 23 Report View's
* Foreign Ship Classes section. Mirrors the wire `OthersShipClass`
* (carries the owning `race`, the five tech-derived numbers, plus the
* `mass` the local ship-classes table does not surface — useful for
* fleet-mass comparison against incoming groups). Stable order:
* sorted by `(race, name)`.
*/
export interface ReportOtherShipClass {
race: string;
name: string;
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
mass: number;
}
/**
* ReportBombing is a single row in the Phase 23 Report View's
* Bombings section. Mirrors the wire `Bombing` (post-bombing planet
* snapshot, attacker/owner identity, attack power, and the boolean
* `wiped` flag that drives a visually-distinct row state). Sorted by
* `planetNumber` for deterministic rendering.
*
* Field naming follows the existing `ReportPlanet` convention:
* `capital → industryStockpile`, `material → materialsStockpile`,
* `number → planetNumber`.
*/
export interface ReportBombing {
planetNumber: number;
planet: string;
owner: string;
attacker: string;
production: string;
industry: number;
population: number;
colonists: number;
industryStockpile: number;
materialsStockpile: number;
attackPower: number;
wiped: boolean;
}
/**
* ReportShipProduction is a single row in the Phase 23 Report View's
* Ships In Production section. Mirrors the wire `ShipProduction`.
* `planetNumber` resolves against `GameReport.planets` so the section
* can render the producing planet's name; `cost` is the per-ship
* production cost (`ShipProductionCost(shipMass)`, not including the
* per-turn material-farming term); `prodUsed` is the engine's residual
* production poured into the partial ship this turn; `percent` is the
* cumulative build progress as a fraction in [0, 1]; `freeIndustry`
* mirrors the producing planet's free industry. Stable order: sorted
* by `(planetNumber, class)`.
*/
export interface ReportShipProduction {
planetNumber: number;
class: string;
cost: number;
prodUsed: number;
percent: number;
freeIndustry: number;
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -389,6 +500,50 @@ export interface GameReport {
* report always carries a non-empty value.
*/
myVoteFor: string;
/**
* players is the richer per-player projection Phase 23 added for
* the Report View's Player Status section. Same data source as
* `races[]` (`report.player[]`) but with the local player and
* extinct rows included, sorted alphabetically by name and tagged
* with `isLocal`. `races[]` stays Phase 22's view (other,
* non-extinct) so diplomatic-stance code paths do not churn.
*/
players: ReportPlayer[];
/**
* otherScience is the per-race foreign-sciences projection Phase
* 23 added for the Report View's Foreign Sciences section. Sorted
* by `(race, name)`. Empty when the report has no foreign science
* data (boot state, single-race game, legacy synthetic data).
*/
otherScience: ReportOtherScience[];
/**
* otherShipClass is the per-race foreign-ship-classes projection
* Phase 23 added for the Report View's Foreign Ship Classes
* section. Sorted by `(race, name)`. Empty when the report has no
* foreign ship-class data.
*/
otherShipClass: ReportOtherShipClass[];
/**
* battleIds is the list of battle UUIDs the engine recorded for
* the current turn. Phase 23 renders them as inactive
* monospace identifiers; Phase 27 will turn them into navigation
* targets once the battle viewer lands. Empty when no battles
* occurred last turn.
*/
battleIds: string[];
/**
* bombings is the per-bombing projection Phase 23 added for the
* Report View's Bombings section. Sorted by `planetNumber`. Empty
* when no planets were bombed last turn.
*/
bombings: ReportBombing[];
/**
* shipProductions is the per-ship-production projection Phase 23
* added for the Report View's Ships In Production section.
* Sorted by `(planetNumber, class)`. Empty when no planet is
* currently producing a ship.
*/
shipProductions: ReportShipProduction[];
}
export async function fetchGameReport(
@@ -537,11 +692,17 @@ function decodeReport(report: Report): GameReport {
const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName);
const races = collectOtherRaceRows(report, raceName);
const players = decodePlayers(report, raceName);
const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report);
const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
const localFleets = decodeLocalFleets(report);
const otherScience = decodeOtherScience(report);
const otherShipClass = decodeOtherShipClass(report);
const battleIds = decodeBattleIds(report);
const bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report);
return {
turn: Number(report.turn()),
@@ -566,6 +727,12 @@ function decodeReport(report: Report): GameReport {
races,
myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "",
players,
otherScience,
otherShipClass,
battleIds,
bombings,
shipProductions,
};
}
@@ -903,6 +1070,146 @@ function collectOtherRaceRows(
return out;
}
/**
* decodePlayers walks `report.player[]` and emits the full status
* roster the Phase 23 Report View's Player Status section renders:
* every named row including the local player and extinct races,
* sorted alphabetically (case-insensitive). The local row carries
* `isLocal: true` so the section can highlight it; the wire
* `relation` field is intentionally dropped (self carries the engine
* "-" sentinel, other rows already surface relation through
* `GameReport.races`).
*/
function decodePlayers(report: Report, raceName: string): ReportPlayer[] {
const out: ReportPlayer[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
const name = player.name() ?? "";
if (name === "") continue;
out.push({
name,
drive: player.drive(),
weapons: player.weapons(),
shields: player.shields(),
cargo: player.cargo(),
population: player.population(),
industry: player.industry(),
planets: player.planets(),
votesReceived: player.votes(),
extinct: player.extinct(),
isLocal: name === raceName,
});
}
out.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
return out;
}
function decodeOtherScience(report: Report): ReportOtherScience[] {
const out: ReportOtherScience[] = [];
for (let i = 0; i < report.otherScienceLength(); i++) {
const s = report.otherScience(i);
if (s === null) continue;
out.push({
race: s.race() ?? "",
name: s.name() ?? "",
drive: s.drive(),
weapons: s.weapons(),
shields: s.shields(),
cargo: s.cargo(),
});
}
out.sort((a, b) => {
const byRace = a.race.localeCompare(b.race);
if (byRace !== 0) return byRace;
return a.name.localeCompare(b.name);
});
return out;
}
function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
const out: ReportOtherShipClass[] = [];
for (let i = 0; i < report.otherShipClassLength(); i++) {
const sc = report.otherShipClass(i);
if (sc === null) continue;
out.push({
race: sc.race() ?? "",
name: sc.name() ?? "",
drive: sc.drive(),
armament: Number(sc.armament()),
weapons: sc.weapons(),
shields: sc.shields(),
cargo: sc.cargo(),
mass: sc.mass(),
});
}
out.sort((a, b) => {
const byRace = a.race.localeCompare(b.race);
if (byRace !== 0) return byRace;
return a.name.localeCompare(b.name);
});
return out;
}
function decodeBattleIds(report: Report): string[] {
const out: string[] = [];
for (let i = 0; i < report.battleLength(); i++) {
const uuid = report.battle(i);
const value = uuidStringFromFB(uuid);
if (value === null) continue;
out.push(value);
}
return out;
}
function decodeBombings(report: Report): ReportBombing[] {
const out: ReportBombing[] = [];
for (let i = 0; i < report.bombingLength(); i++) {
const b = report.bombing(i);
if (b === null) continue;
out.push({
planetNumber: Number(b.number()),
planet: b.planet() ?? "",
owner: b.owner() ?? "",
attacker: b.attacker() ?? "",
production: b.production() ?? "",
industry: b.industry(),
population: b.population(),
colonists: b.colonists(),
industryStockpile: b.capital(),
materialsStockpile: b.material(),
attackPower: b.attackPower(),
wiped: b.wiped(),
});
}
out.sort((a, b) => a.planetNumber - b.planetNumber);
return out;
}
function decodeShipProductions(report: Report): ReportShipProduction[] {
const out: ReportShipProduction[] = [];
for (let i = 0; i < report.shipProductionLength(); i++) {
const sp = report.shipProduction(i);
if (sp === null) continue;
out.push({
planetNumber: Number(sp.planet()),
class: sp.class_() ?? "",
cost: sp.cost(),
prodUsed: sp.prodUsed(),
percent: sp.percent(),
freeIndustry: sp.free(),
});
}
out.sort((a, b) => {
const byPlanet = a.planetNumber - b.planetNumber;
if (byPlanet !== 0) return byPlanet;
return a.class.localeCompare(b.class);
});
return out;
}
/**
* uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -1125,6 +1432,16 @@ export function applyOrderOverlay(
localScience: mutatedScience ?? report.localScience ?? [],
races: mutatedRaces ?? report.races ?? [],
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
// Phase 23 read-only fields. No overlay branches touch them
// today; the `?? []` keeps a stale HMR-instance of `report`
// (loaded before the shape bump) from blanking the Report
// View when its section components iterate.
players: report.players ?? [],
otherScience: report.otherScience ?? [],
otherShipClass: report.otherShipClass ?? [],
battleIds: report.battleIds ?? [],
bombings: report.bombings ?? [],
shipProductions: report.shipProductions ?? [],
};
}
+149
View File
@@ -20,13 +20,18 @@
import type {
GameReport,
ReportBombing,
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherRace,
ReportOtherScience,
ReportOtherShipClass,
ReportOtherShipGroup,
ReportPlanet,
ReportPlayer,
ReportRoute,
ReportShipProduction,
ReportUnidentifiedShipGroup,
ScienceSummary,
ShipClassSummary,
@@ -159,6 +164,39 @@ interface SyntheticScience {
cargo?: number;
}
interface SyntheticOtherScience extends SyntheticScience {
race?: string;
}
interface SyntheticOtherShipClass extends SyntheticShipClass {
race?: string;
mass?: number;
}
interface SyntheticBombing {
planet?: number; // wire field "number"
planetName?: string; // wire field "planetName"
owner?: string;
attacker?: string;
production?: string;
industry?: number;
population?: number;
colonists?: number;
capital?: number;
material?: number;
attack?: number;
wiped?: boolean;
}
interface SyntheticShipProductionRow {
planet?: number;
class?: string;
cost?: number;
prodUsed?: number;
percent?: number;
free?: number;
}
interface SyntheticReportRoot {
turn?: number;
mapWidth?: number;
@@ -173,12 +211,17 @@ interface SyntheticReportRoot {
uninhabitedPlanet?: SyntheticPlanet[];
unidentifiedPlanet?: SyntheticPlanet[];
localShipClass?: SyntheticShipClass[];
otherShipClass?: SyntheticOtherShipClass[];
localScience?: SyntheticScience[];
otherScience?: SyntheticOtherScience[];
localGroup?: SyntheticShipGroup[];
otherGroup?: SyntheticShipGroup[];
incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[];
battle?: string[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
}
function decodeSyntheticReport(json: unknown): GameReport {
@@ -278,6 +321,78 @@ function decodeSyntheticReport(json: unknown): GameReport {
state: typeof f.state === "string" ? f.state : "",
}));
const otherScience: ReportOtherScience[] = (root.otherScience ?? []).map(
(sc) => ({
race: typeof sc.race === "string" ? sc.race : "",
name: typeof sc.name === "string" ? sc.name : "",
drive: numOr0(sc.drive),
weapons: numOr0(sc.weapons),
shields: numOr0(sc.shields),
cargo: numOr0(sc.cargo),
}),
);
otherScience.sort((a, b) => {
const byRace = a.race.localeCompare(b.race);
if (byRace !== 0) return byRace;
return a.name.localeCompare(b.name);
});
const otherShipClass: ReportOtherShipClass[] = (root.otherShipClass ?? []).map(
(sc) => ({
race: typeof sc.race === "string" ? sc.race : "",
name: typeof sc.name === "string" ? sc.name : "",
drive: numOr0(sc.drive),
armament: Math.trunc(numOr0(sc.armament)),
weapons: numOr0(sc.weapons),
shields: numOr0(sc.shields),
cargo: numOr0(sc.cargo),
// `mass` is on the wire but synthetic fixtures may omit
// it; fall back to 0 rather than reject the row.
mass: typeof sc.mass === "number" ? sc.mass : 0,
}),
);
otherShipClass.sort((a, b) => {
const byRace = a.race.localeCompare(b.race);
if (byRace !== 0) return byRace;
return a.name.localeCompare(b.name);
});
const battleIds: string[] = (root.battle ?? []).filter(
(v): v is string => typeof v === "string" && v !== "",
);
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
planetNumber: numOr0(b.planet),
planet: typeof b.planetName === "string" ? b.planetName : "",
owner: typeof b.owner === "string" ? b.owner : "",
attacker: typeof b.attacker === "string" ? b.attacker : "",
production: typeof b.production === "string" ? b.production : "",
industry: numOr0(b.industry),
population: numOr0(b.population),
colonists: numOr0(b.colonists),
industryStockpile: numOr0(b.capital),
materialsStockpile: numOr0(b.material),
attackPower: numOr0(b.attack),
wiped: b.wiped === true,
}));
bombings.sort((a, b) => a.planetNumber - b.planetNumber);
const shipProductions: ReportShipProduction[] = (root.shipProduction ?? []).map(
(sp) => ({
planetNumber: numOr0(sp.planet),
class: typeof sp.class === "string" ? sp.class : "",
cost: numOr0(sp.cost),
prodUsed: numOr0(sp.prodUsed),
percent: numOr0(sp.percent),
freeIndustry: numOr0(sp.free),
}),
);
shipProductions.sort((a, b) => {
const byPlanet = a.planetNumber - b.planetNumber;
if (byPlanet !== 0) return byPlanet;
return a.class.localeCompare(b.class);
});
return {
turn: numOr0(root.turn),
mapWidth: numOr0(root.mapWidth),
@@ -301,9 +416,43 @@ function decodeSyntheticReport(json: unknown): GameReport {
races: collectOtherRaceRowsFromSynthetic(root, race),
myVotes: numOr0(root.votes),
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
players: collectPlayersFromSynthetic(root, race),
otherScience,
otherShipClass,
battleIds,
bombings,
shipProductions,
};
}
function collectPlayersFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): ReportPlayer[] {
const out: ReportPlayer[] = [];
for (const player of root.player ?? []) {
const name = typeof player.name === "string" ? player.name : "";
if (name === "") continue;
out.push({
name,
drive: numOr0(player.drive),
weapons: numOr0(player.weapons),
shields: numOr0(player.shields),
cargo: numOr0(player.cargo),
population: numOr0(player.population),
industry: numOr0(player.industry),
planets: Math.trunc(numOr0(player.planets)),
votesReceived: numOr0(player.votes),
extinct: player.extinct === true,
isLocal: name === raceName,
});
}
out.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
return out;
}
function collectOtherRacesFromSynthetic(
root: SyntheticReportRoot,
raceName: string,