Files
Ilia Denisov bd11cd80da ui/phase-27: root-cause aggregation of duplicate (race, className) rows
Legacy reports list the same `(race, className)` pair across several
roster rows; the engine likewise creates one ShipGroup per arrival.
Both the legacy parser and `TransformBattle` were keyed on shipClass
without summing — only the last row / group's counts survived, so a
protocol's destroy count appeared to exceed the recorded initial
roster. The UI worked around this with phantom-frame logic.

Both parser and engine now SUM `Number`/`NumberLeft` across rows /
groups sharing the same class; the phantom-frame workaround is gone.
KNNTS041 turn 41 planet #7 reconciles: `Nails:pup` 1168 initial −
86 survivors = 1082 destroys.

The engine's previously latent nil-map write on `bg.Tech` (would
have paniced on any group with non-empty Tech) is fixed in the same
patch — it blocked the aggregation regression test.

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

1030 lines
35 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")
}
}
// TestParseBattleAggregatesDuplicateClasses guards against the
// regression that produced the original "phantom destroys" symptom:
// the same `(race, className)` pair appearing on multiple roster
// rows must collapse into a single BattleReportGroup whose `Number`
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
// column, survivors) are the sums across rows. Without the
// aggregation only the last row's counts survived and the protocol's
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
func TestParseBattleAggregatesDuplicateClasses(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",
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
"",
"Battle Protocol",
"",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
}, "\n")
rep, battles, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(battles), 1; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
b := battles[0]
// The three Foo roster rows collapse into two BattleReportGroup
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
if got, want := len(b.Ships), 3; got != want {
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
}
var drone, cruiser, pistolet *report.BattleReportGroup
for i := range b.Ships {
g := b.Ships[i]
switch g.ClassName {
case "Drone":
drone = &g
case "Cruiser":
cruiser = &g
case "Pistolet":
pistolet = &g
}
}
if drone == nil || cruiser == nil || pistolet == nil {
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
}
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
// Protocol corroborates: four Destroyed shots against Drone, so
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
// recorded delta only when both rows are summed.
if drone.Number != 7 {
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
}
if drone.NumberLeft != 3 {
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
}
// Cruiser and Pistolet are single-row classes — counts must match
// the file verbatim with no spurious merging across classes.
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
cruiser.Number, cruiser.NumberLeft)
}
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
pistolet.Number, pistolet.NumberLeft)
}
// rep-level summary must reflect the merged shape: 4 shots, one
// battle, no crash or spurious extra battles.
if got, want := len(rep.Battle), 1; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if rep.Battle[0].Shots != 4 {
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
}
}
// 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)
}