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>
This commit is contained in:
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
|
||||
"KnightErrants 16.02",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestParseLocalAndOtherPlanets(t *testing.T) {
|
||||
" 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))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
|
||||
" 1 579.12 489.37",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestParseShipClasses(t *testing.T) {
|
||||
"Dragon 16.70 1 1.10 1.00 1 19.80",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
|
||||
"_Drift 1 0 0 0",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestParseBombings(t *testing.T) {
|
||||
"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))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
|
||||
" 17 Castle CombatFlame 990.10 0.07 1000.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
" 99 Lost Frigate 100.00 0.05 500.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSkipsBattles covers the only remaining legacy section the
|
||||
// parser ignores: "Battle at ..." headers and the following "Battle
|
||||
// Protocol" block. Bombings, Ships In Production, and the per-race
|
||||
// Sciences / Ship Types blocks now flow through real parsers; the
|
||||
// dedicated section tests below cover them.
|
||||
func TestParseSkipsBattles(t *testing.T) {
|
||||
// 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",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle",
|
||||
"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 fires on Bar : Destroyed",
|
||||
"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",
|
||||
"",
|
||||
@@ -415,15 +449,87 @@ func TestParseSkipsBattles(t *testing.T) {
|
||||
" 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))
|
||||
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 rows must not leak in)", got, want)
|
||||
t.Fatalf("len(LocalPlanet) = %d, want %d (battle state did not close)", got, want)
|
||||
}
|
||||
if got, want := len(rep.Battle), 0; got != want {
|
||||
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +557,7 @@ func TestParseYourGroups(t *testing.T) {
|
||||
" 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))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -515,7 +621,7 @@ func TestParseYourFleets(t *testing.T) {
|
||||
" 1 Far 2 North Castle 4.50 20 In_Space",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -564,7 +670,7 @@ func TestParseIncomingGroups(t *testing.T) {
|
||||
" 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))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -597,11 +703,12 @@ type smokeWant struct {
|
||||
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, err := parseFile(t, path)
|
||||
rep, battles, err := parseFile(t, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("legacy report fixture missing: %s", path)
|
||||
@@ -647,12 +754,31 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
{"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
|
||||
@@ -676,6 +802,7 @@ func TestParseDgKNNTS039(t *testing.T) {
|
||||
otherShipClass: 170,
|
||||
bombings: 16,
|
||||
shipProductions: 6,
|
||||
battles: 28,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -694,6 +821,7 @@ func TestParseDgKNNTS040(t *testing.T) {
|
||||
otherShipClass: 160,
|
||||
bombings: 24,
|
||||
shipProductions: 16,
|
||||
battles: 79,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,6 +843,7 @@ func TestParseDgKNNTS041(t *testing.T) {
|
||||
otherShipClass: 218,
|
||||
bombings: 12,
|
||||
shipProductions: 22,
|
||||
battles: 56,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,6 +865,7 @@ func TestParseGplus40(t *testing.T) {
|
||||
otherShipClass: 183,
|
||||
bombings: 4,
|
||||
shipProductions: 8,
|
||||
battles: 30,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -757,6 +887,7 @@ func TestParseDgKiller031(t *testing.T) {
|
||||
otherShipClass: 161,
|
||||
bombings: 18,
|
||||
shipProductions: 0,
|
||||
battles: 83,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,18 +910,19 @@ func TestParseDgTancordia037(t *testing.T) {
|
||||
otherShipClass: 123,
|
||||
bombings: 22,
|
||||
shipProductions: 20,
|
||||
battles: 57,
|
||||
})
|
||||
}
|
||||
|
||||
func parseFile(t *testing.T, rel string) (report.Report, error) {
|
||||
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{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return Parse(f)
|
||||
|
||||
Reference in New Issue
Block a user