- kits/ops/op-cqbw.js (W, 炮手; ART-X / ART-Y), op-phenxi.js (菲亚梅塔, 炮手; ART-Y / ART-X / ISW-A attributes only), op-wisdel.js (维什戴尔, 投掷手; BOM-X), appended to OPERATOR_KIT_FILES; numbers from data/backups.json, mechanics from PRTS (备注) and the client's skill prefabs / buff templates. The 炮手 kits splash 1.0 (PRTS 溅射半径一览; the aoesniper profile keeps 1.1): docs/SIM.md §8 says so. - W: 红桃K / D12 cast like an attack (that attack throws the grenade / bombs), 惊吓盒子 plants 此面向敌 by PRTS's tile priority and is refused with no free deployable tile (activate guard); the mine arms within 1.35, blows 1.5 s later with W's ATK read at arming, ground only; D12 is 无来源 with the cast's cached ATK, early on a fallen target; 设伏 / ART-Y ramp, 落井下石 (any physical damage on a stunned enemy of her range, strongest of two W), ART-X SP on her projectiles' / mines' knock-outs. - 菲亚梅塔: 陈述苦难 流失 every 0.1 s and 精力充沛 (ART-X stage 3 floor 50.3 %), 宣告终局 outside / inside a skill, S1 per the 2026-08-01 client (PRTS 备注: ground targets only, the splash still hits air, no range growth — one constant switches to the skill text), S2 灼痕弹 and its 灼痕, S3 持续攻击 shells; ISW-A: base talents. - 维什戴尔: the aftershocks are the kit's (好礼 ×main, 残影 and its 15 % / 100 % explosion), S1 / S2 过载 (the second half, 4 random rounds) / S3 (air targets and splash, 2.5), 魂灵之影 (talent and S3, cap 3, 迷彩 on its x-5, 禁疗, its skill on her range, unmarked first, 0–2 SP back). - Tests: test/content/op_cqbw.test.js (10), op_phenxi.test.js (8), op_wisdel.test.js (8). - Golden: only diy moved — diy-049 … diy-054 now field the three new kits in every form × module × skill (W: 049 / 050, 菲亚梅塔: 051 – 053, 维什戴尔: 053 / 054), the prototypes moved to diy-055 … diy-058 (same packing, new ids); roster, bonds, fields, matches and standins unchanged.
320 lines
23 KiB
JavaScript
320 lines
23 KiB
JavaScript
// test/content/op_wisdel.test.js — the 自选 operator kit of 维什戴尔 (char_1035_wisdel, 6★ 投掷手; kit
|
||
// server/sim/content/kits/ops/op-wisdel.js) and of her summon “魂灵之影” (token_10035_wisdel_wward), 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 BOM-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.
|
||
// Run: node --test test/content/op_wisdel.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 WD = 'char_1035_wisdel';
|
||
const SHADOW = 'token_10035_wisdel_wward';
|
||
const FORMS = BACKUPS.units[WD].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const BOM = 'uniequip_002_wisdel';
|
||
const S1 = 'skchr_wisdel_1', S2 = 'skchr_wisdel_2', S3 = 'skchr_wisdel_3';
|
||
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
|
||
const formOf = (tier, elite) => FORMS[statusOf(tier, elite)];
|
||
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
|
||
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
|
||
const shadowOf = (tier, elite) => BACKUPS.tokens[SHADOW].variants[`${WD}@${statusOf(tier, elite)}`];
|
||
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_atk: dummy('enemy_atk', { atk: 100, range: 3 }) };
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BOM].map((m) => [t, true, m]))];
|
||
|
||
/** 维什戴尔 as uid 1 at (10, 4) facing RIGHT (3-9: rows 8–12; (10, 4..8), (9 / 11, 4..7), (8 / 12, 4..6)). */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 1200, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'heal'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: WD, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
|
||
const on = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e);
|
||
const shadowsOf = (h, u) => h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === SHADOW && t.ownerUnit === u);
|
||
const tag = (c, t) => !!c.dmg.tags?.includes(t);
|
||
const markKey = (u) => `wisdel:mark:${u.id}`;
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||
const t0Of = (tier, elite, mod) => (elite && mod === BOM && tier === 6 ? modOf(6, BOM).talentChanges[0].bb : formOf(tier, elite).talents[0].bb);
|
||
|
||
test('维什戴尔 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-9, 投掷手 (ground-only 0.9 splash, never air), blocks 1, the data triggers, a 魂灵之影 beside her at once; offered as a pick', () => {
|
||
assert.equal(OPERATOR_KITS[WD], KITS[WD]);
|
||
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], [WD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd], [form.stats.maxHp, form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res, 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.groundOnly, u.profile.dmgType, u.base.bat, u.profile.splashRadius, u.profile.sub],
|
||
[1, 'ranged', false, true, 'phys', 2.1, 0.9, 'bombarder'], `${label(f)}: 投掷手`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-9`);
|
||
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
|
||
const sh = shadowsOf(h, u);
|
||
assert.deepEqual(sh.map((t) => [t.tileR, t.tileC]), [[9, 4]], `${label(f)}: a shadow on the nearest free tile of her range (lower row first)`);
|
||
assert.ok(u.s.flags.camou && !u.s.flags.liftoff && !u.s.flags.stealth, `${label(f)}: 迷彩 beside it, ground-targetable otherwise`);
|
||
done(h);
|
||
}
|
||
}
|
||
assert.deepEqual(formOf(6, true).skills.map((s) => [s.skillType, s.trigger.rule, s.trigger.rawRule]), [['AUTO', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'SP_FULL', 'ALWAYS']]);
|
||
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], [1434, 583, 1735, 652]);
|
||
assert.deepEqual([modOf(5, BOM).attr, modOf(6, BOM).attr], [{ atk: 45, aspd: 5 }, { atk: 65, aspd: 7 }]);
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.ok(KITTED_CHARS.includes(WD) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(WD)));
|
||
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: WD, skillIndex: 2, uniEquipId: BOM } }, { data, kitted: KITTED_CHARS }).ok, true);
|
||
});
|
||
|
||
test('trait 投掷手 + T1 好礼: the main target ×1.15 (BOM-X stage 3 ×1.25), the 0.9 splash in full, then 0.3 s later an aftershock at 50 % ATK on the spot (×1.15 on the main one); BOM-X: two; flyers never hit; her 残影 on the main target', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const t0 = t0Of(tier, elite, mod);
|
||
const shocks = elite && mod ? 2 : 1;
|
||
assert.deepEqual([t0['attack@main_atk_scale'], t0['attack@bomb_atk_scale']], elite && mod === BOM && tier === 6 ? [1.25, 1.75] : [1.15, 1.5], label(f));
|
||
assert.equal(formOf(tier, elite).trait.bb['attack@append_atk_scale'], 0.5, label(f));
|
||
if (elite && mod) assert.equal(modOf(tier, mod).traitOverride.bb['attack@enable_third_attack'], 1, label(f));
|
||
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 21 });
|
||
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||
const sp = h.spawn('enemy_dummy', { pos: [10, 8.85] }); // 0.85: her splash, outside her range
|
||
const out = h.spawn('enemy_dummy', { pos: [11, 8.75] }); // √(1 + 0.5625) = 1.25
|
||
const fly = h.spawn('enemy_fly', { pos: [9, 8] }); // in her range, an air unit
|
||
assert.ok(h.runUntil(() => on(h, main).length > 0, 4), `${label(f)}: an attack`);
|
||
const t = h.b.time;
|
||
h.run(0.8);
|
||
const atk = u.s.atk;
|
||
const ms = t0['attack@main_atk_scale'];
|
||
const plain = (c) => !tag(c, 'wisdel:bomb');
|
||
const first = from(h, u).filter((c) => plain(c) && c.t <= t + 1e-9);
|
||
assert.deepEqual(new Set(first.map((c) => c.target)), new Set([main, sp]), `${label(f)}: the main target and the splash`);
|
||
approx(first.find((c) => c.target === main).amount, atk * ms, `${label(f)}: main ×${ms}`);
|
||
approx(first.find((c) => c.target === sp).amount, atk, `${label(f)}: splash in full`);
|
||
const shockHits = from(h, u).filter((c) => plain(c) && tag(c, 'aftershock') && c.t <= t + 0.75);
|
||
assert.equal(shockHits.filter((c) => c.target === main).length, shocks, `${label(f)}: ${shocks} aftershock(s)`);
|
||
for (const c of shockHits) approx(c.amount, atk * 0.5 * (c.target === main ? ms : 1), `${label(f)}: 50 %`);
|
||
assert.ok(Math.abs(shockHits[0].t - t - 0.3) < 0.04, `${label(f)}: 0.3 s later`);
|
||
assert.deepEqual([on(h, out).length, on(h, fly).length], [0, 0], `${label(f)}: 1.25 away and the flyer untouched`);
|
||
assert.ok(main.findBuff(markKey(u)) || on(h, main).some((c) => tag(c, 'wisdel:bomb')), `${label(f)}: marked (or the mark went off)`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T1 好礼: each aftershock on a marked enemy goes off 15 % of the time — 150 % (175 %) ATK physical to every enemy within 1.1, air too, stun 1 s, the mark spent; the 残影 leave with her', () => {
|
||
for (const f of [[5, false, null], [6, true, BOM]]) {
|
||
const [tier, elite, mod] = f;
|
||
const t0 = t0Of(tier, elite, mod);
|
||
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 33 });
|
||
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 9] }); // 1.0 from the main target: only the explosion reaches it
|
||
h.run(400);
|
||
const bombs = from(h, u).filter((c) => tag(c, 'wisdel:bomb') && c.target === main);
|
||
const rolls = from(h, u).filter((c) => tag(c, 'aftershock') && c.target === main).length;
|
||
assert.ok(rolls > 150, `${label(f)}: ${rolls} rolls`);
|
||
const r = bombs.length / rolls;
|
||
assert.ok(r > 0.09 && r < 0.21, `${label(f)}: ${bombs.length} / ${rolls} ≈ 15 %`);
|
||
for (const c of bombs) assert.equal(c.type, 'phys', label(f));
|
||
const flyBombs = on(h, fly).filter((c) => tag(c, 'wisdel:bomb'));
|
||
assert.equal(flyBombs.length, bombs.length, `${label(f)}: the flyer 1.0 away takes every explosion`);
|
||
assert.equal(on(h, fly).length, flyBombs.length, `${label(f)}: and nothing else`);
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === fly);
|
||
assert.equal(stuns.length, bombs.length, `${label(f)}: each explosion stuns`);
|
||
for (const c of stuns) approx(c.duration, t0['attack@stun'], `${label(f)}: 1 s`);
|
||
// the amounts: her ATK × bomb_atk_scale (the explosion is no attack: no ×main)
|
||
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
|
||
h.run(2);
|
||
main.buffs = main.buffs.filter((b) => b.key !== markKey(u));
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
h.b.addBuff(e, { key: markKey(u), source: u });
|
||
h.b.removeBuff(u, 'test:disarm');
|
||
assert.ok(h.runUntil(() => on(h, e).some((c) => tag(c, 'wisdel:bomb')), 200), `${label(f)}: it goes off on a marked one`);
|
||
const b = on(h, e).find((c) => tag(c, 'wisdel:bomb'));
|
||
approx(b.amount, u.s.atk * t0['attack@bomb_atk_scale'], `${label(f)}: ×${t0['attack@bomb_atk_scale']}`);
|
||
assert.ok(!e.findBuff(markKey(u)) || on(h, e).filter((c) => c.t > b.t && !tag(c, 'wisdel:bomb')).length > 0, `${label(f)}: spent (or re-marked by a later attack)`);
|
||
h.b.addBuff(e, { key: markKey(u), source: u });
|
||
h.b.retreat(u);
|
||
assert.ok(!e.findBuff(markKey(u)) && !main.findBuff(markKey(u)), `${label(f)}: her 残影 leave with her`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 死魂灵的余息: a 魂灵之影 (the form\'s stats, 禁疗, no attack, attackable) at each deployment on the nearest free tile of her range, at most 3; 迷彩 while one that came in on her x-5 stands; the shadows stay when she leaves', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const v = shadowOf(tier, elite);
|
||
assert.deepEqual([v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.respawnTime, v.stats.deployLimit], [3500, elite ? 738 : 660, elite ? 628 : 585, 50, 5, 3], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
const [t] = shadowsOf(h, u);
|
||
assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.base.res, t.s.blockCnt, !!t.s.flags.noHeal, !!t.s.flags.untargetable, t.profile.noAttack],
|
||
[3500, v.stats.atk, v.stats.def, 50, 0, true, false, true], `T${tier}: stats, 禁疗, attackable, no attack`);
|
||
assert.ok(u.findBuff(`wisdel:camou:${t.id}`) && u.s.flags.camou, `T${tier}: 迷彩`);
|
||
// a ranged enemy in reach: she is not its target while camouflaged and not blocking
|
||
const shooter = h.spawn('enemy_atk', { pos: [10, 6] });
|
||
h.run(4);
|
||
assert.ok(!h.hooksOf('damaged').some((c) => c.source === shooter && c.target === u), `T${tier}: 迷彩: the shooter does not pick her`);
|
||
h.b.kill(shooter, null);
|
||
h.b.kill(t, null);
|
||
assert.ok(!u.s.flags.camou, `T${tier}: gone with its shadow`);
|
||
for (let i = 0; i < 4; i++) {
|
||
h.b.retreat(u);
|
||
h.run(0.2);
|
||
assert.ok(h.b.redeploy(u, { free: true }), `T${tier}: redeploy ${i}`);
|
||
h.run(0.2);
|
||
}
|
||
assert.equal(shadowsOf(h, u).filter((s) => s.alive).length, 3, `T${tier}: at most 3 (they stay when she leaves)`);
|
||
done(h);
|
||
}
|
||
// no free tile next to her: the shadow lands on the next nearest (lower row first) and gives no 迷彩
|
||
const others = [[9, 4], [11, 4], [10, 5]].map(([r, c], i) => ({ uid: 10 + i, chessId: 'chess_char_1_02_a', row: r, col: c }));
|
||
const { h, u } = field({ skill: 1, others });
|
||
assert.deepEqual(shadowsOf(h, u).map((t) => [t.tileR, t.tileC]), [[9, 5]], 'the nearest free one: √2 away, lower row first');
|
||
assert.ok(!u.s.flags.camou, 'not on its x-5: no 迷彩');
|
||
done(h);
|
||
});
|
||
|
||
test('魂灵之影\'s skill: every 5 SP (time; +0–2 after each cast) a ground enemy of 维什戴尔\'s range — unmarked first — takes its ATK as arts damage, 停顿 1 s and her 残影; never an air unit', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const v = shadowOf(tier, elite);
|
||
assert.deepEqual([v.skill.skillType, v.skill.spType, v.skill.spCost, v.skill.bb], ['AUTO', 'INCREASE_WITH_TIME', 5, { sluggish: 1, sp_min: 0, sp_max: 3 }], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
|
||
const [t] = shadowsOf(h, u);
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||
h.run(8);
|
||
assert.deepEqual([t.skill.activations, on(h, fly).length], [0, 0], `T${tier}: an air unit only: nothing, SP kept`);
|
||
assert.equal(t.skill.charges, 1, `T${tier}`);
|
||
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
const b = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||
const outside = h.spawn('enemy_dummy', { pos: [10, 9] }); // outside her 3-9
|
||
h.b.addBuff(a, { key: markKey(u), source: u }); // already marked
|
||
h.step();
|
||
assert.equal(t.skill.activations, 1, `T${tier}: cast at once`);
|
||
const hit = from(h, t);
|
||
assert.deepEqual(hit.map((c) => c.target), [b], `T${tier}: the unmarked one`);
|
||
approx(hit[0].amount, t.s.atk, `T${tier}: its ATK`);
|
||
assert.deepEqual([hit[0].type, hit[0].dmg.isSkill], ['arts', true], `T${tier}: arts`);
|
||
assert.ok(b.findBuff(markKey(u)), `T${tier}: her 残影`);
|
||
const slow = h.hooksOf('statusApplied').find((c) => c.source === t && c.target === b);
|
||
assert.deepEqual([slow?.status, slow?.duration], ['sluggish', 1], `T${tier}: 停顿 1 s`);
|
||
h.run(60);
|
||
const casts = t.skill.activations;
|
||
assert.ok(casts >= 60 / 5 + 1 && casts <= 60 / 3 + 2, `T${tier}: 5 SP a cast, 0–2 back each time (${casts} in 60 s)`);
|
||
assert.equal(on(h, outside).length, 0, `T${tier}: outside her range: never`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 定点清算 (AUTO, attack SP 4 / 3, data DEFAULT): the next attack splashes 1.1, makes 2 more aftershocks at 75 % / 90 % ATK and stuns every enemy it hits 0.5 / 1 s', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S1), t0 = t0Of(tier, elite, mod);
|
||
assert.deepEqual([sk.spType, sk.spCost, sk.bb.append_atk_scale, sk.bb.stun_duration], ['INCREASE_WHEN_ATTACK', elite ? 3 : 4, elite ? 0.9 : 0.75, elite ? 1 : 0.5], label(f));
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 });
|
||
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT'], label(f));
|
||
h.b.addBuff(shadowsOf(h, u)[0], { key: 'test:silence', flags: { silence: true } });
|
||
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||
const sp = h.spawn('enemy_dummy', { pos: [10, 9.05] }); // 1.05: only the 1.1 splash
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `${label(f)}: cast on her next attack`);
|
||
h.run(2);
|
||
const atk = u.s.atk;
|
||
const hits = from(h, u).filter((c) => !tag(c, 'wisdel:bomb') && c.dmg.isSkill);
|
||
const n = (elite && mod ? 2 : 1) + 2;
|
||
assert.equal(hits.filter((c) => c.target === main && tag(c, 'aftershock')).length, n, `${label(f)}: ${n} aftershocks`);
|
||
for (const c of hits.filter((x) => tag(x, 'aftershock'))) approx(c.amount, atk * sk.bb.append_atk_scale * (c.target === main ? t0['attack@main_atk_scale'] : 1), `${label(f)}: ${sk.bb.append_atk_scale * 100} %`);
|
||
assert.ok(hits.some((c) => c.target === sp && !tag(c, 'aftershock')), `${label(f)}: the 1.1 splash`);
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === sp);
|
||
assert.ok(stuns.length >= 1 && stuns.every((c) => Math.abs(c.duration - sk.bb.stun_duration) < 1e-9), `${label(f)}: stunned ${sk.bb.stun_duration} s`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 饱和复仇 (MANUAL, 32 / 29 SP from 15, 25 s, data DEFAULT): ATK +16 % / +25 %, interval −0.5 / −0.7 s, 3 targets; 过载 the second half: every attack 4 rounds of 65 % / 70 % on random enemies of her range', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
assert.deepEqual([sk.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@atk_scale_ol']], elite ? [29, 15, 25, 0.25, -0.7, 0.7] : [32, 15, 25, 0.16, -0.5, 0.65], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, skill: 1, seed: 8 });
|
||
h.b.addBuff(shadowsOf(h, u)[0], { key: 'test:silence', flags: { silence: true } });
|
||
assert.deepEqual([u.skill.kind, u.skill.rule], ['duration', 'DEFAULT'], `T${tier}`);
|
||
for (const p of [[10, 6], [11, 6], [9, 6], [12, 5]]) h.spawn('enemy_dummy', { pos: p });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||
const ts = h.b.time;
|
||
approx(u.s.interval, 2.1 + sk.bb.base_attack_time, `T${tier}: interval`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
h.run(26);
|
||
const mains = from(h, u).filter((c) => c.dmg.isAttack && !c.dmg.isSplash && c.dmg.isSkill);
|
||
const early = mains.filter((c) => c.t < ts + 12.4), late = mains.filter((c) => c.t > ts + 12.6 && c.t < ts + 25);
|
||
const byAttack = (list) => { const m = new Map(); for (const c of list) m.set(c.dmg.attackId, [...(m.get(c.dmg.attackId) ?? []), c]); return [...m.values()]; };
|
||
for (const g of byAttack(early)) {
|
||
assert.equal(new Set(g.map((c) => c.target)).size, 3, `T${tier}: 3 targets at once`);
|
||
for (const c of g) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * 1.15, `T${tier}: ×1.15 on each main target`);
|
||
}
|
||
const lg = byAttack(late);
|
||
assert.ok(lg.length >= 5, `T${tier}: overload attacks`);
|
||
for (const g of lg) {
|
||
assert.equal(g.length, 4, `T${tier}: 4 rounds`);
|
||
for (const c of g) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * sk.bb['attack@atk_scale_ol'] * 1.15, `T${tier}: ${sk.bb['attack@atk_scale_ol'] * 100} %`);
|
||
}
|
||
assert.ok(lg.some((g) => new Set(g.map((c) => c.target)).size < 4), `T${tier}: random picks (repeats happen)`);
|
||
assert.ok(new Set(lg.flatMap((g) => g.map((c) => c.target))).size === 4, `T${tier}: every enemy of her range drawn some time`);
|
||
assert.ok(!u.skill.active, `T${tier}: ends after 25 s`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 爆裂黎明 (MANUAL, data SP_FULL: at once, no enemy needed): 1 / 2 shadows (the first with 3 SP), ATK +120 % / +150 %, interval +2.9 s, 6 attacks at 175 % / 190 % with a 2.5 splash on air units too, 好礼 at 100 %', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, BOM]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
assert.deepEqual([sk.spCost, sk.initSp, sk.bb.max_cnt, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@atk_scale_3'], sk.bb['attack@trigger_time'], sk.bb['attack@prob'], sk.bb.sp],
|
||
elite ? [58, 35, 2, 1.5, 2.9, 1.9, 6, 1, 3] : [64, 30, 1, 1.2, 2.9, 1.75, 6, 1, 3], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 4 });
|
||
assert.deepEqual([u.skill.kind, u.skill.rule], ['ammo', 'SP_FULL'], `T${tier}`);
|
||
for (const t of shadowsOf(h, u)) h.b.addBuff(t, { key: 'test:silence', flags: { silence: true } });
|
||
u.skill.gainSp(999);
|
||
h.step();
|
||
assert.ok(u.skill.active, `T${tier}: cast at once, no enemy on the field`);
|
||
const sh = shadowsOf(h, u);
|
||
assert.equal(sh.length, 1 + sk.bb.max_cnt, `T${tier}: the talent's + ${sk.bb.max_cnt}`);
|
||
assert.ok(Math.floor(sh[1].skill.sp) === 3 && (sh.length < 3 || Math.floor(sh[2].skill.sp) === 0), `T${tier}: the first S3 shadow with 3 SP`);
|
||
approx(u.s.interval, (2.1 + 2.9) * 100 / u.s.aspd, `T${tier}: interval +2.9 s`);
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => on(h, fly).length > 0, 2), `T${tier}: an air unit alone in her range is her target now`);
|
||
const g = h.spawn('enemy_dummy', { pos: [11, 8] }); // √2 from the flyer: her 2.5 splash, her aftershocks
|
||
const ring = h.spawn('enemy_fly', { pos: [11.5, 8.9] }); // 2.42 from the flyer, outside her range
|
||
h.b.addBuff(g, { key: markKey(u), source: u });
|
||
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: 6 attacks and done`);
|
||
h.run(1);
|
||
const mains = from(h, u).filter((c) => c.dmg.isAttack && !c.dmg.isSplash);
|
||
assert.equal(new Set(mains.map((c) => c.dmg.attackId)).size, 6, `T${tier}: 6 attacks`);
|
||
// ATK +atk while it runs (the last shell lands after its 6th bullet ended it: the engine's ammo rule, skills.js)
|
||
const tEnd = h.hooksOf('skillEnd').find((c) => c.unit === u).t;
|
||
const atkAt = (t) => u.base.atk * (t <= tEnd + 1e-9 ? 1 + sk.bb.atk : 1);
|
||
const ms = t0Of(tier, elite, mod)['attack@main_atk_scale'];
|
||
for (const c of mains) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'] * ms, `T${tier}: ×${sk.bb['attack@atk_scale_3']} × main`);
|
||
assert.ok(mains.filter((c) => c.t <= tEnd).length >= 5, `T${tier}: five land while it runs`);
|
||
assert.ok(mains.every((c) => c.target === fly), `T${tier}: the flyer was her target throughout`);
|
||
const splash = from(h, u).filter((c) => c.dmg.isSplash && !tag(c, 'aftershock') && !tag(c, 'wisdel:bomb'));
|
||
assert.ok(splash.some((c) => c.target === ring) && splash.some((c) => c.target === g), `T${tier}: the 2.5 splash reaches the air unit 2.42 away and the ground one`);
|
||
for (const c of splash) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'], `T${tier}: splash in full`);
|
||
assert.ok(on(h, fly).every((c) => !tag(c, 'aftershock')) && on(h, ring).every((c) => !tag(c, 'aftershock')), `T${tier}: aftershocks only on ground enemies`);
|
||
const shocks = from(h, u).filter((c) => tag(c, 'aftershock') && c.target === g);
|
||
assert.equal(shocks.length, 6 * (elite && mod ? 2 : 1), `T${tier}: every aftershock reaches the ground one (it came before the first one)`);
|
||
for (const c of shocks) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'] * 0.5, `T${tier}: 50 % of the boosted ATK`);
|
||
assert.ok(on(h, g).some((c) => tag(c, 'wisdel:bomb') && Math.abs(c.t - shocks[0].t) < 1e-9), `T${tier}: 好礼 at 100 %: the marked one went off on the first aftershock`);
|
||
done(h);
|
||
}
|
||
});
|