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
@@ -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,
});
}