- kits/ops/op-logos.js (中坚术师; CCR-Δ, CCR-Y), op-mantra.js (本源术师; PRI-X), op-veen.js (秘术师; MSC-Y), registered in
OPERATOR_KIT_FILES one line per file; numbers from data/backups.json, mechanics from the PRTS 备注 (cited in each header),
[ASSUMED] items marked there. Data triggers kept; 维伊 S1 (an AUTO self buff) fires at full SP (the owner's rule).
- server/sim/ai.js: enemyAttack emits `palsyTrigger { enemy, buff, keep }` when a 麻痹 stack interrupts an attack (PRTS 真言
备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"); `keep` leaves the stack (真言 噤声限域). Additive: no listener, no change.
docs/SIM.md §3 / §5 rows; test/sim/palsy-trigger.test.js.
- Tests: test/content/op_logos.test.js, op_mantra.test.js, op_veen.test.js (every form, skill, talent, module, range,
trigger, targeting, damage type).
- Golden: only the diy family moved — diy-003 … diy-007 now field 逻各斯 / 真言 / 维伊 in every form × module × skill (60
configurations; diy-003 … 006 held the prototypes before), the prototype picks moved to diy-008 … 011 (same packing, new
ids); diy-001 / 002 (推进之王) and roster, bonds, fields, matches, standins byte-identical.
391 lines
24 KiB
JavaScript
391 lines
24 KiB
JavaScript
// 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);
|
||
}
|
||
}
|
||
});
|