Files
ark-stronghold-protocol/test/content/op_phatm2.test.js
T
sganggs a61d82ee4c feedback5 O22: 自选 kits for 淬羽赫默 (with her 夜灯), 遥 and 酒神 (with her 本能的召唤 and 迷狂牢笼)
Three owned-6★ picks get their operator kits (kits/ops/op-slent2.js, op-haruka.js, op-phatm2.js; OPERATOR_KIT_FILES):
every skill at rank 4 / 7, both talents, the trait and every module at stages 1 and 3, from data/backups.json, PRTS and the
client's battle data (charpacks + buff_template_data of the three operators).

- 淬羽赫默: 护佑者 (arts attacks, heals while a skill runs; BLS-X 100 %, BLS-Y 2 targets); 无声砥柱 庇护 10 % → 24 % by the
  lost HP (shared `protect`); 丰润羽翼 as 生命回复速度 (the atk_to_hp_recovery template PRTS documents so for 絮雨 / 琴柳), ×2
  for 莱茵生命; BLS-X Lv3 max HP +10 %, BLS-Y Lv3 the once-per-deployment barrier; S2's placed 夜灯 (T1 ×2.1 / ×2.4 around it,
  withdrawn at the skill's end or when she leaves); S3's 不死 for the first lethal hit in her range, 2 uses a battle.
- 遥: bubbles with each attack (or instead of one), 30 % 庇护 ([bonus], ×1.4 / ×1.7 in S3), breaking 0.5 s after an enemy's
  damage, the break heal (BLS-Y Lv3 +1 SP to the holder); S1 re-bubbles around, S2 heal → arts splash and endless from the
  2nd use, S3 y-8 and the levitation with its damage.
