Files
galaxy-game/tools/local-dev/legacy-report/parser.go
T
Ilia Denisov 8839f46c25 ui/phase-19: legacy parser learns Your Groups / Your Fleets / Incoming Groups
The parity rule from ui/PLAN.md says every UI phase that decodes a
new Report field must extend the legacy converter in lockstep.
Phase 19 brings ship groups (LocalGroup / OtherGroup /
UnidentifiedGroup / IncomingGroup) and LocalFleet onto the wire-
compatible UI surface; this commit teaches
tools/local-dev/legacy-report to populate the three sections that
exist in the legacy text format:

  - "Your Groups" → []LocalGroup. Cargo type, load, fleet name,
    state, on-planet vs hyperspace position (origin / range) all
    decoded; LocalGroup.ID is synthesised deterministically from
    the per-report group index so re-running the converter
    produces byte-identical JSON. Speed is left zero — the legacy
    table doesn't expose it.
  - "Your Fleets" → []LocalFleet. Origin / range / state mirror
    the row layout used by Killer / Tancordia variants; gplus's
    state-less rows still resolve.
  - "Incoming Groups" → []IncomingGroup. Origin / destination
    names — and `#NN` by-id references — resolve against the
    parsed planet tables. Because the section can land before
    "Your Planets" in some engines, group / fleet / incoming rows
    are buffered and resolved in `parser.finish` after every
    planet is known.

Battles, OtherGroup (only ever in battle rosters), and
UnidentifiedGroup stay out of scope — README.md spells out what
remains not-derivable.

Adds Killer031–033 / TSERCON_Z032–033 / Tancordia036–039 fixtures
to the dg directory and exercises three of them through new
TestParseDg{Killer031,Tancordia037,KNNTS041} smoke tests, plus
inline tests for each new section parser. Drops the stale
KNNTS039.json artefact left over from Phase 18 development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 13:23:17 +02:00

751 lines
19 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/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
)
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.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
}
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
}
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)
}
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",
"Bombings",
"Ships In Production",
"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 _, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionNone, "", true
}
if _, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionNone, "", 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) {
if len(fields) < 7 {
return
}
drive, err := parseFloat(fields[1])
if err != nil {
return
}
armament, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5])
mass, _ := parseFloat(fields[6])
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),
})
}
// 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),
})
}
}
// 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)
}