From 14e292f491bb663ea7ab9544327cb18aa587f755 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 11:52:52 +0800 Subject: [PATCH] feedback1 integrate: hidden-tab and catch-up events keep their own game time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catch-up frame or a hidden-tab step drains its events every EV_SLICE (15) ticks and each batch is stamped with that game time; the held backlog is emitted batched by the time it was drained at. The render engine then sees a late form fx as late and skips a change clip that would already have ended (a 转译基底·α no longer replays A_Die_C 16 s after its change). --- public/js/battle/runner.js | 66 +++++++++++++++++------ test/match/feedback1-runner-forms.test.js | 64 ++++++++++++++++++++-- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index e283de7b..aad7db34 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -18,7 +18,9 @@ // hidden the battle on screen keeps the same events (compacted past HELD_MAX; a backlog still past it makes the view // re-enter from the field meta) and the first frame back delivers them — or, for a battle that ended while hidden, the // visibilitychange itself — so the view still knows every unit and every enemy's current form (player report #5 after -// 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model). +// 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model). Such a +// frame or step covers seconds of play, so it drains the events every EV_SLICE ticks and each batch keeps its own game +// time (`gt`): the render engine then knows how late a form fx is and skips a change clip that has already ended. // Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same // spec fast-forwarded to the server's clock (`elapsed`) and never report. // A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent @@ -82,6 +84,11 @@ export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxFo * (thousands of spawns) makes the view re-enter from the field meta. */ export const HELD_MAX = 3000; +/** + * Ticks per event batch of a step that covers more than one frame of play (a catch-up frame of up to CATCHUP_TICKS, a + * hidden-tab step): its events are drained every EV_SLICE ticks and stamped with that game time, not the frame's. + */ +export const EV_SLICE = 15; /** * A hidden-tab backlog without the superseded toggles: of the 'status' tuples only the last per (unit, status), of the @@ -190,6 +197,8 @@ export function createBattleRunner(deps) { /** a normal field's leak count (or a battle's bond layers) changed since the last publishState() */ let leaksDirty = false; const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 }; + /** Hidden-tab backlog tuple → the game time it was drained at (emitFrame batches the backlog by it). */ + const heldAt = new WeakMap(); const hidden = () => !!(doc && doc.hidden); /** The battle clock: frozen at the pause instant while the solo battle is paused. */ @@ -314,12 +323,22 @@ export function createBattleRunner(deps) { /** Target tick of an entry on its clock. */ const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9)); - function stepEntry(e, n) { + /** + * Step `n` ticks. `sliced` (a catch-up frame, a hidden-tab step): every EV_SLICE ticks the events drained so far go to + * `e.slices` as { gt, ev } with that game time, for emitFrame / hold. + */ + function stepEntry(e, n, sliced = false) { const b = e.battle; const t = now(); let k = 0; try { - for (; k < n && !b.finished; k++) b.step(); + for (; k < n && !b.finished; k++) { + b.step(); + if (sliced && (k + 1) % EV_SLICE === 0 && k + 1 < n) { + const ev = b.drainEvents() || []; + if (ev.length) e.slices.push({ gt: Number(b.time) || 0, ev }); + } + } } catch (err) { stats.errors++; console.warn('[runner] battle step failed', err); @@ -337,17 +356,33 @@ export function createBattleRunner(deps) { return { ...rest, t: 'b.snap', fieldId: e.fieldId, gt: Number.isFinite(gt) ? gt : 0 }; } + /** The steps' events, each batch with its own game time: the sliced ones (stepEntry), then the rest at `gt`. */ + function drainSlices(e, gt) { + let ev = []; + try { ev = e.battle.drainEvents() || []; } catch { ev = []; } + const out = e.slices; + e.slices = []; + if (ev.length) out.push({ gt, ev }); + return out; + } + function emitFrame(e, catchingUp) { // the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form) if (e.stale) { show(e); return; } - let ev = []; - try { ev = e.battle.drainEvents() || []; } catch { ev = []; } const gt = Number(e.battle.time) || 0; + // the hidden-tab backlog first, batched by the game time each tuple was drained at (a tuple put there by hand: now) const held = e.held; if (held.length) e.held = []; - if (ev.length || held.length) { - const list = held.concat(catchingUp ? ev.filter(keepsState) : ev); - if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list }); + let run = null; + for (const x of held) { + const at = heldAt.get(x) ?? gt; + if (!run || run.gt !== at) { if (run) emit('ev', run); run = { t: 'b.ev', fieldId: e.fieldId, gt: at, ev: [] }; } + run.ev.push(x); + } + if (run) emit('ev', run); + for (const s of drainSlices(e, gt)) { + const list = catchingUp ? s.ev.filter(keepsState) : s.ev; + if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list }); } try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); } } @@ -358,10 +393,9 @@ export function createBattleRunner(deps) { * from its field meta when shown (show()), so its events go. */ function hold(e) { - let ev = []; - try { ev = e.battle.drainEvents() || []; } catch { ev = []; } + const batches = drainSlices(e, Number(e.battle.time) || 0); if (e !== cur || e.stale) return; - for (const x of ev) if (keepsState(x)) e.held.push(x); + for (const s of batches) for (const x of s.ev) if (keepsState(x)) { e.held.push(x); heldAt.set(x, s.gt); } if (e.held.length > HELD_MAX) { e.held = compactHeld(e.held); if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; } @@ -480,7 +514,7 @@ export function createBattleRunner(deps) { const catchingUp = behind > cap * 4; if (catchingUp) stats.catchups++; const n = Math.min(behind, catchingUp ? CATCHUP_TICKS : cap); - const dt = stepEntry(e, n); + const dt = stepEntry(e, n, catchingUp || !render); if (dt > stats.maxFrameMs) stats.maxFrameMs = dt; if (render) emitFrame(e, catchingUp); else hold(e); @@ -520,6 +554,7 @@ export function createBattleRunner(deps) { function show(e) { cur = e; e.held = []; + e.slices = []; e.stale = false; let meta = null; try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; } @@ -603,9 +638,10 @@ export function createBattleRunner(deps) { leaks: 0, leakMark: '', left: null, // live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers) live: null, layerSum: 0, - // state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame), - // and whether that backlog overflowed (the next frame re-enters the view from the field meta) - held: [], stale: false, + // state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame; + // heldAt = the game time each was drained at), and whether that backlog overflowed (the next frame re-enters the + // view from the field meta); the event batches of the current sliced step (stepEntry) + held: [], stale: false, slices: [], }; if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') { battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined); diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 38469a57..4c61b64b 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -15,7 +15,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createBattleRunner, keepsState, compactHeld, HELD_MAX } from '../../public/js/battle/runner.js'; +import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js'; import { SnapshotBuffer } from '../../public/js/render/interp.js'; import { createStore, initialState } from '../../public/js/store.js'; import * as specMod from '../../server/sim/spec.js'; @@ -152,9 +152,15 @@ test('a hidden tab across the change: nothing rendered meanwhile, the first fram assert.ok(e.held.length > 0 && e.held.every(keepsState), 'the battle on screen holds its state-bearing events'); r.show(); r.advance(1000 / 60); - const first = r.feed.evs[0]?.ev || []; + const first = all(r.feed); // nothing was rendered while hidden: the first frame back assert.ok(first.some((x) => x[0] === 'spawn' && x[1].id === tr.id), 'the translator\'s spawn (it used to come back as an unknown view)'); assert.deepEqual(formsOf(first, tr.id), ['translator_youling'], 'and its form'); + // each held tuple keeps the game time it was drained at (batches of EV_SLICE ticks), not the first frame's + const spawnMsg = r.feed.evs.find((m) => m.ev.some((x) => x[0] === 'spawn' && x[1].id === tr.id)); + const formMsg = r.feed.evs.find((m) => formsOf(m.ev, tr.id).length); + const frameGt = r.feed.evs[r.feed.evs.length - 1].gt; + assert.ok(spawnMsg.gt < formMsg.gt && formMsg.gt < frameGt - 1.5, `stamps: spawn ${spawnMsg.gt}, form ${formMsg.gt}, frame ${frameGt}`); + assert.ok(r.feed.evs.every((m, i) => i === 0 || m.gt >= r.feed.evs[i - 1].gt), 'in game-time order'); assert.equal(e.held.length, 0); assert.equal(r.feed.fields.length, 1, 'no re-entry needed'); r.runner.dispose(); @@ -179,7 +185,7 @@ test('a hidden-tab backlog of status toggles beyond HELD_MAX is compacted: no re r.show(); r.advance(1000 / 60); assert.equal(r.feed.fields.length, 1, 'no re-entry'); - const first = r.feed.evs[0]?.ev || []; + const first = all(r.feed); assert.deepEqual(first.filter((x) => x[0] === 'status' && x[2] === 'stun'), [['status', tr.id, 'stun', 1]], 'the last toggle only'); assert.deepEqual(formsOf(first, tr.id), ['translator_youling'], 'the form fx kept'); r.runner.dispose(); @@ -245,6 +251,7 @@ function viewRig({ hidden = false } = {}) { const r = rig({ hidden }); const interp = new SnapshotBuffer({ delay: 0.034, rate: 2 }); const handled = []; + const late = new Map(); // like render/app.js processEvents: state events handed out > 1.5 game s late → how late let clock = 0; r.runner.on('field', () => interp.reset()); r.runner.on('snap', (x) => interp.push(x, clock)); @@ -252,7 +259,7 @@ function viewRig({ hidden = false } = {}) { const render = (tSec) => { clock = tSec; const rT = interp.update(tSec); - if (Number.isFinite(rT)) interp.takeEvents(rT, handled, rT - 1.5); + if (Number.isFinite(rT)) interp.takeEvents(rT, handled, rT - 1.5, late); }; const advance = r.advance; let realMs = 1000; @@ -265,7 +272,7 @@ function viewRig({ hidden = false } = {}) { if (!r.doc.hidden) render(realMs / 1000); } }; - return { ...r, handled, interp }; + return { ...r, handled, interp, late }; } test('runner → render engine: one 1.2 s stall right after the change — the view still handles the 幽灵 form fx', async () => { @@ -299,3 +306,50 @@ test('runner → render engine: a watched replica hidden 20 s across the change assert.deepEqual(formsOf(r.handled, id), ['translator_youling'], `the view learnt the form (sim: ${trOf(e)?.form ?? 'gone'})`); r.runner.dispose(); }); + +// QA of feedback1 (§21.19): a hidden tab's backlog and a catch-up frame used to go out stamped with the frame's game time, +// so the render engine saw a form fx as on time and replayed its 2 s change clip seconds after the change (a 转译基底·α +// walking along in A_Die_C). Each batch now keeps its own game time (EV_SLICE ticks): the fx is handed out as late as it +// is, and render/app.js skips a change clip that would already have ended. +const formLate = (r, id) => { const x = r.handled.find((ev) => fxForm(ev) !== undefined && ev[4].id === id); return x ? r.late.get(x) ?? 0 : null; }; + +test('runner → render engine: the own battle hidden 20 s across the change — the form fx comes out as late as it is (the 2 s change clip is not replayed)', async () => { + const r = viewRig(); + r.net.emit('b.start', start(true)); + await r.settle(); + const e = r.runner._entries.get('fb1.runner'); + let changedAt = null; + e.battle.on('tick', () => { if (changedAt == null && trOf(e)?.form != null) changedAt = e.battle.time; }); + r.advance(300); + r.doc.hidden = true; + r.advance(20000, 250); + assert.ok(changedAt != null && changedAt < 15, `changed while hidden (t ${changedAt})`); + r.show(); + r.advance(1000); + const tr = trOf(e); + const id = tr?.id ?? all(r.feed).find((x) => x[0] === 'spawn' && x[1].defId === TR)?.[1].id; + const formMsg = r.feed.evs.find((m) => formsOf(m.ev, id).length); + assert.ok(formMsg && formMsg.gt >= changedAt - 1e-9 && formMsg.gt <= changedAt + EV_SLICE / 30 + 1e-9, `stamped at the change (${formMsg?.gt} vs ${changedAt})`); + const late = formLate(r, id); + assert.ok(late != null && late > 10, `handed out ${late?.toFixed(1)} game s late — render/app.js skips the change clip`); + r.runner.dispose(); +}); + +test('runner → render engine: catch-up frames of 8 game s (a replica shown 20 s late) stamp each slice — the form fx is late by its age in the frame', async () => { + const r = viewRig(); + r.net.emit('b.start', start(false)); + await r.settle(); + r.advance(300); + r.doc.hidden = true; + r.advance(20000, 250); + r.show(); + r.advance(4000); + const id = all(r.feed).find((x) => x[0] === 'spawn' && x[1].defId === TR)?.[1].id; + assert.ok(r.runner.stats().catchups > 0, 'caught up on return'); + const formMsg = r.feed.evs.find((m) => formsOf(m.ev, id).length); + const sameFrame = r.feed.evs.filter((m) => m.gt > formMsg.gt); + assert.ok(sameFrame.length && sameFrame[0].gt - formMsg.gt < 8, 'later batches of the catch-up carry later stamps'); + const late = formLate(r, id); + assert.ok(late != null && late > 2, `the fx is handed out ${late?.toFixed(1)} game s late (was 0: the frame's stamp)`); + r.runner.dispose(); +});