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,