PRTS: 分支特性信息 吟游者 「特性为基于自身攻击力来增加受益者的“生命回复速度”属性」 (魔王, 浊心斯卡蒂); the 备注 of 调香师 熏衣草, 瑕光 S2, 铃兰 S3 and 锡人 S2 「生命恢复的提供方式为增加目标的“生命回复速度”属性…不受治疗加成和禁疗影响」 (铃兰: none in the first second, refreshed every second); 异常效果 禁疗 「增减生命回复速度…的效果不会被识别为治疗类能力」. These were heals from the provider, which damage.js refused on 收割者 / 不屈者 / 武者 (noHeal) and under 禁疗 — 隐德来希 gained nothing under 魔王. This is the separation proposed in PR #135 by @SpiritedAwayCN, written anew (the PR's patch is not used): - professions.js bardRegen: the trait is an hpRegen buff per bard refreshed every 0.25 s; 魔王's 微尘 ×1.5 goes through its `bardRegen` hook (both 魔王 kits); 浊心斯卡蒂's pulse and the 海嗣 token use it too. - 调香师 (every ally), 瑕光 S2 (the skill range, removed at its end), 铃兰 S3 (from the first full second, removed at its end), 锡人 (per 炼金单元, its cached ATK): hpRegen buffs instead of battle.heal. - damage.js: a `regen` tick takes no healing multiplier and a heal hook cannot change its amount (no 治疗加成 on any 生命回复速度); 流明's 凡人之愿 and 华法琳's S1 bonus skip a regen tick of their own. Direct heals are unchanged (still refused / scaled); 魔王 S3's redistribution and 纯烬's HoT are untouched. Other per-second recoveries without such a note stay heals (引星棘刺, 塞雷娅 S3, 流明 / 纯烬 HoTs; 余 闲云隐市 and 天马之盔 left open). Golden moved (every one fields 魔王 / 浊心斯卡蒂 / 调香师 / 瑕光 S2 / 铃兰 / 锡人): roster-004, roster-005, roster-006, roster-011, roster-013, roster-015, roster-016, roster-018, roster-023, roster-025, roster-030, roster-032, roster-035, roster-037, roster-039, roster-041, roster-042, roster-045, roster-046; bond-deputShip-high, bond-emptyShip-low, bond-emptyShip-high, bond-indomShip-high, bond-kazimierzShip-low, bond-kazimierzShip-high, bond-sargonShip-low, bond-sargonShip-high, bond-skillfulShip-high; boss-boss_1-pair, boss-boss_1-solo, boss-boss_2-solo, boss-boss_3-pair, boss-boss_3-solo, boss-boss_4-solo, boss-boss_5-pair, boss-boss_5-solo, boss-boss_6-solo, boss-boss_7-pair, boss-boss_7-solo, hidden-boss_8-solo, hidden-boss_9-pair, hidden-boss_9-solo, hidden-boss_10-solo, unite-1; solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-NORMAL-14-standins. Docs: SIM.md (heal pipeline, bard row), PLAYING.md, kits/README.md (checklist item 11).
1208 lines
73 KiB
JavaScript
1208 lines
73 KiB
JavaScript
// 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 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);
|
||
// the trait: 生命回复速度 (an hpRegen buff — PRTS 分支特性信息 吟游者; professions.js bardRegen), ×1.5 with a mote
|
||
const trait = (x) => x.findBuff(`trait:bard:${u.id}`)?.mods.hpRegen ?? 0;
|
||
h.run(0.3);
|
||
approx(trait(a), u.s.atk * D(id).traitBb['attack@atk_to_hp_recovery_ratio'] * t0['attack@trait_mul'], 1e-6, 'trait ×1.5 with a mote');
|
||
assert.equal(noisy(h, 'heal').filter((c) => c.source === u && c.target === a).length, 0, 'no heal of hers');
|
||
// 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.run(0.3);
|
||
approx(trait(b), u.s.atk * bb['attack@atk_to_hp_recovery_ratio'] * (b.findBuff(`cetsyr:mote:${u.id}`) ? t0['attack@trait_mul'] : 1), 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');
|
||
});
|