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
+87
View File
@@ -8,6 +8,7 @@ import (
"galaxy/calc"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert"
)
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
assert.Zero(t, c.ShipGroup(3).Number)
}
}
// TestTransformBattleAggregatesSameShipClass guards against the
// engine-side variant of the duplicate-class bug. Several ShipGroups
// of the same ShipClass.ID can take part in the same battle (arrivals
// from different planets, tech splits, etc.); they must collapse into
// a single BattleReportGroup with summed Number and NumberLeft. The
// pre-fix engine cached the first group's index and silently dropped
// every subsequent group's initial / survivor counts, which manifested
// downstream as more Destroyed shots in the protocol than the
// recorded initial roster could account for.
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
// (owned by Race_0; the Race_1 group lands there via the Unsafe
// helper that bypasses the ownership check). Group indices land
// at 0, 1, 2 in creation order.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
// Simulate post-battle survivor counts: Group 0 ended the battle
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
// group's 6 — that's the regression).
c.ShipGroup(0).Number = 8
c.ShipGroup(1).Number = 6
b := &controller.Battle{
Planet: R0_Planet_0_num,
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
// Protocol must reference every in-battle group at least once
// (otherwise TransformBattle won't register it through the
// `ship()` path). Two shots from Race_1 against each Race_0
// group hits both groupIds.
Protocol: []controller.BattleAction{
{Attacker: 2, Defender: 0, Destroyed: true},
{Attacker: 2, Defender: 1, Destroyed: true},
},
}
r := controller.TransformBattle(c, b)
// Two BattleReportGroup entries total: one merged Race_0_Gunship
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
if got, want := len(r.Ships), 2; got != want {
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
}
var gunship0, gunship1 *report.BattleReportGroup
for i := range r.Ships {
grp := r.Ships[i]
switch grp.Race {
case Race_0.Name:
gunship0 = &grp
case Race_1.Name:
gunship1 = &grp
}
}
if gunship0 == nil || gunship1 == nil {
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
}
if gunship0.ClassName != Race_0_Gunship {
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
}
if gunship0.Number != 20 {
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
}
if gunship0.NumberLeft != 14 {
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
}
if !gunship0.InBattle {
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
}
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
gunship1.Number, gunship1.NumberLeft)
}
}
+27 -5
View File
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
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,
@@ -31,23 +56,20 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
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 {
shipClass := c.ShipGroupShipClass(groupId)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true)
}
}
race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId)
+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
+27 -15
View File
@@ -132,23 +132,35 @@ on the same defender therefore look like two distinct pulses
rather than one continuous line. On pause the line and flash
stay drawn so the user can study the current shot.
## Phantom destroys
## Aggregated ship-class buckets
Legacy emitters (the `dg` engine format that feeds the synthetic-
report path) occasionally log more `Destroyed` (and `Shields`)
lines against a ship-group bucket than the bucket's initial
population — the emitter keeps recording hits past the moment a
group emptied. `buildFrames` marks every such frame as
`phantom: true` and skips the race-total decrement so the race
stays on the scene until its actual ships are gone.
Real legacy reports list the same `(race, className)` pair across
several roster rows — different tech variants, ships pulled from
multiple stacks or planets. The legacy-report parser
([parser.go](../../tools/local-dev/legacy-report/parser.go))
collapses those rows into a single `BattleReportGroup` keyed by
`(race, className)` by SUMMING `Number` and `NumberLeft`; the
engine's `TransformBattle`
([battle_transform.go](../../game/internal/controller/battle_transform.go))
applies the same merge keyed by `ShipClass.ID`, guarded by a
processed-group set so the same source `groupId` is not summed
twice across multiple protocol references.
During play the BattleViewer fast-forwards through streaks of
phantom frames via a 0 ms timer so the user never sees a silent
gap (KNNTS041 had ~30 phantom frames between shots 224 and 255
right after the last `Nails:pup` died). Step controls and the
scrubber can still land on a phantom frame deliberately — useful
when inspecting the protocol entry that the engine emitted into
the void.
Without this aggregation only the last roster row's counts
survived, and the protocol's destroy count against the class
would dwarf the recorded initial count — KNNTS041 turn 41 planet
\#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
buggy parser stored only the last row's 88, so the 1082 destroys
in the protocol looked like phantom hits past the empty bucket.
After the fix both sides reconcile: 1168 initial 86 survivors =
1082 destroys.
`buildFrames`
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
a malformed protocol never pushes a race below zero; in normal
operation the clamp is a no-op because parser + engine already
folded duplicate rows together.
## Final-frame freeze
@@ -67,7 +67,6 @@ matching `pkg/model/report/battle.go` and it plays back.
return {
shotIndex: cur.shotIndex,
lastAction: cur.lastAction,
phantom: cur.phantom,
remaining: prev.remaining,
activeRaceIds: prev.activeRaceIds,
};
@@ -79,29 +78,11 @@ matching `pkg/model/report/battle.go` and it plays back.
// 10 % of the frame's interval, then advance. Effect re-arms
// whenever frameIndex / playing / speed changes; previous
// timers clean up through the return.
//
// A phantom frame (shot against an already-empty defender)
// would otherwise hold the scene silent for the full interval.
// During play we fast-forward to the next non-phantom frame
// through a 0 ms timer, so streaks of phantoms (KNNTS041
// frames 225..255, 401..414, …) collapse into a single tick
// from the user's POV.
$effect(() => {
void frameIndex;
void speed;
shotVisible = true;
if (!playing) return;
if (rawFrame.phantom && frameIndex < frames.length - 1) {
let next = frameIndex + 1;
while (next < frames.length - 1 && frames[next].phantom) {
next++;
}
const target = next;
const skip = setTimeout(() => {
frameIndex = target;
}, 0);
return () => clearTimeout(skip);
}
const intervalMs = 400 / speed;
const blinkOff = setTimeout(() => {
shotVisible = false;
+13 -19
View File
@@ -19,19 +19,12 @@ import type {
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame.
* `phantom` is true when the action's defender ship-group was
* already at zero before the action ran — legacy emitters keep
* logging hits past the moment a group emptied. The viewer fast-
* forwards through phantom frames during play so the user never
* sees a silent gap; the frame is still in the sequence so step
* controls and the scrubber can land on it deliberately.
*/
export interface Frame {
shotIndex: number;
remaining: Map<number, number>;
activeRaceIds: number[];
lastAction: BattleActionReport | null;
phantom: boolean;
}
export interface NormalisedGroup {
@@ -102,7 +95,6 @@ export function buildFrames(report: BattleReport): Frame[] {
remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null,
phantom: false,
});
const groupRaceByKey = new Map<number, number>();
@@ -112,28 +104,30 @@ export function buildFrames(report: BattleReport): Frame[] {
const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i];
// A shot whose defender group was empty before the action
// ran is a phantom: legacy emitters keep logging hits past
// the moment a group emptied. We keep the frame in the
// sequence (step controls and the scrubber can still land
// on it deliberately) but mark it so the play loop fast-
// forwards across the silent gap.
const leftBefore = current.get(action.sd) ?? 0;
const phantom = leftBefore === 0;
if (action.x && !phantom) {
current.set(action.sd, leftBefore - 1);
if (action.x) {
// Defence in depth: a malformed protocol that fires more
// `Destroyed` rows than the group has ships would push
// `runningRaceTotals` below zero and drop the race from
// `activeRaceIds` prematurely. Real legacy data folds
// duplicate `(race, className)` roster rows into the
// same `BattleReportGroup` (parser + engine), so this
// branch is hit only on a real shrink — but the clamp
// keeps the math from going negative either way.
const left = current.get(action.sd) ?? 0;
if (left > 0) {
current.set(action.sd, left - 1);
const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t));
}
}
}
frames.push({
shotIndex: i + 1,
remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action,
phantom,
});
}
-9
View File
@@ -208,15 +208,6 @@ describe("buildFrames phantom-destroy clamp", () => {
// the only active race for the remainder of the protocol.
expect(frames[5].remaining.get(10)).toBe(0);
expect(frames[5].activeRaceIds).toEqual([1]);
// Phantom flags: first two destroys land on a non-empty
// group → real shots; the remaining three are phantoms.
expect(frames[1].phantom).toBe(false);
expect(frames[2].phantom).toBe(false);
expect(frames[3].phantom).toBe(true);
expect(frames[4].phantom).toBe(true);
expect(frames[5].phantom).toBe(true);
// The initial frame is never a phantom.
expect(frames[0].phantom).toBe(false);
});
it("keeps a race active while phantom destroys hit one of its empty groups", () => {