tools/local-dev: legacy-report-to-json CLI for synthetic UI testing
A Go module under tools/local-dev/legacy-report that converts the "dg" / "gplus" engine .REP files in tools/local-dev/reports/ into the JSON shape of pkg/model/report.Report. The output drives a DEV-only synthetic-mode loader on the UI lobby so the map, inspectors, and order-overlay can be exercised against rich game states without playing many turns end-to-end. Scope is intentionally narrow: only the fields the UI client decodes today (planets, players, own ship classes, header). Importing pkg/model/report keeps the parser and the typed contract in lockstep — any backwards-incompatible schema change breaks the tool's compilation before it ships. The README spells out the parity rule for extending the parser alongside future UI decoders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
// 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
|
||||
// "<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 "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)
|
||||
}
|
||||
Reference in New Issue
Block a user