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
+299 -110
View File
@@ -244,133 +244,322 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
}
}
// TestParseYourGroups exercises the local-group section. Two rows
// cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space",
// origin name + range) variants, plus a cargo-loaded row to assert the
// load-type / load-quantity columns are wired through. A planet
// table is mounted upfront so destination/origin name resolution
// has something to bind against.
func TestParseYourGroups(t *testing.T) {
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",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
"Your Groups",
"",
" G # T D W S C T Q D F R P M L",
" 0 2 Frontier 5.05 0.00 0.00 1.0 - 0 Castle - - 92.84 12.37 - In_Orbit",
" 1 1 Bow105 11.19 4.76 7.09 1.0 COL 1 Castle - - 111.9 149.54 - In_Orbit",
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalGroup), 3; got != want {
t.Fatalf("len(LocalGroup) = %d, want %d", got, want)
}
first := rep.LocalGroup[0]
if first.Number != 2 || first.Class != "Frontier" {
t.Errorf("first group = (%d, %q), want (2, Frontier)", first.Number, first.Class)
}
if got, want := float64(first.Tech["drive"]), 5.05; got != want {
t.Errorf("first.Tech[drive] = %v, want %v", got, want)
}
if first.Destination != 17 {
t.Errorf("first.Destination = %d, want 17 (Castle resolved)", first.Destination)
}
if first.Origin != nil || first.Range != nil {
t.Errorf("first.{Origin,Range} = (%v, %v), want both nil for In_Orbit", first.Origin, first.Range)
}
if first.State != "In_Orbit" {
t.Errorf("first.State = %q, want In_Orbit", first.State)
}
if first.Fleet != nil {
t.Errorf("first.Fleet = %v, want nil for `-`", first.Fleet)
}
loaded := rep.LocalGroup[1]
if loaded.Cargo != "COL" || float64(loaded.Load) != 1.0 {
t.Errorf("loaded cargo/load = (%q, %v), want (COL, 1.0)", loaded.Cargo, float64(loaded.Load))
}
flying := rep.LocalGroup[2]
if flying.State != "In_Space" {
t.Errorf("flying.State = %q, want In_Space", flying.State)
}
if flying.Origin == nil || *flying.Origin != 17 {
t.Errorf("flying.Origin = %v, want 17 (Castle)", flying.Origin)
}
if flying.Range == nil || float64(*flying.Range) != 7.5 {
t.Errorf("flying.Range = %v, want 7.5", flying.Range)
}
if flying.Destination != 87 {
t.Errorf("flying.Destination = %d, want 87 (North)", flying.Destination)
}
}
func TestParseYourFleets(t *testing.T) {
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",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
"Your Fleets",
"",
" # N G D F R P",
" 0 Fast 3 Castle - - 45 In_Orbit",
" 1 Far 2 North Castle 4.50 20 In_Space",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalFleet), 2; got != want {
t.Fatalf("len(LocalFleet) = %d, want %d", got, want)
}
fast := rep.LocalFleet[0]
if fast.Name != "Fast" || fast.Groups != 3 || fast.Destination != 17 {
t.Errorf("fast = %+v, want Name=Fast Groups=3 Destination=17", fast)
}
if fast.State != "In_Orbit" {
t.Errorf("fast.State = %q, want In_Orbit", fast.State)
}
if fast.Origin != nil || fast.Range != nil {
t.Errorf("fast.{Origin,Range} = (%v, %v), want both nil", fast.Origin, fast.Range)
}
far := rep.LocalFleet[1]
if far.Origin == nil || *far.Origin != 17 {
t.Errorf("far.Origin = %v, want 17 (Castle)", far.Origin)
}
if far.Range == nil || float64(*far.Range) != 4.5 {
t.Errorf("far.Range = %v, want 4.5", far.Range)
}
}
func TestParseIncomingGroups(t *testing.T) {
// Origin is a `#NN` by-id reference; destination resolves
// against the local planet table that was parsed earlier in
// this synthetic file. The order is intentionally swapped — in
// real legacy reports "Incoming Groups" can land before
// "Your Planets", which is why the parser buffers rows for
// post-processing.
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Incoming Groups",
"",
"O D R S M",
"#98 Castle 136.16 190 1",
"North Castle 42.12 99 2",
"",
"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",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.IncomingGroup), 2; got != want {
t.Fatalf("len(IncomingGroup) = %d, want %d", got, want)
}
a := rep.IncomingGroup[0]
if a.Origin != 98 || a.Destination != 17 {
t.Errorf("a (Origin, Destination) = (%d, %d), want (98, 17)", a.Origin, a.Destination)
}
if float64(a.Distance) != 136.16 || float64(a.Speed) != 190 {
t.Errorf("a (Distance, Speed) = (%v, %v), want (136.16, 190)", float64(a.Distance), float64(a.Speed))
}
b := rep.IncomingGroup[1]
if b.Origin != 87 || b.Destination != 17 {
t.Errorf("b (Origin, Destination) = (%d, %d), want (87, 17)", b.Origin, b.Destination)
}
}
// --- smoke tests -----------------------------------------------------
type smokeWant struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper()
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
if rep.Race != want.race || rep.Turn != want.turn {
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn)
}
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
}
if want.voteFor != "" {
if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes {
t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)",
rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes)
}
}
extinct := 0
for _, pl := range rep.Player {
if pl.Extinct {
extinct++
}
}
checks := []struct {
name string
got int
want int
}{
{"Player", len(rep.Player), want.players},
{"extinct", extinct, want.extinct},
{"LocalPlanet", len(rep.LocalPlanet), want.local},
{"OtherPlanet", len(rep.OtherPlanet), want.other},
{"UninhabitedPlanet", len(rep.UninhabitedPlanet), want.uninhabited},
{"UnidentifiedPlanet", len(rep.UnidentifiedPlanet), want.unidentified},
{"LocalShipClass", len(rep.LocalShipClass), want.shipClasses},
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
}
}
}
// TestParseDgKNNTS039 is a smoke test: the parser must produce
// stable top-line counts from the real dg/KNNTS039.REP fixture.
// Field-level fidelity is asserted in the unit tests above; this
// test catches regressions where a section-classifier change
// silently drops half the data.
func TestParseDgKNNTS039(t *testing.T) {
const path = "../reports/dg/KNNTS039.REP"
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
want := struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other, uninhabited, unidentified, shipClasses int
}{
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
race: "KnightErrants", turn: 39,
mapW: 800, mapH: 800, planetCount: 700,
voteFor: "KnightErrants", votes: 16.02,
players: 91, extinct: 49,
local: 22, other: 89, uninhabited: 17, unidentified: 572,
shipClasses: 24,
}
if rep.Race != want.race || rep.Turn != want.turn {
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn)
}
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
}
if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes {
t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)",
rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes)
}
extinct := 0
for _, pl := range rep.Player {
if pl.Extinct {
extinct++
}
}
if got, exp := len(rep.Player), want.players; got != exp {
t.Errorf("len(Player) = %d, want %d", got, exp)
}
if extinct != want.extinct {
t.Errorf("extinct = %d, want %d", extinct, want.extinct)
}
if got, exp := len(rep.LocalPlanet), want.local; got != exp {
t.Errorf("len(LocalPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.OtherPlanet), want.other; got != exp {
t.Errorf("len(OtherPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp {
t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp {
t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp {
t.Errorf("len(LocalShipClass) = %d, want %d", got, exp)
}
shipClasses: 24,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
})
}
// TestParseGplus40 mirrors TestParseDgKNNTS039 for the gplus engine
// fixture so the variant difference (tabs vs spaces in headers) is
// exercised on a real file.
func TestParseDgKNNTS040(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
race: "KnightErrants", turn: 40,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49,
local: 22, other: 93, uninhabited: 27, unidentified: 558,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
})
}
// TestParseDgKNNTS041 covers a turn with active "Incoming Groups"
// entries (12 rows) appearing before the "Your Planets" table —
// exercises the deferred name-resolution path in [parser.finish].
func TestParseDgKNNTS041(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
race: "KnightErrants", turn: 41,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50,
local: 29, other: 103, uninhabited: 23, unidentified: 545,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
})
}
// TestParseGplus40 exercises the gplus engine variant (tabs in
// section headers, pseudo-cyrillic ASCII names) and a single fleet.
// gplus also sneaks "Incoming Groups" between sections.
func TestParseGplus40(t *testing.T) {
const path = "../reports/gplus/40.REP"
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
want := struct {
race string
turn uint
mapW, mapH, planetCount uint32
players, extinct, local, other, uninhabited, unidentified, shipClasses int
}{
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
race: "MbI", turn: 40,
mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 152,
shipClasses: 56,
}
if rep.Race != want.race || rep.Turn != want.turn {
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn)
}
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
}
extinct := 0
for _, pl := range rep.Player {
if pl.Extinct {
extinct++
}
}
if got, exp := len(rep.Player), want.players; got != exp {
t.Errorf("len(Player) = %d, want %d", got, exp)
}
if extinct != want.extinct {
t.Errorf("extinct = %d, want %d", extinct, want.extinct)
}
if got, exp := len(rep.LocalPlanet), want.local; got != exp {
t.Errorf("len(LocalPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.OtherPlanet), want.other; got != exp {
t.Errorf("len(OtherPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp {
t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp {
t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp {
t.Errorf("len(LocalShipClass) = %d, want %d", got, exp)
}
local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
})
}
// TestParseDgKiller031 exercises the Killer engine variant which
// ships "Your Fleets" + "Your Groups" with the Fl1/F2 fleet
// membership shape (no "Incoming Groups" this turn).
func TestParseDgKiller031(t *testing.T) {
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
race: "Killer", turn: 31,
mapW: 250, mapH: 250, planetCount: 175,
players: 25, extinct: 12,
local: 18, other: 127, uninhabited: 20, unidentified: 10,
shipClasses: 11,
localGroups: 175,
localFleets: 2,
incomingGroups: 0,
})
}
// TestParseDgTancordia037 is the richest smoke fixture: it carries
// 311 local groups across 30 fleets, two incoming groups, and the
// "Incoming Groups" section appears before "Your Planets" (so the
// deferred name resolution is exercised in production conditions).
func TestParseDgTancordia037(t *testing.T) {
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
race: "Tancordia", turn: 37,
mapW: 210, mapH: 210, planetCount: 140,
players: 18, extinct: 7,
local: 23, other: 62, uninhabited: 26, unidentified: 29,
shipClasses: 40,
localGroups: 311,
localFleets: 30,
incomingGroups: 2,
})
}
func parseFile(t *testing.T, rel string) (report.Report, error) {