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
-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", () => {