Files
sganggs db29acf907 feedback7 R22-GHSIM: #298 rework — the one-interval gap of an instant skill with no attack of its own sits on the skill (nextCastAt), not on the operator's attack cooldown: its casts no longer replace the attacks / heals that were due; golden: back to 2 moves against the code before #298 (roster-033, roster-034)
Review of fb7-gh-sim (MERGE AFTER FIXES, high): 4128965a wrote u.atkCd after such a cast and gated the tick casts on it,
so each cast took the attack that was due — 度算浪波 with 迅捷: 15 casts, 0 attacks in 20 s (silenced: 15 attacks);
冲锋号令·γ型 and 医疗无人机 lost one attack / heal per cast; after an idle cast the next attack waited the armed cooldown.
PRTS 度算浪波 is an automatic instant throw, not an attack.

Now (skills.js): such a cast (instant / charges, no spec.attack, not the DEFAULT cast right before an attack) sets
skill.nextCastAt = now + the unit's attack interval, and the casts checked in skill.tick wait for it; the attack cooldown
is not touched (reset on deployment, like opReadyAt). Measured (20 s, 引星棘刺 S1, 迅捷 229 layers): 12 casts — the first at
4 s as SP fills (#124), then one per 1.36 s — and 14 attacks, as many as silenced; without 迅捷 3 casts and 14 attacks; an
idle cast does not delay the next attack (1 tick). [ASSUMED] one attack interval stays.

Tests: bonds_addon «迅捷 refills 引星棘刺 S1 at once …» now asserts the casts take no attack (attacks = the silenced run's;
fails on 4128965a: 0 vs 4, and before it: 180 casts in 6 s); op_wang waits skill.nextCastAt; playtest6-skills (锏) back to
its original timing. test/content + test/sim: 2835 pass. Docs: SIM.md §7, PLAYING.md 技能自动释放 (no attack is taken).

Golden: against 4128965a's files roster (21) bonds (9) fields (9) matches (16) standins (6) diy (22) scenarios move back; against the
same code with the gap disabled only roster-033 and roster-034 differ (引星棘刺 S1 recast within one interval) — the
84-scenario move of 4128965a came from the attack cooldown, now gone.
2026-10-08 21:06:45 +08:00

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