From 4e681000655494165c90b19cbf45cd65d43b35a7 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:30:52 +0800 Subject: [PATCH] feedback1 integrate: form fx survive the render engine's stale-event drop; hidden-tab backlog compacted and flushed when a finished battle is shown; same-field re-entry buffered - render/interp.js isCosmeticEvent: an fx with a form is state, never dropped as stale nor shed from a full queue; a late one switches the model without its telegraph, its closing clip shortened (render/app.js) - battle/runner.js: a backlog past HELD_MAX keeps only the last status / skill toggle per unit; a battle that ended while the tab was hidden delivers its backlog and last snapshot when the tab is shown - screens/game.js: a new field meta for the field on screen buffers its frames until the view re-enters it - browser test stalls the main thread (blocking 1 s long tasks); Node tests drive the runner into the render buffer --- docs/ASSETS.md | 5 +- docs/SIM.md | 8 +- public/js/battle/runner.js | 43 ++++++- public/js/render/app.js | 19 ++- public/js/render/interp.js | 25 +++- public/js/render/units.js | 4 +- public/js/screens/game.js | 17 ++- test/match/feedback1-runner-forms.test.js | 125 +++++++++++++++++++- test/render/feedback1-forms.browser.test.js | 20 ++-- test/render/forms.test.js | 7 +- test/render/interp.test.js | 25 ++++ 11 files changed, 259 insertions(+), 39 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index ffd7bd04..7b1f315e 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -209,8 +209,9 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from `public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive' -/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; the client runner keeps these fx through catch-up frames and -hidden tabs — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a +/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through +catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buffer (`keepEarly`) and the render engine's +event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only while its 隐匿 is on: diff --git a/docs/SIM.md b/docs/SIM.md index 45483d86..6ea4997b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -277,8 +277,12 @@ fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `k ('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge 'phase' kinds carry no `form` and are no forms. A form is state, not decoration (b.snap tuples carry none): the client keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames -and its hidden-tab backlog (`battle/runner.js keepsState`), the game screen's events buffered before a field is entered -(`screens/game.js`); dropping them was report #5's look again after a stall or a background tab. +and its hidden-tab backlog (`battle/runner.js keepsState`; a backlog past `HELD_MAX` drops only superseded status / +skill toggles, and a battle that ended while hidden delivers it when the tab is shown), the game screen's events +buffered before a field is entered (`screens/game.js keepEarly`, also while a re-sent field meta re-enters the field on +screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`: a form fx is never dropped as stale, +more than 1.5 game s behind the render clock, nor shed from a full queue; one handed out late switches the model +without its telegraph); dropping them was report #5's look again after a stall or a background tab. **Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 7730af77..e283de7b 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -15,9 +15,10 @@ // ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse. // A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys, // statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is -// hidden the battle on screen keeps the same events (≤ HELD_MAX, else the view re-enters from the field meta) and the -// first frame back delivers them, 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). +// 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). // 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 @@ -75,8 +76,26 @@ const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']); * enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0). */ export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined); -/** Hidden-tab backlog cap (tuples) of the battle on screen; beyond it the view re-enters from the field meta. */ +/** + * Hidden-tab backlog cap (tuples) of the battle on screen. Past it the backlog is compacted (compactHeld: only the last + * 'status' per unit and status, the last 'skill' per unit — what the view ends up showing); only a backlog still past it + * (thousands of spawns) makes the view re-enter from the field meta. + */ export const HELD_MAX = 3000; + +/** + * A hidden-tab backlog without the superseded toggles: of the 'status' tuples only the last per (unit, status), of the + * 'skill' tuples only the last per unit, every other tuple (spawn / die / deploy / leak / form fx — bounded by the units) + * kept; order preserved. A long hidden boss fight toggles statuses thousands of times, the rest stays small. + */ +export function compactHeld(list) { + const last = new Map(); + list.forEach((x, i) => { + if (x[0] === 'status') last.set(`s:${x[1]}:${x[2]}`, i); + else if (x[0] === 'skill') last.set(`k:${x[1]}`, i); + }); + return list.filter((x, i) => (x[0] === 'status' ? last.get(`s:${x[1]}:${x[2]}`) === i : x[0] === 'skill' ? last.get(`k:${x[1]}`) === i : true)); +} /** Data files the simulation reads (DataSource + content/support gameData()). */ export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']); @@ -343,7 +362,19 @@ export function createBattleRunner(deps) { try { ev = e.battle.drainEvents() || []; } catch { ev = []; } if (e !== cur || e.stale) return; for (const x of ev) if (keepsState(x)) e.held.push(x); - if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; } + if (e.held.length > HELD_MAX) { + e.held = compactHeld(e.held); + if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; } + } + } + + /** + * The tab is visible again: the battle on screen that ended while it was hidden (advance() no longer runs for it) + * delivers its backlog and last snapshot now — else the view kept the pre-hide state for the rest of the phase. + */ + function flushHidden() { + const e = cur; + if (e && e.battle.finished && (e.held.length || e.stale)) emitFrame(e, false); } function progress(e, force = false) { @@ -671,7 +702,7 @@ export function createBattleRunner(deps) { })); } if (doc && typeof doc.addEventListener === 'function') { - doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); }); + doc.addEventListener('visibilitychange', () => { if (hidden()) return; flushHidden(); schedule(); }); } return { diff --git a/public/js/render/app.js b/public/js/render/app.js index c2390253..3ce6a891 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -24,7 +24,7 @@ // user playtest #5 item 2) goes straight to the held pose, no burst; an // fx with a `form` (shared/protocol.js fxForm) switches the enemy's // model to that clip set (render/units.js FORMS) -// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat): +// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat): // ~2-frame buffer at the battle's game speed // view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'| // 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all @@ -1376,9 +1376,12 @@ export async function createFieldView(host, options = {}) { } const EVS = []; + /** State events handed out more than 1.5 game s late (a stall, a hidden tab, a field entered late) → how late. */ + const LATE = new Map(); function processEvents(renderT) { EVS.length = 0; - interp.takeEvents(renderT, EVS, renderT - 1.5); + LATE.clear(); + interp.takeEvents(renderT, EVS, renderT - 1.5, LATE); for (const e of EVS) { try { handleEvent(e, renderT); } catch (err) { if (!handleEvent.warned) { handleEvent.warned = true; console.warn('[render] event failed', e, err); } } } @@ -1446,15 +1449,19 @@ export async function createFieldView(host, options = {}) { // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after // 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes - // nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames, - // hidden-tab backlog), nor does the game screen's pre-entry buffer. + // nothing); the info keeps it for a view built later. No client stage drops these fx: battle/runner.js + // keepsState (catch-up frames, hidden-tab backlog), screens/game.js keepEarly (the pre-entry buffer) and + // render/interp.js isCosmeticEvent (stale-event drop, full-queue shed). One handed out late switches the model + // without its telegraph, its closing clip shortened by the lateness. const form = fxForm(e); + const late = form !== undefined ? LATE.get(e) || 0 : 0; if (form !== undefined) { const inf = infos.get(e[4].id); if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form; - views.get(e[4].id)?.setForm?.(form, e[4]); + const x = late > 0 && Number(e[4].dur) > 0 ? { ...e[4], dur: Math.max(0, Number(e[4].dur) - late) } : e[4]; + views.get(e[4].id)?.setForm?.(form, x); } - fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); + if (!(late > 0)) fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; } case 'layer': { diff --git a/public/js/render/interp.js b/public/js/render/interp.js index eb284853..f2771ae8 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -16,7 +16,8 @@ // * event queue: a `b.ev` batch is stamped with its game time (`gt`, the snapshot it was drained with); a batch // without one is placed inside the latest snapshot interval (newest snapshot time minus half an interval), // and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag` -// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered. +// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx +// included — are always delivered, and a full queue sheds only cosmetic ones. // // Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along // (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9): @@ -27,6 +28,8 @@ // `units`): downAt(time) returns the list of the snapshot at `time`. // Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. +import { fxForm } from '../../../shared/protocol.js'; + export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 }); /** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */ const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']); @@ -37,7 +40,13 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); /** Cosmetic event kinds that may be dropped when far behind (never state-changing). */ export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']); -export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]); +/** + * Whether an event may be dropped when stale or shed from a full queue. An 'fx' that carries an enemy's model `form` + * (shared/protocol.js fxForm) is state: dropping it left the view on the old model after a long main-thread stall, on a + * watched field after a hidden tab and on a field entered late (user report #5 after 0.1.0) — the fourth place the client + * keeps it, with battle/runner.js keepsState and screens/game.js keepEarly. + */ +export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]) && fxForm(ev) === undefined; /** * Game time (s) of a b.snap / b.ev payload, or NaN. On the wire every frame is `{ t: '', … }`, so the server @@ -308,9 +317,12 @@ export class SnapshotBuffer { /** * Remove and return (in order) the queued events due at `time` (default renderT). When `dropCosmeticBefore` - * is a number, cosmetic events stamped earlier than it are discarded instead of returned. + * is a number, cosmetic events stamped earlier than it are discarded instead of returned; a state event stamped + * earlier is returned, and when `late` (a Map) is given it records that event → how far (game s) its stamp lies + * before `time`, so the caller can skip its stale cosmetics (render/app.js: a late form fx switches the model without + * replaying its telegraph and with its closing clip shortened). */ - takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null) { + takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null, late = null) { if (!this.events.length || !Number.isFinite(time)) return out; let n = 0; while (n < this.events.length && this.events[n].t <= time) n++; @@ -318,7 +330,10 @@ export class SnapshotBuffer { const list = this.events; for (let i = 0; i < n; i++) { const e = list[i]; - if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore && isCosmeticEvent(e.ev)) continue; + if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore) { + if (isCosmeticEvent(e.ev)) continue; + if (late) late.set(e.ev, time - e.t); + } out.push(e.ev); } const rest = list.length - n; diff --git a/public/js/render/units.js b/public/js/render/units.js index e8ba9078..73d49d92 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -27,7 +27,7 @@ // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit') // goes down with `die(true)`: straight to the held end of the clip, no fall. -// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式 +// Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its // bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像 // likewise (user report after 0.1.0) — FORMS. A view built later (`info.form` = UnitInfo `form`, the sim's current form, @@ -113,7 +113,7 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130 export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 }); /** - * Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind, or the `form` of another sim fx + * Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm — * → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays * once first: * - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index eeebb1f7..19fdcd09 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -328,6 +328,10 @@ function MatchScreen() { // m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost. const evBufRef = useRef(new Map()); // fieldId → ev tuples since that field's m.field const snapBufRef = useRef(new Map()); // fieldId → latest snapshot + // a new m.field for the field already on screen (a resync, or the runner re-showing it after a hidden-tab backlog + // overflowed): its frames are buffered like a new field's until the enter effect re-enters it — enterBattle resets + // the view, so a 'spawn' or form fx pushed in between used to be lost (placeholder views, the old model) + const reentryRef = useRef(null); // prep rendering (own board) vs battle rendering (m.field). The field object present when prep starts is stale // (last round's battle): combat only enters an m.field pushed after that (a new object), and every new m.field @@ -361,6 +365,7 @@ function MatchScreen() { viewModeRef.current = 'prep'; lastFieldRef.current = null; enteredFieldRef.current = null; + reentryRef.current = null; setHud(null); } if (priv) { @@ -376,6 +381,7 @@ function MatchScreen() { if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d)); enteredFieldRef.current = field; lastFieldRef.current = field.fieldId; + reentryRef.current = null; viewModeRef.current = 'battle'; snapUnitsRef.current = new Map(); view.enterBattle(field); @@ -423,10 +429,15 @@ function MatchScreen() { let pending = null; const flush = () => { pending = null; last = performance.now(); setHud(hudRef.current); }; const onFieldMeta = (msg) => { - if (msg && typeof msg.fieldId === 'string') { evBufRef.current.set(msg.fieldId, []); snapBufRef.current.delete(msg.fieldId); } + if (!msg || typeof msg.fieldId !== 'string') return; + evBufRef.current.set(msg.fieldId, []); + snapBufRef.current.delete(msg.fieldId); + if (msg.fieldId === lastFieldRef.current) reentryRef.current = msg.fieldId; }; + // the field the view shows now (null while a re-entry of it is pending: its frames are buffered) + const shownId = () => (reentryRef.current && reentryRef.current === lastFieldRef.current ? null : lastFieldRef.current); const onSnap = (snap) => { - const cur = lastFieldRef.current; + const cur = shownId(); if (!snap || typeof snap !== 'object') return; if (!cur || (snap.fieldId && snap.fieldId !== cur)) { if (typeof snap.fieldId === 'string') snapBufRef.current.set(snap.fieldId, snap); @@ -444,7 +455,7 @@ function MatchScreen() { else if (!pending) pending = setTimeout(flush, HUD_HZ_MS - dt); }; const onEv = (msg) => { - const cur = lastFieldRef.current; + const cur = shownId(); if (!msg || !Array.isArray(msg.ev)) return; if (!cur || (msg.fieldId && msg.fieldId !== cur)) { if (typeof msg.fieldId !== 'string') return; diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 5e397057..38469a57 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -5,15 +5,18 @@ // frame (a stall of more than ≈0.53 s real at 2×: only spawn / die / deploy / status / skill / leak passed) and every // event while the tab was hidden, so the view kept the first form and the 转译基底·α died on the 寻仇者's B_Die (the // review's headless-Chrome repro). Now a catch-up keeps them (keepsState), a hidden tab holds the battle on screen's -// state-bearing events for the first frame back (≤ HELD_MAX, else the view re-enters from the field meta), and the game -// screen's pre-entry buffer keeps them too (screens/game.js keepEarly, same predicate). +// state-bearing events for the first frame back (compacted past HELD_MAX; still past it, the view re-enters from the field +// meta) — a battle that ended while hidden delivers them when the tab is shown — the game screen's pre-entry buffer keeps +// them too (screens/game.js keepEarly, same predicate) and so does the render engine's event queue (render/interp.js +// isCosmeticEvent: never dropped as stale, never shed). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createBattleRunner, keepsState, HELD_MAX } from '../../public/js/battle/runner.js'; +import { createBattleRunner, keepsState, compactHeld, HELD_MAX } 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'; import { DataSource } from '../../server/sim/simdata.js'; @@ -157,7 +160,51 @@ test('a hidden tab across the change: nothing rendered meanwhile, the first fram r.runner.dispose(); }); -test('a hidden-tab backlog beyond HELD_MAX: the first frame back re-enters the view from the field meta (UnitInfo `form`)', async () => { +test('compactHeld: a long hidden fight\'s status / skill toggles shrink to the last per unit, everything else stays in order', () => { + const list = [['spawn', { id: 1 }], ['status', 1, 'stun', 1], ['skill', 2, 1], ['status', 1, 'stun', 0], ['fx', 'phase', 0, 0, { id: 1, form: 'husk' }], + ['status', 1, 'cold', 1], ['skill', 2, 0], ['die', 1, 'killed'], ['status', 1, 'stun', 1]]; + assert.deepEqual(compactHeld(list), [list[0], list[4], list[5], list[6], list[7], list[8]]); +}); + +test('a hidden-tab backlog of status toggles beyond HELD_MAX is compacted: no re-entry, the last state arrives', async () => { + const r = rig({ hidden: true }); + r.net.emit('b.start', start(true)); + await r.settle(); + const e = r.runner._entries.get('fb1.runner'); + for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250); + const tr = trOf(e); + e.held.push(...Array.from({ length: HELD_MAX }, (_, i) => ['status', tr.id, 'stun', i % 2])); + r.advance(500, 250); + assert.ok(!e.stale && e.held.length < 100, `compacted (${e.held.length})`); + r.show(); + r.advance(1000 / 60); + assert.equal(r.feed.fields.length, 1, 'no re-entry'); + const first = r.feed.evs[0]?.ev || []; + 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(); +}); + +test('the own battle ends while the tab is hidden: showing it delivers the backlog and a last snapshot (no frame runs for a finished battle)', async () => { + const r = rig({ hidden: true }); + r.net.emit('b.start', start(true)); + await r.settle(); + const e = r.runner._entries.get('fb1.runner'); + for (let i = 0; i < 60 && !e.battle.finished; i++) r.advance(1000, 250); + assert.ok(e.battle.finished && e.done, 'it ended while hidden'); + assert.ok(e.held.length > 0, 'its state events wait'); + const snaps = r.feed.snaps; + r.show(); + const list = all(r.feed); + const tr = trOf(e) || { id: list.find((x) => x[0] === 'spawn' && x[1].defId === TR)?.[1].id }; + assert.ok(list.some((x) => x[0] === 'spawn' && x[1].id === tr.id), 'the spawn'); + assert.ok(list.some((x) => (x[0] === 'die' && x[1] === tr.id) || (x[0] === 'leak' && x[1] === tr.id)), 'and its end'); + assert.ok(r.feed.snaps > snaps, 'a last snapshot'); + assert.equal(e.held.length, 0); + r.runner.dispose(); +}); + +test('a hidden-tab backlog still beyond HELD_MAX after compaction: the first frame back re-enters the view from the field meta (UnitInfo `form`)', async () => { const r = rig({ hidden: true }); r.net.emit('b.start', start(true)); await r.settle(); @@ -165,7 +212,7 @@ test('a hidden-tab backlog beyond HELD_MAX: the first frame back re-enters the v for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250); const tr = trOf(e); assert.ok(tr?.form, 'changing'); - e.held.push(...Array.from({ length: HELD_MAX }, () => ['status', tr.id, 'stun', false])); + e.held.push(...Array.from({ length: HELD_MAX }, (_, i) => ['spawn', { id: 100000 + i }]), ['status', tr.id, 'stun', 1]); r.advance(500, 250); assert.ok(e.stale && e.held.length === 0, 'overflow: dropped, marked stale'); r.show(); @@ -183,4 +230,72 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); assert.match(src, /const keepEarly = \(e\) => Array\.isArray\(e\) && \(STATE_EV\.has\(e\[0\]\) \|\| fxForm\(e\) !== undefined\);/); assert.match(src, /for \(const e of msg\.ev\) if \(keepEarly\(e\)\) buf\.push\(e\);/); + // a new m.field for the field on screen buffers its frames until the enter effect re-enters it (enterBattle resets) + assert.match(src, /if \(msg\.fieldId === lastFieldRef\.current\) reentryRef\.current = msg\.fieldId;/); + assert.match(src, /const onEv = \(msg\) => \{\s+const cur = shownId\(\);/); + assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/); +}); + +/** + * The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock + * follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx + * must come out of it (review of the final WD fix: a stall > ≈0.78 s real used to drop it there, the fourth place). + */ +function viewRig({ hidden = false } = {}) { + const r = rig({ hidden }); + const interp = new SnapshotBuffer({ delay: 0.034, rate: 2 }); + const handled = []; + let clock = 0; + r.runner.on('field', () => interp.reset()); + r.runner.on('snap', (x) => interp.push(x, clock)); + r.runner.on('ev', (m) => interp.pushEvents(m.ev, clock, m.gt)); + const render = (tSec) => { + clock = tSec; + const rT = interp.update(tSec); + if (Number.isFinite(rT)) interp.takeEvents(rT, handled, rT - 1.5); + }; + const advance = r.advance; + let realMs = 1000; + r.advance = (ms, step = 1000 / 60) => { + const end = realMs + ms; + while (realMs < end) { + const d = Math.min(step, end - realMs); + advance(d, d); + realMs += d; + if (!r.doc.hidden) render(realMs / 1000); + } + }; + return { ...r, handled, interp }; +} + +test('runner → render engine: one 1.2 s stall right after the change — the view still handles the 幽灵 form fx', async () => { + const r = viewRig(); + r.net.emit('b.start', start(true)); + await r.settle(); + const e = r.runner._entries.get('fb1.runner'); + for (let i = 0; i < 2000 && trOf(e)?.form == null; i++) r.advance(1000 / 60); + r.advance(1000 / 60); + r.advance(1200, 1200); + r.advance(1500); + const tr = trOf(e); + assert.equal(tr?.form, 'translator_youling'); + assert.deepEqual(formsOf(r.handled, tr.id), ['translator_youling']); + r.runner.dispose(); +}); + +test('runner → render engine: a watched replica hidden 20 s across the change (catch-up frames of 8 game s on return) — the view handles the form fx', 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 e = r.runner._entries.get('fb1.runner'); + const id = all(r.feed).find((x) => x[0] === 'spawn' && x[1].defId === TR)?.[1].id; + assert.ok(id != null, 'the translator was announced'); + assert.ok(r.runner.stats().catchups > 0, 'caught up on return'); + assert.deepEqual(formsOf(r.handled, id), ['translator_youling'], `the view learnt the form (sim: ${trOf(e)?.form ?? 'gone'})`); + r.runner.dispose(); }); diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js index 90ab2923..3e709aef 100644 --- a/test/render/feedback1-forms.browser.test.js +++ b/test/render/feedback1-forms.browser.test.js @@ -9,8 +9,10 @@ // field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js // renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering; // the real client runner (battle/runner.js createBattleRunner) feeding the view the way screens/game.js does, with -// janky 0.7 s frames (every frame a catch-up) across the change of a 转译基底·α that walks into 百炼嘉维尔's block: -// the view turns 幽灵 (C_*) — the catch-up filter used to drop the form fx, so it stayed on A_Move and died on B_Die. +// blocking 1 s long tasks (every frame a catch-up, and the render clock jumps past its 1.5 game s stale-event +// window) across the change of a 转译基底·α that walks into 百炼嘉维尔's block: the view turns 幽灵 (C_*) — the +// catch-up filter, and then render/interp.js's stale-event drop, used to lose the form fx, so it stayed on A_Move +// and died on B_Die. // // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png. @@ -126,8 +128,8 @@ async function lateViewInPage(page, port, s, atSecs, setupSrc) { /** * The real client runner in the page (fake socket / store, its default sim loader and clock) feeds the demo's view like - * screens/game.js (field → enterBattle, snap → pushSnapshot, ev → pushEvents); its animation frames come every `jankMs` - * real ms for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the + * screens/game.js (field → enterBattle, snap → pushSnapshot, ev → pushEvents); every frame follows a blocking `jankMs` + * real ms long task, for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the * runner's catch-up count. */ async function runnerInPage(page, port, s, jankMs, secs) { @@ -155,7 +157,11 @@ async function runnerInPage(page, port, s, jankMs, secs) { const out = []; const end = performance.now() + secs * 1000; while (performance.now() < end) { - await sleep(jankMs); + // a blocking long task (the main thread stalls: the render engine's own ticker stalls too, so its clock jumps + // jankMs × 2 game s at once — past the 1.5 game s stale-event window), then one free frame + const until = performance.now() + jankMs; + while (performance.now() < until) { /* stall */ } + await sleep(16); for (const fn of queue.splice(0)) fn(performance.now()); const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai'); const view = tr && v.debug.views.get(tr.id); @@ -271,10 +277,10 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea await p.close(); } }); - test('the real client runner with janky frames (every frame a catch-up): 转译基底·α blocked by 百炼嘉维尔 turns 幽灵 in the view (C_*), never back to A_Move / B_Die', async () => { + test('the real client runner with blocking 1 s long tasks (every frame a catch-up, the render clock jumps 2 game s): 转译基底·α blocked by 百炼嘉维尔 turns 幽灵 in the view (C_*), never back to A_Move / B_Die', async () => { const { p, problems } = await page(); try { - const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 700, 9); + const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 1000, 12); await p.screenshot({ path: path.join(OUT, 'feedback1-runner.png') }); assert.ok(catchups >= 5, `catch-up frames (${catchups})`); const changed = out.find((x) => x.simForm === 'translator_youling'); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index cd48ec3e..a12f25ef 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -369,5 +369,10 @@ test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) with the fx to the view and keeps the mode on the unit info', () => { const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); - assert.match(src, /const form = fxForm\(e\);[\s\S]{0,200}inf\.form = form[\s\S]{0,120}setForm\?\.\(form, e\[4\]\)/); + assert.match(src, /const form = fxForm\(e\);[\s\S]{0,250}inf\.form = form[\s\S]{0,250}setForm\?\.\(form, x\)/); + // a form fx handed out late (render/interp.js takeEvents `late`) switches the model with its closing clip shortened by + // the lateness and without replaying its telegraph + assert.match(src, /const x = late > 0 && Number\(e\[4\]\.dur\) > 0 \? \{ \.\.\.e\[4\], dur: Math\.max\(0, Number\(e\[4\]\.dur\) - late\) \} : e\[4\];/); + assert.match(src, /if \(!\(late > 0\)\) fx\.simFx\(/); + assert.match(src, /interp\.takeEvents\(renderT, EVS, renderT - 1\.5, LATE\);/); }); diff --git a/test/render/interp.test.js b/test/render/interp.test.js index 95415bd5..ae1ad564 100644 --- a/test/render/interp.test.js +++ b/test/render/interp.test.js @@ -5,6 +5,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { SnapshotBuffer, normalizeSnapshot, isCosmeticEvent, frameTime } from '../../public/js/render/interp.js'; +const fxFormOf = (e) => (e && e[0] === 'fx' && e[4] && Object.hasOwn(e[4], 'form') ? e[4].form : undefined); const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; const snap = (t, units) => ({ fieldId: 'n:1', t, units, dp: 10, killed: 0, total: 5 }); const U = (id, x, y, hp = 100, anim = 0, flags = 0) => [id, x, y, hp, 100, 5, 10, flags, anim]; @@ -188,6 +189,30 @@ describe('SnapshotBuffer', () => { assert.deepEqual(b.flushEvents().map((e) => e[0]), ['leak']); }); + test('an enemy\'s form fx is state (player report #5 after 0.1.0): it survives the stale drop and the full-queue shed, a plain fx does not', () => { + const form = (id, f) => ['fx', 'phase', 1, 1, { id, kind: f, form: f, dur: 2 }]; + assert.ok(!isCosmeticEvent(form(3, 'translator_youling')), 'a form fx is never cosmetic'); + assert.ok(!isCosmeticEvent(['fx', 'revive', 1, 1, { id: 3, form: null }]), 'back to the base clips (form null) too'); + assert.ok(isCosmeticEvent(['fx', 'phase', 1, 1, { id: 3, kind: 'artsBarrier' }]), 'a barrier phase stays cosmetic'); + // a 1 s stall at 2×: the render clock jumps past the 1.5 game s window + const b = new SnapshotBuffer(); + b.pushEvents([['dmg', 3, 5, 'phys'], form(3, 'translator_youling'), ['fx', 'burst', 1, 1, {}]], 0, 1); + const late = new Map(); + const out = b.takeEvents(10, [], 10 - 1.5, late); + assert.deepEqual(out.map((e) => e[4]?.form ?? e[0]), ['translator_youling'], 'only the form fx is handed out'); + assert.ok(Math.abs(late.get(out[0]) - 9) < 1e-9, 'with its lateness (game s)'); + // replayed without a stamp right after a reset (screens/game.js early buffer: stamped 0) + const r = new SnapshotBuffer(); + r.pushEvents([form(4, 'husk'), ['fx', 'ember', 1, 1, { r: 1 }]], 0); + assert.deepEqual(r.takeEvents(30, [], 28.5).map(fxFormOf), ['husk']); + // a hidden-tab flood: the shed keeps it + const q = new SnapshotBuffer(); + q.pushEvents([form(5, 'reborn')], 0, 0.5); + for (let i = 0; i < 70; i++) q.pushEvents(Array.from({ length: 100 }, () => ['fx', 'burst', 1, 1, {}]), 0, 1 + i * 0.01); + assert.ok(q.events.length <= 6000, `bounded (${q.events.length})`); + assert.equal(q.events.filter((e) => e.ev[4]?.form === 'reborn').length, 1, 'never shed'); + }); + test('sample before any snapshot / with NaN time is empty; update before snapshots is NaN', () => { const b = new SnapshotBuffer(); assert.ok(Number.isNaN(b.update(1)));