// Add-on bonds (server/sim/content/bonds/addon.js): battle effects of 精准 迅捷 灵巧 奥术 坚守 助力 突袭 不屈 协防干员 独行 // 绝技 and the prep side of 助力 远见 奇迹 投资人 调和 (numbers from data/bonds.json, research 02 §3.9–§3.23). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeBattle, chessRec, enemyRec, checkInvariants, flatStage } from '../helpers/battleHarness.js'; import { gainLayers, inRange } from '../../server/sim/content/support/index.js'; import { bondBb, procChance } from '../../server/sim/content/bonds/addon/battle.js'; import { registerMeta } from '../../server/sim/content/bonds/addon.js'; import { createRegistry } from '../../server/match/effectsMeta.js'; import { makeMatch, give, legalTileFor } from '../match/harness.js'; const close = (a, b, msg, eps = 1e-6) => 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 }); const ranged = (id, bonds, extra = {}) => chessRec({ id, bonds, profession: 'SNIPER', skill: null, rangeGrid: [[0, 0]], ...extra }); const bond = (tier, layers = 0, count = 2) => ({ count, active: tier > 0, tier, layers }); const DUMMY = { enemy_addon_dummy: enemyRec({ key: 'enemy_addon_dummy', hp: 1e6, speed: 0 }) }; const hasBondBuff = (u) => u.buffs.some((b) => String(b.key).startsWith('bond:')); // --------------------------------------------------------------------------------------------------------------------- // battle side test('精准: members ATK ×(1+0.10+0.012·L), live layers; tier 2 widens to ranged operators with 30% DEF/RES ignore', () => { const bb = bondBb('preciShip'); const defs = { chess: { p_m: op('p_m', ['preciShip']), p_r: ranged('p_r', []), p_x: op('p_x', []) } }; const units = [{ chessId: 'p_m', row: 10, col: 3 }, { chessId: 'p_r', row: 11, col: 3 }, { chessId: 'p_x', row: 12, col: 3 }]; const h = makeBattle({ defs, units, bonds: { preciShip: bond(1, 10) } }); h.step(); close(h.unit('p_m').s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 10), 'member'); close(h.unit('p_r').s.atk, 500, 'ranged non-member (tier 1)'); close(h.unit('p_m').s.defIgnorePct, 0); gainLayers(h.b, { playerId: 'p1', bonds: 'preciShip', n: 15 }); h.step(2); close(h.unit('p_m').s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 25), 'live layers'); checkInvariants(h.b); const h2 = makeBattle({ defs, units, bonds: { preciShip: bond(2, 0, 3) } }); h2.step(); const r = h2.unit('p_r'); close(r.s.atk, 500 * (1 + bb.base_atk), 'ranged op in scope'); close(r.s.defIgnorePct, bb.power_def_penetrate); close(r.s.resIgnorePct, bb.power_magic_resist_penetrate); close(h2.unit('p_x').s.atk, 500, 'melee non-member'); checkInvariants(h2.b); }); test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operator +15 extra; inactive → nothing', () => { const bb = bondBb('swiftShip'); const sk = { spCost: 30, initSp: 30, trigger: { rule: 'SP_FULL' } }; const defs = { chess: { s_m: chessRec({ id: 's_m', bonds: ['swiftShip'], skill: sk }), s_x: chessRec({ id: 's_x', bonds: [], skill: sk }), } }; const kits = { s_m: () => ({ skill: { kind: 'instant', trigger: 'SP_FULL' } }), s_x: () => ({ skill: { kind: 'instant', trigger: 'SP_FULL' } }) }; const run = (b) => { const seen = {}; const h = makeBattle({ defs, kits, bonds: { swiftShip: b }, units: [{ chessId: 's_m', row: 10, col: 3 }, { chessId: 's_x', row: 12, col: 3 }], setup: (bt) => bt.on('skillEnd', (c) => { seen[c.unit.defId] ??= c.unit.skill.sp; }, { priority: -1000 }), }); h.run(0.2); checkInvariants(h.b); return seen; }; const hi = run(bond(1, 1000)); close(hi.s_m, bb.normal_sp + bb.power_sp, 'member rolls both'); close(hi.s_x, bb.power_sp, 'non-member: 40-layer extra only'); const low = run(bond(1, 39)); assert.ok(low.s_x === 0, 'no 40-layer effect below the milestone'); const off = run(bond(0, 1000)); assert.equal(off.s_m, 0); assert.equal(off.s_x, 0); }); test('迅捷 refills 引星棘刺 S1 at once: it is cast at most once per attack interval, and her attacks go on untouched (GitHub #298)', () => { // S1 度算浪波: AUTO, no duration, no attack of its own, SP_FULL, cost 6; 迅捷 refunds 12 + 15 SP at ≥ 40 layers const run = ({ aspd = 0, enemy = true, silence = false } = {}) => { const h = makeBattle({ seed: 1, bonds: { swiftShip: bond(2, 229, 3) }, autoFinish: false, timeLimit: 30, units: [{ chessId: 'chess_char_5_15_b', skillIndex: 0, row: 10, col: 4 }], enemies: enemy ? [{ key: 'enemy_still', route: { motion: 'WALK', start: [10, 6], end: [10, 6], checkpoints: [] } }] : [], defs: { enemies: { enemy_still: enemyRec({ key: 'enemy_still', hp: 1e9, atk: 0, speed: 0, blockCnt: 0 }) } }, }); h.step(); const u = h.unit('chess_char_5_15_b'); if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } }); if (silence) h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } }); if (enemy) assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 'an enemy stays in range'); u.skill.gainSp(u.skill.spCost, 'test'); const n0 = u.skill.activations, a0 = u.stats.attacks; h.run(6); checkInvariants(h.b); return { n: u.skill.activations - n0, attacks: u.stats.attacks - a0, interval: u.s.interval }; }; const base = run(), muted = run({ silence: true }); // the first at once, then one per interval (1.36 s): 5 in 6 s — every tick before (180) assert.ok(base.n >= 4 && base.n <= 5, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 6 s`); assert.ok(base.attacks > 0 && base.attacks === muted.attacks, `the casts take no attack (${base.attacks} vs ${muted.attacks} silenced)`); const fast = run({ aspd: 500 }), fastMuted = run({ aspd: 500, silence: true }); assert.ok(fast.n >= 23 && fast.n <= 25, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`); assert.equal(fast.attacks, fastMuted.attacks, 'still no attack taken'); const alone = run({ enemy: false }); assert.ok(alone.n >= 4 && alone.n <= 5 && alone.attacks === 0, `no enemy: still fires at full SP (#124), one per interval — ${alone.n}`); }); test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => { const h = makeBattle({ defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } }, units: [{ chessId: 't_deploy', row: 10, col: 4 }], bonds: { swiftShip: bond(1, 1000), raidShip: bond(1) }, captureNoisy: true, kits: { t_deploy: () => ({ skill: { kind: 'duration', activateOnDeploy: true, duration: 2, spCost: 0, spType: 'none', trigger: 'NEVER', } }) }, }); h.b.start(); const u = h.unit('t_deploy'), seq = u.deploySeq; h.run(5); assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']); assert.ok(h.hooksOf('spGain').some((c) => c.unit === u), 'swift end handler attempted SP gain'); assert.equal(u.skill.sp, 0); assert.equal(u.skill.charges, 0); assert.equal(u.skill.ready, false); assert.equal(u.skill.activations, 1); assert.equal(u.deploySeq, seq, 'skill end alone does not meet raid readiness'); checkInvariants(h.b); }); test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => { const sw = bondBb('swiftShip'), ind = bondBb('indomShip'); close(procChance(sw, 0), 0.20); close(procChance(sw, 40), 0.20 + 0.0035 * 40); close(procChance(sw, 228), 0.20 + 0.0035 * 228); close(procChance(sw, 229), 1, 'swift reaches 100 % at 229'); close(procChance(ind, 0), 0.18); close(procChance(ind, 50), 0.18 + 0.004 * 50); close(procChance(ind, 205), 1, 'indom reaches 100 % at 205'); close(procChance(ind, 1000), 1, 'capped'); }); test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, survives death and follows relocation', () => { const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } }; const h = makeBattle({ defs, bonds: { skillfulShip: bond(1, 5) }, units: [ { chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 3 }, { chessId: 'k_n', row: 10, col: 5 }, { chessId: 'k_d', row: 11, col: 5 }, { chessId: 'k_f', row: 12, col: 9 }, ], }); h.step(2); const a = (id) => h.unit(id).s.aspd; assert.equal(a('k_1'), 115, 'member, two auras → once'); assert.equal(a('k_n'), 115, '4-neighbour'); assert.equal(a('k_d'), 100, 'diagonal outside the 4 tiles'); assert.equal(a('k_f'), 100); gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 35 }); h.step(2); assert.equal(a('k_d'), 150, '8 tiles at 40 layers'); assert.equal(a('k_n'), 150); assert.ok(h.b.relocate(h.unit('k_n'), 12, 7)); h.run(0.4); assert.equal(a('k_n'), 100, 'relocated out of the aura'); h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' }); h.step(2); assert.equal(a('k_d'), 150, 'downed aura source still buffs neighbours'); checkInvariants(h.b); }); for (const layers of [5, 40]) { test(`灵巧: downed sources keep one aura at ${layers} layers, update live layers and restore it on redeploy`, () => { const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), } }; const h = makeBattle({ defs, bonds: { skillfulShip: bond(1, layers) }, units: [ { chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 6 }, { chessId: 'k_n', row: 10, col: 5 }, { chessId: 'k_d', row: 11, col: 5 }, ], }); h.step(); const first = h.unit('k_1'), second = h.unit('k_2'), neighbour = h.unit('k_n'), diagonal = h.unit('k_d'); assert.equal(neighbour.s.aspd, 110 + layers, 'overlapping sources buff once'); h.b.kill(first); assert.ok(h.b.isDown(first)); assert.equal(neighbour.s.aspd, 110 + layers, 'no attack speed drop on death'); h.b.kill(second); assert.ok(h.b.isDown(second)); h.run(1); assert.equal(neighbour.s.aspd, 110 + layers, 'all sources down: polling keeps the aura'); assert.equal(diagonal.s.aspd, layers >= 40 ? 110 + layers : 100, '4 / 8 tile range still applies'); assert.equal(neighbour.buffs.filter((b) => b.key === 'bond:skillfulShip').length, 1); gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 40 - layers + 2 }); h.step(2); assert.equal(neighbour.s.aspd, 152, 'downed sources use live layer values'); assert.equal(diagonal.s.aspd, 152, 'downed sources widen their aura at 40 layers'); h.b.kill(neighbour); assert.ok(h.b.redeploy(neighbour, { free: true })); assert.equal(neighbour.s.aspd, 152, 'recipient redeployed next to a downed source'); assert.ok(h.b.redeploy(first, { free: true })); assert.equal(first.s.aspd, 152, 'source regains its own bonus on redeploy'); assert.equal(neighbour.s.aspd, 152, 'redeploy does not stack the aura'); h.b.retreat(first); assert.equal(neighbour.s.aspd, 152, 'remaining downed source still covers the recipient'); assert.ok(h.b.redeploy(second, { free: true })); h.b.retreat(second); assert.equal(neighbour.s.aspd, 100, 'withdrawn sources no longer provide an aura'); checkInvariants(h.b); }); } test('灵巧: a downed source provides its aura around the body tile when it returns home after death', () => { const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_home: op('k_home', []), k_away: op('k_away', []), k_tile: op('k_tile', []), } }; const h = makeBattle({ defs, bonds: { skillfulShip: bond(1, 5) }, units: [ { chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 12, col: 3 }, { chessId: 'k_home', row: 10, col: 5 }, { chessId: 'k_away', row: 10, col: 8 }, { chessId: 'k_tile', row: 10, col: 7 }, ], }); h.step(); h.b.retreat(h.unit('k_tile'), { permanent: true }); assert.ok(h.b.relocate(h.unit('k_1'), 10, 7)); h.run(0.4); assert.equal(h.unit('k_home').s.aspd, 100); assert.equal(h.unit('k_away').s.aspd, 115); h.b.kill(h.unit('k_1')); assert.deepEqual(h.unit('k_1').body, [10, 4]); assert.equal(h.unit('k_home').s.aspd, 115, 'aura follows the downed body back home'); assert.equal(h.unit('k_away').s.aspd, 100, 'last living tile no longer provides an aura'); checkInvariants(h.b); }); for (const reason of ['retreat', 'forcedExit', 'merchant']) { test(`灵巧: ${reason} leaves a down model without preserving the killed-source aura`, () => { const h = makeBattle({ defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } }, bonds: { skillfulShip: bond(1, 5) }, units: [{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_n', row: 10, col: 5 }], }); h.step(); const source = h.unit('k_1'), recipient = h.unit('k_n'); assert.equal(recipient.s.aspd, 115); h.b.retreat(source, { reason }); assert.ok(h.b.isDown(source), 'a down model is not proof of being killed'); assert.equal(source.removeReason, reason); assert.equal(recipient.s.aspd, 100, 'aura stops immediately on retreat'); assert.ok(h.b.redeploy(source, { free: true })); assert.equal(recipient.s.aspd, 115, 'the living source restores its aura'); h.b.kill(source); assert.equal(recipient.s.aspd, 115, 'a subsequent real kill keeps the aura'); checkInvariants(h.b); }); } test('灵巧: unite carry-down forcedExit starts without an aura and restores it on redeploy', () => { const h = makeBattle({ kind: 'unite', defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } }, bonds: { skillfulShip: bond(1, 40) }, units: [{ chessId: 'k_1', row: 10, col: 4, carryState: { down: true } }, { chessId: 'k_n', row: 11, col: 5 }], }); h.step(); const source = h.unit('k_1'), recipient = h.unit('k_n'); assert.equal(source.removeReason, 'forcedExit'); assert.ok(h.b.isDown(source)); assert.equal(recipient.s.aspd, 100, 'forcedExit is not a kill in this battle'); assert.ok(h.b.redeploy(source, { free: true })); assert.equal(recipient.s.aspd, 150, 'the redeployed source covers all eight neighbours'); checkInvariants(h.b); }); test('奥术: member arts damage → target arts taken ×(1.2+0.01·L) for 3 s; tier 2 below 50% HP ×1.4', () => { const bb = bondBb('arcaneShip'); const defs = { chess: { a_m: chessRec({ id: 'a_m', bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] }), a_x: op('a_x', []) }, enemies: DUMMY }; const mk = (b) => makeBattle({ defs, bonds: { arcaneShip: b }, units: [{ chessId: 'a_m', row: 12, col: 3 }, { chessId: 'a_x', row: 11, col: 3 }], enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] }); const h = mk(bond(1, 10)); h.step(2); const e = h.enemy('enemy_addon_dummy'); h.b.dealDamage(h.unit('a_x'), e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, 1, 'non-member'); h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'phys' }); close(e.s.artsTakenMul, 1, 'physical damage'); h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'arts' }); const m = bb.base_damage_scale + bb.damage_scale_per_stack * 10; close(e.s.artsTakenMul, m, 'vulnerable'); e.hp = e.s.maxHp * 0.3; h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, m, 'tier 1 ignores the HP threshold'); h.run(bb.weak_duration + 0.2); close(e.s.artsTakenMul, 1, 'expired'); checkInvariants(h.b); const h2 = mk(bond(2, 10, 3)); h2.step(2); const e2 = h2.enemy('enemy_addon_dummy'); e2.hp = e2.s.maxHp * 0.3; h2.b.dealDamage(h2.unit('a_m'), e2, { amount: 100, type: 'arts' }); close(e2.s.artsTakenMul, m * bb.power_weak_scale, 'low-HP multiplier (+68%+1.4%/L)'); close(e2.s.artsTakenMul - 1, bb.base_damage_scale_show_ex + bb.damage_scale_per_stack_show_ex * 10, 'matches the *_show keys'); checkInvariants(h2.b); }); test('奥术 with two players on one field: one instance per target, the strongest wins (never multiplied); a weaker one resumes after it', () => { // DESIGN §20.10: PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶 如果對面層數比你高 // 就不需要再特別激活直接吃他的奧術buff". v2.5 kept one instance per player: ×(1.3)·×(1.7) here. const bb = bondBb('arcaneShip'); const caster = (id) => chessRec({ id, bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] }); const defs = { chess: { a_1: caster('a_1'), a_2: caster('a_2'), a_x: op('a_x', []) }, enemies: DUMMY }; const h = makeBattle({ kind: 'boss', defs, players: [ { playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'a_1', row: 12, col: 3 }], bonds: { arcaneShip: bond(1, 10) } }, { playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'a_2', row: 12, col: 3 }, { uid: 3, kind: 'chess', chessId: 'a_x', row: 11, col: 3 }], bonds: { arcaneShip: bond(1, 50) } }, ], enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }], }); h.step(2); const e = h.enemy('enemy_addon_dummy'); const u1 = h.b.allyUnits.find((u) => u.defId === 'a_1'), u2 = h.b.allyUnits.find((u) => u.defId === 'a_2'); const m1 = bb.base_damage_scale + bb.damage_scale_per_stack * 10, m2 = bb.base_damage_scale + bb.damage_scale_per_stack * 50; h.b.dealDamage(u1, e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, m1, 'p1 alone'); h.b.dealDamage(u2, e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, m2, 'the stronger p2 instance takes over — not m1 × m2'); h.b.dealDamage(u1, e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, m2, 'a weaker application never overrides it'); assert.equal(e.buffs.filter((b) => String(b.key).startsWith('bond:arcaneShip')).length, 1, 'one buff on the target'); // the partner's non-member arts damage benefits too (it is a debuff on the target) const x = h.b.allyUnits.find((u) => u.defId === 'a_x'); const hp0 = e.hp; h.b.dealDamage(x, e, { amount: 100, type: 'arts' }); close(hp0 - e.hp, 100 * m2, 'any arts damage on the target'); // p2 stops: its instance expires at 3 s, p1's later application (≈ 3 s too) resumes for the rest h.run(1); h.b.dealDamage(u1, e, { amount: 100, type: 'arts' }); close(e.s.artsTakenMul, m2, 'still the stronger one'); h.run(bb.weak_duration - 1 + 0.1); close(e.s.artsTakenMul, m1, 'the stronger expired, the weaker (applied later) resumes'); h.run(1.1); close(e.s.artsTakenMul, 1, 'both expired'); checkInvariants(h.b); }); test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members, thorns + fragile, cooldown, no loops', () => { const bb = bondBb('steadShip'); const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY }; const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }]; const h1 = makeBattle({ defs, units, bonds: { steadShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] }); h1.step(2); const hpMul = 1 + bb.base_max_hp + bb.max_hp_per_stack * 10; close(h1.unit('d_n').s.maxHp, 2000 * hpMul, 'non-member HP'); const n1 = h1.unit('d_n'); h1.b.dealDamage(h1.enemy('enemy_addon_dummy'), n1, { amount: 1000, type: 'true' }); close(n1.s.maxHp - n1.hp, 1000, 'tier 1: no redirect'); const h = makeBattle({ defs, units, bonds: { steadShip: bond(2, 10, 3) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] }); h.step(2); const e = h.enemy('enemy_addon_dummy'); const [d1, d2, dn] = ['d_1', 'd_2', 'd_n'].map((id) => h.unit(id)); const e0 = e.hp; h.b.dealDamage(e, dn, { amount: 1000, type: 'true' }); close(dn.s.maxHp - dn.hp, 600, 'non-member keeps 60%'); close(d1.s.maxHp - d1.hp, 200, 'member share'); close(d2.s.maxHp - d2.hp, 200, 'member share'); close(e.hp, e0, 'redirected damage never thorns'); const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10; h.b.dealDamage(e, d1, { amount: 100, type: 'true' }); close(e0 - e.hp, thorn, 'thorns'); close(e.s.dmgTakenMul, bb.damage_scale, 'fragile'); const e1 = e.hp; h.b.dealDamage(e, d1, { amount: 100, type: 'true' }); close(e.hp, e1, 'per-member cooldown'); h.b.dealDamage(e, d2, { amount: 100, type: 'true' }); close(e1 - e.hp, thorn * bb.damage_scale, 'other member thorns, fragile applies'); h.run(bb.cd_duration + 0.05); const e2 = e.hp; h.b.dealDamage(null, d1, { amount: 50, type: 'true' }); close(e.hp, e2, 'sourceless damage: no thorns'); checkInvariants(h.b); }); test('坚守 thorns: 无来源 (no attacker bonus, hooks see no source) but credited to the member — its stats and the shared-pool tally', async () => { // DESIGN §20.10: v2.5 dealt them with no source at all, so no player's boss damage counted them (19 % of a co-op pool) const { SharedBossPool } = await import('../../server/match/finalAssault.js'); const bb = bondBb('steadShip'); const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']) }, enemies: DUMMY }; const pool = new SharedBossPool(1e6); const h = makeBattle({ defs, kind: 'boss', sharedBoss: pool, units: [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }], bonds: { steadShip: bond(2, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9], tag: 'boss' }], hooks: ['damaged'], captureNoisy: true }); h.step(2); const e = h.enemy('enemy_addon_dummy'), d1 = h.unit('d_1'); assert.ok(e.bossPool, 'the leader drains the pool'); h.b.addBuff(d1, { key: 'test:rage', mods: { dmgDealtMul: 2 } }); const p0 = pool.hp, dmg0 = d1.stats.dmg; h.b.dealDamage(e, d1, { amount: 100, type: 'true' }); const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10; close(p0 - pool.hp, thorn, 'thorns hit the pool, the member\'s ×2 does not apply'); const ev = h.hooksOf('damaged').filter((c) => c.target === e); assert.equal(ev.length, 1); assert.equal(ev[0].source, null, '无来源: hooks see no source'); assert.equal(ev[0].credit, d1, 'credited to the member'); close(d1.stats.dmg - dmg0, thorn, 'member stats'); close(h.result().perPlayer.p1.bossDamage, thorn, 'its player\'s shared-pool tally'); }); test('坚守 tier 2: the redirected 40 % is the already-scaled damage — the attacker’s damage bonus is not applied twice', () => { const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY }; const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }]; const h = makeBattle({ defs, units, bonds: { steadShip: bond(2, 0, 3) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] }); h.step(2); const e = h.enemy('enemy_addon_dummy'); h.b.addBuff(e, { key: 'test:rage', mods: { dmgDealtMul: 2 } }); const [d1, d2, dn] = ['d_1', 'd_2', 'd_n'].map((id) => h.unit(id)); h.b.dealDamage(e, dn, { amount: 1000, type: 'true' }); // 2000 after the attacker's ×2 close(dn.s.maxHp - dn.hp, 1200, 'non-member keeps 60 % of 2000'); close(d1.s.maxHp - d1.hp, 400, 'member share: 40 % of 2000 split in two (not ×2 again)'); close(d2.s.maxHp - d2.hp, 400); checkInvariants(h.b); }); test('助力: all operators DEF ×(1.15+0.012·L) and redeploy time ×0.7', () => { const bb = bondBb('deputShip'); const defs = { chess: { z_m: op('z_m', ['deputShip']), z_x: op('z_x', []) } }; const h = makeBattle({ defs, bonds: { deputShip: bond(1, 20) }, units: [{ chessId: 'z_m', row: 10, col: 3 }, { chessId: 'z_x', row: 12, col: 3 }] }); h.step(); const x = h.unit('z_x'); close(x.s.def, 200 * (1 + bb.base_def + bb.def_per_stack * 20), 'DEF'); const t0 = h.b.time; h.b.dealDamage(null, x, { amount: 1e9, type: 'true' }); close(x.respawnAt - t0, 20 * (1 + bb.respawn_time), 'redeploy time'); checkInvariants(h.b); }); test('突袭: idle member relocates next to a ground enemy (SP kept) with ATK/HP ×(1.25+0.01·L); ≥50 layers all ASPD +50', () => { const bb = bondBb('raidShip'); const defs = { chess: { r_m: op('r_m', ['raidShip']), r_x: op('r_x', []) }, enemies: DUMMY }; const h = makeBattle({ defs, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], units: [{ chessId: 'r_m', row: 12, col: 3 }, { chessId: 'r_x', row: 11, col: 3 }], }); const u = h.unit('r_m'); h.run(bb.no_attack_duration - 1); assert.deepEqual([u.tileR, u.tileC], [12, 3], 'still waiting'); close(h.unit('r_x').s.aspd, 100, 'no milestone below 50 layers'); h.run(1.5); const e = h.enemy('enemy_addon_dummy'); assert.notDeepEqual([u.tileR, u.tileC], [12, 3], 'relocated'); assert.ok(Math.max(Math.abs(u.tileR - 9), Math.abs(u.tileC - 8)) <= 2, 'next to the enemy'); assert.ok(inRange(u, e), 'enemy in range after the jump'); close(u.s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 10), 'ATK'); close(u.s.maxHp, 2000 * (1 + bb.base_max_hp + bb.max_hp_per_stack * 10), 'HP'); assert.deepEqual([h.unit('r_x').tileR, h.unit('r_x').tileC], [11, 3], 'non-member stays'); checkInvariants(h.b); // skill ready → immediate; 50 layers → every operator ASPD +50 const defs2 = { chess: { r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }), r_x: op('r_x', []) }, enemies: DUMMY }; const h2 = makeBattle({ defs: defs2, bonds: { raidShip: bond(1, bb.power_bond_stack_cnt) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], units: [{ chessId: 'r_s', row: 12, col: 3 }, { chessId: 'r_x', row: 11, col: 3 }], }); h2.run(0.6); const s = h2.unit('r_s'); assert.notDeepEqual([s.tileR, s.tileC], [12, 3], 'ready skill → relocated at once'); close(h2.unit('r_x').s.aspd, 100 + bb.power_attack_speed, 'ASPD milestone'); checkInvariants(h2.b); }); test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; knocked out there, it comes back there', () => { const bb = bondBb('raidShip'); const defs = { chess: { r_m: chessRec({ id: 'r_m', bonds: ['raidShip'], skill: { spCost: 40, initSp: 0, duration: 10 } }) }, enemies: DUMMY }; let spAtJump = null; const h = makeBattle({ defs, bonds: { raidShip: bond(1, 0) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], hooks: ['deploy', 'death'], units: [{ chessId: 'r_m', row: 12, col: 3 }], setup: (b) => b.on('death', (c) => { if (c.reason === 'raid') spAtJump = c.unit.skill.sp; }), }); h.step(1); const u = h.unit('r_m'); h.b.dealDamage(null, u, { amount: 500, type: 'true' }); assert.ok(h.runUntil(() => u.tileR !== 12 || u.tileC !== 3, bb.no_attack_duration + 1), 'jumped'); const sp = spAtJump; assert.ok(sp > 5 && sp < 40, `SP ${sp} accumulated`); const redeploys = h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial); assert.equal(redeploys.length, 1, 'a deploy event (部署时 traits, 突袭手雷, 卡西米尔 …)'); assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'left the field as a retreat, not a knock-out'); close(u.skill.sp, sp, `SP kept (${u.skill.sp} vs ${sp})`, 0.1); close(u.hp, u.s.maxHp, 'fresh deployment: full HP'); close(u.s.maxHp, 2000 * (1 + bb.base_max_hp), 'raid HP bonus'); assert.equal(h.result?.().perPlayer?.p1?.deaths ?? 0, 0, 'not counted as a death'); // knocked out later: it lies on its landing tile and comes back there ("原地留下一个“倒地干员”…满足再部署条件时… // 自动部署至该位置", PRTS 卫戍协议/帮助 — player report F5 after 0.1.0), without the raid bonus; its home stays the // board tile const landed = [u.tileR, u.tileC]; h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); assert.deepEqual([u.tileR, u.tileC], landed, 'lies where it fell'); h.b.redeploy(u); assert.deepEqual([u.tileR, u.tileC], landed, 'redeployed where it lay'); assert.deepEqual([u.homeR, u.homeC], [12, 3], 'home tile unchanged'); close(u.s.maxHp, 2000, 'bonus gone after leaving the field'); checkInvariants(h.b); }); // GitHub issue #51 (reported on 0.1.0, still so in 0.1.1): the 10 s idle jump landed where no enemy was in range and // then hopped between such tiles every 10 s, and it ignored a second enemy it could have reached. Either trigger now jumps only to a landing tile // with its target in range — the first candidate that has one — else the member stays and the next poll looks again // [ASSUMED: the text says only "再部署至一名地面敌人周围"]. Row 9 here: cols 8–9 plain floor (not deployable); a // RIGHT-facing melee member (its own tile + the tile in front) reaches a speed-0 enemy on (9,9) only from (9,9) or // (9,8) — neither deployable. const RAID51_STAGE = flatStage({ rows: { 9: '##Errrrrff' + 'S' + 'rrrrrrr' + 'S##' } }); const RAID51_ENEMY = { enemy_raid51: enemyRec({ key: 'enemy_raid51', hp: 1e7, speed: 0 }) }; const raidJumps = (h) => h.hooksOf('deploy').filter((c) => c.unit.kind === 'op' && !c.initial); // (an enemy spawn is a deploy too) test('突袭 #51: no landing tile reaches the enemy → neither trigger jumps (no hopping); once one can, the jump comes at once', () => { const defs = { chess: { r_m: op('r_m', ['raidShip']), r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }) }, enemies: RAID51_ENEMY, }; const h = makeBattle({ stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_raid51', pos: [9, 9] }], units: [{ chessId: 'r_m', row: 12, col: 3 }, { chessId: 'r_s', row: 11, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 120, }); const m = h.unit('r_m'), s = h.unit('r_s'); h.run(45); assert.equal(m.lastAttackAt, -Infinity, 'r_m idle the whole time (the 10 s trigger, four times over)'); assert.ok(s.skill.ready, 'r_s: its skill stays ready the whole time (the 技能就绪 trigger)'); assert.deepEqual(raidJumps(h), [], 'no jump: no landing tile has the enemy in range'); assert.deepEqual(h.hooksOf('death').filter((c) => c.reason === 'raid'), [], 'no 突袭 retreat'); assert.deepEqual([m.tileR, m.tileC, s.tileR, s.tileC], [12, 3, 11, 3], 'both stay where they are'); // a ground enemy that can be reached: both jump at the next poll (the idle time kept counting) and land with it in range const t0 = h.b.time; const e2 = h.spawn('enemy_raid51', { pos: [12, 7] }); h.run(0.3); assert.equal(raidJumps(h).length, 2, 'both members jumped'); for (const c of raidJumps(h)) assert.ok(c.t - t0 <= 0.25 + 1e-6, `within one poll (${(c.t - t0).toFixed(2)} s)`); for (const u of [m, s]) { assert.ok(inRange(u, e2), `${u.defId}: the new enemy in range after the jump (${u.tileR},${u.tileC})`); assert.ok(Math.max(Math.abs(u.tileR - 12), Math.abs(u.tileC - 7)) <= 2, `${u.defId}: next to it`); } checkInvariants(h.b); }); test('突袭 #51: the most advanced enemy out of reach → the jump goes to the next one it can reach and fights there; idle time restarts', () => { const defs = { chess: { r_m: op('r_m', ['raidShip']) }, enemies: RAID51_ENEMY }; const idle = bondBb('raidShip').no_attack_duration; const h = makeBattle({ stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 120, enemies: [{ key: 'enemy_raid51', pos: [9, 9] }, { key: 'enemy_raid51', pos: [12, 9] }], units: [{ chessId: 'r_m', row: 12, col: 3 }], }); h.step(); const [e1, e2] = h.enemies(); assert.deepEqual([e1.y, e1.x, e2.y, e2.x], [9, 9, 12, 9]); assert.ok(h.b.remainingDistance(e1) < h.b.remainingDistance(e2), 'the one out of reach is the more advanced (first candidate)'); const u = h.unit('r_m'); assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'jumped after the idle time'); const t1 = h.b.time; assert.ok(inRange(u, e2) && !inRange(u, e1), `landed on ${u.tileR},${u.tileC} with the second enemy in range`); h.run(5); assert.ok(u.lastAttackAt > t1 && e2.hp < e2.s.maxHp, 'it attacks the enemy it jumped to'); assert.equal(raidJumps(h).length, 1, 'busy there: no further jump'); // its enemy gone, a reachable one elsewhere: the next jump comes the idle time after its last attack, not at once h.b.dealDamage(null, e2, { amount: 1e9, type: 'true' }); const last = u.lastAttackAt; const e3 = h.spawn('enemy_raid51', { pos: [10, 5] }); assert.ok(!inRange(u, e3)); assert.ok(h.runUntil(() => raidJumps(h).length === 2, idle + 1), 'jumped again'); const dt = h.b.time - last; assert.ok(dt >= idle - 1e-6 && dt <= idle + 0.25 + 1e-6, `${dt.toFixed(2)} s after its last attack`); assert.ok(inRange(u, e3), 'the new enemy in range'); checkInvariants(h.b); }); // GitHub #49: skills.js `ready` is false for every passive skill, so a passive 突袭 member (缄默德克萨斯, 宴 …) only jumped on // the 10 s idle trigger; the reporter's footage of the official game shows 缄默德克萨斯 jumping within her passive's 10 s. // raidPoll (only there) counts a passive skill that is on as 技能就绪, and — since #109 made her skills deploy-timed // duration skills — a deploy-timed skill while its window runs; the landing rule (#51) still keeps it from hopping. test('突袭 #49: a passive skill that is on, or a deploy-timed skill while it runs (#109), counts as 技能就绪 — 缄默德克萨斯 jumps within a few seconds, then stays while her enemy is in range', () => { const tex = 'chess_char_4_16_a'; const h = makeBattle({ defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], units: [{ chessId: tex, row: 12, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 60, }); h.step(); const u = h.unit(tex), e = h.enemy('enemy_addon_dummy'); assert.deepEqual([u.skill.kind, !!u.skill.spec.activateOnDeploy, u.skill.ready, u.skill.active], ['duration', true, false, true], 'her deploy-timed skill (#109): on, and the global `ready` false'); assert.ok(!inRange(u, e), 'nothing in her range'); assert.ok(h.runUntil(() => raidJumps(h).length > 0, 3), 'jumped within 3 s (no 10 s idle wait)'); assert.ok(h.b.time < bondBb('raidShip').no_attack_duration - 5, `at ${h.b.time.toFixed(2)} s`); assert.ok(inRange(u, e), `landed (${u.tileR},${u.tileC}) with the enemy in range`); h.run(8); assert.equal(raidJumps(h).length, 1, 'busy there: no further jump (no hopping)'); checkInvariants(h.b); }); test('突袭 #49 with #109: a deploy-timed skill counts only while its window runs — once it has ended, only the idle trigger is left', () => { const tex = 'chess_char_4_16_a'; const idle = bondBb('raidShip').no_attack_duration; const h = makeBattle({ defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], units: [{ chessId: tex, row: 12, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 60, }); h.step(); const u = h.unit(tex); u.skill.end('duration'); // her window over before the first poll, as after its duration assert.deepEqual([u.skill.active, u.skill.ready], [false, false], 'ended: no charge until the next deployment'); h.run(3); assert.equal(raidJumps(h).length, 0, 'no 技能就绪 jump once the window has ended'); assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'the idle trigger'); assert.ok(h.b.time >= idle - 1e-6, `at ${h.b.time.toFixed(2)} s`); assert.ok(u.skill.active, 'the landing is a new deployment: a new window'); checkInvariants(h.b); }); test('突袭 #49: non-passive members unchanged — a charged skill jumps at once, an uncharged one waits for the idle time; a passive one jumps at once', () => { const idle = bondBb('raidShip').no_attack_duration; const defs = { chess: { r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }), r_u: chessRec({ id: 'r_u', bonds: ['raidShip'], skill: { spCost: 100, initSp: 0, spType: 'INCREASE_WHEN_ATTACK' } }), r_p: chessRec({ id: 'r_p', bonds: ['raidShip'], skill: { skillType: 'PASSIVE', spCost: 0, duration: -1, spType: 8 } }), }, enemies: DUMMY, }; const h = makeBattle({ defs, bonds: { raidShip: bond(1, 10, 3) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }, { key: 'enemy_addon_dummy', pos: [12, 9] }], units: [{ chessId: 'r_s', row: 12, col: 3 }, { chessId: 'r_u', row: 11, col: 3 }, { chessId: 'r_p', row: 10, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 60, }); h.step(); const s = h.unit('r_s'), un = h.unit('r_u'), p = h.unit('r_p'); assert.deepEqual([s.skill.ready, un.skill.ready, p.skill.kind, p.skill.ready], [true, false, 'passive', false]); h.run(1); const jumped = (u) => raidJumps(h).filter((c) => c.unit === u); assert.equal(jumped(s).length, 1, 'charged: at once'); assert.equal(jumped(p).length, 1, 'passive: at once'); assert.equal(jumped(un).length, 0, 'uncharged: not yet'); assert.ok(h.runUntil(() => jumped(un).length > 0, idle + 1), 'uncharged: after the idle time'); assert.ok(h.b.time >= idle - 1e-6, `at ${h.b.time.toFixed(2)} s`); checkInvariants(h.b); }); test('不屈: knocked-out 地面干员 (melee position) redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / ranged → no', () => { const bb = bondBb('indomShip'); const sk = { spCost: 50, initSp: 0 }; const defs = { chess: { i_g: op('i_g', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } }; const units = [{ chessId: 'i_g', row: 10, col: 4 }, { chessId: 'i_o', row: 12, col: 6 }]; const h = makeBattle({ defs, units, bonds: { indomShip: bond(2, 300, 3) } }); h.step(2); const g = h.unit('i_g'), o = h.unit('i_o'); assert.equal(g.ground, true); const sp0 = o.skill.sp; h.b.dealDamage(null, g, { amount: 1e9, type: 'true' }); assert.ok(g.alive && g.deployed, 'redeployed immediately'); assert.deepEqual([g.tileR, g.tileC], [10, 4]); close(o.skill.sp - sp0, bb.sp, 'tier 2 SP'); checkInvariants(h.b); const off = makeBattle({ defs, units, bonds: { indomShip: bond(0, 300) } }); off.step(2); off.b.dealDamage(null, off.unit('i_g'), { amount: 1e9, type: 'true' }); assert.equal(off.unit('i_g').alive, false, 'inactive'); const t1 = makeBattle({ defs, units, bonds: { indomShip: bond(1, 300) } }); t1.step(2); const o1 = t1.unit('i_o'), s1 = o1.skill.sp; t1.b.dealDamage(null, t1.unit('i_g'), { amount: 1e9, type: 'true' }); assert.ok(t1.unit('i_g').alive); close(o1.skill.sp, s1, 'tier 1: no SP'); checkInvariants(t1.b); // 地面干员 = the melee position, whatever the tile: up on a 高台 it still counts; a ranged operator on a melee tile never const hi = makeBattle({ defs, units: [{ chessId: 'i_g', row: 10, col: 2 }], bonds: { indomShip: bond(1, 300) } }); hi.step(2); const hg = hi.unit('i_g'); assert.equal(hg.ground, false, 'elevated tile'); hi.b.dealDamage(null, hg, { amount: 1e9, type: 'true' }); assert.ok(hg.alive && hg.deployed, 'a melee operator on a 高台 is a 地面干员'); const rd = { chess: { i_r: ranged('i_r', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } }; const lo = makeBattle({ defs: rd, units: [{ chessId: 'i_r', row: 10, col: 4 }, { chessId: 'i_o', row: 12, col: 6 }], bonds: { indomShip: bond(2, 300, 3) } }); lo.step(2); const lr = lo.unit('i_r'), lsp = lo.unit('i_o').skill.sp; assert.equal(lr.ground, true, 'a melee (ground) tile'); lo.b.dealDamage(null, lr, { amount: 1e9, type: 'true' }); assert.equal(lr.alive, false, 'a ranged operator on a melee tile is not a 地面干员'); close(lo.unit('i_o').skill.sp, lsp, 'no tier 2 SP either'); }); test('协防干员: all operators take ×0.8 phys/arts; members deal ×1.2 (elite ×1.4)', () => { const bb = bondBb('emptyShip'); const defs = { chess: { c_n: op('c_n', ['emptyShip']), c_e_b: op('c_e_b', ['emptyShip'], { golden: true }), c_x: op('c_x', []) } }; const h = makeBattle({ defs, bonds: { emptyShip: bond(1) }, units: [{ chessId: 'c_n', row: 10, col: 3 }, { chessId: 'c_e_b', row: 11, col: 3 }, { chessId: 'c_x', row: 12, col: 3 }] }); h.step(); for (const id of ['c_n', 'c_e_b', 'c_x']) { close(h.unit(id).s.physTakenMul, 1 - bb.damage_resistance, `${id} phys`); close(h.unit(id).s.artsTakenMul, 1 - bb.damage_resistance, `${id} arts`); } close(h.unit('c_n').s.dmgDealtMul, bb.damage_scale_normal); close(h.unit('c_e_b').s.dmgDealtMul, bb.damage_scale_extra); close(h.unit('c_x').s.dmgDealtMul, 1); checkInvariants(h.b); }); test('独行: the lone member ATK/HP ×1.6 and +15 SP on every deploy', () => { const bb = bondBb('soloShip'); const defs = { chess: { o_m: chessRec({ id: 'o_m', bonds: ['soloShip'], skill: { spCost: 40, initSp: 0 } }), o_x: op('o_x', []) } }; const h = makeBattle({ defs, bonds: { soloShip: bond(1, 0, 1) }, units: [{ chessId: 'o_m', row: 10, col: 3 }, { chessId: 'o_x', row: 12, col: 3 }] }); h.step(); const u = h.unit('o_m'); close(u.s.atk, 500 * (1 + bb.atk)); close(u.s.maxHp, 2000 * (1 + bb.max_hp)); close(h.unit('o_x').s.atk, 500); assert.ok(u.skill.sp >= bb.sp && u.skill.sp < bb.sp + 0.5, `initial SP ${u.skill.sp}`); h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); assert.ok(h.b.redeploy(u)); close(u.skill.sp, bb.sp, 'SP again after the redeploy'); checkInvariants(h.b); }); test('绝技: 2 elites → elite ATK ×1.3; 5 elites → elite SP cost ×0.7 (floored); normal chess untouched', () => { const bb = bondBb('suntShip'); const defs = { chess: { j_e_b: chessRec({ id: 'j_e_b', golden: true, skill: { spCost: 10 } }), j_n: chessRec({ id: 'j_n', skill: { spCost: 10 } }), } }; const units = [{ chessId: 'j_e_b', row: 10, col: 3 }, { chessId: 'j_n', row: 12, col: 3 }]; const h1 = makeBattle({ defs, units, bonds: { suntShip: bond(1, 0, 2) } }); h1.step(); close(h1.unit('j_e_b').s.atk, 500 * (1 + bb.power_atk)); close(h1.unit('j_n').s.atk, 500); assert.equal(h1.unit('j_e_b').skill.spCost, 10, 'tier 1: SP cost unchanged'); const h2 = makeBattle({ defs, units, bonds: { suntShip: bond(2, 0, 5) } }); h2.step(); assert.equal(h2.unit('j_e_b').skill.spCost, Math.floor(10 * bb.sp_ratio)); assert.equal(h2.unit('j_n').skill.spCost, 10); checkInvariants(h2.b); }); test('inactive add-on bonds do nothing', () => { const ids = ['preciShip', 'swiftShip', 'skillfulShip', 'arcaneShip', 'steadShip', 'deputShip', 'raidShip', 'indomShip', 'emptyShip', 'soloShip', 'suntShip']; const bonds = Object.fromEntries(ids.map((id) => [id, { count: 1, active: false, tier: 0, layers: 500 }])); const defs = { chess: { n_1_b: op('n_1_b', ids, { golden: true }), n_2: ranged('n_2', ids) } }; const h = makeBattle({ defs, bonds, units: [{ chessId: 'n_1_b', row: 10, col: 3 }, { chessId: 'n_2', row: 10, col: 4 }] }); h.step(3); for (const u of h.allies()) assert.ok(!hasBondBuff(u), `${u.defId} has no bond buff`); close(h.unit('n_1_b').s.atk, 500); checkInvariants(h.b); }); test('two players on one field: bonds only buff their owner', () => { const defs = { chess: { w_1: op('w_1', ['steadShip']), w_2: op('w_2', []) } }; const h = makeBattle({ kind: 'boss', defs, players: [ { playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'w_1', row: 10, col: 4 }], bonds: { steadShip: bond(1, 10), deputShip: bond(1, 0) } }, { playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'w_2', row: 10, col: 4 }], bonds: {} }, ], }); h.step(2); const a = h.b.allyUnits.find((u) => u.ownerId === 'p1'), b = h.b.allyUnits.find((u) => u.ownerId === 'p2'); assert.ok(a.s.maxHp > 2000 && a.s.def > 200, 'owner buffed'); close(b.s.maxHp, 2000, 'partner HP untouched'); close(b.s.def, 200, 'partner DEF untouched'); checkInvariants(h.b); }); // --------------------------------------------------------------------------------------------------------------------- // prep side function addonRegistry() { const reg = createRegistry({ content: false }); registerMeta(reg); return reg; } function metaMatch(reg, seed = 90) { const h = makeMatch({ mode: 'solo', seed, 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.funds = 50; ps.layers = {}; ps.recompute(); return { h, m: h.m, ps }; } const onBoard = (m, ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id)); test('registry: the add-on meta handlers are registered by the real content', () => { const reg = createRegistry(); for (const id of ['deputShip', 'visiShip', 'miraShip', 'victoriaShip']) assert.ok(reg.has(`bond:${id}`), id); assert.ok(reg.has('global:bondaddon_prepend'), 'prep-end marker for pending payouts'); assert.equal(reg.warnings.filter((w) => w.startsWith('bond:') || w.startsWith('global:bondaddon')).length, 0, 'no misspelt hooks'); }); test('助力 meta: prep end → every active bond +2, +4 with 3 助力 (distinct name or elite state)', () => { const { h, m, ps } = metaMatch(addonRegistry()); onBoard(m, ps, 'chess_char_1_13_a'); // 波登可 助力 onBoard(m, ps, 'chess_char_6_14_a'); // 流明 助力 onBoard(m, ps, 'chess_char_1_09_a'); // 跃跃 精准 onBoard(m, ps, 'chess_char_4_06_a'); // 寒芒克洛丝 精准 give(m, ps, 'chess_char_4_21_a', 'hand'); // 白面鸮 in the hand: 灵巧 inactive assert.equal(ps.bonds.deputShip.tier, 1); m.dispatch(ps, 'onPrepEnd', { round: m.round }); assert.equal(ps.layers.deputShip, 2); assert.equal(ps.layers.preciShip, 2); assert.ok(!ps.layers.skillfulShip, 'inactive bond unchanged'); onBoard(m, ps, 'chess_char_1_13_b'); // elite 波登可: 3rd 助力 by elite state assert.equal(ps.bonds.deputShip.tier, 2); m.dispatch(ps, 'onPrepEnd', { round: m.round }); assert.equal(ps.layers.deputShip, 6); assert.equal(ps.layers.preciShip, 6); h.invariants(); m.dispose(); }); test('远见 meta: +2 funds per 10 layers, 80 → 远见 chess −1, 150 → every chess −1; catch-up once active', () => { const { h, m, ps } = metaMatch(addonRegistry()); give(m, ps, 'chess_char_2_02_a', 'hand'); // 赫默 give(m, ps, 'chess_char_5_21_a', 'hand'); // 寒檀 (BOARD_AND_DECK) assert.ok(ps.bonds.visiShip.active); const f0 = ps.funds; ps.addLayers('visiShip', 25); assert.equal(ps.funds, f0 + 4); const visi = { kind: 'chess', id: 'chess_char_4_04_a', basePrice: 3 }; const other = { kind: 'chess', id: 'chess_char_1_09_a', basePrice: 3 }; assert.equal(ps.priceOf(visi), 3); ps.addLayers('visiShip', 55); assert.equal(ps.funds, f0 + 16); assert.equal(ps.priceOf(visi), 2, '远见 discount at 80'); assert.equal(ps.priceOf(other), 3); ps.addLayers('visiShip', 70); assert.equal(ps.priceOf(other), 2, 'every chess at 150'); assert.equal(ps.priceOf(visi), 2, 'replaces, does not stack'); assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_1_09_a', basePrice: 0 }), 0, 'never below 0'); h.invariants(); m.dispose(); const b = metaMatch(addonRegistry(), 91); give(b.m, b.ps, 'chess_char_2_02_a', 'hand'); const g0 = b.ps.funds; b.ps.addLayers('visiShip', 30); assert.equal(b.ps.funds, g0, 'inactive: no payout'); give(b.m, b.ps, 'chess_char_5_21_a', 'hand'); b.m.dispatch(b.ps, 'onRoundStart', { round: b.m.round }); assert.equal(b.ps.funds, g0 + 6, 'caught up once active'); b.m.dispatch(b.ps, 'onPrepEnd', { round: b.m.round }); assert.equal(b.ps.funds, g0 + 6, 'paid once'); b.m.dispose(); }); test('远见 meta: a milestone crossed after the prep phase ended (助力 +2 at prep end) is paid as pending funds, not wiped', () => { const { h, m, ps } = metaMatch(addonRegistry(), 93); onBoard(m, ps, 'chess_char_1_13_a'); // 波登可 助力 onBoard(m, ps, 'chess_char_6_14_a'); // 流明 助力 give(m, ps, 'chess_char_2_02_a', 'hand'); // 赫默 远见 give(m, ps, 'chess_char_5_21_a', 'hand'); // 寒檀 远见 (BOARD_AND_DECK) assert.ok(ps.bonds.deputShip.active && ps.bonds.visiShip.active); ps.layers.visiShip = 8; ps.recompute(); const f0 = ps.funds, p0 = ps.pendingFunds; m.dispatch(ps, 'onPrepEnd', { round: m.round }); assert.equal(ps.layers.visiShip, 10, '助力 +2'); assert.equal(ps.funds, f0, 'not paid into funds that are wiped right after'); assert.equal(ps.pendingFunds, p0 + 2, 'credited at the next round start'); h.invariants(); m.dispose(); }); test('远见 meta: 「购买价格永久-1资金」 has no floor but 0 — a price of 1 becomes 0 (owner\'s decision 2026-10-04)', () => { const { m, ps } = metaMatch(addonRegistry(), 94); ps.counters['bondaddon:visi:disc'] = 2; // 150 layers reached: every chess −1 const at = (basePrice) => ps.priceOf({ kind: 'chess', id: 'chess_char_1_09_a', basePrice }); assert.equal(at(3), 2); assert.equal(at(2), 1); assert.equal(at(1), 0, 'a price of 1 (至简 / 休露丝) becomes 0'); assert.equal(at(0), 0, 'never negative'); ps.counters['bondaddon:visi:disc'] = 1; // 80: 远见 chess only assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 2 }), 1); assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 1 }), 0, '80: a 远见 operator at 1 → 0 too'); assert.equal(at(2), 2, 'non-远见 chess unchanged at 80'); assert.equal(at(1), 1); m.dispose(); }); test('独行 / 绝技 (match side): 独行 active with exactly 1 distinct member (copies ok); 绝技 counts every elite on board', () => { const { m, ps } = metaMatch(addonRegistry(), 95); onBoard(m, ps, 'chess_char_1_08_a'); // 德克萨斯 独行 assert.equal(ps.bonds.soloShip.active, true); onBoard(m, ps, 'chess_char_1_08_b'); // its elite copy: still one distinct 独行 assert.equal(ps.bonds.soloShip.count, 1); assert.equal(ps.bonds.soloShip.active, true); onBoard(m, ps, 'chess_char_2_17_a'); // 折桠: a second distinct 独行 breaks it assert.equal(ps.bonds.soloShip.active, false); // 绝技: elites on board, duplicates included → 1 (德克萨斯_b) + … assert.equal(ps.bonds.suntShip.count, 1); assert.equal(ps.bonds.suntShip.active, false); onBoard(m, ps, 'chess_char_1_08_b'); assert.equal(ps.bonds.suntShip.count, 2, 'duplicate elites each count'); assert.equal(ps.bonds.suntShip.tier, 1); onBoard(m, ps, 'chess_char_1_09_b'); onBoard(m, ps, 'chess_char_1_13_b'); assert.equal(ps.bonds.suntShip.tier, 1); onBoard(m, ps, 'chess_char_2_02_b'); assert.equal(ps.bonds.suntShip.count, 5); assert.equal(ps.bonds.suntShip.tier, 2); m.dispose(); }); test('奇迹 meta: refresh → next refresh free (p=1 at 300 layers), +20 funds per 100 layers; inactive → never', () => { const { h, m, ps } = metaMatch(addonRegistry()); give(m, ps, 'chess_char_2_11_a', 'hand'); // 风丸 give(m, ps, 'chess_char_3_01_a', 'hand'); // 能天使 const f0 = ps.funds; ps.addLayers('miraShip', 300); assert.equal(ps.funds, f0 + 60, '3 × 20 funds'); const f1 = ps.funds; assert.equal(ps.shop.freeRefreshes, 0); assert.equal(m.handle('p_0', { t: 'g.refresh' }).ok, true); assert.equal(ps.funds, f1 - m.gd.refreshPrice); assert.equal(ps.shop.freeRefreshes, 1); m.handle('p_0', { t: 'g.refresh' }); assert.equal(ps.funds, f1 - m.gd.refreshPrice, 'free refresh'); assert.equal(ps.shop.freeRefreshes, 1, 'rolled again after the free one'); h.invariants(); m.dispose(); const b = metaMatch(addonRegistry(), 92); give(b.m, b.ps, 'chess_char_2_11_a', 'hand'); b.ps.layers.miraShip = 1000; b.ps.recompute(); for (let i = 0; i < 3; i++) b.m.handle('p_0', { t: 'g.refresh' }); assert.equal(b.ps.shop.freeRefreshes, 0, 'inactive'); b.m.dispose(); }); test('投资人 meta: 获得时 garrisons run ×2 while active, ×3 at ≥100 layers (dispatcher)', () => { const reg = createRegistry({ content: false }); let calls = 0; reg.garrison('SERVER_ADD_BOND_CHESS_ALL', { run: () => { calls++; } }); // 深靛 <获得时> const { m, ps } = metaMatch(reg); give(m, ps, 'chess_char_2_19_a', 'hand'); // 锡人 give(m, ps, 'chess_char_3_04_a', 'hand'); // 琳琅诗怀雅 ps.acquireChess('chess_char_1_17_a', { source: 'test' }); assert.equal(calls, 1, 'inactive: once'); give(m, ps, 'chess_char_5_17_a', 'hand'); // 山 → 3 投资人 assert.ok(ps.bonds.investShip.active); calls = 0; ps.acquireChess('chess_char_1_17_a', { source: 'test' }); assert.equal(calls, 2); ps.layers.investShip = 100; ps.recompute(); calls = 0; ps.acquireChess('chess_char_1_03_a', { source: 'test' }); // 惊蛰: no 获得时 trait assert.equal(calls, 0); const g = give(m, ps, 'chess_char_1_17_a', 'temp'); m.dispatch(ps, 'onGain', { piece: g, kind: 'chess', source: 'test' }); assert.equal(calls, 3, '×3 at 100 layers'); m.dispose(); }); test('调和 (match side): +1 to core bonds that already have a real member', () => { const { m, ps } = metaMatch(addonRegistry()); onBoard(m, ps, 'chess_char_6_11_a'); // 缪尔赛思 调和 assert.ok(ps.bonds.maniShip.active); assert.ok(!ps.bonds.yanShip || ps.bonds.yanShip.count === 0, 'no real 炎 → no +1'); onBoard(m, ps, 'chess_char_1_03_a'); // 惊蛰 炎 assert.equal(ps.bonds.yanShip.count, 2); assert.equal(ps.bonds.yanShip.active, false); onBoard(m, ps, 'chess_char_4_17_a'); // 星熊 炎 assert.equal(ps.bonds.yanShip.count, 3); assert.equal(ps.bonds.yanShip.active, true, '1 调和 + 2 炎 activates 炎'); m.dispose(); });