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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user