PRTS 黑键 S2 备注 "攻击范围内不存在可部署位时,技能不会被触发" (skill prefab _minTileNum 1): the kit casts it (trigger NEVER + a tick: ready, able to act, one free deployable tile in his range — hasRemnantTile, no rng) instead of the data's DEFAULT waiting for an enemy. Checked and kept: 温蒂's cannon (summons follow the basic strategy, PRTS "对于拥有手动释放技能的召唤物,亦会强制开启技能"), 凯尔希·思衡托 S2 (a medic's target is an injured ally; the owner's ACTIVE_RANGE on her y-11). Golden: diy-114, diy-115, diy-116 moved — the scenarios fielding 黑键 S2 (his S2 fires at full SP from the battle start).
462 lines
27 KiB
JavaScript
462 lines
27 KiB
JavaScript
// test/content/op_ebnhlz.test.js — the 自选 operator kit of 黑键 (char_4046_ebnhlz, 6★ 秘术师; kit
|
||
// server/sim/content/kits/ops/op-ebnhlz.js) and of his S2 summon 旧日残影 (token_10024_ebnhlz_rcube), 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, MSC-X 源石骰子收纳盒, MSC-Y “乐理阐释者” or MSC-Δ 朽坏传承 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_ebnhlz.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 EBN = 'char_4046_ebnhlz';
|
||
const REMNANT = 'token_10024_ebnhlz_rcube';
|
||
const FORMS = BACKUPS.units[EBN].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const MX = 'uniequip_002_ebnhlz', MY = 'uniequip_003_ebnhlz', MD = 'uniequip_004_ebnhlz';
|
||
const S1 = 'skchr_ebnhlz_1', S2 = 'skchr_ebnhlz_2', S3 = 'skchr_ebnhlz_3';
|
||
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);
|
||
/** The composed talent `i` of a form + module (the module's change replaces the base talent). */
|
||
const talentOf = (tier, elite, mod, i) => modOf(tier, mod)?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents[i].bb;
|
||
const traitOf = (tier, elite, mod) => modOf(tier, mod)?.traitOverride?.bb ?? formOf(tier, elite).trait.bb;
|
||
const hiddenOf = (tier, mod) => Object.assign({}, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === -1).map((t) => t.bb));
|
||
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_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
|
||
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
|
||
};
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, MX, MY, MD].map((m) => [t, true, m]))];
|
||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||
|
||
/** 黑键 as uid 1 at (row, col) facing `dir` (default RIGHT), plus `others`. */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, dir = 'RIGHT', others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'death'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: EBN, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const stored = (u) => ({ n: u.trait.ebn?.n ?? 0, e: u.trait.ebn?.e ?? 0 });
|
||
const tagged = (c, t) => (c.dmg?.tags || []).includes(t);
|
||
const mainHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !tagged(c, 'ebnhlz:energy'));
|
||
const energyHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && tagged(c, 'ebnhlz:energy'));
|
||
const yiyinHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && tagged(c, 'ebnhlz:yiyin'));
|
||
/** Hold his skill off (SP 0, no charge): the trait / talent checks see no cast. */
|
||
const holdSkill = (u) => { u.skill.sp = 0; u.skill.charges = 0; };
|
||
/** Start a check with nothing stored. */
|
||
const clearEnergy = (u) => { const v = u.trait.ebn; if (v) { v.n = 0; v.e = 0; } };
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
/** Step until he holds `n` / `e` (no enemy on the field), recording [time, interval] at each store. */
|
||
function storeUp(h, u, n, e, max = 60) {
|
||
const log = [];
|
||
let last = stored(u).n + stored(u).e;
|
||
for (let i = 0; i < max * 30; i++) {
|
||
const s = stored(u);
|
||
if (s.n === n && s.e === e) return log;
|
||
h.step();
|
||
const t = stored(u).n + stored(u).e;
|
||
if (t !== last) { log.push([h.b.time, u.atkCd]); last = t; }
|
||
}
|
||
assert.fail(`stores up to ${n}/${e}: ${JSON.stringify(stored(u))}`);
|
||
return log;
|
||
}
|
||
|
||
test('黑键 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-14, ranged arts every 3 s that hits air units, blocks 1, ground-targetable, the data\'s DEFAULT triggers (S2: the kit casts it), 空 bond, no 特质', () => {
|
||
assert.equal(OPERATOR_KITS[EBN], KITS[EBN]);
|
||
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], [EBN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd],
|
||
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.res + (m?.attr.res ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 3], `${label(f)}: 秘术师`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
|
||
assert.equal(u.skill.rule, skill === 1 ? 'NEVER' : 'DEFAULT', `${label(f)}: the data's trigger (S2: the kit's own cast)`);
|
||
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 him`);
|
||
done(h);
|
||
}
|
||
}
|
||
// the numbers (zh_CN): E2 Lv1 1359 / 1134 / 120 / 20, E2 Lv60 1570 / 1310 / 130 / 20; MSC-X +58 ATK +3 ASPD → +90 / +5,
|
||
// MSC-Y +80 HP +88 ATK → +175 / +135, MSC-Δ +76 ATK +4 RES → +124 / +5
|
||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk], [1359, 1134, 1310]);
|
||
assert.deepEqual([modOf(5, MX).attr, modOf(6, MX).attr, modOf(5, MY).attr, modOf(6, MY).attr, modOf(5, MD).attr, modOf(6, MD).attr],
|
||
[{ atk: 58, aspd: 3 }, { atk: 90, aspd: 5 }, { maxHp: 80, atk: 88 }, { maxHp: 175, atk: 135 }, { atk: 76, res: 4 }, { atk: 124, res: 5 }]);
|
||
// he hits a flyer
|
||
const { h, u } = field({ skill: 2 });
|
||
holdSkill(u);
|
||
const f = h.spawn('enemy_fly', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).some((c) => c.target === f), 5), 'an air unit is hit');
|
||
done(h);
|
||
});
|
||
|
||
test('a 自选 pick: 黑键 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.ok(KITTED_CHARS.includes(EBN));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(EBN), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: EBN, skillIndex: 1, uniEquipId: MD } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: EBN, skillIndex: 1, uniEquipId: MD } } });
|
||
});
|
||
|
||
test('trait + T1 强弱法: no target ⇒ one energy per attack interval (an attack action — PRTS 分支特性信息), up to times (3; MSC-X 4), then the one elite-only energy, then idle; MSC-Y: ASPD +30 while he holds any', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const N = traitOf(tier, elite, mod).times, E = talentOf(tier, elite, mod, 0).times_2;
|
||
assert.deepEqual([N, E], [mod === MX ? 4 : 3, 1], `${label(f)}: times / times_2`);
|
||
assert.deepEqual(stored(u), { n: 1, e: 0 }, `${label(f)}: the first at once (his attack is ready at deployment)`);
|
||
const first = [h.b.time, u.atkCd];
|
||
approx(first[1], u.base.bat * 100 / u.base.aspd, `${label(f)}: then a full interval`, 1e-6);
|
||
const log = [first, ...storeUp(h, u, N, E)];
|
||
assert.equal(log.length, N + E, `${label(f)}: ${N} + ${E} stores`);
|
||
for (let i = 1; i < log.length; i++) approx(log[i][0] - log[i - 1][0], log[i - 1][1], `${label(f)}: store ${i + 1} one interval after the last`, 0.02);
|
||
const aspd = hiddenOf(tier, mod).attack_speed ?? 0;
|
||
assert.equal(aspd, mod === MY ? 30 : 0, `${label(f)}: MSC-Y attack_speed`);
|
||
approx(u.s.aspd, u.base.aspd + aspd, `${label(f)}: ASPD while holding energy`);
|
||
const before = h.b.time;
|
||
h.run(10);
|
||
assert.deepEqual(stored(u), { n: N, e: E }, `${label(f)}: full — idle`);
|
||
assert.ok(h.b.time - before >= 10 - 1e-9);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('the volley: his next attack releases every normal energy at its target, ATK × atk_scale (1.35; MSC-X stage 3 1.43) arts attack hits landing with the main bolt; the elite energy only at an elite or leader target', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const N = traitOf(tier, elite, mod).times, E = talentOf(tier, elite, mod, 0).times_2;
|
||
const scale = talentOf(tier, elite, mod, 0).atk_scale;
|
||
assert.equal(scale, mod === MX && tier === 6 ? 1.43 : 1.35, `${label(f)}: 强弱法 scale`);
|
||
storeUp(h, u, N, E);
|
||
holdSkill(u);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5), `${label(f)}: attacks`);
|
||
const main = mainHits(h, u)[0];
|
||
const en = energyHits(h, u).filter((c) => c.dmg.attackId === main.dmg.attackId);
|
||
assert.equal(en.length, N, `${label(f)}: ${N} normal energies at a normal enemy`);
|
||
for (const c of en) { approx(c.amount, u.s.atk * scale, `${label(f)}: energy ×${scale}`, 1e-3); assert.deepEqual([c.type, c.target, c.dmg.isAttack], ['arts', e, true]); }
|
||
approx(main.amount, u.s.atk, `${label(f)}: the main bolt 100 %`, 1e-3);
|
||
assert.deepEqual(stored(u), { n: 0, e: E }, `${label(f)}: the elite energy kept`);
|
||
// an elite target: the elite energy goes too
|
||
h.b.kill(e, null);
|
||
holdSkill(u);
|
||
const el = h.spawn('enemy_elite', { pos: [10, 6] });
|
||
const n0 = mainHits(h, u).length;
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > n0 && mainHits(h, u).at(-1).target === el, 8), `${label(f)}: attacks the elite`);
|
||
const m2 = mainHits(h, u).at(-1);
|
||
const en2 = energyHits(h, u).filter((c) => c.dmg.attackId === m2.dmg.attackId);
|
||
assert.equal(en2.length, E, `${label(f)}: the elite energy at an elite`);
|
||
assert.deepEqual(stored(u), { n: 0, e: 0 });
|
||
done(h);
|
||
}
|
||
// a leader (BOSS rank) takes it too
|
||
const { h, u } = field({ tier: 6, elite: true });
|
||
storeUp(h, u, 3, 1);
|
||
holdSkill(u);
|
||
h.spawn('enemy_boss', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5));
|
||
assert.equal(energyHits(h, u).length, 4, 'three normal + the elite energy at a leader');
|
||
done(h);
|
||
});
|
||
|
||
test('T2 倚音: per projectile (the main bolt and every energy) a target with no other enemy within range_radius 1.1 takes ATK × atk_scale arts (15 %; MSC-Y 20 %, MSC-Δ 30 % at stage 3); with one there: nothing — MSC-Y stage 3 ATK × 36 % arts splash on the others instead; MSC-Δ stage 3: + ATK × 30 % 元素伤害 on a target in its 凋亡 burst', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const t1 = talentOf(tier, elite, mod, 1);
|
||
assert.equal(t1.range_radius, 1.1);
|
||
assert.equal(t1.atk_scale, tier === 6 && mod === MY ? 0.2 : tier === 6 && mod === MD ? 0.3 : 0.15, `${label(f)}: atk_scale`);
|
||
// alone: the main bolt + 2 energies ⇒ 3 additions
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
storeUp(h, u, 2, 0);
|
||
holdSkill(u);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5));
|
||
const id = mainHits(h, u)[0].dmg.attackId;
|
||
const add = yiyinHits(h, u).filter((c) => c.dmg.attackId === id && c.type === 'arts');
|
||
assert.equal(add.length, 3, `${label(f)}: the main bolt and 2 energies`);
|
||
for (const c of add) { approx(c.amount, u.s.atk * t1.atk_scale, `${label(f)}: ${t1.atk_scale}`, 1e-3); assert.equal(c.target, e); }
|
||
done(h);
|
||
}
|
||
// not alone: an enemy 1.0 away (within 1.1)
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
holdSkill(u);
|
||
clearEnergy(u);
|
||
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
const b = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5));
|
||
const main = mainHits(h, u)[0];
|
||
const add = yiyinHits(h, u).filter((c) => c.dmg.attackId === main.dmg.attackId);
|
||
const other = main.target === a ? b : a;
|
||
if (t1.atk_scale_2 > 0) {
|
||
assert.equal(t1.atk_scale_2, 0.36, `${label(f)}: MSC-Y stage 3`);
|
||
assert.deepEqual(add.map((c) => c.target), [other], `${label(f)}: the splash spares the main target`);
|
||
approx(add[0].amount, u.s.atk * 0.36, `${label(f)}: 36 %`, 1e-3);
|
||
assert.ok(add[0].dmg.isSplash);
|
||
} else assert.equal(add.length, 0, `${label(f)}: no addition with another enemy near`);
|
||
done(h);
|
||
}
|
||
// MSC-Δ stage 3: a target in its 凋亡 burst
|
||
if (t1.element_atk_scale > 0) {
|
||
assert.deepEqual([tier, mod, t1.element_atk_scale], [6, MD, 0.3]);
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
holdSkill(u);
|
||
clearEnergy(u);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
h.b.addBuff(e, { key: 'apoptosisBurst', duration: 15, flags: { burstLock: true } });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5));
|
||
const id = mainHits(h, u)[0].dmg.attackId;
|
||
const add = yiyinHits(h, u).filter((c) => c.dmg.attackId === id);
|
||
assert.deepEqual(add.map((c) => c.type), ['arts', 'elemental'], 'arts first, then 元素伤害 (PRTS 备注)');
|
||
approx(add[1].amount, u.s.atk * 0.3, '30 % 元素伤害', 1e-3);
|
||
done(h);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('MSC-Δ 朽坏传承: every arts damage of his (attacks, energies, 倚音) and of his 旧日残影 attaches 8 % of it as 凋亡损伤; none without the module', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const ratio = hiddenOf(tier, mod).ep_damage_ratio ?? 0;
|
||
assert.equal(ratio, mod === MD ? 0.08 : 0, label(f));
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
holdSkill(u);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 5));
|
||
const id = mainHits(h, u)[0].dmg.attackId;
|
||
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'arts' && c.dmg.attackId === id);
|
||
const sum = arts.reduce((s, c) => s + c.amount, 0);
|
||
approx(e.elem.apoptosis, sum * ratio, `${label(f)}: 凋亡 gauge = ${ratio} × the arts dealt`, 1e-6);
|
||
done(h);
|
||
}
|
||
// the remnant's blast too
|
||
const { h, u } = field({ tier: 6, elite: true, mod: MD, skill: 1 });
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source?.defId === REMNANT), 5), 'a remnant blast');
|
||
const blast = h.hooksOf('damaged').find((c) => c.source?.defId === REMNANT && c.type === 'arts');
|
||
const fills = h.hooksOf('damaged').filter((c) => c.source === blast.source && c.type === 'element');
|
||
approx(fills[0].dmg.amount, blast.amount * 0.08, 'the remnant attaches 8 % too', 1e-6);
|
||
assert.equal(e.alive, true);
|
||
done(h);
|
||
});
|
||
|
||
test('S1 渐快急板 (MANUAL, data DEFAULT): 5 s, range 4-1, attack interval ×0.2, every attack 35 % / 40 % ATK arts — the energies released meanwhile 35 % / 40 % × 强弱法; all back after', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S1);
|
||
assert.deepEqual([sk.duration, sk.bb['attack@atk_scale'], sk.bb.base_attack_time, sk.rangeId, sk.spCost, sk.initSp], [5, elite ? 0.4 : 0.35, 0.2, '4-1', elite ? 19 : 22, 5], label(f));
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const own = () => u.base.bat * 100 / u.s.aspd;
|
||
u.skill.gainSp(999);
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 5), `${label(f)}: cast with an enemy in 3-14`);
|
||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 4-1`);
|
||
approx(u.skill.timeLeft, 5, `${label(f)}: 5 s`, 0.02);
|
||
approx(u.s.interval, own() * 0.2, `${label(f)}: ×0.2`, 1e-6);
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > 0, 2));
|
||
const main = mainHits(h, u)[0];
|
||
approx(main.amount, u.s.atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale']} ATK`, 1e-3);
|
||
const en = energyHits(h, u).filter((c) => c.dmg.attackId === main.dmg.attackId);
|
||
assert.equal(en.length, 1);
|
||
approx(en[0].amount, u.s.atk * sk.bb['attack@atk_scale'] * talentOf(tier, elite, mod, 0).atk_scale, `${label(f)}: the energy at the skill's scale`, 1e-3);
|
||
// the 4-1 reaches 4 tiles ahead (3-14 only 3)
|
||
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||
h.b.kill(e, null);
|
||
assert.ok(h.runUntil(() => mainHits(h, u).some((c) => c.target === far), 2), `${label(f)}: the 4-1's far tile`);
|
||
h.runUntil(() => !u.skill.active, 6);
|
||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: 3-14 back`);
|
||
approx(u.s.interval, own(), `${label(f)}: interval back`, 1e-6);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 荒芜回响 (AUTO: full SP and one free placeable tile in his range, no enemy needed — PRTS 备注, prefab _minTileNum 1): spends every energy (elite first) on energies + 1 旧日残影 on free tiles of his range — the enemy\'s tile first, then the nearest; fewer tiles ⇒ fewer spent; no free tile ⇒ no cast', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, MX]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
assert.deepEqual([sk.skillType, sk.bb.atk_scale, sk.bb.force, sk.spCost, sk.initSp], ['AUTO', elite ? 2 : 1.7, 1, elite ? 14 : 15, 0]);
|
||
// no enemy on the field: it casts as soon as its SP is full
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||
assert.equal(u.skill.rule, 'NEVER', 'the kit casts it');
|
||
let before = stored(u);
|
||
for (let i = 0; i < (sk.spCost + 1) * 30 && u.skill.activations === 0; i++) { before = stored(u); h.step(); }
|
||
assert.equal(u.skill.activations, 1, 'cast at full SP with no enemy anywhere');
|
||
approx(h.b.time, sk.spCost, `T${tier}: at ${sk.spCost} s`, 0.2);
|
||
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT && t.alive).length, before.n + before.e + 1, 'the energies stored meanwhile + 1 remnants');
|
||
assert.deepEqual(stored(u), { n: 0, e: 0 }, 'every energy spent');
|
||
done(h);
|
||
}
|
||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||
const N = traitOf(tier, elite, mod).times;
|
||
holdSkill(u);
|
||
storeUp(h, u, N, 1);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast');
|
||
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
|
||
assert.equal(rem.length, N + 2, `${N} + 1 energies + 1 remnants`);
|
||
assert.deepEqual([rem[0].tileR, rem[0].tileC], [10, 7], 'the enemy\'s tile first');
|
||
const d = (t) => Math.hypot(t.tileC - 7, t.tileR - 10);
|
||
for (let i = 2; i < rem.length; i++) assert.ok(d(rem[i]) >= d(rem[i - 1]) - 1e-9, 'then by the distance to the enemy');
|
||
assert.deepEqual(stored(u), { n: 0, e: 0 }, 'every energy spent');
|
||
assert.equal(energyHits(h, u).length, 0, 'none left for the attack');
|
||
void e;
|
||
done(h);
|
||
}
|
||
// the flat stage's edge (col 2: RANGED tiles; cols 0–1 none): facing LEFT from (10, 2) no tile of his range is free
|
||
{
|
||
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 2, dir: 'LEFT' });
|
||
holdSkill(u);
|
||
storeUp(h, u, 3, 1);
|
||
h.spawn('enemy_dummy', { pos: [10, 1] });
|
||
u.skill.gainSp(999);
|
||
h.run(5);
|
||
assert.equal(u.skill.activations, 0, 'no placeable tile: no cast, an enemy in range notwithstanding');
|
||
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no remnant');
|
||
assert.ok(u.skill.ready, 'the charge waits');
|
||
done(h);
|
||
}
|
||
// fewer free tiles than energies + 1: facing LEFT from (10, 3) only (10, 2) and (11, 2) are placeable
|
||
{
|
||
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 3, dir: 'LEFT' });
|
||
holdSkill(u);
|
||
storeUp(h, u, 3, 1);
|
||
let atEnd = null;
|
||
h.b.on('skillEnd', (c) => { if (c.unit === u) atEnd = stored(u); });
|
||
h.spawn('enemy_dummy', { pos: [10, 2] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
|
||
assert.deepEqual(rem.map((t) => [t.tileR, t.tileC]), [[10, 2], [11, 2]], 'the enemy\'s tile, then the other free one');
|
||
assert.deepEqual(atEnd, { n: 3, e: 0 }, 'one energy spent — the elite one first');
|
||
assert.ok(h.runUntil(() => energyHits(h, u).length === 3, 4), 'his next attack (3 s interval) releases the three left');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('旧日残影: a selectable ground enemy within 1.35 activates it; 0.93 s later every ground enemy within 1.35 takes 黑键\'s ATK × 170 % / 200 % arts from it and a 中力 inward push, then it withdraws; never flyers; 30 s life; it leaves with 黑键', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||
const r = h.b.allyUnits.find((t) => t.kind === 'token' && t.defId === REMNANT && t.tileR === 10 && t.tileC === 7);
|
||
assert.ok(r && r.alive, 'a remnant on the enemy');
|
||
const near = h.spawn('enemy_dummy', { pos: [11, 8] }); // 1.41 away: outside
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 8] });
|
||
const t0 = h.b.time;
|
||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === r), 3), 'it goes off');
|
||
approx(h.b.time - t0, 0.93, 'about 0.93 s after the activation', 0.05);
|
||
const hits = h.hooksOf('damaged').filter((c) => c.source === r && c.type === 'arts');
|
||
assert.deepEqual(hits.map((c) => c.target), [e], 'only the ground enemy within 1.35');
|
||
approx(hits[0].amount, u.s.atk * sk.bb.atk_scale, `${sk.bb.atk_scale} × his ATK`, 1e-3);
|
||
assert.ok(!hits.some((c) => c.target === fly || c.target === near));
|
||
assert.ok(!r.alive, 'withdrawn once its skill went off');
|
||
done(h);
|
||
}
|
||
// an inward push: a light enemy 1 tile off is drawn towards it
|
||
{
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||
const r = h.b.allyUnits.find((t) => t.kind === 'token' && t.tileR === 10 && t.tileC === 7);
|
||
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||
const d0 = Math.hypot(side.x - r.x, side.y - r.y);
|
||
assert.ok(h.runUntil(() => !r.alive, 3));
|
||
assert.ok(Math.hypot(side.x - r.x, side.y - r.y) < d0 - 0.1, 'pulled in (a push towards it)');
|
||
void e;
|
||
done(h);
|
||
}
|
||
// life 30 s (no enemy near), and gone with 黑键
|
||
{
|
||
const { h, u } = field({ tier: 5, skill: 1 });
|
||
storeUp(h, u, 3, 1);
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||
const quiet = h.b.allyUnits.filter((t) => t.kind === 'token' && t.alive && Math.hypot(t.x - e.x, t.y - e.y) > 1.35);
|
||
assert.ok(quiet.length >= 1, 'a remnant out of reach');
|
||
assert.equal(h.b.tokenDef(REMNANT, u).talents[0].bb.duration, 30);
|
||
h.b.kill(e, null);
|
||
h.run(29);
|
||
assert.ok(quiet.every((t) => t.alive), 'still there at 29 s');
|
||
h.run(1.5);
|
||
assert.ok(quiet.every((t) => !t.alive), 'gone after 30 s');
|
||
done(h);
|
||
}
|
||
{
|
||
const { h, u } = field({ tier: 5, skill: 1 });
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||
h.b.kill(e, null);
|
||
assert.ok(h.b.allyUnits.some((t) => t.kind === 'token' && t.alive));
|
||
h.b.retreat(u);
|
||
h.step();
|
||
assert.ok(!h.b.allyUnits.some((t) => t.kind === 'token' && t.alive), 'withdrawn when he leaves');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 寂静之声 (MANUAL, data DEFAULT): 30 s, ASPD +50 / +60, ATK +43 % / +50 %; attacks only elite or leader enemies (with none he stores elite energy); energies turn elite at its start, deal × talent_scale_multiplier (1.23 / 1.30); at its end one stays elite, the rest normal', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, false, null], [5, true, MY], [6, true, MX]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
assert.deepEqual([sk.duration, sk.bb.attack_speed, sk.bb.atk, sk.bb.talent_scale_multiplier, sk.spCost, sk.initSp],
|
||
[30, elite ? 60 : 50, elite ? 0.5 : 0.43, elite ? 1.3 : 1.23, elite ? 22 : 24, elite ? 8 : 6]);
|
||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||
const N = traitOf(tier, elite, mod).times;
|
||
storeUp(h, u, 2, 0);
|
||
u.skill.gainSp(999);
|
||
let atCast = null;
|
||
h.b.on('skillStart', (c) => { if (c.unit === u) atCast = stored(u); });
|
||
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 5), 'cast with an enemy in range');
|
||
assert.deepEqual(atCast, { n: 0, e: 2 }, 'the stored energies turned elite');
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK');
|
||
const n0 = mainHits(h, u).length;
|
||
h.run(8);
|
||
assert.equal(mainHits(h, u).length, n0, 'never a normal enemy');
|
||
assert.deepEqual(stored(u), { n: 0, e: N + 1 }, `elite energies only, up to ${N} + 1`);
|
||
const el = h.spawn('enemy_elite', { pos: [11, 6] });
|
||
assert.ok(h.runUntil(() => mainHits(h, u).length > n0, 5), 'the elite is attacked');
|
||
const main = mainHits(h, u).at(-1);
|
||
assert.equal(main.target, el);
|
||
const en = energyHits(h, u).filter((c) => c.dmg.attackId === main.dmg.attackId);
|
||
assert.equal(en.length, N + 1);
|
||
const scale = talentOf(tier, elite, mod, 0).atk_scale * sk.bb.talent_scale_multiplier;
|
||
approx(en[0].amount, u.s.atk * scale, `energies × ${scale}`, 1e-3);
|
||
approx(u.s.aspd, u.base.aspd + sk.bb.attack_speed, 'ASPD');
|
||
// at the end: one elite stays, the rest normal
|
||
h.b.kill(el, null);
|
||
h.runUntil(() => stored(u).e === N + 1, 30);
|
||
u.skill.end('test');
|
||
assert.deepEqual(stored(u), { n: N, e: 1 }, 'one elite kept, the rest normal (up to times)');
|
||
void a;
|
||
done(h);
|
||
}
|
||
});
|