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:
Ilia Denisov
2026-05-10 13:23:17 +02:00
parent 132ed4e0db
commit 8839f46c25
14 changed files with 42416 additions and 6327 deletions
+314 -1
View File
@@ -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)
}