// 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). 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" "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 ) type parser struct { rep report.Report sec section otherOwner string skipHeader bool sawHeader bool sawSize bool } 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) } return nil } func (p *parser) finish() (report.Report, error) { if !p.sawHeader { return report.Report{}, errors.New("legacyreport: missing report header line") } 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 "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), }) } func parseFloat(s string) (float64, error) { return strconv.ParseFloat(s, 64) }