Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item / 机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call) - official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11 - a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight, promotion effect - 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪 - DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
1 parent
6464e4b46a
commit
620f88c0d8
81 files changed
+4194
-447
No files matched your search
+14
-1
@@ -4,7 +4,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { makeBattle, chessRec, enemyRec, flatStage, hashOf, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { UF, ANIM } from '../../shared/constants.js';
|
||||
import { UF, ANIM, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
import { EV } from '../../shared/protocol.js';
|
||||
import { getDefaultSource, spawnsFromTemplate, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { LocalBossPool } from '../../server/sim/spec.js';
|
||||
@@ -478,6 +478,19 @@ test('layers & coins: addLayers records gains (normal field) and emits client ev
|
||||
assert.equal(h2.b.addLayers('p1', 'yanShip', 3), 0);
|
||||
});
|
||||
|
||||
test('layers: an IN_BATTLE gain stops at BOND_LAYER_CAP (999, MAX_GARRISON_STACK) — the battle\'s count and the reported gain', () => {
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
const h = makeBattle({ content: 'none', bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 995 } } });
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 3, 'test'), 3);
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 1, 'only the room left under the cap');
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 0, 'at the cap: nothing');
|
||||
assert.equal(h.b.getPlayer('p1').bonds.yanShip.layers, 999);
|
||||
h.b.forceEnd('forced');
|
||||
assert.deepEqual(h.result().perPlayer.p1.layerGains, { yanShip: 4 });
|
||||
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [3, 1], 'the client sees what was added');
|
||||
});
|
||||
|
||||
test('tokens: spawnToken deploys a token next to its owner; manual token pieces deploy from input', () => {
|
||||
const ds = getDefaultSource();
|
||||
const h = makeBattle({
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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 12: both pair fields carry two 奥术 players) 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 { createRequire } from 'node:module';
|
||||
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 12, 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: 12, 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 require = createRequire(path.join(ROOT, 'package.json'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
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: 'new', 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 奥术');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// "终极 leaders die in seconds" (user report after playtest #6): the percentage attribute bonuses of the 卫戍 systems
|
||||
// compounded. PRTS 卫戍协议:盟约 下半/PRTS盟约记录: "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"; PRTS 游戏数据基础
|
||||
// 属性基本公式: 直接乘算 values are SUMMED — D_t = t₁ + … + tₙ, A = (A₀ + D_p)(1 + D_t) — and PRTS 作战机制 X₂ = X₁(1 + b₁% +
|
||||
// b₂%) with a skill's "攻击力+X%" in the same sum. v2.5 gave every bond / strategy / item its own ×(1 + x) (atkMul), so
|
||||
// with many layers a ranged operator under 精准 + 独行 + 卡西米尔 + an item + a 特质 dealt ×74 instead of ×12 of its ATK.
|
||||
// Now: content/support directMods → atkPct / defPct / hpPct (constants.js DIRECT_BONUS_STACKING 'add'); the 炎佑 ×1.5
|
||||
// ("提升至1.5倍") and the char_attribute_mul 特质 (a rune on the chess's base attributes) stay multipliers.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, flatStage, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { directMods } from '../../server/sim/content/support/index.js';
|
||||
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
|
||||
import { DIRECT_BONUS_STACKING } from '../../server/sim/constants.js';
|
||||
import { SharedBossPool } from '../../server/match/finalAssault.js';
|
||||
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg ?? ''} expected ${b}, got ${a}`);
|
||||
const bond = (tier, layers = 0, count = 2) => ({ count, active: tier > 0, tier, layers });
|
||||
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 攻击力+15%
|
||||
const BLADE = 'chess_item_3_07_e_a'; // 阿戈尔重刃 攻击力+40%
|
||||
|
||||
test('directMods: 直接乘算 ratios go to the additive percentage bucket (zeros dropped, extras merged)', () => {
|
||||
assert.equal(DIRECT_BONUS_STACKING, 'add');
|
||||
assert.deepEqual(directMods({ atk: 0.3, def: 0.2, hp: -0.1 }), { atkPct: 0.3, defPct: 0.2, hpPct: -0.1 });
|
||||
assert.deepEqual(directMods({ atk: 0, hp: NaN }), {});
|
||||
assert.deepEqual(directMods({ def: 0.5 }, { redeployMul: 0.7 }), { defPct: 0.5, redeployMul: 0.7 });
|
||||
});
|
||||
|
||||
test('bonds + equipment + a skill ATK%: one sum (精准 L100 +130 %, 独行 +60 %, two items +55 %, skill +50 %)', () => {
|
||||
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
|
||||
const preci = pre.base_atk + pre.atk_per_stack * 100;
|
||||
const defs = { chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null }) } };
|
||||
const h = makeBattle({
|
||||
defs, units: [{ chessId: 's_op', row: 10, col: 3, items: [HAMMER, BLADE] }],
|
||||
bonds: { preciShip: bond(1, 100), soloShip: bond(1, 0, 1) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('s_op');
|
||||
const sum = 1 + preci + solo.atk + 0.15 + 0.4;
|
||||
close(u.s.atk, 500 * sum, 'bonds and items add up');
|
||||
h.b.addBuff(u, { key: 'test:skill', mods: { atkPct: 0.5 } }); // a skill's "攻击力+50%" (直接乘算 too)
|
||||
close(u.s.atk, 500 * (sum + 0.5), 'the skill joins the same sum');
|
||||
const v25 = 500 * (1 + preci) * (1 + solo.atk) * 1.15 * 1.4 * 1.5; // the v2.5 product (each source its own multiplier)
|
||||
assert.ok(u.s.atk < v25 / 1.8, `far below the v2.5 product ${v25}`);
|
||||
close(u.s.maxHp, 2000 * (1 + solo.max_hp), '独行 HP');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('strategy + bonds add up: 阿米娅 众志合一 (3 active bonds: +20 % ATK / HP) with 精准 and 绝技 (elite)', () => {
|
||||
const pre = bondBb('preciShip'), sunt = bondBb('suntShip');
|
||||
const defs = { chess: { e_op: chessRec({ id: 'e_op', bonds: ['preciShip'], profession: 'SNIPER', skill: null, golden: true }) } };
|
||||
const h = makeBattle({
|
||||
defs, bandId: 'band_amiya', units: [{ chessId: 'e_op', row: 10, col: 3 }],
|
||||
bonds: { preciShip: bond(1, 50), suntShip: bond(1, 0, 2), steadShip: bond(1, 0) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('e_op');
|
||||
const want = 1 + pre.base_atk + pre.atk_per_stack * 50 + sunt.power_atk + 0.2;
|
||||
close(u.s.atk, 500 * want, 'one 直接乘算 sum');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('a leader sharing the pool takes the summed ATK: one normal hit of a stacked operator (精准 L600 + 独行 + 2 items)', () => {
|
||||
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
|
||||
const defs = {
|
||||
chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] }) },
|
||||
enemies: { enemy_stack_leader: { ...enemyRec({ key: 'enemy_stack_leader', hp: 600000, speed: 0, dmgType: 'none' }), rank: 'BOSS', hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } } },
|
||||
};
|
||||
const pool = new SharedBossPool(3600000);
|
||||
const h = makeBattle({
|
||||
defs, kind: 'boss', stage: flatStage(), sharedBoss: pool, autoFinish: false,
|
||||
units: [{ chessId: 's_op', row: 10, col: 6, items: [HAMMER, BLADE] }],
|
||||
bonds: { preciShip: bond(1, 600), soloShip: bond(1, 0, 1) },
|
||||
enemies: [{ key: 'enemy_stack_leader', pos: [3, 10], route: { motion: 'WALK', start: [3, 10], end: [3, 10], checkpoints: [] }, tag: 'boss' }],
|
||||
});
|
||||
const hits = [];
|
||||
h.b.on('damaged', (c) => { if (c.target.bossPool && c.dmg.isAttack) hits.push(c.amount); }, { priority: -1e9 });
|
||||
h.runUntil(() => hits.length >= 1, 10);
|
||||
const atk = 500 * (1 + pre.base_atk + pre.atk_per_stack * 600 + solo.atk + 0.15 + 0.4);
|
||||
assert.ok(hits.length >= 1, 'the operator hits the leader');
|
||||
close(hits[0], atk, 'DEF 0: one hit = the summed ATK');
|
||||
close(pool.maxHp - pool.hp, hits.reduce((s, x) => s + x, 0), 'the pool lost exactly the hits');
|
||||
assert.ok(hits[0] < 500 * (1 + pre.base_atk + pre.atk_per_stack * 600) * (1 + solo.atk) * 1.15 * 1.4 / 2, 'v2.5 compounded this to more than twice as much');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -139,8 +139,11 @@ for (const bossId of BOSSES) {
|
||||
const pool = new SharedBossPool(max);
|
||||
const { b } = bossBattle(bossId, pool);
|
||||
const leader = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (max - 0.4) / 1e9 });
|
||||
assert.ok(dealt > max - 1, `${bossId}: the hit took the whole pool (${dealt} of ${max})`);
|
||||
// the team already dealt all but 250000 (one hit of the whole pool would be cancelled by 限伤, BOSS_HIT_LIMIT 300000)
|
||||
const rest = 250000;
|
||||
pool.damage('p_1', max - rest);
|
||||
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (rest - 0.4) / 1e9 });
|
||||
assert.ok(dealt > rest - 1, `${bossId}: the hit took the rest of the pool (${dealt} of ${rest})`);
|
||||
assert.equal(pool.hp, 0, 'not 0.4 HP: nobody would ever see it and no hit may be needed to finish it');
|
||||
for (let i = 0; i < 3 && !b.finished; i++) b.step();
|
||||
assert.equal(b.finished, true);
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Official limits (docs/research/11-limits-official.md; the user asked for the co-op leaders to be as hard to kill as
|
||||
// officially): (1) bond layers stop at 999 per bond — the client's AutoChessBattleConst.MAX_GARRISON_STACK and
|
||||
// AddBondCount `min(L + n, 999)`; (2) the boss-hit limit "限伤" — in a boss battle outside training (our kinds 'boss' /
|
||||
// 'hidden'), a single hit on a leader (AutoChessBattleUtil.IsBossEnemy) whose ceil(damage) ≥ 300000
|
||||
// (AutoChessBattleConst.MAX_BATTLE_DAMAGE) is cancelled: 0 damage, nothing credited to the shared pool.
|
||||
//
|
||||
// This file: the switches (shared/constants.js), the in-battle layer copy (Battle.addLayers / support.gainLayers) and
|
||||
// the hit limit in the damage pipeline (server/sim/damage.js leaderHitCancelled: dealDamage, Battle.loseHp).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../../shared/constants.js';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { gainLayers } from '../../server/sim/content/support/index.js';
|
||||
import { SharedBossPool } from '../../server/match/finalAssault.js';
|
||||
import { LocalBossPool, buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { buildBossWave, setupMatchWaves } from '../../server/match/waves.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { leaderHitCancelled } from '../../server/sim/damage.js';
|
||||
import { abOf } from '../../server/sim/content/enemies.js';
|
||||
|
||||
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
|
||||
const LIMIT = BOSS_HIT_LIMIT;
|
||||
|
||||
test('switches: BOND_LAYER_CAP = 999 (MAX_GARRISON_STACK), BOSS_HIT_LIMIT = 300000 (MAX_BATTLE_DAMAGE)', () => {
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
assert.equal(BOSS_HIT_LIMIT, 300000);
|
||||
});
|
||||
|
||||
test('layerGainRoom: min(n, 999 − before), never negative, never lowers a count; +Infinity = the room left', () => {
|
||||
assert.equal(layerGainRoom(0, 5), 5);
|
||||
assert.equal(layerGainRoom(990, 5), 5);
|
||||
assert.equal(layerGainRoom(995, 10), 4, 'clamped to 999');
|
||||
assert.equal(layerGainRoom(999, 1), 0, 'a gain at the cap adds 0');
|
||||
assert.equal(layerGainRoom(1200, 3), 0, 'a count over the cap (a test write) gains 0 and is not lowered');
|
||||
assert.equal(layerGainRoom(300, Infinity), 699);
|
||||
assert.equal(layerGainRoom(undefined, 7), 7);
|
||||
assert.equal(layerGainRoom(NaN, 7), 7);
|
||||
for (const bad of [0, -3, NaN, null, undefined]) assert.equal(layerGainRoom(10, bad), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// in-battle layer gains (the live copy, as the client's AddBondCount)
|
||||
|
||||
const opRec = (o = {}) => chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e6 }, skill: null, bonds: ['yanShip'], ...o });
|
||||
function layerBattle(bonds, extra = {}) {
|
||||
return makeBattle({
|
||||
defs: { chess: { t_op: opRec() } },
|
||||
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_op', row: 10, col: 4 }], bonds, playerEffects: [] }],
|
||||
content: 'none', autoFinish: false, ...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('Battle.addLayers: clamps the live copy at 999; the event and layerGains carry what was added; at the cap: 0, no hook, no event', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 990 } });
|
||||
h.step();
|
||||
const b = h.b;
|
||||
assert.equal(b.addLayers('P', 'yanShip', 4, 'garrison'), 4);
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 994);
|
||||
assert.equal(b.addLayers('P', 'yanShip', 20, 'garrison'), 5, 'only the room left (999 − 994)');
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
const hooks0 = h.hooksOf('layerGain').length;
|
||||
assert.equal(b.addLayers('P', 'yanShip', 3, 'garrison'), 0, 'a gain at the cap adds 0');
|
||||
assert.equal(h.hooksOf('layerGain').length, hooks0, 'no layerGain hook at the cap');
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [4, 5], "'layer' events carry the clamped gains, none at the cap");
|
||||
assert.equal(h.result().perPlayer.P.layerGains.yanShip, 9, 'the result reports what was added');
|
||||
});
|
||||
|
||||
test('Battle.addLayers: a layerGain hook that raises n (魔王 +1) is clamped after the hook', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 997 } }, {
|
||||
setup: (b) => b.on('layerGain', (c) => { c.n += 5; }),
|
||||
});
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'yanShip', 1, 'garrison'), 2, '1 + 5 asked, 2 room');
|
||||
assert.equal(h.b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
});
|
||||
|
||||
test('Battle.addLayers without a live copy of the bond (partial input): the battle\'s own gains stop at 999', () => {
|
||||
const h = layerBattle({});
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 600);
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 399);
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 1, 'bond'), 0);
|
||||
assert.equal(h.result().perPlayer.P.layerGains.kjeragShip, 999);
|
||||
});
|
||||
|
||||
test('support.gainLayers (特质 / bond gains): returns the clamped total; a capped bond counts nothing toward the per-battle cap', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 998 }, kjeragShip: { count: 2, active: true, tier: 1, layers: 10 } });
|
||||
h.step();
|
||||
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: ['yanShip', 'kjeragShip'], n: 3, reason: 'garrison' }), 1 + 3);
|
||||
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: 'yanShip', n: 3, reason: 'garrison', cap: 24, capKey: 'g' }), 0);
|
||||
const ps = h.b.getPlayer('P');
|
||||
assert.deepEqual([ps.bonds.yanShip.layers, ps.bonds.kjeragShip.layers], [999, 13]);
|
||||
});
|
||||
|
||||
test('layer gains stay disabled in boss / 联防 fields (unchanged)', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 10 } }, { kind: 'boss' });
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'yanShip', 5, 'garrison'), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 限伤: the boss-hit limit
|
||||
|
||||
const guardRec = () => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 10, maxHp: 1e9 }, skill: null });
|
||||
/** A field of `kind` with a leader (tag 'boss'), a minion and a part, all standing still; the shared pool when given. */
|
||||
function hitField({ kind = 'boss', pool = null, leaderDef = 0, leaderRes = 0 } = {}) {
|
||||
const h = makeBattle({
|
||||
kind, sharedBoss: pool,
|
||||
defs: {
|
||||
chess: { t_guard: guardRec() },
|
||||
enemies: {
|
||||
enemy_lead: enemyRec({ key: 'enemy_lead', rank: 'BOSS', hp: 1e9, speed: 0, def: leaderDef, res: leaderRes }),
|
||||
enemy_mini: enemyRec({ key: 'enemy_mini', hp: 1e9, speed: 0 }),
|
||||
enemy_part: enemyRec({ key: 'enemy_part', rank: 'ELITE', hp: 1e9, speed: 0 }),
|
||||
},
|
||||
},
|
||||
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_guard', row: 10, col: 2 }], bonds: {}, playerEffects: [] }],
|
||||
enemies: kind === 'boss' || kind === 'hidden'
|
||||
? [{ key: 'enemy_lead', pos: [3, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [3, 12] }, { key: 'enemy_part', pos: [4, 12], tag: 'part' }]
|
||||
: [{ key: 'enemy_lead', pos: [10, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [11, 9] }],
|
||||
content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const leader = h.enemy('enemy_lead');
|
||||
const op = h.unit(1);
|
||||
assert.ok(leader && leader.isBoss, 'the leader is a boss unit');
|
||||
return { h, b: h.b, leader, mini: h.enemy('enemy_mini'), part: h.enemy('enemy_part'), op };
|
||||
}
|
||||
const capEvents = (h) => h.eventsOf('fx').filter((e) => e[1] === 'hitCap');
|
||||
|
||||
test('限伤: a leader\'s hit of 299999 lands, 300000 deals 0 — nothing credited to the shared pool, no number, a hitCap event', () => {
|
||||
const pool = new SharedBossPool(5e6);
|
||||
const { h, b, leader, op } = hitField({ pool });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: LIMIT - 1, type: 'true' }), LIMIT - 1);
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
|
||||
assert.equal(pool.byPlayer.get('P'), LIMIT - 1);
|
||||
const dmg0 = h.eventsOf('dmg').length;
|
||||
const damaged0 = h.hooksOf('damaged').length;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: LIMIT, type: 'true' }), 0, 'cancelled, not clamped');
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1), 'the pool is untouched');
|
||||
assert.equal(pool.byPlayer.get('P'), LIMIT - 1, 'nothing credited');
|
||||
assert.equal(b._pp('P').bossDamage, LIMIT - 1, 'no boss damage stat');
|
||||
assert.equal(op.stats.dmg, LIMIT - 1, 'no damage stat');
|
||||
assert.equal(h.eventsOf('dmg').length, dmg0, 'no damage number');
|
||||
assert.equal(h.hooksOf('damaged').length, damaged0, "no 'damaged' hook (nothing was dealt)");
|
||||
const ev = capEvents(h);
|
||||
assert.equal(ev.length, 1);
|
||||
assert.deepEqual([ev[0][4].id, ev[0][4].n], [leader.id, LIMIT]);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e6, type: 'true' }), 0, 'a whole pool in one hit is cancelled too');
|
||||
assert.ok(leader.alive);
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
|
||||
});
|
||||
|
||||
test('限伤: the check is ceil(final damage) — 299999.2 rounds up to the line and is cancelled, 299999 exactly lands', () => {
|
||||
const { b, leader, op } = hitField();
|
||||
const hp0 = leader.hp;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 299999.2, type: 'true' }), 0);
|
||||
assert.equal(leader.hp, hp0);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 299999, type: 'true' }), 299999);
|
||||
});
|
||||
|
||||
test('限伤: the value checked is the hit after DEF / RES and every multiplier (before shields)', () => {
|
||||
const { b, leader, op } = hitField({ leaderDef: 100000, leaderRes: 50 });
|
||||
// phys: 399999 − 100000 DEF = 299999 → lands; 400000 − 100000 = 300000 → cancelled
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 399999, type: 'phys' }), 299999);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 400000, type: 'phys' }), 0);
|
||||
// arts: 590000 × (1 − 50 %) = 295000 lands; 600000 × 50 % = 300000 cancelled
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 590000, type: 'arts' }), 295000);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 600000, type: 'arts' }), 0);
|
||||
// a multiplier pushes a small hit past the line: 150000 true × 2 (脆弱 on the leader) = 300000 → 0
|
||||
b.addBuff(leader, { key: 'test:fragile', mods: { dmgTakenMul: 2 } });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 149999, type: 'true' }), 299998);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 150000, type: 'true' }), 0, 'bigger multipliers lower the damage of hits past the line');
|
||||
// the attacker's damage-dealt multiplier and dmg.mul count too
|
||||
b.removeBuff(leader, 'test:fragile');
|
||||
b.addBuff(op, { key: 'test:dealt', mods: { dmgDealtMul: 3 } });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 100000, type: 'true' }), 0);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 99999, type: 'true' }), 299997);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 50000, type: 'true', mul: 2 }), 0, '50000 × 3 × 2');
|
||||
});
|
||||
|
||||
test('限伤: checked before shields — a cancelled hit leaves the leader\'s shield untouched; a smaller hit is absorbed as usual', () => {
|
||||
const { b, leader, op } = hitField();
|
||||
b.addBuff(leader, { key: 'test:shield', shield: 100000 });
|
||||
const hp0 = leader.hp;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 350000, type: 'true' }), 0);
|
||||
assert.equal(leader.findBuff('test:shield').shield, 100000, 'shield untouched');
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 250000, type: 'true' }), 150000, '100000 absorbed, 150000 lands');
|
||||
assert.equal(leader.hp, hp0 - 150000);
|
||||
});
|
||||
|
||||
test('限伤: only leaders — a minion and a part in the same boss field take any hit', () => {
|
||||
const { b, mini, part, op } = hitField();
|
||||
assert.equal(b.dealDamage(op, mini, { amount: 1e6, type: 'true' }), 1e6);
|
||||
assert.equal(b.dealDamage(op, part, { amount: 2e6, type: 'phys' }), 2e6);
|
||||
assert.equal(part.isBoss, false);
|
||||
});
|
||||
|
||||
test('限伤: only boss / hidden battles — the same leader-tagged unit takes any hit in a normal or 联防 field', () => {
|
||||
for (const kind of ['normal', 'unite']) {
|
||||
const { b, leader, op } = hitField({ kind });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 5e5, kind);
|
||||
}
|
||||
const { b, leader, op } = hitField({ kind: 'hidden' });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 0, 'the Hidden Core is a boss battle');
|
||||
});
|
||||
|
||||
test('限伤: every HP-damage kind — 元素伤害 and element bursts, DoT ticks, losses passed on (loseHp); element 损伤 (gauge) is not HP damage', () => {
|
||||
const pool = new SharedBossPool(5e7);
|
||||
const { h, b, leader, op } = hitField({ pool });
|
||||
const hp = () => pool.hp;
|
||||
// 元素伤害
|
||||
let p0 = hp();
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 4e5, type: 'elemental', element: 'burn' }), 0);
|
||||
assert.equal(hp(), p0);
|
||||
// a burn burst whose 7000 元素伤害 is pushed past the line by 元素脆弱 ×100: the gauge bursts (its lock / RES cut stay),
|
||||
// the burst's hit is cancelled
|
||||
b.addBuff(leader, { key: 'test:elemFragile', mods: { elementalTakenMul: 100 } });
|
||||
p0 = hp();
|
||||
const caps0 = capEvents(h).length;
|
||||
b.dealDamage(op, leader, { amount: 1e7, type: 'element', element: 'burn' });
|
||||
assert.ok(leader.findBuff('burnBurst'), 'the gauge burst (爆发冷却 running)');
|
||||
assert.equal(hp(), p0, 'the burst\'s 700000 元素伤害 was cancelled');
|
||||
assert.equal(capEvents(h).length, caps0 + 1);
|
||||
b.removeBuff(leader, 'test:elemFragile');
|
||||
// DoT ticks: each tick is a hit of its own
|
||||
p0 = hp();
|
||||
b.addBuff(leader, { key: 'test:dotBig', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 4e5, type: 'true', canDodge: false }) });
|
||||
b.addBuff(leader, { key: 'test:dotSmall', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 1000, type: 'true', canDodge: false }) });
|
||||
h.run(3.2);
|
||||
assert.equal(p0 - hp(), 3000, 'only the small ticks landed');
|
||||
// a loss passed on to the leader (parts' 传递, a drone's death): one hit too
|
||||
p0 = hp();
|
||||
assert.equal(b.loseHp(leader, 3e5, { source: op }), 0);
|
||||
assert.equal(hp(), p0);
|
||||
assert.equal(b.loseHp(leader, 2e5, { source: op }), 2e5);
|
||||
assert.equal(hp(), p0 - 2e5);
|
||||
});
|
||||
|
||||
test('限伤: what ran before the hit stays — the attack\'s hit hook fires; nothing after (damaged, fatal, kill)', () => {
|
||||
const { h, b, leader, op } = hitField();
|
||||
const hit0 = h.hooksOf('hit').length;
|
||||
const dmg0 = h.hooksOf('damaged').length;
|
||||
b.dealDamage(op, leader, { amount: 1e6, type: 'phys', isAttack: true });
|
||||
assert.equal(h.hooksOf('hit').length, hit0 + 1, "the 'hit' hook (before mitigation) ran");
|
||||
assert.equal(h.hooksOf('damaged').length, dmg0);
|
||||
assert.equal(h.hooksOf('fatal').length, 0);
|
||||
assert.equal(h.hooksOf('kill').length, 0);
|
||||
assert.ok(leader.alive);
|
||||
});
|
||||
|
||||
test('限伤: a browser pool (client-side combat) behaves the same; the field stays alive and deterministic', () => {
|
||||
const pool = new LocalBossPool(1e6);
|
||||
const { b, leader, op } = hitField({ pool });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 3e5, type: 'true' }), 0);
|
||||
assert.equal(pool.hp, 1e6);
|
||||
assert.equal(pool.cum, 0, 'nothing to report to the server');
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 2e5, type: 'true' }), 2e5);
|
||||
assert.equal(pool.cum, 2e5);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// the real leaders (data/bosses.json enemyKey = activity_table autoChessData.bossInfoDict enemyId, the official
|
||||
// IsBossEnemy list) on real boss fields
|
||||
|
||||
test('IsBossEnemy ⇔ tag \'boss\': every tag-\'boss\' spawn of a boss / hidden template is a bosses.json enemyKey; parts and escorts never are', () => {
|
||||
const leaders = new Set(Object.values(DATA.bosses).map((x) => x.enemyKey));
|
||||
assert.equal(leaders.size, 10);
|
||||
const parts = new Set(Object.values(DATA.bosses).flatMap((x) => x.parts || []));
|
||||
for (const [id, t] of Object.entries(DATA.waves)) {
|
||||
if (!t || !Array.isArray(t.spawns) || (t.kind !== 'boss' && t.kind !== 'hidden')) continue;
|
||||
for (const s of t.spawns) {
|
||||
const key = s && (s.key ?? s.enemyKey);
|
||||
if (!key) continue;
|
||||
if (s.tag === 'boss') assert.ok(leaders.has(key), `${id}: ${key} tagged boss is a leader`);
|
||||
else assert.ok(!leaders.has(key), `${id}: ${key} is a leader but not tagged boss`);
|
||||
if (s.tag === 'part') assert.ok(parts.has(key) && !leaders.has(key), `${id}: part ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const setup = setupMatchWaves(gd, createRng(3));
|
||||
const PAIR = [
|
||||
{ playerId: 'p_0', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] },
|
||||
{ playerId: 'p_1', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {}, playerEffects: [] },
|
||||
];
|
||||
function realBossField(bossId, hidden, pool, players = PAIR) {
|
||||
const wave = buildBossWave(gd, createRng(1), setup.factions, hidden ? 15 : 14, { bossId, solo: false });
|
||||
const spec = buildBattleSpec({
|
||||
battleId: `lim.${bossId}`, fieldId: 'b1', kind: hidden ? 'hidden' : 'boss', seed: 5, modeId: 'mode_multi_hard', round: hidden ? 15 : 14,
|
||||
stageId: 'act2autochess_m01', rect: { ...GEO.BOSS_RECT }, timeLimit: null, players,
|
||||
spawns: wave.spawns.filter((s) => s && gd.enemy(s.enemyKey)), routes: wave.routes, flags: { layerGainsEnabled: false, ...gd.dp },
|
||||
enemyOverrides: wave.overrides, waveId: wave.templateId, bossId, boss: { poolHp: pool.maxHp, poolMax: pool.maxHp },
|
||||
});
|
||||
const b = createBattleFromSpec(spec, undefined, { sharedBoss: pool, recordEvents: true, quiet: true });
|
||||
const want = gd.boss(bossId).parts?.length ? 1 + gd.boss(bossId).parts.length : 1;
|
||||
for (let i = 0; i < 30 * 120 && b.enemies.filter((e) => e.alive && (e.isBoss || e.tag === 'part')).length < want; i++) b.step();
|
||||
return { b, spec };
|
||||
}
|
||||
|
||||
for (const [bossId, hidden] of [['boss_1', false], ['boss_5', false], ['boss_8', true], ['boss_9', true], ['boss_10', true]]) {
|
||||
test(`限伤 on a real field: ${bossId} — every copy of the leader cancels a 300000 hit and takes a 299999 one; its parts are no leaders`, () => {
|
||||
const pool = new SharedBossPool(gd.boss(bossId).bloodPoint.HARD);
|
||||
const { b } = realBossField(bossId, hidden, pool);
|
||||
const caps = [];
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps.push(p.id); return fx0(k, p); };
|
||||
const leaders = b.enemies.filter((e) => e.alive && e.isBoss);
|
||||
assert.ok(leaders.length >= 1, 'the leader spawned');
|
||||
for (const L of leaders) {
|
||||
assert.equal(L.defId, gd.boss(bossId).enemyKey);
|
||||
const before = pool.hp;
|
||||
assert.equal(b.dealDamage(null, L, { amount: LIMIT, type: 'true', ignoreSleep: true }), 0, `${L.defId} #${L.id}`);
|
||||
assert.equal(pool.hp, before);
|
||||
assert.ok(caps.includes(L.id), `${L.defId} #${L.id}: cancelled by the limit (not by an invulnerable phase)`);
|
||||
assert.equal(b.dealDamage(null, L, { amount: LIMIT - 1, type: 'true', ignoreSleep: true }), LIMIT - 1);
|
||||
assert.equal(pool.hp, before - (LIMIT - 1));
|
||||
}
|
||||
const parts = b.enemies.filter((e) => e.alive && e.tag === 'part');
|
||||
if (hidden && bossId !== 'boss_10') assert.ok(parts.length > 0, 'the parts spawned');
|
||||
for (const p of parts) {
|
||||
assert.equal(p.isBoss, false);
|
||||
assert.equal(leaderHitCancelled(b, p, 1e7), false, `part ${p.defId} is no leader`);
|
||||
}
|
||||
for (const m of b.enemies.filter((e) => e.alive && !e.isBoss && e.tag !== 'part')) assert.equal(leaderHitCancelled(b, m, 1e7), false, m.defId);
|
||||
});
|
||||
}
|
||||
|
||||
test('限伤 vs the real leader defences [ASSUMED order]: 阿利斯泰尔\'s 5000 vest barrier stays under a cancelled hit; a 再生 block acts first', () => {
|
||||
// boss_6: the kit's own 莫非王土 animates a shield equipment lying on the field → the `boss:vest` HP shield (5000)
|
||||
const pool6 = new SharedBossPool(gd.boss('boss_6').bloodPoint.HARD);
|
||||
const { b: b6 } = realBossField('boss_6', false, pool6);
|
||||
const lion = b6.enemies.find((e) => e.alive && e.isBoss);
|
||||
const spot = [Math.round(lion.y), Math.round(lion.x) + 1];
|
||||
assert.ok(b6.spawnEnemy('enemy_10029_vtshld', { pos: spot, route: { motion: 'FLY', start: spot, end: spot, steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false }));
|
||||
for (let i = 0; i < 30 * 10 && !lion.findBuff('boss:vest'); i++) b6.step();
|
||||
const vest = lion.findBuff('boss:vest');
|
||||
assert.ok(vest, 'the kit put on the vest');
|
||||
assert.equal(vest.shield, 5000, 'store.dynamic');
|
||||
const caps6 = [];
|
||||
const fx6 = b6.fx.bind(b6);
|
||||
b6.fx = (k, p) => { if (k === 'hitCap') caps6.push(p.id); return fx6(k, p); };
|
||||
const before = pool6.hp;
|
||||
assert.equal(b6.dealDamage(null, lion, { amount: 302000, type: 'true', ignoreSleep: true }), 0, 'within 5000 of the line: still cancelled');
|
||||
assert.equal(lion.findBuff('boss:vest')?.shield, 5000, 'the barrier is untouched (限伤 before shields)');
|
||||
assert.equal(pool6.hp, before);
|
||||
assert.deepEqual(caps6, [lion.id]);
|
||||
assert.equal(b6.dealDamage(null, lion, { amount: 299999, type: 'true', ignoreSleep: true }), 294999, 'under the line: 5000 absorbed, the rest lands');
|
||||
assert.equal(pool6.hp, before - 294999);
|
||||
assert.equal(lion.findBuff('boss:vest'), null, 'the barrier is spent');
|
||||
|
||||
// boss_10 (hidden): a 假想敌:再生 aura block (`hit` step) negates the hit before the limit sees it
|
||||
const pool10 = new SharedBossPool(gd.boss('boss_10').bloodPoint.HARD);
|
||||
const { b: b10 } = realBossField('boss_10', true, pool10);
|
||||
const pipe = b10.enemies.find((e) => e.alive && e.isBoss);
|
||||
abOf(b10, pipe).hitShield = 1;
|
||||
const caps10 = [];
|
||||
const fx10 = b10.fx.bind(b10);
|
||||
b10.fx = (k, p) => { if (k === 'hitCap') caps10.push(p.id); return fx10(k, p); };
|
||||
const before10 = pool10.hp;
|
||||
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
|
||||
assert.equal(abOf(b10, pipe).hitShield, 0, 'the block is spent');
|
||||
assert.deepEqual(caps10, [], 'the limit never saw the hit');
|
||||
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
|
||||
assert.deepEqual(caps10, [pipe.id], 'the next one is cancelled by the limit');
|
||||
assert.equal(pool10.hp, before10);
|
||||
});
|
||||
|
||||
test('限伤 vs 【死亡集群】 (real kit): a drone killed by an operator costs the leader 2 % of the pool max — a loss that lands up to a 14999950 pool and is cancelled above it (ceil)', () => {
|
||||
// boss_1 / boss_8 skill 2: bb hp_ratio 0.02; the leader's max HP is the shared pool's max (Battle syncs it), so the
|
||||
// loss is 0.02 × pool max. Today's largest drone pool is boss_8 ABYSS 7.2M → 144000; a pool above 14999950 (≈ 2.08 ×
|
||||
// that), where ceil(0.02 × max) reaches 300000, would make every drone kill a cancelled hit — kept in view for any
|
||||
// change to the pool size (research 11 §6).
|
||||
assert.equal(gd.enemy('enemy_9013_acstmk').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
|
||||
assert.equal(gd.enemy('enemy_9013_acstmk_2').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
|
||||
const withOp = [{ ...PAIR[0], units: [{ uid: 1, chessId: 'chess_char_1_01_a', row: 10, col: 2 }] }, PAIR[1]];
|
||||
const run = (poolMax) => {
|
||||
const pool = new SharedBossPool(poolMax);
|
||||
const { b } = realBossField('boss_1', false, pool, withOp);
|
||||
const L = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
const key = gd.enemy(L.defId).skills.find((s) => s.prefabKey === '2').bbStr.enemy_key; // enemy_1005_yokai
|
||||
const caps = [];
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps.push([p.id, p.n]); return fx0(k, p); };
|
||||
for (let i = 0; i < 30 * 5 && !b.enemies.some((e) => e.alive && e.defId === key); i++) b.step();
|
||||
const drone = b.enemies.find((e) => e.alive && e.defId === key);
|
||||
assert.ok(drone, 'the kit summoned its drones (skill 2, initCooldown 0)');
|
||||
const op = b.units.find((u) => u.alive && u.side === 'ally');
|
||||
assert.ok(op, 'an operator to kill the drone');
|
||||
assert.equal(L.s.maxHp, poolMax, 'the leader\'s max HP is the pool max');
|
||||
const before = pool.hp;
|
||||
b.dealDamage(op, drone, { amount: 1e9, type: 'true', ignoreSleep: true });
|
||||
assert.equal(drone.alive, false, 'the drone died');
|
||||
return { lost: before - pool.hp, caps, id: L.id };
|
||||
};
|
||||
const today = run(7200000);
|
||||
assert.equal(today.lost, 144000, 'boss_8 ABYSS-sized pool: the loss lands');
|
||||
assert.deepEqual(today.caps, []);
|
||||
const under = run(14999950);
|
||||
assert.equal(under.lost, 299999, 'just under the line: lands');
|
||||
assert.deepEqual(under.caps, []);
|
||||
for (const m of [14999951, 15000000]) {
|
||||
const over = run(m);
|
||||
assert.equal(over.lost, 0, `${m}: ceil(0.02 × max) = 300000, one hit at the line: cancelled`);
|
||||
assert.deepEqual(over.caps.map((c) => c[0]), [over.id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('限伤 keeps the sim deterministic: a boss field whose hits cross the line gives the same result digest twice', () => {
|
||||
const run = () => {
|
||||
const pool = new LocalBossPool(gd.boss('boss_1').bloodPoint.HARD);
|
||||
const { b } = realBossField('boss_1', false, pool);
|
||||
// a stand-in for a 999-layer board: every hit on the leader is ×40 (some land, the big ones are cancelled)
|
||||
b.on('hit', (c) => { if (c.target && c.target.isBoss) c.dmg.mul *= 40; });
|
||||
b.on('tick', () => {
|
||||
const L = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
if (L && b.tickCount % 15 === 0) b.dealDamage(null, L, { amount: 2000 + (b.tickCount % 7) * 2500, type: 'true' });
|
||||
});
|
||||
let caps = 0;
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps++; return fx0(k, p); };
|
||||
for (let i = 0; i < 30 * 20; i++) b.step();
|
||||
return { d: resultDigest(b.result()), hp: pool.hp, caps };
|
||||
};
|
||||
const a = run(), c = run();
|
||||
assert.deepEqual(a.d, c.d);
|
||||
assert.equal(a.hp, c.hp);
|
||||
assert.ok(a.caps > 0, 'some hits were cancelled');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// the docs state the rules (kept here, next to the code, so they move together)
|
||||
|
||||
test('docs: research 11 is indexed; SIM / META / DATA / PLAYING / BALANCE state the 999 cap and the 300000 cancel', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
|
||||
const R11 = doc('docs/research/11-limits-official.md');
|
||||
assert.match(R11, /^# 11 · Official limits/);
|
||||
assert.match(R11, /MAX_GARRISON_STACK/);
|
||||
assert.match(R11, /MAX_BATTLE_DAMAGE/);
|
||||
assert.match(R11, /IsBossEnemy/);
|
||||
assert.match(doc('docs/research/00-INDEX.md'), /`11-limits-official\.md`/);
|
||||
const SIM = doc('docs/SIM.md');
|
||||
assert.match(SIM, /限伤/);
|
||||
assert.match(SIM, /BOSS_HIT_LIMIT/);
|
||||
assert.match(SIM, /BOND_LAYER_CAP/);
|
||||
assert.match(SIM, /hitCap/);
|
||||
const META = doc('docs/META.md');
|
||||
assert.match(META, /BOND_LAYER_CAP/);
|
||||
assert.match(META, /BOSS_HIT_LIMIT/);
|
||||
assert.match(doc('docs/DATA.md'), /IsBossEnemy/);
|
||||
const PLAYING = doc('docs/PLAYING.md');
|
||||
assert.match(PLAYING, /999 层/);
|
||||
assert.match(PLAYING, /300000/);
|
||||
assert.match(doc('docs/BALANCE.md'), /限伤 300000/);
|
||||
});
|
||||
Reference in new issue
Block a user