// test/content/op_haruka.test.js — the 自选 operator kit of 遥 (char_4202_haruka, 6★ 护佑者; kit // server/sim/content/kits/ops/op-haruka.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 or BLS-Y // “迟来的纪念” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md. // Run: node --test test/content/op_haruka.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { makeBattle, enemyRec, chessRec, 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'; const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); const CHESS = load('chess'); const BACKUPS = load('backups'); const ID = 'char_4202_haruka'; const FORMS = BACKUPS.units[ID].forms; const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; const Y = 'uniequip_002_haruka'; const S1 = 'skchr_haruka_1', S2 = 'skchr_haruka_2', S3 = 'skchr_haruka_3'; const BUBBLE = 'haruka:bubble'; const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; 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); 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_heavy: dummy('enemy_heavy', { mass: 4 }), enemy_noflt: dummy('enemy_noflt', { immunities: { levitate: true } }), }; const PLAIN_REC = chessRec({ id: 'test_plain_a', charId: 'char_test_plain', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } }); const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, Y], [6, true, null], [6, true, Y]]; const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; /** 遥 as uid 1 at (10, 5) facing RIGHT, plus `others`. */ function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) { const h = makeBattle({ defs: { enemies: ENEMIES, chess: { test_plain_a: PLAIN_REC } }, timeLimit: 900, autoFinish: false, seed, flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'statusApplied', 'attack'], captureNoisy: true, units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], }); 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 ally = (uid, row, col) => ({ uid, chessId: 'test_plain_a', row, col }); /** Clear every bubble and make her next attack (or bubble) come at once — the battle start already gave one. */ function fresh(h, u) { for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); u.atkCd = 0; } const bubbled = (h) => h.b.allyUnits.filter((a) => a.findBuff(BUBBLE)).map((a) => a.uid).sort(); const heals = (h, u, from = 0) => h.hooksOf('heal').slice(from).filter((x) => x.source === u && !x.opts?.regen); test('遥 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-6, 护佑者 (ranged arts, hits air, blocks 1; BLS-Y 2 targets), S1 / S2 DEFAULT, S3 ACTIVE_RANGE on its y-8, ground-targetable, 空构 bond, no 特质', () => { assert.equal(OPERATOR_KITS[ID], KITS[ID]); 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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`); assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 护佑者`); assert.equal(u.profile.maxTargets, mod === Y ? 2 : 1, `${label(f)}: targets`); assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`); assert.equal(u.skill.rule, skill === 2 ? 'ACTIVE_RANGE' : 'DEFAULT', `${label(f)}: trigger`); if (skill === 2) assert.deepEqual(u.skill.triggerGrid, form.skills[2].rangeGrid, `${label(f)}: the y-8 trigger grid`); assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`); assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); done(h); } } assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1415, 423, 1728, 477]); assert.deepEqual([modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 130, atk: 30 }, { maxHp: 210, atk: 52 }]); }); test('a 自选 pick: 遥 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => { const data = { chess: CHESS, backups: BACKUPS }; assert.ok(KITTED_CHARS.includes(ID)); for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`); assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ID, skillIndex: 1, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }), { ok: true, picks: { [SLOT[5]]: { charId: ID, skillIndex: 1, uniEquipId: Y } } }); }); test('Trait: arts attacks (BLS-Y: two targets); while a skill runs every attack heals the most injured ally (BLS-Y: two) for 75 % of her ATK', () => { for (const [tier, elite, mod] of [[5, false, null], [6, true, Y]]) { const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 11, 6), ally(3, 9, 6)] }); h.spawn('enemy_dummy', { pos: [10, 7] }); h.spawn('enemy_fly', { pos: [11, 7] }); h.run(3); const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); assert.ok(hits.length && hits.every((c) => c.type === 'arts')); assert.equal(new Set(hits.map((c) => c.target)).size, mod === Y ? 2 : 1, `${label([tier, elite, mod])}: targets`); const [a, b] = [h.unit(2), h.unit(3)]; a.hp = 600; b.hp = 900; const n0 = h.hooksOf('heal').length; u.skill.gainSp(999); assert.ok(h.runUntil(() => heals(h, u, n0).length > 0, 4)); const first = heals(h, u, n0).filter((x, i, l) => Math.abs(x.t - l[0].t) < 1e-9); assert.deepEqual(first.map((x) => x.target.uid).sort(), mod === Y ? [2, 3] : [2]); for (const x of first) approx(x.amount, u.s.atk * 0.75, '75 % ATK'); done(h); } }); test('T1 浮光泡影: with each attack a bubble goes to the ally of her range with the lowest HP share that holds none (herself too); with nothing to attack the attack becomes the bubble, one per attack interval; 30 % 庇护 on physical and arts, not true; out of range nobody', () => { const { h, u } = field({ others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 9)] }); const [a, b, out] = [h.unit(2), h.unit(3), h.unit(4)]; a.hp = 1000; b.hp = 1200; out.hp = 100; fresh(h, u); // no enemy: ShieldOnly at once (a, 50 %), then one interval later b (60 %), then herself (full) h.run(0.05); assert.deepEqual(bubbled(h), [2]); h.run(1.6); assert.deepEqual(bubbled(h), [2, 3]); h.run(1.6); assert.deepEqual(bubbled(h), [1, 2, 3]); assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'no attack'); h.run(3.2); assert.deepEqual(bubbled(h), [1, 2, 3], 'nobody left in range (the one at (10, 9) is outside)'); const bub = a.findBuff(BUBBLE); approx(bub.data.v, 0.3, '30 %', 1e-9); const e = h.spawn('enemy_dummy', { pos: [11, 9] }); for (const [type, want] of [['phys', 70], ['arts', 70], ['true', 100]]) { const hp0 = b.hp; // a 无来源 hit: the bubble takes no notice h.b.dealDamage(e, b, { amount: 100, type, canDodge: false, sourceless: true }); approx(hp0 - b.hp, want, `${type}`); b.hp = 1200; } assert.ok(b.findBuff(BUBBLE), '无来源 damage never breaks a bubble'); // with a target: a bubble after each attack for (const x of [a, b, u]) h.b.removeBuff(x, BUBBLE); h.spawn('enemy_dummy', { pos: [10, 7] }); const n0 = h.hooksOf('attack').length; assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 2)); h.step(); assert.deepEqual(bubbled(h), [2], 'the attack and its bubble'); done(h); }); test('T1 / T2: an enemy hit that lowers the holder\'s HP breaks its bubble 0.5 s later (once; a fully shielded hit does not count) and heals it 28 % of her ATK as it was when she gave it; BLS-Y stage 3: 20 % to give the holder 1 SP; stage 1: never', () => { for (const [tier, elite, mod] of [[5, false, null], [6, true, Y], [5, true, Y]]) { const { h, u } = field({ tier, elite, mod, others: [ally(2, 11, 6)] }); const a = h.unit(2); a.hp = 1000; fresh(h, u); h.run(0.05); const b = a.findBuff(BUBBLE); assert.ok(b, 'bubbled'); const atk0 = u.s.atk; h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } }); // her ATK doubles after the grant const e = h.spawn('enemy_dummy', { pos: [11, 9] }); h.b.addBuff(a, { key: 'test:shield', shield: 500 }); h.b.dealDamage(e, a, { amount: 300, type: 'true' }); h.run(0.6); assert.equal(a.findBuff(BUBBLE), b, 'a hit the shield took whole: no break'); h.b.removeBuff(a, 'test:shield'); const n0 = h.hooksOf('heal').length; h.b.dealDamage(e, a, { amount: 100, type: 'true' }); h.run(0.25); h.b.dealDamage(e, a, { amount: 100, type: 'true' }); h.run(0.2); assert.equal(a.findBuff(BUBBLE), b, 'still there at 0.45 s'); h.run(0.1); assert.notEqual(a.findBuff(BUBBLE), b, 'broken 0.5 s after the first hit'); const hs = heals(h, u, n0).filter((x) => x.target === a); assert.equal(hs.length, 1, 'one break, one heal'); approx(hs[0].amount, atk0 * 0.28, '28 % of the ATK of the grant'); void u; done(h); } // BLS-Y stage 3: the dice for (const [tier, want] of [[6, true], [5, false]]) { const { h, u } = field({ tier, elite: true, mod: Y, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] }); const tex = h.unit(2); const e = h.spawn('enemy_dummy', { pos: [11, 9] }); let gained = 0, breaks = 0; h.b.on('spGain', (c) => { if (c.unit === tex && c.reason === 'talent') gained += c.amount; }); for (let i = 0; i < 300; i++) { tex.skill.sp = 0; tex.skill.charges = 0; if (!tex.findBuff(BUBBLE)) h.run(0.05); if (!tex.findBuff(BUBBLE)) { h.run(1.7); continue; } tex.hp = tex.s.maxHp * 0.5; h.b.dealDamage(e, tex, { amount: 1, type: 'true' }); h.run(0.55); breaks++; } if (want) assert.ok(gained / breaks > 0.12 && gained / breaks < 0.28, `≈ 20 %: ${gained} / ${breaks}`); else assert.equal(gained, 0, 'stage 1: no SP'); void u; done(h); } }); test('S1 夜啼彩羽 (DEFAULT): ASPD +30 / +45 for 19 / 20 s, heals; a bubble breaking meanwhile at once gives a new one to the ally of the 8 around its holder that has none, the lowest HP share first', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S1); const { h, u } = field({ tier, elite, skill: 0, others: [ally(2, 11, 6), ally(3, 12, 6), ally(4, 12, 7), ally(5, 9, 9)] }); assert.deepEqual([sk.duration, sk.bb.attack_speed, u.skill.spCost, sk.initSp], [elite ? 20 : 19, elite ? 45 : 30, 25, elite ? 10 : 7]); const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)]; h.spawn('enemy_dummy', { pos: [10, 7] }); u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 3)); approx(u.skill.timeLeft, sk.duration, 'duration', 0.05); approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD'); for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.3, atk: u.s.atk, heal: 0.25, prob: 0, sp: 0, boom: false } }); b.hp = 1500; c.hp = 1200; // both around a; c has the lower share const e = h.spawn('enemy_dummy', { pos: [11, 9] }); h.b.dealDamage(e, a, { amount: 10, type: 'true' }); h.run(0.52); assert.ok(!a.findBuff(BUBBLE) && c.findBuff(BUBBLE), 'the new bubble on the lowest share of the 8 around'); assert.equal(c.findBuff(BUBBLE).source, u); h.runUntil(() => !u.skill.active, 25); approx(u.s.aspd, 100, 'back'); done(h); } }); test('S2 幽隙栖萤: every heal of hers meanwhile deals heal × 140 % / 200 % arts damage to the 2 / 3 nearest enemies within 1.7 of the healed unit (air units too); rank 7: one more heal target and one more bubble; the first use lasts 22 / 23 s with no ATK bonus, the next ones ATK +20 % / +25 % and endless; the count restarts with a deployment', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S2); assert.deepEqual([sk.duration, sk.bb.atk_scale_extra, sk.bb.max_target_extra, sk.bb['attack@max_target_heal_add'], sk.bb.max_target_shield_add, sk.bb.atk, sk.bb.ability_range_radius], [elite ? 23 : 22, elite ? 2 : 1.4, elite ? 3 : 2, elite ? 1 : 0, elite ? 1 : 0, elite ? 0.25 : 0.2, 1.7]); const { h, u } = field({ tier, elite, skill: 1, others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 4)] }); const [a, b] = [h.unit(2), h.unit(3)]; h.spawn('enemy_dummy', { pos: [9, 7] }); // her target, √5 from a const near = [h.spawn('enemy_dummy', { pos: [12, 6] }), h.spawn('enemy_fly', { pos: [11, 7] }), h.spawn('enemy_dummy', { pos: [12, 7] })]; h.spawn('enemy_dummy', { pos: [13, 9] }); // too far from (11, 6) h.run(0.1); for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); a.hp = 400; b.hp = 1000; const n0 = h.hooksOf('heal').length; u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 3)); approx(u.s.atk, u.base.atk, 'first use: no ATK bonus'); assert.ok(h.runUntil(() => heals(h, u, n0).length > 0, 3)); const t0 = heals(h, u, n0)[0].t; const first = heals(h, u, n0).filter((x) => Math.abs(x.t - t0) < 1e-9); assert.deepEqual(first.map((x) => x.target.uid).sort(), elite ? [2, 3] : [2], 'rank 7: two heal targets'); const ha = first.find((x) => x.target === a); const dmg = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('haruka:s2') && Math.abs(c.t - t0) < 1e-9); // around a (11, 6): the three near enemies; (12, 7) is √2 ≈ 1.41 away — the 2 nearest at rank 4 are (12, 6) and (11, 7) const onA = dmg.filter((c) => near.includes(c.target)); assert.equal(new Set(onA.map((c) => c.target)).size, elite ? 3 : 2, 'max_target_extra'); for (const c of onA) approx(c.amount, ha.amount * sk.bb.atk_scale_extra, `heal × ${sk.bb.atk_scale_extra}`); assert.ok(onA.some((c) => c.target === near[1]), 'the flyer too'); assert.ok(onA.every((c) => c.type === 'arts')); // rank 7: two bubbles per attack h.step(); assert.ok(bubbled(h).length >= (elite ? 2 : 1), 'bubbles per attack'); h.runUntil(() => !u.skill.active, 30); approx(h.hooksOf('skillEnd').filter((c) => c.unit === u).at(-1).t - h.hooksOf('skillStart').filter((c) => c.unit === u).at(-1).t, sk.duration, 'first use: its duration', 0.05); // second use: ATK bonus, endless u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'second use: ATK'); h.run(sk.duration * 3); assert.ok(u.skill.active, 'endless'); // a new deployment: the first use again h.b.retreat(u); h.b.redeploy(u); u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); approx(u.s.atk, u.base.atk, 'first use of the new deployment'); done(h); } }); test('S3 夏末游鳞 (ACTIVE_RANGE): cast with an enemy on its y-8 outside her y-6; range y-8 while on, back after; ATK +25 % / +40 %; interval 1.6 − 0.4 / 0.6 s; heals; her bubbles ×1.4 / ×1.7 (also those given before); an enemy setting one off is 浮空 3 / 4 s (half on 重量 > 3, none when immune) and takes 60 % / 70 % ATK arts each second of it (from 0.5 s)', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S3); const form = formOf(tier, elite); assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.damage_resistance_scale, sk.bb.levitate_duration, sk.bb.atk_scale, sk.bb.interval], [40, elite ? 0.4 : 0.25, elite ? -0.6 : -0.4, elite ? 1.7 : 1.4, elite ? 4 : 3, elite ? 0.7 : 0.6, 1]); const { h, u } = field({ tier, elite, skill: 2, others: [ally(2, 11, 6), ally(3, 9, 6)] }); const a = h.unit(2); h.run(0.1); assert.ok(a.findBuff(BUBBLE) || h.unit(3).findBuff(BUBBLE) || u.findBuff(BUBBLE), 'bubbles before'); const holder = [a, h.unit(3), u].find((x) => x.findBuff(BUBBLE)); u.skill.gainSp(999); h.run(2); assert.equal(u.skill.activations, 0, 'no enemy: no cast'); h.spawn('enemy_dummy', { pos: [10, 8] }); // y-8 (+3), not y-6 assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on the y-8'); assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, 'y-8 while on'); approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK'); approx(u.s.interval, 1.6 + sk.bb.base_attack_time, 'interval'); approx(holder.findBuff(BUBBLE).data.v, 0.3 * sk.bb.damage_resistance_scale, 'a bubble given before: rescaled', 1e-9); // levitation: a hit on a bubbled ally for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.42, atk: u.s.atk, heal: 0, prob: 0, sp: 0, boom: false } }); const e = h.spawn('enemy_dummy', { pos: [12, 9] }); h.b.dealDamage(e, a, { amount: 50, type: 'true' }); const lev = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'levitate'); assert.equal(lev.length, 1); approx(lev[0].duration, sk.bb.levitate_duration, 'levitate'); assert.ok(e.isFlying, 'an air unit meanwhile'); const n0 = h.hooksOf('damaged').length; h.run(sk.bb.levitate_duration + 0.2); const ticks = h.hooksOf('damaged').slice(n0).filter((c) => c.target === e && c.dmg?.tags?.includes('haruka:float')); assert.equal(ticks.length, sk.bb.levitate_duration, 'one tick a second: 0.5, 1.5 …'); approx(ticks[0].t - lev[0].t, 0.5, 'first tick at 0.5 s', 0.05); for (const c of ticks) { approx(c.amount, u.s.atk * sk.bb.atk_scale, `${sk.bb.atk_scale * 100} % ATK`); assert.equal(c.type, 'arts'); } // heavy: half; immune: none const heavy = h.spawn('enemy_heavy', { pos: [12, 10] }), immune = h.spawn('enemy_noflt', { pos: [12, 11] }); h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.42, atk: u.s.atk, heal: 0, prob: 0, sp: 0, boom: false } }); h.b.dealDamage(heavy, a, { amount: 10, type: 'true' }); h.b.dealDamage(immune, a, { amount: 10, type: 'true' }); approx(h.hooksOf('statusApplied').filter((c) => c.target === heavy && c.status === 'levitate')[0].duration, sk.bb.levitate_duration / 2, '重量 > 3: half'); assert.equal(h.hooksOf('statusApplied').filter((c) => c.target === immune && c.status === 'levitate').length, 0, 'immune'); h.run(2); assert.equal(h.hooksOf('damaged').filter((c) => c.target === immune && c.dmg?.tags?.includes('haruka:float')).length, 0, 'no damage without the levitation'); h.runUntil(() => !u.skill.active, 45); assert.deepEqual(u.liveRangeGrid, form.rangeGrid, 'back to y-6'); approx(u.s.interval, 1.6, 'interval back'); done(h); } }); test('outside S3 a bubble levitates nobody; a bubble outlasts her leaving and still heals with the ATK of its grant', () => { const { h, u } = field({ skill: 0, others: [ally(2, 11, 6)] }); const a = h.unit(2); a.hp = 1000; fresh(h, u); h.run(0.05); assert.ok(a.findBuff(BUBBLE)); const atk = u.s.atk; const e = h.spawn('enemy_dummy', { pos: [11, 9] }); h.b.retreat(u); h.step(); assert.ok(a.findBuff(BUBBLE), 'still there'); const n0 = h.hooksOf('heal').length; h.b.dealDamage(e, a, { amount: 10, type: 'true' }); assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'levitate').length, 0); h.run(0.6); approx(heals(h, u, n0)[0].amount, atk * 0.28, 'her heal after she left'); done(h); });