ui/phase-23: turn-report view with twenty sections and TOC

Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 14:33:56 +02:00
parent 81d8be08b2
commit c58027c034
48 changed files with 5368 additions and 103 deletions
+28 -15
View File
@@ -60,6 +60,11 @@ already decodes from server responses
| `UninhabitedPlanet[]` | `Uninhabited Planets` |
| `UnidentifiedPlanet[]`| `Unidentified Planets` |
| `LocalShipClass[]` | `Your Ship Types` |
| `OtherShipClass[]` | `<Race> Ship Types` (Phase 23) |
| `LocalScience[]` | `Your Sciences` (Phase 23) |
| `OtherScience[]` | `<Race> Sciences` (Phase 23) |
| `Bombing[]` | `Bombings` (Phase 23) |
| `ShipProduction[]` | `Ships In Production` (Phase 23) |
| `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
@@ -78,29 +83,37 @@ tables (foreign-only knowledge the local player lacks) cause the entire
group / fleet / incoming row to be dropped — preferable to fabricating
a destination.
`ShipProduction.ProdUsed` is derived from the on-disk `Percent` and the
producing planet's material/resources via [`pkg/calc.ShipBuildCost`]
(the same helper the engine's `controller.ProduceShip` uses). The
legacy text format does not carry a `prod_used` column directly; the
derivation gives the cumulative production-equivalent of the build
progress so far. The real engine's `ProdUsed` is the per-turn
residual production poured into the partial ship, which is not
recoverable from a single legacy snapshot. The two numbers stay in
the same units and the same ballpark, which is good enough for the
synthetic-mode UI — live engine reports come over the FBS wire and
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.
## Skipped sections (today)
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).
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).
- Foreign / other ship types (`<Race> Ship Types`)
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`)
- 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`)
- Ships in production (`Ships In Production`)
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema
carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text
carries per-battle rosters with stripped columns (no origin / range /
destination) and no stable identifier. Synthesising UUIDs from the
text would invent data that future Phase 27 work would have to drop;
the synthetic JSON therefore emits `battle: []`.
- `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`.
+242 -23
View File
@@ -21,6 +21,7 @@ import (
"github.com/google/uuid"
"galaxy/calc"
"galaxy/model/report"
)
@@ -57,6 +58,11 @@ const (
sectionYourGroups
sectionYourFleets
sectionIncomingGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
)
type parser struct {
@@ -71,10 +77,14 @@ type parser struct {
// 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
// some engine variants. Ships-in-production rows are buffered
// because their prod_used derivation needs the producing planet's
// material and resources (read from "Your Planets") to call
// [calc.ShipBuildCost], and the section order is not guaranteed.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction
}
type pendingGroup struct {
@@ -112,6 +122,14 @@ type pendingIncoming struct {
mass float64
}
type pendingShipProduction struct {
planetNumber uint
class string
cost float64
percent float64
free float64
}
func newParser() *parser {
return &parser{sec: sectionNone}
}
@@ -177,6 +195,16 @@ func (p *parser) handle(line string) error {
p.parseYourFleet(fields)
case sectionIncomingGroups:
p.parseIncomingGroup(fields)
case sectionYourSciences:
p.parseYourScience(fields)
case sectionOtherSciences:
p.parseOtherScience(fields)
case sectionOtherShipTypes:
p.parseOtherShipClass(fields)
case sectionBombings:
p.parseBombing(fields)
case sectionShipsInProduction:
p.parseShipProductionRow(fields)
}
return nil
}
@@ -259,10 +287,13 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
return sectionUnidentifiedPlanets, "", true
case "Your vote:":
return sectionYourVote, "", true
case "Your Sciences",
"Bombings",
"Ships In Production",
"Approaching Groups",
case "Your Sciences":
return sectionYourSciences, "", true
case "Bombings":
return sectionBombings, "", true
case "Ships In Production":
return sectionShipsInProduction, "", true
case "Approaching Groups",
"Broadcast Message",
"Battle Protocol":
return sectionNone, "", true
@@ -279,11 +310,11 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
return sectionOtherPlanets, owner, true
}
if _, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionNone, "", true
if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionOtherShipTypes, owner, true
}
if _, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionNone, "", true
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionOtherSciences, owner, true
}
if _, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionNone, "", true
@@ -468,30 +499,173 @@ func (p *parser) parseUnidentifiedPlanet(fields []string) {
//
// N D A W S C M
func (p *parser) parseShipClass(fields []string) {
if len(fields) < 7 {
sc, ok := decodeShipClassRow(fields)
if !ok {
return
}
p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
}
// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
// Same 7-column layout as [parser.parseShipClass]; the owning race is
// captured into [parser.otherOwner] when the section header is
// classified by [classifySection].
func (p *parser) parseOtherShipClass(fields []string) {
sc, ok := decodeShipClassRow(fields)
if !ok {
return
}
p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
Race: p.otherOwner,
ShipClass: sc,
})
}
func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
var sc report.ShipClass
if len(fields) < 7 {
return sc, false
}
drive, err := parseFloat(fields[1])
if err != nil {
return
return sc, false
}
armament, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
return sc, false
}
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5])
mass, _ := parseFloat(fields[6])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Armament = uint(armament)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
sc.Mass = report.F(mass)
return sc, true
}
p.rep.LocalShipClass = append(p.rep.LocalShipClass, report.ShipClass{
Name: fields[0],
Drive: report.F(drive),
Armament: uint(armament),
Weapons: report.F(weapons),
Shields: report.F(shields),
Cargo: report.F(cargo),
Mass: report.F(mass),
// parseYourScience parses one row of the "Your Sciences" block.
// Columns:
//
// N D W S C
//
// where D/W/S/C are the four tech proportions as fractions summing
// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
func (p *parser) parseYourScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.LocalScience = append(p.rep.LocalScience, sc)
}
// parseOtherScience parses one row of a "<Race> Sciences" block.
// Same 5-column layout as [parser.parseYourScience]; the owning race
// is captured into [parser.otherOwner] by [classifySection].
func (p *parser) parseOtherScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
Race: p.otherOwner,
Science: sc,
})
}
func decodeScienceRow(fields []string) (report.Science, bool) {
var sc report.Science
if len(fields) < 5 {
return sc, false
}
drive, err := parseFloat(fields[1])
if err != nil {
return sc, false
}
weapons, _ := parseFloat(fields[2])
shields, _ := parseFloat(fields[3])
cargo, _ := parseFloat(fields[4])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
return sc, true
}
// parseBombing parses one row of the "Bombings" block. Columns
// (12 tokens, last is the wiped/damaged status word):
//
// W O # N P I P $ M C A status
//
// where the first P is the post-bombing population and the second
// P is the production string left on the planet. Status is parsed
// positionally — the header has a duplicate P, so a header-name
// lookup is not safe.
func (p *parser) parseBombing(fields []string) {
if len(fields) < 12 {
return
}
number, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
population, _ := parseFloat(fields[4])
industry, _ := parseFloat(fields[5])
capital, _ := parseFloat(fields[7])
material, _ := parseFloat(fields[8])
colonists, _ := parseFloat(fields[9])
attack, _ := parseFloat(fields[10])
wiped := fields[11] == "Wiped"
p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
Attacker: fields[0],
Owner: fields[1],
Number: uint(number),
Planet: fields[3],
Population: report.F(population),
Industry: report.F(industry),
Production: fields[6],
Capital: report.F(capital),
Material: report.F(material),
Colonists: report.F(colonists),
AttackPower: report.F(attack),
Wiped: wiped,
})
}
// parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns:
//
// # N S C P L
//
// where # is the planet number, N is the planet name (decorative —
// resolution uses #), S is the building ship class, C is the cost
// (== shipMass * 10), P is the build progress as a fraction in
// [0, 1], and L is the producing planet's free industry. The wire
// shape's `prod_used` field is not carried by the legacy text; it is
// derived during [parser.resolvePending] from the planet's material
// and resources via [calc.ShipBuildCost].
func (p *parser) parseShipProductionRow(fields []string) {
if len(fields) < 6 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
cost, _ := parseFloat(fields[3])
percent, _ := parseFloat(fields[4])
free, _ := parseFloat(fields[5])
p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
planetNumber: uint(number),
class: fields[2],
cost: cost,
percent: percent,
free: free,
})
}
@@ -695,6 +869,51 @@ func (p *parser) resolvePending() {
Mass: report.F(pi.mass),
})
}
for _, ps := range p.pendingShipProducts {
lp, ok := p.findLocalPlanet(ps.planetNumber)
if !ok {
continue
}
shipMass := ps.cost / 10
totalCost := calc.ShipBuildCost(
shipMass,
float64(lp.Material),
float64(lp.Resources),
)
// `ProdUsed` is the cumulative production-equivalent of the
// build progress so far. The real engine's `Progress` field
// accumulates across turns and the per-turn `ProdUsed` is a
// transient residual — neither of those is recoverable from a
// single legacy report. The derivation here keeps the value in
// the same units (production points) and in the right ballpark
// for synthetic-mode UI rendering; live engine reports do not
// flow through this parser, so the approximation never reaches
// production traffic. README.md skips section explains.
prodUsed := totalCost * ps.percent
p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
Planet: ps.planetNumber,
Class: ps.class,
Cost: report.F(ps.cost),
ProdUsed: report.F(prodUsed),
Percent: report.F(ps.percent),
Free: report.F(ps.free),
})
}
}
// findLocalPlanet returns the parsed "Your Planets" entry with the
// given number, used by the ships-in-production resolver to read
// material / resources for the [calc.ShipBuildCost] derivation.
// Ships-in-production only lists own ships, so the lookup against
// `LocalPlanet` is correct.
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
for _, lp := range p.rep.LocalPlanet {
if lp.Number == number {
return lp, true
}
}
return report.LocalPlanet{}, false
}
// lookupPlanetNumber resolves a legacy planet reference — either a
+259 -39
View File
@@ -201,7 +201,7 @@ func TestParseShipClasses(t *testing.T) {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalShipClass), 2; got != want {
t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want)
t.Fatalf("len(LocalShipClass) = %d, want %d", got, want)
}
bow := rep.LocalShipClass[1]
if bow.Name != "Bow105" || bow.Armament != 105 {
@@ -210,9 +210,193 @@ func TestParseShipClasses(t *testing.T) {
if got, want := float64(bow.Drive), 74.77; got != want {
t.Errorf("Bow105.Drive = %v, want %v", got, want)
}
if got, want := len(rep.OtherShipClass), 1; got != want {
t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
}
dragon := rep.OtherShipClass[0]
if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
dragon.Race, dragon.Name, dragon.Armament)
}
if got, want := float64(dragon.Mass), 19.80; got != want {
t.Errorf("Dragon.Mass = %v, want %v", got, want)
}
}
func TestParseSkipsBattlesAndBombings(t *testing.T) {
// TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
// in one fixture. The five-column layout (N D W S C) is shared.
func TestParseSciences(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Sciences",
"",
"N D W S C",
"_TerraForm 1 0 0 0",
"BalancedMix 0.5 0.2 0.2 0.1",
"",
"Pahanchiks Sciences",
"",
"N D W S C",
"_Drift 1 0 0 0",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalScience), 2; got != want {
t.Fatalf("len(LocalScience) = %d, want %d", got, want)
}
mix := rep.LocalScience[1]
if mix.Name != "BalancedMix" {
t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
}
if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
float64(mix.Drive), float64(mix.Cargo))
}
if got, want := len(rep.OtherScience), 1; got != want {
t.Fatalf("len(OtherScience) = %d, want %d", got, want)
}
drift := rep.OtherScience[0]
if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
drift.Race, drift.Name)
}
}
// TestParseBombings covers a wiped row + a damaged row + the duplicate
// `P` column header (population vs production string) — assertions
// hit every wire field so a positional-index slip is caught.
func TestParseBombings(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Bombings",
"",
"W O # N P I P $ M C A",
"Knights Ricksha 20 DW-1207 1.56 0.00 Dron 0.00 0.00 0.00 7.62 Wiped",
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.Bombing), 2; got != want {
t.Fatalf("len(Bombing) = %d, want %d", got, want)
}
wiped := rep.Bombing[0]
if !wiped.Wiped {
t.Errorf("Bombing[0].Wiped = false, want true")
}
if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
wiped.Attacker, wiped.Owner, wiped.Number)
}
if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
wiped.Planet, wiped.Production)
}
if float64(wiped.AttackPower) != 7.62 {
t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
}
damaged := rep.Bombing[1]
if damaged.Wiped {
t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
}
if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
float64(damaged.Capital), float64(damaged.Colonists))
}
}
// TestParseShipsInProduction covers the prod_used derivation through
// [calc.ShipBuildCost]. The producing planet is mounted first with a
// non-zero material stockpile so the farming term contributes a
// non-trivial slice of totalCost; the expected prod_used number is
// derived from totalCost * percent with the same formula the parser
// uses.
func TestParseShipsInProduction(t *testing.T) {
// Planet: Material=0.68, Resources=10.00.
// Ship: cost=990.10 -> shipMass=99.01.
// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
// = 990.10 + 9.833
// = 999.933
// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
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 CombatFlame 0.00 0.68 88.78 1000.00",
"",
"Ships In Production",
"",
" # N S C P L",
" 17 Castle CombatFlame 990.10 0.07 1000.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 1; got != want {
t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
}
sp := rep.ShipProduction[0]
if sp.Planet != 17 || sp.Class != "CombatFlame" {
t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
sp.Planet, sp.Class)
}
if got := float64(sp.Cost); got != 990.10 {
t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
}
if got := float64(sp.Percent); got != 0.07 {
t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
}
if got := float64(sp.Free); got != 1000.0 {
t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
}
wantProdUsed := 69.995
if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
got, wantProdUsed)
}
}
// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
// net: a ships-in-production row referencing a planet not seen in
// "Your Planets" is dropped, because the prod_used derivation needs
// the planet's material and resources.
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Ships In Production",
"",
" # N S C P L",
" 99 Lost Frigate 100.00 0.05 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 0; got != want {
t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
got, want)
}
}
// TestParseSkipsBattles covers the only remaining legacy section the
// parser ignores: "Battle at ..." headers and the following "Battle
// Protocol" block. Bombings, Ships In Production, and the per-race
// Sciences / Ship Types blocks now flow through real parsers; the
// dedicated section tests below cover them.
func TestParseSkipsBattles(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
@@ -225,10 +409,6 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
"",
"Foo fires on Bar : Destroyed",
"",
"Bombings",
"",
"# data line",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
@@ -240,7 +420,10 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalPlanet), 1; got != want {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want)
t.Fatalf("len(LocalPlanet) = %d, want %d (battle rows must not leak in)", got, want)
}
if got, want := len(rep.Battle), 0; got != want {
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", got, want)
}
}
@@ -404,14 +587,16 @@ func TestParseIncomingGroups(t *testing.T) {
// --- 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
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
localScience, otherScience, otherShipClass int
bombings, shipProductions int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
@@ -457,6 +642,11 @@ 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},
{"LocalScience", len(rep.LocalScience), want.localScience},
{"OtherScience", len(rep.OtherScience), want.otherScience},
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
}
for _, c := range checks {
if c.got != c.want {
@@ -477,10 +667,15 @@ func TestParseDgKNNTS039(t *testing.T) {
voteFor: "KnightErrants", votes: 16.02,
players: 91, extinct: 49,
local: 22, other: 89, uninhabited: 17, unidentified: 572,
shipClasses: 24,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
shipClasses: 24,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
})
}
@@ -490,10 +685,15 @@ func TestParseDgKNNTS040(t *testing.T) {
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,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
})
}
@@ -506,10 +706,15 @@ func TestParseDgKNNTS041(t *testing.T) {
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,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
localScience: 1,
otherScience: 1,
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
})
}
@@ -522,10 +727,15 @@ func TestParseGplus40(t *testing.T) {
mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
localScience: 0,
otherScience: 0,
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
})
}
@@ -538,10 +748,15 @@ func TestParseDgKiller031(t *testing.T) {
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,
shipClasses: 11,
localGroups: 175,
localFleets: 2,
incomingGroups: 0,
localScience: 0,
otherScience: 0,
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
})
}
@@ -555,10 +770,15 @@ func TestParseDgTancordia037(t *testing.T) {
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,
shipClasses: 40,
localGroups: 311,
localFleets: 30,
incomingGroups: 2,
localScience: 1,
otherScience: 1,
otherShipClass: 123,
bombings: 22,
shipProductions: 20,
})
}