- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
229 lines
15 KiB
JavaScript
229 lines
15 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 剑 / 锤 transfers) 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).
|
||
// 瘫痪 (fx 'palsy': a 麻痹 stack interrupts an enemy attack) is checked on the same four specs with a golden 催泪瓦斯 in
|
||
// every operator's second slot (withTearGas), compared Chrome vs Node like the others: the bots' only 麻痹 had been one
|
||
// 5 % 催泪瓦斯 on one operator — a single hit at 0.1.4, none since 0.2.0's enemy damage frame (WE1) re-timed every fight.
|
||
// 限伤 (and 奥术, which the bots no longer draft either) likewise, on a third copy of the four whose first player fields a
|
||
// fixed board of 999-layer heavy hitters
|
||
// (withHeavyHitters): the bots' own hits of ≥ 300000 came from whatever 维多利亚 / 精准 / 奥术 mix they drafted — none
|
||
// since 0.2.0's CB3 (the 受到攻击时 counters answer every damage instance) re-drafted seed 22, whose best hit is now
|
||
// about half the limit; the cancel path itself is unchanged (the earlier captures still cancel the same hits).
|
||
// 无来源 HP (drone 死亡集群 links and 剑 / 锤 transfers) likewise, on a fourth copy (withTransferWindow). Shared co-op
|
||
// stock (DESIGN §29.8) re-drafted seed 22: the 999-layer boards now clear in tens of seconds, before a drone is killed
|
||
// on every field and before a blade takes a transferable hit (a forced replace is not damage). The transfer path is
|
||
// unchanged — the 0.2.3 specs of this seed still lose leader HP on every field under this sim. The copy keeps the
|
||
// drafted operators and holds 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 at 1 layer so the fight lasts long enough.
|
||
//
|
||
// 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 { GameData } from '../../server/match/gamedata.js';
|
||
import { computeBonds, bondSnapshot } from '../../server/match/bondsMeta.js';
|
||
import { tileKey } from '../../server/match/board.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)';
|
||
// game seconds (Battle.runToEnd), a safety net only: every field must clear before it. The bots' boards follow the rules
|
||
// of the whole match (seed 22): since 0.2.0's last fixes (GitHub #232 re-drafted the earlier rounds) the Hidden Core b1
|
||
// board clears at about 511 s, its 催泪瓦斯 copy (the second item replaced) at about 619 s — twice that leaves room
|
||
const MAX_SECONDS = 1200;
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* A copy of `spec` where every operator carries a golden 催泪瓦斯 (攻击时有5%概率使目标获得一层麻痹) in its second slot —
|
||
* two slots, as in the game: a 麻痹 source on every field whatever the bots drafted (seed 22's own was a single 5 % roll).
|
||
*/
|
||
const TEAR_GAS = 'chess_item_5_01_e_b';
|
||
function withTearGas(spec) {
|
||
const s = JSON.parse(JSON.stringify(spec));
|
||
for (const p of s.players || []) {
|
||
for (const u of p.units || []) {
|
||
if (u.kind === 'chess') u.items = [...(u.items || []).filter((id) => !String(id).startsWith('chess_item_5_01_e')).slice(0, 1), TEAR_GAS];
|
||
}
|
||
}
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* A copy of `spec` whose first player's first five operators become a fixed 限伤 board on the same tiles, each with a
|
||
* 维式重锤: elite 焰影苇草 (维多利亚 + 精准), 洛洛 (维多利亚 + 奥术), 深靛 (奥术 + 精准), 刺玫 (维多利亚) and 远牙 (精准) — all
|
||
* ranged, so any tile takes them; the tokens of the operators replaced go with them. That player's bonds are recomputed
|
||
* for the new board as the match computes them (server/match/bondsMeta.js computeBonds), every active one at 999
|
||
* layers: 精准 (3: every ranged operator) ATK ×13, 维多利亚 ×9.2 damage with equipment and 奥术 ×11 法术伤害 taken on the
|
||
* leader put the arts hits of 焰影苇草, 洛洛 and 刺玫 at up to 1.3 M — hundreds per field past 限伤's 300000, whatever
|
||
* the bots drafted (every field of seeds 1–40 at 0.2.0).
|
||
*/
|
||
const HEAVY_HITTERS = ['chess_char_6_08_b', 'chess_char_2_10_b', 'chess_char_1_17_b', 'chess_char_1_06_b', 'chess_char_4_20_b'];
|
||
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤: 攻击力+15%
|
||
function withHeavyHitters(spec) {
|
||
const s = JSON.parse(JSON.stringify(spec));
|
||
const p = s.players[0];
|
||
const replaced = new Set();
|
||
p.units = p.units.map((u) => {
|
||
if (u.kind !== 'chess' || replaced.size >= HEAVY_HITTERS.length) return u;
|
||
const chessId = HEAVY_HITTERS[replaced.size];
|
||
replaced.add(u.uid);
|
||
return { uid: u.uid, kind: 'chess', chessId, row: u.row, col: u.col, dir: u.dir, items: [HAMMER] };
|
||
}).filter((u) => !(u.kind === 'token' && replaced.has(u.ownerUid)));
|
||
const gd = new GameData(DATA, s.modeId);
|
||
const board = new Map(p.units.filter((u) => u.kind === 'chess').map((u) => [tileKey(u.row, u.col), { kind: 'chess', id: u.chessId, items: (u.items || []).map((id) => ({ id })) }]));
|
||
const layers = Object.fromEntries(gd.bondIds.map((id) => [id, BOND_LAYER_CAP]));
|
||
p.bonds = bondSnapshot(computeBonds(gd, { board, hand: [], layers, bondCountBonus: {} }));
|
||
for (const b of Object.values(p.bonds)) if (!b.active) b.layers = 0;
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* A copy of `spec` whose 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 layers are held at 1. At 999 those bonds end seed 22's
|
||
* shared-stock boards before a 死亡集群 drone dies on every field and before a 斩胄之剑 / 破胄之锤 takes damage
|
||
* (DESIGN §29.8). One layer keeps the bond and lets the parts land; the operators stay the ones the bots drafted.
|
||
*/
|
||
const TRANSFER_BONDS = ['victoriaShip', 'preciShip', 'arcaneShip', 'kazimierzShip', 'suntShip'];
|
||
function withTransferWindow(spec) {
|
||
const s = JSON.parse(JSON.stringify(spec));
|
||
for (const p of s.players || []) {
|
||
for (const id of TRANSFER_BONDS) {
|
||
const b = p.bonds && p.bonds[id];
|
||
if (b && b.active && b.layers > 1) b.layers = 1;
|
||
}
|
||
}
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* 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']);
|
||
// the four as captured, the same four with a 麻痹 source on every field (withTearGas), with 限伤-sized hits
|
||
// on every field (withHeavyHitters), and with the damage bonds held at 1 layer (withTransferWindow)
|
||
const runs = [...specs, ...specs.map(withTearGas), ...specs.map(withHeavyHitters), ...specs.map(withTransferWindow)];
|
||
const ds = new DataSource(DATA, null);
|
||
const node = runs.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));
|
||
}, runs, runField.toString(), MAX_SECONDS);
|
||
await page.close();
|
||
assert.deepEqual(problems, []);
|
||
|
||
const copies = ['', ' +催泪瓦斯', ' +heavy hitters', ' +transfer window'];
|
||
runs.forEach((spec, i) => {
|
||
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}${copies[Math.floor(i / specs.length)]}`;
|
||
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 real = node.slice(0, specs.length);
|
||
const gassed = node.slice(specs.length, 2 * specs.length);
|
||
const heavy = node.slice(2 * specs.length, 3 * specs.length);
|
||
const opened = node.slice(3 * specs.length);
|
||
const sum = (rows, k) => rows.reduce((a, r) => a + (r.fx[k] || 0), 0);
|
||
assert.ok(sum(heavy, 'hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${heavy.map((r) => r.fx.hitCap || 0)} with the heavy hitters, ${sum(real, 'hitCap')} on the bots' own boards)`);
|
||
assert.ok(sum(gassed, 'palsy') >= 1, `瘫痪 occurred with the 催泪瓦斯 (${gassed.map((r) => r.fx.palsy || 0)})`);
|
||
assert.ok(opened.every((r) => r.hpLoss >= 1), `holding the damage bonds at 1 layer, every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${opened.map((r) => r.hpLoss)}; the 999-layer boards: ${real.map((r) => r.hpLoss)})`);
|
||
// 奥术 on the bots' own boards or on the heavy-hitter copy (洛洛 + 深靛 = 奥术 2): seed 22's bots stopped drafting it
|
||
// when 0.2.0's last fixes re-drafted the earlier rounds
|
||
assert.ok([...real, ...heavy].some((r) => r.maxArcane === 1), `the leader carried 奥术 (bots' boards ${real.map((r) => r.maxArcane)}, heavy hitters ${heavy.map((r) => r.maxArcane)})`);
|
||
});
|
||
});
|