- kits/ops/op-poca.js 早露 (攻城手): heaviest first; 深入骨髓 60 % DEF ignore vs 重量等级 ≥ 3; 学生楷模 ATK on every 【乌萨斯学生自治团】 operator of her team (teamId student); S1 ATK, S2 ATK + 2 targets, S3 雪崩击 links the 3 heaviest, binds them and strikes each every second (6 / 7 attacks, PRTS link-break rules); SIE-X ×1.15 vs heavy + Lv3 60 % ATK extra hit; SIE-Y distance bonus ≤ 12 % + Lv3 学生楷模 +12 % and +15 % per student in skill (≤ 45 %). - kits/ops/op-typhon.js 提丰 (攻城手): 锐如兽牙 stacking DEF ignore; 重如沼泥 first hit per enemy per cast ×1.6 + 停顿 3 s; S1 ATK / ASPD; S2 two arrows (both at a lone enemy), 40 % stun, 20 s on the first cast of a deployment then endless; S3 永恒狩猎 mark (radius 1.3, follows its enemy), whole-field range, 4-arrow rain per bullet with 0.4 s stuns; SIE-X ×1.15, Lv3 12 % / 10 s; SIE-Y distance bonus, Lv3 two marks ×2.2; ISW-A attributes only (集成战略 parts N/A). - kits/ops/op-narant.js 娜仁图亚 (回环射手): 偷取 25 ATK / 20 DEF per landing attack hit (caps 250 / 200; LPS-X Lv3 300 / 240, twice within one tile; the largest thief wins; given back when she leaves); 婀娜虚影 35 % dodge and −20 % 命中率 for enemies within one tile (LPS-Y Lv3 −30 % and ATK +15 % after 6 s unhurt); S1 旋刃 toggle, range −1, up to 3 bounces; S2 恶魇 dash and the sweep on its way back; S3 吞日 3 boomerangs and the recall blast; LPS-X ×1.1 within one tile; LPS-Y 1 SP per 5 boomerangs caught. - Data triggers unchanged (all nine skills MANUAL / DEFAULT). Registered in OPERATOR_KIT_FILES (one line per file). - Tests: test/content/op_poca.test.js (11), op_typhon.test.js (12), op_narant.test.js (11, two on the engine hooks). - Golden: test/golden/diy.json only — diy-003 … diy-009 now field the three kits in every form × module × skill, the prototypes / 预备干员 re-packed into diy-009 … diy-013; diy-001 / diy-002 (推进之王) and every other family unchanged.
344 lines
22 KiB
JavaScript
344 lines
22 KiB
JavaScript
// test/content/op_typhon.test.js — the 自选 operator kit of 提丰 (char_2012_typhon, 6★ 攻城手; kit
|
||
// server/sim/content/kits/ops/op-typhon.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, SIE-X
|
||
// 自然的包容, SIE-Y 冰原的影子 or ISW-A 提丰特限证章 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_typhon.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 TYPHON = 'char_2012_typhon';
|
||
const FORMS = BACKUPS.units[TYPHON].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const SIEX = 'uniequip_002_typhon', SIEY = 'uniequip_003_typhon', ISWA = 'uniequip_004_typhon';
|
||
const S1 = 'skcom_quickattack[3]', S2 = 'skchr_typhon_2', S3 = 'skchr_typhon_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 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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
|
||
enemy_plate: dummy('enemy_plate', { mass: 1, def: 600 }), enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }),
|
||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.8, mass: 3 }),
|
||
};
|
||
/** Every 自选 form: [tier, elite, module]. */
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY, ISWA].map((m) => [t, true, m]))];
|
||
|
||
/** A battle with 提丰 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: TYPHON, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
|
||
const rainHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.tags?.includes('typhon:rain'));
|
||
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'}`;
|
||
|
||
test('提丰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (SIE-Y ASPD), 4-3 range, ranged physical arrows that hit air, heaviest first, no 特质', () => {
|
||
assert.equal(OPERATOR_KITS[TYPHON], KITS[TYPHON]);
|
||
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], [TYPHON, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
|
||
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.priority, u.base.bat], [1, 'ranged', 'phys', true, 'heaviest', 2.4], `${label(f)}: 攻城手`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[TYPHON].bonds, []], `${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);
|
||
}
|
||
}
|
||
// E2 Lv1 1310 / 867 / 92, E2 Lv60 1570 / 985 / 106; SIE-X +120 HP / +60 ATK → +210 / +90, SIE-Y +70 ATK / +5 ASPD → +94 / +7,
|
||
// ISW-A +100 / +70 → +150 / +90
|
||
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], [1310, 867, 1570, 985]);
|
||
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
|
||
[{ maxHp: 120, atk: 60 }, { maxHp: 210, atk: 90 }, { atk: 70, aspd: 5 }, { atk: 94, aspd: 7 }, { maxHp: 100, atk: 70 }, { maxHp: 150, atk: 90 }]);
|
||
});
|
||
|
||
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(TYPHON));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(TYPHON), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } } });
|
||
});
|
||
|
||
test('trait 攻城手: the heaviest enemy of her range first; a flyer is a target', () => {
|
||
const { h, u } = field({ tier: 6, elite: true });
|
||
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
|
||
h.run(3);
|
||
assert.equal(atkHits(h, u)[0].target, heavy);
|
||
h.b.kill(heavy, null);
|
||
h.b.kill(light, null);
|
||
const fly = h.spawn('enemy_fly', { pos: [11, 5] });
|
||
h.run(3);
|
||
assert.ok(atkHits(h, u).some((c) => c.target === fly), '可对空');
|
||
done(h);
|
||
});
|
||
|
||
test('S1 迅捷打击·γ型 (MANUAL, data DEFAULT): ATK +27 % / +34 %, ASPD +25 / +35 for 35 s; SP 42 / 39 from 5 / 10', () => {
|
||
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, u.skill.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
|
||
['DEFAULT', 'duration', elite ? 39 : 42, elite ? 10 : 5, 35, elite ? 0.34 : 0.27, elite ? 35 : 25], `T${tier}`);
|
||
u.skill.gainSp(999);
|
||
h.run(2);
|
||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||
h.spawn('enemy_light', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||
approx(u.skill.timeLeft, 35, `T${tier}: 35 s`, 0.01);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
|
||
h.runUntil(() => !u.skill.active, 36);
|
||
assert.deepEqual([u.s.atk, u.s.aspd], [u.base.atk, 100], `T${tier}: back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 冰原秩序: ATK +26 % / +35 %, two arrows per attack — two enemies when there are two, both at a lone one — each with 40 % to stun 1 s; 20 s on the first cast of a deployment, then 持续时间无限 until she leaves (a redeploy counts again)', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const { h, u } = field({ tier, elite, skill: 1, seed: 9 });
|
||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.first_duration, sk.bb.atk, sk.bb['attack@prob'], sk.bb['attack@stun'], u.skill.spCost, u.skill.initSp],
|
||
['DEFAULT', 'toggle', 20, 20, elite ? 0.35 : 0.26, 0.4, 1, elite ? 51 : 53, elite ? 40 : 37], `T${tier}`);
|
||
const lone = h.spawn('enemy_light', { pos: [10, 6] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||
const t0 = h.b.time;
|
||
approx(u.skill.timeLeft, 20, `T${tier}: first cast 20 s`, 0.05);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
h.run(5);
|
||
const s2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||
const byAttack = Map.groupBy(s2, (c) => c.dmg.attackId);
|
||
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
|
||
for (const list of byAttack.values()) assert.deepEqual(list.map((c) => c.target), [lone, lone], `T${tier}: both arrows at the lone enemy`);
|
||
const other = h.spawn('enemy_mid', { pos: [11, 6] });
|
||
h.run(5);
|
||
const later = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t > h.b.time - 4.5);
|
||
for (const list of Map.groupBy(later, (c) => c.dmg.attackId).values()) {
|
||
assert.deepEqual(list.map((c) => c.target).sort((a, b) => a.id - b.id), [lone, other].sort((a, b) => a.id - b.id), `T${tier}: two different enemies`);
|
||
}
|
||
assert.ok(h.runUntil(() => !u.skill.active, 15), `T${tier}: the first cast ends`);
|
||
approx(h.b.time - t0, 20, `T${tier}: after 20 s`, 0.05);
|
||
// the second cast never ends
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: second cast`);
|
||
assert.equal(u.skill.timeLeft, Infinity, `T${tier}: 持续时间无限`);
|
||
h.run(120);
|
||
assert.ok(u.skill.active, `T${tier}: still on after 120 s`);
|
||
// the stun: 40 % of the arrows, 1 s
|
||
const all = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||
assert.ok(all.length > 100, `T${tier}: ${all.length} arrows`);
|
||
assert.ok(stuns.length / all.length > 0.32 && stuns.length / all.length < 0.48, `T${tier}: ${stuns.length} / ${all.length} stuns ≈ 40 %`);
|
||
for (const c of stuns) approx(c.duration, 1, `T${tier}: 1 s`);
|
||
// a redeploy counts its casts again
|
||
h.b.retreat(u);
|
||
h.b.redeploy(u);
|
||
assert.ok(!u.skill.active && u.deployed, `T${tier}: back on the field, skill off`);
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast after the redeploy`);
|
||
approx(u.skill.timeLeft, 20, `T${tier}: the first cast of this deployment — 20 s again`, 0.05);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 “永恒狩猎”: 7 / 8 rounds, attack interval 2.4 + 3.1 s; the mark on the heaviest enemy of her own range; range 全场 but attacks only with an enemy in the mark area (radius 1.3); each round 4 arrows 0.1 s apart on random enemies of the area, 145 % / 160 % ATK physical skill hits (no attack), 0.4 s stun once they land', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
|
||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.durationType, sk.bb['attack@s3_trigger_time'], sk.bb.base_attack_time, sk.bb['attack@s3_atk_scale'], sk.bb['attack@s3_max_hit_num'], sk.bb['attack@s3_stun'], u.skill.spCost, u.skill.initSp],
|
||
['DEFAULT', 'ammo', 'AMMO', elite ? 8 : 7, 3.1, elite ? 1.6 : 1.45, 4, 0.4, elite ? 43 : 47, 25], `T${tier}`);
|
||
const marked = h.spawn('enemy_heavy', { pos: [10, 6] }), near = h.spawn('enemy_light', { pos: [11, 6] });
|
||
const away = h.spawn('enemy_mid', { pos: [9, 5] }); // in her range, 1.4+ tiles from the mark: never hit
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||
const t0 = h.b.time;
|
||
assert.equal(u.mem.typhonMark.target, marked, `T${tier}: the heaviest of her range is marked`);
|
||
assert.ok(u.liveRangeGrid.length >= 400, `T${tier}: the card shows 全场`);
|
||
approx(u.s.interval, 2.4 + 3.1, `T${tier}: interval 5.5 s`);
|
||
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: ammo spent`);
|
||
h.run(0.5);
|
||
const rain = rainHits(h, u);
|
||
assert.equal(atkHits(h, u).filter((c) => c.t >= t0 - 0.05).length, 0, `T${tier}: the round itself deals nothing`);
|
||
assert.equal(rain.length, sk.bb['attack@s3_trigger_time'] * 4, `T${tier}: ${rain.length} arrows (rounds × 4)`);
|
||
assert.ok(rain.every((c) => c.target === marked || c.target === near), `T${tier}: the area only`);
|
||
assert.ok(rain.some((c) => c.target === near) && rain.some((c) => c.target === marked), `T${tier}: random among the area`);
|
||
assert.ok(rain.every((c) => c.type === 'phys' && c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: physical skill hits, no attack`);
|
||
const rounds = Map.groupBy(rain, (c) => Math.round((c.t - t0) / 5.5));
|
||
assert.equal(rounds.size, sk.bb['attack@s3_trigger_time'], `T${tier}: one round per bullet`);
|
||
for (const list of rounds.values()) list.forEach((c, i) => i && approx(c.t - list[i - 1].t, 0.1, `T${tier}: 0.1 s apart`, 0.05));
|
||
// amounts: ATK × s3_atk_scale (DEF 0), the first hit on each enemy ×1.6 (重如沼泥)
|
||
const firsts = new Set([marked, near].map((e) => rain.find((c) => c.target === e)));
|
||
for (const c of rain) approx(c.amount, u.s.atk * sk.bb['attack@s3_atk_scale'] * (firsts.has(c) ? 1.6 : 1), `T${tier}: s3_atk_scale`);
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||
assert.equal(stuns.length, rain.length, `T${tier}: a stun per landed arrow`);
|
||
for (const c of stuns) approx(c.duration, 0.4, `T${tier}: 0.4 s`);
|
||
assert.ok(!hitsBy(h, u).some((c) => c.target === away), `T${tier}: outside the mark area`);
|
||
assert.ok(u.liveRangeGrid.length < 20, `T${tier}: own range back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 “永恒狩猎”: no enemy in the mark area ⇒ no attack and no bullet spent; the mark follows its enemy and stays where it left the field', () => {
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 3 });
|
||
const walker = h.spawn('enemy_walk', { routeIndex: 0 });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 30), 'cast once the walker is in her range');
|
||
assert.equal(u.mem.typhonMark.target, walker);
|
||
const x0 = u.mem.typhonMark.x;
|
||
h.run(1);
|
||
assert.notEqual(u.mem.typhonMark.x, x0, 'the mark moves with its enemy');
|
||
const ammo = u.skill.ammoLeft;
|
||
h.b.kill(walker, null);
|
||
const spot = [u.mem.typhonMark.x, u.mem.typhonMark.y];
|
||
h.run(12);
|
||
assert.equal(u.skill.ammoLeft, ammo, 'nothing in the area: no round');
|
||
assert.deepEqual([u.mem.typhonMark.x, u.mem.typhonMark.y], spot, 'the mark stays');
|
||
const later = h.spawn('enemy_light', { pos: [Math.round(spot[1]), Math.round(spot[0])] });
|
||
assert.ok(h.runUntil(() => rainHits(h, u).some((c) => c.target === later), 12), 'an enemy at the old spot is hit');
|
||
assert.equal(u.skill.ammoLeft, ammo - 1);
|
||
done(h);
|
||
});
|
||
|
||
test('T1 锐如兽牙: each attack adds 10 % (SIE-X stage 3: 12 %) DEF ignore up to 5 stacks, applied to that attack already; all gone 8 s (10 s) after her last attack', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEX], [6, true, SIEX]]) {
|
||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||
const tc = mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0 && !t.hidden)?.bb : null;
|
||
const per = tc?.def_penetrate ?? t0.def_penetrate, dur = tc?.duration ?? t0.duration;
|
||
assert.deepEqual([per, dur, tc?.max_stack_cnt ?? t0.max_stack_cnt], tier === 6 && mod === SIEX ? [0.12, 10, 5] : [0.1, 8, 5], label([tier, elite, mod]));
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const e = h.spawn('enemy_plate', { pos: [10, 6] });
|
||
h.run(16);
|
||
const list = atkHits(h, u).filter((c) => c.target === e);
|
||
assert.ok(list.length >= 6, `${label([tier, elite, mod])}: ${list.length} attacks`);
|
||
list.forEach((c, i) => approx(c.amount, u.s.atk - 600 * (1 - per * Math.min(5, i + 1)), `${label([tier, elite, mod])}: attack ${i + 1}`));
|
||
h.b.kill(e, null);
|
||
const last = list.at(-1).t;
|
||
h.run(last + dur - 1 - h.b.time);
|
||
assert.equal(u.findBuff('talent:typhon:fang')?.stacks, 5, `${label([tier, elite, mod])}: still there ${dur - 1} s after the last attack`);
|
||
h.run(1.5);
|
||
assert.equal(u.findBuff('talent:typhon:fang'), null, `${label([tier, elite, mod])}: lost after ${dur} s without an attack`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 重如沼泥: while her skill runs, the first damage on each enemy deals ×1.6 and 停顿 3 s (SIE-Y stage 3: the first two, ×2.2); not outside a skill; a new cast clears the marks', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEY], [6, true, SIEY], [6, true, ISWA]]) {
|
||
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
|
||
const tc = mod === SIEY && tier === 6 ? modOf(tier, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb : null;
|
||
const n = tc?.max_stack_cnt ?? 1, scale = tc?.atk_scale ?? t1.atk_scale;
|
||
assert.deepEqual([scale, tc?.sluggish ?? t1.sluggish], [tier === 6 && mod === SIEY ? 2.2 : 1.6, 3], label([tier, elite, mod]));
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const a = h.spawn('enemy_light', { pos: [10, 5] });
|
||
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
|
||
h.run(5);
|
||
assert.ok(atkHits(h, u).length && atkHits(h, u).every((c) => c.target === a), `${label([tier, elite, mod])}: attacks`);
|
||
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * far(2), `${label([tier, elite, mod])}: no skill, no bonus`);
|
||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish').length, 0);
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||
const t0 = h.b.time;
|
||
h.run(8);
|
||
const during = atkHits(h, u).filter((c) => c.t >= t0 - 0.05);
|
||
assert.ok(during.length > n + 1, `${label([tier, elite, mod])}: attacks during S1`);
|
||
during.forEach((c, i) => approx(c.amount, u.s.atk * far(2) * (i < n ? scale : 1), `${label([tier, elite, mod])}: hit ${i + 1}`));
|
||
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||
assert.equal(slows.length, n, `${label([tier, elite, mod])}: 停顿 with the marked hits`);
|
||
for (const c of slows) approx(c.duration, 3, '3 s');
|
||
// a second enemy gets its own first hit; a new cast clears the marks
|
||
u.skill.end('test');
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||
const t1s = h.b.time;
|
||
h.run(3);
|
||
const again = atkHits(h, u).filter((c) => c.t >= t1s - 0.05);
|
||
approx(again[0].amount, u.s.atk * far(2) * scale, `${label([tier, elite, mod])}: marks cleared by the new cast`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('SIE-X 自然的包容: ×1.15 on her attacks against 重量等级 ≥ 3, nothing below; the S3 arrow rain (no attack) is not raised', () => {
|
||
for (const [tier, mod] of [[5, SIEX], [6, SIEX], [6, null], [6, ISWA]]) {
|
||
const xs = mod === SIEX ? 1.15 : 1;
|
||
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 });
|
||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
|
||
h.run(4);
|
||
const mid = h.spawn('enemy_mid', { pos: [10, 6] });
|
||
h.b.kill(heavy, null);
|
||
h.run(4);
|
||
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * (c.target.weight >= 3 ? xs : 1), `T${tier} ${mod}: ${c.target.defId}`);
|
||
assert.ok(atkHits(h, u).some((c) => c.target === mid) && atkHits(h, u).some((c) => c.target.weight >= 3));
|
||
done(h);
|
||
}
|
||
// S3: rain hits on a weight-3 enemy are s3_atk_scale × ATK (× 1.6 the first)
|
||
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 2 });
|
||
h.spawn('enemy_heavy', { pos: [10, 6] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||
h.run(7);
|
||
const rain = rainHits(h, u);
|
||
rain.forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i === 0 ? 1.6 : 1), `rain ${i + 1}: no ×1.15`));
|
||
done(h);
|
||
});
|
||
|
||
test('SIE-Y 冰原的影子: her attack damage rises with the distance, linearly 1 → 4.5 tiles, at most +12 % (stages 1 and 3); the S3 rain is not raised', () => {
|
||
for (const tier of [5, 6]) {
|
||
assert.deepEqual(modOf(tier, SIEY).traitOverride.bb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 });
|
||
for (const [r, c] of [[10, 5], [10, 7], [11, 6]]) {
|
||
const { h, u } = field({ tier, elite: true, mod: SIEY, skill: 0 });
|
||
const e = h.spawn('enemy_light', { pos: [r, c] });
|
||
h.run(3);
|
||
const d = Math.hypot(c - 3, r - 10);
|
||
approx(atkHits(h, u).find((x) => x.target === e).amount, u.s.atk * (1 + 0.12 * (d - 1) / 3.5), `T${tier}: ${d.toFixed(2)} tiles`);
|
||
done(h);
|
||
}
|
||
}
|
||
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 2 });
|
||
h.spawn('enemy_light', { pos: [10, 7] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||
h.run(7);
|
||
const n = modOf(6, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb.max_stack_cnt;
|
||
rainHits(h, u).forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i < n ? 2.2 : 1), `rain ${i + 1}: no distance bonus`));
|
||
done(h);
|
||
});
|
||
|
||
test('ISW-A 提丰特限证章: only its HP / ATK — the 集成战略 trait (damage_scale 1.4 on heavy enemies) and arrow-rain mark do nothing here', () => {
|
||
for (const tier of [5, 6]) {
|
||
const m = modOf(tier, ISWA);
|
||
assert.match(m.traitOverride.moduleDesc, /^在集成战略中/);
|
||
assert.ok(m.talentChanges.some((t) => t.bb.damage_scale === 1.4), 'the data carries the IS part');
|
||
const { h, u } = field({ tier, elite: true, mod: ISWA, skill: 0 });
|
||
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
|
||
h.run(8);
|
||
for (const c of atkHits(h, u).filter((x) => x.target === heavy)) approx(c.amount, u.s.atk, `T${tier}: ×1 on a weight-3 enemy`);
|
||
h.run(30);
|
||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg.isAttack).length, 0, `T${tier}: no arrow rain of its own`);
|
||
done(h);
|
||
}
|
||
});
|