// 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. // // 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); } 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 + LAG && x.t < state.hit + 1.8); 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(); } }); });