feat(ui+legacy): F8-05 owner-feedback round 1 — inspector tweaks + parser
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m45s

Owner-reported polish on top of #48, plus a legacy-parser gap that
prevented verifying stationed ship groups against a real .REP fixture.

UI:
- Production: drop the empty `(production)` placeholder option. Owned
  planets always produce something, so the primary select now opens on
  `industry` by default when `planet.production` is null/unknown,
  keeping the row inside the four real production kinds at all times.
- Production: lock the row to a single line (no flex-wrap) and strip
  border + padding from the ✓/✗ buttons so the apply/cancel icons read
  as glyphs and the row no longer breaks into two visual rows for
  Research / Ship contexts where both selects are present.
- Cargo routes: the placeholder option is now an `<option disabled>`
  styled like a section header (greyed, italic) and reads "manage
  routes" instead of "cargo routes". The wording shifts the intent
  from a section label to an action prompt.

Legacy parser:
- F8-05 (#48 п.32) "Stationed ship groups" couldn't be verified against
  the dg fixture because the legacy `<Race> Groups` blocks (outside
  battles) and the `Unidentified Groups` block were dropped by the
  parser — both are now wired up. Foreign group rows parse the
  `# T D W S C T Q D P M` columns and resolve the destination against
  the parsed planet tables (rows with an invisible destination drop,
  matching the existing local-group convention). The legacy row
  carries no origin / range columns, so foreign groups surface as
  stationed at the destination.
- Smoke tests on every fixture extended with `otherGroups` and
  `unidentifiedGroups` counts. New focused unit test
  `TestParseOtherAndUnidentifiedGroups` covers the column layout, the
  drop-on-unknown-destination rule, and the `X Y`-only unidentified
  rows.
- `tools/local-dev/reports/dg/KNNTS039.json` and
  `tools/local-dev/reports/dg/KNNTS041.json` regenerated so the
  synthetic-loader fixtures carry the new arrays.
- README updated: the two sections move out of "Skipped sections" into
  a "Foreign and unidentified groups" block; package doc-comment
  reflects the broader scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 15:21:55 +02:00
parent aee5f39a7e
commit cc4bc3c2b7
10 changed files with 46770 additions and 9291 deletions
+21 -5
View File
@@ -129,17 +129,33 @@ do not flow through this parser. A ships-in-production row pointing
at a planet that did not appear in `Your Planets` (which would be a
malformed legacy file) is dropped.
## Foreign and unidentified groups
The legacy text format does carry top-level `<Race> Groups` blocks
and a single `Unidentified Groups` block, both outside the battle
rosters — earlier parser revisions silently dropped them. F8-05
wires them up:
- **`OtherGroup[]`** — every `<Race> Groups` section outside a
`Battle at` block contributes one entry per row. The legacy row
is `# T D W S C T Q D P M` (count, class, drive/weapons/shields/
cargo tech, cargo type, load, destination, power=drive·20 — not
retained, mass). The destination resolves against the parsed
planet tables (`Your Planets`, `<Race> Planets`, `Uninhabited
Planets`); rows whose destination is invisible to the local
player are dropped — preferable to fabricating a number. The
legacy row carries no origin / range columns, so foreign groups
surface as stationed at the destination (origin / range nil).
- **`UnidentifiedGroup[]`** — the `Unidentified Groups` section
carries `X Y` floats only. Each row maps directly onto
`UnidentifiedGroup{X, Y}`; no planet resolution needed.
## Skipped sections (today)
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).
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
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
synthetic JSON emits `route: []`. The UI's overlay path
(`applyOrderOverlay`) supports running on top of an empty `routes`.
+121 -7
View File
@@ -4,11 +4,13 @@
// 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.
// and incoming groups; and — added in F8-05 — foreign `<Race> Groups`
// blocks outside battles together with `Unidentified 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 (
@@ -65,6 +67,8 @@ const (
sectionYourGroups
sectionYourFleets
sectionIncomingGroups
sectionOtherGroups
sectionUnidentifiedGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
@@ -94,6 +98,7 @@ type parser struct {
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction
pendingOtherGroups []pendingOtherGroup
// Battle accumulator. `battles` collects every parsed BattleReport;
// `pendingBattle` carries the in-flight battle until its block
@@ -148,6 +153,25 @@ type pendingGroup struct {
state string
}
// pendingOtherGroup buffers a foreign "<Race> Groups" row outside any
// battle block — these are visible to the local player but live on
// foreign planets, so the destination resolves against the parsed
// planet tables in [parser.resolvePending]. The legacy row carries
// no origin / range / fleet columns, so foreign groups are always
// treated as stationed at the destination.
type pendingOtherGroup struct {
count uint
class string
drive float64
weapons float64
shields float64
cargoTech float64
cargoType string
load float64
destinationName string
mass float64
}
type pendingFleet struct {
name string
groups uint
@@ -290,6 +314,10 @@ func (p *parser) handle(line string) error {
p.parseYourFleet(fields)
case sectionIncomingGroups:
p.parseIncomingGroup(fields)
case sectionOtherGroups:
p.parseOtherGroup(fields)
case sectionUnidentifiedGroups:
p.parseUnidentifiedGroup(fields)
case sectionYourSciences:
p.parseYourScience(fields)
case sectionOtherSciences:
@@ -381,6 +409,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
return sectionYourFleets, "", true
case "Incoming Groups":
return sectionIncomingGroups, "", true
case "Unidentified Groups":
return sectionUnidentifiedGroups, "", true
case "Uninhabited Planets":
return sectionUninhabitedPlanets, "", true
case "Unidentified Planets":
@@ -417,8 +447,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionOtherSciences, owner, true
}
if _, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionNone, "", true
if owner, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionOtherGroups, owner, true
}
return sectionNone, "", false
}
@@ -1067,6 +1097,68 @@ func (p *parser) parseYourFleet(fields []string) {
})
}
// parseOtherGroup buffers a foreign "<Race> Groups" row. The row's
// race is the current `otherOwner` set by classifySection. Columns
// (11 fields, last is mass):
//
// # T D W S C T Q D P M
//
// where the second T is the cargo type, the second D is the
// destination planet name, P is the flight-distance hint (drive*20,
// not retained) and M is the mass.
func (p *parser) parseOtherGroup(fields []string) {
if len(fields) < 11 {
return
}
count, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
drive, _ := parseFloat(fields[2])
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargoTech, _ := parseFloat(fields[5])
load, _ := parseFloat(fields[7])
mass, _ := parseFloat(fields[10])
p.pendingOtherGroups = append(p.pendingOtherGroups, pendingOtherGroup{
count: uint(count),
class: fields[1],
drive: drive,
weapons: weapons,
shields: shields,
cargoTech: cargoTech,
cargoType: fields[6],
load: load,
destinationName: fields[8],
mass: mass,
})
}
// parseUnidentifiedGroup appends an "Unidentified Groups" row
// directly to the report — the legacy format only carries the
// floating-point world coordinates of the in-flight blip, so
// there is nothing to defer to [parser.finish].
//
// X Y
func (p *parser) parseUnidentifiedGroup(fields []string) {
if len(fields) < 2 {
return
}
x, err := parseFloat(fields[0])
if err != nil {
return
}
y, err := parseFloat(fields[1])
if err != nil {
return
}
p.rep.UnidentifiedGroup = append(p.rep.UnidentifiedGroup, report.UnidentifiedGroup{
X: report.F(x),
Y: report.F(y),
})
}
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
//
// O D R S M
@@ -1145,6 +1237,28 @@ func (p *parser) resolvePending() {
})
}
for _, pg := range p.pendingOtherGroups {
dest, ok := p.lookupPlanetNumber(pg.destinationName)
if !ok {
continue
}
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.OtherGroup = append(p.rep.OtherGroup, report.OtherGroup{
Number: pg.count,
Class: pg.class,
Tech: tech,
Cargo: pg.cargoType,
Load: report.F(pg.load),
Destination: dest,
Mass: report.F(pg.mass),
})
}
for _, pf := range p.pendingFleets {
dest, ok := p.lookupPlanetNumber(pf.destinationName)
if !ok {
@@ -790,6 +790,87 @@ func TestParseIncomingGroups(t *testing.T) {
}
}
func TestParseOtherAndUnidentifiedGroups(t *testing.T) {
// Mixed fixture: two foreign "<Race> Groups" sections (one row
// drops because its destination is not in any parsed planet
// table), plus a single "Unidentified Groups" block. The
// per-race section header sets `otherOwner`, but the resulting
// model carries the destination planet number only — the race
// name surfaces through battle rosters and the planet's
// `owner` field, not on the group row.
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
"",
"Aliens Planets",
"",
" # X Y N S P I R P $ M C L",
" 42 200.00 300.00 Far 500.00 500.00 500.00 10.00 Capital 0.00 0.00 0.00 500.00",
"",
"Aliens Groups",
"",
" # T D W S C T Q D P M",
" 6 Skiff 3.40 0 0 1 COL 2 Castle 68.00 3.00",
" 1 Drone 5.10 0 0 0 - 0 Limbo 102.0 1.00",
"",
"Reds Groups",
"",
" # T D W S C T Q D P M",
" 1 Phantom 4.20 0 0 0 - 0 Far 84.0 9.00",
"",
"Unidentified Groups",
"",
" X Y",
"100.50 200.25",
"333.10 444.20",
"",
}, "\n")
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
// Skiff → Castle resolves against Your Planets, Phantom → Far
// resolves against Other Planets, Drone → Limbo drops (unknown).
if got, want := len(rep.OtherGroup), 2; got != want {
t.Fatalf("len(OtherGroup) = %d, want %d (Drone → Limbo dropped due to unknown destination); rows=%+v",
got, want, rep.OtherGroup)
}
a := rep.OtherGroup[0]
if a.Class != "Skiff" || a.Number != 6 || a.Destination != 17 {
t.Errorf("a (Class, Number, Destination) = (%q, %d, %d), want (\"Skiff\", 6, 17)",
a.Class, a.Number, a.Destination)
}
if a.Cargo != "COL" || float64(a.Load) != 2 || float64(a.Mass) != 3 {
t.Errorf("a (Cargo, Load, Mass) = (%q, %v, %v), want (\"COL\", 2, 3)",
a.Cargo, float64(a.Load), float64(a.Mass))
}
if a.Origin != nil || a.Range != nil {
t.Errorf("a (Origin, Range) = (%v, %v), want (nil, nil) — legacy foreign rows have no origin/range columns",
a.Origin, a.Range)
}
if float64(a.Tech["drive"]) != 3.40 {
t.Errorf("a Tech.drive = %v, want 3.40", float64(a.Tech["drive"]))
}
b := rep.OtherGroup[1]
if b.Class != "Phantom" || b.Number != 1 || b.Destination != 42 {
t.Errorf("b (Class, Number, Destination) = (%q, %d, %d), want (\"Phantom\", 1, 42)",
b.Class, b.Number, b.Destination)
}
if got, want := len(rep.UnidentifiedGroup), 2; got != want {
t.Fatalf("len(UnidentifiedGroup) = %d, want %d", got, want)
}
u := rep.UnidentifiedGroup[0]
if float64(u.X) != 100.50 || float64(u.Y) != 200.25 {
t.Errorf("u[0] (X, Y) = (%v, %v), want (100.50, 200.25)",
float64(u.X), float64(u.Y))
}
}
// --- smoke tests -----------------------------------------------------
type smokeWant struct {
@@ -801,6 +882,7 @@ type smokeWant struct {
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
otherGroups, unidentifiedGroups int
localScience, otherScience, otherShipClass int
bombings, shipProductions int
battles int
@@ -849,6 +931,8 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
{"OtherGroup", len(rep.OtherGroup), want.otherGroups},
{"UnidentifiedGroup", len(rep.UnidentifiedGroup), want.unidentifiedGroups},
{"LocalScience", len(rep.LocalScience), want.localScience},
{"OtherScience", len(rep.OtherScience), want.otherScience},
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
@@ -888,6 +972,7 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
// silently drops half the data.
func TestParseDgKNNTS039(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
otherGroups: 723, unidentifiedGroups: 72,
race: "KnightErrants", turn: 39,
mapW: 800, mapH: 800, planetCount: 700,
voteFor: "KnightErrants", votes: 16.02,
@@ -908,6 +993,7 @@ func TestParseDgKNNTS039(t *testing.T) {
func TestParseDgKNNTS040(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
otherGroups: 734, unidentifiedGroups: 109,
race: "KnightErrants", turn: 40,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49,
@@ -930,6 +1016,7 @@ func TestParseDgKNNTS040(t *testing.T) {
// exercises the deferred name-resolution path in [parser.finish].
func TestParseDgKNNTS041(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
otherGroups: 772, unidentifiedGroups: 349,
race: "KnightErrants", turn: 41,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50,
@@ -952,6 +1039,7 @@ func TestParseDgKNNTS041(t *testing.T) {
// gplus also sneaks "Incoming Groups" between sections.
func TestParseGplus40(t *testing.T) {
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
otherGroups: 1042, unidentifiedGroups: 44,
race: "MbI", turn: 40,
mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0,
@@ -974,6 +1062,7 @@ func TestParseGplus40(t *testing.T) {
// membership shape (no "Incoming Groups" this turn).
func TestParseDgKiller031(t *testing.T) {
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
otherGroups: 925, unidentifiedGroups: 34,
race: "Killer", turn: 31,
mapW: 250, mapH: 250, planetCount: 175,
players: 25, extinct: 12,
@@ -997,6 +1086,7 @@ func TestParseDgKiller031(t *testing.T) {
// deferred name resolution is exercised in production conditions).
func TestParseDgTancordia037(t *testing.T) {
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
otherGroups: 580, unidentifiedGroups: 24,
race: "Tancordia", turn: 37,
mapW: 210, mapH: 210, planetCount: 140,
players: 18, extinct: 7,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff