Files
ark-stronghold-protocol/test/content/op_ebnhlz.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +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, full potential): E2 Lv1 1359 / 1186 / 120 / 20, E2 Lv60 1570 / 1362 / 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, 1186, 1362]);
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 (17 %; MSC-Y 22 %, MSC-Δ 32 % 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.22 : tier === 6 && mod === MD ? 0.32 : 0.17, `${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);
}
});