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
+242 -23
View File
@@ -21,6 +21,7 @@ import (
"github.com/google/uuid"
"galaxy/calc"
"galaxy/model/report"
)
@@ -57,6 +58,11 @@ const (
sectionYourGroups
sectionYourFleets
sectionIncomingGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
)
type parser struct {
@@ -71,10 +77,14 @@ type parser struct {
// they carry destination/origin planet names that may resolve
// against the planet tables only after the whole file has been
// read — "Incoming Groups" can appear before "Your Planets" in
// some engine variants.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
// some engine variants. Ships-in-production rows are buffered
// because their prod_used derivation needs the producing planet's
// material and resources (read from "Your Planets") to call
// [calc.ShipBuildCost], and the section order is not guaranteed.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction
}
type pendingGroup struct {
@@ -112,6 +122,14 @@ type pendingIncoming struct {
mass float64
}
type pendingShipProduction struct {
planetNumber uint
class string
cost float64
percent float64
free float64
}
func newParser() *parser {
return &parser{sec: sectionNone}
}
@@ -177,6 +195,16 @@ func (p *parser) handle(line string) error {
p.parseYourFleet(fields)
case sectionIncomingGroups:
p.parseIncomingGroup(fields)
case sectionYourSciences:
p.parseYourScience(fields)
case sectionOtherSciences:
p.parseOtherScience(fields)
case sectionOtherShipTypes:
p.parseOtherShipClass(fields)
case sectionBombings:
p.parseBombing(fields)
case sectionShipsInProduction:
p.parseShipProductionRow(fields)
}
return nil
}
@@ -259,10 +287,13 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
return sectionUnidentifiedPlanets, "", true
case "Your vote:":
return sectionYourVote, "", true
case "Your Sciences",
"Bombings",
"Ships In Production",
"Approaching Groups",
case "Your Sciences":
return sectionYourSciences, "", true
case "Bombings":
return sectionBombings, "", true
case "Ships In Production":
return sectionShipsInProduction, "", true
case "Approaching Groups",
"Broadcast Message",
"Battle Protocol":
return sectionNone, "", true
@@ -279,11 +310,11 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
return sectionOtherPlanets, owner, true
}
if _, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionNone, "", true
if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionOtherShipTypes, owner, true
}
if _, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionNone, "", true
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionOtherSciences, owner, true
}
if _, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionNone, "", true
@@ -468,30 +499,173 @@ func (p *parser) parseUnidentifiedPlanet(fields []string) {
//
// N D A W S C M
func (p *parser) parseShipClass(fields []string) {
if len(fields) < 7 {
sc, ok := decodeShipClassRow(fields)
if !ok {
return
}
p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
}
// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
// Same 7-column layout as [parser.parseShipClass]; the owning race is
// captured into [parser.otherOwner] when the section header is
// classified by [classifySection].
func (p *parser) parseOtherShipClass(fields []string) {
sc, ok := decodeShipClassRow(fields)
if !ok {
return
}
p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
Race: p.otherOwner,
ShipClass: sc,
})
}
func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
var sc report.ShipClass
if len(fields) < 7 {
return sc, false
}
drive, err := parseFloat(fields[1])
if err != nil {
return
return sc, false
}
armament, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
return sc, false
}
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5])
mass, _ := parseFloat(fields[6])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Armament = uint(armament)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
sc.Mass = report.F(mass)
return sc, true
}
p.rep.LocalShipClass = append(p.rep.LocalShipClass, report.ShipClass{
Name: fields[0],
Drive: report.F(drive),
Armament: uint(armament),
Weapons: report.F(weapons),
Shields: report.F(shields),
Cargo: report.F(cargo),
Mass: report.F(mass),
// parseYourScience parses one row of the "Your Sciences" block.
// Columns:
//
// N D W S C
//
// where D/W/S/C are the four tech proportions as fractions summing
// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
func (p *parser) parseYourScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.LocalScience = append(p.rep.LocalScience, sc)
}
// parseOtherScience parses one row of a "<Race> Sciences" block.
// Same 5-column layout as [parser.parseYourScience]; the owning race
// is captured into [parser.otherOwner] by [classifySection].
func (p *parser) parseOtherScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
Race: p.otherOwner,
Science: sc,
})
}
func decodeScienceRow(fields []string) (report.Science, bool) {
var sc report.Science
if len(fields) < 5 {
return sc, false
}
drive, err := parseFloat(fields[1])
if err != nil {
return sc, false
}
weapons, _ := parseFloat(fields[2])
shields, _ := parseFloat(fields[3])
cargo, _ := parseFloat(fields[4])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
return sc, true
}
// parseBombing parses one row of the "Bombings" block. Columns
// (12 tokens, last is the wiped/damaged status word):
//
// W O # N P I P $ M C A status
//
// where the first P is the post-bombing population and the second
// P is the production string left on the planet. Status is parsed
// positionally — the header has a duplicate P, so a header-name
// lookup is not safe.
func (p *parser) parseBombing(fields []string) {
if len(fields) < 12 {
return
}
number, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
population, _ := parseFloat(fields[4])
industry, _ := parseFloat(fields[5])
capital, _ := parseFloat(fields[7])
material, _ := parseFloat(fields[8])
colonists, _ := parseFloat(fields[9])
attack, _ := parseFloat(fields[10])
wiped := fields[11] == "Wiped"
p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
Attacker: fields[0],
Owner: fields[1],
Number: uint(number),
Planet: fields[3],
Population: report.F(population),
Industry: report.F(industry),
Production: fields[6],
Capital: report.F(capital),
Material: report.F(material),
Colonists: report.F(colonists),
AttackPower: report.F(attack),
Wiped: wiped,
})
}
// parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns:
//
// # N S C P L
//
// where # is the planet number, N is the planet name (decorative —
// resolution uses #), S is the building ship class, C is the cost
// (== shipMass * 10), P is the build progress as a fraction in
// [0, 1], and L is the producing planet's free industry. The wire
// shape's `prod_used` field is not carried by the legacy text; it is
// derived during [parser.resolvePending] from the planet's material
// and resources via [calc.ShipBuildCost].
func (p *parser) parseShipProductionRow(fields []string) {
if len(fields) < 6 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
cost, _ := parseFloat(fields[3])
percent, _ := parseFloat(fields[4])
free, _ := parseFloat(fields[5])
p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
planetNumber: uint(number),
class: fields[2],
cost: cost,
percent: percent,
free: free,
})
}
@@ -695,6 +869,51 @@ func (p *parser) resolvePending() {
Mass: report.F(pi.mass),
})
}
for _, ps := range p.pendingShipProducts {
lp, ok := p.findLocalPlanet(ps.planetNumber)
if !ok {
continue
}
shipMass := ps.cost / 10
totalCost := calc.ShipBuildCost(
shipMass,
float64(lp.Material),
float64(lp.Resources),
)
// `ProdUsed` is the cumulative production-equivalent of the
// build progress so far. The real engine's `Progress` field
// accumulates across turns and the per-turn `ProdUsed` is a
// transient residual — neither of those is recoverable from a
// single legacy report. The derivation here keeps the value in
// the same units (production points) and in the right ballpark
// for synthetic-mode UI rendering; live engine reports do not
// flow through this parser, so the approximation never reaches
// production traffic. README.md skips section explains.
prodUsed := totalCost * ps.percent
p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
Planet: ps.planetNumber,
Class: ps.class,
Cost: report.F(ps.cost),
ProdUsed: report.F(prodUsed),
Percent: report.F(ps.percent),
Free: report.F(ps.free),
})
}
}
// findLocalPlanet returns the parsed "Your Planets" entry with the
// given number, used by the ships-in-production resolver to read
// material / resources for the [calc.ShipBuildCost] derivation.
// Ships-in-production only lists own ships, so the lookup against
// `LocalPlanet` is correct.
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
for _, lp := range p.rep.LocalPlanet {
if lp.Number == number {
return lp, true
}
}
return report.LocalPlanet{}, false
}
// lookupPlanetNumber resolves a legacy planet reference — either a