Files
Ilia Denisov bd11cd80da 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>
2026-05-13 18:52:40 +02:00

104 lines
3.0 KiB
Go

package controller
import (
"galaxy/model/report"
"github.com/google/uuid"
)
func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
r := &report.BattleReport{
ID: b.ID,
Planet: b.Planet,
PlanetName: c.MustPlanet(b.Planet).Name,
Races: make(map[int]uuid.UUID),
Ships: make(map[int]report.BattleReportGroup),
Protocol: make([]report.BattleActionReport, len(b.Protocol)),
}
cacheShipClass := make(map[uuid.UUID]int)
cacheRaceName := make(map[uuid.UUID]int)
processedGroup := make(map[int]bool)
addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(groupId)
sg := c.ShipGroup(groupId)
// Several ship-groups of the same race/class can take part
// in the same battle (different tech upgrades, arrivals from
// different planets, …). They share a single
// BattleReportGroup entry keyed by ShipClass.ID — when a
// later group lands on a cached class we add its Number and
// NumberLeft into the existing entry instead of dropping
// them, so the protocol's per-class destroy counts reconcile
// with the recorded totals. `processedGroup` guards against
// double-counting a single groupId across multiple shots in
// the protocol — `ship()` runs on every attacker and defender
// reference, the merge must happen once per groupId.
if existing, ok := cacheShipClass[shipClass.ID]; ok {
if !processedGroup[groupId] {
bg := r.Ships[existing]
bg.Number += b.InitialNumbers[groupId]
bg.NumberLeft += sg.Number
if inBattle {
bg.InBattle = true
}
r.Ships[existing] = bg
processedGroup[groupId] = true
}
return existing
}
itemNumber := len(r.Ships)
bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
InBattle: inBattle,
Number: b.InitialNumbers[groupId],
NumberLeft: sg.Number,
ClassName: shipClass.Name,
LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()),
Tech: make(map[string]report.Float, len(sg.Tech)),
}
for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F())
}
r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber
processedGroup[groupId] = true
return itemNumber
}
ship := func(groupId int) int {
return addShipGroup(groupId, true)
}
race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId)
if v, ok := cacheRaceName[race.ID]; ok {
return v
} else {
itemNumber := len(r.Races)
r.Races[itemNumber] = race.ID
cacheRaceName[race.ID] = itemNumber
return itemNumber
}
}
for i := range b.Protocol {
r.Protocol[i] = report.BattleActionReport{
Attacker: race(b.Protocol[i].Attacker),
AttackerShipClass: ship(b.Protocol[i].Attacker),
Defender: race(b.Protocol[i].Defender),
DefenderShipClass: ship(b.Protocol[i].Defender),
Destroyed: b.Protocol[i].Destroyed,
}
}
for sgi, inBattle := range b.ObserverGroups {
if !inBattle {
addShipGroup(sgi, false)
}
}
return r
}