b23649059f
Side activity on top of Phase 27: the legacy-report tool now extracts
the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used
to skip. Both the per-battle summary (Report.Battle: []BattleSummary)
and the full BattleReport (rosters + protocol) flow through.
Parser:
- new sectionBattle / sectionBattleProtocol states, with handle()
trapping the per-race "<Race> Groups" sub-headers so the roster
stays attributed to the right race;
- parseBattleHeader extracts (planet, planetName) from
"Battle at (#NN) <Name>";
- parseBattleRosterRow maps the 10-token row into
BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against
KNNTS fixtures;
- parseBattleProtocolLine counts shots and builds
BattleActionReport entries from the 8-token "X Y fires on A B :
Destroyed|Shields" lines;
- flushPendingBattle finalises a battle on next "Battle at" or any
top-level section change and appends both the summary and the
full report;
- syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise
stable UUIDs in dedicated namespaces so re-runs produce
byte-identical JSON.
Parse() signature widens to (Report, []BattleReport, error); the
single caller — the CLI — is updated.
CLI emits a v1 envelope:
{ "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } }
Bare-Report JSONs still load on the UI side for backward compat.
UI synthetic loader: loadSyntheticReportFromJSON detects the v1
envelope, decodes the report as before, and forwards every battle
through registerSyntheticBattle so the Battle Viewer resolves any
UUID offline. Pre-envelope JSON files (no `version` field) still
load — the battle registry stays empty for them.
Docs: legacy-report README moves Battles from "Skipped" to
in-scope, documents the envelope and UUID namespaces;
docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic
mode is now end-to-end via the envelope.
Tests:
- TestParseBattles covers two battles with full rosters,
per-shot destroyed/shielded mapping, NumberLeft from column 8,
deterministic UUIDs across re-parses, and proves a trailing
top-level section still parses (battle state closes cleanly);
- smokeWant gains a battles count; runSmoke cross-checks
BattleSummary ↔ BattleReport alignment (id/planet/shots);
- all six real-fixture smoke tests pinned to their `Battle at`
counts (28, 79, 56, 30, 83, 57);
- Vitest covers the synthetic-report envelope path (battles
forwarded, missing-battles tolerated, bare-Report backward
compat);
- KNNTS041.json regenerated against the new parser (existing
diff was stale w.r.t. Phase 23 anyway; this commit brings it
in line with the v1 envelope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1293 lines
37 KiB
Go
1293 lines
37 KiB
Go
// Package legacyreport parses legacy text-format Galaxy turn reports
|
|
// (the "dg" / "gplus" engines) into [report.Report] values.
|
|
//
|
|
// Scope is intentionally narrow: only the fields the UI client decodes
|
|
// from server reports today (planets, players, own ship classes,
|
|
// header data, plus — added in Phase 19 — own ship groups, own fleets
|
|
// and incoming groups). Everything else in the legacy file is silently
|
|
// skipped. The synthetic-report parity rule in ui/PLAN.md is the
|
|
// source of truth for when to extend this parser; the package's
|
|
// README.md tracks every legacy section that could be wired up later
|
|
// when the corresponding UI decoder lands.
|
|
package legacyreport
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"galaxy/calc"
|
|
"galaxy/model/report"
|
|
)
|
|
|
|
// Parse reads a legacy text report and returns a [report.Report]
|
|
// 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{}, nil, err
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
|
|
}
|
|
battles, err := p.finish()
|
|
if err != nil {
|
|
return report.Report{}, nil, err
|
|
}
|
|
return p.rep, battles, nil
|
|
}
|
|
|
|
type section int
|
|
|
|
const (
|
|
sectionNone section = iota
|
|
sectionStatusOfPlayers
|
|
sectionYourVote
|
|
sectionYourPlanets
|
|
sectionOtherPlanets
|
|
sectionUninhabitedPlanets
|
|
sectionUnidentifiedPlanets
|
|
sectionYourShipTypes
|
|
sectionYourGroups
|
|
sectionYourFleets
|
|
sectionIncomingGroups
|
|
sectionYourSciences
|
|
sectionOtherSciences
|
|
sectionOtherShipTypes
|
|
sectionBombings
|
|
sectionShipsInProduction
|
|
sectionBattle
|
|
sectionBattleProtocol
|
|
)
|
|
|
|
type parser struct {
|
|
rep report.Report
|
|
sec section
|
|
otherOwner string
|
|
skipHeader bool
|
|
sawHeader bool
|
|
sawSize bool
|
|
|
|
// Group/fleet/incoming rows are buffered during the scan because
|
|
// they carry destination/origin planet names that may resolve
|
|
// against the planet tables only after the whole file has been
|
|
// read — "Incoming Groups" can appear before "Your Planets" in
|
|
// some engine variants. Ships-in-production rows are buffered
|
|
// because their prod_used derivation needs the producing planet's
|
|
// material and resources (read from "Your Planets") to call
|
|
// [calc.ShipBuildCost], and the section order is not guaranteed.
|
|
pendingGroups []pendingGroup
|
|
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 {
|
|
g uint
|
|
number uint
|
|
class string
|
|
drive float64
|
|
weapons float64
|
|
shields float64
|
|
cargoTech float64
|
|
cargoType string
|
|
load float64
|
|
destinationName string
|
|
originName string // empty when "-"
|
|
rangeStr string // empty when "-"
|
|
mass float64
|
|
fleet string // empty when "-"
|
|
state string
|
|
}
|
|
|
|
type pendingFleet struct {
|
|
name string
|
|
groups uint
|
|
destinationName string
|
|
originName string // empty when "-"
|
|
rangeStr string // empty when "-"
|
|
state string
|
|
}
|
|
|
|
type pendingIncoming struct {
|
|
originName string
|
|
destinationName string
|
|
distance float64
|
|
speed float64
|
|
mass float64
|
|
}
|
|
|
|
type pendingShipProduction struct {
|
|
planetNumber uint
|
|
class string
|
|
cost float64
|
|
percent float64
|
|
free float64
|
|
}
|
|
|
|
func newParser() *parser {
|
|
return &parser{sec: sectionNone}
|
|
}
|
|
|
|
func (p *parser) handle(line string) error {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if !p.sawHeader && trimmed != "" {
|
|
if race, turn, ok := parseHeader(trimmed); ok {
|
|
p.rep.Race = race
|
|
p.rep.Turn = turn
|
|
p.sawHeader = true
|
|
return nil
|
|
}
|
|
}
|
|
if !p.sawSize && strings.HasPrefix(trimmed, "Size:") {
|
|
if w, planets, ok := parseSize(trimmed); ok {
|
|
p.rep.Width = w
|
|
p.rep.Height = w
|
|
p.rep.PlanetCount = planets
|
|
p.sawSize = true
|
|
}
|
|
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
|
|
// `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
|
|
}
|
|
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
if p.sec == sectionNone {
|
|
return nil
|
|
}
|
|
if p.skipHeader {
|
|
p.skipHeader = false
|
|
return nil
|
|
}
|
|
|
|
fields := strings.Fields(trimmed)
|
|
switch p.sec {
|
|
case sectionStatusOfPlayers:
|
|
p.parsePlayer(fields)
|
|
case sectionYourVote:
|
|
p.parseYourVote(fields)
|
|
case sectionYourPlanets:
|
|
p.parseLocalPlanet(fields)
|
|
case sectionOtherPlanets:
|
|
p.parseOtherPlanet(fields)
|
|
case sectionUninhabitedPlanets:
|
|
p.parseUninhabitedPlanet(fields)
|
|
case sectionUnidentifiedPlanets:
|
|
p.parseUnidentifiedPlanet(fields)
|
|
case sectionYourShipTypes:
|
|
p.parseShipClass(fields)
|
|
case sectionYourGroups:
|
|
p.parseYourGroup(fields)
|
|
case sectionYourFleets:
|
|
p.parseYourFleet(fields)
|
|
case sectionIncomingGroups:
|
|
p.parseIncomingGroup(fields)
|
|
case sectionYourSciences:
|
|
p.parseYourScience(fields)
|
|
case sectionOtherSciences:
|
|
p.parseOtherScience(fields)
|
|
case sectionOtherShipTypes:
|
|
p.parseOtherShipClass(fields)
|
|
case sectionBombings:
|
|
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.BattleReport, error) {
|
|
if !p.sawHeader {
|
|
return nil, errors.New("legacyreport: missing report header line")
|
|
}
|
|
p.flushPendingBattle()
|
|
p.resolvePending()
|
|
return p.battles, nil
|
|
}
|
|
|
|
// parseHeader extracts (race, turn) from
|
|
// "<Race> Report for Galaxy ... Turn N ...".
|
|
func parseHeader(line string) (string, uint, bool) {
|
|
race, rest, ok := strings.Cut(line, " Report for Galaxy ")
|
|
if !ok {
|
|
return "", 0, false
|
|
}
|
|
_, afterTurn, ok := strings.Cut(rest, " Turn ")
|
|
if !ok {
|
|
return "", 0, false
|
|
}
|
|
after := strings.Fields(afterTurn)
|
|
if len(after) == 0 {
|
|
return "", 0, false
|
|
}
|
|
n, err := strconv.ParseUint(after[0], 10, 32)
|
|
if err != nil {
|
|
return "", 0, false
|
|
}
|
|
return race, uint(n), true
|
|
}
|
|
|
|
// parseSize extracts (size, planets) from
|
|
// "Size: W Planets: P Players: N". Players is intentionally
|
|
// dropped: report.Report has no field for it.
|
|
func parseSize(line string) (uint32, uint32, bool) {
|
|
fields := strings.Fields(line)
|
|
var size, planets uint32
|
|
var ok bool
|
|
for i := 0; i+1 < len(fields); i++ {
|
|
key := strings.TrimRight(fields[i], ":")
|
|
switch key {
|
|
case "Size":
|
|
if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
|
|
size = uint32(n)
|
|
ok = true
|
|
}
|
|
case "Planets":
|
|
if n, err := strconv.ParseUint(fields[i+1], 10, 32); err == nil {
|
|
planets = uint32(n)
|
|
}
|
|
}
|
|
}
|
|
return size, planets, ok
|
|
}
|
|
|
|
// classifySection returns the section the trimmed line opens. When
|
|
// isHeader is true the caller transitions state — sectionNone there
|
|
// means "switch into skip mode" (an uninteresting section like
|
|
// Bombings, Battle at ..., or a foreign Ship Types block).
|
|
func classifySection(line string) (sec section, owner string, isHeader bool) {
|
|
switch line {
|
|
case "":
|
|
return sectionNone, "", false
|
|
case "Your Planets":
|
|
return sectionYourPlanets, "", true
|
|
case "Your Ship Types":
|
|
return sectionYourShipTypes, "", true
|
|
case "Your Groups":
|
|
return sectionYourGroups, "", true
|
|
case "Your Fleets":
|
|
return sectionYourFleets, "", true
|
|
case "Incoming Groups":
|
|
return sectionIncomingGroups, "", true
|
|
case "Uninhabited Planets":
|
|
return sectionUninhabitedPlanets, "", true
|
|
case "Unidentified Planets":
|
|
return sectionUnidentifiedPlanets, "", true
|
|
case "Your vote:":
|
|
return sectionYourVote, "", true
|
|
case "Your Sciences":
|
|
return sectionYourSciences, "", true
|
|
case "Bombings":
|
|
return sectionBombings, "", true
|
|
case "Ships In Production":
|
|
return sectionShipsInProduction, "", true
|
|
case "Approaching Groups",
|
|
"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 sectionBattle, "", true
|
|
}
|
|
if strings.HasPrefix(line, "=== ATTENTION") {
|
|
return sectionNone, "", true
|
|
}
|
|
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
|
|
return sectionOtherPlanets, owner, true
|
|
}
|
|
if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
|
|
return sectionOtherShipTypes, owner, true
|
|
}
|
|
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
|
|
return sectionOtherSciences, owner, true
|
|
}
|
|
if _, ok := singleTokenPrefix(line, " Groups"); ok {
|
|
return sectionNone, "", true
|
|
}
|
|
return sectionNone, "", false
|
|
}
|
|
|
|
func singleTokenPrefix(line, suffix string) (string, bool) {
|
|
if !strings.HasSuffix(line, suffix) {
|
|
return "", false
|
|
}
|
|
prefix := strings.TrimSuffix(line, suffix)
|
|
if prefix == "" || strings.ContainsAny(prefix, " \t") {
|
|
return "", false
|
|
}
|
|
return prefix, true
|
|
}
|
|
|
|
// parsePlayer expects 10 columns:
|
|
//
|
|
// N D W S C P I # R V
|
|
func (p *parser) parsePlayer(fields []string) {
|
|
if len(fields) < 10 {
|
|
return
|
|
}
|
|
name := fields[0]
|
|
drive, err := parseFloat(fields[1])
|
|
if err != nil {
|
|
return
|
|
}
|
|
weapons, _ := parseFloat(fields[2])
|
|
shields, _ := parseFloat(fields[3])
|
|
cargo, _ := parseFloat(fields[4])
|
|
population, _ := parseFloat(fields[5])
|
|
industry, _ := parseFloat(fields[6])
|
|
plCount, err := strconv.ParseUint(fields[7], 10, 16)
|
|
if err != nil {
|
|
return
|
|
}
|
|
relation := fields[8]
|
|
votes, _ := parseFloat(fields[9])
|
|
|
|
extinct := strings.HasSuffix(name, "_RIP")
|
|
if extinct {
|
|
name = strings.TrimSuffix(name, "_RIP")
|
|
}
|
|
p.rep.Player = append(p.rep.Player, report.Player{
|
|
Name: name,
|
|
Drive: report.F(drive),
|
|
Weapons: report.F(weapons),
|
|
Shields: report.F(shields),
|
|
Cargo: report.F(cargo),
|
|
Population: report.F(population),
|
|
Industry: report.F(industry),
|
|
Planets: uint16(plCount),
|
|
Relation: relation,
|
|
Votes: report.F(votes),
|
|
Extinct: extinct,
|
|
})
|
|
}
|
|
|
|
func (p *parser) parseYourVote(fields []string) {
|
|
if len(fields) < 2 {
|
|
return
|
|
}
|
|
p.rep.VoteFor = fields[0]
|
|
if v, err := parseFloat(fields[1]); err == nil {
|
|
p.rep.Votes = report.F(v)
|
|
}
|
|
p.sec = sectionNone
|
|
}
|
|
|
|
// parseLocalPlanet expects 13 columns:
|
|
//
|
|
// # X Y N S P I R Production $ M C L
|
|
func (p *parser) parseLocalPlanet(fields []string) {
|
|
lp, ok := decodeLocalPlanetRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.LocalPlanet = append(p.rep.LocalPlanet, lp)
|
|
}
|
|
|
|
func (p *parser) parseOtherPlanet(fields []string) {
|
|
lp, ok := decodeLocalPlanetRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.OtherPlanet = append(p.rep.OtherPlanet, report.OtherPlanet{
|
|
Owner: p.otherOwner,
|
|
LocalPlanet: lp,
|
|
})
|
|
}
|
|
|
|
func decodeLocalPlanetRow(fields []string) (report.LocalPlanet, bool) {
|
|
var lp report.LocalPlanet
|
|
if len(fields) < 13 {
|
|
return lp, false
|
|
}
|
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
|
if err != nil {
|
|
return lp, false
|
|
}
|
|
x, _ := parseFloat(fields[1])
|
|
y, _ := parseFloat(fields[2])
|
|
size, _ := parseFloat(fields[4])
|
|
population, _ := parseFloat(fields[5])
|
|
industry, _ := parseFloat(fields[6])
|
|
resources, _ := parseFloat(fields[7])
|
|
capital, _ := parseFloat(fields[9])
|
|
material, _ := parseFloat(fields[10])
|
|
colonists, _ := parseFloat(fields[11])
|
|
free, _ := parseFloat(fields[12])
|
|
|
|
lp.Number = uint(number)
|
|
lp.X = report.F(x)
|
|
lp.Y = report.F(y)
|
|
lp.Name = fields[3]
|
|
lp.Size = report.F(size)
|
|
lp.Resources = report.F(resources)
|
|
lp.Capital = report.F(capital)
|
|
lp.Material = report.F(material)
|
|
lp.Industry = report.F(industry)
|
|
lp.Population = report.F(population)
|
|
lp.Colonists = report.F(colonists)
|
|
lp.Production = fields[8]
|
|
lp.FreeIndustry = report.F(free)
|
|
return lp, true
|
|
}
|
|
|
|
// parseUninhabitedPlanet expects 8 columns:
|
|
//
|
|
// # X Y N S R $ M
|
|
func (p *parser) parseUninhabitedPlanet(fields []string) {
|
|
if len(fields) < 8 {
|
|
return
|
|
}
|
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
x, _ := parseFloat(fields[1])
|
|
y, _ := parseFloat(fields[2])
|
|
size, _ := parseFloat(fields[4])
|
|
resources, _ := parseFloat(fields[5])
|
|
capital, _ := parseFloat(fields[6])
|
|
material, _ := parseFloat(fields[7])
|
|
|
|
var u report.UninhabitedPlanet
|
|
u.Number = uint(number)
|
|
u.X = report.F(x)
|
|
u.Y = report.F(y)
|
|
u.Name = fields[3]
|
|
u.Size = report.F(size)
|
|
u.Resources = report.F(resources)
|
|
u.Capital = report.F(capital)
|
|
u.Material = report.F(material)
|
|
p.rep.UninhabitedPlanet = append(p.rep.UninhabitedPlanet, u)
|
|
}
|
|
|
|
// parseUnidentifiedPlanet expects 3 columns:
|
|
//
|
|
// # X Y
|
|
func (p *parser) parseUnidentifiedPlanet(fields []string) {
|
|
if len(fields) < 3 {
|
|
return
|
|
}
|
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
x, _ := parseFloat(fields[1])
|
|
y, _ := parseFloat(fields[2])
|
|
p.rep.UnidentifiedPlanet = append(p.rep.UnidentifiedPlanet, report.UnidentifiedPlanet{
|
|
Number: uint(number),
|
|
X: report.F(x),
|
|
Y: report.F(y),
|
|
})
|
|
}
|
|
|
|
// parseShipClass expects 7 columns:
|
|
//
|
|
// N D A W S C M
|
|
func (p *parser) parseShipClass(fields []string) {
|
|
sc, ok := decodeShipClassRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
|
|
}
|
|
|
|
// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
|
|
// Same 7-column layout as [parser.parseShipClass]; the owning race is
|
|
// captured into [parser.otherOwner] when the section header is
|
|
// classified by [classifySection].
|
|
func (p *parser) parseOtherShipClass(fields []string) {
|
|
sc, ok := decodeShipClassRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
|
|
Race: p.otherOwner,
|
|
ShipClass: sc,
|
|
})
|
|
}
|
|
|
|
func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
|
|
var sc report.ShipClass
|
|
if len(fields) < 7 {
|
|
return sc, false
|
|
}
|
|
drive, err := parseFloat(fields[1])
|
|
if err != nil {
|
|
return sc, false
|
|
}
|
|
armament, err := strconv.ParseUint(fields[2], 10, 32)
|
|
if err != nil {
|
|
return sc, false
|
|
}
|
|
weapons, _ := parseFloat(fields[3])
|
|
shields, _ := parseFloat(fields[4])
|
|
cargo, _ := parseFloat(fields[5])
|
|
mass, _ := parseFloat(fields[6])
|
|
sc.Name = fields[0]
|
|
sc.Drive = report.F(drive)
|
|
sc.Armament = uint(armament)
|
|
sc.Weapons = report.F(weapons)
|
|
sc.Shields = report.F(shields)
|
|
sc.Cargo = report.F(cargo)
|
|
sc.Mass = report.F(mass)
|
|
return sc, true
|
|
}
|
|
|
|
// parseYourScience parses one row of the "Your Sciences" block.
|
|
// Columns:
|
|
//
|
|
// N D W S C
|
|
//
|
|
// where D/W/S/C are the four tech proportions as fractions summing
|
|
// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
|
|
func (p *parser) parseYourScience(fields []string) {
|
|
sc, ok := decodeScienceRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.LocalScience = append(p.rep.LocalScience, sc)
|
|
}
|
|
|
|
// parseOtherScience parses one row of a "<Race> Sciences" block.
|
|
// Same 5-column layout as [parser.parseYourScience]; the owning race
|
|
// is captured into [parser.otherOwner] by [classifySection].
|
|
func (p *parser) parseOtherScience(fields []string) {
|
|
sc, ok := decodeScienceRow(fields)
|
|
if !ok {
|
|
return
|
|
}
|
|
p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
|
|
Race: p.otherOwner,
|
|
Science: sc,
|
|
})
|
|
}
|
|
|
|
func decodeScienceRow(fields []string) (report.Science, bool) {
|
|
var sc report.Science
|
|
if len(fields) < 5 {
|
|
return sc, false
|
|
}
|
|
drive, err := parseFloat(fields[1])
|
|
if err != nil {
|
|
return sc, false
|
|
}
|
|
weapons, _ := parseFloat(fields[2])
|
|
shields, _ := parseFloat(fields[3])
|
|
cargo, _ := parseFloat(fields[4])
|
|
sc.Name = fields[0]
|
|
sc.Drive = report.F(drive)
|
|
sc.Weapons = report.F(weapons)
|
|
sc.Shields = report.F(shields)
|
|
sc.Cargo = report.F(cargo)
|
|
return sc, true
|
|
}
|
|
|
|
// parseBombing parses one row of the "Bombings" block. Columns
|
|
// (12 tokens, last is the wiped/damaged status word):
|
|
//
|
|
// W O # N P I P $ M C A status
|
|
//
|
|
// where the first P is the post-bombing population and the second
|
|
// P is the production string left on the planet. Status is parsed
|
|
// positionally — the header has a duplicate P, so a header-name
|
|
// lookup is not safe.
|
|
func (p *parser) parseBombing(fields []string) {
|
|
if len(fields) < 12 {
|
|
return
|
|
}
|
|
number, err := strconv.ParseUint(fields[2], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
population, _ := parseFloat(fields[4])
|
|
industry, _ := parseFloat(fields[5])
|
|
capital, _ := parseFloat(fields[7])
|
|
material, _ := parseFloat(fields[8])
|
|
colonists, _ := parseFloat(fields[9])
|
|
attack, _ := parseFloat(fields[10])
|
|
wiped := fields[11] == "Wiped"
|
|
p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
|
|
Attacker: fields[0],
|
|
Owner: fields[1],
|
|
Number: uint(number),
|
|
Planet: fields[3],
|
|
Population: report.F(population),
|
|
Industry: report.F(industry),
|
|
Production: fields[6],
|
|
Capital: report.F(capital),
|
|
Material: report.F(material),
|
|
Colonists: report.F(colonists),
|
|
AttackPower: report.F(attack),
|
|
Wiped: wiped,
|
|
})
|
|
}
|
|
|
|
// 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:
|
|
//
|
|
// # N S C P L
|
|
//
|
|
// where # is the planet number, N is the planet name (decorative —
|
|
// resolution uses #), S is the building ship class, C is the cost
|
|
// (== shipMass * 10), P is the build progress as a fraction in
|
|
// [0, 1], and L is the producing planet's free industry. The wire
|
|
// shape's `prod_used` field is not carried by the legacy text; it is
|
|
// derived during [parser.resolvePending] from the planet's material
|
|
// and resources via [calc.ShipBuildCost].
|
|
func (p *parser) parseShipProductionRow(fields []string) {
|
|
if len(fields) < 6 {
|
|
return
|
|
}
|
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cost, _ := parseFloat(fields[3])
|
|
percent, _ := parseFloat(fields[4])
|
|
free, _ := parseFloat(fields[5])
|
|
p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
|
|
planetNumber: uint(number),
|
|
class: fields[2],
|
|
cost: cost,
|
|
percent: percent,
|
|
free: free,
|
|
})
|
|
}
|
|
|
|
// parseYourGroup buffers a "Your Groups" row for post-processing in
|
|
// [parser.finish]. Columns (16 fields, last is state):
|
|
//
|
|
// G # T D W S C T Q D F R P M L state
|
|
//
|
|
// where the second D is the destination planet name, F is the origin
|
|
// planet name (or "-" for on-planet groups), R is the remaining
|
|
// distance, and L is the fleet membership (or "-").
|
|
func (p *parser) parseYourGroup(fields []string) {
|
|
if len(fields) < 16 {
|
|
return
|
|
}
|
|
g, err := strconv.ParseUint(fields[0], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
number, err := strconv.ParseUint(fields[1], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
drive, _ := parseFloat(fields[3])
|
|
weapons, _ := parseFloat(fields[4])
|
|
shields, _ := parseFloat(fields[5])
|
|
cargoTech, _ := parseFloat(fields[6])
|
|
load, _ := parseFloat(fields[8])
|
|
mass, _ := parseFloat(fields[13])
|
|
|
|
p.pendingGroups = append(p.pendingGroups, pendingGroup{
|
|
g: uint(g),
|
|
number: uint(number),
|
|
class: fields[2],
|
|
drive: drive,
|
|
weapons: weapons,
|
|
shields: shields,
|
|
cargoTech: cargoTech,
|
|
cargoType: fields[7],
|
|
load: load,
|
|
destinationName: fields[9],
|
|
originName: dashOrEmpty(fields[10]),
|
|
rangeStr: dashOrEmpty(fields[11]),
|
|
mass: mass,
|
|
fleet: dashOrEmpty(fields[14]),
|
|
state: fields[15],
|
|
})
|
|
}
|
|
|
|
// parseYourFleet buffers a "Your Fleets" row. Columns vary by engine
|
|
// — Killer/Tancordia ship 8 fields including a trailing state token,
|
|
// gplus emits 7 (no state). Layout:
|
|
//
|
|
// # N G D F R P [state]
|
|
//
|
|
// where D is the destination planet name, F is the origin planet
|
|
// name (or "-"), and R is the remaining distance.
|
|
func (p *parser) parseYourFleet(fields []string) {
|
|
if len(fields) < 7 {
|
|
return
|
|
}
|
|
groups, err := strconv.ParseUint(fields[2], 10, 32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
state := ""
|
|
if len(fields) >= 8 {
|
|
state = fields[7]
|
|
}
|
|
p.pendingFleets = append(p.pendingFleets, pendingFleet{
|
|
name: fields[1],
|
|
groups: uint(groups),
|
|
destinationName: fields[3],
|
|
originName: dashOrEmpty(fields[4]),
|
|
rangeStr: dashOrEmpty(fields[5]),
|
|
state: state,
|
|
})
|
|
}
|
|
|
|
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
|
|
//
|
|
// O D R S M
|
|
func (p *parser) parseIncomingGroup(fields []string) {
|
|
if len(fields) < 5 {
|
|
return
|
|
}
|
|
distance, err := parseFloat(fields[2])
|
|
if err != nil {
|
|
return
|
|
}
|
|
speed, _ := parseFloat(fields[3])
|
|
mass, _ := parseFloat(fields[4])
|
|
p.pendingIncomings = append(p.pendingIncomings, pendingIncoming{
|
|
originName: fields[0],
|
|
destinationName: fields[1],
|
|
distance: distance,
|
|
speed: speed,
|
|
mass: mass,
|
|
})
|
|
}
|
|
|
|
// resolvePending walks the buffered group/fleet/incoming rows and
|
|
// emits the typed entries on the report. Names that resolve neither
|
|
// against the parsed planet tables nor the "#NN" id syntax are
|
|
// skipped silently — they typically point at planets not visible to
|
|
// the local player. Stable LocalGroup IDs are derived from the
|
|
// per-report group index so repeated conversions of the same file
|
|
// produce byte-identical JSON.
|
|
func (p *parser) resolvePending() {
|
|
for _, pg := range p.pendingGroups {
|
|
dest, ok := p.lookupPlanetNumber(pg.destinationName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var origin *uint
|
|
if pg.originName != "" {
|
|
if n, ok := p.lookupPlanetNumber(pg.originName); ok {
|
|
v := n
|
|
origin = &v
|
|
}
|
|
}
|
|
var rng *report.Float
|
|
if pg.rangeStr != "" {
|
|
if r, err := parseFloat(pg.rangeStr); err == nil {
|
|
v := report.F(r)
|
|
rng = &v
|
|
}
|
|
}
|
|
var fleet *string
|
|
if pg.fleet != "" {
|
|
f := pg.fleet
|
|
fleet = &f
|
|
}
|
|
tech := map[string]report.Float{
|
|
"drive": report.F(pg.drive),
|
|
"weapons": report.F(pg.weapons),
|
|
"shields": report.F(pg.shields),
|
|
"cargo": report.F(pg.cargoTech),
|
|
}
|
|
p.rep.LocalGroup = append(p.rep.LocalGroup, report.LocalGroup{
|
|
OtherGroup: report.OtherGroup{
|
|
Number: pg.number,
|
|
Class: pg.class,
|
|
Tech: tech,
|
|
Cargo: pg.cargoType,
|
|
Load: report.F(pg.load),
|
|
Destination: dest,
|
|
Origin: origin,
|
|
Range: rng,
|
|
Mass: report.F(pg.mass),
|
|
},
|
|
ID: syntheticGroupID(pg.g),
|
|
State: pg.state,
|
|
Fleet: fleet,
|
|
})
|
|
}
|
|
|
|
for _, pf := range p.pendingFleets {
|
|
dest, ok := p.lookupPlanetNumber(pf.destinationName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var origin *uint
|
|
if pf.originName != "" {
|
|
if n, ok := p.lookupPlanetNumber(pf.originName); ok {
|
|
v := n
|
|
origin = &v
|
|
}
|
|
}
|
|
var rng *report.Float
|
|
if pf.rangeStr != "" {
|
|
if r, err := parseFloat(pf.rangeStr); err == nil {
|
|
v := report.F(r)
|
|
rng = &v
|
|
}
|
|
}
|
|
p.rep.LocalFleet = append(p.rep.LocalFleet, report.LocalFleet{
|
|
Name: pf.name,
|
|
Groups: pf.groups,
|
|
Destination: dest,
|
|
Origin: origin,
|
|
Range: rng,
|
|
State: pf.state,
|
|
})
|
|
}
|
|
|
|
for _, pi := range p.pendingIncomings {
|
|
origin, ok := p.lookupPlanetNumber(pi.originName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
dest, ok := p.lookupPlanetNumber(pi.destinationName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
p.rep.IncomingGroup = append(p.rep.IncomingGroup, report.IncomingGroup{
|
|
Origin: origin,
|
|
Destination: dest,
|
|
Distance: report.F(pi.distance),
|
|
Speed: report.F(pi.speed),
|
|
Mass: report.F(pi.mass),
|
|
})
|
|
}
|
|
|
|
for _, ps := range p.pendingShipProducts {
|
|
lp, ok := p.findLocalPlanet(ps.planetNumber)
|
|
if !ok {
|
|
continue
|
|
}
|
|
shipMass := ps.cost / 10
|
|
totalCost := calc.ShipBuildCost(
|
|
shipMass,
|
|
float64(lp.Material),
|
|
float64(lp.Resources),
|
|
)
|
|
// `ProdUsed` is the cumulative production-equivalent of the
|
|
// build progress so far. The real engine's `Progress` field
|
|
// accumulates across turns and the per-turn `ProdUsed` is a
|
|
// transient residual — neither of those is recoverable from a
|
|
// single legacy report. The derivation here keeps the value in
|
|
// the same units (production points) and in the right ballpark
|
|
// for synthetic-mode UI rendering; live engine reports do not
|
|
// flow through this parser, so the approximation never reaches
|
|
// production traffic. README.md skips section explains.
|
|
prodUsed := totalCost * ps.percent
|
|
p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
|
|
Planet: ps.planetNumber,
|
|
Class: ps.class,
|
|
Cost: report.F(ps.cost),
|
|
ProdUsed: report.F(prodUsed),
|
|
Percent: report.F(ps.percent),
|
|
Free: report.F(ps.free),
|
|
})
|
|
}
|
|
}
|
|
|
|
// findLocalPlanet returns the parsed "Your Planets" entry with the
|
|
// given number, used by the ships-in-production resolver to read
|
|
// material / resources for the [calc.ShipBuildCost] derivation.
|
|
// Ships-in-production only lists own ships, so the lookup against
|
|
// `LocalPlanet` is correct.
|
|
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
|
|
for _, lp := range p.rep.LocalPlanet {
|
|
if lp.Number == number {
|
|
return lp, true
|
|
}
|
|
}
|
|
return report.LocalPlanet{}, false
|
|
}
|
|
|
|
// lookupPlanetNumber resolves a legacy planet reference — either a
|
|
// "#NN" by-id form or a planet name from one of the parsed planet
|
|
// tables. Returns false when the planet is not visible to the local
|
|
// player (the caller drops the row).
|
|
func (p *parser) lookupPlanetNumber(s string) (uint, bool) {
|
|
if strings.HasPrefix(s, "#") {
|
|
n, err := strconv.ParseUint(s[1:], 10, 32)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return uint(n), true
|
|
}
|
|
for _, lp := range p.rep.LocalPlanet {
|
|
if lp.Name == s {
|
|
return lp.Number, true
|
|
}
|
|
}
|
|
for _, op := range p.rep.OtherPlanet {
|
|
if op.Name == s {
|
|
return op.Number, true
|
|
}
|
|
}
|
|
for _, up := range p.rep.UninhabitedPlanet {
|
|
if up.Name == s {
|
|
return up.Number, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// syntheticGroupNamespace seeds [uuid.NewSHA1] for the per-report
|
|
// group-index → UUID derivation. The constant value is arbitrary;
|
|
// any UUID would work as long as it stays stable across releases so
|
|
// re-running the converter on the same input file yields the same
|
|
// LocalGroup IDs.
|
|
var syntheticGroupNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000001")
|
|
|
|
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 ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
func parseFloat(s string) (float64, error) {
|
|
return strconv.ParseFloat(s, 64)
|
|
}
|