ui/phase-19: legacy parser learns Your Groups / Your Fleets / Incoming Groups
The parity rule from ui/PLAN.md says every UI phase that decodes a
new Report field must extend the legacy converter in lockstep.
Phase 19 brings ship groups (LocalGroup / OtherGroup /
UnidentifiedGroup / IncomingGroup) and LocalFleet onto the wire-
compatible UI surface; this commit teaches
tools/local-dev/legacy-report to populate the three sections that
exist in the legacy text format:
- "Your Groups" → []LocalGroup. Cargo type, load, fleet name,
state, on-planet vs hyperspace position (origin / range) all
decoded; LocalGroup.ID is synthesised deterministically from
the per-report group index so re-running the converter
produces byte-identical JSON. Speed is left zero — the legacy
table doesn't expose it.
- "Your Fleets" → []LocalFleet. Origin / range / state mirror
the row layout used by Killer / Tancordia variants; gplus's
state-less rows still resolve.
- "Incoming Groups" → []IncomingGroup. Origin / destination
names — and `#NN` by-id references — resolve against the
parsed planet tables. Because the section can land before
"Your Planets" in some engines, group / fleet / incoming rows
are buffered and resolved in `parser.finish` after every
planet is known.
Battles, OtherGroup (only ever in battle rosters), and
UnidentifiedGroup stay out of scope — README.md spells out what
remains not-derivable.
Adds Killer031–033 / TSERCON_Z032–033 / Tancordia036–039 fixtures
to the dg directory and exercises three of them through new
TestParseDg{Killer031,Tancordia037,KNNTS041} smoke tests, plus
inline tests for each new section parser. Drops the stale
KNNTS039.json artefact left over from Phase 18 development.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// Scope is intentionally narrow: only the fields the UI client decodes
|
||||
// from server reports today (planets, players, own ship classes,
|
||||
// header data). Everything else in the legacy file is silently
|
||||
// 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
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
@@ -51,6 +54,9 @@ const (
|
||||
sectionUninhabitedPlanets
|
||||
sectionUnidentifiedPlanets
|
||||
sectionYourShipTypes
|
||||
sectionYourGroups
|
||||
sectionYourFleets
|
||||
sectionIncomingGroups
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
@@ -60,6 +66,50 @@ type parser struct {
|
||||
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.
|
||||
pendingGroups []pendingGroup
|
||||
pendingFleets []pendingFleet
|
||||
pendingIncomings []pendingIncoming
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func newParser() *parser {
|
||||
@@ -121,6 +171,12 @@ func (p *parser) handle(line string) error {
|
||||
p.parseUnidentifiedPlanet(fields)
|
||||
case sectionYourShipTypes:
|
||||
p.parseShipClass(fields)
|
||||
case sectionYourGroups:
|
||||
p.parseYourGroup(fields)
|
||||
case sectionYourFleets:
|
||||
p.parseYourFleet(fields)
|
||||
case sectionIncomingGroups:
|
||||
p.parseIncomingGroup(fields)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -129,6 +185,7 @@ func (p *parser) finish() (report.Report, error) {
|
||||
if !p.sawHeader {
|
||||
return report.Report{}, errors.New("legacyreport: missing report header line")
|
||||
}
|
||||
p.resolvePending()
|
||||
return p.rep, nil
|
||||
}
|
||||
|
||||
@@ -190,6 +247,12 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
||||
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":
|
||||
@@ -432,6 +495,256 @@ func (p *parser) parseShipClass(fields []string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
func dashOrEmpty(s string) string {
|
||||
if s == "-" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user