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:
sganggs committed 2026-10-03 07:30:52 +08:00
1 parent 88d419b095
commit 4e68100065
11 files changed
+259 -39

No files matched your search

+3 -2
View File
@@ -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 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' `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 / 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through
hidden tabs — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a 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 `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 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: stealth bit only while its 隐匿 is on:
+6 -2
View File
@@ -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 ('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 '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 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 and its hidden-tab backlog (`battle/runner.js keepsState`; a backlog past `HELD_MAX` drops only superseded status /
(`screens/game.js`); dropping them was report #5's look again after a stall or a background tab. 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 **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 player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is
+37 -6
View File
@@ -15,9 +15,10 @@
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse. // ~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, // 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 // 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 // hidden the battle on screen keeps the same events (compacted past HELD_MAX; a backlog still past it makes the view
// first frame back delivers them, so the view still knows every unit and every enemy's current form (player report #5 // re-enter from the field meta) and the first frame back delivers them — or, for a battle that ended while hidden, the
// after 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model). // 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 // 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. // 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 // 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). * 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); 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; 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()). */ /** 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']); 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 = []; } try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
if (e !== cur || e.stale) return; if (e !== cur || e.stale) return;
for (const x of ev) if (keepsState(x)) e.held.push(x); 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) { function progress(e, force = false) {
@@ -671,7 +702,7 @@ export function createBattleRunner(deps) {
})); }));
} }
if (doc && typeof doc.addEventListener === 'function') { if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); }); doc.addEventListener('visibilitychange', () => { if (hidden()) return; flushHidden(); schedule(); });
} }
return { return {
+13 -6
View File
@@ -24,7 +24,7 @@
// user playtest #5 item 2) goes straight to the held pose, no burst; an // 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 // fx with a `form` (shared/protocol.js fxForm) switches the enemy's
// model to that clip set (render/units.js FORMS) // 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 // ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'| // view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all // 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
@@ -1376,9 +1376,12 @@ export async function createFieldView(host, options = {}) {
} }
const EVS = []; 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) { function processEvents(renderT) {
EVS.length = 0; 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) { for (const e of EVS) {
try { handleEvent(e, renderT); } catch (err) { if (!handleEvent.warned) { handleEvent.warned = true; console.warn('[render] event failed', e, err); } } 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 // 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 // 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 // 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, // nothing); the info keeps it for a view built later. No client stage drops these fx: battle/runner.js
// hidden-tab backlog), nor does the game screen's pre-entry buffer. // 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 form = fxForm(e);
const late = form !== undefined ? LATE.get(e) || 0 : 0;
if (form !== undefined) { if (form !== undefined) {
const inf = infos.get(e[4].id); const inf = infos.get(e[4].id);
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form; 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; break;
} }
case 'layer': { case 'layer': {
+20 -5
View File
@@ -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 // * 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), // 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` // 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 // 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): // (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`. // `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. // 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 }); 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). */ /** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']); 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). */ /** 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 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: '<type>', … }`, so the server * Game time (s) of a b.snap / b.ev payload, or NaN. On the wire every frame is `{ t: '<type>', … }`, 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` * 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; if (!this.events.length || !Number.isFinite(time)) return out;
let n = 0; let n = 0;
while (n < this.events.length && this.events[n].t <= time) n++; while (n < this.events.length && this.events[n].t <= time) n++;
@@ -318,7 +330,10 @@ export class SnapshotBuffer {
const list = this.events; const list = this.events;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const e = list[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); out.push(e.ev);
} }
const rest = list.length - n; const rest = list.length - n;
+2 -2
View File
@@ -27,7 +27,7 @@
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // 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') // 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. // 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 // (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 守墓石像 // 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, // 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 }); 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 * → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
* once first: * once first:
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'; * - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
+14 -3
View File
@@ -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. // 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 evBufRef = useRef(new Map()); // fieldId → ev tuples since that field's m.field
const snapBufRef = useRef(new Map()); // fieldId → latest snapshot 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 // 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 // (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'; viewModeRef.current = 'prep';
lastFieldRef.current = null; lastFieldRef.current = null;
enteredFieldRef.current = null; enteredFieldRef.current = null;
reentryRef.current = null;
setHud(null); setHud(null);
} }
if (priv) { if (priv) {
@@ -376,6 +381,7 @@ function MatchScreen() {
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d)); if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
enteredFieldRef.current = field; enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId; lastFieldRef.current = field.fieldId;
reentryRef.current = null;
viewModeRef.current = 'battle'; viewModeRef.current = 'battle';
snapUnitsRef.current = new Map(); snapUnitsRef.current = new Map();
view.enterBattle(field); view.enterBattle(field);
@@ -423,10 +429,15 @@ function MatchScreen() {
let pending = null; let pending = null;
const flush = () => { pending = null; last = performance.now(); setHud(hudRef.current); }; const flush = () => { pending = null; last = performance.now(); setHud(hudRef.current); };
const onFieldMeta = (msg) => { 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 onSnap = (snap) => {
const cur = lastFieldRef.current; const cur = shownId();
if (!snap || typeof snap !== 'object') return; if (!snap || typeof snap !== 'object') return;
if (!cur || (snap.fieldId && snap.fieldId !== cur)) { if (!cur || (snap.fieldId && snap.fieldId !== cur)) {
if (typeof snap.fieldId === 'string') snapBufRef.current.set(snap.fieldId, snap); 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); else if (!pending) pending = setTimeout(flush, HUD_HZ_MS - dt);
}; };
const onEv = (msg) => { const onEv = (msg) => {
const cur = lastFieldRef.current; const cur = shownId();
if (!msg || !Array.isArray(msg.ev)) return; if (!msg || !Array.isArray(msg.ev)) return;
if (!cur || (msg.fieldId && msg.fieldId !== cur)) { if (!cur || (msg.fieldId && msg.fieldId !== cur)) {
if (typeof msg.fieldId !== 'string') return; if (typeof msg.fieldId !== 'string') return;
+120 -5
View File
@@ -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 // 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 // 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 // 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 // state-bearing events for the first frame back (compacted past HELD_MAX; still past it, the view re-enters from the field
// screen's pre-entry buffer keeps them too (screens/game.js keepEarly, same predicate). // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js'; import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.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(); 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 }); const r = rig({ hidden: true });
r.net.emit('b.start', start(true)); r.net.emit('b.start', start(true));
await r.settle(); 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); for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250);
const tr = trOf(e); const tr = trOf(e);
assert.ok(tr?.form, 'changing'); 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); r.advance(500, 250);
assert.ok(e.stale && e.held.length === 0, 'overflow: dropped, marked stale'); assert.ok(e.stale && e.held.length === 0, 'overflow: dropped, marked stale');
r.show(); 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'); 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, /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\);/); 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();
}); });
+13 -7
View File
@@ -9,8 +9,10 @@
// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js // 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; // 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 // 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: // blocking 1 s long tasks (every frame a catch-up, and the render clock jumps past its 1.5 game s stale-event
// 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. // 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 // 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. // 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 * 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` * screens/game.js (field → enterBattle, snap → pushSnapshot, ev → pushEvents); every frame follows a blocking `jankMs`
* real ms for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the * 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. * runner's catch-up count.
*/ */
async function runnerInPage(page, port, s, jankMs, secs) { async function runnerInPage(page, port, s, jankMs, secs) {
@@ -155,7 +157,11 @@ async function runnerInPage(page, port, s, jankMs, secs) {
const out = []; const out = [];
const end = performance.now() + secs * 1000; const end = performance.now() + secs * 1000;
while (performance.now() < end) { 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()); for (const fn of queue.splice(0)) fn(performance.now());
const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai'); const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai');
const view = tr && v.debug.views.get(tr.id); 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(); 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(); const { p, problems } = await page();
try { 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') }); await p.screenshot({ path: path.join(OUT, 'feedback1-runner.png') });
assert.ok(catchups >= 5, `catch-up frames (${catchups})`); assert.ok(catchups >= 5, `catch-up frames (${catchups})`);
const changed = out.find((x) => x.simForm === 'translator_youling'); const changed = out.find((x) => x.simForm === 'translator_youling');
+6 -1
View File
@@ -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', () => { 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'); 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\);/);
}); });
+25
View File
@@ -5,6 +5,7 @@ import { test, describe } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot, isCosmeticEvent, frameTime } from '../../public/js/render/interp.js'; 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 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 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]; 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']); 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', () => { test('sample before any snapshot / with NaN time is empty; update before snapshots is NaN', () => {
const b = new SnapshotBuffer(); const b = new SnapshotBuffer();
assert.ok(Number.isNaN(b.update(1))); assert.ok(Number.isNaN(b.update(1)));