Files
ark-stronghold-protocol/test/content/op_haruka.test.js
T
sganggs a61d82ee4c feedback5 O22: 自选 kits for 淬羽赫默 (with her 夜灯), 遥 and 酒神 (with her 本能的召唤 and 迷狂牢笼)
Three owned-6★ picks get their operator kits (kits/ops/op-slent2.js, op-haruka.js, op-phatm2.js; OPERATOR_KIT_FILES):
every skill at rank 4 / 7, both talents, the trait and every module at stages 1 and 3, from data/backups.json, PRTS and the
client's battle data (charpacks + buff_template_data of the three operators).

- 淬羽赫默: 护佑者 (arts attacks, heals while a skill runs; BLS-X 100 %, BLS-Y 2 targets); 无声砥柱 庇护 10 % → 24 % by the
  lost HP (shared `protect`); 丰润羽翼 as 生命回复速度 (the atk_to_hp_recovery template PRTS documents so for 絮雨 / 琴柳), ×2
  for 莱茵生命; BLS-X Lv3 max HP +10 %, BLS-Y Lv3 the once-per-deployment barrier; S2's placed 夜灯 (T1 ×2.1 / ×2.4 around it,
  withdrawn at the skill's end or when she leaves); S3's 不死 for the first lethal hit in her range, 2 uses a battle.
- 遥: bubbles with each attack (or instead of one), 30 % 庇护 ([bonus], ×1.4 / ×1.7 in S3), breaking 0.5 s after an enemy's
  damage, the break heal (BLS-Y Lv3 +1 SP to the holder); S1 re-bubbles around, S2 heal → arts splash and endless from the
  2nd use, S3 y-8 and the levitation with its damage.
- 酒神: T1 神经损伤 on each hit + 1.3 splash; 堕梦 ASPD cut in 神经 bursts and 70 / 90 on enemy attacks (a burst interrupts
  the attack); RIT-X ×1.18 vs elite / leader; S1 two hits + 束缚 ×1.3 / ×1.5; S2 (SP_FULL toggle) the placed 本能的召唤
  (lure 4, exit 停顿 + DoT, 25 s redeploys while it runs); S3 y-8, per-second 神经损伤 until a burst, 1.5× burst recovery
  and the 迷狂牢笼 (blocks its tile's enemies at spawn / refresh, 1 per hit, 30 s after the skill).

Engine (additive, no handler before): ai.js hook `enemyAttackStart` (an enemy's normal attack, before 麻痹), blocking.js
flag `noNewBlock`, items/battle.js `holdsUndying` also sees a buff flagged `undying`; docs/SIM.md §3 / §5, kits/README.md
(docked skill-summon pieces: their kit without offOwner).

Golden: only diy moved — diy-106 … diy-109 (the prototype configurations shifted behind the new operators) and the new
diy-110 … diy-114 (the three operators' configurations); roster, bonds, fields, matches, standins unchanged.
Tests: test/content/op_slent2.test.js (11), op_haruka.test.js (9), op_phatm2.test.js (13).
2026-10-06 05:14:29 +08:00

346 lines
21 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_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, 398, 1728, 452]);
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 25 % 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.25, '25 % 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.25, 'her heal after she left');
done(h);
});