Files
ark-stronghold-protocol/test/content/op_thorns.test.js
T
sganggs fd7f16c595 feedback5 O10: 自选 operator kits 棘刺, 司霆惊蛰 and 赤刃明霄陈 — every skill, talent, trait and module (stage 1 / 3) at every form
- server/sim/content/kits/ops/op-thorns.js (领主; LOR-X, LOR-Δ), op-leizi2.js (解放者; LIB-X), op-chen3.js (术战者; AFT-X),
  appended to OPERATOR_KIT_FILES; numbers from the composed records (backups.json), mechanics from PRTS and the client's
  battle data (buff templates, charpacks, skill / equip / projectile prefabs, skeleton clips). No engine change; the data
  triggers unchanged (司霆惊蛰 S1 / S2 SEARCH reported, not changed).
- tests: test/content/op_thorns.test.js (12), op_leizi2.test.js (12), op_chen3.test.js (11).
- golden: only diy moved — diy-049 … diy-053 now field the three new kits in every form × module × skill (the diy family
  packs every kitted operator, 12 pieces per battle); the prototype battles moved from diy-049 … 052 to diy-054 … 057 (same
  pieces, new ids); diy-001 … 048 and the roster / bonds / fields / matches / standins families unchanged.
2026-10-06 01:35:01 +08:00

