Files
galaxy-game/tools/local-dev/legacy-report/parser_test.go
T
Ilia Denisov b23649059f legacy-report: parse battles + envelope JSON output
Side activity on top of Phase 27: the legacy-report tool now extracts
the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used
to skip. Both the per-battle summary (Report.Battle: []BattleSummary)
and the full BattleReport (rosters + protocol) flow through.

Parser:
- new sectionBattle / sectionBattleProtocol states, with handle()
  trapping the per-race "<Race> Groups" sub-headers so the roster
  stays attributed to the right race;
- parseBattleHeader extracts (planet, planetName) from
  "Battle at (#NN) <Name>";
- parseBattleRosterRow maps the 10-token row into
  BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against
  KNNTS fixtures;
- parseBattleProtocolLine counts shots and builds
  BattleActionReport entries from the 8-token "X Y fires on A B :
  Destroyed|Shields" lines;
- flushPendingBattle finalises a battle on next "Battle at" or any
  top-level section change and appends both the summary and the
  full report;
- syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise
  stable UUIDs in dedicated namespaces so re-runs produce
  byte-identical JSON.

Parse() signature widens to (Report, []BattleReport, error); the
single caller — the CLI — is updated.

CLI emits a v1 envelope:
  { "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } }
Bare-Report JSONs still load on the UI side for backward compat.

UI synthetic loader: loadSyntheticReportFromJSON detects the v1
envelope, decodes the report as before, and forwards every battle
through registerSyntheticBattle so the Battle Viewer resolves any
UUID offline. Pre-envelope JSON files (no `version` field) still
load — the battle registry stays empty for them.

Docs: legacy-report README moves Battles from "Skipped" to
in-scope, documents the envelope and UUID namespaces;
docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic
mode is now end-to-end via the envelope.

Tests:
- TestParseBattles covers two battles with full rosters,
  per-shot destroyed/shielded mapping, NumberLeft from column 8,
  deterministic UUIDs across re-parses, and proves a trailing
  top-level section still parses (battle state closes cleanly);
- smokeWant gains a battles count; runSmoke cross-checks
  BattleSummary ↔ BattleReport alignment (id/planet/shots);
- all six real-fixture smoke tests pinned to their `Battle at`
  counts (28, 79, 56, 30, 83, 57);
- Vitest covers the synthetic-report envelope path (battles
  forwarded, missing-battles tolerated, bare-Report backward
  compat);
- KNNTS041.json regenerated against the new parser (existing
  diff was stale w.r.t. Phase 23 anyway; this commit brings it
  in line with the v1 envelope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:22:53 +02:00

930 lines
31 KiB
Go

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", 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)
}
if got, want := len(rep.OtherShipClass), 1; got != want {
t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
}
dragon := rep.OtherShipClass[0]
if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
dragon.Race, dragon.Name, dragon.Armament)
}
if got, want := float64(dragon.Mass), 19.80; got != want {
t.Errorf("Dragon.Mass = %v, want %v", got, want)
}
}
// TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
// in one fixture. The five-column layout (N D W S C) is shared.
func TestParseSciences(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Sciences",
"",
"N D W S C",
"_TerraForm 1 0 0 0",
"BalancedMix 0.5 0.2 0.2 0.1",
"",
"Pahanchiks Sciences",
"",
"N D W S C",
"_Drift 1 0 0 0",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalScience), 2; got != want {
t.Fatalf("len(LocalScience) = %d, want %d", got, want)
}
mix := rep.LocalScience[1]
if mix.Name != "BalancedMix" {
t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
}
if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
float64(mix.Drive), float64(mix.Cargo))
}
if got, want := len(rep.OtherScience), 1; got != want {
t.Fatalf("len(OtherScience) = %d, want %d", got, want)
}
drift := rep.OtherScience[0]
if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
drift.Race, drift.Name)
}
}
// TestParseBombings covers a wiped row + a damaged row + the duplicate
// `P` column header (population vs production string) — assertions
// hit every wire field so a positional-index slip is caught.
func TestParseBombings(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Bombings",
"",
"W O # N P I P $ M C A",
"Knights Ricksha 20 DW-1207 1.56 0.00 Dron 0.00 0.00 0.00 7.62 Wiped",
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.Bombing), 2; got != want {
t.Fatalf("len(Bombing) = %d, want %d", got, want)
}
wiped := rep.Bombing[0]
if !wiped.Wiped {
t.Errorf("Bombing[0].Wiped = false, want true")
}
if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
wiped.Attacker, wiped.Owner, wiped.Number)
}
if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
wiped.Planet, wiped.Production)
}
if float64(wiped.AttackPower) != 7.62 {
t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
}
damaged := rep.Bombing[1]
if damaged.Wiped {
t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
}
if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
float64(damaged.Capital), float64(damaged.Colonists))
}
}
// TestParseShipsInProduction covers the prod_used derivation through
// [calc.ShipBuildCost]. The producing planet is mounted first with a
// non-zero material stockpile so the farming term contributes a
// non-trivial slice of totalCost; the expected prod_used number is
// derived from totalCost * percent with the same formula the parser
// uses.
func TestParseShipsInProduction(t *testing.T) {
// Planet: Material=0.68, Resources=10.00.
// Ship: cost=990.10 -> shipMass=99.01.
// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
// = 990.10 + 9.833
// = 999.933
// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
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 CombatFlame 0.00 0.68 88.78 1000.00",
"",
"Ships In Production",
"",
" # N S C P L",
" 17 Castle CombatFlame 990.10 0.07 1000.00",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 1; got != want {
t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
}
sp := rep.ShipProduction[0]
if sp.Planet != 17 || sp.Class != "CombatFlame" {
t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
sp.Planet, sp.Class)
}
if got := float64(sp.Cost); got != 990.10 {
t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
}
if got := float64(sp.Percent); got != 0.07 {
t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
}
if got := float64(sp.Free); got != 1000.0 {
t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
}
wantProdUsed := 69.995
if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
got, wantProdUsed)
}
}
// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
// net: a ships-in-production row referencing a planet not seen in
// "Your Planets" is dropped, because the prod_used derivation needs
// the planet's material and resources.
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Ships In Production",
"",
" # N S C P L",
" 99 Lost Frigate 100.00 0.05 500.00",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 0; got != want {
t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
got, want)
}
}
// TestParseBattles exercises the battle-block parser end-to-end:
// two battles with two races each, full rosters, and protocols. The
// inline fixture mirrors the KNNTS-style layout (race-named roster
// sub-headers, 10-column roster rows, 8-token shot lines) so any
// drift from the real engine format breaks this test before a smoke
// regression. Asserts:
// - report.Battle carries one BattleSummary per "Battle at"
// - BattleReport slice mirrors that with full Races/Ships/Protocol
// - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
// map to BattleActionReport entries with the correct destroyed flag
// - Roster column 8 (the "L" column) populates NumberLeft
// - Top-level sections after a battle (Your Planets) still parse
// — battle state must close cleanly without leaking rows.
func TestParseBattles(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Battle at (#7) B-007",
"",
"Foo Groups",
"",
"# T D W S C T Q L",
"1 PeaceShip 4.0 0 0 0 - 0 1 In_Battle",
"2 Drone 0.0 1 1 0 - 0 0 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"1 Pistolet 1.0 1.0 0 0 - 0 1 In_Battle",
"",
"Battle Protocol",
"",
"Foo PeaceShip fires on Bar Pistolet : Shields",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
"Battle at (#11) X-011",
"",
"Foo Groups",
"",
"# T D W S C T Q L",
"1 Scout 1.0 0 0 0 - 0 1 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"1 Sniper 2.0 1 0 0 - 0 0 In_Battle",
"",
"Battle Protocol",
"",
"Foo Scout fires on Bar Sniper : Destroyed",
"",
"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, battles, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
// The trailing Your Planets section must still parse — battle
// state must close before the next top-level header.
if got, want := len(rep.LocalPlanet), 1; got != want {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle state did not close)", got, want)
}
if got, want := len(rep.Battle), 2; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if got, want := len(battles), 2; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
// First battle: planet 7, 3 shots; protocol shape with one
// shielded shot and two destroyed shots.
b0 := battles[0]
if b0.Planet != 7 || b0.PlanetName != "B-007" {
t.Errorf("battle[0] = (planet=%d, name=%q), want (7, %q)",
b0.Planet, b0.PlanetName, "B-007")
}
if got, want := len(b0.Protocol), 3; got != want {
t.Fatalf("battle[0].Protocol = %d shots, want %d", got, want)
}
if b0.Protocol[0].Destroyed {
t.Errorf("battle[0].Protocol[0].Destroyed = true (Shields hit), want false")
}
if !b0.Protocol[1].Destroyed || !b0.Protocol[2].Destroyed {
t.Errorf("battle[0].Protocol[1..2].Destroyed must be true (Destroyed hits)")
}
// First battle: roster size and NumberLeft mapping.
if got, want := len(b0.Ships), 3; got != want {
t.Fatalf("battle[0].Ships = %d groups, want %d", got, want)
}
// 'Drone' has NumberLeft=0 in the roster (column 8 = 0). The
// protocol corroborates: Pistolet destroyed Drone twice.
dronePresent := false
for _, ship := range b0.Ships {
if ship.ClassName == "Drone" {
dronePresent = true
if ship.NumberLeft != 0 {
t.Errorf("Drone.NumberLeft = %d, want 0", ship.NumberLeft)
}
if ship.Number != 2 {
t.Errorf("Drone.Number = %d, want 2", ship.Number)
}
}
}
if !dronePresent {
t.Errorf("Drone roster row not parsed into battle[0].Ships")
}
// Summary mirrors the BattleReport ID and shot count.
if rep.Battle[0].ID != b0.ID {
t.Errorf("rep.Battle[0].ID = %s, want %s", rep.Battle[0].ID, b0.ID)
}
if rep.Battle[0].Shots != 3 {
t.Errorf("rep.Battle[0].Shots = %d, want 3", rep.Battle[0].Shots)
}
if rep.Battle[0].Planet != 7 {
t.Errorf("rep.Battle[0].Planet = %d, want 7", rep.Battle[0].Planet)
}
// Second battle: planet 11, 1 shot.
if rep.Battle[1].Planet != 11 || rep.Battle[1].Shots != 1 {
t.Errorf("rep.Battle[1] = (planet=%d, shots=%d), want (11, 1)",
rep.Battle[1].Planet, rep.Battle[1].Shots)
}
// Battle IDs are stable across re-parses.
rep2, battles2, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse (second pass): %v", err)
}
if rep.Battle[0].ID != rep2.Battle[0].ID || battles[0].ID != battles2[0].ID {
t.Errorf("battle id must be deterministic across re-parses")
}
}
// TestParseYourGroups exercises the local-group section. Two rows
// cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space",
// origin name + range) variants, plus a cargo-loaded row to assert the
// load-type / load-quantity columns are wired through. A planet
// table is mounted upfront so destination/origin name resolution
// has something to bind against.
func TestParseYourGroups(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 Capital 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",
"",
"Your Groups",
"",
" G # T D W S C T Q D F R P M L",
" 0 2 Frontier 5.05 0.00 0.00 1.0 - 0 Castle - - 92.84 12.37 - In_Orbit",
" 1 1 Bow105 11.19 4.76 7.09 1.0 COL 1 Castle - - 111.9 149.54 - In_Orbit",
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalGroup), 3; got != want {
t.Fatalf("len(LocalGroup) = %d, want %d", got, want)
}
first := rep.LocalGroup[0]
if first.Number != 2 || first.Class != "Frontier" {
t.Errorf("first group = (%d, %q), want (2, Frontier)", first.Number, first.Class)
}
if got, want := float64(first.Tech["drive"]), 5.05; got != want {
t.Errorf("first.Tech[drive] = %v, want %v", got, want)
}
if first.Destination != 17 {
t.Errorf("first.Destination = %d, want 17 (Castle resolved)", first.Destination)
}
if first.Origin != nil || first.Range != nil {
t.Errorf("first.{Origin,Range} = (%v, %v), want both nil for In_Orbit", first.Origin, first.Range)
}
if first.State != "In_Orbit" {
t.Errorf("first.State = %q, want In_Orbit", first.State)
}
if first.Fleet != nil {
t.Errorf("first.Fleet = %v, want nil for `-`", first.Fleet)
}
loaded := rep.LocalGroup[1]
if loaded.Cargo != "COL" || float64(loaded.Load) != 1.0 {
t.Errorf("loaded cargo/load = (%q, %v), want (COL, 1.0)", loaded.Cargo, float64(loaded.Load))
}
flying := rep.LocalGroup[2]
if flying.State != "In_Space" {
t.Errorf("flying.State = %q, want In_Space", flying.State)
}
if flying.Origin == nil || *flying.Origin != 17 {
t.Errorf("flying.Origin = %v, want 17 (Castle)", flying.Origin)
}
if flying.Range == nil || float64(*flying.Range) != 7.5 {
t.Errorf("flying.Range = %v, want 7.5", flying.Range)
}
if flying.Destination != 87 {
t.Errorf("flying.Destination = %d, want 87 (North)", flying.Destination)
}
}
func TestParseYourFleets(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 Capital 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",
"",
"Your Fleets",
"",
" # N G D F R P",
" 0 Fast 3 Castle - - 45 In_Orbit",
" 1 Far 2 North Castle 4.50 20 In_Space",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalFleet), 2; got != want {
t.Fatalf("len(LocalFleet) = %d, want %d", got, want)
}
fast := rep.LocalFleet[0]
if fast.Name != "Fast" || fast.Groups != 3 || fast.Destination != 17 {
t.Errorf("fast = %+v, want Name=Fast Groups=3 Destination=17", fast)
}
if fast.State != "In_Orbit" {
t.Errorf("fast.State = %q, want In_Orbit", fast.State)
}
if fast.Origin != nil || fast.Range != nil {
t.Errorf("fast.{Origin,Range} = (%v, %v), want both nil", fast.Origin, fast.Range)
}
far := rep.LocalFleet[1]
if far.Origin == nil || *far.Origin != 17 {
t.Errorf("far.Origin = %v, want 17 (Castle)", far.Origin)
}
if far.Range == nil || float64(*far.Range) != 4.5 {
t.Errorf("far.Range = %v, want 4.5", far.Range)
}
}
func TestParseIncomingGroups(t *testing.T) {
// Origin is a `#NN` by-id reference; destination resolves
// against the local planet table that was parsed earlier in
// this synthetic file. The order is intentionally swapped — in
// real legacy reports "Incoming Groups" can land before
// "Your Planets", which is why the parser buffers rows for
// post-processing.
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Incoming Groups",
"",
"O D R S M",
"#98 Castle 136.16 190 1",
"North Castle 42.12 99 2",
"",
"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",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.IncomingGroup), 2; got != want {
t.Fatalf("len(IncomingGroup) = %d, want %d", got, want)
}
a := rep.IncomingGroup[0]
if a.Origin != 98 || a.Destination != 17 {
t.Errorf("a (Origin, Destination) = (%d, %d), want (98, 17)", a.Origin, a.Destination)
}
if float64(a.Distance) != 136.16 || float64(a.Speed) != 190 {
t.Errorf("a (Distance, Speed) = (%v, %v), want (136.16, 190)", float64(a.Distance), float64(a.Speed))
}
b := rep.IncomingGroup[1]
if b.Origin != 87 || b.Destination != 17 {
t.Errorf("b (Origin, Destination) = (%d, %d), want (87, 17)", b.Origin, b.Destination)
}
}
// --- smoke tests -----------------------------------------------------
type smokeWant struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int
bombings, shipProductions int
battles int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper()
rep, battles, 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)
}
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 want.voteFor != "" {
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++
}
}
checks := []struct {
name string
got int
want int
}{
{"Player", len(rep.Player), want.players},
{"extinct", extinct, want.extinct},
{"LocalPlanet", len(rep.LocalPlanet), want.local},
{"OtherPlanet", len(rep.OtherPlanet), want.other},
{"UninhabitedPlanet", len(rep.UninhabitedPlanet), want.uninhabited},
{"UnidentifiedPlanet", len(rep.UnidentifiedPlanet), want.unidentified},
{"LocalShipClass", len(rep.LocalShipClass), want.shipClasses},
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
{"LocalScience", len(rep.LocalScience), want.localScience},
{"OtherScience", len(rep.OtherScience), want.otherScience},
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
{"Battle (summary)", len(rep.Battle), want.battles},
{"BattleReport", len(battles), want.battles},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
}
}
for i, summary := range rep.Battle {
if i >= len(battles) {
break
}
if summary.ID != battles[i].ID {
t.Errorf("battle[%d].ID summary=%s vs report=%s",
i, summary.ID, battles[i].ID)
}
if summary.Shots != uint(len(battles[i].Protocol)) {
t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
i, summary.Shots, len(battles[i].Protocol))
}
if summary.Planet != battles[i].Planet {
t.Errorf("battle[%d].Planet summary=%d vs report=%d",
i, summary.Planet, battles[i].Planet)
}
}
}
// 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) {
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
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,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
battles: 28,
})
}
func TestParseDgKNNTS040(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
race: "KnightErrants", turn: 40,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49,
local: 22, other: 93, uninhabited: 27, unidentified: 558,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
battles: 79,
})
}
// TestParseDgKNNTS041 covers a turn with active "Incoming Groups"
// entries (12 rows) appearing before the "Your Planets" table —
// exercises the deferred name-resolution path in [parser.finish].
func TestParseDgKNNTS041(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
race: "KnightErrants", turn: 41,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50,
local: 29, other: 103, uninhabited: 23, unidentified: 545,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
localScience: 1,
otherScience: 1,
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
battles: 56,
})
}
// TestParseGplus40 exercises the gplus engine variant (tabs in
// section headers, pseudo-cyrillic ASCII names) and a single fleet.
// gplus also sneaks "Incoming Groups" between sections.
func TestParseGplus40(t *testing.T) {
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
race: "MbI", turn: 40,
mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
localScience: 0,
otherScience: 0,
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
battles: 30,
})
}
// TestParseDgKiller031 exercises the Killer engine variant which
// ships "Your Fleets" + "Your Groups" with the Fl1/F2 fleet
// membership shape (no "Incoming Groups" this turn).
func TestParseDgKiller031(t *testing.T) {
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
race: "Killer", turn: 31,
mapW: 250, mapH: 250, planetCount: 175,
players: 25, extinct: 12,
local: 18, other: 127, uninhabited: 20, unidentified: 10,
shipClasses: 11,
localGroups: 175,
localFleets: 2,
incomingGroups: 0,
localScience: 0,
otherScience: 0,
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
battles: 83,
})
}
// TestParseDgTancordia037 is the richest smoke fixture: it carries
// 311 local groups across 30 fleets, two incoming groups, and the
// "Incoming Groups" section appears before "Your Planets" (so the
// deferred name resolution is exercised in production conditions).
func TestParseDgTancordia037(t *testing.T) {
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
race: "Tancordia", turn: 37,
mapW: 210, mapH: 210, planetCount: 140,
players: 18, extinct: 7,
local: 23, other: 62, uninhabited: 26, unidentified: 29,
shipClasses: 40,
localGroups: 311,
localFleets: 30,
incomingGroups: 2,
localScience: 1,
otherScience: 1,
otherShipClass: 123,
bombings: 22,
shipProductions: 20,
battles: 57,
})
}
func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
t.Helper()
abs, err := filepath.Abs(rel)
if err != nil {
return report.Report{}, nil, err
}
f, err := os.Open(abs)
if err != nil {
return report.Report{}, nil, err
}
defer func() { _ = f.Close() }()
return Parse(f)
}