Files
ark-stronghold-protocol/test/content/bonds_addon.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

596 lines
30 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('坚守: 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('坚守 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();
});