Files
ark-stronghold-protocol/test/content/op_typhon.test.js
T

346 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (ISW-A, a 集成战略 module, is refused)', () => {
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.equal(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }).error, 'BAD_TARGET',
'a 集成战略 module (ISW-A) is not a 自选 choice (shared/diy.js, W5K)');
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: SIEX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: SIEX } } });
});
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);
}
});