Files
ark-stronghold-protocol/test/content/op_ebnhlz.test.js
T
sganggs 708719f382 feedback5 WE2: 黑键 S2 casts at full SP once a placeable tile of his range is free, no enemy needed (#23)
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).
2026-10-06 15:25:07 +08:00

462 lines
27 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_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);
}
});