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
+8
View File
@@ -732,6 +732,14 @@ The current report wire carries a `battle: [{ id, planet, shots }]`
summary per battle so the map markers know where to anchor without summary per battle so the map markers know where to anchor without
fetching every full `BattleReport`. fetching every full `BattleReport`.
For DEV / e2e the legacy-report CLI
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) emits an
envelope `{version: 1, report, battles}` where `battles` carries the
full `BattleReport`-s parsed out of legacy `Battle at (#N)` blocks.
The synthetic-report loader on the lobby unwraps the envelope and
hands every battle to `registerSyntheticBattle`, so the Battle Viewer
resolves any UUID without a network fetch.
### 6.6 Side effects ### 6.6 Side effects
A successful turn generation publishes a runtime snapshot into the A successful turn generation publishes a runtime snapshot into the
+8
View File
@@ -750,6 +750,14 @@ wiped), клик скроллит соответствующую строку в
на каждую битву, чтобы map-маркеры могли расположиться без на каждую битву, чтобы map-маркеры могли расположиться без
дополнительного запроса полного `BattleReport`. дополнительного запроса полного `BattleReport`.
Для DEV / e2e легаси-CLI
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт
envelope `{version: 1, report, battles}`, где `battles` несёт полные
`BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic-
загрузчик в лобби разбирает envelope и регистрирует каждую битву
через `registerSyntheticBattle`, так что Battle Viewer открывает
любой UUID без сетевого запроса.
### 6.6 Побочные эффекты ### 6.6 Побочные эффекты
Успешная генерация хода публикует runtime-snapshot в lobby-модуль, Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
+38 -12
View File
@@ -1,8 +1,25 @@
# legacy-report-to-json # legacy-report-to-json
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus* Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
engines that lived under `tools/local-dev/reports/`) into the JSON engines that lived under `tools/local-dev/reports/`) into a JSON
shape of [`pkg/model/report.Report`](../../../pkg/model/report). envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
plus full `BattleReport`s (Phase 27).
## Output envelope
```jsonc
{
"version": 1,
"report": { /* report.Report */ },
"battles": { "<uuid>": { /* report.BattleReport */ }, ... }
}
```
`version: 1` lets the UI distinguish a current-format envelope from a
bare `Report` JSON. The synthetic-report loader accepts both — pre-
envelope synthetic JSON files still load, just without battle
fixtures. `battles` is omitted when the legacy file has no combat
events.
The output is consumed by the **DEV-only synthetic-report loader** on The output is consumed by the **DEV-only synthetic-report loader** on
the UI client's lobby (`import.meta.env.DEV`). With it, the map view, the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
@@ -17,8 +34,8 @@ The tool is part of the synthetic-report parity rule documented in
```sh ```sh
# from the repo root, with the Go workspace active # from the repo root, with the Go workspace active
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \ go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
--in tools/local-dev/reports/dg/KNNTS039.REP \ --in tools/local-dev/reports/dg/KNNTS041.REP \
--out tools/local-dev/reports/dg/KNNTS039.json --out tools/local-dev/reports/dg/KNNTS041.json
``` ```
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or `--in` reads `-` as stdin; `--out` defaults to stdout when empty or
@@ -68,6 +85,21 @@ already decodes from server responses
| `LocalGroup[]` | `Your Groups` (Phase 19) | | `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) | | `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) | | `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
| `Battle[]` (summary) | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |
The envelope's `battles` map carries the full `BattleReport`-s parsed
out of the same blocks: every roster row turns into a
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
line turns into a `BattleActionReport`. UUIDs are synthesised
deterministically — `syntheticBattleID(idx)` for the battle
identifier (per-report 0-based index, SHA1 namespace
`be01a000-0000-0000-0000-000000000002`) and
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
converter on the same input file yields byte-identical JSON, so
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
stay stable across regenerations.
Players whose name in the legacy file ends with `_RIP` are emitted with Players whose name in the legacy file ends with `_RIP` are emitted with
the suffix stripped and `Extinct: true`. the suffix stripped and `Extinct: true`.
@@ -103,15 +135,9 @@ These exist in legacy reports but cannot be derived from the legacy
text format at all. Each could become in-scope if a strong enough text format at all. Each could become in-scope if a strong enough
reason arises (see "Adding a new field" below). reason arises (see "Adding a new field" below).
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema
carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text
carries per-battle rosters with stripped columns (no origin / range /
destination) and no stable identifier. Synthesising UUIDs from the
text would invent data that future Phase 27 work would have to drop;
the synthetic JSON therefore emits `battle: []`.
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear - `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the only inside battle rosters; the synthetic JSON emits
synthetic JSON emits `otherGroup: []`. `otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON - `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`. emits `unidentifiedGroup: []`.
- Cargo routes — no dedicated section in the legacy text format; the - Cargo routes — no dedicated section in the legacy text format; the
@@ -1,7 +1,18 @@
// Command legacy-report-to-json converts a legacy text-format Galaxy // Command legacy-report-to-json converts a legacy text-format Galaxy
// turn report (the "dg" / "gplus" engines) into the JSON shape of // turn report (the "dg" / "gplus" engines) into a JSON envelope
// pkg/model/report.Report. The resulting file is what the UI client's // readable by the UI client's DEV-only synthetic-report loader:
// DEV-only synthetic-report loader on the lobby consumes. //
// {
// "version": 1,
// "report": <Report JSON>,
// "battles": { "<uuid>": <BattleReport JSON>, ... }
// }
//
// Carrying the per-turn report and the full BattleReports in one
// payload lets the synthetic loader register the battles up-front
// so the Battle Viewer can render any battle without a network
// fetch. The bare Report shape (no envelope) the lobby loader
// historically accepted remains backward-compatible on the UI side.
package main package main
import ( import (
@@ -12,8 +23,18 @@ import (
"os" "os"
legacyreport "galaxy/legacy-report" legacyreport "galaxy/legacy-report"
"galaxy/model/report"
) )
// envelope is the on-disk shape emitted by this CLI. `Version` lets
// the UI loader distinguish a v1 envelope from a bare Report; future
// versions can bump it without breaking older synthetic JSON files.
type envelope struct {
Version int `json:"version"`
Report report.Report `json:"report"`
Battles map[string]report.BattleReport `json:"battles,omitempty"`
}
func main() { func main() {
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)") in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)") out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
@@ -31,7 +52,7 @@ func main() {
} }
defer closeIn() defer closeIn()
rep, err := legacyreport.Parse(r) rep, battles, err := legacyreport.Parse(r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "parse: %v\n", err) fmt.Fprintf(os.Stderr, "parse: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -44,9 +65,17 @@ func main() {
} }
defer closeOut() defer closeOut()
env := envelope{Version: 1, Report: rep}
if len(battles) > 0 {
env.Battles = make(map[string]report.BattleReport, len(battles))
for i := range battles {
env.Battles[battles[i].ID.String()] = battles[i]
}
}
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil { if err := enc.Encode(env); err != nil {
fmt.Fprintf(os.Stderr, "encode: %v\n", err) fmt.Fprintf(os.Stderr, "encode: %v\n", err)
os.Exit(1) os.Exit(1)
} }
+337 -14
View File
@@ -26,22 +26,29 @@ import (
) )
// Parse reads a legacy text report and returns a [report.Report] // Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields. The Width and Height of the // carrying the in-scope subset of fields, plus the per-battle
// returned report are both set to the legacy "Size" value (galaxies // [report.BattleReport] payloads parsed out of the "Battle at (#N)"
// are square in the legacy engines). // blocks. The Width and Height of the returned report are both set
func Parse(r io.Reader) (report.Report, error) { // to the legacy "Size" value (galaxies are square in the legacy
// engines). The battle slice is empty when the legacy file carries
// no combat events.
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
p := newParser() p := newParser()
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024) sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
for sc.Scan() { for sc.Scan() {
if err := p.handle(sc.Text()); err != nil { if err := p.handle(sc.Text()); err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err) return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
} }
return p.finish() battles, err := p.finish()
if err != nil {
return report.Report{}, nil, err
}
return p.rep, battles, nil
} }
type section int type section int
@@ -63,6 +70,8 @@ const (
sectionOtherShipTypes sectionOtherShipTypes
sectionBombings sectionBombings
sectionShipsInProduction sectionShipsInProduction
sectionBattle
sectionBattleProtocol
) )
type parser struct { type parser struct {
@@ -85,6 +94,40 @@ type parser struct {
pendingFleets []pendingFleet pendingFleets []pendingFleet
pendingIncomings []pendingIncoming pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction pendingShipProducts []pendingShipProduction
// Battle accumulator. `battles` collects every parsed BattleReport;
// `pendingBattle` carries the in-flight battle until its block
// ends (next "Battle at " header, a top-level section header, or
// end-of-file). `battleIndex` is the per-report 0-based index used
// to derive a stable synthetic UUID through `syntheticBattleID`.
// `pendingBattleRace` holds the race name currently being
// rostered, set by the "<Race> Groups" sub-header that opens each
// race's roster table inside the battle block.
battles []report.BattleReport
pendingBattle *pendingBattle
battleIndex uint
pendingBattleRace string
}
type pendingBattle struct {
id uuid.UUID
planet uint
planetName string
// Race name → race index used in Protocol.{a,d}. Indices are
// 0-based and assigned in first-seen order across the battle.
raceIndex map[string]int
// (race name, class name) → ship-group index used in
// Protocol.{sa,sd}. Indices are 0-based and assigned in
// first-seen order across the battle, across all races.
shipIndex map[shipKey]int
races map[int]uuid.UUID
ships map[int]report.BattleReportGroup
protocol []report.BattleActionReport
}
type shipKey struct {
race string
class string
} }
type pendingGroup struct { type pendingGroup struct {
@@ -155,10 +198,62 @@ func (p *parser) handle(line string) error {
return nil return nil
} }
// Inside a battle block, "<Race> Groups" lines open a per-race
// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
// and would otherwise be treated as a top-level section transition
// by classifySection. Trap it here so the battle state stays open.
if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
// New roster — the protocol block, if it had started,
// cannot reopen; but the engine never emits "<Race> Groups"
// after "Battle Protocol" inside the same battle.
p.sec = sectionBattle
p.pendingBattleRace = race
p.skipHeader = true
return nil
}
}
if newSec, owner, isHeader := classifySection(trimmed); isHeader { if newSec, owner, isHeader := classifySection(trimmed); isHeader {
// Flush the previous battle on any header transition that
// moves us out of the battle block. Sub-transitions
// (sectionBattle → sectionBattleProtocol or vice-versa)
// inside the same battle do not flush.
switch {
case newSec == sectionBattle:
p.flushPendingBattle()
planet, planetName, ok := parseBattleHeader(trimmed)
if ok {
p.pendingBattle = &pendingBattle{
id: syntheticBattleID(p.battleIndex),
planet: planet,
planetName: planetName,
raceIndex: make(map[string]int),
shipIndex: make(map[shipKey]int),
races: make(map[int]uuid.UUID),
ships: make(map[int]report.BattleReportGroup),
}
p.battleIndex++
}
p.pendingBattleRace = ""
case newSec == sectionBattleProtocol:
// Stay in the same battle; the protocol header itself
// has no column header to skip — `Battle Protocol` is
// followed by the shot lines directly. Reset
// pendingBattleRace because the roster phase ended.
p.pendingBattleRace = ""
default:
// Any other section transition closes the battle.
p.flushPendingBattle()
}
p.sec = newSec p.sec = newSec
p.otherOwner = owner p.otherOwner = owner
p.skipHeader = newSec != sectionNone // `Battle Protocol` has no column header to skip; ditto for
// the per-race `<Race> Groups` sub-header trapped above (we
// handle that branch separately). For sectionBattle the
// header line is "Battle at (#N) Name" with no following
// column row, so skipHeader stays false there as well.
p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
return nil return nil
} }
@@ -205,16 +300,21 @@ func (p *parser) handle(line string) error {
p.parseBombing(fields) p.parseBombing(fields)
case sectionShipsInProduction: case sectionShipsInProduction:
p.parseShipProductionRow(fields) p.parseShipProductionRow(fields)
case sectionBattle:
p.parseBattleRosterRow(fields)
case sectionBattleProtocol:
p.parseBattleProtocolLine(fields)
} }
return nil return nil
} }
func (p *parser) finish() (report.Report, error) { func (p *parser) finish() ([]report.BattleReport, error) {
if !p.sawHeader { if !p.sawHeader {
return report.Report{}, errors.New("legacyreport: missing report header line") return nil, errors.New("legacyreport: missing report header line")
} }
p.flushPendingBattle()
p.resolvePending() p.resolvePending()
return p.rep, nil return p.battles, nil
} }
// parseHeader extracts (race, turn) from // parseHeader extracts (race, turn) from
@@ -294,15 +394,16 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
case "Ships In Production": case "Ships In Production":
return sectionShipsInProduction, "", true return sectionShipsInProduction, "", true
case "Approaching Groups", case "Approaching Groups",
"Broadcast Message", "Broadcast Message":
"Battle Protocol":
return sectionNone, "", true return sectionNone, "", true
case "Battle Protocol":
return sectionBattleProtocol, "", true
} }
if strings.HasPrefix(line, "Status of Players") { if strings.HasPrefix(line, "Status of Players") {
return sectionStatusOfPlayers, "", true return sectionStatusOfPlayers, "", true
} }
if strings.HasPrefix(line, "Battle at ") { if strings.HasPrefix(line, "Battle at ") {
return sectionNone, "", true return sectionBattle, "", true
} }
if strings.HasPrefix(line, "=== ATTENTION") { if strings.HasPrefix(line, "=== ATTENTION") {
return sectionNone, "", true return sectionNone, "", true
@@ -637,6 +738,203 @@ func (p *parser) parseBombing(fields []string) {
}) })
} }
// parseBattleHeader extracts (planet, planetName) from a
// "Battle at (#N) <PlanetName>" line. The planet number is the
// integer between "(#" and ")"; the planet name is the rest of the
// line after the closing parenthesis (trimmed).
func parseBattleHeader(line string) (uint, string, bool) {
const prefix = "Battle at "
if !strings.HasPrefix(line, prefix) {
return 0, "", false
}
rest := strings.TrimSpace(line[len(prefix):])
if !strings.HasPrefix(rest, "(#") {
return 0, "", false
}
closing := strings.IndexByte(rest, ')')
if closing < 0 {
return 0, "", false
}
num, err := strconv.ParseUint(rest[2:closing], 10, 32)
if err != nil {
return 0, "", false
}
name := strings.TrimSpace(rest[closing+1:])
return uint(num), name, true
}
// parseBattleRosterRow consumes one ship-group line from a battle
// roster sub-table. Columns (10 tokens; the last is the per-group
// state word):
//
// # T D W S C T Q L state
// 1 Pistolet 1.6 1.00 1.00 0 - 0 1 In_Battle
//
// where column "L" carries the number of ships remaining after the
// battle (confirmed against KNNTS fixtures). Rows are appended to
// `pendingBattle.ships` under the race name currently held in
// `pendingBattleRace`.
func (p *parser) parseBattleRosterRow(fields []string) {
if p.pendingBattle == nil || p.pendingBattleRace == "" {
return
}
if len(fields) < 10 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
className := fields[1]
drive, _ := parseFloat(fields[2])
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5])
loadQuantity, _ := parseFloat(fields[7])
numLeft, err := strconv.ParseUint(fields[8], 10, 32)
if err != nil {
return
}
state := fields[9]
tech := make(map[string]report.Float, 4)
if drive != 0 {
tech["DRIVE"] = report.F(drive)
}
if weapons != 0 {
tech["WEAPONS"] = report.F(weapons)
}
if shields != 0 {
tech["SHIELDS"] = report.F(shields)
}
if cargo != 0 {
tech["CARGO"] = report.F(cargo)
}
p.assignRaceIndex(p.pendingBattleRace)
key := shipKey{race: p.pendingBattleRace, class: className}
idx := p.assignShipIndex(key)
p.pendingBattle.ships[idx] = report.BattleReportGroup{
Race: p.pendingBattleRace,
ClassName: className,
Tech: tech,
Number: uint(number),
NumberLeft: uint(numLeft),
LoadType: dashOrEmpty(fields[6]),
LoadQuantity: report.F(loadQuantity),
InBattle: state == "In_Battle",
}
}
// parseBattleProtocolLine consumes one shot line of the
// "Battle Protocol" sub-block. Required shape (8 tokens):
//
// <atkRace> <atkClass> fires on <defRace> <defClass> : <Destroyed|Shields>
//
// Anything else (including the empty line separating the protocol
// from the preceding rosters) is silently skipped — the engine never
// emits other text inside this block.
func (p *parser) parseBattleProtocolLine(fields []string) {
if p.pendingBattle == nil {
return
}
if len(fields) != 8 {
return
}
if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" {
return
}
atkRace, atkClass := fields[0], fields[1]
defRace, defClass := fields[4], fields[5]
destroyed := fields[7] == "Destroyed"
aRace := p.assignRaceIndex(atkRace)
dRace := p.assignRaceIndex(defRace)
sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass})
sd := p.assignShipIndex(shipKey{race: defRace, class: defClass})
// Synthesise a minimal BattleReportGroup entry when the shot
// references a (race, class) pair that the roster did not
// declare. This happens when the legacy emitter trims a roster
// row but the engine logged a shot for that group.
if _, ok := p.pendingBattle.ships[sa]; !ok {
p.pendingBattle.ships[sa] = report.BattleReportGroup{
Race: atkRace, ClassName: atkClass, InBattle: true,
Tech: map[string]report.Float{},
}
}
if _, ok := p.pendingBattle.ships[sd]; !ok {
p.pendingBattle.ships[sd] = report.BattleReportGroup{
Race: defRace, ClassName: defClass, InBattle: true,
Tech: map[string]report.Float{},
}
}
p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{
Attacker: aRace,
AttackerShipClass: sa,
Defender: dRace,
DefenderShipClass: sd,
Destroyed: destroyed,
})
}
// assignRaceIndex returns the in-battle race index for raceName,
// creating a new entry on first sight. Race indices are 0-based and
// monotonically increasing in first-seen order. The synthetic race
// UUID is derived from the race name through
// `syntheticBattleRaceNamespace`.
func (p *parser) assignRaceIndex(raceName string) int {
if idx, ok := p.pendingBattle.raceIndex[raceName]; ok {
return idx
}
idx := len(p.pendingBattle.raceIndex)
p.pendingBattle.raceIndex[raceName] = idx
p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName)
return idx
}
// assignShipIndex returns the in-battle ship-group index for
// (race, class), creating a new entry on first sight. Indices are
// 0-based and monotonically increasing in first-seen order across
// all races.
func (p *parser) assignShipIndex(key shipKey) int {
if idx, ok := p.pendingBattle.shipIndex[key]; ok {
return idx
}
idx := len(p.pendingBattle.shipIndex)
p.pendingBattle.shipIndex[key] = idx
return idx
}
// flushPendingBattle finalises the in-flight battle: appends the
// BattleReport to `p.battles` and a matching BattleSummary
// (id/planet/shots) to `p.rep.Battle`. No-op when no battle is
// pending. Idempotent — clears `pendingBattle` on completion.
func (p *parser) flushPendingBattle() {
if p.pendingBattle == nil {
return
}
pb := p.pendingBattle
p.pendingBattle = nil
p.pendingBattleRace = ""
br := report.BattleReport{
ID: pb.id,
Planet: pb.planet,
PlanetName: pb.planetName,
Races: pb.races,
Ships: pb.ships,
Protocol: pb.protocol,
}
p.battles = append(p.battles, br)
p.rep.Battle = append(p.rep.Battle, report.BattleSummary{
ID: pb.id,
Planet: pb.planet,
Shots: uint(len(pb.protocol)),
})
}
// parseShipProductionRow buffers a "Ships In Production" row for // parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns: // post-processing in [parser.finish]. Columns:
// //
@@ -957,6 +1255,31 @@ func syntheticGroupID(g uint) uuid.UUID {
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g)) return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
} }
// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
// per-report battle index can never collide with a ship-group id.
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
// value, stable across releases.
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")
// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
// per-battle race name → race UUID derivation that fills
// `BattleReport.Races`. Engine-side reports carry the real race
// UUID; the legacy text only carries the race name, so we derive a
// stable identifier from the name. The constant is independent of
// `syntheticBattleNamespace` so race UUIDs can never collide with
// battle UUIDs.
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")
func syntheticBattleID(idx uint) uuid.UUID {
return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
}
func syntheticBattleRaceID(name string) uuid.UUID {
return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
}
func dashOrEmpty(s string) string { func dashOrEmpty(s string) string {
if s == "-" { if s == "-" {
return "" return ""
+161 -29
View File
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
"KnightErrants 16.02", "KnightErrants 16.02",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) 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", " 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
" 1 579.12 489.37", " 1 579.12 489.37",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) 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", "Dragon 16.70 1 1.10 1.00 1 19.80",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
"_Drift 1 0 0 0", "_Drift 1 0 0 0",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) 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", "Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
" 17 Castle CombatFlame 990.10 0.07 1000.00", " 17 Castle CombatFlame 990.10 0.07 1000.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
" 99 Lost Frigate 100.00 0.05 500.00", " 99 Lost Frigate 100.00 0.05 500.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
} }
} }
// TestParseSkipsBattles covers the only remaining legacy section the // TestParseBattles exercises the battle-block parser end-to-end:
// parser ignores: "Battle at ..." headers and the following "Battle // two battles with two races each, full rosters, and protocols. The
// Protocol" block. Bombings, Ships In Production, and the per-race // inline fixture mirrors the KNNTS-style layout (race-named roster
// Sciences / Ship Types blocks now flow through real parsers; the // sub-headers, 10-column roster rows, 8-token shot lines) so any
// dedicated section tests below cover them. // drift from the real engine format breaks this test before a smoke
func TestParseSkipsBattles(t *testing.T) { // 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{ in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1", "Race Report for Galaxy PLUS Turn 1",
"", "",
"Battle at (#7) B-007", "Battle at (#7) B-007",
"", "",
"Foo Groups",
"",
"# T D W S C T Q L", "# T D W S C T Q L",
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", "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", "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", "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", " 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, battles, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) 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 { 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", " 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -515,7 +621,7 @@ func TestParseYourFleets(t *testing.T) {
" 1 Far 2 North Castle 4.50 20 In_Space", " 1 Far 2 North Castle 4.50 20 In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) 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", " 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -597,11 +703,12 @@ type smokeWant struct {
localGroups, localFleets, incomingGroups int localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int localScience, otherScience, otherShipClass int
bombings, shipProductions int bombings, shipProductions int
battles int
} }
func runSmoke(t *testing.T, path string, want smokeWant) { func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper() t.Helper()
rep, err := parseFile(t, path) rep, battles, err := parseFile(t, path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path) 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}, {"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings}, {"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions}, {"ShipProduction", len(rep.ShipProduction), want.shipProductions},
{"Battle (summary)", len(rep.Battle), want.battles},
{"BattleReport", len(battles), want.battles},
} }
for _, c := range checks { for _, c := range checks {
if c.got != c.want { if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, 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 // TestParseDgKNNTS039 is a smoke test: the parser must produce
@@ -676,6 +802,7 @@ func TestParseDgKNNTS039(t *testing.T) {
otherShipClass: 170, otherShipClass: 170,
bombings: 16, bombings: 16,
shipProductions: 6, shipProductions: 6,
battles: 28,
}) })
} }
@@ -694,6 +821,7 @@ func TestParseDgKNNTS040(t *testing.T) {
otherShipClass: 160, otherShipClass: 160,
bombings: 24, bombings: 24,
shipProductions: 16, shipProductions: 16,
battles: 79,
}) })
} }
@@ -715,6 +843,7 @@ func TestParseDgKNNTS041(t *testing.T) {
otherShipClass: 218, otherShipClass: 218,
bombings: 12, bombings: 12,
shipProductions: 22, shipProductions: 22,
battles: 56,
}) })
} }
@@ -736,6 +865,7 @@ func TestParseGplus40(t *testing.T) {
otherShipClass: 183, otherShipClass: 183,
bombings: 4, bombings: 4,
shipProductions: 8, shipProductions: 8,
battles: 30,
}) })
} }
@@ -757,6 +887,7 @@ func TestParseDgKiller031(t *testing.T) {
otherShipClass: 161, otherShipClass: 161,
bombings: 18, bombings: 18,
shipProductions: 0, shipProductions: 0,
battles: 83,
}) })
} }
@@ -779,18 +910,19 @@ func TestParseDgTancordia037(t *testing.T) {
otherShipClass: 123, otherShipClass: 123,
bombings: 22, bombings: 22,
shipProductions: 20, 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() t.Helper()
abs, err := filepath.Abs(rel) abs, err := filepath.Abs(rel)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
f, err := os.Open(abs) f, err := os.Open(abs)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
return Parse(f) return Parse(f)
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -39,6 +39,8 @@ import type {
} from "./game-state"; } from "./game-state";
import type { CargoLoadType, Relation } from "../sync/order-types"; import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType, isRelation } 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-"; export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
* loadSyntheticReportFromJSON validates the passed payload, decodes * loadSyntheticReportFromJSON validates the passed payload, decodes
* it into a `GameReport`, registers it in the in-memory map under a * it into a `GameReport`, registers it in the in-memory map under a
* fresh `synthetic-<uuid>` id, and returns both the id and the * 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): { export function loadSyntheticReportFromJSON(json: unknown): {
gameId: string; gameId: string;
report: GameReport; 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(); const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
SYNTHETIC_REPORTS.set(gameId, report); SYNTHETIC_REPORTS.set(gameId, report);
return { 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`, /** getSyntheticReport returns the report registered under `gameId`,
* or `undefined` if the entry was lost (e.g. page reload). */ * or `undefined` if the entry was lost (e.g. page reload). */
export function getSyntheticReport(gameId: string): GameReport | undefined { export function getSyntheticReport(gameId: string): GameReport | undefined {
@@ -16,6 +16,11 @@ import {
isSyntheticGameId, isSyntheticGameId,
loadSyntheticReportFromJSON, loadSyntheticReportFromJSON,
} from "../src/api/synthetic-report"; } 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 { function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
return { return {
@@ -244,3 +249,55 @@ describe("getSyntheticReport", () => {
expect(getSyntheticReport("synthetic-missing")).toBeUndefined(); 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();
});
});