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
+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;
+17 -23
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,20 +104,23 @@ 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);
const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t));
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({
@@ -133,7 +128,6 @@ export function buildFrames(report: BattleReport): Frame[] {
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", () => {