- puppeteer-core ^23.11.1 -> ^25.12.0 (dev only); npm audit: 7 high (basic-ftp via @puppeteer/browsers / proxy-agent, extract-zip) -> 0; production audit stays clean - puppeteer-core 25 is ESM-only and needs Node >= 22.12: the render / sim browser suites load it with await import() instead of createRequire - headless: 'new' -> headless: true (same new headless mode; 'new' is no longer a typed value) - fieldview CPU throttle: page.emulateCPUThrottling() instead of the deprecated page.target() CDP session - THIRD-PARTY-NOTICES version, DESIGN test note
199 lines
11 KiB
JavaScript
199 lines
11 KiB
JavaScript
// test/render/elembar.browser.test.js — user playtest #6 report 11 in headless Chrome ("干员血条右边的元素损伤条积累速度
|
||
// 不对,损伤条远远没扣完但是实际已经爆条"): a real battle (古米 against 底海滑动者) is simulated in Node, its frames are fed
|
||
// to the real field view (public/dev/render-demo.html, real Spine models) the way the client runner does (local feed,
|
||
// one tick per animation frame), and the drawn element gauge is read back every frame:
|
||
// · the white bar is the sim's remaining 元素值 at the frame shown (within 1 %, never below it, never empty before the
|
||
// burst), and the burst shows as the bar refilling over the 爆发冷却 in the element's colour (never white);
|
||
// · a row of five operators with gauges (desktop and a 844×390 phone at DPR 3): each gauge row lies under its own
|
||
// unit's bars and inside their span (the v2.3 ring sat on the next operator's tier chip and bars).
|
||
//
|
||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/elembar.browser.test.js
|
||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/elembar-*.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 { makeBattle } from '../helpers/battleHarness.js';
|
||
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
|
||
import { ELEMENT_RING } from '../../public/js/render/textures.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)';
|
||
const NEURAL_TINT = ELEMENT_RING.neural.tint;
|
||
|
||
/** Real frames: 古米 blocking 底海滑动者 (15 % ATK 神经 per hit) until past its first burst. */
|
||
function recordBattle(seconds = 54) {
|
||
const h = makeBattle({ units: [{ chessId: 'chess_char_1_10_a', row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
|
||
h.step();
|
||
h.spawn('enemy_1148_dssbr', { pos: [10, 5.6], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000 } });
|
||
const u = h.allies()[0];
|
||
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
|
||
h.b.drainEvents();
|
||
const frames = [];
|
||
for (let i = 0; i < seconds * 30; i++) {
|
||
h.step();
|
||
if (u.alive) u.hp = u.s.maxHp;
|
||
let bv = 0, el = null;
|
||
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
|
||
const { t, ...rest } = h.b.snapshot();
|
||
frames.push({ snap: { ...rest, t: 'b.snap', gt: t }, ev: h.b.drainEvents(), gt: t, el, left: el ? 1 - bv / u.gaugeMax : null, lock: !!u.s.flags.burstLock });
|
||
}
|
||
return { meta, frames, opId: u.id };
|
||
}
|
||
|
||
/** Page side: the drawn gauge of unit `id` (null: none) — element, the white bar's share, the render clock. */
|
||
function readGauge(id) {
|
||
const v = window.__demo.view;
|
||
const x = v.debug.views.get(id);
|
||
const r = x && x._elBar;
|
||
if (!r || !r.root.visible) return { el: null, renderT: v.debug.interp.renderT };
|
||
return { el: x.el, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, tint: r.fill.tint, renderT: v.debug.interp.renderT };
|
||
}
|
||
|
||
describe('element gauge row in headless Chrome (user playtest #6)', { 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();
|
||
});
|
||
|
||
const open = async (viewport) => {
|
||
const page = await browser.newPage();
|
||
const problems = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||
await page.setViewport(viewport);
|
||
await page.goto(`http://127.0.0.1:${srv.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, problems };
|
||
};
|
||
|
||
test('a real battle: the white bar drawn every frame is the sim\'s remaining 元素值; the burst refills it', async () => {
|
||
const { meta, frames, opId } = recordBattle();
|
||
const { page, problems } = await open({ width: 1280, height: 720 });
|
||
try {
|
||
await page.evaluate((meta) => {
|
||
const v = window.__demo.view;
|
||
v.enterBattle(meta);
|
||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||
v.setLocalFeed({ on: true, speed: 2 });
|
||
}, meta);
|
||
const byT = new Map(frames.map((f) => [f.gt, f]));
|
||
const rows = [];
|
||
const CHUNK = 30;
|
||
for (let i = 0; i < frames.length; i += CHUNK) {
|
||
const got = await page.evaluate(async (chunk, id, readSrc) => {
|
||
const read = new Function(`return (${readSrc})`)();
|
||
const v = window.__demo.view, out = [];
|
||
for (const f of chunk) {
|
||
if (f.ev.length) v.pushEvents({ t: 'b.ev', fieldId: f.snap.fieldId, gt: f.gt, ev: f.ev });
|
||
v.pushSnapshot(f.snap);
|
||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||
const g = read(id);
|
||
const s = v.debug.interp.snaps;
|
||
let shown = s[0]?.t;
|
||
for (const x of s) if (x.t <= g.renderT) shown = x.t;
|
||
out.push({ ...g, shownT: shown });
|
||
}
|
||
return out;
|
||
}, frames.slice(i, i + CHUNK), opId, readGauge.toString());
|
||
rows.push(...got);
|
||
}
|
||
let checked = 0, refills = 0, prevLock = false, lastBefore = null;
|
||
for (const row of rows) {
|
||
const f = byT.get(row.shownT);
|
||
if (!f) continue;
|
||
if (f.lock) {
|
||
assert.equal(row.el, 'neural', 'the 爆发冷却 shows');
|
||
if (!prevLock) { refills++; assert.ok(row.share < 0.03, `the refill starts empty (${row.share})`); }
|
||
assert.equal(row.tint, NEURAL_TINT, 'the refill is drawn in the 神经 colour, not white');
|
||
} else if (f.el) {
|
||
checked++;
|
||
assert.equal(row.el, f.el);
|
||
assert.equal(row.tint, 0xffffff, 'the 元素值 left is white');
|
||
assert.ok(row.share >= f.left - 1e-6 && row.share - f.left < 0.01 + 1e-6, `gt ${f.gt}: bar ${row.share} vs sim ${f.left}`);
|
||
assert.ok(row.share > 0, 'never empty before the burst');
|
||
lastBefore = row.share;
|
||
} else assert.equal(row.el, null);
|
||
prevLock = f.lock;
|
||
}
|
||
assert.ok(checked > 1000, `${checked} frames checked`);
|
||
assert.equal(refills, 1, 'one 神经 burst');
|
||
assert.ok(lastBefore <= 0.05, `the burst comes off a sliver (${lastBefore})`);
|
||
await page.screenshot({ path: path.join(OUT, 'elembar-cooldown.png') });
|
||
assert.deepEqual(problems, []);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
});
|
||
|
||
for (const [name, viewport] of [['desktop', { width: 1280, height: 720 }], ['phone', { width: 844, height: 390, deviceScaleFactor: 3 }]]) {
|
||
test(`a row of operators (${name}): every gauge row lies under its own unit's bars, inside their span`, async () => {
|
||
const { page, problems } = await open(viewport);
|
||
try {
|
||
await page.evaluate(() => {
|
||
const v = window.__demo.view;
|
||
const U = (id, side, spine, x, y) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3 });
|
||
const units = [3, 4, 5, 6, 7].map((c, i) => U(i + 1, 'ally', i % 2 ? 'char_1016_agoat2' : 'char_102_texas', c, 10))
|
||
.concat([U(6, 'enemy', 'enemy_1000_gopro_2', 3, 11.4), U(7, 'enemy', 'enemy_1000_gopro_2', 6, 11.4)]);
|
||
v.enterBattle({ fieldId: 'eb', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
|
||
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
|
||
v.setLocalFeed({ on: true, speed: 2 });
|
||
window.__units = units;
|
||
});
|
||
const feed = (from, to) => page.evaluate(async (from, to) => {
|
||
const v = window.__demo.view, units = window.__units;
|
||
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) {
|
||
v.pushSnapshot({ t: 'b.snap', fieldId: 'eb', gt: +gt.toFixed(4), units: units.map((u) => [u.id, u.x, u.y, u.side === 'enemy' ? 700 : 1000, 1000, 5, 10, 0, 0]), dp: 5, killed: 0, total: 2,
|
||
elem: [[1, 'neural', 0.1, 0, 0], [2, 'erosion', 0.5, 0, 0], [3, 'burn', 0.9, 0, 0], [4, 'apoptosis', 0.97, 0, 0], [5, 'neural', 1, 12, 10], [6, 'neural', 0.25, 0, 0], [7, 'erosion', 0.75, 0, 0]] });
|
||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||
}
|
||
}, from, to);
|
||
await feed(1, 2.5);
|
||
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
|
||
await feed(2.5, 7);
|
||
const geo = await page.evaluate(() => {
|
||
const out = {};
|
||
for (const [id, x] of window.__demo.view.debug.views) {
|
||
const r = x._elBar;
|
||
if (!r || !r.root.visible) continue;
|
||
const x0 = x.hpBg.position.x + 1, bw = x.hpBg.width - 2;
|
||
const barsBottom = x.spFill.visible ? x.spFill.position.y + x.spFill.height / 2 : x.hpBg.position.y + x.hpBg.height / 2;
|
||
out[id] = { x0, bw, barsBottom, discL: r.disc.position.x - r.disc.width * 0.336, discTop: r.disc.position.y - r.disc.height * 0.336,
|
||
barR: r.bg.position.x + r.bg.width, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, el: x.el, tint: r.fill.tint };
|
||
}
|
||
return out;
|
||
});
|
||
const want = { 1: 0.9, 2: 0.5, 3: 0.1, 4: 0.03, 6: 0.75, 7: 0.25 };
|
||
for (const [id, left] of Object.entries(want)) {
|
||
const g = geo[id];
|
||
assert.ok(g, `unit ${id} shows its gauge`);
|
||
assert.ok(Math.abs(g.share - left) < 1e-6, `unit ${id}: bar ${g.share} = remaining ${left}`);
|
||
assert.ok(g.discL >= g.x0 - 1 && g.barR <= g.x0 + g.bw + 1.5, `unit ${id}: inside its bars' span`);
|
||
assert.ok(g.discTop >= g.barsBottom, `unit ${id}: under its bars`);
|
||
assert.equal(g.tint, 0xffffff, `unit ${id}: 元素值 left is white`);
|
||
}
|
||
assert.ok(geo[5].share > 0.4 && geo[5].share < 0.6, `the 爆发冷却 half refilled (${geo[5].share})`);
|
||
assert.equal(geo[5].tint, NEURAL_TINT, 'the 爆发冷却 refill in the element\'s colour, unlike unit 1\'s white 神经 bar');
|
||
await page.screenshot({ path: path.join(OUT, `elembar-row-${name}.png`) });
|
||
assert.deepEqual(problems, []);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
});
|
||
}
|
||
});
|