// 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, plus the per-battle // [report.BattleReport] payloads parsed out of the "Battle at (#N)" // blocks. The Width and Height of the returned report are both set // to the legacy "Size" value (galaxies are square in the legacy // engines). The battle slice is empty when the legacy file carries // no combat events. func Parse(r io.Reader) (report.Report, []report.BattleReport, 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{}, nil, err } } if err := sc.Err(); err != nil { return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err) } battles, err := p.finish() if err != nil { return report.Report{}, nil, err } return p.rep, battles, nil } type section int const ( sectionNone section = iota sectionStatusOfPlayers sectionYourVote sectionYourPlanets sectionOtherPlanets sectionUninhabitedPlanets sectionUnidentifiedPlanets sectionYourShipTypes sectionYourGroups sectionYourFleets sectionIncomingGroups sectionYourSciences sectionOtherSciences sectionOtherShipTypes sectionBombings sectionShipsInProduction sectionBattle sectionBattleProtocol ) 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 // Battle accumulator. `battles` collects every parsed BattleReport; // `pendingBattle` carries the in-flight battle until its block // ends (next "Battle at " header, a top-level section header, or // end-of-file). `battleIndex` is the per-report 0-based index used // to derive a stable synthetic UUID through `syntheticBattleID`. // `pendingBattleRace` holds the race name currently being // rostered, set by the " Groups" sub-header that opens each // race's roster table inside the battle block. battles []report.BattleReport pendingBattle *pendingBattle battleIndex uint pendingBattleRace string } type pendingBattle struct { id uuid.UUID planet uint planetName string // Race name → race index used in Protocol.{a,d}. Indices are // 0-based and assigned in first-seen order across the battle. raceIndex map[string]int // (race name, class name) → ship-group index used in // Protocol.{sa,sd}. Indices are 0-based and assigned in // first-seen order across the battle, across all races. shipIndex map[shipKey]int races map[int]uuid.UUID ships map[int]report.BattleReportGroup protocol []report.BattleActionReport } type shipKey struct { race string class string } 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 } // Inside a battle block, " Groups" lines open a per-race // roster sub-table. The line matches singleTokenPrefix(_, " Groups") // and would otherwise be treated as a top-level section transition // by classifySection. Trap it here so the battle state stays open. if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil { if race, ok := singleTokenPrefix(trimmed, " Groups"); ok { // New roster — the protocol block, if it had started, // cannot reopen; but the engine never emits " Groups" // after "Battle Protocol" inside the same battle. p.sec = sectionBattle p.pendingBattleRace = race p.skipHeader = true return nil } } if newSec, owner, isHeader := classifySection(trimmed); isHeader { // Flush the previous battle on any header transition that // moves us out of the battle block. Sub-transitions // (sectionBattle → sectionBattleProtocol or vice-versa) // inside the same battle do not flush. switch { case newSec == sectionBattle: p.flushPendingBattle() planet, planetName, ok := parseBattleHeader(trimmed) if ok { p.pendingBattle = &pendingBattle{ id: syntheticBattleID(p.battleIndex), planet: planet, planetName: planetName, raceIndex: make(map[string]int), shipIndex: make(map[shipKey]int), races: make(map[int]uuid.UUID), ships: make(map[int]report.BattleReportGroup), } p.battleIndex++ } p.pendingBattleRace = "" case newSec == sectionBattleProtocol: // Stay in the same battle; the protocol header itself // has no column header to skip — `Battle Protocol` is // followed by the shot lines directly. Reset // pendingBattleRace because the roster phase ended. p.pendingBattleRace = "" default: // Any other section transition closes the battle. p.flushPendingBattle() } p.sec = newSec p.otherOwner = owner // `Battle Protocol` has no column header to skip; ditto for // the per-race ` Groups` sub-header trapped above (we // handle that branch separately). For sectionBattle the // header line is "Battle at (#N) Name" with no following // column row, so skipHeader stays false there as well. p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol 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) case sectionBattle: p.parseBattleRosterRow(fields) case sectionBattleProtocol: p.parseBattleProtocolLine(fields) } return nil } func (p *parser) finish() ([]report.BattleReport, error) { if !p.sawHeader { return nil, errors.New("legacyreport: missing report header line") } p.flushPendingBattle() p.resolvePending() return p.battles, 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": return sectionYourSciences, "", true case "Bombings": return sectionBombings, "", true case "Ships In Production": return sectionShipsInProduction, "", true case "Approaching Groups", "Broadcast Message": return sectionNone, "", true case "Battle Protocol": return sectionBattleProtocol, "", true } if strings.HasPrefix(line, "Status of Players") { return sectionStatusOfPlayers, "", true } if strings.HasPrefix(line, "Battle at ") { return sectionBattle, "", 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 " 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 " 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, }) } // parseBattleHeader extracts (planet, planetName) from a // "Battle at (#N) " line. The planet number is the // integer between "(#" and ")"; the planet name is the rest of the // line after the closing parenthesis (trimmed). func parseBattleHeader(line string) (uint, string, bool) { const prefix = "Battle at " if !strings.HasPrefix(line, prefix) { return 0, "", false } rest := strings.TrimSpace(line[len(prefix):]) if !strings.HasPrefix(rest, "(#") { return 0, "", false } closing := strings.IndexByte(rest, ')') if closing < 0 { return 0, "", false } num, err := strconv.ParseUint(rest[2:closing], 10, 32) if err != nil { return 0, "", false } name := strings.TrimSpace(rest[closing+1:]) return uint(num), name, true } // parseBattleRosterRow consumes one ship-group line from a battle // roster sub-table. Columns (10 tokens; the last is the per-group // state word): // // # T D W S C T Q L state // 1 Pistolet 1.6 1.00 1.00 0 - 0 1 In_Battle // // where column "L" carries the number of ships remaining after the // battle (confirmed against KNNTS fixtures). Rows are appended to // `pendingBattle.ships` under the race name currently held in // `pendingBattleRace`. func (p *parser) parseBattleRosterRow(fields []string) { if p.pendingBattle == nil || p.pendingBattleRace == "" { return } if len(fields) < 10 { return } number, err := strconv.ParseUint(fields[0], 10, 32) if err != nil { return } className := fields[1] drive, _ := parseFloat(fields[2]) weapons, _ := parseFloat(fields[3]) shields, _ := parseFloat(fields[4]) cargo, _ := parseFloat(fields[5]) loadQuantity, _ := parseFloat(fields[7]) numLeft, err := strconv.ParseUint(fields[8], 10, 32) if err != nil { return } state := fields[9] tech := make(map[string]report.Float, 4) if drive != 0 { tech["DRIVE"] = report.F(drive) } if weapons != 0 { tech["WEAPONS"] = report.F(weapons) } if shields != 0 { tech["SHIELDS"] = report.F(shields) } if cargo != 0 { tech["CARGO"] = report.F(cargo) } p.assignRaceIndex(p.pendingBattleRace) key := shipKey{race: p.pendingBattleRace, class: className} idx := p.assignShipIndex(key) // Legacy battle rosters may list the same `(race, className)` // across multiple rows — different tech variants, ships pulled // from several stacks / planets, etc. We collapse those rows // into one BattleReportGroup keyed by `(race, className)` (the // viewer aggregates per class anyway) by SUMMING Number and // NumberLeft instead of overwriting; otherwise only the last // row's counts survive and the battle protocol's destroy count // would dwarf the recorded initial count (the original // motivation for the now-removed "phantom destroy" workaround). if existing, found := p.pendingBattle.ships[idx]; found { existing.Number += uint(number) existing.NumberLeft += uint(numLeft) // LoadQuantity is per-ship cargo — average is a fair fallback // when several stacks of the same class merge into one bucket. existing.LoadQuantity = report.F( (existing.LoadQuantity.F() + loadQuantity) / 2, ) // Tech / LoadType / InBattle keep their first-seen values: // the viewer treats them as bucket-wide attributes and the // first row is normally the most representative tech variant. p.pendingBattle.ships[idx] = existing return } p.pendingBattle.ships[idx] = report.BattleReportGroup{ Race: p.pendingBattleRace, ClassName: className, Tech: tech, Number: uint(number), NumberLeft: uint(numLeft), LoadType: dashOrEmpty(fields[6]), LoadQuantity: report.F(loadQuantity), InBattle: state == "In_Battle", } } // parseBattleProtocolLine consumes one shot line of the // "Battle Protocol" sub-block. Required shape (8 tokens): // // fires on : // // Anything else (including the empty line separating the protocol // from the preceding rosters) is silently skipped — the engine never // emits other text inside this block. func (p *parser) parseBattleProtocolLine(fields []string) { if p.pendingBattle == nil { return } if len(fields) != 8 { return } if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" { return } atkRace, atkClass := fields[0], fields[1] defRace, defClass := fields[4], fields[5] destroyed := fields[7] == "Destroyed" aRace := p.assignRaceIndex(atkRace) dRace := p.assignRaceIndex(defRace) sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass}) sd := p.assignShipIndex(shipKey{race: defRace, class: defClass}) // Synthesise a minimal BattleReportGroup entry when the shot // references a (race, class) pair that the roster did not // declare. This happens when the legacy emitter trims a roster // row but the engine logged a shot for that group. if _, ok := p.pendingBattle.ships[sa]; !ok { p.pendingBattle.ships[sa] = report.BattleReportGroup{ Race: atkRace, ClassName: atkClass, InBattle: true, Tech: map[string]report.Float{}, } } if _, ok := p.pendingBattle.ships[sd]; !ok { p.pendingBattle.ships[sd] = report.BattleReportGroup{ Race: defRace, ClassName: defClass, InBattle: true, Tech: map[string]report.Float{}, } } p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{ Attacker: aRace, AttackerShipClass: sa, Defender: dRace, DefenderShipClass: sd, Destroyed: destroyed, }) } // assignRaceIndex returns the in-battle race index for raceName, // creating a new entry on first sight. Race indices are 0-based and // monotonically increasing in first-seen order. The synthetic race // UUID is derived from the race name through // `syntheticBattleRaceNamespace`. func (p *parser) assignRaceIndex(raceName string) int { if idx, ok := p.pendingBattle.raceIndex[raceName]; ok { return idx } idx := len(p.pendingBattle.raceIndex) p.pendingBattle.raceIndex[raceName] = idx p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName) return idx } // assignShipIndex returns the in-battle ship-group index for // (race, class), creating a new entry on first sight. Indices are // 0-based and monotonically increasing in first-seen order across // all races. func (p *parser) assignShipIndex(key shipKey) int { if idx, ok := p.pendingBattle.shipIndex[key]; ok { return idx } idx := len(p.pendingBattle.shipIndex) p.pendingBattle.shipIndex[key] = idx return idx } // flushPendingBattle finalises the in-flight battle: appends the // BattleReport to `p.battles` and a matching BattleSummary // (id/planet/shots) to `p.rep.Battle`. No-op when no battle is // pending. Idempotent — clears `pendingBattle` on completion. func (p *parser) flushPendingBattle() { if p.pendingBattle == nil { return } pb := p.pendingBattle p.pendingBattle = nil p.pendingBattleRace = "" br := report.BattleReport{ ID: pb.id, Planet: pb.planet, PlanetName: pb.planetName, Races: pb.races, Ships: pb.ships, Protocol: pb.protocol, } p.battles = append(p.battles, br) p.rep.Battle = append(p.rep.Battle, report.BattleSummary{ ID: pb.id, Planet: pb.planet, Shots: uint(len(pb.protocol)), }) } // 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)) } // syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report // battle-index → UUID derivation used by `Report.Battle[i].ID` and // `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a // per-report battle index can never collide with a ship-group id. // Mirrors the rationale in `syntheticGroupNamespace`: arbitrary // value, stable across releases. var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002") // syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the // per-battle race name → race UUID derivation that fills // `BattleReport.Races`. Engine-side reports carry the real race // UUID; the legacy text only carries the race name, so we derive a // stable identifier from the name. The constant is independent of // `syntheticBattleNamespace` so race UUIDs can never collide with // battle UUIDs. var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003") func syntheticBattleID(idx uint) uuid.UUID { return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx)) } func syntheticBattleRaceID(name string) uuid.UUID { return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name)) } func dashOrEmpty(s string) string { if s == "-" { return "" } return s } func parseFloat(s string) (float64, error) { return strconv.ParseFloat(s, 64) }