391 lines
22 KiB
JavaScript
391 lines
22 KiB
JavaScript
// test/content/op_necras.test.js — the 自选 operator kit of 死芒 (char_450_necras, 6★ 塑灵术师; kit
|
||
// server/sim/content/kits/ops/op-necras.js) and of her summon 悲叹的仆役 (token_10043_necras_skeltn, and its special form),
|
||
// fielded the production way (a DIY slot + its `diy` pick) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module)
|
||
// and elite (E2 Lv60, rank 7) with no module or SOC-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_necras.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 NEC = 'char_450_necras';
|
||
const SKEL = 'token_10043_necras_skeltn';
|
||
const FORMS = BACKUPS.units[NEC].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const X = 'uniequip_002_necras';
|
||
const S1 = 'skchr_necras_1', S2 = 'skchr_necras_2', S3 = 'skchr_necras_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 skelOf = (tier, elite, skill) => {
|
||
let v = BACKUPS.tokens[SKEL].variants[`${NEC}@${statusOf(tier, elite)}`];
|
||
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
|
||
return v;
|
||
};
|
||
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 ENEMIES = {
|
||
enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }),
|
||
enemy_weak: enemyRec({ key: 'enemy_weak', hp: 100, speed: 0, mass: 0 }),
|
||
enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e9, speed: 0, mass: 0, motion: 'FLY' }),
|
||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.5, mass: 0 }),
|
||
};
|
||
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, 3) facing RIGHT: her 3-1 is rows 9–11 × cols 3–5 and (10, 6). */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'statusApplied', 'heal'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NEC, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const skels = (u) => (u.mem.necras?.skels ?? []).filter((s) => s.alive);
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
/** Knock an enemy out at `pos` (the kill is credited to nobody: anyone's kill counts). */
|
||
function killAt(h, pos, key = 'enemy_weak') {
|
||
const e = h.spawn(key, { pos });
|
||
h.step();
|
||
h.b.kill(e, null);
|
||
return e;
|
||
}
|
||
|
||
test('死芒 in every 自选 form: her kit (all three skills authored), stats + module attributes, 3-1, blocks 1, ranged arts that hit air, 维多利亚, no 特质, the data triggers (S3 SKILL_RANGE on its 3-3); offered as a pick', () => {
|
||
assert.equal(OPERATOR_KITS[NEC], KITS[NEC]);
|
||
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], [NEC, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'arts', 1.6], `${label(f)}: 塑灵术师`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
|
||
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
|
||
done(h);
|
||
}
|
||
}
|
||
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'SKILL_RANGE']);
|
||
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 120, atk: 35 }, { maxHp: 200, atk: 55 }]);
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.ok(KITTED_CHARS.includes(NEC));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NEC), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } } });
|
||
});
|
||
|
||
test('复燃: a knock-out on her range (anyone\'s, stealth ignored) makes a 悲叹的仆役 on its tile 0.1 s later (facing as she does); off her range nothing; up to 3, then the upgrade (+100 % HP / ATK, +40 % DEF, block +1, healed to full; not yet upgraded first), all upgraded: the lowest healed; they leave with her', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||
const v = skelOf(tier, elite, 0);
|
||
assert.deepEqual([t0.max_token_cnt, t0.max_hp, t0.atk, t0.def, t0.block_cnt], [3, 1, 1, 0.4, 1], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
killAt(h, [10, 5]);
|
||
assert.equal(skels(u).length, 0, `T${tier}: not at once`);
|
||
h.run(0.11);
|
||
const [a] = skels(u);
|
||
assert.ok(a, `T${tier}: one 0.1 s later`);
|
||
assert.deepEqual([a.tileR, a.tileC, a.dir, a.defId], [10, 5, u.dir, SKEL], `T${tier}: on the kill tile, facing as she does`);
|
||
assert.deepEqual([a.base.maxHp, a.base.atk, a.base.def, a.s.blockCnt, a.profile.dmgType, a.profile.attack, a.profile.canHitFly], [v.stats.maxHp, v.stats.atk, v.stats.def, 1, 'arts', 'melee', false], `T${tier}: the token's stats, arts melee, ground only`);
|
||
assert.deepEqual(a.liveRangeGrid, v.rangeGrid, `T${tier}: x-5`);
|
||
assert.ok(a.s.flags.noHeal && a.s.flags.noSp && a.s.flags.silence, `T${tier}: 禁疗, 阻回, 沉默`);
|
||
killAt(h, [10, 8]);
|
||
h.run(0.2);
|
||
assert.equal(skels(u).length, 1, `T${tier}: off her range — nothing`);
|
||
killAt(h, [9, 4]);
|
||
h.run(0.2);
|
||
killAt(h, [11, 4]);
|
||
h.run(0.2);
|
||
assert.equal(skels(u).length, 3, `T${tier}: three`);
|
||
const [, b, c] = skels(u);
|
||
b.hp = b.s.maxHp * 0.5;
|
||
c.hp = c.s.maxHp * 0.3;
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
assert.equal(skels(u).length, 3, `T${tier}: no 4th`);
|
||
assert.ok(c.findBuff('necras:upgrade') && !a.findBuff('necras:upgrade') && !b.findBuff('necras:upgrade'), `T${tier}: the lowest-HP one upgraded`);
|
||
assert.deepEqual(c.findBuff('necras:upgrade').mods, { hpPct: 1, atkPct: 1, defPct: 0.4, blockCnt: 1 });
|
||
assert.deepEqual([c.hp, c.s.maxHp, c.s.blockCnt], [c.base.maxHp * 2, c.base.maxHp * 2, 2], `T${tier}: healed to full, block 2`);
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
assert.ok(skels(u).every((s) => s.findBuff('necras:upgrade')), `T${tier}: all three upgraded`);
|
||
b.hp = 100;
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
assert.equal(b.hp, b.s.maxHp, `T${tier}: all upgraded — the lowest healed`);
|
||
h.b.kill(u, null);
|
||
assert.equal(skels(u).length, 0, `T${tier}: they leave with her`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('复燃: no deployable kill tile ⇒ a random free low melee tile of her range, those with an enemy first; no tile at all ⇒ the upgrade instead', () => {
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 1, others: [
|
||
{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 5, chessId: 'chess_char_1_02_a', row: 9, col: 5 },
|
||
{ uid: 6, chessId: 'chess_char_1_02_a', row: 11, col: 3 }, { uid: 7, chessId: 'chess_char_1_02_a', row: 11, col: 4 }, { uid: 8, chessId: 'chess_char_1_02_a', row: 11, col: 5 },
|
||
] });
|
||
// her range: (9|10|11, 3–5) + (10, 6); free: (10, 4), (10, 5), (10, 6); an enemy stands on (10, 6)
|
||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
// a kill on a taken tile (an operator on it) ⇒ the tile with the enemy
|
||
killAt(h, [9, 4]);
|
||
h.run(0.2);
|
||
const [a] = skels(u);
|
||
assert.deepEqual([a.tileR, a.tileC], [10, 6], 'the free tile with an enemy');
|
||
killAt(h, [9, 5]);
|
||
h.run(0.2);
|
||
killAt(h, [9, 5]);
|
||
h.run(0.2);
|
||
assert.deepEqual(skels(u).map((s) => [s.tileR, s.tileC]).sort(), [[10, 4], [10, 5], [10, 6]], 'then the other free tiles');
|
||
done(h);
|
||
});
|
||
|
||
test('回光黯淡: her and her summons\' damage on an enemy below 60 % HP ×1.45 (the final damage); SOC-X "攻击力提升至115%" on an enemy her summon blocks (stage 1 and 3), which she can attack anywhere', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, X]]) {
|
||
const f = label([tier, elite, mod]);
|
||
const { h, u } = field({ tier, elite, mod, skill: 1, row: 9, col: 3 });
|
||
const e = h.spawn('enemy_dummy', { pos: [9, 5] });
|
||
h.run(3);
|
||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack);
|
||
const full = hits().at(-1).amount;
|
||
approx(full, u.s.atk, `${f}: ×1 at full HP`);
|
||
e.hp = e.s.maxHp * 0.59;
|
||
const n0 = hits().length;
|
||
h.run(2);
|
||
approx(hits().slice(n0).at(-1).amount, u.s.atk * 1.45, `${f}: ×1.45 below 60 %`);
|
||
done(h);
|
||
}
|
||
// the trait: she attacks the enemy her summon blocks out of her range (×1.15 with SOC-X)
|
||
for (const [tier, elite, mod, mul] of [[5, false, null, 1], [5, true, X, 1.15], [6, true, X, 1.15]]) {
|
||
const f = label([tier, elite, mod]);
|
||
const { h, u } = field({ tier, elite, mod, skill: 1, row: 12, col: 8 });
|
||
// her range: rows 11–13 × cols 8–10 and (12, 11); a 悲叹的仆役 made on (11, 9) blocks a walker on row 11 coming from the right
|
||
killAt(h, [11, 9]);
|
||
h.run(0.2);
|
||
const [s] = skels(u);
|
||
assert.deepEqual([s.tileR, s.tileC], [11, 9]);
|
||
const w = h.spawn('enemy_walk', { pos: [11, 13] });
|
||
assert.ok(h.runUntil(() => w.blockedBy === s, 20), `${f}: blocked by her summon`);
|
||
h.step(3);
|
||
assert.ok(u.extraRangeKeys && u.extraRangeKeys.includes(Math.round(w.y) * 21 + Math.round(w.x)), `${f}: its tile is her target`);
|
||
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w).length;
|
||
h.run(4);
|
||
const hit = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w && c.dmg.isAttack).slice(n0);
|
||
assert.ok(hit.length >= 1, `${f}: she attacks it`);
|
||
approx(hit[0].amount, u.s.atk * mul, `${f}: ×${mul}`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('SOC-X stage 3: a knock-out on her range or her summons\' ranges ⇒ her ATK +25 % for 10 s (refreshed); one on a summon\'s range only also makes a 悲叹的仆役 (a random tile of her range) — none of it at stage 1', () => {
|
||
for (const [tier, mod, on] of [[5, X, false], [6, X, true], [6, null, false]]) {
|
||
const f = label([tier, true, mod]);
|
||
const { h, u } = field({ tier, elite: true, mod, skill: 1, row: 10, col: 3 });
|
||
// a summon on the tip of her range (10, 6): its x-5 reaches (10, 7), (9, 6), (11, 6) — outside hers
|
||
killAt(h, [10, 6]);
|
||
h.run(0.2);
|
||
const [s] = skels(u);
|
||
assert.deepEqual([s.tileR, s.tileC], [10, 6]);
|
||
assert.equal(!!u.findBuff('necras:killAtk'), on, `${f}: ATK buff`);
|
||
if (on) {
|
||
assert.deepEqual(u.findBuff('necras:killAtk').mods, { atkPct: 0.25 });
|
||
approx(u.findBuff('necras:killAtk').timeLeft, 10 - 0.2, `${f}: 10 s`, 0.02);
|
||
h.run(5);
|
||
killAt(h, [9, 4]);
|
||
approx(u.findBuff('necras:killAtk').timeLeft, 10, `${f}: refreshed`, 0.02);
|
||
h.run(0.2);
|
||
}
|
||
const n = skels(u).length;
|
||
killAt(h, [10, 7]);
|
||
h.run(0.2);
|
||
assert.equal(skels(u).length, on ? n + 1 : n, `${f}: the summon-range kill makes one (stage 3 only)`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 噩愿: each 悲叹的仆役 made or upgraded strikes every enemy within 1.5 for her ATK × 310 % / 370 % arts (air units too); the cast re-makes them all — 1 per summon, 2 per upgraded one, 1 with none', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
assert.deepEqual([sk.bb.atk_scale, sk.bb.range_radius], [elite ? 3.7 : 3.1, 1.5]);
|
||
const { h, u } = field({ tier, elite, skill: 0 });
|
||
const near = h.spawn('enemy_fly', { pos: [11, 5] });
|
||
const far = h.spawn('enemy_dummy', { pos: [12, 7] });
|
||
killAt(h, [10, 5]);
|
||
h.run(0.2);
|
||
const by = (t) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === t && c.dmg.tags?.includes('necras:s1'));
|
||
assert.equal(by(near).length, 1, `T${tier}: the flyer within 1.5`);
|
||
approx(by(near)[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||
assert.equal(by(far).length, 0, `T${tier}: farther untouched`);
|
||
// fill to 3 and upgrade one (another strike)
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
killAt(h, [11, 4]);
|
||
h.run(0.2);
|
||
const k0 = by(near).length;
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
assert.ok(skels(u).some((s) => s.findBuff('necras:upgrade')));
|
||
assert.ok(by(near).length >= k0, `T${tier}: the upgrade strikes too`);
|
||
// the cast: 2 + 1 + 1 = 4 credits ⇒ 3 summons + 1 upgrade
|
||
const old = skels(u).map((s) => s.id);
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`);
|
||
h.run(0.5);
|
||
const now = skels(u);
|
||
assert.equal(now.length, 3, `T${tier}: three again`);
|
||
assert.ok(now.every((s) => !old.includes(s.id)), `T${tier}: all re-made`);
|
||
assert.equal(now.filter((s) => s.findBuff('necras:upgrade')).length, 1, `T${tier}: the 4th credit upgrades one`);
|
||
done(h);
|
||
}
|
||
// none on the field ⇒ one
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
|
||
h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
|
||
h.run(0.3);
|
||
assert.equal(skels(u).length, 1, 'none ⇒ one');
|
||
done(h);
|
||
});
|
||
|
||
test('S2 折朽: 12 s; up to 2 enemies of her range sleep while linked and take her ATK × 100 % / 130 % arts every 0.5 s through it; no attack meanwhile; a linked one knocked out ⇒ +2 summons; every link broken ⇒ the skill ends and the sleep with it', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
assert.deepEqual([sk.duration, sk.bb.atk_scale, sk.bb.max_target, sk.bb.interval, sk.bb.additional_token_cnt], [12, elite ? 1.3 : 1, 2, 0.5, 2], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
const a = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
const b = h.spawn('enemy_dummy', { pos: [9, 5] });
|
||
const c = h.spawn('enemy_dummy', { pos: [11, 5] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||
const slept = [a, b, c].filter((e) => e.findBuff('sleep'));
|
||
assert.equal(slept.length, 2, `T${tier}: two asleep`);
|
||
assert.deepEqual(u.skill.spec.attack, { noAttack: true });
|
||
const t0 = h.b.time;
|
||
h.run(3.01);
|
||
const ticks = h.hooksOf('damaged').filter((x) => x.source === u && slept.includes(x.target) && x.dmg.tags?.includes('necras:s2') && x.t > t0 - 1e-9);
|
||
assert.equal(ticks.length, 12, `T${tier}: 6 ticks each in 3 s`);
|
||
for (const x of ticks) approx(x.amount, u.s.atk * sk.bb.atk_scale * (1 - x.target.s.res / 100), `T${tier}: ${sk.bb.atk_scale * 100} % arts`, 1e-3);
|
||
assert.ok(ticks.every((x) => x.target.findBuff('sleep')), `T${tier}: through the sleep`);
|
||
// a linked one knocked out ⇒ +2 summons (and 复燃's own one on its tile)
|
||
const n0 = skels(u).length;
|
||
h.b.kill(slept[0], null);
|
||
h.run(0.4);
|
||
assert.equal(skels(u).length, n0 + 3, `T${tier}: 2 + 1 summons`);
|
||
assert.ok(u.skill.active, `T${tier}: one link left`);
|
||
h.b.kill(slept[1], null);
|
||
h.step(3);
|
||
assert.equal(u.skill.active, false, `T${tier}: every link broken ⇒ ends`);
|
||
done(h);
|
||
}
|
||
// the end releases the sleep; a stun interrupts it
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
assert.ok(e.findBuff('sleep'));
|
||
h.b.applyStatus(u, 'stun', { duration: 1 });
|
||
h.step(3);
|
||
assert.ok(!u.skill.active && !e.findBuff('sleep'), 'stunned: interrupted, the sleep gone');
|
||
done(h);
|
||
});
|
||
|
||
test('S3 冠死以冕: the next 悲叹的仆役 is the special form (+200 % HP / ATK, +100 % DEF, +10 RES, +0.7 s, block +1; 怨火缠身 15 SP / 8 s, 120 % ATK / s on its 1-1); the cast (SKILL_RANGE, 3-3 meanwhile): 2 strikes at her attack interval on every enemy of the 3-3 (550 % / 650 %), each consuming a normal summon to upgrade it (twice for an upgraded one; +80 / 80 / 40 %, heal 20 %, 3rd: block +1, 攻击距离 +1)', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const v = skelOf(tier, elite, 2);
|
||
const dr = formOf(tier, elite).talents.find((t) => t.index === 3).bb;
|
||
assert.deepEqual([dr.max_hp, dr.atk, dr.def, dr.magic_resistance, dr.base_attack_time, dr.block_cnt], [2, 2, 1, 10, 0.7, 1]);
|
||
assert.deepEqual([sk.bb['attack@atk_scale'], sk.bb['attack@max_hp'], sk.bb['attack@atk'], sk.bb['attack@def'], sk.bb['attack@hp_ratio'], sk.bb['attack@max_stack_cnt'], sk.rangeId], [elite ? 6.5 : 5.5, 0.8, 0.8, 0.4, 0.2, 6, '3-3'], `T${tier}`);
|
||
assert.deepEqual([v.skill.skillId, v.skill.spCost, v.skill.bb['attack@atk_scale'], v.skill.bb['attack@duration']], ['sktok_necras_skeltn_3', 15, 1.2, 8]);
|
||
const { h, u } = field({ tier, elite, skill: 2 });
|
||
killAt(h, [10, 5]);
|
||
h.run(0.2);
|
||
const [d] = skels(u);
|
||
assert.ok(d.mem.necrasDragon, `T${tier}: the first one is the special form`);
|
||
assert.deepEqual([d.s.maxHp, d.s.atk, d.s.def, d.s.res, d.s.blockCnt], [d.base.maxHp * 3, d.base.atk * 3, d.base.def * 2, d.base.res + 10, 2], `T${tier}: the special form's attributes`);
|
||
approx(d.s.bat, d.base.bat + 0.7, `T${tier}: +0.7 s`);
|
||
assert.ok(!d.s.flags.noSp && d.skill.spCost === 15, `T${tier}: its skill charges`);
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
killAt(h, [11, 4]);
|
||
h.run(0.2);
|
||
const [, n1, n2] = skels(u);
|
||
assert.ok(!n1.mem.necrasDragon && !n2.mem.necrasDragon && n1.s.flags.noSp, `T${tier}: the others normal, 阻回`);
|
||
// upgrade n1 (the earliest normal one), so its consumption upgrades twice
|
||
killAt(h, [10, 4]);
|
||
h.run(0.2);
|
||
assert.equal([n1, n2].filter((x) => x.findBuff('necras:upgrade')).length, 1, `T${tier}: one normal summon upgraded`);
|
||
const up1 = n1.findBuff('necras:upgrade') ? n1 : n2;
|
||
// the cast
|
||
const foe = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||
const u0atk = u.s.atk;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: SKILL_RANGE — an enemy in the 3-3`);
|
||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while it runs`);
|
||
const tCast = h.b.time;
|
||
assert.ok(h.runUntil(() => !u.skill.active, 5), `T${tier}: ends after its 2 strikes`);
|
||
const strikes = h.hooksOf('damaged').filter((x) => x.source === u && x.target === foe && x.dmg.tags?.includes('necras:s3'));
|
||
assert.equal(strikes.length, 2, `T${tier}: 2 strikes`);
|
||
approx(strikes[0].amount, u0atk * sk.bb['attack@atk_scale'] * (1 - foe.s.res / 100), `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % arts`, 1e-3);
|
||
approx(strikes[1].t - strikes[0].t, u.s.interval, `T${tier}: at her attack interval`, 0.05);
|
||
void tCast;
|
||
h.run(0.3);
|
||
assert.ok(!n1.alive && !n2.alive, `T${tier}: both normal summons consumed`);
|
||
void up1;
|
||
assert.equal(d.mem.necrasUps, 3, `T${tier}: 2 (the upgraded one) + 1 upgrades`);
|
||
assert.deepEqual(d.findBuff('necras:dragonUp').mods, { hpPct: 0.8 * 3, atkPct: 0.8 * 3, defPct: 0.4 * 3, blockCnt: 1, rangeExtend: 1 }, `T${tier}: 3 upgrades — block +1, 攻击距离 +1`);
|
||
assert.equal(u.liveRangeGrid.length, formOf(tier, elite).rangeGrid.length, `T${tier}: her range back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('怨火缠身 (the special form\'s skill): 15 SP over time, 8 s, its ATK × 120 % arts every second on every enemy of its 1-1; the normal forms never cast (阻回)', () => {
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||
killAt(h, [10, 5]);
|
||
h.run(0.2);
|
||
const [d] = skels(u);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => d.skill.active, 17), 'cast at 15 SP with an enemy in range');
|
||
approx(d.skill.timeLeft, 8, '8 s', 0.05);
|
||
const t0 = h.b.time;
|
||
h.runUntil(() => !d.skill.active, 10);
|
||
const ticks = h.hooksOf('damaged').filter((x) => x.source === d && x.target === e && x.dmg.isSkill && x.t >= t0 - 1e-9);
|
||
assert.equal(ticks.length, 8, 'one per second for 8 s');
|
||
approx(ticks[0].amount, d.s.atk * 1.2 * (1 - e.s.res / 100), '120 % arts', 1e-3);
|
||
done(h);
|
||
});
|
||
|
||
test('复燃: a stealthed enemy knocked out on her range counts (天赋无视隐匿); with no free tile left the summon becomes the upgrade', () => {
|
||
const tiles = [[9, 3], [9, 4], [9, 5], [11, 3], [11, 4], [11, 5], [10, 4], [10, 6]];
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 1, others: tiles.map(([r, c], i) => ({ uid: 3 + i, chessId: 'chess_char_1_02_a', row: r, col: c })) });
|
||
const e = h.spawn('enemy_weak', { pos: [10, 5] });
|
||
h.step();
|
||
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
|
||
h.step();
|
||
h.b.kill(e, null);
|
||
h.run(0.2);
|
||
const [s1] = skels(u);
|
||
assert.ok(s1, 'a summon for the stealthed one');
|
||
assert.deepEqual([s1.tileR, s1.tileC], [10, 5]);
|
||
killAt(h, [9, 4]);
|
||
h.run(0.2);
|
||
assert.equal(skels(u).length, 1, 'no free tile: no second summon');
|
||
assert.ok(s1.findBuff('necras:upgrade'), '… the upgrade instead');
|
||
done(h);
|
||
});
|