ui/phase-27: skip phantom frames during play + freeze final layout

Two more KNNTS041 viewer fixes:

1. Phantom-frame fast-forward. `buildFrames` now flags every frame
   whose shot landed on an already-empty defender group as
   `phantom: true`. During play the BattleViewer effect detects a
   phantom frame and chains a 0 ms timer to the next non-phantom,
   so streaks of phantoms (the ~30 frames between shots 224 and
   255, and the 401..414 stretch) collapse from "the player just
   mots the timeline" into a single visual tick. Step controls and
   the scrubber can still land on a phantom deliberately for
   protocol inspection.

2. Final-frame layout freeze. `displayFrame` derives from the raw
   `frames[i]` and, on the very last frame when `activeRaceIds`
   shrinks vs the penultimate frame (the killing blow eliminates a
   race), substitutes the penultimate's `remaining` and
   `activeRaceIds` while keeping the current `shotIndex` and
   `lastAction`. The result: the surviving cluster no longer
   reflows onto the planet ring on the very last shot — the user
   sees the killing line + defender flash rendered against the
   picture they saw a moment earlier.

Tests: `phantom-destroy clamp` case extended with `frame.phantom`
flag assertions across the protocol; 644 Vitest cases stay green,
4 Playwright `battle-viewer` cases stay green.

Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward
behaviour and the final-frame freeze.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-13 18:16:11 +02:00
parent e2aba856b5
commit 2e7478f5ea
4 changed files with 107 additions and 33 deletions
+9
View File
@@ -208,6 +208,15 @@ 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", () => {