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
+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,