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
This commit is contained in:
1 parent
88d419b095
commit
4e68100065
11 files changed
+259
-39
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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\);/);
|
||||
});
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user