Files
ark-stronghold-protocol/test/content/op_ebnhlz.test.js
T
sganggs 5029b646a3 feedback5 O20: 自选 kits for 多萝西 (with her 共振装置), 予愿安洁莉娜 (with “一会儿见!”) and 黑键 (with his 旧日残影)
Every skill, talent, trait and module (stages 1 and 3) at every form, from backups.json, PRTS (备注) and the client's
battle data (charpacks, skill / equip / projectile / token prefabs, buff templates); [ASSUMED] items in each file header.
- 多萝西 (op-doroth.js): placed traps deploy with the board and come back on their tile once the card is ready, a trap is in
  stock and the cost is paid; stock 8 refilled at each deployment, +1 per cast (AUTO acting on herself: SP_FULL), 阻回 when
  full; T1 summons on deploy; S1 / S2 / S3 trap effects (S3 chains other traps 2 s later); 梦想家 stacks; TRP-Y x2 traps;
  TRP-X cost 2 / limit 13, stage 3: 3 summons and 50 % extra per placed piece (re-rolled at full T2).
- 予愿安洁莉娜 (op-aglna2.js): 起飞 only while a skill runs (air block, 对地规避); T1 arts addition (weight <= 3) and 失重 in
  her range airborne; T2 ATK and 生命回复速度 for airborne operators; S1 56 s from each deployment; S2 glide (浮空 / 缚地
  sweep, 2.5 s chant); S3 ammo with 对空庇护, 3 ground + flyers, the move to a blockable flyer, the marker, the anchored range.
- 黑键 (op-ebnhlz.js): his own store (on time), 强弱法 elite energy, 倚音 per projectile (+ MSC-Y splash, MSC-Δ 元素伤害),
  MSC-Y ASPD, MSC-Δ 凋亡; S1 4-1; S2 remnants (activation, 0.93 s blast, inward push, 30 s); S3 elite-only and conversions.
- kits/index.js: three lines in OPERATOR_KIT_FILES; docs/SIM.md §8: the skywalker / traper rows.
Golden: only diy moved — diy-106 … diy-111 now field the new configs, the prototype battles moved to diy-111 … diy-115
(the 12-per-battle chunks shift); diy-001 … diy-105 unchanged. Tests: test/content/op_doroth.test.js (11),
op_aglna2.test.js (9), op_ebnhlz.test.js (10).
2026-10-06 05:22:08 +08:00

446 lines
26 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, 空 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, 'DEFAULT', `${label(f)}: the data's trigger`);
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, data DEFAULT): 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 (the charge back)', () => {
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]);
const { h, u } = field({ tier, elite, mod, skill: 1 });
const N = traitOf(tier, elite, mod).times;
storeUp(h, u, N, 1);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast with an enemy in range');
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' });
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 1] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), 'the DEFAULT condition holds (an enemy in range)');
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no placeable tile: no remnant');
assert.ok(u.skill.ready, 'the charge is back');
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' });
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
let atEnd = null;
h.b.on('skillEnd', (c) => { if (c.unit === u) atEnd = stored(u); });
h.spawn('enemy_dummy', { pos: [10, 2] });
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, 2), 'the attack that follows 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);
}
});