Files
galaxy-game/tools/local-dev/legacy-report/parser.go
T
Ilia Denisov c58027c034 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>
2026-05-11 14:33:56 +02:00

970 lines
26 KiB
Go

// Package legacyreport parses legacy text-format Galaxy turn reports
// (the "dg" / "gplus" engines) into [report.Report] values.
//
// Scope is intentionally narrow: only the fields the UI client decodes
// from server reports today (planets, players, own ship classes,
// header data, plus — added in Phase 19 — own ship groups, own fleets
// and incoming groups). Everything else in the legacy file is silently
// skipped. The synthetic-report parity rule in ui/PLAN.md is the
// source of truth for when to extend this parser; the package's
// README.md tracks every legacy section that could be wired up later
// when the corresponding UI decoder lands.
package legacyreport
import (
"bufio"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/google/uuid"
"galaxy/calc"
"galaxy/model/report"
)
// Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields. The Width and Height of the
// returned report are both set to the legacy "Size" value (galaxies
// are square in the legacy engines).
func Parse(r io.Reader) (report.Report, error) {
p := newParser()
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
for sc.Scan() {
if err := p.handle(sc.Text()); err != nil {
return report.Report{}, err
}
}
if err := sc.Err(); err != nil {
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err)
}
return p.finish()
}
type section int
const (
sectionNone section = iota
sectionStatusOfPlayers
sectionYourVote
sectionYourPlanets
sectionOtherPlanets
sectionUninhabitedPlanets
sectionUnidentifiedPlanets
sectionYourShipTypes
sectionYourGroups
sectionYourFleets
sectionIncomingGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
)
type parser struct {
rep report.Report
sec section
otherOwner string
skipHeader bool
sawHeader bool
sawSize bool
// Group/fleet/incoming rows are buffered during the scan because
// 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. 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 {
g uint
number uint
class string
drive float64
weapons float64
shields float64
cargoTech float64
cargoType string
load float64
destinationName string
originName string // empty when "-"
rangeStr string // empty when "-"
mass float64
fleet string // empty when "-"
state string
}
type pendingFleet struct {
name string
groups uint
destinationName string
originName string // empty when "-"
rangeStr string // empty when "-"
state string
}
type pendingIncoming struct {
originName string
destinationName string
distance float64
speed float64
mass float64
}
type pendingShipProduction struct {
planetNumber uint
class string
cost float64
percent float64
free float64
}
func newParser() *parser {
return &parser{sec: sectionNone}
}
func (p *parser) handle(line string) error {
trimmed := strings.TrimSpace(line)
if !p.sawHeader && trimmed != "" {
if race, turn, ok := parseHeader(trimmed); ok {
p.rep.Race = race
p.rep.Turn = turn
p.sawHeader = true
return nil
}
}
if !p.sawSize && strings.HasPrefix(trimmed, "Size:") {
if w, planets, ok := parseSize(trimmed); ok {
p.rep.Width = w
p.rep.Height = w
p.rep.PlanetCount = planets
p.sawSize = true
}
return nil
}
if newSec, owner, isHeader := classifySection(trimmed); isHeader {
p.sec = newSec
p.otherOwner = owner
p.skipHeader = newSec != sectionNone
return nil
}
if trimmed == "" {
return nil
}
if p.sec == sectionNone {
return nil
}
if p.skipHeader {
p.skipHeader = false
return nil
}
fields := strings.Fields(trimmed)
switch p.sec {
case sectionStatusOfPlayers:
p.parsePlayer(fields)
case sectionYourVote:
p.parseYourVote(fields)
case sectionYourPlanets:
p.parseLocalPlanet(fields)
case sectionOtherPlanets:
p.parseOtherPlanet(fields)
case sectionUninhabitedPlanets:
p.parseUninhabitedPlanet(fields)
case sectionUnidentifiedPlanets:
p.parseUnidentifiedPlanet(fields)
case sectionYourShipTypes:
p.parseShipClass(fields)
case sectionYourGroups:
p.parseYourGroup(fields)
case sectionYourFleets:
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
}
func (p *parser) finish() (report.Report, error) {
if !p.sawHeader {
return report.Report{}, errors.New("legacyreport: missing report header line")
}
p.resolvePending()
return p.rep, nil
}
// parseHeader extracts (race, turn) from
// "<Race> Report for Galaxy ... Turn N ...".
func parseHeader(line string) (string, uint, bool) {
race, rest, ok := strings.Cut(line, " Report for Galaxy ")
if !ok {
return "", 0, false
}
_, afterTurn, ok := strings.Cut(rest, " Turn ")
if !ok {
return "", 0, false
}
after := strings.Fields(afterTurn)
if len(after) == 0 {
return "", 0, false
}
n, err := strconv.ParseUint(after[0], 10, 32)
if err != nil {
return "", 0, false
}
return race, uint(n), true
}
// parseSize extracts (size, planets) from
// "Size: W Planets: P Players: N". Players is intentionally
// dropped: report.Report has no field for it.
func parseSize(line string) (uint32, uint32, bool) {
fields := strings.Fields(line)
var size, planets uint32
var ok bool
for i := 0; i+1 < len(fields); i++ {
key := strings.TrimRight(fields[i], ":")
switch key {
case "Size":
if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
size = uint32(n)
ok = true
}
case "Planets":
if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
planets = uint32(n)
}
}
}
return size, planets, ok
}
// classifySection returns the section the trimmed line opens. When
// isHeader is true the caller transitions state — sectionNone there
// means "switch into skip mode" (an uninteresting section like
// Bombings, Battle at ..., or a foreign Ship Types block).
func classifySection(line string) (sec section, owner string, isHeader bool) {
switch line {
case "":
return sectionNone, "", false
case "Your Planets":
return sectionYourPlanets, "", true
case "Your Ship Types":
return sectionYourShipTypes, "", true
case "Your Groups":
return sectionYourGroups, "", true
case "Your Fleets":
return sectionYourFleets, "", true
case "Incoming Groups":
return sectionIncomingGroups, "", true
case "Uninhabited Planets":
return sectionUninhabitedPlanets, "", true
case "Unidentified Planets":
return sectionUnidentifiedPlanets, "", true
case "Your vote:":
return sectionYourVote, "", true
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
}
if strings.HasPrefix(line, "Status of Players") {
return sectionStatusOfPlayers, "", true
}
if strings.HasPrefix(line, "Battle at ") {
return sectionNone, "", true
}
if strings.HasPrefix(line, "=== ATTENTION") {
return sectionNone, "", true
}
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
return sectionOtherPlanets, owner, true
}
if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionOtherShipTypes, owner, true
}
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionOtherSciences, owner, true
}
if _, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionNone, "", true
}
return sectionNone, "", false
}
func singleTokenPrefix(line, suffix string) (string, bool) {
if !strings.HasSuffix(line, suffix) {
return "", false
}
prefix := strings.TrimSuffix(line, suffix)
if prefix == "" || strings.ContainsAny(prefix, " \t") {
return "", false
}
return prefix, true
}
// parsePlayer expects 10 columns:
//
// N D W S C P I # R V
func (p *parser) parsePlayer(fields []string) {
if len(fields) < 10 {
return
}
name := fields[0]
drive, err := parseFloat(fields[1])
if err != nil {
return
}
weapons, _ := parseFloat(fields[2])
shields, _ := parseFloat(fields[3])
cargo, _ := parseFloat(fields[4])
population, _ := parseFloat(fields[5])
industry, _ := parseFloat(fields[6])
plCount, err := strconv.ParseUint(fields[7], 10, 16)
if err != nil {
return
}
relation := fields[8]
votes, _ := parseFloat(fields[9])
extinct := strings.HasSuffix(name, "_RIP")
if extinct {
name = strings.TrimSuffix(name, "_RIP")
}
p.rep.Player = append(p.rep.Player, report.Player{
Name: name,
Drive: report.F(drive),
Weapons: report.F(weapons),
Shields: report.F(shields),
Cargo: report.F(cargo),
Population: report.F(population),
Industry: report.F(industry),
Planets: uint16(plCount),
Relation: relation,
Votes: report.F(votes),
Extinct: extinct,
})
}
func (p *parser) parseYourVote(fields []string) {
if len(fields) < 2 {
return
}
p.rep.VoteFor = fields[0]
if v, err := parseFloat(fields[1]); err == nil {
p.rep.Votes = report.F(v)
}
p.sec = sectionNone
}
// parseLocalPlanet expects 13 columns:
//
// # X Y N S P I R Production $ M C L
func (p *parser) parseLocalPlanet(fields []string) {
lp, ok := decodeLocalPlanetRow(fields)
if !ok {
return
}
p.rep.LocalPlanet = append(p.rep.LocalPlanet, lp)
}
func (p *parser) parseOtherPlanet(fields []string) {
lp, ok := decodeLocalPlanetRow(fields)
if !ok {
return
}
p.rep.OtherPlanet = append(p.rep.OtherPlanet, report.OtherPlanet{
Owner: p.otherOwner,
LocalPlanet: lp,
})
}
func decodeLocalPlanetRow(fields []string) (report.LocalPlanet, bool) {
var lp report.LocalPlanet
if len(fields) < 13 {
return lp, false
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return lp, false
}
x, _ := parseFloat(fields[1])
y, _ := parseFloat(fields[2])
size, _ := parseFloat(fields[4])
population, _ := parseFloat(fields[5])
industry, _ := parseFloat(fields[6])
resources, _ := parseFloat(fields[7])
capital, _ := parseFloat(fields[9])
material, _ := parseFloat(fields[10])
colonists, _ := parseFloat(fields[11])
free, _ := parseFloat(fields[12])
lp.Number = uint(number)
lp.X = report.F(x)
lp.Y = report.F(y)
lp.Name = fields[3]
lp.Size = report.F(size)
lp.Resources = report.F(resources)
lp.Capital = report.F(capital)
lp.Material = report.F(material)
lp.Industry = report.F(industry)
lp.Population = report.F(population)
lp.Colonists = report.F(colonists)
lp.Production = fields[8]
lp.FreeIndustry = report.F(free)
return lp, true
}
// parseUninhabitedPlanet expects 8 columns:
//
// # X Y N S R $ M
func (p *parser) parseUninhabitedPlanet(fields []string) {
if len(fields) < 8 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
x, _ := parseFloat(fields[1])
y, _ := parseFloat(fields[2])
size, _ := parseFloat(fields[4])
resources, _ := parseFloat(fields[5])
capital, _ := parseFloat(fields[6])
material, _ := parseFloat(fields[7])
var u report.UninhabitedPlanet
u.Number = uint(number)
u.X = report.F(x)
u.Y = report.F(y)
u.Name = fields[3]
u.Size = report.F(size)
u.Resources = report.F(resources)
u.Capital = report.F(capital)
u.Material = report.F(material)
p.rep.UninhabitedPlanet = append(p.rep.UninhabitedPlanet, u)
}
// parseUnidentifiedPlanet expects 3 columns:
//
// # X Y
func (p *parser) parseUnidentifiedPlanet(fields []string) {
if len(fields) < 3 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
x, _ := parseFloat(fields[1])
y, _ := parseFloat(fields[2])
p.rep.UnidentifiedPlanet = append(p.rep.UnidentifiedPlanet, report.UnidentifiedPlanet{
Number: uint(number),
X: report.F(x),
Y: report.F(y),
})
}
// parseShipClass expects 7 columns:
//
// N D A W S C M
func (p *parser) parseShipClass(fields []string) {
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 sc, false
}
armament, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
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
}
// 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,
})
}
// parseYourGroup buffers a "Your Groups" row for post-processing in
// [parser.finish]. Columns (16 fields, last is state):
//
// G # T D W S C T Q D F R P M L state
//
// where the second D is the destination planet name, F is the origin
// planet name (or "-" for on-planet groups), R is the remaining
// distance, and L is the fleet membership (or "-").
func (p *parser) parseYourGroup(fields []string) {
if len(fields) < 16 {
return
}
g, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
number, err := strconv.ParseUint(fields[1], 10, 32)
if err != nil {
return
}
drive, _ := parseFloat(fields[3])
weapons, _ := parseFloat(fields[4])
shields, _ := parseFloat(fields[5])
cargoTech, _ := parseFloat(fields[6])
load, _ := parseFloat(fields[8])
mass, _ := parseFloat(fields[13])
p.pendingGroups = append(p.pendingGroups, pendingGroup{
g: uint(g),
number: uint(number),
class: fields[2],
drive: drive,
weapons: weapons,
shields: shields,
cargoTech: cargoTech,
cargoType: fields[7],
load: load,
destinationName: fields[9],
originName: dashOrEmpty(fields[10]),
rangeStr: dashOrEmpty(fields[11]),
mass: mass,
fleet: dashOrEmpty(fields[14]),
state: fields[15],
})
}
// parseYourFleet buffers a "Your Fleets" row. Columns vary by engine
// — Killer/Tancordia ship 8 fields including a trailing state token,
// gplus emits 7 (no state). Layout:
//
// # N G D F R P [state]
//
// where D is the destination planet name, F is the origin planet
// name (or "-"), and R is the remaining distance.
func (p *parser) parseYourFleet(fields []string) {
if len(fields) < 7 {
return
}
groups, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
state := ""
if len(fields) >= 8 {
state = fields[7]
}
p.pendingFleets = append(p.pendingFleets, pendingFleet{
name: fields[1],
groups: uint(groups),
destinationName: fields[3],
originName: dashOrEmpty(fields[4]),
rangeStr: dashOrEmpty(fields[5]),
state: state,
})
}
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
//
// O D R S M
func (p *parser) parseIncomingGroup(fields []string) {
if len(fields) < 5 {
return
}
distance, err := parseFloat(fields[2])
if err != nil {
return
}
speed, _ := parseFloat(fields[3])
mass, _ := parseFloat(fields[4])
p.pendingIncomings = append(p.pendingIncomings, pendingIncoming{
originName: fields[0],
destinationName: fields[1],
distance: distance,
speed: speed,
mass: mass,
})
}
// resolvePending walks the buffered group/fleet/incoming rows and
// emits the typed entries on the report. Names that resolve neither
// against the parsed planet tables nor the "#NN" id syntax are
// skipped silently — they typically point at planets not visible to
// the local player. Stable LocalGroup IDs are derived from the
// per-report group index so repeated conversions of the same file
// produce byte-identical JSON.
func (p *parser) resolvePending() {
for _, pg := range p.pendingGroups {
dest, ok := p.lookupPlanetNumber(pg.destinationName)
if !ok {
continue
}
var origin *uint
if pg.originName != "" {
if n, ok := p.lookupPlanetNumber(pg.originName); ok {
v := n
origin = &v
}
}
var rng *report.Float
if pg.rangeStr != "" {
if r, err := parseFloat(pg.rangeStr); err == nil {
v := report.F(r)
rng = &v
}
}
var fleet *string
if pg.fleet != "" {
f := pg.fleet
fleet = &f
}
tech := map[string]report.Float{
"drive": report.F(pg.drive),
"weapons": report.F(pg.weapons),
"shields": report.F(pg.shields),
"cargo": report.F(pg.cargoTech),
}
p.rep.LocalGroup = append(p.rep.LocalGroup, report.LocalGroup{
OtherGroup: report.OtherGroup{
Number: pg.number,
Class: pg.class,
Tech: tech,
Cargo: pg.cargoType,
Load: report.F(pg.load),
Destination: dest,
Origin: origin,
Range: rng,
Mass: report.F(pg.mass),
},
ID: syntheticGroupID(pg.g),
State: pg.state,
Fleet: fleet,
})
}
for _, pf := range p.pendingFleets {
dest, ok := p.lookupPlanetNumber(pf.destinationName)
if !ok {
continue
}
var origin *uint
if pf.originName != "" {
if n, ok := p.lookupPlanetNumber(pf.originName); ok {
v := n
origin = &v
}
}
var rng *report.Float
if pf.rangeStr != "" {
if r, err := parseFloat(pf.rangeStr); err == nil {
v := report.F(r)
rng = &v
}
}
p.rep.LocalFleet = append(p.rep.LocalFleet, report.LocalFleet{
Name: pf.name,
Groups: pf.groups,
Destination: dest,
Origin: origin,
Range: rng,
State: pf.state,
})
}
for _, pi := range p.pendingIncomings {
origin, ok := p.lookupPlanetNumber(pi.originName)
if !ok {
continue
}
dest, ok := p.lookupPlanetNumber(pi.destinationName)
if !ok {
continue
}
p.rep.IncomingGroup = append(p.rep.IncomingGroup, report.IncomingGroup{
Origin: origin,
Destination: dest,
Distance: report.F(pi.distance),
Speed: report.F(pi.speed),
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
// "#NN" by-id form or a planet name from one of the parsed planet
// tables. Returns false when the planet is not visible to the local
// player (the caller drops the row).
func (p *parser) lookupPlanetNumber(s string) (uint, bool) {
if strings.HasPrefix(s, "#") {
n, err := strconv.ParseUint(s[1:], 10, 32)
if err != nil {
return 0, false
}
return uint(n), true
}
for _, lp := range p.rep.LocalPlanet {
if lp.Name == s {
return lp.Number, true
}
}
for _, op := range p.rep.OtherPlanet {
if op.Name == s {
return op.Number, true
}
}
for _, up := range p.rep.UninhabitedPlanet {
if up.Name == s {
return up.Number, true
}
}
return 0, false
}
// syntheticGroupNamespace seeds [uuid.NewSHA1] for the per-report
// group-index → UUID derivation. The constant value is arbitrary;
// any UUID would work as long as it stays stable across releases so
// re-running the converter on the same input file yields the same
// LocalGroup IDs.
var syntheticGroupNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000001")
func syntheticGroupID(g uint) uuid.UUID {
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
}
func dashOrEmpty(s string) string {
if s == "-" {
return ""
}
return s
}
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}