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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user