Files
ark-stronghold-protocol/test/content/bonds_addon.test.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

661 lines
34 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.
// 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 } 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('迅捷 / 不屈 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, follows death and 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'), 100, 'aura source died');
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; later redeploys use the board tile', () => {
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: the auto-redeploy goes back to its board tile, without the raid bonus
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
h.b.redeploy(u);
assert.deepEqual([u.tileR, u.tileC], [12, 3], 'home tile unchanged');
close(u.s.maxHp, 2000, 'bonus gone after leaving the field');
checkInvariants(h.b);
});
test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → 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);
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.equal(hg.alive, false, 'not a ground operator');
});
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: the discounts never push a price below 1', () => {
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), 1, 'a price of 1 (至简 / 休露丝) stays 1');
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 }), 1);
assert.equal(at(2), 2, 'non-远见 chess unchanged at 80');
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();
});