ui: plan 01-27 done #1
@@ -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
|
||||
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
|
||||
|
||||
A successful turn generation publishes a runtime snapshot into the
|
||||
|
||||
@@ -750,6 +750,14 @@ wiped), клик скроллит соответствующую строку в
|
||||
на каждую битву, чтобы map-маркеры могли расположиться без
|
||||
дополнительного запроса полного `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 Побочные эффекты
|
||||
|
||||
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
# legacy-report-to-json
|
||||
|
||||
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
|
||||
engines that lived under `tools/local-dev/reports/`) into the JSON
|
||||
shape of [`pkg/model/report.Report`](../../../pkg/model/report).
|
||||
engines that lived under `tools/local-dev/reports/`) into a JSON
|
||||
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 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
|
||||
# from the repo root, with the Go workspace active
|
||||
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
|
||||
--in tools/local-dev/reports/dg/KNNTS039.REP \
|
||||
--out tools/local-dev/reports/dg/KNNTS039.json
|
||||
--in tools/local-dev/reports/dg/KNNTS041.REP \
|
||||
--out tools/local-dev/reports/dg/KNNTS041.json
|
||||
```
|
||||
|
||||
`--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) |
|
||||
| `LocalFleet[]` | `Your Fleets` (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
|
||||
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
|
||||
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
|
||||
only inside battle rosters (see above), with stripped columns; the
|
||||
synthetic JSON emits `otherGroup: []`.
|
||||
only inside battle rosters; the synthetic JSON emits
|
||||
`otherGroup: []`.
|
||||
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
|
||||
emits `unidentifiedGroup: []`.
|
||||
- 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
|
||||
// turn report (the "dg" / "gplus" engines) into the JSON shape of
|
||||
// pkg/model/report.Report. The resulting file is what the UI client's
|
||||
// DEV-only synthetic-report loader on the lobby consumes.
|
||||
// turn report (the "dg" / "gplus" engines) into a JSON envelope
|
||||
// readable by the UI client's DEV-only synthetic-report loader:
|
||||
//
|
||||
// {
|
||||
// "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
|
||||
|
||||
import (
|
||||
@@ -12,8 +23,18 @@ import (
|
||||
"os"
|
||||
|
||||
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() {
|
||||
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)")
|
||||
@@ -31,7 +52,7 @@ func main() {
|
||||
}
|
||||
defer closeIn()
|
||||
|
||||
rep, err := legacyreport.Parse(r)
|
||||
rep, battles, err := legacyreport.Parse(r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "parse: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -44,9 +65,17 @@ func main() {
|
||||
}
|
||||
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.SetIndent("", " ")
|
||||
if err := enc.Encode(rep); err != nil {
|
||||
if err := enc.Encode(env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -26,22 +26,29 @@ import (
|
||||
)
|
||||
|
||||
// Parse reads a legacy text report and returns a [report.Report]
|
||||
// carrying the in-scope subset of fields. The Width and Height of the
|
||||
// returned report are both set to the legacy "Size" value (galaxies
|
||||
// are square in the legacy engines).
|
||||
func Parse(r io.Reader) (report.Report, error) {
|
||||
// carrying the in-scope subset of fields, plus the per-battle
|
||||
// [report.BattleReport] payloads parsed out of the "Battle at (#N)"
|
||||
// blocks. The Width and Height of the returned report are both set
|
||||
// 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()
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
if err := p.handle(sc.Text()); err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -63,6 +70,8 @@ const (
|
||||
sectionOtherShipTypes
|
||||
sectionBombings
|
||||
sectionShipsInProduction
|
||||
sectionBattle
|
||||
sectionBattleProtocol
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
@@ -85,6 +94,40 @@ type parser struct {
|
||||
pendingFleets []pendingFleet
|
||||
pendingIncomings []pendingIncoming
|
||||
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 {
|
||||
@@ -155,10 +198,62 @@ func (p *parser) handle(line string) error {
|
||||
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 {
|
||||
// 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.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
|
||||
}
|
||||
|
||||
@@ -205,16 +300,21 @@ func (p *parser) handle(line string) error {
|
||||
p.parseBombing(fields)
|
||||
case sectionShipsInProduction:
|
||||
p.parseShipProductionRow(fields)
|
||||
case sectionBattle:
|
||||
p.parseBattleRosterRow(fields)
|
||||
case sectionBattleProtocol:
|
||||
p.parseBattleProtocolLine(fields)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) finish() (report.Report, error) {
|
||||
func (p *parser) finish() ([]report.BattleReport, error) {
|
||||
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()
|
||||
return p.rep, nil
|
||||
return p.battles, nil
|
||||
}
|
||||
|
||||
// parseHeader extracts (race, turn) from
|
||||
@@ -294,15 +394,16 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
||||
case "Ships In Production":
|
||||
return sectionShipsInProduction, "", true
|
||||
case "Approaching Groups",
|
||||
"Broadcast Message",
|
||||
"Battle Protocol":
|
||||
"Broadcast Message":
|
||||
return sectionNone, "", true
|
||||
case "Battle Protocol":
|
||||
return sectionBattleProtocol, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "Status of Players") {
|
||||
return sectionStatusOfPlayers, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "Battle at ") {
|
||||
return sectionNone, "", true
|
||||
return sectionBattle, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "=== ATTENTION") {
|
||||
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
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if s == "-" {
|
||||
return ""
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
|
||||
"KnightErrants 16.02",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
|
||||
" 1 579.12 489.37",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
|
||||
"_Drift 1 0 0 0",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
|
||||
" 17 Castle CombatFlame 990.10 0.07 1000.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
" 99 Lost Frigate 100.00 0.05 500.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSkipsBattles covers the only remaining legacy section the
|
||||
// parser ignores: "Battle at ..." headers and the following "Battle
|
||||
// Protocol" block. Bombings, Ships In Production, and the per-race
|
||||
// Sciences / Ship Types blocks now flow through real parsers; the
|
||||
// dedicated section tests below cover them.
|
||||
func TestParseSkipsBattles(t *testing.T) {
|
||||
// TestParseBattles exercises the battle-block parser end-to-end:
|
||||
// two battles with two races each, full rosters, and protocols. The
|
||||
// inline fixture mirrors the KNNTS-style layout (race-named roster
|
||||
// sub-headers, 10-column roster rows, 8-token shot lines) so any
|
||||
// drift from the real engine format breaks this test before a smoke
|
||||
// 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{
|
||||
"Race Report for Galaxy PLUS Turn 1",
|
||||
"",
|
||||
"Battle at (#7) B-007",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle",
|
||||
"Foo Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"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",
|
||||
"",
|
||||
"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",
|
||||
"",
|
||||
@@ -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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, battles, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
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 {
|
||||
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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -515,7 +621,7 @@ func TestParseYourFleets(t *testing.T) {
|
||||
" 1 Far 2 North Castle 4.50 20 In_Space",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
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",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -597,11 +703,12 @@ type smokeWant struct {
|
||||
localGroups, localFleets, incomingGroups int
|
||||
localScience, otherScience, otherShipClass int
|
||||
bombings, shipProductions int
|
||||
battles int
|
||||
}
|
||||
|
||||
func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
t.Helper()
|
||||
rep, err := parseFile(t, path)
|
||||
rep, battles, err := parseFile(t, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
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},
|
||||
{"Bombing", len(rep.Bombing), want.bombings},
|
||||
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
|
||||
{"Battle (summary)", len(rep.Battle), want.battles},
|
||||
{"BattleReport", len(battles), want.battles},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if 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
|
||||
@@ -676,6 +802,7 @@ func TestParseDgKNNTS039(t *testing.T) {
|
||||
otherShipClass: 170,
|
||||
bombings: 16,
|
||||
shipProductions: 6,
|
||||
battles: 28,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -694,6 +821,7 @@ func TestParseDgKNNTS040(t *testing.T) {
|
||||
otherShipClass: 160,
|
||||
bombings: 24,
|
||||
shipProductions: 16,
|
||||
battles: 79,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,6 +843,7 @@ func TestParseDgKNNTS041(t *testing.T) {
|
||||
otherShipClass: 218,
|
||||
bombings: 12,
|
||||
shipProductions: 22,
|
||||
battles: 56,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,6 +865,7 @@ func TestParseGplus40(t *testing.T) {
|
||||
otherShipClass: 183,
|
||||
bombings: 4,
|
||||
shipProductions: 8,
|
||||
battles: 30,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -757,6 +887,7 @@ func TestParseDgKiller031(t *testing.T) {
|
||||
otherShipClass: 161,
|
||||
bombings: 18,
|
||||
shipProductions: 0,
|
||||
battles: 83,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,18 +910,19 @@ func TestParseDgTancordia037(t *testing.T) {
|
||||
otherShipClass: 123,
|
||||
bombings: 22,
|
||||
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()
|
||||
abs, err := filepath.Abs(rel)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return Parse(f)
|
||||
|
||||
+48884
-11788
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,8 @@ import type {
|
||||
} from "./game-state";
|
||||
import type { CargoLoadType, Relation } from "../sync/order-types";
|
||||
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
||||
import type { BattleReport } from "./battle-fetch";
|
||||
import { registerSyntheticBattle } from "./synthetic-battle";
|
||||
|
||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||
|
||||
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
|
||||
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
||||
* it into a `GameReport`, registers it in the in-memory map under a
|
||||
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
||||
* decoded report. Throws `SyntheticReportError` for malformed input.
|
||||
* decoded report.
|
||||
*
|
||||
* Accepts two on-disk shapes:
|
||||
*
|
||||
* 1. Envelope (Phase 27 legacy-report CLI):
|
||||
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
|
||||
* — battles are forwarded to `registerSyntheticBattle` so the
|
||||
* Battle Viewer can resolve them offline.
|
||||
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
|
||||
* before; battle UUIDs in the report can still be clicked, but
|
||||
* the Viewer page will show "battle not found" because no
|
||||
* fixture was registered.
|
||||
*
|
||||
* Throws `SyntheticReportError` for malformed input in either shape.
|
||||
*/
|
||||
export function loadSyntheticReportFromJSON(json: unknown): {
|
||||
gameId: string;
|
||||
report: GameReport;
|
||||
} {
|
||||
const report = decodeSyntheticReport(json);
|
||||
const { reportPayload, battles } = extractEnvelope(json);
|
||||
const report = decodeSyntheticReport(reportPayload);
|
||||
for (const battle of battles) {
|
||||
registerSyntheticBattle(battle);
|
||||
}
|
||||
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
||||
SYNTHETIC_REPORTS.set(gameId, report);
|
||||
return { gameId, report };
|
||||
}
|
||||
|
||||
interface SyntheticEnvelope {
|
||||
version?: number;
|
||||
report?: unknown;
|
||||
battles?: Record<string, BattleReport>;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractEnvelope distinguishes the v1 envelope shape from a bare
|
||||
* Report payload. The envelope check is `version === 1` to leave room
|
||||
* for future format bumps and to avoid mistaking a bare Report whose
|
||||
* top-level fields happen to include `report`/`battles` (none do
|
||||
* today) for an envelope.
|
||||
*/
|
||||
function extractEnvelope(json: unknown): {
|
||||
reportPayload: unknown;
|
||||
battles: BattleReport[];
|
||||
} {
|
||||
if (typeof json !== "object" || json === null) {
|
||||
// Defer the error to `decodeSyntheticReport`; it already
|
||||
// raises a `SyntheticReportError` with the right message.
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
const env = json as SyntheticEnvelope;
|
||||
if (env.version === 1 && env.report !== undefined) {
|
||||
const battlesMap = env.battles ?? {};
|
||||
const battles: BattleReport[] = [];
|
||||
for (const value of Object.values(battlesMap)) {
|
||||
if (value && typeof value === "object") {
|
||||
battles.push(value);
|
||||
}
|
||||
}
|
||||
return { reportPayload: env.report, battles };
|
||||
}
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
|
||||
/** getSyntheticReport returns the report registered under `gameId`,
|
||||
* or `undefined` if the entry was lost (e.g. page reload). */
|
||||
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
isSyntheticGameId,
|
||||
loadSyntheticReportFromJSON,
|
||||
} from "../src/api/synthetic-report";
|
||||
import type { BattleReport } from "../src/api/battle-fetch";
|
||||
import {
|
||||
lookupSyntheticBattle,
|
||||
resetSyntheticBattles,
|
||||
} from "../src/api/synthetic-battle";
|
||||
|
||||
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
|
||||
return {
|
||||
@@ -244,3 +249,55 @@ describe("getSyntheticReport", () => {
|
||||
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope shape (v1)", () => {
|
||||
test("forwards battles to the synthetic-battle registry", () => {
|
||||
resetSyntheticBattles();
|
||||
const battle: BattleReport = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
planet: 17,
|
||||
planetName: "Castle",
|
||||
races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
|
||||
ships: {
|
||||
"0": {
|
||||
race: "KnightErrants",
|
||||
className: "Drone",
|
||||
tech: { DRIVE: 1 },
|
||||
num: 1,
|
||||
numLeft: 0,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
},
|
||||
protocol: [],
|
||||
};
|
||||
const envelope = {
|
||||
version: 1,
|
||||
report: syntheticJSON(),
|
||||
battles: { [battle.id]: battle },
|
||||
};
|
||||
|
||||
const { gameId, report } = loadSyntheticReportFromJSON(envelope);
|
||||
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
|
||||
expect(report.turn).toBe(39);
|
||||
expect(lookupSyntheticBattle(battle.id)).toEqual(battle);
|
||||
});
|
||||
|
||||
test("missing battles field leaves the registry untouched", () => {
|
||||
resetSyntheticBattles();
|
||||
const envelope = {
|
||||
version: 1,
|
||||
report: syntheticJSON(),
|
||||
};
|
||||
loadSyntheticReportFromJSON(envelope);
|
||||
expect(lookupSyntheticBattle("any")).toBeNull();
|
||||
});
|
||||
|
||||
test("bare Report (no envelope) still loads — backward compat", () => {
|
||||
resetSyntheticBattles();
|
||||
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||
expect(report.turn).toBe(39);
|
||||
expect(lookupSyntheticBattle("any")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user