ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
14 changed files with 42416 additions and 6327 deletions
Showing only changes of commit 8839f46c25 - Show all commits
+47 -10
View File
@@ -60,22 +60,47 @@ already decodes from server responses
| `UninhabitedPlanet[]` | `Uninhabited Planets` |
| `UnidentifiedPlanet[]`| `Unidentified Planets` |
| `LocalShipClass[]` | `Your Ship Types` |
| `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
Players whose name in the legacy file ends with `_RIP` are emitted with
the suffix stripped and `Extinct: true`.
`LocalGroup.ID` is synthesised deterministically from the per-report
group index via `uuid.NewSHA1`, so re-running the converter on the same
input file yields byte-identical JSON.
`LocalGroup.Speed` is left at zero — the legacy "Your Groups" table does
not expose ship speed; the UI can derive it from `pkg/calc.Speed` if
ever required.
Origin / Range names that don't resolve against the parsed planet
tables (foreign-only knowledge the local player lacks) cause the entire
group / fleet / incoming row to be dropped — preferable to fabricating
a destination.
## Skipped sections (today)
These exist in legacy reports but have no UI decoder yet, so the
parser ignores them. Each becomes in-scope as soon as its UI phase
lands (see "Adding a new field" below).
These exist in legacy reports but either have no UI decoder yet or
cannot be derived from the legacy text format at all. Each becomes
in-scope as soon as its UI phase lands (see "Adding a new field"
below).
- Foreign / other ship types (`<Race> Ship Types`)
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`)
- Battles (`Battle at (#N) Name`, `Battle Protocol`)
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters
inside these blocks carry minimal columns (no origin / range /
destination) and are intentionally skipped: parsing them would
produce mostly-empty `OtherGroup` records that drift away from the
typed contract.
- Bombings (`Bombings`)
- Approaching / foreign groups (`Approaching Groups`, `<Race> Groups`)
- Ships in production (`Ships In Production`)
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the
synthetic JSON emits `otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`.
- `OtherShipClass[]` — present in legacy as `<Race> Ship Types`, but
no UI decoder yet; synthetic JSON emits `otherShipClass: []`.
- 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`.
@@ -114,8 +139,20 @@ go test ./tools/local-dev/legacy-report/...
```
Inline fixtures exercise the per-section row parsers; smoke tests
parse the real `dg/KNNTS039.REP` and `gplus/40.REP` and assert
top-level counts (number of planets, players, extinct races, ship
classes). Field-level fidelity is the inline tests' responsibility;
the smoke tests catch regressions where a refactor of the section
classifier silently drops a whole table.
parse the real fixtures under `tools/local-dev/reports/dg/` and
`tools/local-dev/reports/gplus/` and assert top-level counts. The
current smoke set spans:
- **dg/KNNTS039041** — KnightErrants saga; `041` is the only one
with `Incoming Groups`, exercising deferred name resolution.
- **dg/Killer031** — Killer engine variant with two `Your Fleets`
entries (`Fl1`, `F2`).
- **dg/Tancordia037** — the richest fixture: 311 local groups in
30 fleets, two incoming groups, "Incoming Groups" landing before
"Your Planets".
- **gplus/40.REP** — gplus variant; tabs in headers, pseudo-cyrillic
ship class names, single fleet, ten incoming groups.
Field-level fidelity is the inline tests' responsibility; the smoke
tests catch regressions where a refactor of the section classifier
silently drops a whole table.
+1 -1
View File
@@ -4,6 +4,6 @@ go 1.26.0
require galaxy/model v0.0.0
require github.com/google/uuid v1.6.0 // indirect
require github.com/google/uuid v1.6.0
replace galaxy/model => ../../../pkg/model
+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)
}
+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) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff