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:
+27
-15
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user