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:
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user