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:
Ilia Denisov
2026-05-13 14:22:53 +02:00
parent 46996ebf31
commit b23649059f
9 changed files with 49585 additions and 11851 deletions
+162 -30
View File
@@ -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)