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:
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user