396 lines
24 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_thorns.test.js — the 自选 operator kit of 棘刺 (char_293_thorns, 6★ 领主; kit
// server/sim/content/kits/ops/op-thorns.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, LOR-X
// “盐与沙” or LOR-Δ “沙蚀” 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_thorns.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 THORNS = 'char_293_thorns';
const FORMS = BACKUPS.units[THORNS].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const LORX = 'uniequip_002_thorns', LORD = 'uniequip_003_thorns';
const S1 = 'skcom_atk_up[3]', S2 = 'skchr_thorns_2', S3 = 'skchr_thorns_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);
/** Talent 0's blackboard of a form with its module (the module talent change merged over the base). */
const t0Of = (tier, elite, mod) => {
const base = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0) : null;
return { ...base, ...(ch?.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_ranged: dummy('enemy_ranged', { applyWay: 'RANGED' }),
enemy_both: dummy('enemy_both', { applyWay: 'ALL' }),
// a stationary ranged attacker: 100 ATK every second on whoever stands within 2.5
enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LORX, LORD].map((m) => [t, true, m]))];
/** 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, seed = 5, others = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: THORNS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const from = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c));
const tagged = (c, t) => (c.dmg?.tags || []).includes(t);
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: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-12, blocks 2, a 领主 hitting air, physical, no 特质, ground enemies can target him', () => {
assert.equal(OPERATOR_KITS[THORNS], KITS[THORNS]);
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], [THORNS, 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, 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.sub, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat, u.profile.rangedScale], [2, 'lord', 'ranged', true, 'phys', 1.3, 0.8], `${label(f)}: 领主`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-12`);
assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`);
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 2063 / 575 / 333, E2 Lv60 2427 / 665 / 379; LOR-X +160 / +39 / +5 → +260 / +55 / +7,
// LOR-Δ +80 / +60 / +5 → +150 / +75 / +7 (HP / ATK / ASPD)
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], [2063, 575, 2427, 665]);
assert.deepEqual([modOf(5, LORX).attr, modOf(6, LORX).attr, modOf(5, LORD).attr, modOf(6, LORD).attr],
[{ maxHp: 160, atk: 39, aspd: 5 }, { maxHp: 260, atk: 55, aspd: 7 }, { maxHp: 80, atk: 60, aspd: 5 }, { maxHp: 150, atk: 75, aspd: 7 }]);
});
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(THORNS));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(THORNS), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: THORNS, skillIndex: 2, uniEquipId: LORD } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: THORNS, skillIndex: 2, uniEquipId: LORD } } });
});
test('trait 领主: a ranged attack on an enemy two tiles ahead deals 80 % ATK, one on the tile in front 100 %; he hits a flyer', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 0 });
u.skill.sp = 0; // keep S1 off
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === far).length > 0, 5);
approx(from(h, u, (c) => c.dmg.isAttack && c.target === far)[0].amount, u.s.atk * 0.8, `T${tier}: two tiles ahead`);
h.b.kill(far, null);
const near = h.spawn('enemy_dummy', { pos: [10, 6] });
h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === near).length > 0, 5);
approx(from(h, u, (c) => c.dmg.isAttack && c.target === near)[0].amount, u.s.atk, `T${tier}: the tile in front`);
h.b.kill(near, null);
const fly = h.spawn('enemy_fly', { pos: [11, 6] });
assert.ok(h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === fly).length > 0, 5), `T${tier}: a flyer`);
done(h);
}
});
test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s with an enemy in range', () => {
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, sk.duration, sk.bb.atk, u.skill.spCost, sk.initSp], ['DEFAULT', 30, elite ? 0.6 : 0.45, elite ? 35 : 37, elite ? 10 : 5], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
h.runUntil(() => !u.skill.active, 31);
approx(u.s.atk, u.base.atk, `T${tier}: back`);
done(h);
}
});
test('S2 护身尖刺 (MANUAL, 技能范围 3-1, data SKILL_RANGE): no attack, ATK / DEF up; an enemy\'s 普通伤害 sets off up to 4 spikes 0.5 s later on the 3-1 (air too) for 80 % ATK physical, at most every 0.8 / 0.75 s; his range stays 3-12', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb.def, sk.bb.cooldown, sk.bb.max_target],
['SKILL_RANGE', '3-1', elite ? 36 : 33, elite ? 0.4 : 0.3, elite ? 0.7 : 0.55, elite ? 0.75 : 0.8, 4], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody on the 3-1`);
// targets on the 3-1: (9,7) (11,7) (10,8) a flyer at (11,6); one outside it at (10,9)
const shooter = h.spawn('enemy_shooter', { pos: [10, 7] });
const a = h.spawn('enemy_dummy', { pos: [9, 7] }), b = h.spawn('enemy_dummy', { pos: [11, 7] });
const fly = h.spawn('enemy_fly', { pos: [11, 6] }), out = h.spawn('enemy_dummy', { pos: [10, 9] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with an enemy on the 3-1`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: the card keeps his 3-12`);
const n0 = from(h, u).length, cast = h.b.time;
h.run(6);
assert.equal(from(h, u, (c) => c.dmg.isAttack && c.t >= cast).length, 0, `T${tier}: 停止攻击`);
const hurt = h.hooksOf('damaged').filter((c) => c.target === u && c.source === shooter);
const spikes = from(h, u, (c) => tagged(c, 'thorns:spike')).filter((c) => from(h, u).indexOf(c) >= n0);
assert.ok(hurt.length >= 4, `T${tier}: ${hurt.length} hits from the shooter`);
// each shooter hit (1 s apart, cooldown < 1 s) ⇒ one volley of 4 spikes on the four nearest 3-1 enemies, 0.5 s later
const volleys = new Map();
for (const c of spikes) volleys.set(c.t, [...(volleys.get(c.t) ?? []), c]);
assert.ok(volleys.size >= hurt.length - 1, `T${tier}: ${volleys.size} volleys for ${hurt.length} hits`);
for (const [t, v] of volleys) {
assert.equal(v.length, 4, `T${tier}: 4 spikes at ${t}`);
assert.ok(v.every((c) => c.target !== out), `T${tier}: (10,9) is off the 3-1`);
for (const c of v) { approx(c.amount, u.s.atk * 0.8, `T${tier}: 80 % ATK`); assert.deepEqual([c.type, c.dmg.isSkill, !!c.dmg.isAttack], ['phys', true, false]); }
assert.ok(hurt.some((x) => Math.abs(x.t + 0.5 - t) < 0.05), `T${tier}: a hit 0.5 s before ${t}`);
}
assert.ok(spikes.some((c) => c.target === fly) && spikes.some((c) => c.target === a) && spikes.some((c) => c.target === b), `T${tier}: the flyer and the dummies`);
done(h);
}
});
test('S2: the cooldown (0.8 s) holds a second hit back; 持续 / 溅射 / element / 流失 / 无来源 damage and an ally\'s set nothing off; a stunned 棘刺 casts none', () => {
const { h, u } = field({ tier: 5, skill: 1 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2));
const spikes = () => from(h, u, (c) => tagged(c, 'thorns:spike')).length;
const hit = (o) => h.b.dealDamage(e, u, { amount: 1, type: 'phys', ...o });
hit({ tags: ['dot'] }); hit({ tags: ['periodic'] }); hit({ isSplash: true }); hit({ sourceless: true });
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 10 });
h.b.loseHp(u, 1, { source: e });
h.b.dealDamage(u, u, { amount: 1, type: 'phys' });
h.run(1);
assert.equal(spikes(), 0, 'none of those is an enemy\'s 普通伤害');
hit({});
h.run(0.3);
hit({});
h.run(1);
assert.equal(spikes(), 1, 'the second hit fell inside the 0.8 s cooldown');
hit({});
h.b.applyStatus(u, 'stun', { duration: 1, source: e });
h.run(0.8);
assert.equal(spikes(), 1, 'stunned before the spikes landed: none');
h.run(1);
hit({});
h.run(0.6);
assert.equal(spikes(), 2, 'after the stun: a volley again');
done(h);
});
test('S3 至高之术 (MANUAL, attack SP, data ACTIVE_RANGE on 3-3): 30 s of range 3-3, ATK +33 % / +40 %, ASPD +14 / +16, no 80 % on ranged attacks; the second use of a deployment doubles both and never ends; a redeploy starts the count again', () => {
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.spType, sk.spCost, sk.duration, sk.bb.atk, sk.bb.attack_speed, sk.bb['thorns_s_3[b].atk'], sk.bb['thorns_s_3[b].attack_speed'], sk.bb['thorns_s_3[b].duration']],
['ACTIVE_RANGE', '3-3', 'INCREASE_WHEN_ATTACK', 15, 30, elite ? 0.4 : 0.33, elite ? 16 : 14, elite ? 0.8 : 0.66, elite ? 32 : 28, -1], `T${tier}`);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [11, 8] }); // on the 3-3, off the 3-12
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the 3-3 casts it`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: range 3-3`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `T${tier}: ASPD`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === e).length > 0, 3);
approx(from(h, u, (c) => c.dmg.isAttack && c.target === e)[0].amount, u.s.atk, `T${tier}: full ATK at range`);
h.runUntil(() => !u.skill.active, 31);
assert.deepEqual([u.liveRangeGrid, u.s.aspd], [formOf(tier, elite).rangeGrid, u.base.aspd], `T${tier}: back after 30 s`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: the second use`);
approx(u.s.atk, u.base.atk * (1 + sk.bb['thorns_s_3[b].atk']), `T${tier}: ATK ×2`);
assert.equal(u.s.aspd, u.base.aspd + sk.bb['thorns_s_3[b].attack_speed'], `T${tier}: ASPD ×2`);
h.run(120);
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
// knocked out and back: the count restarts
h.b.kill(u, null);
h.step();
assert.ok(h.b.redeploy(u) && u.alive && u.deployed, `T${tier}: redeployed`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast again`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: the first-use numbers again`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s again`, 0.05);
done(h);
}
});
test('T1 神经腐蚀: an attack poisons its target — 3 ticks of 125 arts (250 on an enemy that attacks at range: RANGED / ALL), the first a second in, 持续伤害; a new hit refreshes it without a second instance', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = t0Of(tier, elite, null);
assert.deepEqual([t0['damage[normal]'], t0['damage[ranged]'], t0.duration], [125, 250, 3]);
for (const [key, dmg] of [['enemy_dummy', 125], ['enemy_ranged', 250], ['enemy_both', 250]]) {
const { h, u } = field({ tier, elite, skill: 0 });
u.skill.sp = 0;
const e = h.spawn(key, { pos: [10, 6] });
h.runUntil(() => from(h, u, (c) => c.dmg.isAttack).length > 0, 3);
const hitT = h.b.time;
u.atkCd = 99; // no further attack
h.run(4);
const ticks = from(h, u, (c) => tagged(c, 'thorns:poison'));
assert.equal(ticks.length, 3, `T${tier} ${key}: 3 ticks`);
approx(ticks[0].t - hitT, 1, `T${tier} ${key}: the first a second in`, 0.05);
for (const c of ticks) {
approx(c.amount, dmg, `T${tier} ${key}: ${dmg} arts`);
assert.deepEqual([c.type, c.dmg.canDodge, tagged(c, 'dot'), c.target], ['arts', false, true, e]);
}
done(h);
}
// refreshed by every hit: one poison per 棘刺, ticking once a second while he keeps attacking
const { h, u } = field({ tier, elite, skill: 0 });
u.skill.sp = 0;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(10.05);
assert.equal(e.buffs.filter((b) => b.key === `thorns:poison:${u.id}`).length, 1, `T${tier}: one instance`);
const ticks = from(h, u, (c) => tagged(c, 'thorns:poison'));
assert.ok(ticks.length >= 8 && ticks.length <= 10, `T${tier}: ${ticks.length} ticks in 10 s`);
assert.ok(ticks.every((c) => Math.abs(c.amount - 125) < 1e-6), `T${tier}: the value never changes`);
done(h);
}
});
test('T1 + LOR-X stage 3 "可叠加4次": the tick is 135 × the hits so far (≤ 4), 270 on a ranged enemy; stage 1 leaves the talent (125, one layer); a dodged hit poisons nothing', () => {
const t03 = t0Of(6, true, LORX), t01 = t0Of(5, true, LORX);
assert.deepEqual([t03['damage[normal]'], t03['damage[ranged]'], t03.max_cnt, t01['damage[normal]'], t01.max_cnt], [135, 270, 4, 125, undefined]);
for (const [tier, per, cap] of [[6, 135, 4], [5, 125, 1]]) {
const { h, u } = field({ tier, elite: true, mod: LORX, skill: 0 });
u.skill.sp = 0;
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(12);
const amounts = from(h, u, (c) => tagged(c, 'thorns:poison')).map((c) => c.amount);
assert.ok(amounts.length >= 9, `T${tier}: ${amounts.length} ticks`);
assert.equal(Math.max(...amounts), per * cap, `T${tier}: up to ${cap} layers`);
assert.ok(amounts.every((a) => a % per === 0 && a <= per * cap), `T${tier}: multiples of ${per}`);
// a hit every 1.3 × 100 / 107 s, a tick every second: one layer more per tick until the cap
if (cap > 1) assert.deepEqual(amounts.slice(0, 4), [per, per * 2, per * 3, per * 4], `T${tier}: growing`);
done(h);
}
// a dodged attack: no poison
const { h, u } = field({ tier: 5, skill: 0 });
u.skill.sp = 0;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(5);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u), 'he attacked');
assert.equal(e.findBuff(`thorns:poison:${u.id}`), null, 'nothing hit, nothing poisoned');
done(h);
});
test('LOR-X “盐与沙” (stages 1 / 3): every attack hit adds 10 % ATK arts (附加伤害); none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.sp = 0;
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(4);
const atks = from(h, u, (c) => c.dmg.isAttack), adds = from(h, u, (c) => tagged(c, 'thorns:lorx'));
const on = elite && mod === LORX;
assert.equal(adds.length, on ? atks.length : 0, `${label(f)}: ${adds.length} additions for ${atks.length} hits`);
if (on) {
assert.deepEqual(u.def.raw.trait.bb, { atk_scale: 0.8, atk_scale_m: 0.1 }, label(f));
for (const c of adds) { approx(c.amount, u.s.atk * 0.1, `${label(f)}: 10 % ATK`); assert.deepEqual([c.type, tagged(c, 'addition')], ['arts', true]); }
}
done(h);
}
});
test('LOR-Δ “沙蚀” (stages 1 / 3): every damage instance he deals carries 10 % of it as 神经损伤; stage 3 poison: ×2.5 元素伤害 in a 神经 burst, and S2\'s spikes poison no more (stage 1 still does)', () => {
for (const [tier] of [[5], [6]]) {
const { h, u } = field({ tier, elite: true, mod: LORD, skill: 0 });
assert.deepEqual(u.def.raw.trait.bb, { atk_scale: 0.8, ep_damage_ratio: 0.1 }, `T${tier}`);
u.skill.sp = 0;
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(6);
const dealt = from(h, u, (c) => c.type !== 'element' && c.amount > 0);
const fills = from(h, u, (c) => c.type === 'element' && c.dmg.element === 'neural');
assert.ok(dealt.length > 3 && fills.length === dealt.length, `T${tier}: one fill per damage instance (${fills.length} / ${dealt.length})`);
for (let i = 0; i < 3; i++) approx(fills[i].dmg.amount, dealt[i].amount * 0.1, `T${tier}: 10 %`);
done(h);
}
// stage 3: the poison on an enemy in its 神经 burst
const t03 = t0Of(6, true, LORD);
assert.deepEqual([t03['damage[normal]'], t03['damage[ranged]'], t03.ep_break_multi], [300, 600, 2.5]);
{
const { h, u } = field({ tier: 6, elite: true, mod: LORD, skill: 0 });
u.skill.sp = 0;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.addBuff(e, { key: 'neuralBurst', duration: 30, flags: { burstLock: true } });
h.run(4);
const ticks = from(h, u, (c) => tagged(c, 'thorns:poison'));
assert.ok(ticks.length >= 2, 'ticks');
for (const c of ticks) { approx(c.amount, 300 * 2.5, '×2.5'); assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']); }
// thorns_e_003_t has no independentCharacterSource: one poison per enemy, not one per 棘刺
assert.deepEqual([!!e.findBuff('thorns:poison'), !!e.findBuff(`thorns:poison:${u.id}`)], [true, false], 'the shared poison');
done(h);
}
// stage 1 has no burst multiplier: plain arts ticks
{
const { h, u } = field({ tier: 5, elite: true, mod: LORD, skill: 0 });
u.skill.sp = 0;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.addBuff(e, { key: 'neuralBurst', duration: 30, flags: { burstLock: true } });
h.run(4);
for (const c of from(h, u, (x) => tagged(x, 'thorns:poison'))) { approx(c.amount, 125, 'stage 1: 125'); assert.equal(c.type, 'arts'); }
done(h);
}
// S2 spikes: they poison at stage 1 and without a module, never at LOR-Δ stage 3
for (const [tier, mod, poisons] of [[6, LORD, false], [5, LORD, true], [6, LORX, true], [6, null, true]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 1 });
const shooter = h.spawn('enemy_shooter', { pos: [10, 7] });
u.skill.gainSp(999);
h.run(4);
assert.ok(from(h, u, (c) => tagged(c, 'thorns:spike')).length > 0, `T${tier} ${mod}: spikes`);
assert.equal(!!(shooter.findBuff(`thorns:poison:${u.id}`) || shooter.findBuff('thorns:poison')), poisons, `T${tier} ${mod}: spikes poison`);
done(h);
}
});
test('T2 故土潮声: 生命回复速度 +3.5 % max HP/s (an hpRegenRatio buff, no 治疗: 禁疗 does not stop it) once his last attack is 2 s old — off while he attacks; S2\'s spikes are no 主动攻击', () => {
for (const f of [[5, false, null], [6, true, LORX]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const r = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
assert.deepEqual([r.delay, r.hp_recovery_per_sec_by_max_hp_ratio], [2, 0.035]);
assert.deepEqual(u.findBuff('talent:thorns:tide')?.mods, { hpRegenRatio: 0.035 }, `${label(f)}: on from the deployment`);
approx(u.s.hpRegen, u.s.maxHp * 0.035, `${label(f)}: 3.5 % max HP / s`);
u.skill.sp = 0;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 3);
h.step();
assert.equal(u.findBuff('talent:thorns:tide'), null, `${label(f)}: off while attacking`);
h.b.kill(e, null);
h.run(1.9);
assert.equal(u.findBuff('talent:thorns:tide'), null, `${label(f)}: not yet`);
h.run(0.2);
assert.ok(u.findBuff('talent:thorns:tide'), `${label(f)}: 2 s after his last attack`);
// 禁疗 (flag healFree) keeps heals off, not the regeneration attribute
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } });
u.hp = u.s.maxHp * 0.5;
h.run(1);
approx(u.hp, u.s.maxHp * (0.5 + 0.035), `${label(f)}: +3.5 % in a second under 禁疗`, 0.01);
done(h);
}
// S2: the spikes keep the regeneration on
const { h, u } = field({ tier: 5, skill: 1 });
h.spawn('enemy_shooter', { pos: [10, 7] });
u.skill.gainSp(999);
h.run(5);
assert.ok(from(h, u, (c) => tagged(c, 'thorns:spike')).length > 0, 'spikes');
assert.ok(u.findBuff('talent:thorns:tide'), 'still on');
done(h);
});