Files
ark-stronghold-protocol/test/content/bonds_addon.test.js
T
sganggs 4128965a9f feedback7 R22-GHSIM: an instant skill with no attack of its own takes one attack cycle — 引星棘刺 S1 under 迅捷 no longer recasts every tick, and attack speed paces such casts (GitHub #298); golden: 84 scenarios move (below)
Seen (#298, FrogThai): 度算浪波 (AUTO, instant, SP_FULL, cost 6, no attack) with 迅捷's refund (12 + 15 at ≥ 40 layers)
cast 60 times in 2 s — every tick — at ASPD 100 and at ASPD 600 alike (repro): skill.tick checks tick rules before the
attack loop and an instant cast ended without touching the attack cooldown.

Now (skills.js): such a cast (instant / charges, no spec.attack) sets atkCd to at least the attack interval, and the
casts checked in skill.tick (the tick rules, the DEFAULT of units that never attack, content trigger ranges) wait while
the cooldown runs beyond this tick — a remainder ≤ dt is this tick's slot, which the cast claims before updateAlly's
attack. A cast right before an attack (onAboutToAttack, DEFAULT) is followed by that attack as before; timed skills,
"next attack" skills and TAKE_DAMAGE / kit casts' gating are unchanged (a TAKE_DAMAGE or kit cast also takes the cycle).
[ASSUMED] one attack interval (PRTS prints no 前后摇 for such casts). Measured: 2 casts / 0 attacks in 2 s at interval
1.36 s, 9 at 0.25 s; with no enemy it still fires at full SP (#124). Not taken: PR #313's 3 s operation cooldown for
AUTO skills (it conflicts with #124). Docs: SIM.md §7 trigger rules, PLAYING.md 技能自动释放.

Golden — every moved battle fields an instant no-attack skill cast from the tick, which now waits for / takes the
attack cycle (德克萨斯 S2, the 先锋 DP skill skcom_charge_cost, 赫默 S2, 雪猎 S2, 莱恩哈特 S2, 凛御银灰 S1 / S2, 异客 S3,
锏 S2, 伺夜 S1 / S2, 焰尾 S1 / S2, 引星棘刺 S1, 忍冬 S2, 郁金香 S3 (补位); 自选 陈 / 艾丽妮 / 嵯峨 S2, 左乐 S3, 重岳 S2,
温蒂 S3, 望 S1 / S2, 多萝西, 黑键 S2): roster (22): roster-001 roster-003 roster-005 roster-008 roster-010 roster-012 roster-014 roster-015 roster-016 roster-018 roster-020 roster-021 roster-028 roster-029 roster-031 roster-033 roster-034 roster-039 roster-040 roster-041 roster-042 roster-048;bonds (9): bond-deputShip-low bond-indomShip-low bond-raidShip-high bond-skillfulShip-low bond-soloShip-high bond-suntShip-low bond-victoriaShip-high bond-visiShip-high bond-visiShip-low;fields (9): boss-boss_1-pair boss-boss_2-pair boss-boss_3-pair boss-boss_4-pair boss-boss_5-pair boss-boss_6-pair hidden-boss_10-pair hidden-boss_8-pair hidden-boss_9-solo;matches (16): coop2-ABYSS-10-boosted coop2-HARD-4 coop2-NORMAL-14-standins coop2-NORMAL-3 coop2-NORMAL-70-diy coop2-NORMAL-8-serverrun coop3-ABYSS-5 coop4-ABYSS-7 coop4-FUNNY-6 solo-ABYSS-1 solo-FUNNY-1 solo-FUNNY-2 solo-HARD-1 solo-HARD-2 solo-HARD-9-boosted solo-NORMAL-1;standins (6): standin-01 standin-03 standin-04 standin-08 standin-09 standin-10;diy (22): diy-001 diy-002 diy-003 diy-004 diy-005 diy-007 diy-013 diy-014 diy-015 diy-033 diy-034 diy-035 diy-036 diy-037 diy-109 diy-110 diy-111 diy-112 diy-114 diy-115 diy-116 diy-137
Tests adapted to the rule: op_wang (the second 立即获得两枚棋子 waits out her cycle), playtest6-skills (锏 S2's first cast
may wait one attack interval).
2026-10-08 20:15:14 +08:00

977 lines
52 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, 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: the instant cast still takes one attack cycle each, so attack speed paces it (GitHub #298)', () => {
// S1 度算浪波: AUTO, no duration, no attack of its own, SP_FULL, cost 6; 迅捷 refunds 12 + 15 SP at ≥ 40 layers
const casts = (aspd = 0, enemy = true) => {
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 (enemy) assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 'an enemy stays in range');
u.atkCd = 0;
u.skill.gainSp(u.skill.spCost, 'test');
const n0 = u.skill.activations, a0 = u.stats.attacks;
h.run(2);
checkInvariants(h.b);
return { n: u.skill.activations - n0, attacks: u.stats.attacks - a0, interval: u.s.interval };
};
const base = casts();
assert.ok(base.n >= 2 && base.n <= 3, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 2 s, not every tick`);
assert.equal(base.attacks, 0, 'each cast takes the attack cycle');
const fast = casts(500);
assert.ok(fast.n >= 8 && fast.n <= 9, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`);
const alone = casts(0, false);
assert.ok(alone.n >= 2 && alone.n <= 3, `no enemy: still fires at full SP (#124), one per cycle — ${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();
});