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:
@@ -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
|
||||||
|
|||||||
@@ -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-модуль,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user