Review of fb7-gh-sim (MERGE AFTER FIXES, high):4128965awrote 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 on4128965a: 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 of4128965acame from the attack cooldown, now gone.
979 lines
53 KiB
JavaScript
979 lines
53 KiB
JavaScript
// 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();
|
||
});
|