Files
ark-stronghold-protocol/test/content/op_lmlee.test.js
T
sganggs 8627791e53 feedback5 O19: 自选 kits for 老鲤, 温蒂 (with her 工程蓄水炮) and 望 (with her 棋子 and 跟子)
Three owned 6★ picks get their operator kits (kits/ops/op-lmlee.js, op-weedy.js, op-wang.js, appended to
OPERATOR_KIT_FILES): every skill at rank 4 / 7, both talents, the trait and every module at stage 1 / 3, and the kits of
the two summons (set up by the owner's talent, as 鸿雪's 打字机).

- 老鲤 (行商): MER-X 2 DP, MER-Y ATK +4 % per payment (5 stacks); 和气生财 (±14, ×2 alone; MER-Y Lv3 ±20); 有备无患 through
  the merchantPay hook (5 DP for a guard against the next 晕眩 / 冻结, counter-stun 3 s; MER-X Lv3 4 DP / 4 s); S1 / S3 fire
  at full SP (the owner's AUTO rule), S2's mark bursts per its PRTS notes, S3's push and outside-range dodge.
- 温蒂 (推击手): S1 next attack (push + stun), S2 water mode (×3.2 interval, range +1, 0.9 splash, pushes), S3 liquid
  nitrogen (1.2 splash, push, 移动创伤 1200 per tile); the cannon (20 s, back on its tile, nearest target, pushes, its
  forced skill, fires with her S3 beside her); 蓄水炮强化 SP (PUS-X Lv3 every 2 s and ATK +20 %); PUS-X refund on a 高台,
  PUS-Y +1 force while blocking two; her composed record takes a 高台 (shared/highGround.js unchanged).
- 望 (陷阱师): hits air; 棋子 activation, trigger, in-place return (2 s, cost; TRP-X 2 DP), 跟子 in the PRTS order, 料敌机先
  stacks, S1 DoT + 停顿, S2 line damage + additive slow, S3 x-6 passive and the 天下劫 overflow / bullets.

Match: the 自选 hand count of a placeable summon reads the active module's byModule deploy limit (server/match/player/
diy.js placeableTokens) — 望 with TRP-X gets 7 棋子; nobody else changes (多萝西's 13 stays at the 9 cap).
Docs: PLAYING.md §4 (温蒂 on a 高台), SIM.md §8 (陷阱师 air through the data), kits/README.md (module deploy limit).

Golden: only diy moved — diy-106 … diy-109 are now the 老鲤 / 温蒂 battles (the prototype battles they held move on),
diy-110 … diy-115 are new (望, then the prototypes, seeds per index); roster, bonds, fields, matches, standins unchanged.
Tests: test/content/op_lmlee.test.js (8), op_weedy.test.js (10), op_wang.test.js (8).
2026-10-06 05:09:27 +08:00

317 lines
19 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.
// test/content/op_lmlee.test.js — the 自选 operator kit of 老鲤 (char_322_lmlee, 6★ 行商; kit
// server/sim/content/kits/ops/op-lmlee.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, MER-X
// “无问吉凶” or MER-Y “但思善恶” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the
// fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_lmlee.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { GUARD_KEY, MARK_RADIUS } from '../../server/sim/content/kits/ops/op-lmlee.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const LEE = 'char_322_lmlee';
const FORMS = BACKUPS.units[LEE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_lmlee', Y = 'uniequip_003_lmlee';
const S1 = 'skchr_lmlee_1', S2 = 'skchr_lmlee_2', S3 = 'skchr_lmlee_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
/** The talent `index` of a form with the picked module's change. */
const talentOf = (tier, elite, mod, index) => modOf(tier, elite ? mod : null)?.talentChanges.find((t) => t.talentIndex === index) ?? formOf(tier, elite).talents.find((t) => t.index === index);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'),
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
enemy_light: dummy('enemy_light', { hp: 1000 }),
enemy_gun: dummy('enemy_gun', { atk: 300, range: 5, bat: 1, dmgType: 'phys' }),
enemy_close: dummy('enemy_close', { atk: 300, range: 1.5, bat: 1, dmgType: 'phys' }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
/** A battle with 老鲤 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, dp = 0 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'dodge', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LEE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.b.players[0].dp = dp;
h.step();
return { h, u: h.unit(1) };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
test('老鲤 in every 自选 form: his kit (all three skills authored), stats + module attributes, 1-1, blocks 1, melee physical ground-only, 行商, 炎, no 特质; S1 / S3 SP_FULL, S2 the data\'s DEFAULT', () => {
assert.equal(OPERATOR_KITS[LEE], KITS[LEE]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [LEE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat, u.profile.sub], [1, 'melee', false, 'phys', 1, 'merchant'], `${label(f)}: 行商`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.equal(u.skill.rule, skill === 1 ? form.skills[1].trigger.rule : 'SP_FULL', `${label(f)}: trigger`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => [s.skillType, s.trigger.rule]), [['AUTO', 'DEFAULT'], ['MANUAL', 'DEFAULT'], ['AUTO', 'DEFAULT']]);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 200, atk: 55 }, { maxHp: 300, atk: 74 }, { atk: 57, def: 35 }, { atk: 76, def: 50 }]);
const data = { chess: CHESS, backups: BACKUPS };
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LEE), `tier ${t}`);
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: LEE, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('trait 行商: −3 DP every 3 s while he stands (MER-X: −2), he retreats when the DP runs short; MER-Y: ATK +4 % per payment, 5 stacks at most', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1, dp: 4 }); // (S2: no cast without an enemy, no ATK bonus)
const cost = elite && mod === X ? 2 : 3;
assert.deepEqual([u.profile.merchantInterval, u.profile.merchantCost], [3, cost], label(f));
h.b.players[0].dp = 100;
const t1 = talentOf(tier, elite, mod, 1);
const extra = Math.abs(t1.bb.extra_cost);
h.run(3.05); // first payment: 有备无患 takes |extra_cost| (DP ≥ it, no guard yet)
approx(h.b.players[0].dp, 100 - extra, `${label(f)}: first payment`);
h.run(3);
approx(h.b.players[0].dp, 100 - extra - cost, `${label(f)}: then the trait's cost`);
const y = elite && mod === Y;
const st = u.findBuff('trait:lmlee:merY');
assert.equal(st?.stacks ?? 0, y ? 2 : 0, `${label(f)}: MER-Y stacks`);
if (y) {
h.run(30);
assert.equal(u.findBuff('trait:lmlee:merY').stacks, 5, `${label(f)}: 5 at most`);
approx(u.s.atk, u.base.atk * (1 + 5 * 0.04), `${label(f)}: ATK +20 %`);
}
h.b.players[0].dp = cost - 0.5;
h.run(3.1);
assert.ok(!u.alive && !u.deployed, `${label(f)}: retreats when the DP runs short`);
done(h);
}
});
test('T1 和气生财: while he blocks one enemy alone in the 3 × 3 — his ASPD +28, its −28; a second enemy around ⇒ ±14; nothing while he blocks nobody (MER-Y stage 3: ±40 / ±20)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5, dp: 999 });
const tb = talentOf(tier, elite, mod, 0).bb;
const self = tb['lmlee_t_1[self].attack_speed'], foe = tb['lmlee_t_1[enemy].attack_speed'];
assert.deepEqual([self, foe, tb.cnt], elite && tier === 6 && mod === Y ? [20, -20, 1] : [14, -14, 1], label(f));
h.run(0.5);
assert.equal(u.findBuff('talent:lmlee:self'), null, `${label(f)}: blocking nobody`);
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => w.blockedBy === u, 30), `${label(f)}: the walker reaches him`);
h.step();
approx(u.s.aspd, 100 + 2 * self + (u.skill.active ? 0 : 0), `${label(f)}: ×2 alone`);
approx(w.s.aspd, 100 + 2 * foe, `${label(f)}: its ASPD ×2`);
const other = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
approx(u.s.aspd, 100 + self, `${label(f)}: two enemies around`);
approx(w.s.aspd, 100 + foe, `${label(f)}: its ASPD`);
h.b.kill(other, null);
h.b.kill(w, null);
h.run(0.3);
assert.equal(u.findBuff('talent:lmlee:self'), null, `${label(f)}: gone with the block`);
done(h);
}
});
test('T2 有备无患: with DP ≥ 5 a payment costs 5 and arms the guard (MER-X stage 3: 4); the guard cancels the next 晕眩 / 冻结 and stuns its enemy source 3 s (4 s); no second guard while one is held; DP 3–4 pays the trait cost only', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const tb = talentOf(tier, elite, mod, 1).bb;
const x3 = elite && tier === 6 && mod === X;
assert.deepEqual([Math.abs(tb.extra_cost), tb.stun], x3 ? [4, 4] : [5, 3], label(f));
const cost = elite && mod === X ? 2 : 3;
const dp0 = Math.abs(tb.extra_cost) - 1;
const { h, u } = field({ tier, elite, mod, skill: 1, dp: dp0 });
h.run(3.05);
approx(h.b.players[0].dp, dp0 - cost, `${label(f)}: DP < |extra_cost| ⇒ the trait cost only`);
assert.equal(u.findBuff(GUARD_KEY), null, `${label(f)}: no guard`);
h.b.players[0].dp = 20;
h.run(3);
approx(h.b.players[0].dp, 20 - Math.abs(tb.extra_cost), `${label(f)}: armed`);
assert.ok(u.findBuff(GUARD_KEY), `${label(f)}: guard`);
h.run(3);
approx(h.b.players[0].dp, 20 - Math.abs(tb.extra_cost) - cost, `${label(f)}: guard held ⇒ trait cost`);
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
assert.equal(h.b.applyStatus(u, 'stun', { duration: 5, source: e }), false, `${label(f)}: stun cancelled`);
assert.ok(!u.s.flags.stun && u.findBuff(GUARD_KEY) === null, `${label(f)}: guard spent`);
assert.ok(e.s.flags.stun, `${label(f)}: the source is stunned`);
approx(e.findBuff('stun').timeLeft, tb.stun, `${label(f)}: ${tb.stun} s`, 0.05);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 1, source: e }), true, `${label(f)}: the next one lands`);
h.run(3.1); // re-armed at the next payment (DP is there)
assert.ok(u.findBuff(GUARD_KEY), `${label(f)}: re-armed`);
h.b.applyStatus(u, 'cold', { duration: 3, source: e });
h.b.applyStatus(u, 'cold', { duration: 3, source: e }); // cold on cold ⇒ 冻结: cancelled
assert.ok(!u.s.flags.freeze && u.findBuff(GUARD_KEY) === null, `${label(f)}: 冻结 cancelled`);
done(h);
}
});
test('S1 小惩大诫 (AUTO, SP_FULL): on at 11 / 10 s with nobody on the field; ATK +25 % / +40 %, 法术闪避 20 % / 25 %, for the rest of the deployment', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.spCost, sk.bb.atk, sk.bb.prob], elite ? [10, 0.4, 0.25] : [11, 0.25, 0.2], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0, dp: 999 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['toggle', 'SP_FULL']);
h.run(sk.spCost - 0.5);
assert.equal(u.skill.activations, 0);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: on at full SP`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.dodgeArts, sk.bb.prob, `T${tier}: 法术闪避`);
h.run(200);
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
done(h);
}
});
test('S2 驱凶辟邪: passive ASPD +15 / +20; cast on his attack, the target is marked (taunt +1) and bursts 5 s later for (230 % + 10 % × hits) / (260 % + 15 % × hits) ATK arts within radius 1 (flyers too)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb.attack_speed, sk.bb.default_atk_scale, sk.bb.factor_atk_scale, sk.bb.max_stack_cnt, sk.bb.paper_duration, sk.bb.taunt_level],
elite ? [20, 2.6, 0.15, 25, 5, 1] : [15, 2.3, 0.1, 20, 5, 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, dp: 999 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT']);
approx(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: passive`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody to attack`);
const t = h.spawn('enemy_dummy', { pos: [10, 6] });
const near = h.spawn('enemy_fly', { pos: [11, 6] }), far = h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast`);
const mark = t.findBuff(`lmlee:mark:${u.id}`);
assert.ok(mark, `T${tier}: marked`);
assert.equal(t.s.taunt, 1, `T${tier}: taunt +1`);
const t0 = h.b.time;
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => !t.findBuff(`lmlee:mark:${u.id}`), 6), `T${tier}: bursts`);
approx(h.b.time - t0, sk.bb.paper_duration, `T${tier}: after 5 s`, 0.05);
const dmg = h.hooksOf('damaged').slice(n0);
const burst = dmg.filter((c) => c.source === u && c.dmg.tags.includes('lmlee:burst'));
const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.source === u && !c.dmg.tags.includes('lmlee:burst') && h.b.time >= t0).length;
assert.deepEqual(burst.map((c) => c.target).sort((a, b) => a.id - b.id), [t, near].sort((a, b) => a.id - b.id), `T${tier}: radius ${MARK_RADIUS}, the flyer too, not 2 tiles away`);
assert.ok(!burst.some((c) => c.target === far));
for (const c of burst) {
assert.equal(c.type, 'arts');
approx(c.amount, u.s.atk * (sk.bb.default_atk_scale + hits * sk.bb.factor_atk_scale), `T${tier}: ${hits} hits counted`, 1e-3);
}
assert.ok(hits >= 4, `T${tier}: his attacks were counted (${hits})`);
done(h);
}
});
test('S2 驱凶辟邪 bursts early: at the hit cap, when the target is knocked out, on a recast; when he is knocked out; and vanishes when he leaves without blocking it', () => {
const sk = skillOf(6, true, S2);
{ // the cap: 25 instances
const { h, u } = field({ tier: 6, elite: true, skill: 1, dp: 999 });
const t = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
for (let i = 0; i < sk.bb.max_stack_cnt - 2; i++) h.b.dealDamage(u, t, { amount: 1, type: 'true' });
assert.ok(t.findBuff(`lmlee:mark:${u.id}`), 'below the cap');
h.b.dealDamage(u, t, { amount: 1, type: 'true' });
h.b.dealDamage(u, t, { amount: 1, type: 'true' });
assert.equal(t.findBuff(`lmlee:mark:${u.id}`), null, 'cap ⇒ at once');
const b = from(h, u).filter((c) => c.dmg.tags.includes('lmlee:burst'));
assert.equal(b.length, 1);
assert.ok(b[0].amount >= u.s.atk * (sk.bb.default_atk_scale + sk.bb.max_stack_cnt * sk.bb.factor_atk_scale) - 1e-3);
done(h);
}
{ // knocked out target ⇒ burst on its spot
const { h, u } = field({ tier: 6, elite: true, skill: 1, dp: 999 });
const t = h.spawn('enemy_light', { pos: [10, 6] });
const by = h.spawn('enemy_dummy', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
h.b.dealDamage(u, t, { amount: 1e7, type: 'true' });
assert.ok(!t.alive);
assert.ok(from(h, u).some((c) => c.target === by && c.dmg.tags.includes('lmlee:burst')), 'the neighbour takes the burst');
done(h);
}
{ // recast ⇒ the old mark bursts
const { h, u } = field({ tier: 6, elite: true, skill: 1, dp: 999 });
const t = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
u.skill.gainSp(999);
u.skill.opReadyAt = -Infinity;
assert.ok(h.runUntil(() => u.skill.activations === 2, 3));
assert.equal(from(h, u).filter((c) => c.dmg.tags.includes('lmlee:burst')).length, 1, 'the first mark burst');
assert.ok(t.findBuff(`lmlee:mark:${u.id}`), 'a new mark');
done(h);
}
{ // he is knocked out ⇒ burst; he leaves without blocking it ⇒ nothing
for (const how of ['killed', 'retreat']) {
const { h, u } = field({ tier: 6, elite: true, skill: 1, dp: 999 });
const t = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
if (how === 'killed') h.b.dealDamage(t, u, { amount: 1e8, type: 'true' });
else h.b.retreat(u);
assert.equal(t.findBuff(`lmlee:mark:${u.id}`), null, how);
assert.equal(from(h, u).filter((c) => c.dmg.tags.includes('lmlee:burst')).length, how === 'killed' ? 1 : 0, how);
done(h);
}
}
});
test('S3 贵客盈门 (AUTO, SP_FULL): range x-4, ATK / DEF +31 % / +37 %, taunt +1, every attack pushes the other enemies of his range; 55 % / 60 % dodge of damage from outside his range only', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.bb.atk, sk.bb.def, sk.bb.taunt_level, sk.bb.prob, sk.bb['attack@force'], sk.bb['attack@prob_knockback'], sk.rangeId], elite ? [0.37, 0.37, 1, 0.6, 0, 1, 'x-4'] : [0.31, 0.31, 1, 0.55, 0, 1, 'x-4'], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2, dp: 999, seed: 9 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['toggle', 'SP_FULL']);
u.skill.gainSp(999);
h.step();
assert.ok(u.skill.active, `T${tier}: on at full SP`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-4`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
assert.equal(u.s.taunt, 1, `T${tier}: taunt`);
// pushes: the target stays, the other enemy of his range is pushed away (radial)
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [11, 5] });
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 3), `T${tier}: attacks`);
const main = h.hooksOf('attack').find((c) => c.attacker === u).targets[0];
const other = main === a ? b : a;
assert.ok(Math.hypot(main.x - (main === a ? 6 : 5), main.y - (main === a ? 10 : 11)) < 1e-6, `T${tier}: the target is not pushed`);
assert.ok(Math.hypot(other.x - (other === a ? 6 : 5), other.y - (other === a ? 10 : 11)) > 1, `T${tier}: the other one is pushed`);
done(h);
}
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, dp: 999, seed: 21 });
u.skill.gainSp(999);
h.step();
const gun = h.spawn('enemy_gun', { pos: [10, 9] }); // outside x-4
const fist = h.spawn('enemy_close', { pos: [10, 6] }); // inside it
h.b.addBuff(u, { key: 'test:tank', mods: { hpFlat: 1e9 } });
h.run(240);
const taken = (src) => h.hooksOf('damaged').filter((c) => c.target === u && c.source === src).length;
const dodged = (src) => h.hooksOf('dodge').filter((c) => c.target === u && c.source === src).length;
const r = dodged(gun) / (dodged(gun) + taken(gun));
assert.ok(Math.abs(r - sk.bb.prob) < 0.08, `T${tier}: ${(r * 100).toFixed(0)} % of the ranged hits dodged`);
assert.equal(dodged(fist), 0, `T${tier}: none from inside his range`);
assert.ok(taken(fist) > 100);
done(h);
}
});