feedback5: merge O3 — 自选 kits for 逻各斯, 真言 and 维伊; engine hook palsyTrigger (麻痹 interrupting an enemy attack); golden: only diy moved
This commit is contained in:
commit
8357149afa
11 files changed
+2270
-121
No files matched your search
@@ -0,0 +1,390 @@
|
||||
// test/content/op_logos.test.js — the 自选 operator kit of 逻各斯 (char_4133_logos, 6★ 中坚术师; kit
|
||||
// server/sim/content/kits/ops/op-logos.js), 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, CCR-Δ
|
||||
// “来自河谷的笔盒” or CCR-Y “《语义范式百科》” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_logos.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 LOGOS = 'char_4133_logos';
|
||||
const FORMS = BACKUPS.units[LOGOS].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CCRD = 'uniequip_002_logos', CCRY = 'uniequip_003_logos';
|
||||
const S1 = 'skchr_logos_1', S2 = 'skchr_logos_2', S3 = 'skchr_logos_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 talent bb of a form with a module's changes (stage 2+ rewrites 语汇演化). */
|
||||
const talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).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_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }),
|
||||
enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }), enemy_low: dummy('enemy_low', { hp: 300 }),
|
||||
enemy_gun: dummy('enemy_gun', { atk: 100, range: 3, bat: 1, applyWay: 'RANGED' }),
|
||||
};
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRD, CCRY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 逻各斯 as uid 1 at (row, col) facing RIGHT. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LOGOS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
/** Start the skill now (SP filled, no enemy needed). */
|
||||
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
|
||||
|
||||
test('逻各斯 in every 自选 form: his operator kit (all three skills authored), stats + module attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[LOGOS], KITS[LOGOS]);
|
||||
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], [LOGOS, 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 + (m?.attr.res ?? 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, 1.6], `${label(f)}: 中坚术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
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 of the forms (zh_CN): E2 Lv1 1297 / 556, E2 Lv60 1540 / 632; CCR-Δ +100 / +36 → +170 / +67 HP / ATK,
|
||||
// CCR-Y +35 / +5 → +60 / +5 ATK / RES
|
||||
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], [1297, 556, 1540, 632]);
|
||||
assert.deepEqual([modOf(5, CCRD).attr, modOf(6, CCRD).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 100, atk: 36 }, { maxHp: 170, atk: 67 }, { atk: 35, res: 5 }, { atk: 60, res: 5 }]);
|
||||
});
|
||||
|
||||
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(LOGOS));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LOGOS), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } } });
|
||||
});
|
||||
|
||||
test('S1 殁亡 (AUTO, data DEFAULT): 74 / 68 SP from 0, cast as he is about to attack an enemy of his 3-1; then for good the 3-3 range and ATK +40 % / +70 %', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.initSp, sk.bb.atk, sk.rangeId], ['DEFAULT', 'toggle', elite ? 68 : 74, 0, elite ? 0.7 : 0.4, '3-3'], `T${tier}`);
|
||||
h.run(sk.spCost - 1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
|
||||
h.run(3);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: full SP, no enemy: no cast`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.run(200);
|
||||
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: still 3-3`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 殁亡 execution: an enemy of his range under 90 % / 120 % of his ATK falls at once (9999999 无来源 true damage, no dodge), then another random enemy of the range takes arts damage equal to the HP it had left; one try only on a survivor; air units too', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
let guard = null;
|
||||
const { h, u } = field({ tier, elite, skill: 0, setup: (b) => b.on('fatal', (c) => { if (c.unit === guard) c.prevented = true; }) });
|
||||
cast(u);
|
||||
const thr = sk.bb['attack@kill_atk_scale'] * u.s.atk;
|
||||
assert.equal(sk.bb['attack@kill_damage'], 9999999);
|
||||
const big = h.spawn('enemy_dummy', { pos: [11, 8] }); // a 3-3 tile outside the 3-1 (the toggle's range counts)
|
||||
const low = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const hp0 = thr * 0.6;
|
||||
low.hp = hp0;
|
||||
const high = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.step();
|
||||
assert.ok(!low.alive, `T${tier}: the flyer under the threshold fell`);
|
||||
const kill = h.hooksOf('damaged').find((c) => c.target === low && (c.dmg?.tags || []).includes('execute'));
|
||||
assert.ok(kill && kill.source === null && kill.credit === u && kill.type === 'true' && kill.dmg.sourceless && !kill.dmg.canDodge, `T${tier}: 无来源 true, credited to him`);
|
||||
assert.equal(low.removeReason, 'killed');
|
||||
const follow = tagged(h, u, 'logosPerish');
|
||||
assert.equal(follow.length, 1, `T${tier}: one follow-up`);
|
||||
assert.ok([big, high].includes(follow[0].target) && follow[0].type === 'arts', `T${tier}: on another enemy of his range`);
|
||||
approx(follow[0].amount, hp0, `T${tier}: the HP the fallen one had left`);
|
||||
assert.ok(![big, high].some((e) => h.hooksOf('damaged').some((c) => c.target === e && (c.dmg?.tags || []).includes('execute'))), `T${tier}: above the threshold: no execution`);
|
||||
// an enemy that falls under the threshold while on his range is executed then (the aura checks every tick)
|
||||
high.hp = thr * 0.9;
|
||||
h.step();
|
||||
assert.ok(!high.alive, `T${tier}: executed once under the threshold`);
|
||||
// a survivor (不死) is never tried again
|
||||
guard = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
guard.hp = thr * 0.5;
|
||||
h.step();
|
||||
const tries = () => h.hooksOf('damaged').filter((c) => c.target === guard && (c.dmg?.tags || []).includes('execute')).length;
|
||||
assert.equal(tries(), 1, `T${tier}: tried once`);
|
||||
assert.ok(guard.alive && guard.hp <= 1 + 1e-9, `T${tier}: held at 1 HP`);
|
||||
h.run(3);
|
||||
assert.equal(tries(), 1, `T${tier}: never again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 提喻 (MANUAL, data DEFAULT): 20 s, RES +40 / +50; locks one target with a hit every 0.5 s whatever the attack speed, 35 %→105 % / 50 %→150 % of ATK and its speed down to 40 % over 10 hits; a stun breaks the lock', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 7 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, b.magic_resistance, b['attack@cooldown'], b['attack@atk_scale_base'], b['attack@atk_scale_delta'], b['attack@move_speed'], b['attack@max_stack_cnt']],
|
||||
['DEFAULT', 'duration', 20, elite ? 50 : 40, 0.5, elite ? 0.5 : 0.35, elite ? 0.1 : 0.07, -0.06, 10], `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 300 } }); // the lock ignores the attack speed
|
||||
const res0 = u.s.res;
|
||||
cast(u);
|
||||
approx(u.s.res, res0 + b.magic_resistance, `T${tier}: RES`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(6.02);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
assert.ok(hits.length >= 12 && hits.length <= 13, `T${tier}: ${hits.length} hits in 6 s (one per 0.5 s)`);
|
||||
for (let i = 1; i < hits.length; i++) approx(hits[i].t - hits[i - 1].t, 0.5, `T${tier}: cadence`, 0.07);
|
||||
hits.forEach((c, i) => {
|
||||
assert.equal(c.target, e, `T${tier}: the locked target`);
|
||||
// + 剜魂具辞's 150 (the first hit marks it already); the target's RES 0
|
||||
approx(c.amount, u.s.atk * (b['attack@atk_scale_base'] + b['attack@atk_scale_delta'] * Math.min(10, i)) + 150, `T${tier}: hit ${i}`, 1e-4);
|
||||
});
|
||||
assert.ok(Math.abs(hits[10].amount - (u.s.atk * b['attack@atk_scale_base'] * 3 + 150)) < 1e-3 * hits[10].amount, `T${tier}: 3× after 5 s`);
|
||||
approx(e.findBuff(`logos:lockSlow:${u.id}`).mods.moveMul, 0.4, `T${tier}: 40 % speed`);
|
||||
// a stun breaks it: the slow goes, the next hits start over
|
||||
h.b.applyStatus(u, 'stun', { duration: 1, source: e });
|
||||
h.step();
|
||||
assert.equal(e.findBuff(`logos:lockSlow:${u.id}`), null, `T${tier}: slow gone with the lock`);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(1.6);
|
||||
const after = atkHits(h, u).slice(n1);
|
||||
approx(after[0].amount, u.s.atk * b['attack@atk_scale_base'] + 150, `T${tier}: reset`, 1e-4);
|
||||
// beam: the hit lands as the attack is made (no bolt in flight)
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
assert.ok(Math.abs(atks[atks.length - 1].t - after[after.length - 1].t) < 1e-9, `T${tier}: instant`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
approx(u.s.res, res0, `T${tier}: RES back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 提喻 keeps its lock while another enemy comes first in the order, and locks anew when its target leaves the field', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
cast(u);
|
||||
h.run(1.1);
|
||||
assert.ok(atkHits(h, u).every((c) => c.target === a));
|
||||
h.spawn('enemy_dummy', { pos: [10, 6], routeIndex: 0 }); // nearer the goal: first in the order
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(1.6);
|
||||
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === a), 'still the locked one');
|
||||
h.b.kill(a, null);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(1.1);
|
||||
const next = atkHits(h, u).slice(n1);
|
||||
assert.ok(next.length >= 2 && next.every((c) => c.target !== a));
|
||||
approx(next[0].amount, u.s.atk * 0.5 + 150, 'a new lock starts at the base scale', 1e-4);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 延异视阈 (MANUAL, data ACTIVE_RANGE on its 3-4): cast with an enemy on the 3-4 only, 30 s, ATK +160 % / +220 %, 3 targets at once', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.projectile_move_scale], ['ACTIVE_RANGE', '3-4', 30, elite ? 2.2 : 1.6, 3, 0.05], `T${tier}`);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is the 3-4`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy`);
|
||||
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-4 only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy outside his own range`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-4 while on`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const more = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_fly', { pos: [9, 7] }), h.spawn('enemy_dummy', { pos: [11, 8] })];
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.run(5);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
|
||||
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 3), `T${tier}: 3 targets per attack`);
|
||||
assert.ok(atks.every((c) => c.targets.every((e) => [far, ...more].includes(e))));
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back once they leave it) and are cleared when the skill ends — not when he is disarmed then', () => {
|
||||
for (const disarm of [false, true]) {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
h.spawn('enemy_gun', { pos: [10, 7] });
|
||||
h.run(1.5);
|
||||
cast(u);
|
||||
h.run(0.2);
|
||||
h.run(3);
|
||||
const shots = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
|
||||
assert.ok(shots.length >= 2, `${shots.length} enemy shots held`);
|
||||
for (const p of shots) approx(p.speed, 10 * 0.05, 'slowed to 0.05×');
|
||||
if (disarm) h.b.applyStatus(u, 'disarm', { duration: 40, source: null });
|
||||
h.runUntil(() => u.skill.timeLeft < 0.5, 40);
|
||||
for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot
|
||||
const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length;
|
||||
assert.ok(held >= 1, `${held} shots in the air as it ends`);
|
||||
const taken = u.stats.taken;
|
||||
h.runUntil(() => !u.skill.active, 2);
|
||||
h.step();
|
||||
const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
|
||||
if (!disarm) {
|
||||
assert.equal(left.length, 0, 'cleared');
|
||||
h.run(5);
|
||||
assert.equal(u.stats.taken, taken, 'none of the held shots hit him');
|
||||
} else {
|
||||
assert.ok(left.length > 0 && left.every((p) => Math.abs(p.speed - 10) < 1e-9), 'disarmed: kept, at full speed again');
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 语汇演化: 40 % per attacked target for an extra 60 % ATK arts hit on a random enemy of his range + 停顿 0.8 s; CCR-Δ stage 3: 60 % and 60 % ATK 元素伤害 on a target in its 凋亡 burst; CCR-Y stage 3: two executions at 65 %', () => {
|
||||
for (const f of [[5, false, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t = talentOf(tier, elite, mod, 0);
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
|
||||
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 500 } });
|
||||
const es = [h.spawn('enemy_dummy', { pos: [10, 7] }), h.spawn('enemy_fly', { pos: [11, 6] })];
|
||||
h.run(50);
|
||||
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).reduce((n, c) => n + c.targets.length, 0);
|
||||
const extra = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'arts');
|
||||
const emit = t.emit_count ?? 1;
|
||||
const ratio = extra.length / (attacks * emit);
|
||||
assert.ok(attacks > 150 && Math.abs(ratio - t.prob) < 0.08, `${label(f)}: ${extra.length} / ${attacks}×${emit} ≈ ${t.prob}`);
|
||||
for (const c of extra.slice(0, 6)) {
|
||||
assert.ok(es.includes(c.target), `${label(f)}: an enemy of his range (air too)`);
|
||||
const marked = h.hooksOf('damaged').some((d) => d.source === u && d.target === c.target && d.dmg?.isAttack && d.t <= c.t && c.t - d.t <= 5);
|
||||
approx(c.amount, u.s.atk * t.atk_scale + (marked ? 150 : 0), `${label(f)}: ${t.atk_scale * 100} % ATK (+ 剜魂具辞 on a marked one)`, 1e-4);
|
||||
}
|
||||
const slug = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||||
assert.ok(slug.length >= extra.length * 0.95 && slug.every((c) => Math.abs(c.duration - t.sluggish) < 1e-9), `${label(f)}: 停顿 ${t.sluggish} s`);
|
||||
const elem = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'elemental');
|
||||
if (t.element_atk_scale) {
|
||||
assert.ok(elem.length > 0, `${label(f)}: 元素伤害 during the 凋亡 bursts (his own CCR-Δ fills them)`);
|
||||
for (const c of elem) {
|
||||
approx(c.amount, u.s.atk * t.element_atk_scale, `${label(f)}: ${t.element_atk_scale * 100} % ATK 元素伤害`, 1e-4);
|
||||
assert.ok(h.hooksOf('elementBurst').some((b) => b.target === c.target && b.element === 'apoptosis' && b.t <= c.t && c.t - b.t < 15), 'in its 凋亡 burst');
|
||||
}
|
||||
} else assert.equal(elem.length, 0, `${label(f)}: no 元素伤害`);
|
||||
done(h);
|
||||
}
|
||||
// the module texts
|
||||
assert.match(modOf(6, CCRD).talentChanges.find((t) => t.talentIndex === 0).desc, /60%几率.*凋亡损伤爆发期间则同时造成相当于攻击力60%的元素伤害/);
|
||||
assert.match(modOf(6, CCRY).talentChanges.find((t) => t.talentIndex === 0).desc, /两个随机目标造成相当于攻击力65%/);
|
||||
assert.equal(modOf(5, CCRD).talentChanges.find((t) => t.talentIndex === 0), undefined, 'stage 1: the talent unchanged');
|
||||
});
|
||||
|
||||
test('T1 语汇演化 also follows 殁亡\'s follow-up hit (备注 "可触发第一天赋") and works while he is disarmed', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0, seed: 3 });
|
||||
cast(u);
|
||||
h.b.applyStatus(u, 'disarm', { duration: 999, source: null });
|
||||
const thr = 1.2 * u.s.atk;
|
||||
let rolls = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
const low = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
low.hp = thr * 0.5;
|
||||
h.step();
|
||||
rolls++;
|
||||
for (const e of h.enemies()) h.b.kill(e, null);
|
||||
h.step();
|
||||
}
|
||||
assert.equal(atkHits(h, u).length, 0, 'disarmed: no attack');
|
||||
assert.equal(tagged(h, u, 'logosPerish').length, rolls);
|
||||
const extra = tagged(h, u, 'logosLexicon');
|
||||
assert.ok(extra.length > rolls * 0.2 && extra.length < rolls * 0.6, `${extra.length} / ${rolls} ≈ 40 %`);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T2 剜魂具辞: each of his attack hits gives the target RES −10 and +150 on every arts damage it takes for 5 s — the hit itself included, other casters\' arts too; one instance per enemy', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, CCRY]]) {
|
||||
const t = talentOf(tier, elite, mod, 1);
|
||||
assert.deepEqual(t, { duration: 5, magic_resistance: -10, atk_addition: 150 });
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 9 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
e.base.res = 30; e.markDirty();
|
||||
const first = () => atkHits(h, u)[0];
|
||||
assert.ok(h.runUntil(() => !!first(), 4));
|
||||
const c = first();
|
||||
approx(c.amount, (u.s.atk + 150) * (1 - 0.2), `T${tier}: (ATK + 150) × (1 − (30 − 10) %) on the very hit`, 1e-4);
|
||||
const mark = e.findBuff('logos:soulRend');
|
||||
assert.ok(mark && Math.abs(mark.mods.resFlat + 10) < 1e-9 && mark.data.value === 150);
|
||||
approx(e.s.res, 20, `T${tier}: RES −10`);
|
||||
// another source's arts damage gets the 150 too; physical does not
|
||||
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'arts', canDodge: false }), (1000 + 150) * 0.8, 'arts +150');
|
||||
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'phys', canDodge: false }), 1000, 'phys unchanged');
|
||||
// it lapses 5 s after his last hit
|
||||
h.b.applyStatus(u, 'stun', { duration: 20, source: null });
|
||||
h.run(5.2);
|
||||
assert.equal(e.findBuff('logos:soulRend'), null, `T${tier}: gone after 5 s`);
|
||||
approx(e.s.res, 30, `T${tier}: RES back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCR-Δ “来自河谷的笔盒” (stages 1 and 3): 8 % of every arts damage he deals as 凋亡 损伤; none without it', () => {
|
||||
for (const f of [[5, false, null], [5, true, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 4 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(4);
|
||||
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts');
|
||||
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'element');
|
||||
assert.ok(arts.length > 0);
|
||||
if (elite && mod === CCRD) {
|
||||
assert.equal(u.def.raw.talents.find((t) => t.index === -1).bb.ep_damage_ratio, 0.08);
|
||||
assert.equal(fills.length, arts.length, `${label(f)}: one fill per arts hit`);
|
||||
fills.forEach((c, i) => {
|
||||
assert.equal(c.dmg.element, 'apoptosis');
|
||||
approx(c.amount, arts[i].amount * 0.08, `${label(f)}: 8 %`);
|
||||
});
|
||||
} else assert.equal(fills.length, 0, `${label(f)}: no 凋亡`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCR-Y “《语义范式百科》” (stages 1 and 3): +1 SP per normal-attack hit on an elite or leader enemy (none on a normal one, none while a skill runs); none without it', () => {
|
||||
for (const f of [[5, true, CCRY], [6, true, CCRY], [6, true, CCRD], [6, false, null]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y = elite && mod === CCRY;
|
||||
if (y) assert.deepEqual(modOf(tier, CCRY).traitOverride.bb, { sp: 1 });
|
||||
for (const key of ['enemy_dummy', 'enemy_elite', 'enemy_boss']) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 });
|
||||
const e = h.spawn(key, { pos: [10, 7] });
|
||||
h.b.applyStatus(u, 'disarm', { duration: 3, source: null });
|
||||
h.run(3.1);
|
||||
const sp0 = u.skill.sp, t0 = h.b.time;
|
||||
h.run(5);
|
||||
const hits = [...atkHits(h, u), ...tagged(h, u, 'logosLexicon')].filter((c) => c.target === e && c.t >= t0 - 1e-9).length;
|
||||
const gain = u.skill.sp - sp0 - 5; // 5 s of natural SP
|
||||
const want = y && key !== 'enemy_dummy' ? hits : 0;
|
||||
approx(gain, want, `${label(f)} ${key}: ${hits} hits`, 1e-6);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
// test/content/op_mantra.test.js — the 自选 operator kit of 真言 (char_4204_mantra, 6★ 本源术师; kit
|
||||
// server/sim/content/kits/ops/op-mantra.js), 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 or PRI-X
|
||||
// “中枢神经探测模块” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that
|
||||
// slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_mantra.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 MANTRA = 'char_4204_mantra';
|
||||
const FORMS = BACKUPS.units[MANTRA].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const PRIX = 'uniequip_002_mantra';
|
||||
const S1 = 'skchr_mantra_1', S2 = 'skchr_mantra_2', S3 = 'skchr_mantra_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).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_fly: dummy('enemy_fly', { motion: 'FLY' }),
|
||||
enemy_shooter: dummy('enemy_shooter', { atk: 1, bat: 1, range: 4 }),
|
||||
enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e9, speed: 0.3, mass: 0 }),
|
||||
enemy_immune: dummy('enemy_immune', { otherImmunities: ['palsy'] }),
|
||||
};
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRIX].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup, stage } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, ...(stage ? { flat: stage } : null),
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: MANTRA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag, src = u) => h.hooksOf('damaged').filter((c) => (c.source === src || c.credit === src) && (c.dmg?.tags || []).includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
|
||||
const palsy = (e) => e.findBuff('palsy')?.stacks ?? 0;
|
||||
|
||||
test('真言 in every 自选 form: her operator kit (all three skills authored), stats + PRI-X attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 萨尔贡 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[MANTRA], KITS[MANTRA]);
|
||||
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], [MANTRA, 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], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 本源术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${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], [1071, 583, 1308, 657]);
|
||||
assert.deepEqual([modOf(5, PRIX).attr, modOf(6, PRIX).attr], [{ maxHp: 90, atk: 43 }, { maxHp: 145, atk: 87 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 真言 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(MANTRA));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MANTRA), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } } });
|
||||
});
|
||||
|
||||
test('S1 共鸣溃缩 (AUTO, attack SP 3, data DEFAULT): every 4th attack deals 230 % / 275 % ATK arts, then 15 % / 20 % of it as 神经 损伤, then — on a target in its 神经 burst — 150 % / 160 % ATK 元素伤害', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, sk.bb.atk_scale, sk.bb.ep_damage_ratio, sk.bb.element_atk_scale],
|
||||
['DEFAULT', 'instant', 'attack', 3, elite ? 2.75 : 2.3, elite ? 0.2 : 0.15, elite ? 1.6 : 1.5], `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(16 * 1.6 + 0.5);
|
||||
const hits = atkHits(h, u).filter((c) => c.target === e);
|
||||
const skillHits = hits.filter((c) => c.dmg.isSkill);
|
||||
assert.ok(hits.length >= 15, `T${tier}: ${hits.length} attacks`);
|
||||
hits.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3, `T${tier}: attack ${i + 1}`));
|
||||
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'element');
|
||||
for (const c of skillHits) {
|
||||
const fill = fills.find((x) => Math.abs(x.t - c.t) < 1e-9);
|
||||
if (fill) approx(fill.amount, c.amount * sk.bb.ep_damage_ratio, `T${tier}: 神经 = ${sk.bb.ep_damage_ratio * 100} % of the damage`);
|
||||
}
|
||||
approx(skillHits[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.ok(fills.length >= 1 && fills.every((x) => x.dmg.element === 'neural'));
|
||||
// in its 神经 burst: + element_atk_scale × ATK 元素伤害 after the 神经 fill (a fresh target one point short of it)
|
||||
h.b.kill(e, null);
|
||||
const e2 = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
e2.elem.neural = e2.gaugeMax - 1;
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isSkill && c.dmg?.isAttack), 8);
|
||||
h.step();
|
||||
const after = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.type === 'elemental' && (c.dmg.tags || []).includes('mantra'));
|
||||
assert.ok(h.hooksOf('elementBurst').some((c) => c.target === e2 && c.element === 'neural'), `T${tier}: her 神经 burst it`);
|
||||
assert.equal(after.length, 1, `T${tier}: the burst made by its own 神经 counts`);
|
||||
approx(after[0].amount, u.s.atk * sk.bb.element_atk_scale, `T${tier}: ${sk.bb.element_atk_scale * 100} % ATK 元素伤害`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 意识联协 (MANUAL, data DEFAULT): 25 s, attack interval 1.6 − 0.6 / 0.7 s; 170 % / 210 % ATK on the target, then 2 / 3 jumps at 3.2 tiles/s to the nearest new enemy within 2.0 tiles for 100 % / 110 %; 神经 riders on every hit', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, b.base_attack_time, b['attack@atk_scale'], b['attack@chain.atk_scale'], b.chain_times, b['attack@ep_damage_ratio'], b['attack@element_atk_scale']],
|
||||
['DEFAULT', 25, elite ? -0.7 : -0.6, elite ? 2.1 : 1.7, elite ? 1.1 : 1, elite ? 3 : 2, elite ? 0.15 : 0.13, elite ? 0.2 : 0.18], `T${tier}`);
|
||||
// a line of enemies 1.5 tiles apart (the 4th is 2.1 from the 3rd: out of the jump radius at tier 6)
|
||||
const main = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const j1 = h.spawn('enemy_dummy', { pos: [10, 8.5] });
|
||||
const j2 = h.spawn('enemy_fly', { pos: [10, 10] });
|
||||
const j3 = h.spawn('enemy_dummy', { pos: [11.5, 10] });
|
||||
const out = h.spawn('enemy_dummy', { pos: [11.5, 12.1] });
|
||||
cast(u);
|
||||
approx(u.s.interval, 1.6 + b.base_attack_time, `T${tier}: interval`);
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === u).length > a0, 2);
|
||||
const at = h.hooksOf('attack').filter((c) => c.attacker === u).slice(-1)[0];
|
||||
assert.equal(at.targets[0], main, `T${tier}: the first target`);
|
||||
h.run(3);
|
||||
const id = atkHits(h, u).find((c) => c.target === main && c.t >= at.t).dmg.attackId;
|
||||
const chainHits = atkHits(h, u).filter((c) => c.dmg.attackId === id);
|
||||
const order = chainHits.map((c) => c.target);
|
||||
assert.deepEqual(order, [main, j1, j2, j3].slice(0, 1 + b.chain_times), `T${tier}: the nearest new enemy each time`);
|
||||
assert.ok(!order.includes(out), `T${tier}: 2.1 tiles: out of reach`);
|
||||
approx(chainHits[0].amount, u.s.atk * b['attack@atk_scale'], `T${tier}: main`);
|
||||
for (const c of chainHits.slice(1)) {
|
||||
approx(c.amount, u.s.atk * b['attack@chain.atk_scale'], `T${tier}: jump`);
|
||||
assert.ok(c.dmg.tags.includes('chain') && c.dmg.isSkill);
|
||||
}
|
||||
approx(chainHits[2].t - chainHits[1].t, 1.5 / 3.2, `T${tier}: 3.2 tiles/s`, 0.1);
|
||||
for (const [i, c] of chainHits.entries()) {
|
||||
const fill = h.hooksOf('damaged').find((x) => x.source === u && x.type === 'element' && x.target === c.target && Math.abs(x.t - c.t) < 1e-9);
|
||||
assert.ok(fill, `T${tier}: a 神经 fill with hit ${i}`);
|
||||
approx(fill.amount, c.amount * b['attack@ep_damage_ratio'], `T${tier}: 神经 ${i}`);
|
||||
}
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真 (MANUAL, data ACTIVE_RANGE on its 3-17): cast for an enemy outside her own range, 40 s, ATK +170 % / +210 %, 2 targets, the 3-17 while on; 隐匿 enemies on it are revealed', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.max_trigger_cnt, sk.bb.per_active, sk.bb.interval_projectile_trigger, sk.bb.atk_scale],
|
||||
['ACTIVE_RANGE', '3-17', 40, elite ? 2.1 : 1.7, 2, 3, 2, 1.5, elite ? 1.45 : 1.1], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-17 only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
|
||||
assert.ok(!formOf(tier, elite).rangeGrid.some(([r, c]) => r === far.y - 10 && c === far.x - 5), `T${tier}: the enemy is not on her own 3-1`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-17`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const sneak = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.b.addBuff(sneak, { key: 'test:stealth', flags: { stealth: true } });
|
||||
h.step();
|
||||
assert.ok(sneak.s.flags.reveal, `T${tier}: revealed on her range`);
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.run(4);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
|
||||
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 2), `T${tier}: 2 targets`);
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
h.run(0.3);
|
||||
assert.ok(!sneak.s.flags.reveal, `T${tier}: hidden again once it ends`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真 麻痹付与 and the cap 2: 0.1 s after the cast every enemy on her range gets 2 麻痹; another operator on her range starting a skill grants again (3 times per cast, a grant kept while nobody is there); stacks over 2 become 溢出麻痹 — each an 元素伤害 bounce of 110 % / 145 % ATK on the holder and the nearest other enemy within 2.0 tiles, one at once and one per 1.5 s', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 2 }];
|
||||
const { h, u } = field({ tier, elite, skill: 2, others });
|
||||
const inside = h.unit(2), outside = h.unit(3);
|
||||
for (const a of [inside, outside]) { a.skill.sp = 0; a.skill.charges = 0; }
|
||||
cast(u);
|
||||
h.run(0.05);
|
||||
// nobody on her range yet: the grant at 0.1 s is kept
|
||||
h.run(0.1);
|
||||
assert.equal(u.mem.mantraS3.grants, 3, `T${tier}: kept`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
h.step();
|
||||
// an operator outside her range starting a skill: nothing
|
||||
outside.skill.gainSp(999); outside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(palsy(e), 0, `T${tier}: an operator off her range`);
|
||||
// the one on her range: the first grant (2 stacks, at the cap)
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.deepEqual([palsy(e), palsy(near), u.mem.mantraS3.grants], [2, 2, 2], `T${tier}: 2 麻痹 each`);
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length, 0, `T${tier}: no overflow yet`);
|
||||
// the second: 2 over the cap each ⇒ 溢出麻痹 2 each, one bounce at once each
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.deepEqual([palsy(e), palsy(near)], [2, 2], `T${tier}: capped at 2`);
|
||||
let bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.deepEqual(bounces.map((c) => c.target.id).sort(), [e.id, near.id].sort(), `T${tier}: each holder hit at once`);
|
||||
for (const c of bounces) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']);
|
||||
}
|
||||
h.run(0.5); // the jumps land (1 tile at 3.2 tiles/s)
|
||||
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.equal(bounces.length, 4, `T${tier}: holder + the nearest other, twice`);
|
||||
h.run(1.1); // 1.5 s after: the second 溢出麻痹 of each
|
||||
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.ok(bounces.length >= 6, `T${tier}: the next one 1.5 s later (${bounces.length})`);
|
||||
// the third grant, then no more
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(u.mem.mantraS3.grants, 0, `T${tier}: 3 grants used`);
|
||||
const g = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length;
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length, g, `T${tier}: no 4th grant`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真: an enemy holding 3 麻痹 entering her range keeps 2 and bounces the third; 溢出麻痹 left when it leaves the range or the skill ends all bounce at once; a 麻痹免疫 enemy counts the stacks she gives it', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
cast(u);
|
||||
h.run(0.2); // the grant of 0.1 s finds nobody (kept, never fired again here)
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 12] }); // off her range
|
||||
h.b.applyStatus(e, 'palsy', { value: 3 });
|
||||
h.step();
|
||||
assert.equal(palsy(e), 3, 'off her range: cap 3');
|
||||
e.x = 7; h.step();
|
||||
assert.equal(palsy(e), 2, 'on her range: 2');
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === e).length, 1, 'the third bounced');
|
||||
// a neural burst's 3 麻痹 on a holder at the cap: 3 溢出麻痹 — one at once, two left; it leaves ⇒ both at once
|
||||
const n0 = tagged(h, u, 'mantraOverflow').length;
|
||||
h.b.applyStatus(e, 'palsy', { value: 3 });
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 1, 'one at once');
|
||||
e.x = 13; h.step();
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 3, 'the two left at once when it leaves');
|
||||
assert.equal(palsy(e), 2, 'its stacks stay (cap 3 again)');
|
||||
// the skill ends with 溢出麻痹 held: all of it bounces
|
||||
const f = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step();
|
||||
h.b.applyStatus(f, 'palsy', { value: 3 });
|
||||
h.b.applyStatus(f, 'palsy', { value: 3 });
|
||||
const n1 = tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length;
|
||||
u.skill.end('test');
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length - n1, 2, 'the rest at the end');
|
||||
// 麻痹免疫: her grants count (ineffective stacks), so the second grant overflows
|
||||
const { h: h2, u: u2 } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] });
|
||||
const imm = h2.spawn('enemy_immune', { pos: [10, 7] });
|
||||
cast(u2);
|
||||
h2.run(0.2);
|
||||
assert.equal(palsy(imm), 0, 'no 麻痹 buff (immune)');
|
||||
assert.equal(imm.mem.mantraPalsy, 2, 'counted');
|
||||
const ally = h2.unit(2);
|
||||
ally.skill.gainSp(999); ally.skill.activate('test');
|
||||
h2.step();
|
||||
assert.equal(tagged(h2, u2, 'mantraOverflow').filter((c) => c.target === imm).length, 1, 'its 溢出麻痹 bounces');
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('T1 噤声限域: an enemy whose attack a 麻痹 stack interrupts takes 135 % (PRI-X stage 3: 160 %) of her ATK as 元素伤害 (tagged 持续伤害); 10 % / 15 % of the interrupts keep the stack; not for an enemy she cannot select (隐匿)', () => {
|
||||
for (const f of [[5, false, null], [6, true, null], [5, true, PRIX], [6, true, PRIX]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t = talentOf(tier, elite, mod, 0);
|
||||
assert.deepEqual(t, elite && mod === PRIX && tier === 6 ? { atk_scale: 1.6, prob: 0.15 } : { atk_scale: 1.35, prob: 0.1 }, label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 13 });
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 8] });
|
||||
h.b.applyStatus(u, 'disarm', { duration: 999, source: null }); // her attacks would kill nothing, but keep it clean
|
||||
h.run(0.6); // the aura's 0.5 s check has marked it
|
||||
let interrupts = 0, kept = 0;
|
||||
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) { interrupts++; } });
|
||||
for (let i = 0; i < 400; i++) {
|
||||
h.b.applyStatus(e, 'palsy', { value: 1 });
|
||||
const before = interrupts;
|
||||
h.runUntil(() => interrupts > before, 3);
|
||||
h.step();
|
||||
if (palsy(e) > 0) { kept++; h.b.removeBuff(e, 'palsy'); }
|
||||
}
|
||||
const hush = tagged(h, u, 'mantraHush');
|
||||
assert.equal(hush.length, interrupts, `${label(f)}: one hit per interrupt`);
|
||||
for (const c of hush.slice(0, 5)) {
|
||||
approx(c.amount, u.s.atk * t.atk_scale, `${label(f)}: ${t.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.element, c.dmg.tags.includes('dot')], ['elemental', 'neural', true]);
|
||||
}
|
||||
assert.ok(Math.abs(kept / interrupts - t.prob) < 0.05, `${label(f)}: kept ${kept} / ${interrupts} ≈ ${t.prob}`);
|
||||
done(h);
|
||||
}
|
||||
// 隐匿: no mark, no hit; during S3 the check ignores 隐匿
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 1 });
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 2] }); // behind her: it shoots her, she cannot see it (and S3 does not reveal it)
|
||||
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
|
||||
let interrupts = 0;
|
||||
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) interrupts++; });
|
||||
h.run(0.6);
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
h.run(2.2);
|
||||
assert.equal(interrupts, 2, 'its attacks were interrupted');
|
||||
assert.equal(tagged(h, u, 'mantraHush').length, 0, 'a 隐匿 enemy: no effect');
|
||||
cast(u);
|
||||
h.run(0.6);
|
||||
assert.ok(!e.s.flags.reveal, 'not revealed (off her range)');
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
h.run(2.2);
|
||||
assert.equal(tagged(h, u, 'mantraHush').length, 2, 'S3: the check ignores 隐匿');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T2 全局洞悉: the 侵入点 nearest to her (Manhattan) — every enemy that comes out of it gets 1 麻痹 0.1 s later (隐匿 too); the other gate\'s do not', () => {
|
||||
for (const [row, gate, other] of [[10, 0, 1], [11, 1, 0]]) { // flat stage gates: route 0 starts at (9,10), route 1 at (12,10)
|
||||
const { h, u } = field({ tier: 5, skill: 0, row, seed: 2 });
|
||||
assert.deepEqual(u.mem.mantraGate, gate === 0 ? [9, 10] : [12, 10], `row ${row}: the nearest gate`);
|
||||
const a = h.spawn('enemy_walker', { routeIndex: gate });
|
||||
const b = h.spawn('enemy_walker', { routeIndex: other });
|
||||
h.b.addBuff(a, { key: 'test:stealth', flags: { stealth: true } });
|
||||
h.step();
|
||||
assert.equal(palsy(a), 0, 'not at once');
|
||||
h.run(0.1);
|
||||
assert.deepEqual([palsy(a), palsy(b)], [1, 0], `row ${row}`);
|
||||
done(h);
|
||||
}
|
||||
// a tie (two gates at the same distance) is drawn at random
|
||||
const picks = new Set();
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { h, u } = field({ tier: 5, skill: 0, row: 10, col: 10, seed, stage: { rows: { 11: '##hrrrrrrrSrrrrrrrf##' } } });
|
||||
picks.add(String(u.mem.mantraGate));
|
||||
done(h);
|
||||
}
|
||||
assert.deepEqual([...picks].sort(), ['11,10', '9,10'], 'both gates one tile away get drawn');
|
||||
});
|
||||
|
||||
test('PRI-X “中枢神经探测模块” (stages 1 and 3): damage to an enemy in an element burst ×1.1; none without it', () => {
|
||||
for (const f of [[5, true, PRIX], [6, true, PRIX], [6, true, null], [5, false, null]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 8 });
|
||||
const plain = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(1.7);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.b.addBuff(plain, { key: 'burnBurst', duration: 99, flags: { burstLock: true } });
|
||||
h.run(1.7);
|
||||
const a = atkHits(h, u)[0].amount, b = atkHits(h, u).slice(n0)[0].amount;
|
||||
approx(b / a, elite && mod === PRIX ? 1.1 : 1, label(f));
|
||||
if (elite && mod === PRIX) assert.equal(modOf(tier, PRIX).traitOverride.bb.damage_scale, 1.1);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// test/content/op_veen.test.js — the 自选 operator kit of 维伊 (char_4226_veen, 6★ 秘术师; kit
|
||||
// server/sim/content/kits/ops/op-veen.js), 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 or MSC-Y
|
||||
// “军械库” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot
|
||||
// status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_veen.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 VEEN = 'char_4226_veen';
|
||||
const FORMS = BACKUPS.units[VEEN].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const MSCY = 'uniequip_002_veen';
|
||||
const S1 = 'skchr_veen_1', S2 = 'skchr_veen_2', S3 = 'skchr_veen_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).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_fly: dummy('enemy_fly', { motion: 'FLY' }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, MSCY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack', 'ammoUsed'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: VEEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const warTicks = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && (c.dmg?.tags || []).includes('veenWar'));
|
||||
const stored = (u) => ({ s: u.trait.veen?.s ?? 0, t: u.trait.veen?.t ?? 0 });
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
/** Run until she holds `t` 转置能量 and `s` stored energies (no enemy on the field). */
|
||||
function storeUp(h, u, t, s) {
|
||||
assert.ok(h.runUntil(() => stored(u).t === t && stored(u).s === s, 60), `stores up to ${t}/${s}`);
|
||||
}
|
||||
|
||||
test('维伊 in every 自选 form: her operator kit (all three skills authored), stats + MSC-Y attributes, 3-14 range, ranged arts every 3 s that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[VEEN], KITS[VEEN]);
|
||||
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], [VEEN, 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], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.base.cost], [1, 'ranged', 'arts', true, 3, 27], `${label(f)}: 秘术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
|
||||
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 her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].trait.bb], [1142, 1320, { times: 9, merge_cnt: 3 }]);
|
||||
assert.deepEqual([modOf(5, MSCY).attr, modOf(6, MSCY).attr], [{ maxHp: 80, atk: 85 }, { maxHp: 190, atk: 120 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 维伊 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(VEEN));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(VEEN), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } } });
|
||||
});
|
||||
|
||||
test('trait + T1: with no target her attack check stores one energy (an attack action: the interval restarts — PRTS 分支特性信息), 3 merge into a 转置能量, at most 3 of those (then she stops); ATK +10 % while she holds one, ASPD +15 while she holds none; MSC-Y: ASPD +30 while she holds any energy', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t0 = talentOf(tier, elite, mod, 0);
|
||||
assert.deepEqual(t0, { atk: 0.1, attack_speed: 15 }, label(f));
|
||||
const msc = elite && mod === MSCY ? 30 : 0;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
assert.deepEqual(stored(u), { s: 1, t: 0 }, `${label(f)}: her first attack check (at deployment) stores at once`);
|
||||
approx(u.s.aspd, 100 + 15 + msc, `${label(f)}: no 转置能量: ASPD +15 (MSC-Y +30 while holding)`);
|
||||
approx(u.s.atk, u.base.atk, `${label(f)}: no ATK bonus`);
|
||||
const t1 = h.b.time;
|
||||
h.runUntil(() => stored(u).s === 2, 10);
|
||||
approx(h.b.time - t1, 3 * 100 / 115, `${label(f)}: the next one an attack interval later (set at the store, before MSC-Y)`, 0.03);
|
||||
const t2 = h.b.time;
|
||||
h.runUntil(() => stored(u).t === 1, 10);
|
||||
approx(h.b.time - t2, 3 * 100 / (115 + msc), `${label(f)}: then at the interval of ASPD ${115 + msc}`, 0.03);
|
||||
storeUp(h, u, 1, 0);
|
||||
approx(u.s.aspd, 100 + msc, `${label(f)}: a 转置能量: the ASPD +15 goes`);
|
||||
approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`);
|
||||
storeUp(h, u, 3, 0);
|
||||
h.run(30);
|
||||
assert.deepEqual(stored(u), { s: 0, t: 3 }, `${label(f)}: no more storing at 3 转置能量`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('the volley: 转置能量 first (300 % ATK each), the main attack (100 %), then the stored energies (100 %) — one target, all landing together, each with the ATK it left with (+10 % while a 转置能量 was held)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 2);
|
||||
const atk = u.s.atk;
|
||||
approx(atk, u.base.atk * 1.1, `T${tier}: holding a 转置能量`);
|
||||
const e = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const a0 = atkHits(h, u).length;
|
||||
h.runUntil(() => atkHits(h, u).length > a0, 4);
|
||||
h.step();
|
||||
const hits = atkHits(h, u).slice(a0);
|
||||
assert.deepEqual(hits.map((c) => Math.round(c.amount / atk * 100) / 100), [3, 1, 1, 1], `T${tier}: 300 % + 100 % + 2 × 100 % at the cached ATK`);
|
||||
assert.ok(hits.every((c) => c.target === e && c.t === hits[0].t && c.dmg.attackId === hits[0].dmg.attackId && c.type === 'arts'), `T${tier}: one attack, one target (a flyer), together`);
|
||||
assert.deepEqual(stored(u), { s: 0, t: 0 }, `T${tier}: spent`);
|
||||
h.run(0.2);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: the ATK bonus gone after the volley`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 战争技艺: each hit adds a stack (a 转置能量 3) of 90 arts / s for 5 s, at most 3 (MSC-Y stage 3: 120 / s, at most 4); she never targets a marked enemy — with only marked ones in range she stores energy', () => {
|
||||
for (const f of [[5, false, null], [5, true, MSCY], [6, true, MSCY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t1 = talentOf(tier, elite, mod, 1);
|
||||
const s3m = elite && mod === MSCY && tier === 6;
|
||||
assert.deepEqual(t1, s3m ? { 'attack@duration': 5, 'attack@value': 120, 'attack@max_stack_cnt': 4 } : { 'attack@duration': 5, 'attack@value': 90, 'attack@max_stack_cnt': 3 }, label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
h.run(3.1);
|
||||
u.trait.veen = null; // (start the check with nothing stored)
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length > 0, 4);
|
||||
h.step();
|
||||
const war = a.findBuff(`veen:war:${u.id}`);
|
||||
assert.ok(war && war.stacks === 1, `${label(f)}: 1 stack`);
|
||||
h.run(1.02);
|
||||
const ticks = warTicks(h, u, a);
|
||||
assert.equal(ticks.length, 1, `${label(f)}: a tick per second`);
|
||||
approx(ticks[0].amount, t1['attack@value'], `${label(f)}: ${t1['attack@value']} arts`);
|
||||
// marked: not targeted — she stores instead while it is her only enemy in range
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(2.9);
|
||||
assert.equal(atkHits(h, u).length, n0, `${label(f)}: no attack on a marked enemy`);
|
||||
assert.ok(stored(u).s >= 1, `${label(f)}: her next attack check stored an energy instead`);
|
||||
// another enemy: targeted at once; the mark lapses 5 s after the last stack
|
||||
const b = h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length > n0, 4);
|
||||
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === b), `${label(f)}: the unmarked one`);
|
||||
assert.ok(h.runUntil(() => !a.findBuff(`veen:war:${u.id}`), 6), `${label(f)}: 5 s`);
|
||||
assert.equal(warTicks(h, u, a).length, 5, `${label(f)}: 5 ticks`);
|
||||
done(h);
|
||||
}
|
||||
// a 转置能量 gives 3 stacks; the cap
|
||||
for (const [tier, mod, cap] of [[6, null, 3], [6, MSCY, 4]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 2, 0);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length >= 3, 4);
|
||||
h.step();
|
||||
assert.equal(e.findBuff(`veen:war:${u.id}`).stacks, cap, `T6 ${mod ?? 'none'}: 2 × 3 + 1 stacks capped at ${cap}`);
|
||||
h.run(1.02);
|
||||
approx(warTicks(h, u, e)[0].amount, talentOf(6, true, mod, 1)['attack@value'] * cap, `${cap} stacks`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 “自呼号生发” (AUTO): ASPD +60 / +75 for 18 s, cast as soon as its SP is full (31 / 29 from 7 / 9) — no enemy needed', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.attack_speed], ['SP_FULL', elite ? 29 : 31, elite ? 9 : 7, 18, elite ? 75 : 60], `T${tier}`);
|
||||
h.run(sk.spCost - sk.initSp - 0.5);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: at full SP, no enemy`);
|
||||
const base = u.s.aspd - sk.bb.attack_speed;
|
||||
assert.ok(base === 115 || base === 100, `T${tier}: +${sk.bb.attack_speed} on top of ${base}`);
|
||||
approx(u.skill.timeLeft, 18, `T${tier}: 18 s`, 0.01);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 “以鲜血洗去” (MANUAL, data DEFAULT): 28 s, attack interval 3.0 − 0.5 s; each released shot +9 % / +11 % ATK and +6 / +7 ASPD (a 转置能量 3 stacks), at most 8 / 9 — later shots of a volley carry the stacks of the earlier ones', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const per = b['attack@veen_s_2_buff[stack].atk'], perAs = b['attack@veen_s_2_buff[stack].attack_speed'], max = b['attack@veen_s_2_buff[stack].max_stack_cnt'];
|
||||
assert.deepEqual([sk.duration, b.base_attack_time, per, perAs, max], [28, -0.5, elite ? 0.11 : 0.09, elite ? 7 : 6, elite ? 9 : 8], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 1);
|
||||
const base = u.base.atk;
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
|
||||
h.step(2);
|
||||
approx(u.s.interval, 2.5 * 100 / u.s.aspd, `T${tier}: base interval 2.5 s`);
|
||||
const hits = atkHits(h, u);
|
||||
h.runUntil(() => atkHits(h, u).length >= 3, 4);
|
||||
const v = atkHits(h, u).slice(0, 3);
|
||||
// 转置能量 (0 stacks, +10 %), main (3 stacks), stored (4 stacks) — every one +10 % (a 转置能量 was held)
|
||||
approx(v[0].amount, base * (1 + 0.1) * 3, `T${tier}: 转置能量 before any stack`);
|
||||
approx(v[1].amount, base * (1 + 0.1 + 3 * per), `T${tier}: the main shot after 3`);
|
||||
approx(v[2].amount, base * (1 + 0.1 + 4 * per), `T${tier}: the stored one after 4`);
|
||||
assert.ok(hits.length <= 3);
|
||||
approx(u.s.aspd, 100 + 15 + 5 * perAs, `T${tier}: 5 stacks of ASPD (and T1's +15 again)`);
|
||||
h.run(25);
|
||||
assert.equal(u.findBuff('veen:s2stack').mods.atkPct, per * max, `T${tier}: at most ${max}`);
|
||||
h.runUntil(() => !u.skill.active, 10);
|
||||
assert.equal(u.findBuff('veen:s2stack'), null, `T${tier}: gone with the skill`);
|
||||
void e;
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 “用赤铁铭记” (MANUAL, data DEFAULT): 18 / 21 bullets, ASPD +110 / +140; an attack spends 1 bullet per stored energy and 3 per 转置能量 (none without), its stored shots deal nothing and its main hit bounces that many times — every 0.5 s within 1.7 tiles, a new enemy first — for 130 % / 140 % of the cached ATK, each with a 战争技艺 stack', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const b = sk.bb;
|
||||
assert.deepEqual([b['attack@trigger_time'], b.attack_speed, b['attack@bounce_atk_scale'], b['attack@base_atk_scale']], [elite ? 21 : 18, elite ? 140 : 110, elite ? 1.4 : 1.3, 1], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 1);
|
||||
const atk = u.s.atk;
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const near = h.spawn('enemy_fly', { pos: [10, 8.2] });
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 12] }); // out of her range and 3.8 from the target
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
|
||||
assert.deepEqual([u.skill.kind, u.skill.ammoMax], ['ammo', b['attack@trigger_time']], `T${tier}`);
|
||||
approx(u.s.aspd, 100 + b.attack_speed + 15, `T${tier}: ASPD (+ T1's 15: the volley spent the 转置能量)`, 1e-9);
|
||||
h.run(2.2);
|
||||
// the first attack: 1 + 3 = 4 bullets, the main hit then 4 bounces (e ↔ near: a new enemy first, then back)
|
||||
assert.equal(u.skill.ammoLeft, b['attack@trigger_time'] - 4, `T${tier}: 4 bullets`);
|
||||
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 4);
|
||||
const hits = atkHits(h, u);
|
||||
const id = hits[0].dmg.attackId;
|
||||
const of = hits.filter((c) => c.dmg.attackId === id);
|
||||
assert.equal(of.length, 5, `T${tier}: main + 4 bounces, no stored-shot damage`);
|
||||
approx(of[0].amount, atk * b['attack@base_atk_scale'], `T${tier}: the main hit`);
|
||||
assert.deepEqual(of.map((c) => c.target.id), [e.id, near.id, e.id, near.id, e.id], `T${tier}: a new enemy first, then between them`);
|
||||
for (const c of of.slice(1)) approx(c.amount, atk * b['attack@bounce_atk_scale'], `T${tier}: bounce at the cached ATK`);
|
||||
for (let i = 2; i < of.length; i++) approx(of[i].t - of[i - 1].t, 0.5, `T${tier}: 0.5 s apart`, 0.08);
|
||||
assert.ok(!of.some((c) => c.target === far), `T${tier}: 1.7 tiles at most`);
|
||||
assert.equal(near.findBuff(`veen:war:${u.id}`).stacks, 2, `T${tier}: a 战争技艺 stack per bounce`);
|
||||
// attacks without stored energy spend nothing; the skill ends when the bullets run out
|
||||
const left = u.skill.ammoLeft;
|
||||
h.run(1.5);
|
||||
assert.ok(u.skill.ammoLeft <= left);
|
||||
h.runUntil(() => !u.skill.active, 300);
|
||||
assert.equal(u.skill.ammoLeft, 0, `T${tier}: ended out of bullets`);
|
||||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u)[0].reason, 'ammo');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user