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
@@ -16,6 +16,11 @@ import {
isSyntheticGameId,
loadSyntheticReportFromJSON,
} from "../src/api/synthetic-report";
import type { BattleReport } from "../src/api/battle-fetch";
import {
lookupSyntheticBattle,
resetSyntheticBattles,
} from "../src/api/synthetic-battle";
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
return {
@@ -244,3 +249,55 @@ describe("getSyntheticReport", () => {
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
});
});
describe("envelope shape (v1)", () => {
test("forwards battles to the synthetic-battle registry", () => {
resetSyntheticBattles();
const battle: BattleReport = {
id: "11111111-1111-1111-1111-111111111111",
planet: 17,
planetName: "Castle",
races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
ships: {
"0": {
race: "KnightErrants",
className: "Drone",
tech: { DRIVE: 1 },
num: 1,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [],
};
const envelope = {
version: 1,
report: syntheticJSON(),
battles: { [battle.id]: battle },
};
const { gameId, report } = loadSyntheticReportFromJSON(envelope);
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle(battle.id)).toEqual(battle);
});
test("missing battles field leaves the registry untouched", () => {
resetSyntheticBattles();
const envelope = {
version: 1,
report: syntheticJSON(),
};
loadSyntheticReportFromJSON(envelope);
expect(lookupSyntheticBattle("any")).toBeNull();
});
test("bare Report (no envelope) still loads — backward compat", () => {
resetSyntheticBattles();
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle("any")).toBeNull();
});
});