Files
ark-stronghold-protocol/test/content/kits_t4.test.js
T

1208 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tier-4 operator kits (server/sim/content/kits/ops/chess_char_4_*.js): one signature test per chess (+ elite checks), real battles
// through the harness. Numbers are read back from the data blackboards so the tests follow data changes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import kits from '../../server/sim/content/kits/tier4.js';
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, ...o });
const withTags = (rec, tags) => ({ ...rec, tags });
const noisy = (h, name) => h.hooksOf(name);
const dmgBy = (h, u, pred = () => true) => noisy(h, 'damaged').filter((c) => c.source === u && pred(c));
const T4 = [];
for (let i = 1; i <= 26; i++) T4.push(`chess_char_4_${String(i).padStart(2, '0')}_a`);
test('tier4: every tier-4 chess has a hand-authored kit', () => {
for (const id of T4) assert.equal(typeof kits[id], 'function', id);
assert.equal(Object.keys(kits).length, 26);
});
test('tier4: all 52 kits (normal + elite) fight a mixed wave without content errors', () => {
for (const id of T4.flatMap((a) => [a, a.replace(/_a$/, '_b')])) {
const h = makeBattle({
defs: { enemies: {
e_walk: enemyRec({ key: 'e_walk', hp: 6000, atk: 300, def: 100, res: 20, speed: 1, bat: 1.5 }),
e_fly: enemyRec({ key: 'e_fly', hp: 3000, atk: 150, motion: 'FLY', speed: 1, range: 1.5 }),
} },
units: [{ chessId: id, row: 9, col: 6 }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { chessId: 'chess_char_1_01_a', row: 11, col: 5 }],
enemies: [{ key: 'e_walk', route: 0, count: 6, interval: 3 }, { key: 'e_walk', route: 1, count: 4, interval: 4 }, { key: 'e_fly', route: 2, count: 3, interval: 5 }],
timeLimit: 70, seed: 5, hooks: [],
});
h.runToEnd(80);
assert.equal(h.b.errorCount, 0, `${id}: ${JSON.stringify(h.b.errors.map((e) => e.label + ' ' + e.message))}`);
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------
test('信仰搅拌机 S3: stops attacking, counters only when hit, 30 ammo; +HP/ATK/DEF; idle shield; DEF stacks', () => {
const id = 'chess_char_4_01_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({
defs: { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 400, bat: 1 }) } },
units: [{ chessId: id, row: 9, col: 5 }], timeLimit: 300, hooks: ['ammoUsed', 'attack', 'damaged'], captureNoisy: true,
});
const u = h.unit(id);
h.run(t1.interval + 0.3);
const sh = u.findBuff('rmixer:shield');
assert.ok(sh, 'idle shield after 8 s');
approx(sh.shield, u.s.maxHp * t1.shield);
h.spawn('enemy_hitter', { pos: [9, 5] });
assert.ok(h.runUntil(() => u.skill.active, 60), 'S3 fired');
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'ATK');
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), 1e-6, 'HP');
const t0s = h.b.time;
h.runUntil(() => !u.skill.active, 120);
const tEnd = h.b.time - h.TICK;
const mine = noisy(h, 'attack').filter((c) => c.attacker === u && c.t >= t0s);
const during = mine.filter((c) => c.isSkill).length;
assert.equal(mine.filter((c) => !c.isSkill && c.t < tEnd - 1e-9).length, 0, 'no active attack while the skill runs');
assert.equal(h.hooksOf('ammoUsed').length, bb['attack@trigger_time'], '30 counters then the skill ends');
assert.equal(during, bb['attack@trigger_time'], 'every shot during the skill was a counter');
// talent 1: stacks of DEF +30 / ASPD +3 (≤ 3)
const st = u.findBuff('rmixer:t1');
assert.ok(st && st.stacks === t0.max_stack_cnt, 'DEF stacks capped');
approx(u.s.def, u.base.def + t0.def * t0.max_stack_cnt, 1e-6, 'DEF flat stacks');
checkInvariants(h.b);
});
test('信仰搅拌机 S3 start reloads other 拉特兰 operators (Lv4 +2 / elite +3)', () => {
for (const id of ['chess_char_4_01_a', 'chess_char_4_01_b']) {
const h = makeBattle({ units: [{ chessId: id, row: 9, col: 5 }, { chessId: 'chess_char_1_01_a', row: 10, col: 4 }], timeLimit: 60, hooks: [] });
h.step();
const ins = h.unit('chess_char_1_01_a'), mix = h.unit(id);
assert.ok(ins.skill.activate('test', { free: true }));
const before = ins.skill.ammoLeft;
assert.ok(mix.skill.activate('test', { free: true }));
assert.equal(ins.skill.ammoLeft, before + D(id).skill.bb.ammo);
}
});
test('信仰搅拌机 elite module: reveals stealthed enemies in range', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_4_01_b', row: 9, col: 5 }], timeLimit: 60, hooks: [] });
h.step();
const e = h.spawn('enemy_dummy', { pos: [9, 6] });
h.b.applyStatus(e, 'stealth', { duration: 30 });
h.run(0.5);
assert.ok(e.s.flags.reveal);
});
test('莫斯提马 S3: ripple hits every enemy in range, ATK +90 %, knock-back; talents: SP aura (casters), slow ×3 during S3', () => {
const id = 'chess_char_4_02_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_4_14_a', row: 12, col: 3 }], timeLimit: 120, hooks: ['damaged'], captureNoisy: true,
});
h.run(0.3);
const u = h.unit(id), leo = h.unit('chess_char_4_14_a');
approx(u.s.spRecovery, 1 + t0.sp_recovery_per_sec, 1e-9, 'caster SP aura (self)');
approx(leo.s.spRecovery, 1 + t0.sp_recovery_per_sec, 1e-9, 'caster SP aura (other caster)');
const es = [h.spawn('enemy_dummy', { pos: [10, 4] }), h.spawn('enemy_dummy', { pos: [9, 5] }), h.spawn('enemy_dummy', { pos: [11, 5] })];
h.run(0.3);
approx(es[0].findBuff(`mostma:slow:${u.id}`).mods.moveMul, 1 + t1.move_speed, 1e-9, 'slow');
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6);
h.run(0.3);
const slowBuff = es[0].findBuff(`mostma:slow:${u.id}`);
approx(slowBuff.mods.moveMul, 1 + t1.move_speed * bb.talent_scale, 1e-9, 'slow ×3');
assert.equal(slowBuff.status, 'slow', 'carries the slow status so the client draws its icon');
const x0 = es.map((e) => e.x);
const n0 = dmgBy(h, u).length;
h.run(u.s.interval + 0.1);
const hits = dmgBy(h, u).slice(n0);
assert.ok(es.every((e) => hits.some((c) => c.target === e)), 'all three enemies hit by one ripple');
approx(hits[0].amount, u.s.atk, 1e-6, 'full damage (res 0)');
assert.ok(es.some((e, i) => e.x > x0[i] + 0.2), 'knocked back');
checkInvariants(h.b);
});
test('耶拉 S2: two drones ramp independently on two targets, ATK up; 低眉 +16 % with ground tiles; cold procs', () => {
const id = 'chess_char_4_03_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, tr = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 120, hooks: ['damaged', 'statusApplied'], captureNoisy: true, seed: 11 });
h.step();
const u = h.unit(id);
approx(u.findBuff('kjera:t1').mods.atkPct, t0['kjera_t_1[high].atk'], 1e-9, 'open ground in range');
const a = h.spawn('enemy_dummy', { pos: [10, 4] }), b = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 60));
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk'] + bb.atk), 1e-6);
const n0 = dmgBy(h, u).length;
h.run(8);
const hits = dmgBy(h, u).slice(n0);
const onA = hits.filter((c) => c.target === a).map((c) => c.amount), onB = hits.filter((c) => c.target === b).map((c) => c.amount);
assert.ok(onA.length >= 3 && onB.length >= 3, 'both locked targets attacked');
const atk = u.s.atk;
approx(onA[0], atk * tr.init_atk_scale, 1e-6, 'ramp start');
approx(onA[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'ramp +15 %');
approx(onB[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'second drone ramps on its own');
h.run(15);
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'cold' && c.duration === bb['attack@cold']), 'cold proc');
checkInvariants(h.b);
});
test('伊内丝 S2: stealth, ATK +60 %, +1 DP per attack, steals ASPD (restored at the end); 影织 bind + ATK steal; 影哨 sentry', () => {
const id = 'chess_char_4_04_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy({ atk: 500 }) } }, flags: { dpPerSec: 0 },
units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200, hooks: ['attack', 'statusApplied'], captureNoisy: true,
});
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.runUntil(() => u.stats.attacks >= 1, 5);
h.run(0.3);
assert.ok(noisy(h, 'statusApplied').some((c) => c.target === e && c.status === 'bind' && c.duration === t0.duration), 'bind 5 s');
approx(e.s.atk, e.base.atk - t0.steal_atk, 1e-9, 'enemy ATK stolen');
assert.equal(u.findBuff('ines:atkGain').mods.atkFlat, t0.steal_atk);
assert.ok(e.findBuff(`ines:sentry:${u.id}`)?.flags?.reveal, 'reveal aura');
approx(e.findBuff(`ines:sentry:${u.id}`).mods.moveMul, 1 + t1.move_speed, 1e-9);
assert.ok(h.runUntil(() => u.skill.active, 60));
assert.ok(u.s.flags.stealth, 'stealth');
const dp0 = h.b.players[0].dp, a0 = u.stats.attacks;
h.runUntil(() => !u.skill.active, 30);
const attacks = u.stats.attacks - a0;
assert.ok(attacks >= 3);
approx(h.b.players[0].dp - dp0, attacks * bb.cost, 1e-9, '+1 DP per attack');
assert.equal(e.findBuff(`ines:aspd:${u.id}`), null, 'stolen ASPD returned');
assert.equal(u.findBuff('ines:aspdGain'), null);
// mid-skill steal magnitude
u.skill.activate('test', { free: true });
h.runUntil(() => u.stats.attacks >= a0 + attacks + 3, 10);
h.run(0.3);
const stolen = u.findBuff('ines:aspdGain').mods.aspd;
assert.ok(stolen >= 3 * bb['attack@steal_atk_speed'] - 1e-9 && stolen <= bb['attack@steal_atk_speed_max']);
approx(e.findBuff(`ines:aspd:${u.id}`).mods.aspd, -stolen, 1e-9);
// leaving: stolen ATK returned, a sentry keeps the aura
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
approx(e.s.atk, e.base.atk, 1e-9, 'ATK returned when Ines leaves');
h.run(1);
assert.ok(e.findBuff(`ines:sentry:${u.id}`), 'sentry aura persists');
checkInvariants(h.b);
});
test('伊内丝 elite module: first redeploy 35 % faster', () => {
const id = 'chess_char_4_04_b', tb = D(id).traitBb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200, hooks: [] });
h.step();
const u = h.unit(id);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
approx(u.respawnAt - u.deathAt, u.base.respawnTime * (1 + tb.respawn_time), 1e-9);
h.runUntil(() => u.alive, 60);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
approx(u.respawnAt - u.deathAt, u.base.respawnTime, 1e-9, 'only the first one');
});
test('蜜蜡 S2: obelisk on a melee tile in range, 200 % ATK arts + 1 s stun on appearance, expires; regen talent only while the skill is off', () => {
const id = 'chess_char_4_05_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 9, col: 5 }], timeLimit: 200, hooks: ['damaged', 'statusApplied'], captureNoisy: true });
h.run(0.2);
const u = h.unit(id);
approx(u.s.hpRegen, u.s.maxHp * t0.hp_recovery_per_sec_by_max_hp_ratio + u.base.hpRecoveryPerSec, 1e-6, 'regen when idle');
const e = h.spawn('enemy_dummy', { pos: [9, 7] });
assert.ok(h.runUntil(() => u.skill.active, 60));
const ob = h.b.allyUnits.find((x) => x.defId === 'token_10011_beewax_oblisk' && x.alive);
assert.ok(ob, 'obelisk summoned');
assert.ok(h.b.grid.canStand(ob.tileR, ob.tileC), 'melee tile');
assert.ok(u.rangeKeys.includes(ob.tileR * 21 + ob.tileC), 'inside her range');
const burst = dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst'));
assert.equal(burst.length, 1);
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(noisy(h, 'statusApplied').some((c) => c.target === e && c.status === 'stun' && c.duration === bb.stun));
assert.equal(u.findBuff('beewax:regen'), null, 'no regen during the skill');
const tokDur = h.b.data.getToken('token_10011_beewax_oblisk', u.defId).talents[0].bb.duration;
h.run(tokDur + 0.2);
assert.equal(ob.alive, false, 'obelisk expired');
checkInvariants(h.b);
});
test('寒芒克洛丝 S2: 2-shot, 4-shot after 40 hits; 中的 crits ×1.5 + 0.2 s stun', () => {
const id = 'chess_char_4_06_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
timeLimit: 200, hooks: ['damaged', 'statusApplied'], captureNoisy: true, seed: 3 });
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 60));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-9, 'flat BAT reduction');
const n0 = dmgBy(h, u).length;
h.runUntil(() => !u.skill.active, 40);
const hits = dmgBy(h, u).slice(n0);
const groups = new Map();
for (const c of hits) groups.set(c.t, (groups.get(c.t) ?? 0) + 1);
const sizes = [...groups.values()];
assert.equal(sizes[0], 2, 'double shot');
assert.ok(sizes.includes(4), 'quad shot after 40 hits');
assert.equal(sizes.indexOf(4), bb['attack@max_stack_count'] / 2, 'switches exactly after 40 hits');
const amounts = hits.map((c) => c.amount).sort((a, b) => a - b);
approx(amounts[amounts.length - 1] / amounts[0], t0.atk_scale, 1e-6, 'crit ×1.5');
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'stun' && c.duration === t0.stun));
});
test('风笛 S2: next attack 145 % twice; 精密填弹 extra target ×1.3; 军事传统 +6 SP to 先锋', () => {
const id = 'chess_char_4_07_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: id, row: 9, col: 5 }, { chessId: 'chess_char_4_19_a', row: 11, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [9, 6] }], timeLimit: 200,
hooks: ['damaged', 'attack', 'skillStart'], captureNoisy: true, seed: 2,
});
h.step();
const u = h.unit(id), fl = h.unit('chess_char_4_19_a');
approx(fl.skill.sp, D('chess_char_4_19_a').skill.initSp + t1.sp, 0.1, '焰尾 initial SP +6');
assert.equal(u.skill.activations, 1, '风笛 starts with a charge (0 + 6 SP) and uses it on her first attack');
h.run(30);
const atk = u.s.atk;
const byAttack = new Map();
for (const c of dmgBy(h, u)) (byAttack.get(c.t) ?? byAttack.set(c.t, []).get(c.t)).push(c);
const sk = [...byAttack.values()].filter((l) => l.some((c) => c.dmg.isSkill));
assert.ok(sk.length >= 2, 'skill attacks');
for (const l of sk) {
const perTarget = l.length / new Set(l.map((c) => c.target.id)).size;
assert.equal(perTarget, 2, 'hits twice');
const boosted = new Set(l.map((c) => c.target.id)).size === 2;
approx(l[0].amount, atk * bb.atk_scale * (boosted ? t0.atk_scale : 1), 1e-6);
}
const normal = [...byAttack.values()].filter((l) => !l.some((c) => c.dmg.isSkill));
const boosted = normal.filter((l) => new Set(l.map((c) => c.target.id)).size === 2);
assert.ok(boosted.length > 0, '25 % extra-target attacks happen');
for (const l of boosted) approx(l[0].amount, atk * t0.atk_scale, 1e-6);
checkInvariants(h.b);
});
test('瑰盐 S2: allies in range take 80 % of phys/arts damage now, 20 % as 5 s HP loss; ATK −5 %, allies heal +15 %', () => {
const id = 'chess_char_4_08_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true });
h.step();
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-9);
h.run(0.3);
approx(ally.s.healingTakenMul, t0.heal_scale, 1e-9);
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-9);
const before = noisy(h, 'damaged').length;
h.b.dealDamage(null, ally, { amount: 1000, type: 'arts' });
const now = noisy(h, 'damaged')[before];
const full = 1000 * (1 - ally.s.res / 100);
approx(now.amount, full * bb['attack@damage_scale'], 1e-6, 'immediate part');
h.run(bb['attack@final_duration'] + 0.5);
const later = noisy(h, 'damaged').slice(before + 1).filter((c) => c.target === ally && c.dmg?.tags?.includes('hpLoss'));
assert.equal(later.length, Math.round(bb['attack@final_duration'] / bb['attack@interval']));
approx(later.reduce((s, c) => s + c.amount, 0), full * (1 - bb['attack@damage_scale']), 1e-6, 'deferred part');
checkInvariants(h.b);
});
test('瑰盐 / 白面鸮 / 莱恩哈特 elite modules extend the range — the initial range of the DEFAULT trigger too', () => {
// 攻击范围扩大 = the module's range grid (the chess range + the centre tile [0,3]), not a forward extension of every
// row, and a skill with its own range keeps it (白面鸮 S2)
for (const id of ['chess_char_4_08_b', 'chess_char_4_21_b', 'chess_char_4_14_b']) {
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 20, hooks: [] });
h.step();
const u = h.unit(id);
const modGrid = D(id).raw.modules.find((m) => m.isDefault).talentChanges.find((t) => t.rangeGrid).rangeGrid;
assert.equal(u.s.rangeExtend, 0, `${id}: no forward extension`);
const own = absoluteRangeKeys(D(id).rangeGrid, u.tileR, u.tileC, u.facing, 0);
const mod = absoluteRangeKeys(modGrid, u.tileR, u.tileC, u.facing, 0);
assert.equal(mod.length, own.length + 1, `${id}: one tile more`);
assert.deepEqual([...u.baseRangeKeys].sort((x, y) => x - y), [...mod].sort((x, y) => x - y), `${id}: module range = initial range`);
assert.deepEqual([...u.rangeKeys].sort((x, y) => x - y), [...mod].sort((x, y) => x - y), `${id}: module range = range`);
assert.ok(u.rangeKeys.includes(u.tileR * 21 + u.tileC + 3) && !u.rangeKeys.includes((u.tileR - 1) * 21 + u.tileC + 3), `${id}: only the centre row grows`);
}
{
const id = 'chess_char_4_21_b';
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 20, hooks: [] });
h.step();
const u = h.unit(id);
assert.ok(u.skill.activate('test', { free: true }));
h.step();
assert.equal(u.rangeKeys.length, D(id).skill.rangeGrid.length, '白面鸮 S2 keeps its own range');
}
// 莱恩哈特 (DEFAULT): an enemy standing only on the module tile fires S2
const id = 'chess_char_4_14_b';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3, carryState: { sp: 99 } }], timeLimit: 20, hooks: [] });
h.step();
const u = h.unit(id);
const own = absoluteRangeKeys(D(id).rangeGrid, u.tileR, u.tileC, u.facing, 0);
const extra = u.baseRangeKeys.find((k) => !own.includes(k) && Math.floor(k / 21) === 10);
h.spawn('enemy_dummy', { pos: [Math.floor(extra / 21), extra % 21] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 3), 'DEFAULT trigger from the module tile');
checkInvariants(h.b);
});
test('水月: 创伤性癔症 50 % arts to the lowest-HP target; S2 +1 target & bind, faster, +ATK; 反移情; elite slow', () => {
const id = 'chess_char_4_09_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 4 }], timeLimit: 200,
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [11, 5] }], hooks: ['damaged', 'statusApplied'], captureNoisy: true, seed: 9 });
const u = h.unit(id);
h.runUntil(() => dmgBy(h, u, (c) => c.dmg?.tags?.includes('talent')).length >= 1, 20);
const tal = dmgBy(h, u, (c) => c.dmg?.tags?.includes('talent'));
approx(tal[0].amount, u.s.atk * t0['attack@mizuki_t_1.atk_scale'], 1e-6);
assert.ok(h.runUntil(() => u.skill.active, 60));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-9, 'flat BAT');
const n0 = dmgBy(h, u, (c) => c.dmg?.tags?.includes('talent')).length;
h.runUntil(() => !u.skill.active, 30);
const during = dmgBy(h, u, (c) => c.dmg?.tags?.includes('talent')).slice(n0);
const perAttack = new Map();
for (const c of during) perAttack.set(c.t, (perAttack.get(c.t) ?? 0) + 1);
assert.ok([...perAttack.values()].every((n) => n === 2), 'talent hits two targets during S2');
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'bind' && c.duration === bb['attack@unmovable']));
// 反移情
const e = h.enemies()[0];
h.b.dealDamage(null, e, { amount: e.hp * 0.6, type: 'true' });
h.run(0.2);
assert.equal(u.findBuff('mizuki:t2')?.mods.atkPct, t1.atk);
// elite module slow
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_4_09_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], timeLimit: 10, hooks: [] });
g.run(0.5);
const mv = D('chess_char_4_09_b').talents.find((t) => !t.name && t.bb.move_speed).bb.move_speed;
approx(g.enemies()[0].findBuff(`mizuki:slow:${g.unit('chess_char_4_09_b').id}`).mods.moveMul, 1 + mv, 1e-9);
});
test('阿罗玛: first attack ×1.1 + 2.5 s levitation; S2: +ATK and landing damage 65 % ATK', () => {
const id = 'chess_char_4_10_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200, hooks: ['damaged', 'statusApplied'], captureNoisy: true });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.runUntil(() => dmgBy(h, u, (c) => c.target === e).length >= 2, 20);
const hits = dmgBy(h, u, (c) => c.target === e && c.dmg.isAttack);
approx(hits[0].amount, hits[1].amount * t0.damage_scale, 1e-6, 'first attack ×1.1');
assert.ok(noisy(h, 'statusApplied').some((c) => c.target === e && c.status === 'levitate' && c.duration === t0.levitate_duration));
// a second enemy: skill on before it is bubbled → landing damage
assert.ok(u.skill.activate('test', { free: true }));
const e2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // on her line: struck by the next attack too (轰击术师)
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-9);
h.runUntil(() => dmgBy(h, u, (c) => c.dmg?.tags?.includes('landing')).length >= 1, 15);
const land = dmgBy(h, u, (c) => c.dmg?.tags?.includes('landing'));
assert.ok(land.length >= 1 && land[0].target === e2, 'landing damage');
approx(land[0].amount, u.s.atk * bb['attack@atk_scale_when_fly_finish'], 1e-6);
checkInvariants(h.b);
});
test('阿罗玛 elite module: farther targets take up to ×1.1', () => {
const id = 'chess_char_4_10_b', tb = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
const u = h.unit(id);
h.runUntil(() => dmgBy(h, u, (c) => c.dmg.isAttack).length >= 2, 20);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack);
approx(hits[1].amount, u.s.atk * (1 + tb.damage_scale), 1e-6, 'distance 5 ≥ max_dist');
});
test('凯瑟琳: 2 placed support devices shield allies (20 % of her HP), 6 %/s after 5 s unhit; S2: 6 %/s regardless, no attacks', () => {
const id = 'chess_char_4_11_a', bb = D(id).skill.bb;
// the devices are hand pieces the player places and turns (user playtest #6): one faces the ally, one faces her
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 5, uid: 3, dir: 'RIGHT' },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 6, uid: 4, dir: 'LEFT' },
],
timeLimit: 120, hooks: ['attack'], captureNoisy: true,
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const devs = h.b.allyUnits.filter((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
assert.equal(devs.length, 2, 'two devices deployed');
assert.ok(devs.every((d) => d.s.flags.untargetable && d.s.flags.invulnerable), 'untargetable and immune (不会受到攻击)');
assert.equal(h.b.dealDamage(null, devs[0], { amount: 1e6, type: 'true' }), 0);
const tb = h.b.data.getToken('token_10041_cathy_catsld', u.defId).talents[0].bb;
approx(ally.findBuff('cathy:shield').shield, u.s.maxHp * tb.max_shield_ratio, 1e-6, 'initial shield');
h.b.dealDamage(null, ally, { amount: u.s.maxHp * 0.15, type: 'true' });
const left = ally.findBuff('cathy:shield').shield;
h.run(tb.interval - 1);
approx(ally.findBuff('cathy:shield').shield, left, 1e-6, 'no refill while recently hit');
h.run(2.1);
assert.ok(ally.findBuff('cathy:shield').shield > left + u.s.maxHp * tb.shield_ratio_each_trigger * 0.99, 'refills 6 %/s');
// S2
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), 1e-9);
const a0 = noisy(h, 'attack').filter((c) => c.attacker === u).length;
h.b.dealDamage(null, ally, { amount: 1e5, type: 'phys' }); // breaks the shield (and more)
h.run(0.05);
if (!ally.alive) return;
h.run(1.05);
const s1 = ally.findBuff('cathy:shield')?.shield ?? 0;
approx(s1, u.s.maxHp * bb.overwrite_ratio, 0.01, 'S2: +6 % even right after a hit');
assert.equal(noisy(h, 'attack').filter((c) => c.attacker === u).length, a0, 'no attacks during S2');
});
test('歌蕾蒂娅 S3: binds the farthest target, tornado pulses 85 % ATK arts every 1.5 s + pull, slows −50 %, final pull; 弱肉强食 ×1.3', () => {
const id = 'chess_char_4_12_a', bb = D(id).skill.bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200,
hooks: ['damaged', 'statusApplied'], captureNoisy: true });
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 4] });
const far = h.spawn('enemy_dummy', { pos: [10, 6] });
h.runUntil(() => dmgBy(h, u, (c) => c.dmg.isAttack).length >= 1, 10);
approx(dmgBy(h, u, (c) => c.dmg.isAttack)[0].amount, u.s.atk * t1.atk_scale, 1e-6, 'weight ≤ 3 ×1.3');
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(noisy(h, 'statusApplied').some((c) => c.target === far && c.status === 'bind'), 'farthest target bound');
h.run(0.2);
approx(far.findBuff(`glady:slow:${u.id}`).mods.moveMul, 1 + bb.move_speed, 1e-9);
const x0 = far.x;
h.runUntil(() => !u.skill.active, 20);
const pulses = dmgBy(h, u, (c) => c.dmg?.tags?.includes('tornado') && c.target === far);
assert.equal(pulses.length, Math.floor(D(id).skill.duration / bb.interval + 1e-9), 'one pulse every 1.5 s');
approx(pulses[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(far.x < x0 - 0.3, 'pulled towards her at the end');
checkInvariants(h.b);
});
test('歌蕾蒂娅 阿戈尔的波涛: 深海猎人 regen 2.5 %/s and −25 % damage from 海怪; elite drag damage', () => {
const id = 'chess_char_4_12_a', t0 = D(id).talents[0].bb;
const sea = withTags(dummy({ key: 'enemy_sea', atk: 0 }), ['seamonster']);
const h = makeBattle({ defs: { enemies: { enemy_sea: sea } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_2_07_a', row: 9, col: 5 }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
h.run(0.3);
const ghost = h.unit('chess_char_2_07_a');
approx(ghost.findBuff('glady:tide').mods.hpRegenRatio, t0.hp_recovery_per_sec_by_max_hp_ratio, 1e-9);
const e = h.spawn('enemy_sea', { pos: [12, 9] });
const n0 = noisy(h, 'damaged').length;
h.b.dealDamage(e, ghost, { amount: 1000, type: 'arts' });
const got = noisy(h, 'damaged')[n0];
approx(got.amount, 1000 * (1 - ghost.s.res / 100) * (1 - t0.damage_resistance), 1e-6);
// elite: drag damage during pulls
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_4_12_b', row: 10, col: 3 }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
g.step();
const gl = g.unit('chess_char_4_12_b');
g.spawn('enemy_dummy', { pos: [10, 5] });
g.spawn('enemy_dummy', { pos: [10, 6] });
g.step();
gl.skill.activate('test', { free: true });
g.runUntil(() => !gl.skill.active, 20);
assert.ok(dmgBy(g, gl, (c) => c.dmg?.tags?.includes('drag')).length > 0, 'drag damage');
});
test('灵知 S2: 130 % ATK arts + 2.5 s cold to all in range; fully charged cast freezes; 坚冰 fragile; 殊途同归 resistance', () => {
const id = 'chess_char_4_13_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 12, col: 3 }], timeLimit: 300,
hooks: ['damaged', 'statusApplied', 'skillStart'], captureNoisy: true });
h.step();
const u = h.unit(id), kj = h.unit('chess_char_1_02_a');
// 殊途同归: after 10 s a 谢拉格 operator halves negative statuses
h.run(t1.interval + 0.3);
assert.ok(kj.findBuff('resist'), 'the engine 抵抗 status');
h.b.applyStatus(kj, 'stun', { duration: 4 });
approx(kj.findBuff('stun').timeLeft, 4 * (1 + t1.one_minus_status_resistance), 1e-9);
// both charges stored (no enemy for a while) ⇒ the first cast is a charged one
assert.ok(h.runUntil(() => u.skill.charges === u.skill.maxCharges, 30));
const a = h.spawn('enemy_dummy', { pos: [10, 4] }), b = h.spawn('enemy_dummy', { pos: [11, 5] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5));
const burst = dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst'));
assert.equal(burst.length, 2, 'both enemies');
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(a.s.flags.freeze && b.s.flags.freeze, 'charged cast: second cold ⇒ freeze');
h.run(0.2);
approx(a.findBuff('gnosis:fragile').mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
// next (single-charge) cast only chills once per enemy
assert.ok(h.runUntil(() => u.skill.activations >= 2, 20));
const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t);
const colds = (T) => noisy(h, 'statusApplied').filter((c) => c.source === u && c.status === 'cold' && c.duration === bb.cold && c.t === T).length;
assert.equal(colds(starts[0]), 4, 'charged: two colds on each of the two enemies');
assert.equal(colds(starts[1]), 2, 'single charge: one cold each');
// normal attacks chill for 1 s
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'cold' && c.duration === t0.cold));
checkInvariants(h.b);
});
test('灵知 殊途同归 抵抗: scales the incoming status (never shortens a longer one), once for several 灵知, never on top of 流明’s 抵抗', () => {
const id = 'chess_char_4_13_a', t1 = D(id).talents[1].bb;
const mul = 1 + t1.one_minus_status_resistance;
// two 灵知 on the board: still a single ×0.5
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_4_13_b', row: 11, col: 3 }, { chessId: 'chess_char_1_02_a', row: 12, col: 3 }], timeLimit: 300, hooks: [] });
const kj = h.unit('chess_char_1_02_a');
h.run(t1.interval + 0.3);
assert.ok(kj.findBuff('resist'));
h.b.applyStatus(kj, 'stun', { duration: 10 });
approx(kj.findBuff('stun').timeLeft, 10 * mul, 1e-9, 'one 抵抗 for two 灵知');
// a shorter resisted re-application never cuts the running one (8 s → 4 s < 5 s left)
h.b.applyStatus(kj, 'stun', { duration: 8 });
approx(kj.findBuff('stun').timeLeft, 10 * mul, 1e-9, 'the longer stun keeps running');
checkInvariants(h.b);
// 流明 (凡人之愿) also grants 抵抗 to the 谢拉格 operator he heals: the two never compound
const g = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_6_14_a', row: 11, col: 3 }, { chessId: 'chess_char_1_02_a', row: 12, col: 3 }], timeLimit: 300, hooks: [] });
const k2 = g.unit('chess_char_1_02_a');
g.run(t1.interval + 0.3);
k2.hp = k2.s.maxHp * 0.5;
assert.ok(g.runUntil(() => k2.hp > k2.s.maxHp * 0.5 + 1, 10), '流明 healed the 谢拉格 operator');
const r = k2.findBuff('resist');
assert.ok(r && r.timeLeft > 1, '凡人之愿 抵抗 (outlasts the 灵知 pulse) — one single engine status');
assert.equal(k2.buffs.filter((b) => b.status === 'resist').length, 1);
g.b.applyStatus(k2, 'stun', { duration: 4 });
approx(k2.findBuff('stun').timeLeft, 4 * mul, 1e-9, 'a single ×0.5');
checkInvariants(g.b);
});
test('莱恩哈特 S2: 170 % ATK arts to all enemies in the wider skill range + RES −8 % for 6 s; 破片杀伤 +4 % per enemy', () => {
const id = 'chess_char_4_14_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 120, hooks: ['damaged'], captureNoisy: true });
h.step();
const u = h.unit(id);
const inR = h.spawn('enemy_dummy', { pos: [10, 5] }), outR = h.spawn('enemy_dummy', { pos: [10, 6] }); // +3 only in the skill range
h.run(0.2);
assert.equal(u.findBuff('lionhd:t1').mods.atkPct, t0.atk * 1, 'one enemy in the normal range');
assert.ok(h.runUntil(() => u.skill.activations >= 1, 30));
const burst = dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst'));
assert.ok(burst.some((c) => c.target === outR), 'reaches the wider range');
approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6);
approx(outR.s.res, 50 * (1 + bb.magic_resistance), 1e-9);
h.run(bb.duration + 0.1);
approx(outR.s.res, 50, 1e-9, 'debuff expired');
assert.ok(inR.alive);
});
test('同名效果取最高: two 灵知 never compound 坚冰, two 莱恩哈特 never compound the RES cut (one instance per enemy, the strongest)', () => {
// DESIGN §20.10 (the 奥术 rule): a pair / 联防 partner's copy of the same operator puts the same-named effect on the
// same enemy — one instance, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"), never m × m
const gn = 'chess_char_4_13_a', t0 = D(gn).talents[0].bb;
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: gn, row: 10, col: 3 }, { chessId: gn, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
g.step();
const both = g.b.allyUnits.filter((u) => u.def.id === gn);
assert.equal(both.length, 2, 'two 灵知 on the board');
const e = g.spawn('enemy_dummy', { pos: [10, 4] });
let seen = 0;
for (let i = 0; i < 120; i++) { // 4 s: chilled / frozen by both
g.step();
const fr = e.buffs.filter((b) => String(b.key).startsWith('gnosis:fragile'));
assert.ok(fr.length <= 1, `one 坚冰 instance (${fr.map((b) => b.key)})`);
if (!fr.length) continue;
seen++;
const want = e.s.flags.freeze ? t0.damage_scale_freeze : t0.damage_scale_cold;
approx(e.s.dmgTakenMul, fr[0].mods.dmgTakenMul, 1e-9, 'the only damage-taken modifier');
assert.ok(e.s.dmgTakenMul <= t0.damage_scale_freeze + 1e-9 && fr[0].mods.dmgTakenMul >= want - 1e-9, `never ${t0.damage_scale_cold}² / ${t0.damage_scale_freeze}² (${e.s.dmgTakenMul})`);
}
assert.ok(seen > 30, `坚冰 was up (${seen})`);
const lh = 'chess_char_4_14_a', bb = D(lh).skill.bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: lh, row: 10, col: 3 }, { chessId: lh, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === lh);
assert.equal(two.length, 2);
const x = h.spawn('enemy_dummy', { pos: [10, 5] });
h.step();
for (const u of two) assert.ok(u.skill.activate('test', { free: true }), 'S2 cast');
assert.equal(x.buffs.filter((b) => String(b.key).startsWith('lionhd:res')).length, 1, 'one RES cut');
approx(x.s.res, 50 * (1 + bb.magic_resistance), 1e-9, 'RES −8 % once, not twice');
h.run(bb.duration + 0.1);
approx(x.s.res, 50, 1e-9, 'expired');
});
test('录武官 S2: healed allies regain 80 HP whenever damaged for 10 s; 学成于聚 SP +1 & ASPD +16 when an operator in range casts', () => {
const id = 'chess_char_4_15_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const sp0 = u.skill.sp;
ally.skill.activate('test', { free: true });
approx(u.skill.sp, sp0 + t0.sp, 1e-9);
approx(u.s.aspd, u.base.aspd + t0.attack_speed, 1e-9);
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-9);
h.b.dealDamage(null, ally, { amount: ally.s.maxHp * 0.6, type: 'true' });
h.runUntil(() => ally.findBuff(`reckpr:guard:${u.id}`), 5);
assert.ok(ally.findBuff(`reckpr:guard:${u.id}`), 'guard buff after her heal');
const n0 = noisy(h, 'heal').length;
h.b.dealDamage(null, ally, { amount: 300, type: 'true' });
const hl = noisy(h, 'heal').slice(n0).filter((c) => c.source === u && c.target === ally);
assert.equal(hl.length, 1);
approx(hl[0].amount, bb['attack@fixed_heal_value'] * ally.s.healingTakenMul, 1e-6);
});
test('录武官 / 华法琳 elite trait: heals on allies below 50 % are ×1.15', () => {
for (const id of ['chess_char_4_15_b', 'chess_char_4_26_b']) {
const tb = D(id).traitBb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 30, hooks: ['heal'], captureNoisy: true });
h.step();
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
h.b.dealDamage(null, ally, { amount: ally.s.maxHp * 0.8, type: 'true' });
h.runUntil(() => noisy(h, 'heal').some((c) => c.source === u && c.target === ally), 10);
const first = noisy(h, 'heal').find((c) => c.source === u && c.target === ally);
approx(first.amount, u.s.atk * tb.heal_scale, 1e-6, id);
}
});
test('缄默德克萨斯 S3 (passive): deploy burst 2 × 115 % arts + 1.5 s stun, sword rain ≤ 2 targets/s for 6 s; talents', () => {
const id = 'chess_char_4_16_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 120, hooks: ['damaged', 'statusApplied'], captureNoisy: true });
h.step();
const u = h.unit(id);
approx(u.s.aspd, u.base.aspd + t1.attack_speed, 1e-9, 'swordplay ASPD');
approx(u.s.dmgTakenMul, 1 - t1.damage_resistance, 1e-9);
const es = [h.spawn('enemy_dummy', { pos: [9, 4] }), h.spawn('enemy_dummy', { pos: [11, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] })];
h.b.retreat(u);
assert.ok(h.b.redeploy(u));
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-9, 'ATK +20 % during the passive');
const burst = dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst'));
assert.equal(burst.length, 6, 'two hits on each of the 3 surrounding enemies');
approx(burst[0].amount, u.s.atk * bb['appear.atk_scale'], 1e-6);
assert.equal(noisy(h, 'statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.duration === bb['appear.stun']).length, 3);
h.run(D(id).skill.duration + 1.5);
const rain = dmgBy(h, u, (c) => c.dmg?.tags?.includes('swordRain'));
const waves = new Map();
for (const c of rain) waves.set(c.t, (waves.get(c.t) ?? 0) + 1);
assert.equal(waves.size, D(id).skill.duration, 'one rain per second while the passive lasts');
assert.ok([...waves.values()].every((n) => n === bb.max_target), '≤ 2 distinct targets');
approx(rain[0].amount, u.base.atk * (1 + t0.atk) * bb.atk_scale, 1e-6);
assert.equal(u.findBuff('texas2:rainAtk'), null, 'ATK bonus ends with the passive');
});
test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed', () => {
const id = 'chess_char_4_16_a';
const h = makeBattle({ defs: { enemies: { enemy_weak: dummy({ key: 'enemy_weak', hp: 50 }), enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 60, hooks: [] });
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.5, type: 'true' });
h.spawn('enemy_weak', { pos: [9, 5] });
h.runUntil(() => u.mem.texasKilled, 10);
assert.ok(u.mem.texasKilled);
approx(u.hpRatio, 1, 1e-3, 'full heal');
assert.equal(u.findBuff('texas2:swordplay'), null);
assert.ok(u.findBuff('texas2:rainAtk'), 'passive recast');
});
test('星熊 S2 (passive): DEF +13 % and 65 % ATK thorns; 战术装甲 negates ~25 % of hits; 重装 aura DEF +6 %', () => {
const id = 'chess_char_4_17_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 900, bat: 0.5 }) } }, units: [{ chessId: id, row: 9, col: 5 }, { chessId: 'chess_char_1_02_a', row: 11, col: 5 }],
timeLimit: 200, hooks: ['damaged', 'attack'], captureNoisy: true, seed: 4 });
h.run(0.3);
const u = h.unit(id), mh = h.unit('chess_char_1_02_a');
approx(u.s.def, u.base.def * (1 + bb.def + t1.def), 1e-9);
approx(mh.s.def, mh.base.def * (1 + t1.def), 1e-9, '角峰 (重装) aura');
const e = h.spawn('enemy_hitter', { pos: [9, 5] });
h.run(60);
const swings = noisy(h, 'attack').filter((c) => c.attacker === e).length;
const landed = noisy(h, 'damaged').filter((c) => c.source === e && c.target === u).length;
const thorns = dmgBy(h, u, (c) => c.dmg?.tags?.includes('counter'));
assert.equal(thorns.length, landed, 'one thorn per landed attack');
approx(thorns[0].amount, u.s.atk * bb.atk_scale, 1e-6);
const blockRate = 1 - landed / swings;
assert.ok(Math.abs(blockRate - t0.prob) < 0.08, `block rate ${blockRate}`);
});
test('泥岩 S2: after 5 hits the next attack heals 4 % and deals 190 % phys to all ground enemies around; 沃土予身 layers; 萨卡兹 −30 %', () => {
const id = 'chess_char_4_18_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const sarkaz = withTags(dummy({ key: 'enemy_sarkaz', atk: 300, bat: 1 }), ['sarkaz']);
const h = makeBattle({ defs: { enemies: { enemy_sarkaz: sarkaz, enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }],
timeLimit: 200, hooks: ['damaged', 'heal', 'skillStart'], captureNoisy: true, seed: 6 });
h.step();
const u = h.unit(id);
assert.equal(u.findBuff('mudrok:layers').shieldHits, t0.times, '1 layer on deploy');
const side = h.spawn('enemy_dummy', { pos: [11, 6] }); // diagonal: outside her normal range
const hitter = h.spawn('enemy_sarkaz', { pos: [10, 5] }); // blocked, attacks her
h.runUntil(() => noisy(h, 'damaged').some((c) => c.source === hitter && c.target === u), 5);
const first = noisy(h, 'damaged').find((c) => c.source === hitter && c.target === u);
assert.equal(first.amount, 0, 'first hit absorbed by the layer');
assert.ok(noisy(h, 'heal').some((c) => c.source === u && c.target === u), 'broken layer heals');
const second = noisy(h, 'damaged').filter((c) => c.source === hitter && c.target === u && c.amount > 0);
h.runUntil(() => noisy(h, 'damaged').filter((c) => c.source === hitter && c.target === u && c.amount > 0).length >= 1, 5);
const hit = noisy(h, 'damaged').find((c) => c.source === hitter && c.target === u && c.amount > 0);
approx(hit.amount, Math.max(300 - u.s.def, 15) * (1 - t1.damage_resistance), 1e-6, 'sarkaz −30 %');
assert.ok(second.length >= 0);
assert.ok(h.runUntil(() => u.skill.activations >= 1, 30));
h.run(u.s.interval + 0.1);
const slam = dmgBy(h, u, (c) => c.dmg.isSkill);
assert.ok(slam.some((c) => c.target === side), 'hits the ground enemy around her');
approx(slam.find((c) => c.target === side).amount, u.s.atk * bb.atk_scale, 1e-6);
checkInvariants(h.b);
});
test('焰尾 S3: +8 DP over the skill, 60 % dodge, block +1; 前锋剑术 riposte after a dodge; 卡西米尔 aura +22 % dodge', () => {
const id = 'chess_char_4_19_a', bb = D(id).skill.bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 200, bat: 0.5 }) } }, flags: { dpPerSec: 0 },
units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_4_20_a', row: 12, col: 3 }], timeLimit: 200, hooks: ['damaged', 'dodge'], captureNoisy: true, seed: 8 });
h.run(0.3);
const u = h.unit(id), ft = h.unit('chess_char_4_20_a');
approx(ft.s.dodgePhys, t1.prob, 1e-9, '远牙 is 卡西米尔');
h.spawn('enemy_hitter', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 60));
approx(u.s.dodgePhys, t1.prob, 1e-9, 'talent aura on herself (the S3 dodge is an independent roll)');
assert.equal(u.s.blockCnt, u.base.blockCnt + bb.block_cnt);
approx(u.s.interval, u.base.bat * bb.base_attack_time * 100 / u.s.aspd, 1e-9, '×0.7 attack interval');
const dp0 = h.b.players[0].dp;
h.runUntil(() => !u.skill.active, 20);
approx(h.b.players[0].dp - dp0, bb.value * bb.cost, 1e-9, '+8 DP');
h.run(20);
assert.ok(noisy(h, 'dodge').some((c) => c.target === u));
assert.ok(dmgBy(h, u, (c) => c.dmg?.tags?.includes('riposte')).length > 0, 'riposte hit');
});
test('远牙 S3: infinite line, ATK +80 %, ×1.25 beyond the normal range, taunt −1; 凝神 +15 %', () => {
const id = 'chess_char_4_20_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_near: dummy({ key: 'enemy_near', def: 200 }), enemy_far: dummy({ key: 'enemy_far', def: 0 }) } },
units: [{ chessId: id, row: 10, col: 2 }], timeLimit: 120, hooks: ['damaged'], captureNoisy: true });
h.run(0.2);
const u = h.unit(id);
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-9, 'not hurt ⇒ +15 %');
const far = h.spawn('enemy_far', { pos: [10, 10] });
h.spawn('enemy_near', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 60));
assert.equal(u.s.taunt, u.base.tauntLevel + t1.taunt_level);
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk), 1e-9);
h.runUntil(() => dmgBy(h, u, (c) => c.target === far).length >= 1, 10);
approx(dmgBy(h, u, (c) => c.target === far)[0].amount, u.s.atk * bb.damage_scale, 1e-6, 'outside the original range ×1.25');
h.b.dealDamage(null, u, { amount: 10, type: 'true' });
h.run(0.2);
assert.equal(u.findBuff('fartth:focus'), null, 'hurt ⇒ no 凝神');
});
test('白面鸮: SP aura +0.3/s for all allies (highest SP aura wins); S2 faster heals in a wider range', () => {
const id = 'chess_char_4_21_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 9, col: 5 }, { chessId: 'chess_char_4_02_a', row: 12, col: 4 }], timeLimit: 30, hooks: [] });
h.run(0.3);
const u = h.unit(id), mh = h.unit('chess_char_1_02_a'), mo = h.unit('chess_char_4_02_a');
approx(mh.s.spRecovery, 1 + t0.sp_recovery_per_sec, 1e-9);
approx(mo.s.spRecovery, 1 + Math.max(t0.sp_recovery_per_sec, D('chess_char_4_02_a').talents[0].bb.sp_recovery_per_sec), 1e-9, 'not cumulative');
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-9);
assert.ok(u.rangeKeys.length > u.baseRangeKeys.length);
});
test('银灰 S3: ATK +125 %, DEF −70 %, ≤ 4 targets at full (melee) scale; 领袖 −10 % redeploy for the team; 鹰眼视觉 reveals', () => {
const id = 'chess_char_4_22_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 12, col: 3 }], timeLimit: 200, hooks: ['damaged'], captureNoisy: true });
h.step();
const u = h.unit(id), mh = h.unit('chess_char_1_02_a');
const es = [[10, 6], [11, 4]].map((p) => h.spawn('enemy_dummy', { pos: p }));
h.b.applyStatus(es[1], 'stealth', { duration: 100 });
h.run(0.5);
assert.ok(es[1].s.flags.reveal, 'revealed');
h.runUntil(() => dmgBy(h, u, (c) => c.dmg.isAttack).length >= 1, 5);
const pre = dmgBy(h, u, (c) => c.dmg.isAttack)[0];
for (const p of [[10, 4], [10, 5], [9, 4]]) es.push(h.spawn('enemy_dummy', { pos: p }));
approx(pre.amount, u.s.atk * D(id).traitBb.atk_scale, 1e-6, 'ranged ×0.8 before the skill');
assert.ok(h.runUntil(() => u.skill.active, 90));
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk), 1e-9);
approx(u.s.def, u.base.def * (1 + bb.def), 1e-9);
const n0 = dmgBy(h, u).length;
h.run(u.s.interval + 0.05);
const hits = dmgBy(h, u).slice(n0);
assert.equal(new Set(hits.map((c) => c.target.id)).size, bb['attack@max_target']);
approx(hits[0].amount, u.s.atk, 1e-6, 'melee scale');
h.b.dealDamage(null, mh, { amount: 1e9, type: 'true' });
approx(mh.respawnAt - mh.deathAt, mh.base.respawnTime * (1 + t0.respawn_time), 1e-9);
});
test('银灰 elite module: attacks add 10 % ATK arts', () => {
const id = 'chess_char_4_22_b', tb = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }], timeLimit: 20, hooks: ['damaged'], captureNoisy: true });
const u = h.unit(id);
h.runUntil(() => dmgBy(h, u, (c) => c.dmg?.tags?.includes('module')).length >= 1, 5);
approx(dmgBy(h, u, (c) => c.dmg?.tags?.includes('module'))[0].amount, u.s.atk * tb.atk_scale_m, 1e-6);
});
test('百炼嘉维尔 S3: ATK/ASPD/block up; only 50 % damage now, the rest as 20 s HP loss afterwards; talents', () => {
const id = 'chess_char_4_23_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 120, hooks: ['heal', 'damaged'], captureNoisy: true });
h.run(0.2);
const u = h.unit(id);
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-9, '战地巨斧');
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk), 1e-9);
assert.equal(u.s.blockCnt, u.base.blockCnt + bb.block_cnt);
const hp0 = u.hp;
h.b.dealDamage(null, u, { amount: 1000, type: 'true' });
approx(hp0 - u.hp, 1000 * (1 - bb.damage_resistance), 1e-9, 'half now');
u.skill.end('test');
const hp1 = u.hp;
h.run(bb.final_duration + 0.5);
const loss = noisy(h, 'damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hpLoss')).reduce((s, c) => s + c.amount, 0);
approx(loss, 1000 * bb.damage_resistance, 1e-6, 'the rest over 20 s');
assert.ok(u.hp <= hp1);
// 医学背景
h.b.heal(null, u, 100);
const hl = noisy(h, 'heal').filter((c) => c.target === u).pop();
approx(hl.amount, 100 * (u.hpRatio < t1.hp_ratio ? t1.heal_scale_2 : t1.heal_scale_1), 1e-9);
});
test('卡涅利安 S2: faster AoE with 0.3 s sluggish; charged cast: ATK +10 %, bind, 80 % heal; 蓄势待发 +0.6 SP/s', () => {
const id = 'chess_char_4_24_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 300, hooks: ['statusApplied', 'heal'], captureNoisy: true });
h.step();
const u = h.unit(id);
h.runUntil(() => u.skill.charges >= 1, 40);
h.run(0.2);
approx(u.s.spRecovery, 1 + t1.sp_recovery_per_sec, 1e-9, 'SP regen up once a charge is stored');
h.runUntil(() => u.skill.charges >= u.skill.maxCharges, 60);
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.9, type: 'true' });
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-9, 'charged ATK bonus');
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-9);
const selfHeal = noisy(h, 'heal').filter((c) => c.source === u && c.target === u).pop();
approx(selfHeal.amount, u.s.maxHp * t0['billro_t_1[enhance].heal_scale'], 1e-6, 'charged heal ×2');
h.run(3);
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'bind' && c.duration === bb['attack@root']));
h.runUntil(() => !u.skill.active, 30);
// an uncharged cast: sluggish
h.runUntil(() => u.skill.active, 40);
h.run(3);
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'sluggish' && c.duration === bb['attack@sluggish']));
checkInvariants(h.b);
});
test('卡涅利安 / 蜜蜡 elite trait: keep DEF +100 % / RES +10 during the skill', () => {
for (const [id, pre] of [['chess_char_4_24_b', 'billro_e_002'], ['chess_char_4_05_b', 'soil_e_002']]) {
const tb = D(id).traitBb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 30, hooks: [] });
h.step();
const u = h.unit(id);
u.skill.activate('test', { free: true });
approx(u.s.def, u.base.def * (1 + tb[`${pre}[buff].def`]), 1e-9, id);
approx(u.s.res, u.base.res + tb[`${pre}[buff].magic_resistance`], 1e-9, id);
}
});
test('魔王 S3: range up, trait heal 65 % ATK/s, 鼓舞 +65 % of her max HP, HP redistributed every 2 s; motes ×1.5; 萨卡兹 −10 %', () => {
const id = 'chess_char_4_25_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb, t1 = D(id).talents[1].bb;
const sarkaz = withTags(dummy({ key: 'enemy_sarkaz' }), ['sarkaz']);
const h = makeBattle({ defs: { enemies: { enemy_sarkaz: sarkaz } }, units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { chessId: 'chess_char_4_17_a', row: 9, col: 5 }],
timeLimit: 120, hooks: ['heal', 'damaged'], captureNoisy: true });
h.run(0.5);
const u = h.unit(id), a = h.unit('chess_char_1_02_a'), b = h.unit('chess_char_4_17_a');
assert.ok(a.findBuff(`cetsyr:mote:${u.id}`) && b.findBuff(`cetsyr:mote:${u.id}`), 'adjacent operators collect motes');
// 萨卡兹 damage reduction
const e = h.spawn('enemy_sarkaz', { pos: [12, 9] });
const n0 = noisy(h, 'damaged').length;
h.b.dealDamage(e, a, { amount: 1000, type: 'true' });
approx(noisy(h, 'damaged')[n0].amount, 1000 * (1 - t1.damage_resistance), 1e-9);
// trait heal ×1.5 with a mote
h.runUntil(() => noisy(h, 'heal').some((c) => c.source === u && c.target === a), 3);
const hl = noisy(h, 'heal').find((c) => c.source === u && c.target === a);
approx(hl.amount, u.s.atk * D(id).traitBb['attack@atk_to_hp_recovery_ratio'] * t0['attack@trait_mul'] * a.s.healingTakenMul, 1e-6);
// S3
h.b.dealDamage(null, b, { amount: b.s.maxHp * 0.7, type: 'true' });
assert.ok(u.skill.activate('test', { free: true }));
h.run(0.3);
approx(a.s.maxHp, a.base.maxHp + u.s.maxHp * bb.max_hp, 1e-6, '鼓舞');
approx(u.s.maxHp, u.base.maxHp, 1e-9, 'not on herself');
let redistributed = false;
for (let i = 0; i < 90 && !redistributed; i++) { h.step(); redistributed = h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'redistribute'); }
assert.ok(redistributed);
approx(a.hpRatio, b.hpRatio, 1e-9, 'equal HP ratios after redistribution');
h.runUntil(() => noisy(h, 'heal').filter((c) => c.source === u && c.target === b && c.t > 0.8).length >= 1, 3);
const sh = noisy(h, 'heal').filter((c) => c.source === u && c.target === b).pop();
approx(sh.amount, u.s.atk * bb['attack@atk_to_hp_recovery_ratio'] * t0['attack@trait_mul'] * b.s.healingTakenMul, 1e-6, 'trait 65 %');
h.runUntil(() => !u.skill.active, 40);
h.run(0.3);
approx(a.s.maxHp, a.base.maxHp, 1e-9, '鼓舞 gone');
checkInvariants(h.b);
});
test('魔王 elite module: ATK +8 % with ≥ 2 other operators in her normal range', () => {
const id = 'chess_char_4_25_b';
const mb = D(id).talents.find((t) => !t.name && t.bb.atk).bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { chessId: 'chess_char_4_17_a', row: 9, col: 5 }], timeLimit: 10, hooks: [] });
h.run(0.5);
approx(h.unit(id).s.atk, h.unit(id).base.atk * (1 + mb.atk), 1e-9);
});
test('华法琳 S1: only fires on an ally below 50 %: +15 % of its max HP; 血液样本回收 +2 SP (self + random ally)', () => {
const id = 'chess_char_4_26_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal', 'skillStart'], captureNoisy: true });
h.step();
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
u.skill.gainSp(u.skill.spCost);
assert.equal(u.skill.charges, 1);
h.b.dealDamage(null, ally, { amount: ally.s.maxHp * 0.3, type: 'true' });
h.runUntil(() => noisy(h, 'heal').some((c) => c.source === u), 5);
assert.equal(noisy(h, 'skillStart').filter((c) => c.unit === u).length, 0, 'not cast above 50 %');
h.b.dealDamage(null, ally, { amount: ally.hp - ally.s.maxHp * 0.3, type: 'true' });
h.runUntil(() => noisy(h, 'skillStart').some((c) => c.unit === u), 5);
const extra = noisy(h, 'heal').filter((c) => c.source === u && c.target === ally).pop();
approx(extra.amount, ally.s.maxHp * bb.hp_ratio * ally.s.healingTakenMul, 1e-6);
// talent: an enemy dies in range
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
const s0 = u.skill.sp, a0 = ally.skill.sp;
h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
approx(u.skill.sp, Math.min(u.skill.spCost, s0 + t0['bldsk_t_1[self].sp']), 1e-9);
approx(ally.skill.sp, Math.min(ally.skill.spCost, a0 + t0['bldsk_t_1[rand].sp']), 1e-9);
});
test('elite modules: 泥岩 −15 % from blocked enemies, 百炼嘉维尔 ×1.1 on blocked, 远牙 up to +15 % by distance, 缄默德克萨斯 lonely ATK +10 %', () => {
// 泥岩
{
const id = 'chess_char_4_18_b', tb = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.step();
assert.equal(e.blockedBy, u);
u.findBuff('mudrok:layers') && h.b.removeBuff(u, 'mudrok:layers');
u.mem.layers = 0;
const n0 = noisy(h, 'damaged').length;
h.b.dealDamage(e, u, { amount: 1000, type: 'true' });
approx(noisy(h, 'damaged')[n0].amount, 1000 * tb.damage_scale, 1e-9);
}
// 百炼嘉维尔
{
const id = 'chess_char_4_23_b', tb = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
const u = h.unit(id);
h.runUntil(() => dmgBy(h, u).length >= 1, 5);
approx(dmgBy(h, u)[0].amount, u.s.atk * tb.atk_scale, 1e-6);
}
// 远牙
{
const id = 'chess_char_4_20_b', tb = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], timeLimit: 30, hooks: ['damaged'], captureNoisy: true });
const u = h.unit(id);
h.runUntil(() => dmgBy(h, u).length >= 1, 5);
approx(dmgBy(h, u)[0].amount, u.s.atk * (1 + tb.damage_scale * Math.min(1, (4 - tb.min_dist) / (tb.max_dist - tb.min_dist))), 1e-6);
}
// 缄默德克萨斯
{
const id = 'chess_char_4_16_b', tb = D(id).traitBb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 12, col: 8 }], timeLimit: 30, hooks: [] });
h.run(0.3);
const u = h.unit(id);
approx(u.s.atk, u.base.atk * (1 + t0.atk + tb.atk), 1e-9, 'alone');
const g = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }], timeLimit: 30, hooks: [] });
g.run(0.3);
approx(g.unit(id).s.atk, g.unit(id).base.atk * (1 + t0.atk), 1e-9, 'neighbour');
}
});
// --------------------------------------------------------------------------------------------------------------
// fidelity-verification regressions
test('缄默德克萨斯: a kill by the deploy burst itself (redeploy) ⇒ full heal + recast, no 剑术 buff', () => {
const id = 'chess_char_4_16_a';
const h = makeBattle({ defs: { enemies: { enemy_weak: dummy({ key: 'enemy_weak', hp: 50 }), enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }],
timeLimit: 60, hooks: ['heal', 'damaged'], captureNoisy: true });
h.step();
const u = h.unit(id);
h.spawn('enemy_weak', { pos: [9, 5] });
assert.ok(h.runUntil(() => u.mem.texasKilled, 10), 'first deployment: first kill');
h.b.retreat(u);
assert.equal(u.mem.texasKilled, false, 'per-deployment state reset when she leaves');
h.spawn('enemy_weak', { pos: [9, 5] });
h.spawn('enemy_dummy', { pos: [10, 6] });
const heals0 = noisy(h, 'heal').filter((c) => c.source === u && c.target === u).length;
const bursts0 = dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst')).length;
assert.ok(h.b.redeploy(u));
assert.equal(u.mem.texasKilled, true, 'the deploy burst scored the kill');
assert.equal(u.findBuff('texas2:swordplay'), null, '剑术 is not granted after the kill');
assert.equal(noisy(h, 'heal').filter((c) => c.source === u && c.target === u).length, heals0 + 1, 'full-heal attempt');
// burst (2 hits × 2 enemies, the weak one dies on the first) + recast burst (2 hits on the survivor)
assert.ok(dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst')).length - bursts0 >= 4, 'passive re-released');
checkInvariants(h.b);
});
test('耶拉 S2: against a lone enemy both drones lock it and ramp independently', () => {
const id = 'chess_char_4_03_a', tr = D(id).traitBb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 120, hooks: ['damaged', 'attack'], captureNoisy: true, seed: 11 });
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.active, 60));
const n0 = dmgBy(h, u).length;
h.run(4);
const hits = dmgBy(h, u).slice(n0);
const byT = new Map();
for (const c of hits) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c.amount / u.s.atk);
const volleys = [...byT.values()];
assert.ok(volleys.length >= 3);
assert.ok(volleys.every((v) => v.length === 2), 'two drone hits per attack');
approx(volleys[0][0], tr.init_atk_scale, 1e-6);
approx(volleys[0][1], tr.init_atk_scale, 1e-6, 'second drone starts its own ramp');
approx(volleys[1][1], tr.init_atk_scale + tr.delta_atk_scale, 1e-6);
checkInvariants(h.b);
});
test('寒芒克洛丝: dodged shots neither count towards the 40 hits nor stun', () => {
const id = 'chess_char_4_06_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 60, hooks: ['statusApplied', 'dodge'], seed: 3 });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
assert.ok(u.skill.activate('test', { free: true }));
h.run(6);
assert.ok(h.hooksOf('dodge').length >= 8, 'shots were fired and dodged');
assert.equal(u.mem.kroosHits ?? 0, 0);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'stun').length, 0);
});
test('星熊 战术装甲: blocks only physical/arts damage (true damage always lands)', () => {
const id = 'chess_char_4_17_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 9, col: 5 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, seed: 4 });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
const n0 = noisy(h, 'damaged').filter((c) => c.target === u).length;
for (let i = 0; i < 40; i++) h.b.dealDamage(e, u, { amount: 1, type: 'true' });
assert.equal(noisy(h, 'damaged').filter((c) => c.target === u).length - n0, 40);
for (let i = 0; i < 80; i++) h.b.dealDamage(e, u, { amount: 1, type: 'arts' });
const arts = noisy(h, 'damaged').filter((c) => c.target === u).length - n0 - 40;
assert.ok(arts < 75 && arts > 45, `arts blocked ~25 %: ${arts}/80 landed`);
});
test('华法琳 血液样本回收: the random SP gift skips allies whose timed skill is running', () => {
const id = 'chess_char_4_26_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: [] });
h.step();
const ally = h.unit('chess_char_1_02_a');
assert.ok(ally.skill.activate('test', { free: true }) && ally.skill.isTimed);
const sp0 = ally.skill.sp;
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
assert.equal(ally.skill.sp, sp0, 'no SP while its skill runs');
});
test('瑰盐 S2 only defers damage taken by operators (summons take it in full)', () => {
const id = 'chess_char_4_08_a', bb = D(id).skill.bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true });
h.step();
const u = h.unit(id);
const tok = h.b.spawnToken(u, 'test_token', 10, 4, { def: { name: 'tok', stats: { maxHp: 5000, atk: 0, def: 0, magicResistance: 0, blockCnt: 0, baseAttackTime: 1, attackSpeed: 100 }, rangeGrid: [[0, 0]], profession: 'TOKEN' } });
assert.ok(tok && u.rangeKeys.includes(tok.tileR * 21 + tok.tileC));
assert.ok(u.skill.activate('test', { free: true }));
const n0 = noisy(h, 'damaged').length;
h.b.dealDamage(null, tok, { amount: 1000, type: 'true' });
h.b.dealDamage(null, tok, { amount: 1000, type: 'phys' });
assert.equal(noisy(h, 'damaged')[n0 + 1].amount, 1000, 'full damage on the summon');
h.run(bb['attack@final_duration'] + 0.5);
assert.equal(noisy(h, 'damaged').filter((c) => c.target === tok && c.dmg?.tags?.includes('hpLoss')).length, 0);
});
test('卡涅利安 S2: every enemy hit by the group attack is slowed', () => {
const id = 'chess_char_4_24_a', bb = D(id).skill.bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 60, hooks: ['statusApplied'] });
h.step();
const u = h.unit(id);
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [11, 6] });
u.skill.gainSp(u.skill.spCost); // one charge (uncharged cast)
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
for (const e of [a, b]) assert.ok(h.hooksOf('statusApplied').some((c) => c.target === e && c.status === 'sluggish' && c.duration === bb['attack@sluggish']), `enemy ${e.id}`);
});
test('elite (精锐) kits read the Lv7 blackboard: skill magnitudes differ from Lv4 and match the data', () => {
const mk = (id, extra = {}) => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 120, hooks: ['damaged', 'statusApplied', 'heal'], captureNoisy: true, seed: 5, ...extra });
h.step();
return [h, h.unit(id), D(id).skill.bb];
};
// stat skills: activate and compare
for (const [id, check] of [
['chess_char_4_01_b', (u, bb) => { approx(u.s.atk, u.base.atk * (1 + bb.atk)); approx(u.s.def, u.base.def * (1 + bb.def)); }],
['chess_char_4_02_b', (u, bb) => approx(u.s.atk, u.base.atk * (1 + bb.atk))],
['chess_char_4_04_b', (u, bb) => approx(u.s.atk, u.base.atk * (1 + bb.atk))],
['chess_char_4_06_b', (u, bb) => approx(u.s.interval, u.base.bat + bb.base_attack_time)],
['chess_char_4_09_b', (u, bb) => approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd)],
['chess_char_4_11_b', (u, bb) => approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp))],
['chess_char_4_15_b', (u, bb) => approx(u.s.atk, u.base.atk * (1 + bb.atk))],
['chess_char_4_21_b', (u, bb) => approx(u.s.interval, u.base.bat + bb.base_attack_time)],
['chess_char_4_22_b', (u, bb) => approx(u.s.atk, u.base.atk * (1 + D('chess_char_4_22_b').talents[0].bb.atk + bb.atk))],
['chess_char_4_23_b', (u, bb) => approx(u.s.aspd, u.base.aspd + bb.attack_speed)],
]) {
const [h, u, bb] = mk(id);
assert.notDeepEqual(bb, D(id.replace(/_b$/, '_a')).skill.bb, `${id}: Lv7 blackboard differs`);
assert.ok(u.skill.activate('test', { free: true }), id);
check(u, bb);
checkInvariants(h.b);
}
// charges: 风笛 ×2 at 160 %, 华法琳 ×3
{ const [, u, bb] = mk('chess_char_4_07_b'); assert.equal(u.skill.maxCharges, 2); assert.equal(bb.atk_scale, 1.6); }
{ const [, u, bb] = mk('chess_char_4_26_b'); assert.equal(u.skill.maxCharges, bb.ct); }
// 灵知: 160 % and 3 s cold
{
const [h, u, bb] = mk('chess_char_4_13_b');
const e = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 20));
approx(dmgBy(h, u, (c) => c.dmg?.tags?.includes('burst'))[0].amount, u.s.atk * bb.atk_scale);
assert.ok(noisy(h, 'statusApplied').some((c) => c.target === e && c.status === 'cold' && c.duration === bb.cold));
}
// 莱恩哈特: 200 % and RES −12 %
{
const [h, u, bb] = mk('chess_char_4_14_b', { defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } } });
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 30));
approx(e.s.res, 50 * (1 + bb.magic_resistance));
}
// 录武官: 120 HP per hit taken
{
const [h, u, bb] = mk('chess_char_4_15_b', { units: [{ chessId: 'chess_char_4_15_b', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }] });
const ally = h.unit('chess_char_1_02_a');
u.skill.activate('test', { free: true });
h.b.dealDamage(null, ally, { amount: ally.s.maxHp * 0.4, type: 'true' });
assert.ok(h.runUntil(() => ally.findBuff(`reckpr:guard:${u.id}`), 5));
const n0 = noisy(h, 'heal').length;
h.b.dealDamage(null, ally, { amount: 10, type: 'true' });
const hl = noisy(h, 'heal').slice(n0).find((c) => c.source === u && c.target === ally);
assert.equal(bb['attack@fixed_heal_value'], 120);
approx(hl.amount, bb['attack@fixed_heal_value'] * ally.s.healingTakenMul * (ally.hpRatio < D('chess_char_4_15_b').traitBb.hp_ratio ? D('chess_char_4_15_b').traitBb.heal_scale : 1), 1e-6);
}
// 缄默德克萨斯: 3 rain targets for 7 s
{
const [h, u, bb] = mk('chess_char_4_16_b', { units: [{ chessId: 'chess_char_4_16_b', row: 10, col: 5 }] });
for (const p of [[9, 4], [11, 6], [10, 4], [9, 6]]) h.spawn('enemy_dummy', { pos: p });
h.b.retreat(u); h.b.redeploy(u);
h.run(D('chess_char_4_16_b').skill.duration + 1.5);
const waves = new Map();
for (const c of dmgBy(h, u, (c) => c.dmg?.tags?.includes('swordRain'))) waves.set(c.t, (waves.get(c.t) ?? 0) + 1);
assert.equal(waves.size, D('chess_char_4_16_b').skill.duration);
assert.ok([...waves.values()].every((n) => n === bb.max_target));
}
// 歌蕾蒂娅 S3 tornado pulse on a weight-1 enemy √2 from the marked point (PRTS 推与拉): elite 中力 (1) = 受力等级 0 ⇒ all the
// way to the point (its 0.05 急停); normal 小力 (0) = −1 ⇒ 35 % of the starting distance
{
const pulled = (id) => {
const [h, u] = mk(id);
h.spawn('enemy_dummy', { pos: [10, 6] });
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
h.step();
const x0 = side.x, y0 = side.y;
u.skill.activate('test', { free: true });
h.run(D(id).skill.bb.interval + 0.1);
return Math.hypot(side.x - x0, side.y - y0);
};
approx(pulled('chess_char_4_12_b'), Math.SQRT2 - 0.05, 0.02);
approx(pulled('chess_char_4_12_a'), 0.35 * Math.SQRT2, 0.02);
}
});
test('焰尾 S3 dodge stacks independently with the 卡西米尔 aura (elite 80 % + 22 % ≈ 84 %, never 100 %)', () => {
for (const id of ['chess_char_4_19_a', 'chess_char_4_19_b']) {
const bb = D(id).skill.bb, q = D(id).talents[1].bb.prob;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 200, hooks: ['dodge', 'damaged'], seed: 21 });
h.run(0.3);
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
assert.ok(u.skill.activate('test', { free: true }));
let landed = 0;
const N = 3000;
for (let i = 0; i < N; i++) if (h.b.dealDamage(e, u, { amount: 1, type: i % 2 ? 'phys' : 'arts', isAttack: true }) > 0 || u.hp < u.s.maxHp) { landed++; u.hp = u.s.maxHp; }
const physRate = 1 - (1 - bb.prob) * (1 - q), artsRate = bb.prob;
const expected = N * (1 - (physRate + artsRate) / 2);
assert.ok(Math.abs(landed - expected) < 0.06 * N, `${id}: ${landed} landed, ~${Math.round(expected)} expected`);
assert.ok(landed > 0, 'never fully immune');
}
});
test('凯瑟琳: a placed device serves the operator it faces and re-shields it after it redeploys on that tile', () => {
const id = 'chess_char_4_11_a';
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 7, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 6, uid: 3, dir: 'RIGHT' },
],
timeLimit: 200, hooks: [],
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const tb = h.b.data.getToken('token_10041_cathy_catsld', u.defId).talents[0].bb;
const dev = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
assert.ok(dev && dev.tileR === 9 && dev.tileC === 6, 'on its placed tile');
assert.equal(dev.mem.target, ally, 'serves the operator in front of it');
h.b.dealDamage(null, ally, { amount: 1e9, type: 'true' });
assert.equal(ally.alive, false);
assert.ok(h.runUntil(() => ally.alive, 150), 'redeployed');
h.run(1.1);
approx(ally.findBuff('cathy:shield')?.shield ?? 0, u.s.maxHp * tb.max_shield_ratio, 1e-6, 'full talent shield again');
});