ui: plan 01-27 done #1
@@ -135,14 +135,31 @@ stay drawn so the user can study the current shot.
|
||||
## Phantom destroys
|
||||
|
||||
Legacy emitters (the `dg` engine format that feeds the synthetic-
|
||||
report path) occasionally log more `Destroyed` lines against a
|
||||
ship-group bucket than the bucket's initial population — the
|
||||
emitter keeps recording hits past the moment the group emptied.
|
||||
`buildFrames` clamps each per-group remaining count at zero and
|
||||
only decrements race totals on a real shrink, so a race stays on
|
||||
the scene until its actual ships are gone. The phantom shots still
|
||||
draw a line during the frame they belong to; only the running
|
||||
counters are protected.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Final-frame freeze
|
||||
|
||||
When the last protocol action eliminates a race, the surviving
|
||||
side would otherwise reflow alone to the planet ring at the very
|
||||
last shot — visually jarring and uninformative. `displayFrame`
|
||||
freezes the layout-determining state (`remaining` and
|
||||
`activeRaceIds`) at the penultimate frame's values while keeping
|
||||
the final frame's `shotIndex` and `lastAction`, so the killing
|
||||
shot still renders as a line + flash against the picture the user
|
||||
saw a moment earlier.
|
||||
|
||||
## Header + layout
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user