- 酒神: T1 神经损伤 on each hit + 1.3 splash; 堕梦 ASPD cut in 神经 bursts and 70 / 90 on enemy attacks (a burst interrupts
  the attack); RIT-X ×1.18 vs elite / leader; S1 two hits + 束缚 ×1.3 / ×1.5; S2 (SP_FULL toggle) the placed 本能的召唤
  (lure 4, exit 停顿 + DoT, 25 s redeploys while it runs); S3 y-8, per-second 神经损伤 until a burst, 1.5× burst recovery
  and the 迷狂牢笼 (blocks its tile's enemies at spawn / refresh, 1 per hit, 30 s after the skill).

Engine (additive, no handler before): ai.js hook `enemyAttackStart` (an enemy's normal attack, before 麻痹), blocking.js
flag `noNewBlock`, items/battle.js `holdsUndying` also sees a buff flagged `undying`; docs/SIM.md §3 / §5, kits/README.md
(docked skill-summon pieces: their kit without offOwner).

Golden: only diy moved — diy-106 … diy-109 (the prototype configurations shifted behind the new operators) and the new
diy-110 … diy-114 (the three operators' configurations); roster, bonds, fields, matches, standins unchanged.
Tests: test/content/op_slent2.test.js (11), op_haruka.test.js (9), op_phatm2.test.js (13).
2026-10-06 05:14:29 +08:00

431 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_phatm2.test.js — the 自选 operator kit of 酒神 (char_1042_phatm2, 6★ 巫役; kit
// server/sim/content/kits/ops/op-phatm2.js) and of her summons 本能的召唤 (token_10054_phatm2_encdool) and 迷狂牢笼
// (token_10055_phatm2_mndclv), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; 本能的召唤 as the
// placed hand piece of her player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60,
// rank 7) with no module or RIT-X “酒神之心” 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_phatm2.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 ID = 'char_1042_phatm2';
const CALL = 'token_10054_phatm2_encdool', CAGE = 'token_10055_phatm2_mndclv';
const FORMS = BACKUPS.units[ID].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_phatm2';
const S1 = 'skchr_phatm2_1', S2 = 'skchr_phatm2_2', S3 = 'skchr_phatm2_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);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }),
enemy_shooter: dummy('enemy_shooter', { atk: 300, range: 2, bat: 1 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 1, mass: 0, atk: 50 }),
enemy_walk_elite: enemyRec({ key: 'enemy_walk_elite', hp: 1e9, speed: 1, mass: 0, atk: 50, rank: 'ELITE' }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 酒神 as uid 1 at (10, 5) facing RIGHT, plus `others`; `call` = the 本能的召唤 piece's tile (uid 9) or null. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], call = null, callFirst = false, seed = 5 } = {}) {
const op = { uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col };
const piece = call ? [{ uid: 9, kind: 'token', tokenId: CALL, ownerUid: 1, row: call[0], col: call[1] }] : [];
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'elementBurst'], captureNoisy: true,
units: callFirst ? [...piece, op, ...others] : [op, ...piece, ...others],
});
h.step();
return { h, u: h.unit(1), callUnit: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === CALL) ?? null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const elemOf = (h, src, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === src && c.type === 'element');
const cagesOf = (h) => h.b.allyUnits.filter((a) => a.kind === 'token' && a.defId === CAGE && a.alive);
/** No attacks of hers (to isolate an effect). */
const quiet = (h, u) => h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
test('酒神 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-2, 巫役 (ranged arts, hits air, blocks 1), S1 AUTO on attack SP (data DEFAULT), S2 at full SP (kit), S3 ACTIVE_RANGE on its y-8, ground-targetable, 维多利亚 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.profile.maxTargets], [1, 'ranged', 'arts', true, 1.6, 1], `${label(f)}: 巫役`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-2`);
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.kind], [['DEFAULT', 'attack', 'instant'], ['SP_FULL', 'time', 'toggle'], ['ACTIVE_RANGE', 'time', 'duration']][skill], `${label(f)}: trigger / SP / kind`);
if (skill === 2) assert.deepEqual(u.skill.triggerGrid, form.skills[2].rangeGrid, `${label(f)}: the y-8 trigger grid`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [936, 411, 1111, 463]);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 125, atk: 30 }, { maxHp: 200, atk: 42 }]);
});
test('a 自选 pick: 酒神 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ID));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: X } } });
});
test('T1 形为心役: every hit of her attacks first gives its target 30 % ATK 神经损伤 and every other enemy within 1.3 of it (中点, air too) 20 % ATK — then the arts damage; one 1.5 away nothing', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 2 });
const t1 = formOf(tier, elite).talents[0].bb;
assert.deepEqual(t1, { 'attack@ep_damage_ratio': 0.3, ep_damage_ratio: 0.2, range_radius: 1.3 });
const main = h.spawn('enemy_dummy', { pos: [10, 6] });
const nearFly = h.spawn('enemy_fly', { pos: [11, 6] });
const far = h.spawn('enemy_dummy', { pos: [11.5, 7] }); // ≈ 1.8 from (10, 6)
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isAttack && c.target === main), 3));
const seq = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u);
const iHit = seq.findIndex((c) => c.dmg?.isAttack && c.target === main);
const before = seq.slice(0, iHit);
const mainEl = before.find((c) => c.type === 'element' && c.target === main);
const flyEl = before.find((c) => c.type === 'element' && c.target === nearFly);
assert.ok(mainEl && flyEl, '神经损伤 before the damage');
assert.equal(mainEl.dmg.element, 'neural');
approx(mainEl.amount, u.s.atk * 0.3, '30 % ATK');
approx(flyEl.amount, u.s.atk * 0.2, '20 % ATK to the flyer around');
assert.ok(!before.some((c) => c.target === far), 'outside the 1.3');
approx(seq[iHit].amount, u.s.atk, 'arts damage = ATK');
assert.equal(seq[iHit].type, 'arts');
done(h);
}
});
test('RIT-X “酒神之心”: her 神经损伤 on an ELITE / leader enemy ×1.18 (stages 1 and 3), on a normal one ×1; none without the module', () => {
for (const [tier, elite, mod] of [[5, true, X], [6, true, X], [6, true, null], [5, false, null]]) {
const { h, u } = field({ tier, elite, mod, skill: 2 });
const want = mod ? 1.18 : 1;
if (mod) assert.equal(modOf(tier, mod).traitOverride.bb.ep_damage_scale, 1.18);
const el = h.spawn('enemy_elite', { pos: [10, 6] });
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === el), 3));
approx(elemOf(h, u, n0).find((c) => c.target === el).amount, u.s.atk * 0.3 * want, `${label([tier, elite, mod])}: elite`);
h.b.kill(el);
const nm = h.spawn('enemy_dummy', { pos: [10, 6] });
const n1 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => elemOf(h, u, n1).some((c) => c.target === nm), 3));
approx(elemOf(h, u, n1).find((c) => c.target === nm).amount, u.s.atk * 0.3, 'normal enemy');
done(h);
}
});
test('T2 堕梦: while she is on the field every enemy in a 神经损伤 burst has ASPD −12 (RIT-X stage 3 −20), on the whole field; none outside the burst or once she left', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [5, true, X]]) {
const { h, u } = field({ tier, elite, mod, skill: 2 });
quiet(h, u);
const v = formOf(tier, elite).talents[1].bb.attack_speed + (mod === X && tier === 6 ? -8 : 0);
const e = h.spawn('enemy_dummy', { pos: [12, 15] }); // far outside her range
const aspd0 = e.s.aspd;
e.elem.neural = 999;
h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 10 });
assert.ok(e.findBuff('neuralBurst'), 'burst');
h.step();
approx(e.s.aspd, aspd0 + v, `ASPD ${v}`);
h.b.retreat(u);
h.run(0.3);
approx(e.s.aspd, aspd0, 'she left: gone');
done(h);
}
});
test('T2 堕梦: an enemy of her range starting a normal attack first takes 70 (RIT-X stage 3: 90) 神经损伤; a burst from it interrupts that very attack (麻痹); outside her range nothing', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
const { h, u } = field({ tier, elite, mod, skill: 2 });
quiet(h, u);
const val = mod === X && tier === 6 ? 90 : 70;
const sh = h.spawn('enemy_shooter', { pos: [10, 7] }); // in her y-2, 2 tiles from her
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === sh && c.target === u), 3), 'it shoots her');
const el = elemOf(h, u, n0).filter((c) => c.target === sh);
assert.equal(el.length, 1, 'one 神经损伤 per attack');
approx(el[0].amount, val, `${val}`);
// a burst from it: the attack is interrupted
sh.elem.neural = 1000 - val + 1;
const hp0 = u.hp, n1 = h.hooksOf('damaged').length;
h.run(1.05);
assert.ok(sh.findBuff('neuralBurst'), 'burst on its attack');
assert.ok(!h.hooksOf('damaged').slice(n1).some((c) => c.source === sh && c.target === u && c.dmg?.isAttack), 'that attack never lands');
assert.equal(u.hp, hp0);
// outside her range
const out = h.spawn('enemy_shooter', { pos: [12, 8] });
h.b.relocate(u, 10, 5);
const n2 = h.hooksOf('damaged').length;
h.run(3);
assert.ok(!elemOf(h, u, n2).some((c) => c.target === out), '(12, 8) is outside her y-2');
done(h);
}
});
test('S1 暗夜回声 (AUTO, 3 attack SP): every 4th attack hits twice for 105 % / 120 % ATK arts, an enemy not in a burst first, and roots it 2 / 3 s; while that root holds 神经损伤 on it ×1.3 / ×1.5 (from anyone)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.times, sk.bb.unmove, sk.bb.ep_damage_scale], [3, 0, elite ? 1.2 : 1.05, 2, elite ? 3 : 2, elite ? 1.5 : 1.3]);
const { h, u } = field({ tier, elite, skill: 0 });
const burst = h.spawn('enemy_dummy', { pos: [10, 6] });
const fresh = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.addBuff(burst, { key: 'neuralBurst', duration: 100, flags: { burstLock: true } });
assert.ok(h.runUntil(() => u.skill.activations === 1, 10));
h.run(0.5); // the bolts land
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.deepEqual(atks.map((c) => c.isSkill), [false, false, false, true], 'three plain attacks, then the skill\'s');
const skillHits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.dmg.isSkill);
assert.equal(skillHits.length, 2, 'two hits');
assert.ok(skillHits.every((c) => c.target === fresh), 'the enemy not in a burst');
for (const c of skillHits) approx(c.amount, u.s.atk * sk.bb.atk_scale, `${sk.bb.atk_scale * 100} %`);
const bind = h.hooksOf('statusApplied').find((c) => c.status === 'bind' && c.target === fresh);
assert.ok(bind, 'rooted');
approx(bind.duration, sk.bb.unmove, 'root time');
const n0 = h.hooksOf('damaged').length;
const other = h.spawn('enemy_dummy', { pos: [12, 12] });
h.b.dealDamage(other, fresh, { type: 'element', element: 'neural', amount: 100 });
approx(h.hooksOf('damaged').slice(n0).find((c) => c.target === fresh && c.type === 'element').amount, 100 * sk.bb.ep_damage_scale, `×${sk.bb.ep_damage_scale} while rooted`);
h.run(sk.bb.unmove + 0.1);
const n1 = h.hooksOf('damaged').length;
h.b.dealDamage(other, fresh, { type: 'element', element: 'neural', amount: 100 });
approx(h.hooksOf('damaged').slice(n1).find((c) => c.target === fresh && c.type === 'element').amount, 100, '×1 after it');
done(h);
}
});
test('S2 群体性谵妄 (AUTO, at full SP, endless): cast after 33 / 30 s with no enemy; ASPD +20 / +25; T1\'s splash radius 1.5; it never ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb.attack_speed, sk.bb['talent@range_radius']], [elite ? 30 : 33, 0, elite ? 25 : 20, 1.5]);
const { h, u } = field({ tier, elite, skill: 1 });
h.run(sk.spCost - 0.5);
assert.equal(u.skill.activations, 0);
assert.ok(h.runUntil(() => u.skill.active, 1), 'at full SP');
approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD');
const main = h.spawn('enemy_dummy', { pos: [10, 6] });
const at14 = h.spawn('enemy_dummy', { pos: [11.4, 6] }); // 1.4 away: inside 1.5, outside 1.3
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === at14), 3), 'the 1.5 splash');
void main;
h.run(200);
assert.ok(u.skill.active, 'endless');
done(h);
}
});
test('S2 + 本能的召唤: the placed piece deploys at the battle start (untargetable, no attack, no block), 诱导s the enemies entering its x-1 (at most 4, ELITE first), leaves 10 s after or when one reaches it; leaving: every enemy of its x-1 停顿 6 s and 12 × (15 % / 20 % ATK 神经损伤 + 100 % / 120 % ATK arts) over 6 s at her ATK of its deployment; back with S2 and again 25 s after each exit while it runs; her leaving withdraws it', () => {
for (const [tier, elite, callFirst] of [[5, false, false], [6, true, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, callUnit } = field({ tier, elite, skill: 1, call: [10, 6], callFirst });
const call = callUnit;
assert.ok(call && call.alive, 'deployed with the board');
assert.ok(call.s.flags.untargetable && call.profile.noAttack && call.s.blockCnt === 0, 'untargetable, no attack, no block');
const atkAtDeploy = u.s.atk;
// nothing to lure: it leaves after 10 s; the enemies of its x-1 then get the 停顿 and the damage
const inside = h.spawn('enemy_dummy', { pos: [11, 7] });
h.b.addBuff(inside, { key: 'test:noattract', flags: { attract: true } }); // (already lured elsewhere: not taken)
h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } }); // her ATK changes after the deployment
quiet(h, u);
assert.ok(h.runUntil(() => !call.alive, 11), 'leaves after 10 s');
approx(h.hooksOf('death').find((c) => c.unit === call).t, 10, '10 s', 0.05);
h.b.removeBuff(inside, 'test:noattract');
const slow = h.hooksOf('statusApplied').find((c) => c.status === 'sluggish' && c.target === inside);
assert.ok(slow);
approx(slow.duration, sk.bb.sluggish, '停顿 6 s');
const n0 = h.hooksOf('damaged').length;
h.run(6.2);
const ticks = h.hooksOf('damaged').slice(n0).filter((c) => c.target === inside && c.source === u);
const arts = ticks.filter((c) => c.type === 'arts'), neural = ticks.filter((c) => c.type === 'element');
assert.equal(arts.length, 12, '12 arts ticks');
// 12 神经损伤 ticks — unless the gauge bursts first (then the 爆发冷却 takes no more: elite 12 × 92.6 > 1000)
const per = atkAtDeploy * sk.bb.ep_damage_ratio_token;
assert.equal(neural.length, Math.min(12, Math.ceil(1000 / per)), '神经损伤 ticks');
for (const c of arts) approx(c.amount, atkAtDeploy * sk.bb.atk_scale, `arts ${sk.bb.atk_scale * 100} % of the cached ATK`);
for (const c of neural) approx(c.amount, atkAtDeploy * sk.bb.ep_damage_ratio_token, `神经损伤 ${sk.bb.ep_damage_ratio_token * 100} %`);
assert.ok(arts.every((c) => c.dmg.tags.includes('dot') && c.dmg.canDodge === false), '持续伤害');
h.b.removeBuff(u, 'test:atk');
h.b.kill(inside);
// S2 (at full SP: 33 / 30 s) gives it back, after its own 25 s redeploy time from its exit at 10 s
assert.ok(h.runUntil(() => u.skill.active, 20));
approx(h.b.time, sk.spCost, 'S2 at full SP', 0.05);
assert.ok(!call.alive, 'still in its redeploy time');
assert.ok(h.runUntil(() => call.alive, 6), 'back with S2');
approx(h.b.time, 35, 'at 10 + 25 s', 0.6);
const t0 = h.b.time;
// five walkers on row 9 enter its x-1 at (9, 7) together: four are lured, the elite first; the first to arrive ends it
const w = [h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk', { routeIndex: 0 }),
h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk_elite', { routeIndex: 0 })];
assert.ok(h.runUntil(() => !call.alive, 12), 'gone again');
const lured = h.hooksOf('statusApplied').filter((c) => c.status === 'attract' && c.source === call && c.t >= t0);
assert.equal(lured.length, 4, 'max_target 4');
assert.equal(lured[0].target, w[4], 'the elite first');
assert.equal(new Set(lured.map((c) => c.target)).size, 4);
const exitT = h.hooksOf('death').filter((c) => c.unit === call).at(-1).t;
assert.ok(exitT - t0 < 10 - 0.5, `left on an arrival, before its 10 s (${(exitT - t0).toFixed(2)} s)`);
assert.ok(lured.some((c) => Math.hypot(c.target.x - 6, c.target.y - 10) <= 0.4 + 1e-6), 'one of them within 0.4 of it');
assert.ok(w.every((e) => !e.findBuff('attract') || e.findBuff('attract').source !== call), 'the 诱导 ends with it');
// 25 s later it is back (S2 still runs)
h.run(24.5);
assert.ok(!call.alive);
assert.ok(h.runUntil(() => call.alive, 1.5), 'back after its own redeploy time');
h.b.retreat(u);
h.step();
assert.ok(!call.alive, 'withdrawn when she leaves');
h.run(30);
assert.ok(!call.alive, 'not back without her');
done(h);
}
});
test('本能的召唤: an ELITE in its x-1 is lured before normal enemies; only reachable ground enemies (flyers always)', () => {
const { h, u, callUnit } = field({ skill: 1, call: [11, 6] });
quiet(h, u);
const n = [h.spawn('enemy_dummy', { pos: [12, 6] }), h.spawn('enemy_dummy', { pos: [11, 5] }), h.spawn('enemy_dummy', { pos: [11, 7] }),
h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_elite', { pos: [12, 7] })];
h.run(0.15);
const lured = h.hooksOf('statusApplied').filter((c) => c.status === 'attract' && c.source === callUnit).map((c) => c.target);
assert.equal(lured.length, 4, 'four');
assert.equal(lured[0], n[4], 'the elite first');
done(h);
});
test('S3 空剧场 (ACTIVE_RANGE on y-8): range y-8 while on, ATK +75 % / +95 %, an enemy not in a burst first; her 神经损伤 starts 10 % ATK 神经损伤 a second (from 0.1 s) until a burst; the 爆发冷却 of enemies in her range runs 1.5×', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const form = formOf(tier, elite);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.ep_damage_ratio, sk.bb.interval, sk.bb['talent@ep_break_recover_speed'], sk.bb['phatm2_s_3[token_cooldown].interval']],
[30, elite ? 0.95 : 0.75, 0.1, 1, 0.5, 30]);
const { h, u } = field({ tier, elite, skill: 2 });
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'no enemy: no cast');
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // y-8 (+3), not y-2
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on the y-8');
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, 'y-8 while on');
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK');
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === e), 3), 'her first 神经损伤');
const t0 = elemOf(h, u, n0).find((c) => c.target === e).t;
quiet(h, u);
h.run(2.2);
const dot = elemOf(h, u, n0).filter((c) => c.target === e && c.dmg.tags.includes('phatm2:theatre'));
assert.ok(dot.length >= 2, `${dot.length} ticks`);
approx(dot[0].t - t0, 0.1, 'first at 0.1 s', 0.05);
approx(dot[1].t - dot[0].t, 1, 'then each second', 0.05);
for (const c of dot) approx(c.amount, u.s.atk * 0.1, '10 % ATK');
// until a burst
e.elem.neural = 999;
h.run(1.1);
assert.ok(e.findBuff('neuralBurst'), 'burst');
const n1 = h.hooksOf('damaged').length;
h.run(3);
assert.equal(elemOf(h, u, n1).filter((c) => c.target === e && c.dmg.tags.includes('phatm2:theatre')).length, 0, 'stops in a burst');
// the 爆发冷却 runs 1.5× in her range
const lock = e.findBuff('neuralBurst');
const left = lock.timeLeft;
h.run(1);
approx(left - lock.timeLeft, 1.5, 'cooldown 1.5×', 0.05);
h.runUntil(() => !u.skill.active, 35);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, 'back to y-2');
done(h);
}
});
test('S3 迷狂牢笼: a 神经损伤 burst of an enemy in her range puts a 牢笼 on its ground tile that blocks the enemies on it (air too), takes 1 per hit from them only, falls after 3 hits; it takes nobody else; refreshed by a burst on its tile; leaves with nobody blocked, 30 s after the skill, or when she leaves', () => {
const { h, u } = field({ skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
quiet(h, u);
const a = h.spawn('enemy_shooter', { pos: [11, 7] }), fly = h.spawn('enemy_fly', { pos: [11, 7] });
a.elem.neural = 990;
h.b.dealDamage(u, a, { type: 'element', element: 'neural', amount: 20 });
const cage = cagesOf(h)[0];
assert.ok(cage, 'a 牢笼');
assert.deepEqual([cage.tileR, cage.tileC, cage.ownerUnit === u], [11, 7, true]);
assert.ok(a.blockedBy === cage && fly.blockedBy === cage, 'both blocked, the flyer too');
assert.equal(cage.s.blockCnt, 2, 'block count = their weight');
assert.ok(cage.s.flags.isolated && cage.s.flags.noHeal && cage.profile.noAttack, '孤立, 禁疗, no attack');
// damage: from another enemy nothing, from a blocked one 1
const other = h.spawn('enemy_dummy', { pos: [12, 12] });
h.b.dealDamage(other, cage, { amount: 500, type: 'phys' });
assert.equal(cage.hp, 3, 'immune to an enemy it does not block');
h.b.dealDamage(null, cage, { amount: 500, type: 'true', sourceless: true });
assert.equal(cage.hp, 3, '无来源: nothing');
h.b.dealDamage(fly, cage, { amount: 500, type: 'phys' });
assert.equal(cage.hp, 2, '1 per hit');
// an enemy arriving on its tile is not taken by contact
const late = h.spawn('enemy_dummy', { pos: [11, 7] });
h.run(0.2);
assert.equal(late.blockedBy, null, 'no new block by contact');
// refreshed by its 神经损伤 burst on that tile: HP back, it is blocked too
late.elem.neural = 999;
h.b.dealDamage(u, late, { type: 'element', element: 'neural', amount: 10 });
assert.equal(late.blockedBy, cage, 'refreshed: blocked');
assert.equal(cage.hp, 3, 'full HP again');
assert.equal(cage.s.blockCnt, 3);
assert.equal(cagesOf(h).length, 1, 'no second 牢笼 on the tile');
// three hits from a blocked enemy: gone, the enemies released
for (let i = 0; i < 3; i++) h.b.dealDamage(fly, cage, { amount: 100, type: 'phys' });
assert.ok(!cage.alive, 'three hits');
assert.ok(!a.blockedBy && !fly.blockedBy, 'released');
done(h);
});
test('S3 迷狂牢笼: no 牢笼 on a tile with a unit or on high ground; at the skill start the enemies of her range already in a burst set T1\'s splash off once and get one; 牢笼 last 30 s after the skill; her leaving withdraws them', () => {
const { h, u } = field({ skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 11, col: 6 }] });
quiet(h, u);
// already bursting before the skill
const pre = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [10.9, 7] });
pre.elem.neural = 999;
h.b.dealDamage(u, pre, { type: 'element', element: 'neural', amount: 10 });
assert.ok(pre.findBuff('neuralBurst'));
const n0 = h.hooksOf('damaged').length;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const splash = elemOf(h, u, n0).filter((c) => c.target === side);
assert.equal(splash.length, 1, 'T1\'s splash once around it');
approx(splash[0].amount, u.s.atk * 0.2, '20 % of her (S3) ATK');
const c1 = cagesOf(h).find((c) => c.tileR === 10 && c.tileC === 7);
assert.ok(c1 && pre.blockedBy === c1, 'a 牢笼 for the bursting one');
// a unit on the tile: none
const onAlly = h.spawn('enemy_dummy', { pos: [11, 6] });
onAlly.elem.neural = 999;
h.b.dealDamage(u, onAlly, { type: 'element', element: 'neural', amount: 10 });
assert.ok(!cagesOf(h).some((c) => c.tileR === 11 && c.tileC === 6), 'not on 角峰\'s tile');
// the skill ends: 30 s more
u.skill.end('test');
h.run(29.5);
if (pre.alive && pre.blockedBy === c1) assert.ok(c1.alive, 'still there at 29.5 s');
h.run(1);
assert.ok(!c1.alive, 'gone 30 s after the skill');
// her leaving withdraws them
u.skill.gainSp(999);
const late = h.spawn('enemy_dummy', { pos: [12, 7] });
assert.ok(h.runUntil(() => u.skill.active, 4));
late.elem.neural = 999;
h.b.dealDamage(u, late, { type: 'element', element: 'neural', amount: 10 });
assert.ok(cagesOf(h).length >= 1);
h.b.retreat(u);
h.step();
assert.equal(cagesOf(h).length, 0, 'withdrawn with her');
done(h);
});