Files
ark-stronghold-protocol/test/sim/bossfield.browser.test.js
T

139 lines
8.0 KiB
JavaScript

// test/sim/bossfield.browser.test.js — the Final Assault / Hidden Core fields in the BROWSER sim (DESIGN §14: the
// clients simulate the boss fields; the server re-simulates them for SP_VERIFY and takeovers). A real bot co-op match
// (终极, seed 22 since 0.1.1 — the bots and the rules of 0.1.1 play seed 12 to boards without a 麻痹 or a 剑 / 锤
// transfer on every field, and seed 14 since the 22-match R11 drafts to boards without a 麻痹; seed 22's pair fields
// carry 奥术, 催泪瓦斯 and 限伤-sized hits) is played to the Hidden Core
// with every active bond at the official 999 layers (DESIGN §20.12); its four captured boss specs (R14 假想敌:胄 boss_1, R15 隐秘核心 boss_8) run to
// the end in Node and in headless Chrome through the client's own loader (public/js/battle/runner.js loadBrowserSim)
// with the field's LocalBossPool, and must give the same result digest, the same pool and the same fx per kind.
// The fields exercise the playtest-6b sim paths: 直接乘算 bonus sums (§20.10), one 奥术 instance per target
// (applyStrongest), the drone 2 % links and the 剑 / 锤 / shell kit (§20.13), and 限伤 cancels (fx 'hitCap', §20.12).
//
// Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1)
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { makeMatch, DATA } from '../match/harness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = (process.env.RENDER_E2E === '1' || process.env.SIM_E2E === '1') && existsSync(CHROME);
const skip = enabled ? false : 'set SIM_E2E=1 or RENDER_E2E=1 (needs Chrome)';
const MAX_SECONDS = 600; // game seconds (Battle.runToEnd): every captured field clears long before
/** The boss / hidden specs of a 4-bot co-op match (终极, seed 22, boss_1 then boss_8), every active bond at 999. */
function captureBossSpecs() {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'ABYSS', seats, seed: 22, captureFrames: false, instant: false, clientCombat: true });
const m = h.m;
m.bossId = 'boss_1';
m.hiddenBossId = 'boss_8';
const specs = [];
const orig = m._ccField.bind(m);
m._ccField = (o) => { const f = orig(o); if (f.kind === 'boss' || f.kind === 'hidden') specs.push(f.spec); return f; };
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = BOND_LAYER_CAP;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'the bots reached the Hidden Core');
m.dispose();
return specs;
}
/**
* Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns
* its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses).
* Serialised into the page with Function#toString, so it uses nothing from this module's scope.
*/
function runField(S, ds, spec, maxSeconds) {
const b = S.createBattleFromSpec(spec, ds, { quiet: true, recordEvents: false });
const fx = {};
const fx0 = b.fx.bind(b);
b.fx = (k, p) => { fx[k] = (fx[k] || 0) + 1; return fx0(k, p); };
let hpLoss = 0;
b.on('damaged', (c) => { if (c.target && c.target.isBoss && c.dmg && c.dmg.tags && c.dmg.tags.includes('hpLoss')) hpLoss++; }, { priority: -1e9 });
let maxArcane = 0;
b.on('tick', () => {
for (const e of b.enemies) {
if (!e.alive || !e.isBoss) continue;
const n = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip')).length;
if (n > maxArcane) maxArcane = n;
}
});
const r = b.runToEnd(maxSeconds);
const d = S.resultDigest(r);
return { hash: d.hash, json: d.json, reason: r ? r.reason : null, poolHp: b.sharedBoss ? b.sharedBoss.hp : null, fx, hpLoss, maxArcane };
}
describe('Final Assault / Hidden Core fields in the browser 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'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => {
const specs = captureBossSpecs();
assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']);
const ds = new DataSource(DATA, null);
const node = specs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS));
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
// (a failed request is reported by the response handler with its URL; the favicon 404 is not a problem)
page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) problems.push(`console: ${m.text()}`); });
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
const chrome = await page.evaluate(async (all, src, maxSeconds) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { spec: S, ds } = await loadBrowserSim();
const run = new Function(`return (${src})`)();
return all.map((spec) => run(S, ds, spec, maxSeconds));
}, specs, runField.toString(), MAX_SECONDS);
await page.close();
assert.deepEqual(problems, []);
specs.forEach((spec, i) => {
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}`;
assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`);
assert.equal(chrome[i].json, node[i].json, `${at}: browser and Node results are identical`);
assert.equal(chrome[i].hash, node[i].hash, at);
assert.equal(chrome[i].poolHp, node[i].poolHp, `${at}: same local pool`);
assert.deepEqual(chrome[i].fx, node[i].fx, `${at}: same fx per kind`);
assert.equal(chrome[i].hpLoss, node[i].hpLoss, `${at}: same 无来源 HP losses on the leader`);
assert.ok(node[i].maxArcane <= 1, `${at}: one 奥术 instance on the leader at a time (${node[i].maxArcane})`);
});
const sum = (k) => node.reduce((a, r) => a + (r.fx[k] || 0), 0);
assert.ok(sum('hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum('hitCap')})`);
assert.ok(sum('palsy') >= 1, `瘫痪 occurred (${sum('palsy')})`);
assert.ok(node.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${node.map((r) => r.hpLoss)})`);
assert.ok(node.some((r) => r.maxArcane === 1), 'the leader carried 奥术');
});
});