Files
ark-stronghold-protocol/test/sim/bossfield.browser.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- 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.
2026-10-11 01:58:52 +08:00

229 lines
15 KiB
JavaScript
Raw 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/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)})`);
});
});