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