// 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 // " 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) }