diff --git a/go.work b/go.work index 42429c8..6ca2236 100644 --- a/go.work +++ b/go.work @@ -18,6 +18,7 @@ use ( ./pkg/storage ./pkg/transcoder ./pkg/util + ./tools/local-dev/legacy-report ./ui/core ./ui/wasm ) diff --git a/tools/local-dev/legacy-report/README.md b/tools/local-dev/legacy-report/README.md new file mode 100644 index 0000000..8b0a7fa --- /dev/null +++ b/tools/local-dev/legacy-report/README.md @@ -0,0 +1,121 @@ +# legacy-report-to-json + +Converts legacy text-format Galaxy turn reports (the *dg* and *gplus* +engines that lived under `tools/local-dev/reports/`) into the JSON +shape of [`pkg/model/report.Report`](../../../pkg/model/report). + +The output is consumed by the **DEV-only synthetic-report loader** on +the UI client's lobby (`import.meta.env.DEV`). With it, the map view, +inspectors, and order-overlay can be exercised against rich game +states without playing many turns end-to-end against a real backend. + +The tool is part of the synthetic-report parity rule documented in +[`ui/PLAN.md`](../../../ui/PLAN.md). + +## Build / run + +```sh +# from the repo root, with the Go workspace active +go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \ + --in tools/local-dev/reports/dg/KNNTS039.REP \ + --out tools/local-dev/reports/dg/KNNTS039.json +``` + +`--in` reads `-` as stdin; `--out` defaults to stdout when empty or +`-`. The tool exits non-zero on any I/O or parse failure. + +## Supported input variants + +| Variant | Sample dir | Status | +| ------- | ------------------------------------- | ------------- | +| dg | `tools/local-dev/reports/dg/*.REP` | First-class | +| gplus | `tools/local-dev/reports/gplus/*.REP` | First-class | +| ng | `tools/local-dev/reports/ng/*.rep` | Not supported | +| lucky | `tools/local-dev/reports/lucky/*.rep` | Not supported | + +dg uses CRLF line endings, gplus uses LF and tabs in section indentation; +both are space-aligned tabular inside data blocks. The parser splits on +runs of whitespace (`strings.Fields`) so the same code handles both. + +Pseudo-Cyrillic glyphs (`MbI`, `KAMA3`, `9IMA`) appear in some races +and ship class names but are stored as plain ASCII letter substitutions +— no encoding conversion is needed. + +## In-scope fields (current) + +The parser only fills the subset of `report.Report` that the UI client +already decodes from server responses +(`ui/frontend/src/api/game-state.ts` → `decodeReport`): + +| `report.Report` field | Source section in legacy file | +| --------------------- | ------------------------------------ | +| `Race` | ` Report for Galaxy ...` line | +| `Turn` | same | +| `Width`, `Height` | `Size: N` (square galaxies) | +| `PlanetCount` | `Planets: N` | +| `VoteFor`, `Votes` | `Your vote:` block | +| `Player[]` | `Status of Players (total ...)` | +| `LocalPlanet[]` | `Your Planets` | +| `OtherPlanet[]` | ` Planets` (one per race) | +| `UninhabitedPlanet[]` | `Uninhabited Planets` | +| `UnidentifiedPlanet[]`| `Unidentified Planets` | +| `LocalShipClass[]` | `Your Ship Types` | + +Players whose name in the legacy file ends with `_RIP` are emitted with +the suffix stripped and `Extinct: true`. + +## Skipped sections (today) + +These exist in legacy reports but have no UI decoder yet, so the +parser ignores them. Each becomes in-scope as soon as its UI phase +lands (see "Adding a new field" below). + +- Foreign / other ship types (` Ship Types`) +- Sciences, both local (`Your Sciences`) and foreign (` Sciences`) +- Battles (`Battle at (#N) Name`, `Battle Protocol`) +- Bombings (`Bombings`) +- Approaching / foreign groups (`Approaching Groups`, ` Groups`) +- Ships in production (`Ships In Production`) +- Cargo routes — no dedicated section in the legacy text format; the + synthetic JSON emits `route: []`. The UI's overlay path + (`applyOrderOverlay`) supports running on top of an empty `routes`. + +## Adding a new field + +`ui/PLAN.md` carries a global rule: every UI phase that extends +`decodeReport` to read a new `report.Report` field also extends this +parser, in the same PR, to populate it from legacy text — or, if the +field cannot be derived, adds an entry to the **Skipped sections** +list above with a one-line explanation. + +The Go side of the rule is enforced mechanically: this tool imports +`galaxy/model/report`, so any backwards-incompatible change to the +schema breaks the tool's compilation before the change ships. + +When extending: + +1. Identify the legacy section in `tools/local-dev/reports/dg/*.REP` + (and `gplus/*.REP`) that carries the field, using `game/rules.txt` + section "Отчет о результатах хода" as the column-layout reference. +2. Add a section to the state machine in `parser.go` + (`classifySection`, the `section` constants, the `parse*` methods). +3. Cover the new section with a unit test in `parser_test.go` (inline + minimal fixture) and update the smoke counts in + `TestParseDgKNNTS039` / `TestParseGplus40` so a future regression + that drops the section is caught. +4. Run `go test ./tools/local-dev/legacy-report/...`, then re-run the + CLI on `dg/KNNTS039.REP` and `gplus/40.REP` and visually skim the + JSON — the field should appear with sensible values. + +## Tests + +```sh +go test ./tools/local-dev/legacy-report/... +``` + +Inline fixtures exercise the per-section row parsers; smoke tests +parse the real `dg/KNNTS039.REP` and `gplus/40.REP` and assert +top-level counts (number of planets, players, extinct races, ship +classes). Field-level fidelity is the inline tests' responsibility; +the smoke tests catch regressions where a refactor of the section +classifier silently drops a whole table. diff --git a/tools/local-dev/legacy-report/cmd/legacy-report-to-json/main.go b/tools/local-dev/legacy-report/cmd/legacy-report-to-json/main.go new file mode 100644 index 0000000..84fdb01 --- /dev/null +++ b/tools/local-dev/legacy-report/cmd/legacy-report-to-json/main.go @@ -0,0 +1,75 @@ +// Command legacy-report-to-json converts a legacy text-format Galaxy +// turn report (the "dg" / "gplus" engines) into the JSON shape of +// pkg/model/report.Report. The resulting file is what the UI client's +// DEV-only synthetic-report loader on the lobby consumes. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + legacyreport "galaxy/legacy-report" +) + +func main() { + in := flag.String("in", "", "path to legacy .REP file (use - for stdin)") + out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)") + flag.Parse() + + if *in == "" { + fmt.Fprintln(os.Stderr, "usage: legacy-report-to-json --in [--out ]") + os.Exit(2) + } + + r, closeIn, err := openInput(*in) + if err != nil { + fmt.Fprintf(os.Stderr, "open input: %v\n", err) + os.Exit(1) + } + defer closeIn() + + rep, err := legacyreport.Parse(r) + if err != nil { + fmt.Fprintf(os.Stderr, "parse: %v\n", err) + os.Exit(1) + } + + w, closeOut, err := openOutput(*out) + if err != nil { + fmt.Fprintf(os.Stderr, "open output: %v\n", err) + os.Exit(1) + } + defer closeOut() + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(rep); err != nil { + fmt.Fprintf(os.Stderr, "encode: %v\n", err) + os.Exit(1) + } +} + +func openInput(path string) (io.Reader, func(), error) { + if path == "-" { + return os.Stdin, func() {}, nil + } + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + return f, func() { _ = f.Close() }, nil +} + +func openOutput(path string) (io.Writer, func(), error) { + if path == "" || path == "-" { + return os.Stdout, func() {}, nil + } + f, err := os.Create(path) + if err != nil { + return nil, nil, err + } + return f, func() { _ = f.Close() }, nil +} diff --git a/tools/local-dev/legacy-report/go.mod b/tools/local-dev/legacy-report/go.mod new file mode 100644 index 0000000..cd12136 --- /dev/null +++ b/tools/local-dev/legacy-report/go.mod @@ -0,0 +1,9 @@ +module galaxy/legacy-report + +go 1.26.0 + +require galaxy/model v0.0.0 + +require github.com/google/uuid v1.6.0 // indirect + +replace galaxy/model => ../../../pkg/model diff --git a/tools/local-dev/legacy-report/go.sum b/tools/local-dev/legacy-report/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/tools/local-dev/legacy-report/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/tools/local-dev/legacy-report/parser.go b/tools/local-dev/legacy-report/parser.go new file mode 100644 index 0000000..d2335bf --- /dev/null +++ b/tools/local-dev/legacy-report/parser.go @@ -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 +// " 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) +} diff --git a/tools/local-dev/legacy-report/parser_test.go b/tools/local-dev/legacy-report/parser_test.go new file mode 100644 index 0000000..1f7ea77 --- /dev/null +++ b/tools/local-dev/legacy-report/parser_test.go @@ -0,0 +1,388 @@ +package legacyreport + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "galaxy/model/report" +) + +// TestParseHeaderAndSize covers the standalone single-line preamble. +func TestParseHeaderAndSize(t *testing.T) { + in := strings.Join([]string{ + " KnightErrants Report for Galaxy PLUS dg283 Turn 39 Thu Jul 06 09:01:16 2000", + "", + " Size: 800 Planets: 700 Players: 91", + "", + }, "\n") + + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := rep.Race, "KnightErrants"; got != want { + t.Errorf("Race = %q, want %q", got, want) + } + if got, want := rep.Turn, uint(39); got != want { + t.Errorf("Turn = %d, want %d", got, want) + } + if got, want := rep.Width, uint32(800); got != want { + t.Errorf("Width = %d, want %d", got, want) + } + if got, want := rep.Height, uint32(800); got != want { + t.Errorf("Height = %d, want %d", got, want) + } + if got, want := rep.PlanetCount, uint32(700); got != want { + t.Errorf("PlanetCount = %d, want %d", got, want) + } +} + +// TestParseStatusOfPlayers exercises the alive / extinct distinction +// that drives the races view. +func TestParseStatusOfPlayers(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Status of Players (total 10.00 votes)", + "", + "N D W S C P I # R V", + "Alpha 4.51 2.24 1.80 1.00 100.00 80.00 3 War 3.00", + "Bravo 9.03 5.62 2.16 1.53 200.00 150.00 5 Peace 5.00", + "Gone_RIP 1.00 1.00 1.00 1.00 0.00 0.00 0 War 0.00", + "", + }, "\n") + + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := len(rep.Player), 3; got != want { + t.Fatalf("len(Player) = %d, want %d", got, want) + } + alpha := rep.Player[0] + if alpha.Name != "Alpha" || alpha.Extinct { + t.Errorf("Alpha = %+v, want Name=Alpha extinct=false", alpha) + } + if alpha.Planets != 3 || alpha.Relation != "War" { + t.Errorf("Alpha planets/relation = %d/%q, want 3/War", alpha.Planets, alpha.Relation) + } + if got, want := float64(alpha.Drive), 4.51; got != want { + t.Errorf("Alpha.Drive = %v, want %v", got, want) + } + gone := rep.Player[2] + if gone.Name != "Gone" || !gone.Extinct { + t.Errorf("Gone = %+v, want Name=Gone extinct=true (suffix _RIP stripped)", gone) + } +} + +func TestParseYourVote(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Your vote:", + "", + "R V", + "KnightErrants 16.02", + "", + }, "\n") + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if rep.VoteFor != "KnightErrants" { + t.Errorf("VoteFor = %q, want KnightErrants", rep.VoteFor) + } + if got, want := float64(rep.Votes), 16.02; got != want { + t.Errorf("Votes = %v, want %v", got, want) + } +} + +func TestParseLocalAndOtherPlanets(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Your Planets", + "", + " # X Y N S P I R P $ M C L", + " 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Drive_Research 0.00 0.68 88.78 1000.00", + " 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00", + "", + "Monstrai Planets", + "", + " # X Y N S P I R P $ M C L", + " 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78", + "", + }, "\n") + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := len(rep.LocalPlanet), 2; got != want { + t.Fatalf("len(LocalPlanet) = %d, want %d", got, want) + } + castle := rep.LocalPlanet[0] + if castle.Number != 17 || castle.Name != "Castle" { + t.Errorf("Castle = (%d, %q), want (17, Castle)", castle.Number, castle.Name) + } + if castle.Production != "Drive_Research" { + t.Errorf("Castle.Production = %q, want Drive_Research", castle.Production) + } + if got, want := float64(castle.Size), 1000.0; got != want { + t.Errorf("Castle.Size = %v, want %v", got, want) + } + if got, want := len(rep.OtherPlanet), 1; got != want { + t.Fatalf("len(OtherPlanet) = %d, want %d", got, want) + } + skarabei := rep.OtherPlanet[0] + if skarabei.Owner != "Monstrai" { + t.Errorf("Skarabei.Owner = %q, want Monstrai", skarabei.Owner) + } + if skarabei.Number != 12 || skarabei.Name != "Skarabei" { + t.Errorf("Skarabei = (%d, %q), want (12, Skarabei)", skarabei.Number, skarabei.Name) + } +} + +func TestParseUninhabitedAndUnidentified(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Uninhabited Planets", + "", + " # X Y N S R $ M", + " 9 117.87 795.21 Dw2 500.00 10.00 0.00 500.00", + "", + "Unidentified Planets", + "", + " # X Y", + " 0 738.08 600.26", + " 1 579.12 489.37", + "", + }, "\n") + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := len(rep.UninhabitedPlanet), 1; got != want { + t.Fatalf("len(UninhabitedPlanet) = %d, want %d", got, want) + } + dw2 := rep.UninhabitedPlanet[0] + if dw2.Number != 9 || dw2.Name != "Dw2" { + t.Errorf("Dw2 = (%d, %q), want (9, Dw2)", dw2.Number, dw2.Name) + } + if got, want := len(rep.UnidentifiedPlanet), 2; got != want { + t.Fatalf("len(UnidentifiedPlanet) = %d, want %d", got, want) + } + first := rep.UnidentifiedPlanet[0] + if first.Number != 0 || float64(first.X) != 738.08 || float64(first.Y) != 600.26 { + t.Errorf("Unidentified[0] = %+v, want (0, 738.08, 600.26)", first) + } +} + +func TestParseShipClasses(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Your Ship Types", + "", + "N D A W S C M", + "Frontier 11.37 0 0.00 0.00 1.00 12.37", + "Bow105 74.77 105 1.00 19.72 1.00 148.49", + "", + "Monstrai Ship Types", + "", + "N D A W S C M", + "Dragon 16.70 1 1.10 1.00 1 19.80", + "", + }, "\n") + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := len(rep.LocalShipClass), 2; got != want { + t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want) + } + bow := rep.LocalShipClass[1] + if bow.Name != "Bow105" || bow.Armament != 105 { + t.Errorf("Bow105 name/armament = %q/%d, want Bow105/105", bow.Name, bow.Armament) + } + if got, want := float64(bow.Drive), 74.77; got != want { + t.Errorf("Bow105.Drive = %v, want %v", got, want) + } +} + +func TestParseSkipsBattlesAndBombings(t *testing.T) { + in := strings.Join([]string{ + "Race Report for Galaxy PLUS Turn 1", + "", + "Battle at (#7) B-007", + "", + "# T D W S C T Q L", + "1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", + "", + "Battle Protocol", + "", + "Foo fires on Bar : Destroyed", + "", + "Bombings", + "", + "# data line", + "", + "Your Planets", + "", + " # X Y N S P I R P $ M C L", + " 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00", + "", + }, "\n") + rep, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got, want := len(rep.LocalPlanet), 1; got != want { + t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want) + } +} + +// TestParseDgKNNTS039 is a smoke test: the parser must produce +// stable top-line counts from the real dg/KNNTS039.REP fixture. +// Field-level fidelity is asserted in the unit tests above; this +// test catches regressions where a section-classifier change +// silently drops half the data. +func TestParseDgKNNTS039(t *testing.T) { + const path = "../reports/dg/KNNTS039.REP" + rep, err := parseFile(t, path) + if err != nil { + if os.IsNotExist(err) { + t.Skipf("legacy report fixture missing: %s", path) + } + t.Fatalf("Parse %s: %v", path, err) + } + want := struct { + race string + turn uint + mapW, mapH, planetCount uint32 + voteFor string + votes float64 + players, extinct, local, other, uninhabited, unidentified, shipClasses int + }{ + race: "KnightErrants", turn: 39, + mapW: 800, mapH: 800, planetCount: 700, + voteFor: "KnightErrants", votes: 16.02, + players: 91, extinct: 49, + local: 22, other: 89, uninhabited: 17, unidentified: 572, + shipClasses: 24, + } + if rep.Race != want.race || rep.Turn != want.turn { + t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn) + } + if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount { + t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)", + rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount) + } + if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes { + t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)", + rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes) + } + extinct := 0 + for _, pl := range rep.Player { + if pl.Extinct { + extinct++ + } + } + if got, exp := len(rep.Player), want.players; got != exp { + t.Errorf("len(Player) = %d, want %d", got, exp) + } + if extinct != want.extinct { + t.Errorf("extinct = %d, want %d", extinct, want.extinct) + } + if got, exp := len(rep.LocalPlanet), want.local; got != exp { + t.Errorf("len(LocalPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.OtherPlanet), want.other; got != exp { + t.Errorf("len(OtherPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp { + t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp { + t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp { + t.Errorf("len(LocalShipClass) = %d, want %d", got, exp) + } +} + +// TestParseGplus40 mirrors TestParseDgKNNTS039 for the gplus engine +// fixture so the variant difference (tabs vs spaces in headers) is +// exercised on a real file. +func TestParseGplus40(t *testing.T) { + const path = "../reports/gplus/40.REP" + rep, err := parseFile(t, path) + if err != nil { + if os.IsNotExist(err) { + t.Skipf("legacy report fixture missing: %s", path) + } + t.Fatalf("Parse %s: %v", path, err) + } + want := struct { + race string + turn uint + mapW, mapH, planetCount uint32 + players, extinct, local, other, uninhabited, unidentified, shipClasses int + }{ + race: "MbI", turn: 40, + mapW: 350, mapH: 350, planetCount: 300, + players: 26, extinct: 0, + local: 26, other: 116, uninhabited: 7, unidentified: 152, + shipClasses: 56, + } + if rep.Race != want.race || rep.Turn != want.turn { + t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn) + } + if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount { + t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)", + rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount) + } + extinct := 0 + for _, pl := range rep.Player { + if pl.Extinct { + extinct++ + } + } + if got, exp := len(rep.Player), want.players; got != exp { + t.Errorf("len(Player) = %d, want %d", got, exp) + } + if extinct != want.extinct { + t.Errorf("extinct = %d, want %d", extinct, want.extinct) + } + if got, exp := len(rep.LocalPlanet), want.local; got != exp { + t.Errorf("len(LocalPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.OtherPlanet), want.other; got != exp { + t.Errorf("len(OtherPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp { + t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp { + t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp) + } + if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp { + t.Errorf("len(LocalShipClass) = %d, want %d", got, exp) + } +} + +func parseFile(t *testing.T, rel string) (report.Report, error) { + t.Helper() + abs, err := filepath.Abs(rel) + if err != nil { + return report.Report{}, err + } + f, err := os.Open(abs) + if err != nil { + return report.Report{}, err + } + defer func() { _ = f.Close() }() + return Parse(f) +}