Files
ark-stronghold-protocol/test/content/op_ifrit.test.js
T
sganggs 541048fed6 feedback5 O1: 自选 operator kits 伊芙利特, 谬因 (with her 中继器) and 林 — every skill, talent, trait and module (stage 1 / 3) at every form
- op-ifrit.js: 轰击术师 line; 精神融解 RES ×0.6 aura, BLA-Δ Lv3 +50 % ATK 元素伤害 on burn-bursting targets (attacks and 灼地 ticks);
  莱茵回路 +2 SP / 6 s, BLA-X Lv3 30 % +5; S1 ATK / ASPD; S2 charges, DEF cut + 灼伤 (current ATK, lasts after she leaves);
  S3 维持技能状态 (no attack, ground-only ticks from the Begin clip's event then every 1 s, RES cut, 2 %/s 流失, ended by stun);
  BLA-X distance scaling on all her damage while she stands, BLA-Δ 8 % 灼燃损伤 rider.
- op-aphris.js: her range is the extension path (5 tiles, +2 and turned by her 中继器; S2 unbounded and turned by allied
  operators); the 中继器's lifetime / marks / redeploy (leaves and returns with her, cleared by S3), ATK +25 / 35 % while it
  stands, 取样优化 法术穿透 on allied operators of her path; S1 SP_FULL toggle; S2 beam every 0.5 s (no attack); S3 ammo 20,
  3 s 强制缴械, extra hit + longer 停顿 past a 中继器; BLA-Y cost −8, Lv3 35 s / 20 s / +35 %.
- op-lin.js: 琉璃璧 shield (absorbs ≤ 200, breaks above: stun + burst on her current range, regrows 8 / 6 s), 韬光 per hit
  taken; S1 状态切换 toggle (停顿), S2 琉璃璧 copies for allies in range, S3 x-2 + threshold ×2 / ×2.5 + kill refresh;
  PLX-X keeps DEF +100 % / RES +10 in skills, PLX-Y +3 % per enemy in range (≤ 5).
- Sources: data/backups.json, PRTS (备注), the client's battle data (charpacks, battle/prefabs skills / equips, buff templates)
  and the battle skeletons' clip events; [ASSUMED] items in the kit headers.
- kits/index.js OPERATOR_KIT_FILES += op-ifrit.js, op-aphris.js, op-lin.js; tests test/content/op_ifrit / op_aphris / op_lin
  (10 each).
- Golden: diy only — diy-003 … diy-006 now pack the new kits (they held the prototypes), diy-007 … diy-012 are new (the rest of
  林, then the prototypes repacked into diy-008 … diy-012); diy-001 / 002 (推进之王) and every other family unchanged.
2026-10-06 00:20:37 +08:00

295 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_ifrit.test.js — the 自选 operator kit of 伊芙利特 (char_134_ifrit, 6★ 轰击术师; kit
// server/sim/content/kits/ops/op-ifrit.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, BLA-X
// “同熔” or BLA-D “热成形记忆” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
// that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_ifrit.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';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const IFRIT = 'char_134_ifrit';
const FORMS = BACKUPS.units[IFRIT].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const BLAX = 'uniequip_002_ifrit', BLAD = 'uniequip_003_ifrit';
const S1 = 'skchr_ifrit_1', S2 = 'skchr_ifrit_2', S3 = 'skchr_ifrit_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);
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_res: dummy('enemy_res', { res: 50 }), enemy_def: dummy('enemy_def', { def: 300 }),
enemy_hitter: dummy('enemy_hitter', { atk: 1000, range: 0, bat: 1 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAX, BLAD].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 伊芙利特 as uid 1 at (row, col) facing RIGHT (her line: that row, col … col + 5), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, flags = {} } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IFRIT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => (c.source === u || c.credit === u) && c.dmg?.tags?.includes(tag));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('伊芙利特 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 5-1 line, 轰击术师 (every enemy of the line, air too, arts, instant), no 特质', () => {
assert.equal(OPERATOR_KITS[IFRIT], KITS[IFRIT]);
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], [IFRIT, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile, u.base.bat], ['ranged', 'arts', true, true, 'beam', 2.9], `${label(f)}: 轰击术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 5-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[IFRIT].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
done(h);
}
}
// E2 Lv1 1276 / 722, E2 Lv60 1544 / 820; BLA-X +50 ATK +5 ASPD → +72 / +7, BLA-D +120 HP +45 ATK → +200 / +70
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], [1276, 722, 1544, 820]);
assert.deepEqual([modOf(5, BLAX).attr, modOf(6, BLAX).attr, modOf(5, BLAD).attr, modOf(6, BLAD).attr], [{ atk: 50, aspd: 5 }, { atk: 72, aspd: 7 }, { maxHp: 120, atk: 45 }, { maxHp: 200, atk: 70 }]);
});
test('a 自选 pick: 伊芙利特 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(IFRIT));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IFRIT), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } } });
});
test('trait 轰击术师: one attack strikes every enemy of her 5-1 line at once — ground and air, the same damage near and far (no module) — and nothing off the line', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 0 });
const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_dummy', { pos: [10, 9] });
const fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_dummy', { pos: [11, 5] }), behind = h.spawn('enemy_dummy', { pos: [10, 3] });
assert.ok(h.runUntil(() => atkHits(h, u).length > 0, 4), `T${tier}: she attacks`);
const first = atkHits(h, u);
const hit = first.filter((c) => c.dmg.attackId === first[0].dmg.attackId);
assert.deepEqual(hit.map((c) => c.target.id).sort(), [near, far, fly].map((e) => e.id).sort(), `T${tier}: her line, air included`);
for (const c of hit) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK at any distance`); assert.equal(c.type, 'arts'); }
for (const e of [off, behind]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: off the line`);
done(h);
}
});
test('S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +10 % / +20 %, ASPD +58 / +67; all back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.attack_speed, u.skill.spCost], ['DEFAULT', 20, elite ? 0.2 : 0.1, elite ? 67 : 58, elite ? 44 : 47], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast on her attack`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
h.runUntil(() => !u.skill.active, 25);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
assert.equal(u.s.aspd, 100, `T${tier}: ASPD back`);
done(h);
}
});
test('S2 炎爆 (AUTO, 2 / 3 charges, data DEFAULT): the next attack hits her whole line for 160 % / 190 %, DEF −100 / −200 for 3 s and the 灼伤 (33 % of her current ATK per s, 3 ticks, no dodge) on every enemy hit — the burn ticks on after she leaves', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.bb.atk_scale, sk.bb.def, sk.bb['burn.atk_scale'], sk.bb.duration],
['DEFAULT', 'charges', elite ? 3 : 2, 8, elite ? 1.9 : 1.6, elite ? -200 : -100, 0.33, 3.01], `T${tier}`);
const a = h.spawn('enemy_def', { pos: [10, 6] }), b = h.spawn('enemy_fly', { pos: [10, 8] }), off = h.spawn('enemy_dummy', { pos: [11, 6] });
u.skill.gainSp(999);
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 4), `T${tier}: cast on her next attack`);
const hits = atkHits(h, u).slice(n0);
const t0 = h.b.time;
for (const c of hits) assert.equal(c.dmg.isSkill, true);
approx(hits.find((c) => c.target === b).amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % on the flyer`);
approx(a.s.def, 300 + sk.bb.def < 0 ? 0 : 300 + sk.bb.def, `T${tier}: DEF cut`);
assert.equal(off.findBuff('ifrit:pyroDef'), null, `T${tier}: off the line`);
assert.ok(a.findBuff('ifrit:pyroBurn') && b.findBuff('ifrit:pyroBurn'), `T${tier}: both burn`);
// the burn: 3 ticks of 33 % of her current ATK; she leaves the field after the first one
h.runUntil(() => tagged(h, u, 'ifrit:burn').filter((c) => c.target === b).length >= 1, 2);
const atkNow = u.s.atk;
h.b.retreat(u);
h.run(3);
const burns = tagged(h, u, 'ifrit:burn').filter((c) => c.target === b);
assert.equal(burns.length, 3, `T${tier}: 3 ticks`);
approx(burns[0].amount, atkNow * 0.33, `T${tier}: 33 % ATK arts`);
for (const c of burns) { assert.deepEqual([c.type, c.dmg.canDodge], ['arts', false]); assert.ok(c.t - t0 <= 3.05, `T${tier}: within 3 s`); }
assert.equal(a.findBuff('ifrit:pyroDef'), null, `T${tier}: DEF cut over after 3 s`);
done(h);
}
});
test('S3 灼地 (MANUAL, data DEFAULT, 20 s, 维持技能状态): no normal attack; from the Begin clip\'s event (0.4 s) every second each ground enemy of her line takes 90 % / 110 % ATK arts and RES −7 / −10 for 1 s; she loses 2 % max HP per s; 20 ticks; a stun ends it at once', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.magic_resistance, sk.bb.hp_ratio], ['DEFAULT', 20, elite ? 1.1 : 0.9, elite ? -10 : -7, 0.02], `T${tier}`);
const g = h.spawn('enemy_res', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const n0 = atkHits(h, u).length, hp0 = u.hp, cast = h.hooksOf('skillStart').at(-1).t;
h.run(1.02);
// RES 50 × 0.6 (精神融解) − 7 / −10: (50 − cut) × 0.6
approx(g.s.res, (50 + sk.bb.magic_resistance) * 0.6, `T${tier}: RES cut + 精神融解`, 1e-9);
approx(off.s.res, 50, `T${tier}: off the line`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
const ticks = () => tagged(h, u, 'ifrit:scorch').filter((c) => c.type === 'arts');
assert.equal(ticks().length, 1, `T${tier}: one tick after 1 s`);
approx(ticks()[0].t - cast, 0.4, `T${tier}: the first tick at the Begin clip's attack event`, 0.05);
approx(ticks()[0].amount, u.s.atk * sk.bb.atk_scale * (1 - (50 + sk.bb.magic_resistance) * 0.6 / 100), `T${tier}: ${sk.bb.atk_scale * 100} % ATK arts`);
assert.ok(!ticks().some((c) => c.target === fly || c.target === off), `T${tier}: ground enemies of her line only`);
approx(hp0 - u.hp, u.s.maxHp * 0.02, `T${tier}: 2 % max HP`);
h.runUntil(() => !u.skill.active, 25);
assert.equal(ticks().length, 20, `T${tier}: 20 ticks`);
const endT = h.hooksOf('skillEnd').at(-1).t;
assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`);
// a second cast, cut short by a stun
u.hp = u.s.maxHp;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6), `T${tier}: cast again`);
h.run(2.5);
h.b.applyStatus(u, 'stun', { duration: 1, force: true });
h.step();
assert.equal(u.skill.active, false, `T${tier}: the stun ends 灼地`);
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'interrupted');
done(h);
}
});
test('T1 精神融解: RES ×0.6 on every enemy of her line (gone once it leaves), none off it; BLA-D stage 3: her attacks and 灼地 ticks on an enemy in its 灼燃 burst add 50 % ATK 元素伤害 (a 持续伤害)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.equal(t0.bb.magic_resistance, -0.4, label(f));
const on = h.spawn('enemy_res', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 7] });
h.run(0.3);
approx(on.s.res, 30, `${label(f)}: 50 → 30`);
approx(off.s.res, 50, `${label(f)}: off the line`);
h.b.retreat(u);
h.run(0.6);
approx(on.s.res, 50, `${label(f)}: gone with her`);
done(h);
}
for (const [tier, mod, extra] of [[6, BLAD, 0.5], [5, BLAD, 0], [6, BLAX, 0], [6, null, 0]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const calm = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(e, { key: 'burnBurst', duration: 60, flags: { burstLock: true } });
const n0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length > n0, 4);
h.step();
const el = () => tagged(h, u, 'ifrit:meltdown');
assert.equal(el().length, extra ? 1 : 0, `T${tier} ${mod}: the extra hit on her attack`);
if (extra) {
approx(el()[0].amount, u.s.atk * extra, 'T6 BLA-D: 50 % ATK');
assert.deepEqual([el()[0].type, el()[0].dmg.element, el()[0].dmg.tags.includes('dot'), el()[0].target], ['elemental', 'burn', true, e]);
assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /灼燃损伤.*爆发期间.*50%的元素伤害/);
}
// the 灼地 ticks are attacks of hers too (the equip adds it to every ability family)
u.skill.gainSp(999);
h.runUntil(() => u.skill.active, 4);
h.run(2.5);
const onTicks = el().filter((c) => c.t > h.hooksOf('skillStart').at(-1).t);
assert.equal(onTicks.length, extra ? 3 : 0, `T${tier} ${mod}: one per 灼地 tick (0.4 / 1.4 / 2.4 s)`);
assert.ok(!el().some((c) => c.target === calm), `T${tier} ${mod}: none on an enemy outside a burst`);
done(h);
}
});
test('T2 莱茵回路: +2 SP every 6 s of a deployment; BLA-X stage 3: a 30 % roll for +5 SP more each 6 s (none in any other form)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
const dice = elite && tier === 6 && mod === BLAX;
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.sp, t1.interval, t1['ifrit_e_002[dice_sp].prob'] ?? 0, t1['ifrit_e_002[dice_sp].sp'] ?? 0], [2, 6, dice ? 0.3 : 0, dice ? 5 : 0], label(f));
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
for (let i = 0; i < 60; i++) { u.skill.sp = 0; h.run(6); }
const g = gains();
const twos = g.filter((c) => c.amount === 2).length, fives = g.filter((c) => c.amount === 5).length;
assert.ok(twos >= 59 && twos <= 60, `${label(f)}: ${twos} gifts of 2 SP in 360 s`);
if (dice) assert.ok(fives >= 9 && fives <= 30, `${label(f)}: ${fives} / 60 rolls of +5 ≈ 30 %`);
else assert.equal(fives, 0, `${label(f)}: no roll`);
done(h);
}
});
test('BLA-X “同熔” (stages 1 and 3): ×(1 + 0.1 × d / 4) by the distance to the target, capped at 4 tiles — on her attacks, the 灼伤 while she stands and the 灼地 ticks; none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const blax = elite && mod === BLAX;
if (blax) assert.deepEqual(u.def.raw.trait.bb, { min_dist: 0, max_dist: 4, damage_scale: 0.1 }, label(f));
const at = [5, 6, 8, 9].map((c) => h.spawn('enemy_dummy', { pos: [10, c] }));
const n0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length >= n0 + 4, 4);
const hits = atkHits(h, u).slice(n0, n0 + 4);
for (const [i, d] of [[0, 1], [1, 2], [2, 4], [3, 5]]) {
const c = hits.find((x) => x.target === at[i]);
approx(c.amount, u.s.atk * (blax ? 1 + 0.1 * Math.min(1, d / 4) : 1), `${label(f)}: d = ${d}`);
}
done(h);
}
// the 灼伤 while she stands and after she left; the 灼地 tick
for (const [skill, tag] of [[1, 'ifrit:burn'], [2, 'ifrit:scorch']]) {
const { h, u } = field({ tier: 6, elite: true, mod: BLAX, skill });
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // 4 tiles: ×1.1
u.skill.gainSp(999);
h.runUntil(() => tagged(h, u, tag).filter((c) => c.type === 'arts').length >= 1, 6);
const c = tagged(h, u, tag).find((x) => x.type === 'arts' && x.target === e);
const scale = skill === 1 ? 0.33 : skillOf(6, true, S3).bb.atk_scale;
approx(c.amount, u.s.atk * scale * 1.1, `${tag}: ×1.1 at 4 tiles`);
if (skill === 1) {
h.b.retreat(u);
h.run(2.1);
const after = tagged(h, u, tag).filter((x) => x.target === e).at(-1);
approx(after.amount, u.s.atk * scale, '灼伤 after she left: no module effect');
}
done(h);
}
});
test('BLA-D “热成形记忆” (stages 1 and 3): every arts damage she deals while on the field attaches 8 % of it as 灼燃损伤 — her attacks and the 灼伤 — and no 元素损伤 without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const blad = elite && mod === BLAD;
if (blad) assert.equal(u.def.raw.trait.bb.ep_damage_ratio, 0.08, label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
h.runUntil(() => tagged(h, u, 'ifrit:burn').length >= 2, 6);
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts').reduce((s, c) => s + c.amount, 0);
approx(e.elem.burn, blad ? arts * 0.08 : 0, `${label(f)}: 8 % of ${Math.round(arts)} arts damage as 灼燃损伤`);
done(h);
}
});