ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
4 changed files with 107 additions and 33 deletions
Showing only changes of commit 2e7478f5ea - Show all commits
+25 -8
View File
@@ -135,14 +135,31 @@ stay drawn so the user can study the current shot.
## Phantom destroys ## Phantom destroys
Legacy emitters (the `dg` engine format that feeds the synthetic- Legacy emitters (the `dg` engine format that feeds the synthetic-
report path) occasionally log more `Destroyed` lines against a report path) occasionally log more `Destroyed` (and `Shields`)
ship-group bucket than the bucket's initial population — the lines against a ship-group bucket than the bucket's initial
emitter keeps recording hits past the moment the group emptied. population — the emitter keeps recording hits past the moment a
`buildFrames` clamps each per-group remaining count at zero and group emptied. `buildFrames` marks every such frame as
only decrements race totals on a real shrink, so a race stays on `phantom: true` and skips the race-total decrement so the race
the scene until its actual ships are gone. The phantom shots still stays on the scene until its actual ships are gone.
draw a line during the frame they belong to; only the running
counters are protected. 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 ## Header + layout
@@ -46,17 +46,62 @@ matching `pkg/model/report/battle.go` and it plays back.
let shotVisible = $state(true); let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null); 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 // One tick per frame: blink the shot line off during the last
// 10 % of the frame's interval, then advance. Effect re-arms // 10 % of the frame's interval, then advance. Effect re-arms
// whenever frameIndex / playing / speed changes; previous // whenever frameIndex / playing / speed changes; previous
// timers clean up through the return. // 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(() => { $effect(() => {
void frameIndex; void frameIndex;
void speed; void speed;
shotVisible = true; shotVisible = true;
if (!playing) return; 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 intervalMs = 400 / speed;
const blinkOff = setTimeout(() => { const blinkOff = setTimeout(() => {
shotVisible = false; 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 // Auto-scroll the visible log row into view so the highlight
// keeps up with the timeline on long battles. // keeps up with the timeline on long battles.
$effect(() => { $effect(() => {
void frame.shotIndex; void displayFrame.shotIndex;
if (!logOpen || logEl === null) return; if (!logOpen || logEl === null) return;
const current = logEl.querySelector( const current = logEl.querySelector(
'li[data-current="true"]', 'li[data-current="true"]',
@@ -150,12 +195,12 @@ matching `pkg/model/report/battle.go` and it plays back.
})} })}
</h2> </h2>
<span class="progress" data-testid="battle-frame-index"> <span class="progress" data-testid="battle-frame-index">
{frame.shotIndex} / {report.protocol.length} {displayFrame.shotIndex} / {report.protocol.length}
</span> </span>
</header> </header>
<div class="scene"> <div class="scene">
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} /> <BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
</div> </div>
<input <input
@@ -188,7 +233,7 @@ matching `pkg/model/report/battle.go` and it plays back.
{#each report.protocol as _action, i (i)} {#each report.protocol as _action, i (i)}
<li <li
data-testid="battle-protocol-log-item" data-testid="battle-protocol-log-item"
data-current={i + 1 === frame.shotIndex ? "true" : "false"} data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
> >
<button <button
type="button" type="button"
+19 -16
View File
@@ -19,12 +19,19 @@ import type {
* `BattleReport.ships`; `activeRaceIds` are the race indices with at * `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action * least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame. * 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 { export interface Frame {
shotIndex: number; shotIndex: number;
remaining: Map<number, number>; remaining: Map<number, number>;
activeRaceIds: number[]; activeRaceIds: number[];
lastAction: BattleActionReport | null; lastAction: BattleActionReport | null;
phantom: boolean;
} }
export interface NormalisedGroup { export interface NormalisedGroup {
@@ -95,6 +102,7 @@ export function buildFrames(report: BattleReport): Frame[] {
remaining: new Map(initialRemaining), remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals), activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null, lastAction: null,
phantom: false,
}); });
const groupRaceByKey = new Map<number, number>(); const groupRaceByKey = new Map<number, number>();
@@ -104,33 +112,28 @@ export function buildFrames(report: BattleReport): Frame[] {
const runningRaceTotals = new Map(raceTotals); const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) { for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i]; const action = report.protocol[i];
if (action.x) { // A shot whose defender group was empty before the action
// Decrement only when the targeted group actually has // ran is a phantom: legacy emitters keep logging hits past
// ships left. Legacy emitters (the `dg` text format used // the moment a group emptied. We keep the frame in the
// by the synthetic-report path) sometimes ship more // sequence (step controls and the scrubber can still land
// `Destroyed` lines than the group's initial population — // on it deliberately) but mark it so the play loop fast-
// looks like the engine keeps logging hits against an // forwards across the silent gap.
// already-empty ship-group bucket. Without this guard const leftBefore = current.get(action.sd) ?? 0;
// `runningRaceTotals` decrements on every phantom and the const phantom = leftBefore === 0;
// race vanishes from `activeRaceIds` long before its if (action.x && !phantom) {
// real groups were all destroyed (KNNTS041 battle on current.set(action.sd, leftBefore - 1);
// 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); const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) { if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1; const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t)); runningRaceTotals.set(raceId, Math.max(0, t));
} }
} }
}
frames.push({ frames.push({
shotIndex: i + 1, shotIndex: i + 1,
remaining: new Map(current), remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals), activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action, lastAction: action,
phantom,
}); });
} }
+9
View File
@@ -208,6 +208,15 @@ describe("buildFrames phantom-destroy clamp", () => {
// the only active race for the remainder of the protocol. // the only active race for the remainder of the protocol.
expect(frames[5].remaining.get(10)).toBe(0); expect(frames[5].remaining.get(10)).toBe(0);
expect(frames[5].activeRaceIds).toEqual([1]); 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", () => { it("keeps a race active while phantom destroys hit one of its empty groups", () => {