Ported by hand from PR #209 by @qingjingshenghuo (its one-line data/config.json was not taken): - server/match/gamedata.js bossPoolShareOf (used by GameData.bossPoolShare and by finalAssault.bossPoolHp for a plain game-data object): solo × `solo` (1); co-op with `perPlayer` true × `coop` (1) × the players alive at the boss round's start (bots and AI 托管 seats count, eliminated / departed seats do not; at most aliveFull 4; a count left out = a full team). The fixed pool stays restorable through config: bossHpScale perPlayer false + solo 0.25 (and its optional aliveScaling). - tools/build-data.mjs: the global bossHpScale carries the rule (perPlayer true, solo 1); the modes' entries keep only bloodPointKey / unaffectedByEnemyScale. data/config.json regenerated offline — JSON compare: only bossHpScale (global and the 9 mode entries) changed. - The problem #209 documented: a 胄 drone death is 2 % of the pool and 限伤 cancels a hit of ceil ≥ 300000, so the hidden 胄 终极 pool at 3 / 4 players (21.6M / 28.8M) would cancel every drone (432000 / 576000). The drone link is a share of the pool, no hit: Battle.loseHp takes noHitLimit and the link passes the limit [ASSUMED: the official client checks every damage modifier and PRTS calls it 真实伤害, but which max HP it reads is undocumented]; a part's 传递 and every hit stay under the limit. Golden (regenerated; why: the pool size): - fields: boss-boss_1..7-pair/-solo and hidden-boss_8..10-pair/-solo (all 20 boss / hidden scenarios) — a pair field's pool × 2 (two players), a solo template's × 4 (solo × 1, was × 0.25); boss-boss_1-pair, boss-boss_1-solo, boss-boss_3-pair, boss-boss_3-solo, boss-boss_5-solo, boss-boss_7-pair and hidden-boss_9-solo now end at the 200 s cap instead of clearing; the two 联防 scenarios are unchanged. - matches: solo-FUNNY-2, solo-NORMAL-1, solo-HARD-1, solo-HARD-2 now lose the Final Assault (solo leaders have 4× the HP: team LP 0 with 93088–176395 of the pool left); solo-FUNNY-1, solo-NORMAL-2, solo-HARD-9-boosted (its Hidden Core too), coop2-NORMAL-3, coop4-FUNNY-6, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy still win with more leader damage (pool × players alive; solo-FUNNY-1 / solo-HARD-9-boosted end with 6 / 1 team LP less). Unchanged: coop2-NORMAL-8-serverrun and coop2-NORMAL-14-standins (one player left at R14: × 1 as before) and the matches that end before R14; roster / bonds / standins / diy unchanged. Tests: test/match/boss-hp-scaling.test.js (from PR #209, adapted — every leader and difficulty × 1–4 players and solo; R14 / R15 leave out two departed players; AI 托管 and bots count, an eliminated player does not; config restores the fixed pool, the opposite of the PR's test; the plain-object fallback agrees; only the leader's HP scales); test/content/enemies_bosses.test.js (hidden 胄 终极 at 3 / 4 players: the drone share lands, a hit of the same size is cancelled — fails without the change). Updated on purpose: finalAssault, balance, bosshp, connection, docs-consistency (the §20.9 record points to §25.13.4; the data carries perPlayer true), sim/playtest6_limits (the drone share lands past the line), match/playtest6-bosspool (the 5 % knob halved to keep the 2-player pool's size: that test is about the pool's arithmetic). Docs: PLAYING §7, DATA, META, BALANCE, SIM, design engine.md / network.md, the §20.9 / §20.10 records, research 00 / 01 / 08, the code comments, DESIGN §25.13.4 and the §25.10 table.
464 lines
27 KiB
JavaScript
464 lines
27 KiB
JavaScript
// 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 share, no hit: it lands past the 300000 line too (DESIGN §25.13.4)', () => {
|
||
// 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. With the pool per player alive (the owner's decision of 2026-10-06, PR #209) boss_8 ABYSS
|
||
// reaches 21.6M / 28.8M at 3 / 4 players: 432000 / 576000 per drone, past the line where ceil(0.02 × max) ≥ 300000 —
|
||
// until 0.2.0 the link was cancelled there like a hit; it is a share of the pool and lands (Battle.loseHp noHitLimit
|
||
// [ASSUMED], 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 solo = run(7200000);
|
||
assert.equal(solo.lost, 144000, 'boss_8 ABYSS-sized pool (one player): the loss lands');
|
||
assert.deepEqual(solo.caps, []);
|
||
const under = run(14999950);
|
||
assert.equal(under.lost, 299999, 'just under the line: lands');
|
||
assert.deepEqual(under.caps, []);
|
||
for (const [m, want] of [[14999951, 299999.02], [15000000, 300000], [21600000, 432000], [28800000, 576000]]) {
|
||
const over = run(m);
|
||
assert.ok(Math.abs(over.lost - want) < 1e-6, `${m}: the share lands past the line (${over.lost})`);
|
||
assert.deepEqual(over.caps, [], `${m}: no cancelled hit`);
|
||
}
|
||
});
|
||
|
||
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/);
|
||
});
|