Files
ark-stronghold-protocol/test/render/feedback1-forms.browser.test.js

311 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/feedback1-forms.browser.test.js — player reports after 0.1.0 (#5, #8) in headless Chrome: the real sim runs
// in the page (the client runner's loadBrowserSim) and feeds the render demo's field view on 战场#01 (render/app.js fx
// 'phase' / `form` → render/units.js setForm / FORMS):
// #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on
// as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了");
// #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever;
// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's
// 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
// 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. Whether a stall lands where a queued form fx falls out of the 1.5 game s window depends on the
// page's frame timing, so this is the real-page check; the deterministic one is test/match/feedback1-runner-forms
// (the runner feeding the render engine's buffer through a 1.2 s stall).
//
// 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.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** The view plays the feed a little behind the sim (its interpolation buffer): game seconds allowed after an event. */
const LAG = 0.4;
/** 百炼嘉维尔 facing the lower gate on (9,8) (`row`, `col`: elsewhere); one enemy walking the lower lane (9,10) → (9,2). */
const spec = (enemyKey, id, row = 9, col = 8) => buildBattleSpec({
battleId: `e2e.${id}`, fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_4_23_b', row, col, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
spawns: [{ time: 0, enemyKey, routeIndex: 0, count: 1, mods: { hpMul: 1, atkMul: 0.01 } }],
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
});
/**
* Run `spec` in the page for `secs` game seconds; `act(b, e, ally)` runs every tick (the test's own interventions).
* Returns samples { t, clip, form, alive, hp, stealth, blocked } of the enemy's view every 6 ticks.
*/
async function runInPage(page, port, s, enemyKey, secs, actSrc) {
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return page.evaluate(async (spec, key, secs, actSrc) => {
const act = new Function('b', 'e', 'ally', 'state', actSrc);
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const out = [];
const state = {};
let e = null;
for (let i = 0; i < 30 * secs; i++) {
if (!b.finished) b.step(); // (the view goes on after the end: the death clip)
const t = Math.max(b.time, (i + 1) / 30);
e = e ?? b.units.find((x) => x.side === 'enemy' && x.defId === key) ?? null;
const ally = b.allyUnits[0];
if (e) act(b, e, ally, state);
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
if (i === 30 * 2) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
const view = e && v.debug.views.get(e.id);
if (e && i % 6 === 5) out.push({ t: +t.toFixed(2), clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: e.alive, hp: e.hp, max: e.s.maxHp, stealth: !!e.s.flags.stealth, blocked: !!e.blockedBy });
}
}
return { out, state };
}, s, enemyKey, secs, actSrc);
}
/**
* The late view: `spec` runs silently in the page (events drained and dropped, like the client runner's catch-up) while
* `setupSrc(b, ally)` changes the enemies' forms; at `atSecs` the field view enters the battle from `b.fieldMeta()` and
* follows the feed. Returns { [name]: { clip, form, alive } } of the views of the enemies `setupSrc` returns by name.
*/
async function lateViewInPage(page, port, s, atSecs, setupSrc) {
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return page.evaluate(async (spec, atSecs, setupSrc) => {
const setup = new Function('b', 'ally', setupSrc);
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const named = setup(b, b.allyUnits[0]);
while (b.time < atSecs) { b.step(); b.drainEvents(); }
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const read = () => Object.fromEntries(Object.entries(named).map(([k, u]) => {
const view = v.debug.views.get(u.id);
return [k, { clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: u.alive, simForm: u.form }];
}));
for (let i = 0; i < 30; i++) {
b.step();
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
}
if (i === 15) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
}
return read();
}, 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); 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, calmSecs = 0) {
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return page.evaluate(async (spec, jankMs, secs, calmSecs) => {
const { createBattleRunner } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
const handlers = new Map();
const net = {
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
send() { return true; }, request() { return Promise.resolve({ t: 'ok' }); },
};
const queue = [];
const runner = createBattleRunner({ net, store: { patch() {} }, doc: { hidden: false, addEventListener() {} }, raf: (fn) => { queue.push(fn); return queue.length; }, caf() {} });
runner.on('field', (f) => { v.enterBattle(f); v.setCamera('normal', { rect: f.rect, side: 'L', instant: true }); v.setLocalFeed({ on: true, speed: f.speed }); });
runner.on('snap', (x) => v.pushSnapshot(x));
runner.on('ev', (m) => v.pushEvents(m));
for (const fn of handlers.get('b.start') || []) fn({ t: 'b.start', battleId: spec.battleId, fieldId: spec.fieldId, kind: 'normal', spec, authoritative: false, watch: true, speed: 2, elapsed: 0 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 100 && !runner._entries.size; i++) await sleep(50);
const e = [...runner._entries.values()][0];
const out = [];
const end = performance.now() + secs * 1000;
const calmFrom = end;
const calmEnd = end + calmSecs * 1000;
while (performance.now() < calmEnd) {
// 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), one free frame for the renderer, then the
// runner's catch-up frame. After `secs` the stalls stop and the frames come smoothly for `calmSecs` (the clips
// catch up: PIXI caps a frame's delta).
const calm = performance.now() >= calmFrom;
if (!calm) {
const until = performance.now() + jankMs;
while (performance.now() < until) { /* stall */ }
}
await sleep(calm ? 33 : 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);
if (tr) out.push({ t: +e.battle.time.toFixed(2), simForm: tr.form, form: view?.form ?? null, clip: view?.actor?.current ?? null, spine: !!view?.spineReady, alive: tr.alive, calm });
}
const catchups = runner.stats().catchups;
runner.dispose();
return { out, catchups };
}, s, jankMs, secs, calmSecs);
}
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function page() {
const p = await browser.newPage();
const problems = [];
p.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
p.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
p.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await p.setViewport({ width: 1280, height: 720 });
return { p, problems };
}
test('#5 转译基底·α: A_* while 4 physical hits do nothing, A_Die_B, then 寻仇者 B_*; it dies on B_Die', async () => {
const { p, problems } = await page();
try {
// the operator stands off its lane (no block); at 3 s: 4 physical hits from it (the 4th starts the change); at 9 s:
// knocked out for good
const log = await runInPage(p, srv.port, spec('enemy_10081_mpplai', 'translator', 11, 4), 'enemy_10081_mpplai', 12, `
if (!state.hit && b.time >= 3) { state.hit = b.time; state.hp = e.hp; for (let i = 0; i < 4; i++) b.dealDamage(ally, e, { amount: 1e6, type: 'phys' }); state.after = e.hp; }
if (!state.kill && b.time >= 9 && e.alive) { state.kill = b.time; b.kill(e, ally); }
`);
await p.screenshot({ path: path.join(OUT, 'feedback1-translator.png') });
const { out, state } = log;
assert.equal(state.after, state.hp, 'the 4 hits took no HP');
const pre = out.filter((x) => x.spine && x.t < state.hit);
assert.ok(pre.length > 0 && pre.every((x) => /^A_/.test(x.clip)), `first form: A_* (${[...new Set(pre.map((x) => x.clip))]})`);
const change = out.filter((x) => x.t > state.hit + 2 * LAG && x.t < state.hit + 1.8); // (a loaded machine: the view lags more)
assert.ok(change.length > 0 && change.every((x) => x.clip === 'A_Die_B' && x.alive), `the change clip (${[...new Set(change.map((x) => x.clip))]})`);
const form = out.filter((x) => x.t > state.hit + 2 + LAG && x.t < state.kill);
assert.ok(form.length > 0 && form.every((x) => /^B_/.test(x.clip) && x.form === 'translator_fuchou'), `寻仇者 clips (${[...new Set(form.map((x) => x.clip))]})`);
const dead = out.filter((x) => x.t > state.kill + LAG);
assert.ok(dead.length > 0 && dead.every((x) => !x.alive && x.clip === 'B_Die'), `dies on B_Die (${[...new Set(dead.map((x) => x.clip))]})`);
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
test('#8 深池逐火战士 knocked out in front of 百炼嘉维尔: Die (重生), the 隐匿 ember on Idle_2, beaten, Die_2', async () => {
const { p, problems } = await page();
try {
const log = await runInPage(p, srv.port, spec('enemy_1288_duskls', 'ember'), 'enemy_1288_duskls', 16, `
if (!state.ko && e.blockedBy && b.time >= 6) { state.ko = b.time; b.kill(e, ally); }
if (state.ko && !state.dead && !e.alive) state.dead = b.time;
`);
await p.screenshot({ path: path.join(OUT, 'feedback1-ember.png') });
const { out, state } = log;
assert.ok(state.ko, 'blocked and knocked out');
const pre = out.filter((x) => x.spine && x.t < state.ko);
assert.ok(pre.length > 0 && pre.every((x) => !/_2$/.test(x.clip)), `the warrior's clips (${[...new Set(pre.map((x) => x.clip))]})`);
const reb = out.filter((x) => x.t > state.ko + LAG && x.t < state.ko + 0.9);
assert.ok(reb.length > 0 && reb.every((x) => x.clip === 'Die' && x.alive), `重生: 'Die' (${[...new Set(reb.map((x) => x.clip))]})`);
assert.ok(state.dead > state.ko + 1, `the blocker beats the ember (dead at ${state.dead})`);
const ember = out.filter((x) => x.t > state.ko + 1 + LAG && x.t < state.dead);
assert.ok(ember.length > 0 && ember.every((x) => x.clip === 'Idle_2' && x.stealth && x.max === 5), `the 隐匿 ember on Idle_2 (${[...new Set(ember.map((x) => x.clip))]})`);
assert.ok(ember.some((x) => x.hp < 5), 'its hit counter runs down');
const dead = out.filter((x) => x.t > state.dead + LAG);
assert.ok(dead.length > 0 && dead.every((x) => x.clip === 'Die_2'), `dies on Die_2 (${[...new Set(dead.map((x) => x.clip))]})`);
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
test('a view built mid-battle (enterBattle(fieldMeta) after a silent catch-up) starts in the current forms: 特战术师 D_*, 幽灵 C_*, the ember Idle_2 / Move_2, 掠海漂移体 *_02', async () => {
const { p, problems } = await page();
try {
const got = await lateViewInPage(p, srv.port, spec('enemy_1007_slime', 'late', 9, 8), 7.5, `
ally.base.atk = 0; ally.markDirty(); // 百炼嘉维尔 only blocks
const still = { mods: { speedMul: 0 } };
const shushi = b.spawnEnemy('enemy_10081_mpplai', { pos: [11, 9], ...still });
const youling = b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8.4], ...still }); // blocked ⇒ 幽灵
const ember = b.spawnEnemy('enemy_1288_duskls', { pos: [12, 9], ...still });
const drift = b.spawnEnemy('enemy_2025_syufo', { pos: [11, 6], ...still });
for (let i = 0; i < 4; i++) b.dealDamage(ally, shushi, { amount: 1, type: 'arts' });
b.kill(ember, ally);
b.applyStatus(drift, 'stun', { duration: 1, source: ally });
return { shushi, youling, ember, drift };
`);
await p.screenshot({ path: path.join(OUT, 'feedback1-lateview.png') });
assert.deepEqual(Object.fromEntries(Object.entries(got).map(([k, x]) => [k, x.simForm])),
{ shushi: 'translator_shushi', youling: 'translator_youling', ember: 'husk', drift: 'crawl' }, 'the sim\'s forms');
for (const [k, x] of Object.entries(got)) assert.ok(x.spine && x.alive && x.form === x.simForm, `${k}: a live Spine view in the sim's form (${JSON.stringify(x)})`);
assert.match(got.shushi.clip, /^D_/, `特战术师 (${got.shushi.clip})`);
assert.match(got.youling.clip, /^C_/, `幽灵 (${got.youling.clip})`);
assert.match(got.ember.clip, /^(Idle|Move)_2$/, `the ember (${got.ember.clip})`);
assert.match(got.drift.clip, /_02$/, `crawling (${got.drift.clip})`);
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
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), 1000, 9, 3);
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');
assert.ok(changed, `the sim's 转译基底·α turned 幽灵 (${JSON.stringify(out.slice(-3))})`);
const after = out.filter((x) => x.spine && x.t > changed.t + 2 + LAG && x.alive);
assert.ok(after.length > 0, 'samples after the change');
// through the stalls the view is in the 幽灵 form (its 2 s change clip may still play: PIXI caps a frame's delta,
// so a clip advances 0.1 s per stalled frame) — never the first form's walk again
assert.ok(after.every((x) => x.form === 'translator_youling' && /^(C_|A_Die_C)/.test(x.clip)), `幽灵's clips (${[...new Set(after.map((x) => `${x.form}:${x.clip}`))]})`);
const calm = after.filter((x) => x.calm);
assert.ok(calm.length > 0 && /^C_/.test(calm[calm.length - 1].clip), `smooth frames again: 幽灵's own clips (${[...new Set(calm.map((x) => x.clip))]})`);
assert.ok(!out.some((x) => x.spine && /^B_/.test(x.clip)), 'never the 寻仇者\'s clips (B_Die was the look of report #5)');
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
});