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:
@@ -39,6 +39,8 @@ import type {
|
||||
} from "./game-state";
|
||||
import type { CargoLoadType, Relation } from "../sync/order-types";
|
||||
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
||||
import type { BattleReport } from "./battle-fetch";
|
||||
import { registerSyntheticBattle } from "./synthetic-battle";
|
||||
|
||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||
|
||||
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
|
||||
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
||||
* it into a `GameReport`, registers it in the in-memory map under a
|
||||
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
||||
* decoded report. Throws `SyntheticReportError` for malformed input.
|
||||
* decoded report.
|
||||
*
|
||||
* Accepts two on-disk shapes:
|
||||
*
|
||||
* 1. Envelope (Phase 27 legacy-report CLI):
|
||||
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
|
||||
* — battles are forwarded to `registerSyntheticBattle` so the
|
||||
* Battle Viewer can resolve them offline.
|
||||
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
|
||||
* before; battle UUIDs in the report can still be clicked, but
|
||||
* the Viewer page will show "battle not found" because no
|
||||
* fixture was registered.
|
||||
*
|
||||
* Throws `SyntheticReportError` for malformed input in either shape.
|
||||
*/
|
||||
export function loadSyntheticReportFromJSON(json: unknown): {
|
||||
gameId: string;
|
||||
report: GameReport;
|
||||
} {
|
||||
const report = decodeSyntheticReport(json);
|
||||
const { reportPayload, battles } = extractEnvelope(json);
|
||||
const report = decodeSyntheticReport(reportPayload);
|
||||
for (const battle of battles) {
|
||||
registerSyntheticBattle(battle);
|
||||
}
|
||||
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
||||
SYNTHETIC_REPORTS.set(gameId, report);
|
||||
return { gameId, report };
|
||||
}
|
||||
|
||||
interface SyntheticEnvelope {
|
||||
version?: number;
|
||||
report?: unknown;
|
||||
battles?: Record<string, BattleReport>;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractEnvelope distinguishes the v1 envelope shape from a bare
|
||||
* Report payload. The envelope check is `version === 1` to leave room
|
||||
* for future format bumps and to avoid mistaking a bare Report whose
|
||||
* top-level fields happen to include `report`/`battles` (none do
|
||||
* today) for an envelope.
|
||||
*/
|
||||
function extractEnvelope(json: unknown): {
|
||||
reportPayload: unknown;
|
||||
battles: BattleReport[];
|
||||
} {
|
||||
if (typeof json !== "object" || json === null) {
|
||||
// Defer the error to `decodeSyntheticReport`; it already
|
||||
// raises a `SyntheticReportError` with the right message.
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
const env = json as SyntheticEnvelope;
|
||||
if (env.version === 1 && env.report !== undefined) {
|
||||
const battlesMap = env.battles ?? {};
|
||||
const battles: BattleReport[] = [];
|
||||
for (const value of Object.values(battlesMap)) {
|
||||
if (value && typeof value === "object") {
|
||||
battles.push(value);
|
||||
}
|
||||
}
|
||||
return { reportPayload: env.report, battles };
|
||||
}
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
|
||||
/** getSyntheticReport returns the report registered under `gameId`,
|
||||
* or `undefined` if the entry was lost (e.g. page reload). */
|
||||
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user