Files
ark-stronghold-protocol/test/content/bonds_core.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

987 lines
62 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.
// The 8 core bonds (server/sim/content/bonds/core.js): per-tier / per-layer formulas of research 02 §3.1–3.8 with the
// data/bonds.json numbers, 调和 / 变形同构体 membership, live layers, inactive bonds, two players in a boss field, and
// the 维多利亚 hammer milestone in a real match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { bondBb, deps, prdConstant, reached, yanyouShare, registerMeta as coreRegisterMeta } from '../../server/sim/content/bonds/core.js';
import * as items from '../../server/sim/content/items.js';
import { TOKEN_IDS } from '../../server/sim/content/tokens.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { makeMatch, DATA, give, legalTileFor } from '../match/harness.js';
import { FORCED_EXIT } from '../../server/sim/constants.js';
const close = (a, b, eps = 1e-6, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} expected ${b}, got ${a}`);
const op = (id, bonds, extra = {}) => chessRec({
id, bonds, profession: 'WARRIOR', skill: null, ...extra,
stats: { atk: 1000, maxHp: 10000, def: 0, blockCnt: 2, ...(extra.stats || {}) },
});
const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers,
});
const dummy = enemyRec({ key: 'e_dummy', hp: 1e8, speed: 0, def: 0, res: 0 });
const buffOf = (u, key) => u.buffs.find((b) => b.key === key) ?? null;
const tagged = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes(tag));
/** Row-major units on board rows 9–12, cols 3.. (board coords). */
function lineup(ids, { row = 10, col = 3, items: it = {} } = {}) {
return ids.map((chessId, i) => ({ chessId, row: row + Math.floor(i / 7), col: col + (i % 7), items: it[i] }));
}
function defsOf(list) {
const chess = {};
for (const [id, bonds, extra] of list) chess[id] = op(id, bonds, extra);
return { chess, enemies: { e_dummy: dummy } };
}
// ---------------------------------------------------------------------------------------------------------------------
// 炎
test('炎: members ATK ×(1+0.23+0.009L), 调和 enjoys it, non-members unchanged, live layers', () => {
const bb = bondBb('yanShip');
assert.equal(bb.base_atk, 0.23);
const defs = defsOf([['y1_a', ['yanShip']], ['y2_a', ['yanShip']], ['y3_a', ['yanShip']], ['mani_a', ['maniShip']], ['none_a', ['preciShip']]]);
const h = makeBattle({
defs, units: lineup(['y1_a', 'y2_a', 'y3_a', 'mani_a', 'none_a']),
bonds: { yanShip: bondOn(4, 10), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
});
h.step(1);
close(h.unit('y1_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 10));
close(h.unit('mani_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 10), 1e-6, '调和');
close(h.unit('none_a').s.atk, 1000);
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.yanyou).length, 0, 'no 炎佑 below 6');
h.b.addLayers('p1', 'yanShip', 20, 'test');
h.step(2);
close(h.unit('y2_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 30), 1e-6, 'live layers');
checkInvariants(h.b);
});
test('炎 6 / 9: one / two 炎佑 with their template stats + 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
assert.equal(yanyouShare(), 0.3);
const bb = bondBb('yanShip');
// PRTS: "登场时使自身攻击力、生命值增加…所有【炎】盟约干员攻击力、生命值的30%(最终加算)" — added to the template
const tpl = DATA.tokens.enemy_9012_acloon.stats;
assert.deepEqual([tpl.atk, tpl.maxHp], [600, 12000], 'enemy_9012_acloon template');
for (const n of [6, 9]) {
const list = [];
for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]);
list.push(['mani_a', ['maniShip']]);
const h = makeBattle({
defs: defsOf(list), units: lineup(list.map((x) => x[0])),
bonds: { yanShip: bondOn(n, 0), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
});
h.step(1);
const yy = h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.yanyou && u.alive);
assert.equal(yy.length, n === 9 ? 2 : 1, `${n} 炎 → 炎佑 count`);
const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums
const sumHp = (n - 1) * 10000;
for (const y of yy) {
close(y.s.atk, (tpl.atk + sumAtk * 0.3) * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
close(y.s.maxHp, tpl.maxHp + sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`);
}
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 萨尔贡
const sargonDefs = (n) => {
const list = [];
for (let i = 0; i < n; i++) list.push([`s${i}_a`, ['sargonShip'], { skill: { duration: 1000, spCost: 10, initSp: 0 } }]);
list.push(['none_a', ['preciShip']]);
return list;
};
test('萨尔贡 3: every member skill start → one independent stack on every member (ASPD +12, 5+0.22L s, ≤ 25)', () => {
const list = sargonDefs(3);
const L = 10;
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { sargonShip: bondOn(3, L, null, [3, 6]) } });
h.step(1);
const [a, b, c] = ['s0_a', 's1_a', 's2_a'].map((id) => h.unit(id));
a.skill.activate('test', { free: true });
b.skill.activate('test', { free: true });
assert.equal(c.s.aspd, 100 + 2 * 12, 'two stacks on the non-caster');
assert.equal(h.unit('none_a').s.aspd, 100);
assert.equal(buffOf(c, 'bond:sargon:atk'), null, 'no ATK stacks below 6');
close(buffOf(c, 'bond:sargon').timeLeft, 5 + 0.22 * L, 1e-6);
// cap: 30 more activations → 25 stacks
for (let i = 0; i < 30; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
assert.equal(c.buffs.filter((x) => x.key === 'bond:sargon').length, 25);
assert.equal(c.s.aspd, Math.min(600, 100 + 300));
h.run(5 + 0.22 * L + 0.2);
assert.equal(c.buffs.filter((x) => x.key === 'bond:sargon').length, 0, 'stacks expire');
assert.equal(c.s.aspd, 100);
checkInvariants(h.b);
});
test('萨尔贡 6: each stack also ATK +12 % (additive, one multiplier), dropped when the stacks expire', () => {
const list = sargonDefs(6);
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
h.step(1);
const a = h.unit('s0_a'), f = h.unit('s5_a');
for (let i = 0; i < 3; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
close(f.s.atk, 1000 * (1 + 3 * 0.12));
for (let i = 0; i < 30; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
close(f.s.atk, 1000 * (1 + 25 * 0.12), 1e-6, 'cap +300 %');
h.run(5.3);
close(f.s.atk, 1000);
checkInvariants(h.b);
});
test('萨尔贡 6 + 娜仁图亚: no ATK stacks; skill start lends the caster’s equipment (tier ≤ 5, 60 s) to its 8 neighbours', () => {
const list = sargonDefs(6);
const calls = [];
deps.lendItemEffects = (battle, from, to, opts) => calls.push([from.defId, to.defId, opts]);
try {
// caster s0 at (10,4) with an item; neighbours: s1 (10,5), s2 (10,3); none_a far away
const units = [
{ chessId: 's0_a', row: 10, col: 4, items: ['test_item_a'] }, { chessId: 's1_a', row: 10, col: 5 }, { chessId: 's2_a', row: 11, col: 3 },
{ chessId: 's3_a', row: 12, col: 8 }, { chessId: 's4_a', row: 12, col: 9 }, { chessId: 's5_a', row: 12, col: 10 },
{ chessId: 'none_a', row: 9, col: 4 },
];
const h = makeBattle({ defs: defsOf(list), units, bandId: 'band_narant', bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
h.step(1);
h.unit('s0_a').skill.activate('test', { free: true });
assert.deepEqual(calls.map((c) => c[1]).sort(), ['none_a', 's1_a', 's2_a']);
assert.deepEqual(calls[0][2], { maxTier: 5, duration: 60 });
assert.equal(h.unit('s1_a').s.aspd, 112, 'ASPD stacks still apply');
close(h.unit('s1_a').s.atk, 1000, 1e-6, 'no ATK stacks with 娜仁图亚');
// a member without equipment lends nothing
calls.length = 0;
h.unit('s1_a').skill.activate('test', { free: true });
assert.equal(calls.length, 0);
checkInvariants(h.b);
} finally { deps.lendItemEffects = null; }
if (typeof items.lendItemEffects === 'function') {
const h = makeBattle({ defs: defsOf(list), units: [{ chessId: 's0_a', row: 10, col: 4, items: ['chess_item_1_01_e_a'] }, { chessId: 's1_a', row: 10, col: 5 }],
bandId: 'band_narant', bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
h.step(1);
h.unit('s0_a').skill.activate('test', { free: true });
h.run(1);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors));
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 维多利亚
test('维多利亚: equipment carriers deal ×(1.25+0.008L); 6: ATK +50 % per item, +80 % per golden item', () => {
const list = [['v0_a', ['victoriaShip']], ['v1_a', ['victoriaShip']], ['v2_a', ['victoriaShip']], ['v3_a', ['victoriaShip']], ['v4_a', ['victoriaShip']], ['v5_a', ['victoriaShip']]];
const L = 30;
for (const n of [3, 6]) {
const h = makeBattle({
defs: defsOf(list), units: lineup(list.slice(0, n).map((x) => x[0]), { items: { 0: ['test_item_a'], 1: ['test_item_a', 'test_item_b'] } }),
bonds: { victoriaShip: bondOn(n, L, null, [3, 6]) },
});
h.step(1);
const [a, b, c] = ['v0_a', 'v1_a', 'v2_a'].map((id) => h.unit(id));
close(a.s.dmgDealtMul, 1.25 + 0.008 * L);
close(b.s.dmgDealtMul, 1.25 + 0.008 * L);
close(c.s.dmgDealtMul, 1, 1e-9, 'no equipment → no bonus');
if (n === 3) {
close(a.s.atk, 1000);
} else {
close(a.s.atk, 1000 * 1.5);
close(b.s.atk, 1000 * (1 + 0.5 + 0.8));
close(c.s.atk, 1000);
}
h.b.addLayers('p1', 'victoriaShip', 10, 'test');
h.step(2);
close(a.s.dmgDealtMul, 1.25 + 0.008 * (L + 10), 1e-9, 'live layers');
checkInvariants(h.b);
}
});
test('维多利亚 hammers: every 25 layers → 1 item of pool_equip_vict, once per milestone, only while active (catch-up)', () => {
// only the core bond meta: no 获得时 garrisons (洛洛 would craft items) muddying the item count
const reg = createRegistry({ content: false });
coreRegisterMeta(reg);
const h = makeMatch({ mode: 'solo', seed: 5, registry: reg, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.recompute();
const pool = new Set(DATA.choices.pools.pool_equip_vict.items.map((id) => id.replace(/_a$/, '')));
// two identical normal hammers merge into the golden one (= 2 hammers)
const hammers = () => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'item' && pool.has(p.id.replace(/_[ab]$/, '')))
.reduce((n, p) => n + (/_b$/.test(p.id) ? 2 : 1), 0);
assert.equal(ps.bonds.victoriaShip?.active ?? false, false);
ps.addLayers('victoriaShip', 51); // "无需激活" gains while inactive: milestones 25 and 50 are owed
assert.equal(hammers(), 0, 'inactive: no hammer');
for (const id of ['chess_char_1_06_a', 'chess_char_2_05_a', 'chess_char_3_08_a']) give(h.m, ps, id, 'board', legalTileFor(h.m, ps, id));
assert.equal(ps.bonds.victoriaShip.active, true);
h.m.dispatch(ps, 'onPrepStart', { round: h.m.round });
assert.equal(hammers(), 2, 'caught up once active');
h.m.dispatch(ps, 'onPrepEnd', { round: h.m.round });
ps.addLayers('victoriaShip', 10); // 61
assert.equal(hammers(), 2, 'no double payment');
ps.addLayers('victoriaShip', 14); // 75
assert.equal(hammers(), 3);
assert.equal(ps.counters['bond:victoria:hammers'], 3);
h.invariants();
h.m.dispose();
});
// ---------------------------------------------------------------------------------------------------------------------
// 谢拉格
test('谢拉格: ×1.25, vs cold / frozen ×(1.35+0.01L); 6: a cold wind every 25 s for 20+0.1L s', () => {
const list = [];
for (let i = 0; i < 6; i++) list.push([`k${i}_a`, ['kjeragShip']]);
const L = 40;
for (const n of [3, 6]) {
const h = makeBattle({
defs: defsOf(list), units: lineup(list.slice(0, n).map((x) => x[0])),
enemies: [{ key: 'e_dummy', pos: [9, 8] }, { key: 'e_dummy', pos: [12, 8] }],
bonds: { kjeragShip: bondOn(n, L, null, [3, 6]) }, autoFinish: false, timeLimit: 200,
});
h.step(1);
const k = h.unit('k0_a');
const [e1, e2] = h.enemies();
if (n === 3) {
const d1 = h.b.dealDamage(k, e1, { amount: 1000, type: 'true' });
close(d1, 1250);
h.b.applyStatus(e2, 'cold', { duration: 5 });
const d2 = h.b.dealDamage(k, e2, { amount: 1000, type: 'true' });
close(d2, 1000 * (1.35 + 0.01 * L));
h.b.applyStatus(e2, 'cold', { duration: 5 }); // second cold ⇒ frozen
assert.ok(e2.s.flags.freeze);
close(h.b.dealDamage(k, e2, { amount: 1000, type: 'true' }), 1000 * (1.35 + 0.01 * L), 1e-6, 'frozen');
h.run(30);
assert.ok(!e1.s.flags.cold, 'no wind below 6');
} else {
h.run(24.8 - h.b.time);
assert.ok(!e1.s.flags.cold);
h.run(0.4);
assert.ok(e1.s.flags.cold && e2.s.flags.cold, 'first gust at 25 s');
h.run(20 + 0.1 * L - 0.6);
assert.ok(e1.s.flags.cold, `cold lasts ${20 + 0.1 * L} s`);
h.run(0.6);
assert.ok(!e1.s.flags.cold && !e1.s.flags.freeze, 'cold over');
}
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 拉特兰
test('拉特兰: ammo ×(1.05+0.015L) floored; 6: every ammo used → all members ATK +4 %, ≤ +200 %', () => {
const list = [];
for (let i = 0; i < 6; i++) list.push([`l${i}_a`, ['lateranoShip'], { profession: 'SNIPER' }]);
list.push(['none_a', ['preciShip'], { profession: 'SNIPER' }]);
const kits = {};
for (const [id] of list) kits[id] = () => ({ skill: { kind: 'ammo', ammo: 14 } });
const L = 10;
for (const n of [3, 6]) {
const ids = [...list.slice(0, n).map((x) => x[0]), 'none_a'];
const h = makeBattle({ defs: defsOf(list), kits, units: lineup(ids), bonds: { lateranoShip: bondOn(n, L, null, [3, 6]) } });
h.step(1);
const a = h.unit('l0_a'), b = h.unit('l1_a'), x = h.unit('none_a');
a.skill.activate('test', { free: true });
x.skill.activate('test', { free: true });
assert.equal(a.skill.ammoLeft, Math.floor(14 * (1 + 0.05 + 0.015 * L)));
assert.equal(x.skill.ammoLeft, 14, 'non-member unchanged');
for (let i = 0; i < 3; i++) a.skill.onAttackPerformed([], null);
if (n === 3) {
close(b.s.atk, 1000);
} else {
close(b.s.atk, 1000 * (1 + 3 * 0.04));
close(x.s.atk, 1000, 1e-9, 'non-member gets nothing');
for (let k = 0; k < 80; k++) {
if (!a.skill.active) a.skill.activate('test', { free: true });
a.skill.onAttackPerformed([], null);
}
close(b.s.atk, 1000 * 3, 1e-6, 'cap +200 %');
}
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 阿戈尔
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 物理流失 less the target\'s DEF, base ATK + block, layers = devoured tiers', () => {
const list = [
['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']],
['fod_a', ['preciShip'], { tier: 4, stats: { atk: 700, blockCnt: 3, maxHp: 20000, def: 900 } }],
['tok_free', ['preciShip'], { tier: 6 }],
];
const L = 10;
// g1 (10,3) → g2 (10,4) → fodder (10,5); g3 (11,3) has an empty front tile
const units = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 11, col: 3 }];
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, L, null, [3, 5]) }, hooks: ['damaged', 'layerGain'], captureNoisy: true });
h.step(1);
const [g1, g2, g3, f] = ['g1_a', 'g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id));
const devours = tagged(h, 'bond:egir:devour');
assert.deepEqual(devours.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']]);
// PRTS 盟约记录 "造成5000点物理流失"; PRTS 作战机制: a 物理流失 is reduced by the target's DEF
for (const c of devours) close(c.amount, Math.max(5000 - c.target.s.def, 5000 * 0.05));
close(g1.s.atk, 1000 + 1000 + 700, 1e-6, 'g1 gains g2 + fodder base ATK');
close(g2.s.atk, 1000 + 700);
close(g3.s.atk, 1000);
assert.equal(g1.s.blockCnt, 2 + 2 + 3);
assert.equal(g2.s.blockCnt, 2 + 3);
close(f.hp, 20000 - 2 * (5000 - 900), 1e-6, '物理流失: less DEF 900 (DEF-free until 0.1.1)');
const Lnow = L + 1 + 4; // g2 tier 1 + fodder tier 4, once each
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, Lnow);
h.step(2);
close(g3.s.maxHp, 10000 * (1 + 0.35 + 0.01 * Lnow), 1e-6, 'HP multiplier follows the devour layers');
close(f.s.maxHp, 20000, 1e-9, 'non-member HP unchanged');
checkInvariants(h.b);
});
test('阿戈尔 devour ATK is a 最终加算 (GitHub #165 point 2, PR #176): 1000 base ATK, +100 % skill ATK, +2000 devoured → 4000, not 6000', () => {
// PRTS 盟约记录 "该付与来源获得所有标记单位的基础攻击力(最终加算)"; PRTS 游戏数据基础 A_f = F_t[(A + D_p)(1 + D_t) + F_p]
const list = [
['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']],
['fod_a', ['preciShip'], { stats: { atk: 2000, maxHp: 20000, def: 0, blockCnt: 1 } }],
];
// g1 (10,3) → fodder (10,4, 2000 base ATK); g2 / g3 face empty tiles
const units = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'fod_a', row: 10, col: 4 }, { chessId: 'g2_a', row: 12, col: 3 }, { chessId: 'g3_a', row: 12, col: 5 }];
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, 0, null, [3, 5]) } });
h.step(1);
const g1 = h.unit('g1_a');
assert.deepEqual(buffOf(g1, 'bond:egir:devour')?.mods, { atkFinal: 2000, blockCnt: 1 }, 'the fodder\'s base ATK as a 最终加算, its block count');
close(g1.s.atk, 1000 + 2000, 1e-6, 'no percentage: as before');
h.b.addBuff(g1, { key: 'test:skill', mods: { atkPct: 1 } });
close(g1.s.atk, 1000 * 2 + 2000, 1e-6, '+100 %: 4000 (0.1.3: (1000 + 2000) × 2 = 6000)');
h.b.addBuff(g1, { key: 'test:flat', mods: { atkFlat: 100 } });
close(g1.s.atk, (1000 + 100) * 2 + 2000, 1e-6, 'a 直接加算 is still scaled by the percentages');
h.b.addBuff(g1, { key: 'test:weaken', mods: { atkMul: 0.5 } });
close(g1.s.atk, ((1000 + 100) * 2 + 2000) * 0.5, 1e-6, 'the 最终乘算 (Πmul) scales the whole, the 最终加算 included');
checkInvariants(h.b);
});
test('阿戈尔 devour: 物理流失 ignores the marker’s damage bonuses and the target’s shields; a fallen marker’s marks still resolve (GitHub #165 point 3); kill → marker', () => {
const list = [
['g1_a', ['egirShip', 'kjeragShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']],
['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000, def: 500 } }],
];
// g1 (10,3) → g2 (10,4, 3000×1.35 HP: dies to the first mark) → fodder (10,5); g3 elsewhere
const units = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }];
const h = makeBattle({
defs: defsOf(list), units, hooks: ['damaged', 'kill'], captureNoisy: true,
bonds: { egirShip: bondOn(3, 0, null, [3, 5]), kjeragShip: bondOn(3, 0, null, [3, 6]) }, // g1 deals ×1.25
setup: (b) => b.on('battleStart', () => b.addBuff(b.allyUnits.find((u) => u.defId === 'fod_a'), { key: 'test:shield', shield: 3000 }), { priority: 100 }),
});
h.step(1);
const [g1, g2, f] = ['g1_a', 'g2_a', 'fod_a'].map((id) => h.unit(id));
close(g1.s.dmgDealtMul, 1.25, 1e-9, '谢拉格 bonus present');
assert.ok(!g2.alive, 'g2 devoured');
assert.ok(h.hooksOf('kill').some((c) => c.victim === g2 && c.killer === g1), 'kill credited to the marker');
const dv = tagged(h, 'bond:egir:devour');
// PRTS 盟约记录: only the knocked-out TARGET's pending marks are cancelled — g2, knocked out by the first mark, still
// resolves its own (until 0.1.3 a marker off the field gave no further mark)
assert.deepEqual(dv.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']], 'g2’s own mark on the fodder resolves');
close(f.hp, 20000 - 2 * (5000 - 500), 1e-6, '5000 less its DEF 500, twice: no ×1.25, shield untouched');
assert.equal(f.buffs.find((b) => b.key === 'test:shield')?.shield, 3000);
close(g1.s.atk, 1000 + 1000 + 1000, 1e-6, 'the base ATK of everything it marked stays');
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'layers = tiers of the devoured units');
checkInvariants(h.b);
});
test('阿戈尔 devour, A → B → C with B devoured first (GitHub #165 point 3, PR #176): C still takes B\'s 5000 and the kill is B\'s', () => {
const list = [
['A_a', ['egirShip']], ['B_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']],
['C_a', ['preciShip'], { tier: 2, stats: { maxHp: 9000, def: 0 } }],
];
// A (10,3) → B (10,4, 3000 × 1.35 HP: knocked out by A's mark) → C (10,5, 9000 HP, DEF 0: A's mark leaves 4000, B's
// knocks it out); g3 faces an empty tile. Marks: A → B, A → C (through B), B → C.
const units = [{ chessId: 'A_a', row: 10, col: 3 }, { chessId: 'B_a', row: 10, col: 4 }, { chessId: 'C_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }];
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged', 'kill'], captureNoisy: true });
h.step(1);
const [A, B, C] = ['A_a', 'B_a', 'C_a'].map((id) => h.unit(id));
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [['A_a', 'B_a'], ['A_a', 'C_a'], ['B_a', 'C_a']]);
assert.ok(!B.alive && !C.alive, 'B falls to A\'s mark, C to B\'s (until 0.1.3 B\'s mark was dropped: C stood at 4000 HP)');
// PRTS 盟约记录 "单位被【吞噬】击杀时,击杀来源始终为对应标记的付与来源"
assert.deepEqual(h.hooksOf('kill').map((c) => [c.victim.defId, c.killer?.defId]), [['B_a', 'A_a'], ['C_a', 'B_a']], 'the kill credit is the mark\'s marker, down or not');
close(A.s.atk, 1000 + 1000 + 1000, 1e-6, 'A: the base ATK of B and C');
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'each devoured unit adds its tier once');
checkInvariants(h.b);
});
test('阿戈尔 devour order: left first, then top first (row 0 is the bottom: the top board row is 12)', () => {
const list = [['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['fod_a', ['preciShip']]];
const units = [
{ uid: 'low', chessId: 'g1_a', row: 9, col: 3 }, { uid: 'f_low', chessId: 'fod_a', row: 9, col: 4 },
{ uid: 'high', chessId: 'g2_a', row: 12, col: 3 }, { uid: 'f_high', chessId: 'fod_a', row: 12, col: 4 },
{ uid: 'right', chessId: 'g3_a', row: 11, col: 2 }, { uid: 'f_right', chessId: 'fod_a', row: 11, col: 3 },
];
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged'], captureNoisy: true });
h.step(1);
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => c.source.uid), ['right', 'high', 'low'], 'col 2 first, then row 12 before row 9');
checkInvariants(h.b);
});
test('阿戈尔 5: in the fight the 3 slots go to the first 3 members knocked out, wherever they stand; a later first knock-out stays down; a second knock-out is final; 3 members: none', () => {
const list = [];
for (let i = 0; i < 5; i++) list.push([`g${i}_a`, ['egirShip'], { skill: { spCost: 30, initSp: 5, duration: 10 } }]);
// one per row pair so nobody devours anybody: col 3 rows 9–12 (g0 bottom … g3 top), g4 at (9,5). PRTS 盟约记录 阿戈尔
// "前3名【阿戈尔】干员首次被击倒时立刻复活"; the owner's decision of 2026-10-05: the first 3 knocked out, in knock-out order
const units = list.map(([id], i) => ({ chessId: id, row: 9 + (i % 4), col: 3 + 2 * Math.floor(i / 4) }));
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['death', 'deploy'] });
h.step(1);
const us = list.map(([id]) => h.unit(id));
const kill = (u) => h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
const redeploys = (u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
us[0].skill.gainSp(20, 'test');
const tiles = us.map((u) => [u.tileR, u.tileC]);
// g4 (second column) and g0 (bottom of the first column) first — the last two by position — then g2
for (const i of [4, 0, 2]) {
const u = us[i];
kill(u);
assert.ok(u.alive && u.deployed, `member ${i} back on the field`);
close(u.hp, u.s.maxHp);
assert.deepEqual([u.tileR, u.tileC], tiles[i], 'same tile');
assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'killed'), 'it was knocked out (被击倒 triggers fire)');
assert.equal(redeploys(u), 1, `member ${i} redeployed`);
}
assert.equal(us[0].skill.sp, 5, 'a redeploy restarts from the initial SP');
// the 3 are gone: g3 (top-left, first by position) and g1 stay down on their first knock-out
for (const i of [3, 1]) {
kill(us[i]);
assert.ok(!us[i].alive, `member ${i}: no slot left`);
assert.equal(redeploys(us[i]), 0);
}
kill(us[4]);
assert.ok(!us[4].alive, 'a second knock-out is final');
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive').length, 3, 'three revives');
checkInvariants(h.b);
// a member moved off its board tile (突袭 / relocation) revives where it was knocked out; later redeploys go home
const hm = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: [] });
hm.step(1);
const m = hm.unit('g3_a');
const home = [m.homeR, m.homeC];
assert.ok(hm.b.relocate(m, 12, 8));
hm.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
assert.ok(m.alive && m.tileR === 12 && m.tileC === 8, 'revived on the tile it fell on');
assert.deepEqual([m.homeR, m.homeC], home, 'home unchanged');
checkInvariants(hm.b);
// 3 members: no revive
const h3 = makeBattle({ defs: defsOf(list), units: units.slice(1, 4), bonds: { egirShip: bondOn(3, 0, null, [3, 5]) } });
h3.step(1);
h3.b.dealDamage(null, h3.unit('g3_a'), { amount: 1e9, type: 'true' });
assert.ok(!h3.unit('g3_a').alive);
});
test('阿戈尔 5 + the devour chain (GitHub #33, #105, #140): the eaten members take the 3 slots (by position once the devour is over — here also the knock-out order), uneaten ones keep none — survivors = uneaten + 3 (normal and 联防)', () => {
// row 10, all facing right: 浊心斯卡蒂 → 幽灵鲨 → 海霓 → 深巡 → 歌蕾蒂娅 → 隐现; 乌尔比安 on row 12 faces an empty tile.
// PRTS 盟约记录 阿戈尔 备注 "标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果". Until 0.1.3
// the slots belonged to the first 3 by position (浊心斯卡蒂, 乌尔比安, 幽灵鲨: two uneaten members held them, so only 幽灵鲨
// of the food came back — "没被吃的阿戈尔干员也会占用复活名额", "无论食物链怎么吃最终都只有三名阿戈尔人存活")
const ids = ['chess_char_6_04_a', 'chess_char_2_07_a', 'chess_char_3_09_a', 'chess_char_1_04_a', 'chess_char_4_12_a', 'chess_char_1_01_a'];
const ULPIA = 'chess_char_5_05_a';
for (const kind of ['normal', 'unite']) {
const h = makeBattle({
kind, units: [...ids.map((chessId, i) => ({ chessId, row: 10, col: 2 + i })), { chessId: ULPIA, row: 12, col: 3 }],
bonds: { egirShip: bondOn(6, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
h.step(1);
const [skadi, ghost, hn, deep, glady, fodder] = ids.map((id) => h.unit(id));
const ulpia = h.unit(ULPIA);
// 浊心斯卡蒂 marks the whole row (through the members) and resolves first; every later mark is on a unit knocked out
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]),
[ghost, hn, deep, glady, fodder].map((t) => [skadi.defId, t.defId]), kind);
const kos = (u) => h.hooksOf('death').filter((c) => c.unit === u && c.reason === 'killed').length;
const revived = (u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
for (const u of [ghost, hn, deep, glady]) assert.equal(kos(u), 1, `${kind}: ${u.defId} knocked out once`);
for (const u of [ghost, hn, deep]) assert.equal(revived(u), 1, `${kind}: ${u.defId} (knocked out 1st–3rd, the first 3 by position) revived at t = 0`);
assert.equal(revived(glady), 0, `${kind}: 歌蕾蒂娅 (knocked out 4th, 4th by position) stays down`);
assert.equal(kos(skadi) + kos(ulpia) + revived(skadi) + revived(ulpia), 0, `${kind}: the uneaten members take no slot`);
h.run(3);
const members = [skadi, ghost, hn, deep, glady, ulpia];
assert.deepEqual(members.filter((u) => u.alive).map((u) => u.defId), [skadi, ghost, hn, deep, ulpia].map((u) => u.defId),
`${kind}: survivors = the 2 uneaten + 3`);
assert.ok(!fodder.alive, `${kind}: the fodder (no member) is down`);
// nothing is left for the fight: 浊心斯卡蒂's first knock-out and 幽灵鲨's second stay down
for (const u of [skadi, ghost]) {
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(!u.alive, `${kind}: ${u.defId} stays down`);
}
checkInvariants(h.b);
}
});
test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at most — its pending marks are cancelled once it is knocked out', () => {
// A (10,3) → B (10,4) → X (10,5) and C (11,5, facing DOWN) → X: three marks on X; D (12,8) faces an empty tile
const list = [['A_a', ['egirShip']], ['B_a', ['egirShip']], ['X_a', ['egirShip']], ['C_a', ['egirShip']], ['D_a', ['egirShip']]];
const defs = defsOf(list.map(([id, b]) => [id, b, { stats: { maxHp: 3000 } }]));
const units = [
{ chessId: 'A_a', row: 10, col: 3 }, { chessId: 'B_a', row: 10, col: 4 }, { chessId: 'X_a', row: 10, col: 5 },
{ chessId: 'C_a', row: 11, col: 5, dir: 'DOWN' }, { chessId: 'D_a', row: 12, col: 8 },
];
const h = makeBattle({ defs, units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60 });
h.step(1);
const [a, b, x, c, d] = ['A_a', 'B_a', 'X_a', 'C_a', 'D_a'].map((id) => h.unit(id));
const kos = (u) => h.hooksOf('death').filter((e) => e.unit === u && e.reason === 'killed').length;
const revived = (u) => h.hooksOf('deploy').filter((e) => e.unit === u && !e.initial).length;
// marked by A (through B), by B and by C; A's mark knocks it out, B's and C's are cancelled
assert.deepEqual(tagged(h, 'bond:egir:devour').map((e) => [e.source.defId, e.target.defId]), [['A_a', 'B_a'], ['A_a', 'X_a']]);
for (const u of [b, x]) assert.ok(u.alive && kos(u) === 1 && revived(u) === 1, `${u.defId}: one knock-out, one slot`);
close(x.hp, x.s.maxHp, 1e-6, 'X at full HP: no second mark');
// two slots used: D's first knock-out takes the third, C's stays down
h.b.dealDamage(null, d, { amount: 1e9, type: 'true' });
assert.ok(d.alive, 'D: the third slot');
h.b.dealDamage(null, c, { amount: 1e9, type: 'true' });
assert.ok(!c.alive, 'C: none left');
assert.ok(a.alive && kos(a) === 0, 'A never knocked out');
checkInvariants(h.b);
// X with more HP survives A's and B's marks and falls to C's: still one knock-out, one slot
const tough = defsOf(list.map(([id, bd]) => [id, bd, { stats: { maxHp: id === 'X_a' ? 8000 : 3000 } }]));
const t = makeBattle({ defs: tough, units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60 });
t.step(1);
const tx = t.unit('X_a');
assert.deepEqual(tagged(t, 'bond:egir:devour').map((e) => [e.source.defId, e.target.defId]), [['A_a', 'B_a'], ['A_a', 'X_a'], ['B_a', 'X_a'], ['C_a', 'X_a']]);
assert.equal(t.hooksOf('death').filter((e) => e.unit === tx && e.reason === 'killed').length, 1, 'X knocked out once, by the third mark');
assert.ok(tx.alive && t.eventsOf('fx').filter((e) => e[1] === 'revive').length === 2, 'B and X: two slots');
checkInvariants(t.b);
});
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
units: [
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
],
bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
});
h.step(1);
const sk = h.unit(SKADI);
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [[ULPIA, SKADI]]);
assert.ok(sk.alive && buffOf(sk, 'trait:skadi_tide'), 'saved by her module');
assert.equal(h.hooksOf('death').filter((c) => c.unit === sk).length, 0, 'not knocked out');
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4] && x[4].n).length, 0, 'no 阿戈尔 revive');
const kill = (id) => { const u = h.unit(id); h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u.alive; };
// all 3 slots are still there: 幽灵鲨, 海霓 and then 斯卡蒂 (DRE-Y spent in this deployment) come back, 深巡 does not
assert.deepEqual([GHOST, HN, SKADI, DEEP].map(kill), [true, true, true, false]);
checkInvariants(h.b);
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']]];
const units = (it = {}) => list.map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i, items: it[i] }));
const killed = (b, id) => { const u = b.unit(id); b.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u; };
// M3茧甲 (an item 复活 — since 0.2.0 PRTS's form: a knock-out answered by a free redeploy, items reviveNow): g0 is knocked
// out and back at once, the 3 slots go to g1, g2, g3; g4 stays down
const hi = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units({ 0: ['chess_item_4_12_e_a'] }), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hi.step(1);
const g0 = killed(hi, 'g0_a');
assert.ok(g0.alive && hi.hooksOf('death').filter((c) => c.unit === g0 && c.reason === 'killed').length === 1, 'item: knocked out and revived');
assert.equal(hi.hooksOf('death').find((c) => c.unit === g0).revivedBy, 'item');
assert.deepEqual(['g1_a', 'g2_a', 'g3_a', 'g4_a'].map((id) => killed(hi, id).alive), [true, true, true, false], 'item: three slots left after the revive');
checkInvariants(hi.b);
// 埃芒加德 (命结之秘: the battle's first 3 knock-downs revive at once — its own count, unchanged): g0, g1, g2 revived by it;
// the 阿戈尔 slots then go to g3, g4 and g0's next knock-out (its first unanswered one); g1's finds none
const hb = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units(), bandId: 'band_ermengard', bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hb.step(1);
for (const id of ['g0_a', 'g1_a', 'g2_a']) {
const u = killed(hb, id);
const deaths = hb.hooksOf('death').filter((c) => c.unit === u);
assert.ok(u.alive && deaths.length === 1 && deaths[0].revivedBy === 'band', `band: ${id} knocked out and revived by the band`);
}
assert.deepEqual(hb.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4] && x[4].left != null).map((x) => x[4].left), [2, 1, 0], '埃芒加德 spent its 3');
assert.deepEqual(['g3_a', 'g4_a', 'g0_a', 'g1_a'].map((id) => killed(hb, id).alive), [true, true, true, false], 'band: the 阿戈尔 slots are untouched');
checkInvariants(hb.b);
});
test('阿戈尔 5 slots in 联防 and on the boss field: a member entering down takes none until it is knocked out after standing up; a 调和 member takes one; a mirrored player counts its own knock-outs', () => {
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']], ['mani_a', ['maniShip']]];
// five members in five columns (one per column, nobody in front of anybody): g0 col 3 … g4 col 7
const units = list.slice(0, 5).map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i }));
const revives = (h, id) => {
const u = h.unit(id);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
return u.alive;
};
// 联防: g0 entered down (forced out before battleStart — not a 击倒, so no slot); g4 and g3 take two slots, g0's first
// knock-out after it stands back up takes the third, g1 finds none
const hu = makeBattle({
kind: 'unite', defs: defsOf(list), autoFinish: false, timeLimit: 60, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
units: units.map((u, i) => (i === 0 ? { ...u, carryState: { down: true } } : u)),
});
hu.step(1);
const g0 = hu.unit('g0_a');
assert.ok(!g0.alive, 'g0 forced out');
assert.deepEqual(['g4_a', 'g3_a'].map((id) => revives(hu, id)), [true, true]);
assert.ok(hu.runUntil(() => g0.alive && g0.deployed, 25), 'g0 stands back up inside the redeploy time');
assert.equal(revives(hu, 'g0_a'), true, 'g0: its first knock-out takes the third slot');
assert.equal(revives(hu, 'g1_a'), false, 'g1: none left');
checkInvariants(hu.b);
// 调和 (an active 调和 member enjoys the core bonds): knocked out first, it takes the first slot
const hm = makeBattle({
defs: defsOf(list), autoFinish: false, timeLimit: 60,
units: [{ chessId: 'mani_a', row: 11, col: 2 }, ...units.slice(0, 4)],
bonds: { egirShip: bondOn(5, 0, null, [3, 5]), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
});
hm.step(1);
assert.deepEqual(['mani_a', 'g3_a', 'g2_a', 'g0_a'].map((id) => revives(hm, id)), [true, true, true, false]);
// the right-hand boss player (mirrored): the order of its own knock-outs, wherever they stand
const hb = makeBattle({
kind: 'boss', defs: defsOf(list), autoFinish: false, timeLimit: 60,
players: [{ playerId: 'R1', side: 'R', colOffset: 8, units: units.map((u, i) => ({ ...u, uid: i + 1 })), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } }],
});
hb.step(1);
assert.deepEqual(['g4_a', 'g3_a', 'g0_a', 'g1_a', 'g2_a'].map((id) => [hb.unit(id).tileC, revives(hb, id)]),
[[13, true], [14, true], [17, true], [16, false], [15, false]]);
checkInvariants(hb.b);
});
test('阿戈尔 devour, a unit back during the pass: a knocked-out marker (revived by the 5-tier once the devour is over) still gives its marks; a target revived in place (埃芒加德) or redeployed (不屈) takes none', () => {
// g1 (10,3) → g2 (10,4, 3000 × 1.35 HP: knocked out by g1's mark, revived) → g3 (10,5) → fodder (10,6); g4 / g5 face empty tiles
const list = [
['g1_a', ['egirShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip'], { stats: { maxHp: 20000 } }],
['g4_a', ['egirShip']], ['g5_a', ['egirShip']], ['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000 } }],
];
const units = [
{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'g3_a', row: 10, col: 5 }, { chessId: 'fod_a', row: 10, col: 6 },
{ chessId: 'g4_a', row: 12, col: 3 }, { chessId: 'g5_a', row: 12, col: 5 },
];
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'deploy'], captureNoisy: true });
h.step(1);
const [g2, f] = ['g2_a', 'fod_a'].map((id) => h.unit(id));
assert.ok(g2.alive && h.hooksOf('deploy').some((c) => c.unit === g2 && !c.initial), 'g2 knocked out and revived');
// every mark resolves whatever became of its marker (DESIGN §24.7): g2, knocked out (back once the pass is over), still
// devours g3 and the fodder
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]),
[['g1_a', 'g2_a'], ['g1_a', 'g3_a'], ['g1_a', 'fod_a'], ['g2_a', 'g3_a'], ['g2_a', 'fod_a'], ['g3_a', 'fod_a']]);
close(f.hp, 20000 - 3 * 5000, 1e-6, 'the fodder: g1, g2 and g3');
close(g2.s.atk, 1000 + 1000 + 1000, 1e-6, 'g2 keeps the base ATK it gained at marking (a persistent buff) [ASSUMED]');
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 1 + 2, 'each devoured unit adds its tier once');
checkInvariants(h.b);
// 3 members, g1 → g2 → fodder (3000 HP): g1's mark knocks the fodder out, it is back at once, g2's mark on it is cancelled
const list2 = [
['g1_a', ['egirShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 20000 } }], ['g3_a', ['egirShip']],
['fod_a', ['indomShip'], { tier: 2, stats: { maxHp: 3000 } }],
];
const units2 = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }];
// 埃芒加德 (命结之秘: the first 3 knocked-out operators revive — knocked out, then redeployed at once where they lie,
// a new deployment: items reviveNow)
const e = makeBattle({ defs: defsOf(list2), units: units2, bandId: 'band_ermengard', bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged'], captureNoisy: true });
e.step(1);
const fe = e.unit('fod_a');
assert.deepEqual(tagged(e, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']]);
assert.ok(fe.alive, 'revived in place');
close(fe.hp, fe.s.maxHp, 1e-6, 'at full HP: no second mark');
assert.deepEqual(e.eventsOf('fx').filter((x) => x[1] === 'revive').map((x) => x[4].left), [2], 'one 埃芒加德 revive spent, not two');
checkInvariants(e.b);
// 不屈 at p = min(1, 0.18 + 0.004 × 300) = 1: 立刻重新部署 where it lies — a knock-out and a new deployment. Until 0.1.1
// g2's mark knocked the fodder out again within the same instant, where 不屈 does not fire twice, and it stayed down
const u = makeBattle({
defs: defsOf(list2), units: units2, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
bonds: { egirShip: bondOn(3, 0, null, [3, 5]), indomShip: bondOn(2, 300, 1, [2, 3]) },
});
u.step(1);
const fu = u.unit('fod_a');
assert.deepEqual(tagged(u, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']]);
assert.ok(fu.alive && u.hooksOf('deploy').some((c) => c.unit === fu && !c.initial), 'redeployed by 不屈');
assert.equal(u.hooksOf('death').filter((c) => c.unit === fu && c.reason === 'killed').length, 1, 'knocked out once');
checkInvariants(u.b);
});
// A community report the owner relayed on 2026-10-07 (DESIGN §25.22.5): 「原作里是第一步给所有鱼加盟约的血量,第二步是开始
// 一个个大鱼吃小鱼,第三步是检测左下到右上哪些鱼睡着了给他复活」.
test('阿戈尔 (community report, 2026-10-07) step 1: the bond HP is on every member before the devour — an elite 深巡 (2904 HP, DEF 655) in front of one 阿戈尔 stands from 15 layers on; step 2: one 5000 物理流失 per mark — over 5000 HP survives one mark, falls to a second', () => {
const ULPIA = 'chess_char_5_05_b', DEEP = 'chess_char_1_04_b', GHOST = 'chess_char_2_07_b', SKADI2 = 'chess_char_6_04_b';
const rest = [{ chessId: 'chess_char_4_09_b', row: 12, col: 3 }, { chessId: 'chess_char_3_09_b', row: 12, col: 5 }, { chessId: 'chess_char_4_12_b', row: 12, col: 7 }];
const fight = (units, layers) => {
const pre = new Map(), post = new Map();
const look = (b, into) => () => { for (const u of b.allyUnits) into.set(u.defId, { hp: u.hp, maxHp: u.s.maxHp, def: u.s.def }); };
const h = makeBattle({
units: [...units, ...rest], autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'kill'], captureNoisy: true,
bonds: { egirShip: bondOn(5, layers, null, [3, 5]) },
// before (priority 100) and after (−100) the devour, which runs at the default 0
setup: (b) => { b.on('battleStart', look(b, pre), { priority: 100 }); b.on('battleStart', look(b, post), { priority: -100 }); },
});
h.step(1);
return { h, pre, post };
};
// the player's official game: 「深巡是精二的,休整期的原始血量只有不到三千,所以肯定是部署后先经过盟约加血才活了下来」
for (const [L, stands] of [[14, false], [15, true]]) {
const { h, pre, post } = fight([{ chessId: ULPIA, row: 10, col: 3 }, { chessId: DEEP, row: 10, col: 4 }], L);
const p = pre.get(DEEP);
close(p.maxHp, 2904 * (1 + 0.35 + 0.01 * L), 1e-6, `L ${L}: the bond's +(35 + L) % is there before the devour`);
close(p.hp, p.maxHp, 1e-6, `L ${L}: deployed at full HP`);
assert.equal(p.def, 655);
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId, c.amount]), [[ULPIA, DEEP, 5000 - 655]]);
assert.equal(h.hooksOf('death').filter((c) => c.unit === h.unit(DEEP) && c.reason === 'killed').length, stands ? 0 : 1, `L ${L}`);
if (stands) close(post.get(DEEP).hp, 2904 * 1.5 - 4345, 1e-6, 'L 15: 4356 − 4345 = 11 HP left');
}
// an elite 幽灵鲨 at 62 layers: 2422 × (1 + 0.35 + 0.62 + 0.12 her talent at full potential) = 5061.98 HP, DEF 365 — one
// mark (4635) leaves 426.98; behind 浊心斯卡蒂 and 乌尔比安 she takes both marks and falls to the second (the kill is 乌尔比安's)
const one = fight([{ chessId: ULPIA, row: 10, col: 3 }, { chessId: GHOST, row: 10, col: 4 }], 62);
close(one.pre.get(GHOST).maxHp, 2422 * 2.09, 1e-6, 'over 5000 HP');
close(one.post.get(GHOST).hp, 2422 * 2.09 - (5000 - 365), 1e-6, 'one mark: she stands');
assert.equal(one.h.hooksOf('death').length, 0);
const two = fight([{ chessId: SKADI2, row: 10, col: 3 }, { chessId: ULPIA, row: 10, col: 4 }, { chessId: GHOST, row: 10, col: 5 }], 62);
assert.deepEqual(tagged(two.h, 'bond:egir:devour').filter((c) => c.target.defId === GHOST).map((c) => [c.source.defId, c.amount]),
[[SKADI2, 4635], [ULPIA, 4635]], 'marked by each 阿戈尔 behind her (PRTS 盟约记录 "每个标记令目标受到一次5000点物理流失")');
assert.deepEqual(two.h.hooksOf('kill').filter((c) => c.victim.defId === GHOST).map((c) => c.killer?.defId), [ULPIA], 'the second mark knocks her out');
checkInvariants(two.h.b);
});
/**
* Nine members, all facing RIGHT unless noted (row 12 is the top of the board, row 9 the bottom). The four food F1 (12,4),
* F2 (11,4), F3 (10,4) and F4 (9,5) face LEFT, back at their marker (which marked them), so they mark nothing; B1 (12,3)
* → F1, B2 (11,3) → F2, B3 (10,3) → F3, B4 (9,4) → F4, and B5 (10,5) facing LEFT → F3 and, through F3, B3. Marks in
* order (left first, then top first): B1→F1, B2→F2, B3→F3, B4→F4, B5→F3, B5→B3. HP ×1.35: F1 / F2 / F4 4050 fall to
* one mark, F3 6750 to its second (B5's), the B's 13500 stand. Knocked out: F1, F2, F4, F3 — in that order.
*/
function egirFood({ downB4 = false } = {}) {
const list = [
...['B1_a', 'B2_a', 'B3_a', 'B4_a', 'B5_a'].map((id) => [id, ['egirShip']]),
['F1_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['F2_a', ['egirShip'], { stats: { maxHp: 3000 } }],
['F3_a', ['egirShip'], { stats: { maxHp: 5000 } }], ['F4_a', ['egirShip'], { stats: { maxHp: 3000 } }],
];
const units = [
{ uid: 11, chessId: 'B1_a', row: 12, col: 3 }, { uid: 1, chessId: 'F1_a', row: 12, col: 4, dir: 'LEFT' },
{ uid: 12, chessId: 'B2_a', row: 11, col: 3 }, { uid: 2, chessId: 'F2_a', row: 11, col: 4, dir: 'LEFT' },
{ uid: 13, chessId: 'B3_a', row: 10, col: 3 }, { uid: 3, chessId: 'F3_a', row: 10, col: 4, dir: 'LEFT' },
{ uid: 14, chessId: 'B4_a', row: 9, col: 4, ...(downB4 ? { carryState: { down: true } } : {}) }, { uid: 4, chessId: 'F4_a', row: 9, col: 5, dir: 'LEFT' },
{ uid: 15, chessId: 'B5_a', row: 10, col: 5, dir: 'LEFT' },
];
return { defs: defsOf(list), units };
}
test('阿戈尔 5 (community report, 2026-10-07) step 3: the battle-start devour\'s knock-outs revive once every devour is over, by position from the bottom-left (columns from the left, bottom first) — not in knock-out order; normal, 联防, the mirrored boss player', () => {
for (const kind of ['normal', 'unite', 'bossR']) {
const { defs, units } = egirFood({ downB4: kind === 'unite' });
const seq = [];
const setup = (b) => {
b.on('damaged', (c) => { if (c.dmg?.tags?.includes('bond:egir:devour')) seq.push(`mark ${c.source.uid}→${c.target.uid}`); }, { priority: -1000 });
b.on('deploy', (c) => { if (!c.initial) seq.push(`revive ${c.unit.uid}`); }, { priority: -1000 });
};
const bonds = { egirShip: bondOn(5, 0, null, [3, 5]) };
const opts = { defs, autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], setup };
const h = kind === 'bossR'
? makeBattle({ ...opts, kind: 'boss', players: [{ playerId: 'R1', side: 'R', colOffset: 8, units, bonds }] })
: makeBattle({ ...opts, kind, units, bonds });
h.step(1);
const uid = (u) => u.uid;
const kos = h.hooksOf('death').filter((c) => c.reason === 'killed').map((c) => uid(c.unit));
assert.deepEqual(kos, [1, 2, 4, 3], `${kind}: knocked out F1, F2, F4, F3`);
// every mark resolves first, then the revives — F3 (10,4), F2 (11,4), F1 (12,4) of the left column, bottom first; F4
// (9,5), in the next column, finds no slot. Until 0.2.0 (knock-out order, DESIGN §24.3): F1, F2, F4 at once, F3 down.
// The other reading of 「左下到右上」 (rows from the bottom) would revive F4, F3, F2 and leave F1 down.
assert.deepEqual(seq, ['mark 11→1', 'mark 12→2', 'mark 13→3', 'mark 14→4', 'mark 15→3', 'mark 15→13', 'revive 3', 'revive 2', 'revive 1'], kind);
const by = (id) => h.b.allyUnits.find((u) => u.uid === id);
assert.deepEqual([1, 2, 3, 4].map((id) => by(id).alive), [true, true, true, false], `${kind}: F4 stays down`);
assert.ok(by(13).alive && !kos.includes(13), `${kind}: B3, marked but standing, takes no slot`);
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4]?.src === 'bond:egirShip').length, 3, `${kind}: the 3 slots`);
if (kind === 'unite') assert.ok(!by(14).alive && by(14).removeReason === FORCED_EXIT, '联防: B4 entered down — its mark resolved, it stays forced out');
// the slots are gone: B1's first knock-out in the fight stays down, F4 stays down
h.b.dealDamage(null, by(11), { amount: 1e9, type: 'true' });
assert.ok(!by(11).alive && !by(4).alive, `${kind}: no slot left`);
checkInvariants(h.b);
}
});
test('阿戈尔 5 (community report, 2026-10-07): the slots the devour leaves go to later knock-outs in time order; an uneaten member takes one only by being knocked out; a member 不屈 redeploys during the devour is awake at the scan and takes none [ASSUMED]', () => {
const { defs } = egirFood();
// only F1 and F2 are eaten: B1 → F1, B2 → F2; B3 (10,3), B4 (9,4), B5 (9,6) face empty tiles
const units = [
{ uid: 11, chessId: 'B1_a', row: 12, col: 3 }, { uid: 1, chessId: 'F1_a', row: 12, col: 4, dir: 'LEFT' },
{ uid: 12, chessId: 'B2_a', row: 11, col: 3 }, { uid: 2, chessId: 'F2_a', row: 11, col: 4, dir: 'LEFT' },
{ uid: 13, chessId: 'B3_a', row: 10, col: 3, dir: 'DOWN' }, { uid: 14, chessId: 'B4_a', row: 9, col: 5 }, { uid: 15, chessId: 'B5_a', row: 9, col: 7 },
];
const run = (extra = {}) => {
const h = makeBattle({ defs, units, autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], bonds: { egirShip: bondOn(5, 0, null, [3, 5]), ...extra } });
h.step(1);
const by = (id) => h.b.allyUnits.find((u) => u.uid === id);
const kill = (id) => { h.b.dealDamage(null, by(id), { amount: 1e9, type: 'true' }); return by(id).alive; };
const egirRevives = () => h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4]?.src === 'bond:egirShip').map((x) => x[4].n);
return { h, by, kill, egirRevives };
};
const a = run();
assert.deepEqual(a.h.hooksOf('deploy').filter((c) => !c.initial).map((c) => c.unit.uid), [2, 1], 'F2 (11,4) before F1 (12,4): bottom first');
assert.deepEqual(a.egirRevives(), [1, 2]);
// the third slot: the first member knocked out in the fight — B5, never eaten; then none (B3), and F1's second knock-out
assert.deepEqual([15, 13, 1].map(a.kill), [true, false, false], 'B5 takes the last slot; B3 and F1 (a second knock-out) stay down');
checkInvariants(a.h.b);
// 不屈 at p = 1 (0.18 + 0.004 × 300): it rolls on each devour knock-out (a 被击倒 like any other) and redeploys F1 and F2 at
// once; awake at the scan, they take no slot — all 3 are left for the fight, where 阿戈尔 (death priority 11) comes first.
// Until 0.2.0 the 阿戈尔 revive answered both knock-outs at once (2 slots) and 不屈 never rolled.
const b = run({ indomShip: bondOn(2, 300, 1, [2, 3]) });
assert.ok(b.by(1).alive && b.by(2).alive, 'F1 and F2 stand again');
assert.equal(b.h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4]?.src === 'bond:indomShip').length, 2, 'by 不屈');
assert.deepEqual(b.egirRevives(), [], 'no 阿戈尔 slot at the battle start');
assert.deepEqual([15, 13, 14].map(b.kill), [true, true, true]);
assert.deepEqual(b.egirRevives(), [1, 2, 3], 'the 3 slots, in the fight');
checkInvariants(b.h.b);
});
// ---------------------------------------------------------------------------------------------------------------------
// 叙拉古
test('叙拉古: after each deployment ASPD +(25+0.8L) for 32+0.4L s; 3 members: no stealth', () => {
const list = [['r0_a', ['siracusaShip']], ['r1_a', ['siracusaShip']], ['r2_a', ['siracusaShip']], ['none_a', ['preciShip']]];
const L = 10;
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { siracusaShip: bondOn(3, L, null, [3, 6]) } });
h.step(1);
const a = h.unit('r0_a');
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
assert.equal(h.unit('none_a').s.aspd, 100);
assert.ok(!a.s.flags.stealth);
h.run(32 + 0.4 * L - h.b.time - 0.1);
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
h.run(0.3);
assert.equal(a.s.aspd, 100);
// redeploy → again
h.b.retreat(a);
a.respawnAt = 0;
h.b.redeploy(a);
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
checkInvariants(h.b);
});
test('叙拉古 6: 隐匿 for the same time; attacks while hidden / ≤ 10 s after proc (PRD) 5000+50L true damage + fear 3 s', () => {
close(prdConstant(0.03), 0.00139, 2e-5);
const list = [];
for (let i = 0; i < 6; i++) list.push([`r${i}_a`, ['siracusaShip']]);
const L = 20;
const h = makeBattle({
defs: defsOf(list), units: lineup(list.map((x) => x[0])), enemies: [{ key: 'e_dummy', pos: [12, 9] }],
bonds: { siracusaShip: bondOn(6, L, null, [3, 6]) }, autoFinish: false, timeLimit: 300, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
});
h.step(1);
const a = h.unit('r0_a');
const e = h.enemies()[0];
assert.ok(a.s.flags.stealth, 'stealthed after deploy');
const attackN = (n) => { for (let i = 0; i < n; i++) h.b.dealDamage(a, e, { amount: 1, type: 'phys', isAttack: true }); };
attackN(750); // PRD guarantees a proc within ⌈1/C⌉ attempts
const procs = tagged(h, 'bond:siracusa');
assert.ok(procs.length >= 1 && procs.length < 60, `procs ${procs.length}`);
for (const p of procs) close(p.amount, 5000 + 50 * L);
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'fear' && c.target === e && Math.abs(c.duration - 3) < 1e-9));
const dur = 32 + 0.4 * L;
h.run(dur + 5 - h.b.time);
assert.ok(!a.s.flags.stealth, 'stealth over');
const before = tagged(h, 'bond:siracusa').length;
attackN(750);
assert.ok(tagged(h, 'bond:siracusa').length > before, 'still procs within 10 s after stealth');
h.run(6);
const after = tagged(h, 'bond:siracusa').length;
attackN(750);
assert.equal(tagged(h, 'bond:siracusa').length, after, 'no proc after the 10 s window');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------------
// 卡西米尔
test('卡西米尔: every own deployment (initial included) → members ATK +20 %, capped at 50+1×L %', () => {
const list = [['z0_a', ['kazimierzShip']], ['z1_a', ['kazimierzShip']], ['z2_a', ['kazimierzShip']], ['none_a', ['preciShip']], ['none2_a', ['preciShip']]];
for (const [L, expect] of [[20, 0.7], [100, 1.0]]) {
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { kazimierzShip: bondOn(3, L, null, [3, 6]) } });
h.step(1);
close(h.unit('z0_a').s.atk, 1000 * (1 + expect), 1e-6, `L=${L}`);
close(h.unit('none_a').s.atk, 1000);
if (L === 100) {
const x = h.unit('none_a');
h.b.retreat(x);
h.b.redeploy(x);
close(h.unit('z1_a').s.atk, 1000 * 2.2, 1e-6, 'a redeploy adds +20 %');
}
checkInvariants(h.b);
}
});
test('卡西米尔 6: blocking members pulse 120 % ATK true damage + 0.1 s stun every 2 s; non-blocking attacks add 30 % ATK true', () => {
const list = [];
for (let i = 0; i < 6; i++) list.push([`z${i}_a`, ['kazimierzShip']]);
const walker = enemyRec({ key: 'e_walk', hp: 1e8, speed: 1, atk: 0 });
const defs = defsOf(list);
defs.enemies.e_walk = walker;
// z0 on the walking lane (row 9); the others on row 12 behind the lane's end
const units = list.map(([id], i) => ({ chessId: id, row: i === 0 ? 9 : 12, col: i === 0 ? 5 : 2 + i }));
const h = makeBattle({
defs, units, enemies: [{ key: 'e_walk', route: 0 }], bonds: { kazimierzShip: bondOn(6, 0, null, [3, 6]) },
autoFinish: false, timeLimit: 120, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
});
const z0 = h.unit('z0_a'), z1 = h.unit('z1_a');
assert.ok(h.runUntil(() => z0.blocking.length > 0, 30), 'blocked');
const t0 = h.b.time;
h.run(4.1);
const pulses = tagged(h, 'bond:kazimierz').filter((c) => c.source === z0 && c.dmg.isSplash && c.t >= t0);
assert.ok(pulses.length >= 1 && pulses.length <= 3, `pulses ${pulses.length}`);
close(pulses[0].amount, 1.2 * z0.s.atk, 1e-6);
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'stun' && c.source === z0 && Math.abs(c.duration - 0.1) < 1e-9));
const onHitExtra = tagged(h, 'bond:kazimierz').filter((c) => c.source === z0 && !c.dmg.isSplash && c.t >= t0);
assert.equal(onHitExtra.length, 0, 'no on-hit true damage while blocking');
// z1 does not block: its attacks add 30 % ATK true damage
const e = h.enemies()[0];
const n0 = tagged(h, 'bond:kazimierz').filter((c) => c.source === z1).length;
h.b.dealDamage(z1, e, { amount: 100, type: 'phys', isAttack: true });
const extra = tagged(h, 'bond:kazimierz').filter((c) => c.source === z1);
assert.equal(extra.length, n0 + 1);
close(extra[extra.length - 1].amount, 0.3 * z1.s.atk, 1e-6);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------------
// cross-cutting
test('inactive core bonds do nothing (layers alone are not enough)', () => {
const bonds = ['yanShip', 'sargonShip', 'victoriaShip', 'kjeragShip', 'lateranoShip', 'egirShip', 'siracusaShip', 'kazimierzShip'];
const list = [['all_a', bonds], ['all2_a', bonds]];
const h = makeBattle({
defs: defsOf(list), units: [{ chessId: 'all_a', row: 10, col: 3, items: ['test_item_a'] }, { chessId: 'all2_a', row: 10, col: 4 }],
bonds: Object.fromEntries(bonds.map((b) => [b, { count: 2, active: false, tier: 0, layers: 50 }])),
hooks: ['damaged'], captureNoisy: true,
});
h.step(1);
const u = h.unit('all_a');
assert.deepEqual(u.buffs.filter((b) => b.key.startsWith('bond:')).map((b) => b.key), []);
close(u.s.atk, 1000);
assert.equal(tagged(h, 'bond:egir:devour').length, 0);
assert.ok(!reached(h.b, 'p1', 'yanShip', 0));
checkInvariants(h.b);
});
test('变形同构体 makes the wearer a member; 6-tier thresholds also read the tier index', () => {
const list = [['y0_a', ['yanShip']], ['y1_a', ['yanShip']], ['x_a', ['preciShip']]];
const h = makeBattle({
defs: defsOf(list),
units: [{ chessId: 'y0_a', row: 10, col: 3 }, { chessId: 'y1_a', row: 10, col: 4 }, { chessId: 'x_a', row: 10, col: 5, items: ['chess_item_6_09_e_a', 'chess_item_3_04_e_a'] }],
bonds: { yanShip: { active: true, tier: 1, layers: 0 } },
});
h.step(1);
assert.ok(buffOf(h.unit('x_a'), 'bond:yan'), 'granted member');
assert.ok(reached(h.b, 'p1', 'yanShip', 3) && !reached(h.b, 'p1', 'yanShip', 6));
checkInvariants(h.b);
});
test('boss field, two players: bonds only touch their owner’s operators; devour layers are disabled there', () => {
const list = [['g0_a', ['egirShip', 'yanShip']], ['g1_a', ['egirShip', 'yanShip']], ['g2_a', ['egirShip', 'yanShip']], ['fod_a', ['preciShip'], { tier: 3 }]];
const lineupFor = (pid) => [
{ uid: `${pid}1`, chessId: 'g0_a', row: 10, col: 3 }, { uid: `${pid}2`, chessId: 'g1_a', row: 10, col: 4 },
{ uid: `${pid}3`, chessId: 'g2_a', row: 11, col: 3 }, { uid: `${pid}4`, chessId: 'fod_a', row: 11, col: 4 },
];
const on = { yanShip: bondOn(3, 0), egirShip: bondOn(3, 0, null, [3, 5]) };
const h = makeBattle({
kind: 'boss', defs: defsOf(list), hooks: ['damaged', 'layerGain'], captureNoisy: true,
players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: lineupFor('p1'), bonds: on, bandId: null, playerEffects: [] },
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: lineupFor('p2'), bonds: {}, bandId: null, playerEffects: [] },
],
});
h.step(1);
const mine = h.unit('p12'), theirs = h.unit('p22'); // g1: devoured by g0, devours nothing itself
close(mine.s.atk, 1000 * 1.23, 1e-6);
close(theirs.s.atk, 1000);
close(h.unit('p23').s.maxHp, 10000);
const dv = tagged(h, 'bond:egir:devour');
assert.ok(dv.length >= 1 && dv.every((c) => c.source.ownerId === 'p1' && c.target.ownerId === 'p1'));
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 0, 'no IN_BATTLE layers in a boss field');
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors));
checkInvariants(h.b);
});