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
@@ -46,17 +46,62 @@ matching `pkg/model/report/battle.go` and it plays back.
let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null);
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// displayFrame freezes the layout at the penultimate frame's
// state once the protocol's last action eliminates a race, so
// the surviving cluster does not suddenly reflow onto the
// planet ring on the very last shot. The frame counter still
// advances to the final shot and `lastAction` still drives the
// killing line + flash; only `remaining` and `activeRaceIds`
// (the layout-determining state) freeze.
const displayFrame = $derived.by(() => {
const last = frames.length - 1;
if (
frameIndex === last &&
last >= 1 &&
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
) {
const prev = frames[last - 1];
const cur = frames[last];
return {
shotIndex: cur.shotIndex,
lastAction: cur.lastAction,
phantom: cur.phantom,
remaining: prev.remaining,
activeRaceIds: prev.activeRaceIds,
};
}
return rawFrame;
});
// One tick per frame: blink the shot line off during the last
// 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;
@@ -77,7 +122,7 @@ matching `pkg/model/report/battle.go` and it plays back.
// Auto-scroll the visible log row into view so the highlight
// keeps up with the timeline on long battles.
$effect(() => {
void frame.shotIndex;
void displayFrame.shotIndex;
if (!logOpen || logEl === null) return;
const current = logEl.querySelector(
'li[data-current="true"]',
@@ -150,12 +195,12 @@ matching `pkg/model/report/battle.go` and it plays back.
})}
</h2>
<span class="progress" data-testid="battle-frame-index">
{frame.shotIndex} / {report.protocol.length}
{displayFrame.shotIndex} / {report.protocol.length}
</span>
</header>
<div class="scene">
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
</div>
<input
@@ -188,7 +233,7 @@ matching `pkg/model/report/battle.go` and it plays back.
{#each report.protocol as _action, i (i)}
<li
data-testid="battle-protocol-log-item"
data-current={i + 1 === frame.shotIndex ? "true" : "false"}
data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
>
<button
type="button"
+23 -20
View File
@@ -19,12 +19,19 @@ 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 {
@@ -95,6 +102,7 @@ export function buildFrames(report: BattleReport): Frame[] {
remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null,
phantom: false,
});
const groupRaceByKey = new Map<number, number>();
@@ -104,26 +112,20 @@ 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];
if (action.x) {
// Decrement only when the targeted group actually has
// ships left. Legacy emitters (the `dg` text format used
// by the synthetic-report path) sometimes ship more
// `Destroyed` lines than the group's initial population —
// looks like the engine keeps logging hits against an
// already-empty ship-group bucket. Without this guard
// `runningRaceTotals` decrements on every phantom and the
// race vanishes from `activeRaceIds` long before its
// real groups were all destroyed (KNNTS041 battle on
// planet 7, frame ≈ 406 of 2317). The line still draws
// for that frame so the user sees the shot happen.
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));
}
// 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));
}
}
frames.push({
@@ -131,6 +133,7 @@ export function buildFrames(report: BattleReport): Frame[] {
remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action,
phantom,
});
}