434 lines
27 KiB
JavaScript
434 lines
27 KiB
JavaScript
// 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, 434, 1111, 486]);
|
||
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 33 % ATK 神经损伤 and every other enemy within 1.3 of it (中点, air too) 23 % 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.33, ep_damage_ratio: 0.23, 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.33, '33 % ATK');
|
||
approx(flyEl.amount, u.s.atk * 0.23, '23 % 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.33 * 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.33, 'normal enemy');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 堕梦: while she is on the field every enemy in a 神经损伤 burst has ASPD −16 (RIT-X stage 3 −24), 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');
|
||
// (her hits so far plus the ones during the root — T1 33 % / 23 % ATK at full potential — would burst it before the
|
||
// ×1 check below, and a bursting enemy takes no 神经损伤: its gauge starts over here)
|
||
fresh.elem.neural = 0;
|
||
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 × 97.2 > 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.23, '23 % 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);
|
||
});
|