b23649059f
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>
304 lines
7.9 KiB
TypeScript
304 lines
7.9 KiB
TypeScript
// Vitest unit coverage for `api/synthetic-report.ts`. The decoder
|
|
// mirrors `pkg/model/report.Report` JSON (as emitted by the Go CLI
|
|
// `tools/local-dev/legacy-report/cmd/legacy-report-to-json`) into the
|
|
// in-game-shell `GameReport` shape. The tests assert the decoder
|
|
// flattens all four planet kinds, looks the local player's tech
|
|
// levels up by race name, defaults missing routes to empty, and
|
|
// rejects malformed input.
|
|
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
SYNTHETIC_GAME_ID_PREFIX,
|
|
SyntheticReportError,
|
|
getSyntheticReport,
|
|
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 {
|
|
turn: 39,
|
|
mapWidth: 800,
|
|
mapHeight: 800,
|
|
mapPlanets: 700,
|
|
race: "KnightErrants",
|
|
votes: 16.02,
|
|
voteFor: "KnightErrants",
|
|
player: [
|
|
{
|
|
name: "KnightErrants",
|
|
drive: 13.25,
|
|
weapons: 6.11,
|
|
shields: 7.09,
|
|
cargo: 1,
|
|
population: 16015.04,
|
|
industry: 13668.76,
|
|
planets: 22,
|
|
relation: "-",
|
|
votes: 16.02,
|
|
extinct: false,
|
|
},
|
|
{
|
|
name: "Other",
|
|
drive: 9.5,
|
|
weapons: 4.01,
|
|
shields: 4.69,
|
|
cargo: 1,
|
|
population: 0,
|
|
industry: 0,
|
|
planets: 0,
|
|
relation: "War",
|
|
votes: 0,
|
|
extinct: false,
|
|
},
|
|
],
|
|
localPlanet: [
|
|
{
|
|
number: 17,
|
|
name: "Castle",
|
|
x: 171.05,
|
|
y: 700.24,
|
|
size: 1000,
|
|
population: 1000,
|
|
industry: 1000,
|
|
resources: 10,
|
|
production: "Drive_Research",
|
|
capital: 0,
|
|
material: 0.68,
|
|
colonists: 88.78,
|
|
freeIndustry: 1000,
|
|
},
|
|
],
|
|
otherPlanet: [
|
|
{
|
|
owner: "Monstrai",
|
|
number: 12,
|
|
name: "Skarabei",
|
|
x: 303.84,
|
|
y: 579.23,
|
|
size: 500,
|
|
population: 500,
|
|
industry: 500,
|
|
resources: 10,
|
|
production: "Capital",
|
|
capital: 0,
|
|
material: 70.99,
|
|
colonists: 20.03,
|
|
freeIndustry: 341.78,
|
|
},
|
|
],
|
|
uninhabitedPlanet: [
|
|
{
|
|
number: 9,
|
|
name: "Dw2",
|
|
x: 117.87,
|
|
y: 795.21,
|
|
size: 500,
|
|
resources: 10,
|
|
capital: 0,
|
|
material: 500,
|
|
},
|
|
],
|
|
unidentifiedPlanet: [
|
|
{ number: 0, x: 738.08, y: 600.26 },
|
|
{ number: 1, x: 579.12, y: 489.37 },
|
|
],
|
|
localShipClass: [
|
|
{
|
|
name: "Frontier",
|
|
drive: 11.37,
|
|
armament: 0,
|
|
weapons: 0,
|
|
shields: 0,
|
|
cargo: 1,
|
|
mass: 12.37,
|
|
},
|
|
],
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
describe("loadSyntheticReportFromJSON", () => {
|
|
test("flattens all four planet kinds with kind-specific nullables", () => {
|
|
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
|
|
|
expect(isSyntheticGameId(gameId)).toBe(true);
|
|
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
|
|
|
|
expect(report.turn).toBe(39);
|
|
expect(report.mapWidth).toBe(800);
|
|
expect(report.mapHeight).toBe(800);
|
|
expect(report.planetCount).toBe(700);
|
|
expect(report.race).toBe("KnightErrants");
|
|
|
|
expect(report.planets).toHaveLength(5);
|
|
|
|
const local = report.planets.find((p) => p.kind === "local")!;
|
|
expect(local.name).toBe("Castle");
|
|
expect(local.industryStockpile).toBe(0);
|
|
expect(local.materialsStockpile).toBe(0.68);
|
|
expect(local.industry).toBe(1000);
|
|
expect(local.production).toBe("Drive_Research");
|
|
|
|
const other = report.planets.find((p) => p.kind === "other")!;
|
|
expect(other.owner).toBe("Monstrai");
|
|
expect(other.name).toBe("Skarabei");
|
|
|
|
const uninhab = report.planets.find((p) => p.kind === "uninhabited")!;
|
|
expect(uninhab.name).toBe("Dw2");
|
|
// Uninhabited planets carry size/resources/stockpiles but no
|
|
// industry / population / production.
|
|
expect(uninhab.size).toBe(500);
|
|
expect(uninhab.industry).toBeNull();
|
|
expect(uninhab.population).toBeNull();
|
|
expect(uninhab.production).toBeNull();
|
|
|
|
const unident = report.planets.filter((p) => p.kind === "unidentified");
|
|
expect(unident).toHaveLength(2);
|
|
expect(unident[0]!.name).toBe("");
|
|
expect(unident[0]!.size).toBeNull();
|
|
});
|
|
|
|
test("derives local player tech from the matching player row", () => {
|
|
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
|
expect(report.localPlayerDrive).toBe(13.25);
|
|
expect(report.localPlayerWeapons).toBe(6.11);
|
|
expect(report.localPlayerShields).toBe(7.09);
|
|
expect(report.localPlayerCargo).toBe(1);
|
|
});
|
|
|
|
test("returns zeros when the local race row is missing", () => {
|
|
const { report } = loadSyntheticReportFromJSON(
|
|
syntheticJSON({ race: "GhostRace" }),
|
|
);
|
|
expect(report.localPlayerDrive).toBe(0);
|
|
expect(report.localPlayerWeapons).toBe(0);
|
|
expect(report.localPlayerShields).toBe(0);
|
|
expect(report.localPlayerCargo).toBe(0);
|
|
});
|
|
|
|
test("emits empty routes (legacy format has no routes section)", () => {
|
|
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
|
expect(report.routes).toEqual([]);
|
|
});
|
|
|
|
test("registers the report under the returned game id", () => {
|
|
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
|
expect(getSyntheticReport(gameId)).toBe(report);
|
|
});
|
|
|
|
test("two loads produce distinct ids", () => {
|
|
const a = loadSyntheticReportFromJSON(syntheticJSON());
|
|
const b = loadSyntheticReportFromJSON(syntheticJSON());
|
|
expect(a.gameId).not.toBe(b.gameId);
|
|
});
|
|
|
|
test("rejects non-object input", () => {
|
|
expect(() => loadSyntheticReportFromJSON(null)).toThrow(
|
|
SyntheticReportError,
|
|
);
|
|
expect(() => loadSyntheticReportFromJSON(42)).toThrow(
|
|
SyntheticReportError,
|
|
);
|
|
expect(() => loadSyntheticReportFromJSON("a string")).toThrow(
|
|
SyntheticReportError,
|
|
);
|
|
});
|
|
|
|
test("ship classes survive with truncated armament", () => {
|
|
const { report } = loadSyntheticReportFromJSON(
|
|
syntheticJSON({
|
|
localShipClass: [
|
|
{
|
|
name: "Bow105",
|
|
drive: 74.77,
|
|
armament: 105,
|
|
weapons: 1,
|
|
shields: 19.72,
|
|
cargo: 1,
|
|
mass: 148.49,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(report.localShipClass).toHaveLength(1);
|
|
expect(report.localShipClass[0]!.name).toBe("Bow105");
|
|
expect(report.localShipClass[0]!.armament).toBe(105);
|
|
});
|
|
});
|
|
|
|
describe("isSyntheticGameId", () => {
|
|
test("recognises the synthetic prefix", () => {
|
|
expect(isSyntheticGameId("synthetic-abc")).toBe(true);
|
|
expect(isSyntheticGameId("00000000-0000-0000-0000-000000000000")).toBe(
|
|
false,
|
|
);
|
|
expect(isSyntheticGameId("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getSyntheticReport", () => {
|
|
test("returns undefined for unknown ids", () => {
|
|
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();
|
|
});
|
|
});
|