ui/phase-27: root-cause aggregation of duplicate (race, className) rows

Legacy reports list the same `(race, className)` pair across several
roster rows; the engine likewise creates one ShipGroup per arrival.
Both the legacy parser and `TransformBattle` were keyed on shipClass
without summing — only the last row / group's counts survived, so a
protocol's destroy count appeared to exceed the recorded initial
roster. The UI worked around this with phantom-frame logic.

Both parser and engine now SUM `Number`/`NumberLeft` across rows /
groups sharing the same class; the phantom-frame workaround is gone.
KNNTS041 turn 41 planet #7 reconciles: `Nails:pup` 1168 initial −
86 survivors = 1082 destroys.

The engine's previously latent nil-map write on `bg.Tech` (would
have paniced on any group with non-empty Tech) is fixed in the same
patch — it blocked the aggregation regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-13 18:52:40 +02:00
parent 2e7478f5ea
commit bd11cd80da
9 changed files with 344 additions and 133 deletions
+24
View File
@@ -814,6 +814,30 @@ func (p *parser) parseBattleRosterRow(fields []string) {
key := shipKey{race: p.pendingBattleRace, class: className}
idx := p.assignShipIndex(key)
// Legacy battle rosters may list the same `(race, className)`
// across multiple rows — different tech variants, ships pulled
// from several stacks / planets, etc. We collapse those rows
// into one BattleReportGroup keyed by `(race, className)` (the
// viewer aggregates per class anyway) by SUMMING Number and
// NumberLeft instead of overwriting; otherwise only the last
// row's counts survive and the battle protocol's destroy count
// would dwarf the recorded initial count (the original
// motivation for the now-removed "phantom destroy" workaround).
if existing, found := p.pendingBattle.ships[idx]; found {
existing.Number += uint(number)
existing.NumberLeft += uint(numLeft)
// LoadQuantity is per-ship cargo — average is a fair fallback
// when several stacks of the same class merge into one bucket.
existing.LoadQuantity = report.F(
(existing.LoadQuantity.F() + loadQuantity) / 2,
)
// Tech / LoadType / InBattle keep their first-seen values:
// the viewer treats them as bucket-wide attributes and the
// first row is normally the most representative tech variant.
p.pendingBattle.ships[idx] = existing
return
}
p.pendingBattle.ships[idx] = report.BattleReportGroup{
Race: p.pendingBattleRace,
ClassName: className,
@@ -533,6 +533,106 @@ func TestParseBattles(t *testing.T) {
}
}
// TestParseBattleAggregatesDuplicateClasses guards against the
// regression that produced the original "phantom destroys" symptom:
// the same `(race, className)` pair appearing on multiple roster
// rows must collapse into a single BattleReportGroup whose `Number`
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
// column, survivors) are the sums across rows. Without the
// aggregation only the last row's counts survived and the protocol's
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Battle at (#7) B-007",
"",
"Foo Groups",
"",
" # T D W S C T Q L",
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
"",
"Battle Protocol",
"",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
}, "\n")
rep, battles, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(battles), 1; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
b := battles[0]
// The three Foo roster rows collapse into two BattleReportGroup
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
if got, want := len(b.Ships), 3; got != want {
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
}
var drone, cruiser, pistolet *report.BattleReportGroup
for i := range b.Ships {
g := b.Ships[i]
switch g.ClassName {
case "Drone":
drone = &g
case "Cruiser":
cruiser = &g
case "Pistolet":
pistolet = &g
}
}
if drone == nil || cruiser == nil || pistolet == nil {
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
}
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
// Protocol corroborates: four Destroyed shots against Drone, so
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
// recorded delta only when both rows are summed.
if drone.Number != 7 {
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
}
if drone.NumberLeft != 3 {
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
}
// Cruiser and Pistolet are single-row classes — counts must match
// the file verbatim with no spurious merging across classes.
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
cruiser.Number, cruiser.NumberLeft)
}
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
pistolet.Number, pistolet.NumberLeft)
}
// rep-level summary must reflect the merged shape: 4 shots, one
// battle, no crash or spurious extra battles.
if got, want := len(rep.Battle), 1; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if rep.Battle[0].Shots != 4 {
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
}
}
// 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
+61 -61
View File
@@ -14743,10 +14743,10 @@
"race": "KnightErrants",
"className": "PeaceShip",
"tech": {
"DRIVE": 9.1
"DRIVE": 9.09
},
"num": 50,
"numLeft": 50,
"num": 52,
"numLeft": 52,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -15498,11 +15498,11 @@
"race": "Frightners",
"className": "moan",
"tech": {
"DRIVE": 7.79,
"SHIELDS": 5.15
"DRIVE": 7.5,
"SHIELDS": 4.85
},
"num": 85,
"numLeft": 85,
"num": 259,
"numLeft": 259,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -15988,13 +15988,13 @@
"race": "Slimes",
"className": "Fly_1",
"tech": {
"DRIVE": 5.79
"DRIVE": 6.02
},
"num": 105,
"numLeft": 105,
"num": 348,
"numLeft": 348,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
"inBattle": false
},
"6": {
"race": "Slimes",
@@ -16325,10 +16325,10 @@
"race": "KnightErrants",
"className": "PeaceShip",
"tech": {
"DRIVE": 9.1
"DRIVE": 10.62
},
"num": 99,
"numLeft": 99,
"num": 100,
"numLeft": 100,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -16497,10 +16497,10 @@
"race": "Enoxes",
"className": "Gnat",
"tech": {
"DRIVE": 11.4
"DRIVE": 9.07
},
"num": 26,
"numLeft": 26,
"num": 167,
"numLeft": 167,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -18583,8 +18583,8 @@
"tech": {
"DRIVE": 5.16
},
"num": 19,
"numLeft": 19,
"num": 1026,
"numLeft": 1026,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -19025,10 +19025,10 @@
"CARGO": 1.1,
"DRIVE": 10.85
},
"num": 1,
"num": 3,
"numLeft": 0,
"loadType": "COL",
"loadQuantity": 1.4,
"loadQuantity": 1.855,
"inBattle": true
},
"12": {
@@ -19140,9 +19140,9 @@
"race": "Koreans",
"className": "d",
"tech": {
"DRIVE": 9.87
"DRIVE": 2.4
},
"num": 112,
"num": 113,
"numLeft": 0,
"loadType": "",
"loadQuantity": 0,
@@ -29824,8 +29824,8 @@
"SHIELDS": 3.19,
"WEAPONS": 3.97
},
"num": 1,
"numLeft": 1,
"num": 2,
"numLeft": 2,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -29893,10 +29893,10 @@
"race": "Nails",
"className": "pup",
"tech": {
"DRIVE": 4.98
"DRIVE": 4.97
},
"num": 88,
"numLeft": 6,
"num": 1168,
"numLeft": 86,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -46450,10 +46450,10 @@
"race": "Ricksha",
"className": "Dron",
"tech": {
"DRIVE": 7.63
"DRIVE": 3.2
},
"num": 647,
"numLeft": 647,
"num": 1211,
"numLeft": 1211,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -46462,11 +46462,11 @@
"race": "Ricksha",
"className": "HDron",
"tech": {
"DRIVE": 7.63,
"DRIVE": 6.88,
"SHIELDS": 3.95
},
"num": 88,
"numLeft": 88,
"num": 112,
"numLeft": 112,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -46690,10 +46690,10 @@
"race": "KnightErrants",
"className": "PeaceShip",
"tech": {
"DRIVE": 9.09
"DRIVE": 9.1
},
"num": 2,
"numLeft": 2,
"num": 164,
"numLeft": 163,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -48389,10 +48389,10 @@
"className": "FS-6",
"tech": {
"DRIVE": 11.4,
"SHIELDS": 5.64
"SHIELDS": 5.1
},
"num": 48,
"numLeft": 48,
"num": 72,
"numLeft": 72,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -48449,10 +48449,10 @@
"race": "Enoxes",
"className": "Gnat",
"tech": {
"DRIVE": 11.4
"DRIVE": 8.4
},
"num": 100,
"numLeft": 100,
"num": 101,
"numLeft": 101,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -48466,10 +48466,10 @@
"SHIELDS": 2.13,
"WEAPONS": 4.22
},
"num": 1,
"numLeft": 1,
"num": 2,
"numLeft": 2,
"loadType": "COL",
"loadQuantity": 5.73,
"loadQuantity": 5.375,
"inBattle": true
},
"8": {
@@ -48479,10 +48479,10 @@
"CARGO": 1,
"DRIVE": 11.4,
"SHIELDS": 5.1,
"WEAPONS": 5.77
"WEAPONS": 5.44
},
"num": 1,
"numLeft": 1,
"num": 2,
"numLeft": 2,
"loadType": "COL",
"loadQuantity": 1.05,
"inBattle": true
@@ -48539,10 +48539,10 @@
"race": "Flagist",
"className": "Drone",
"tech": {
"DRIVE": 8.49
"DRIVE": 6.08
},
"num": 1,
"numLeft": 1,
"num": 2,
"numLeft": 2,
"loadType": "",
"loadQuantity": 0,
"inBattle": false
@@ -48587,10 +48587,10 @@
"race": "Enoxes",
"className": "Gnat",
"tech": {
"DRIVE": 11.4
"DRIVE": 9.07
},
"num": 49,
"numLeft": 49,
"num": 50,
"numLeft": 50,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -48662,10 +48662,10 @@
"race": "KnightErrants",
"className": "PeaceShip",
"tech": {
"DRIVE": 9.09
"DRIVE": 8.71
},
"num": 1,
"numLeft": 1,
"num": 63,
"numLeft": 63,
"loadType": "",
"loadQuantity": 0,
"inBattle": true
@@ -48918,10 +48918,10 @@
"race": "KnightErrants",
"className": "PeaceShip",
"tech": {
"DRIVE": 8.71
"DRIVE": 5.6
},
"num": 1,
"numLeft": 1,
"num": 158,
"numLeft": 158,
"loadType": "",
"loadQuantity": 0,
"inBattle": true