feedback5: merge O10 — 自选 kits for 棘刺, 司霆惊蛰 and 赤刃明霄陈; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:47:05 +08:00
commit eba4f53e87
8 files changed
+2336 -122

No files matched your search

+361
View File
@@ -0,0 +1,361 @@
// test/content/op_chen3.test.js — the 自选 operator kit of 赤刃明霄陈 (char_1050_chen3, 6★ 术战者; kit
// server/sim/content/kits/ops/op-chen3.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 or AFT-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 item by item.
// Run: node --test test/content/op_chen3.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 CHEN3 = 'char_1050_chen3';
const FORMS = BACKUPS.units[CHEN3].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const AFTX = 'uniequip_002_chen3';
const S1 = 'skchr_chen3_1', S2 = 'skchr_chen3_2', S3 = 'skchr_chen3_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 `i`'s blackboard of a form with its module. */
const tOf = (tier, elite, mod, i) => {
const base = formOf(tier, elite).talents.find((t) => t.index === i).bb;
const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === i) : 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_frail: dummy('enemy_frail', { hp: 2000 }),
enemy_mid: dummy('enemy_mid', { hp: 10000 }),
enemy_big: dummy('enemy_big', { hp: 1e6 }),
enemy_flymid: dummy('enemy_flymid', { hp: 10000, motion: 'FLY' }),
enemy_mage: dummy('enemy_mage', { res: 60 }),
enemy_tank: dummy('enemy_tank', { def: 5000 }),
enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, AFTX].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', 'statusApplied', 'heal', 'deploy', 'dodge'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHEN3, 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: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee arts ground-only, 炎, no 特质, ground enemies can target her', () => {
assert.equal(OPERATOR_KITS[CHEN3], KITS[CHEN3]);
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], [CHEN3, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.sub, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'artsfghter', 'melee', false, 'arts', 1.25], `${label(f)}: 术战者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${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 2211 / 542 / 352, E2 Lv60 2674 / 627 / 400; AFT-X +240 / +30 → +420 / +60 (HP / ATK)
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], [2211, 542, 2674, 627]);
assert.deepEqual([modOf(5, AFTX).attr, modOf(6, AFTX).attr], [{ maxHp: 240, atk: 30 }, { maxHp: 420, atk: 60 }]);
});
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(CHEN3));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHEN3), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } } });
});
test('T1 形意洞照: ATK +13 %, ASPD +13; every physical / arts damage of hers is 弱点伤害 — physical on a high-RES enemy, arts on a high-DEF one', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = tOf(tier, elite, mod, 0);
assert.deepEqual([t0.atk, t0.attack_speed], [0.13, 13]);
const { h, u } = field({ tier, elite, mod, skill: 0 });
approx(u.s.atk, u.base.atk * 1.13, `${label(f)}: ATK`);
const as = elite && mod === AFTX ? 8 : 0; // she blocks nobody yet: AFT-X's +8 on top
assert.equal(u.s.aspd, 100 + 13 + as, `${label(f)}: ASPD`);
u.skill.sp = 0;
const mage = h.spawn('enemy_mage', { pos: [10, 6] });
h.runUntil(() => from(h, u, (c) => c.target === mage).length > 0, 3);
const m = from(h, u, (c) => c.target === mage)[0];
assert.deepEqual([m.type, m.dmg.isAttack], ['phys', true], `${label(f)}: physical on RES 60 / DEF 0`);
approx(m.amount, u.s.atk, `${label(f)}: full`);
h.b.kill(mage, null);
const tank = h.spawn('enemy_tank', { pos: [10, 6] });
h.runUntil(() => from(h, u, (c) => c.target === tank).length > 0, 3);
assert.equal(from(h, u, (c) => c.target === tank)[0].type, 'arts', `${label(f)}: arts on DEF 5000 / RES 0`);
done(h);
}
});
test('AFT-X “记忆残页” (stages 1 / 3): ASPD +8 while she blocks nobody — off while she blocks', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 });
const on = elite && mod === AFTX;
if (on) assert.deepEqual(u.def.raw.trait.bb, { attack_speed: 8 }, label(f));
h.step();
assert.equal(!!u.findBuff('trait:chen3:unblocked'), on, `${label(f)}: nobody blocked`);
u.skill.sp = 0;
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`);
h.step();
assert.equal(u.findBuff('trait:chen3:unblocked'), null, `${label(f)}: blocking`);
assert.equal(u.s.aspd, 113, `${label(f)}: ASPD while blocking`);
done(h);
}
});
test('T2 寒暑觉知: every 7 s (AFT-X stage 3: 6 s) without damage a self-heal of ATK × a whole random percent of [30, 161) ([50, 201)) and one held dodge; any damage restarts the count', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, AFTX], [6, true, AFTX]]) {
const [tier, elite, mod] = f;
const t1 = tOf(tier, elite, mod, 1);
const st = t1.stack_time, lo = t1.heal_atk_scale_min, hi = t1.heal_atk_scale_max;
assert.deepEqual([st, lo, hi], elite && mod === AFTX && tier === 6 ? [6, 50, 201] : [7, 30, 161], label(f));
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.hp = 100;
const heals = () => h.hooksOf('heal').filter((c) => c.target === u && c.source === u && !c.opts?.regen);
h.run(st - 0.15);
assert.equal(heals().length, 0, `${label(f)}: not before ${st} s`);
assert.equal(u.mem.chen3T2.evade, false);
h.run(0.25);
assert.equal(heals().length, 1, `${label(f)}: at ${st} s`);
const pct = heals()[0].amount / u.s.atk * 100;
assert.ok(Math.abs(pct - Math.round(pct)) < 1e-6 && pct >= lo && pct < hi, `${label(f)}: ${pct} % of ATK`);
assert.equal(u.mem.chen3T2.evade, true, `${label(f)}: a dodge held`);
// damage restarts the count: no second heal at 2 × st
h.run(st - 3);
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.b.dealDamage(e, u, { amount: 1, type: 'true' });
h.run(3);
assert.equal(heals().length, 1, `${label(f)}: the count restarted`);
h.run(st - 3 + 0.2);
assert.equal(heals().length, 2, `${label(f)}: ${st} s after the damage`);
done(h);
}
});
test('T2: the held dodge cancels the next dodgeable physical / arts damage instance and is spent; true damage passes; a 流失 restarts nothing; her other dodges (S2) are rolled first and keep it', () => {
const { h, u } = field({ tier: 5, skill: 0 });
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.run(7.2);
assert.equal(u.mem.chen3T2.evade, true);
const hp0 = u.hp;
assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'true' }), 300, 'true damage is not dodged');
assert.equal(u.mem.chen3T2.evade, true, 'still held');
assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'arts' }), 0, 'arts dodged');
assert.equal(u.mem.chen3T2.evade, false, 'spent');
assert.equal(h.hooksOf('dodge').filter((c) => c.target === u).length, 1, 'a dodge event');
assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }) > 0, true, 'the next one lands');
approx(u.hp, hp0 - 300 - (300 - u.s.def > 300 * 0.05 ? 300 - u.s.def : 15), 'HP');
// a 流失 does not restart the count
h.run(5);
h.b.loseHp(u, 1, { source: e });
h.run(2.2);
assert.equal(u.mem.chen3T2.evade, true, 'a 流失 is no damage taken');
// another dodge first: a certain one (p 1) wins and the held one is kept
h.b.addBuff(u, { key: 'test:dodge', mods: { dodgePhys: 1 } });
assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }), 0);
assert.equal(u.mem.chen3T2.evade, true, 'kept behind the other dodge');
done(h);
});
test('S1 赤霄·奔夜 (MANUAL, data DEFAULT): ATK +65 % / +80 % for 18 s, every attack hits twice, 沉默 on its target until the skill ends', () => {
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], ['DEFAULT', 18, elite ? 0.8 : 0.65], `T${tier}`);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(u.s.atk, u.base.atk * (1 + 0.13 + sk.bb.atk), `T${tier}: ATK`);
const n0 = from(h, u).length;
h.runUntil(() => from(h, u).length >= n0 + 2, 3);
const two = from(h, u).slice(n0, n0 + 2);
assert.equal(two[0].dmg.attackId, two[1].dmg.attackId, `T${tier}: one attack, two hits`);
approx(two[0].amount, u.s.atk, `T${tier}: ATK each`);
const sil = h.hooksOf('statusApplied').filter((c) => c.status === 'silence' && c.target === e && c.source === u);
assert.ok(sil.length >= 1, `T${tier}: 沉默`);
approx(sil[0].duration + sil[0].t, u.mem.chen3S1End, `T${tier}: until the skill ends`, 0.05);
h.runUntil(() => !u.skill.active, 20);
h.step();
assert.ok(!e.s.flags.silence, `T${tier}: lifted with the skill's end`);
done(h);
}
// knocked out mid-skill: the silences she set end with it
const { h, u } = field({ tier: 5, skill: 0 });
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.status === 'silence'), 3);
assert.ok(e.s.flags.silence);
h.b.kill(u, null);
h.step();
assert.ok(!e.s.flags.silence, 'gone with the skill');
done(h);
});
test('S2 赤霄·绝影-驰 (MANUAL, 技能范围 x-1, data SKILL_RANGE): 10 slashes on the nearest enemy, 0.4 s apart, 390 % / 430 % ATK arts; 无敌, blocks nobody, no attack, stun immune; then a 【移动】 onto its tile and 6 s of ATK +230 % / +260 % with 40 % / 50 % physical and arts dodge', () => {
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_scale, sk.bb['chen3_s2[respawn_buff].atk'], sk.bb['chen3_s2[respawn_buff].prob']],
['SKILL_RANGE', 'x-1', 6, elite ? 4.3 : 3.9, elite ? 2.6 : 2.3, elite ? 0.5 : 0.4], `T${tier}`);
u.skill.gainSp(999);
const near = h.spawn('enemy_dummy', { pos: [11, 6] }); // √2 away
const far = h.spawn('enemy_dummy', { pos: [10, 7] }); // 2 away
const shooter = h.spawn('enemy_shooter', { pos: [12, 7] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`);
const t0 = h.b.time;
h.step();
assert.ok(u.s.flags.invulnerable && u.s.flags.noBlock, `T${tier}: 无敌, 不可阻挡`);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: shooter }), false, `T${tier}: stun immune`);
const hp0 = u.hp;
h.run(3.5);
assert.equal(u.hp, hp0, `T${tier}: nothing hurts her`);
h.runUntil(() => u.mem.chen3S2?.phase !== 1, 3);
const cuts = from(h, u, (c) => tagged(c, 'chen3:slash'));
assert.equal(cuts.length, 10, `T${tier}: 10 slashes`);
assert.ok(cuts.every((c) => c.target === near), `T${tier}: on the nearest`);
cuts.forEach((c, i) => approx(c.t - cuts[0].t, 0.4 * i, `T${tier}: slash ${i + 1}`, 0.04));
approx(cuts[0].t, t0, `T${tier}: the first at the cast`, 0.05);
for (const c of cuts) { approx(c.amount, u.base.atk * 1.13 * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); assert.deepEqual([c.type, c.dmg.isSkill], ['arts', true]); }
// the move onto the target's tile, the 6 s state
const moves = h.hooksOf('deploy').filter((c) => c.unit === u && c.move);
assert.equal(moves.length, 1, `T${tier}: one 【移动】`);
approx(moves[0].t - cuts[0].t, 4, `T${tier}: after 10 × 0.4 s`, 0.05);
assert.equal(from(h, u, (c) => c.dmg.isAttack && c.t < moves[0].t).length, 0, `T${tier}: no attack while slashing`);
assert.deepEqual([u.tileR, u.tileC], [11, 6], `T${tier}: moved onto its tile`);
assert.ok(!u.s.flags.invulnerable && !u.s.flags.noBlock, `T${tier}: the slashing is over`);
approx(u.s.atk, u.base.atk * (1 + 0.13 + sk.bb['chen3_s2[respawn_buff].atk']), `T${tier}: ATK`);
approx(u.s.dodgePhys, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: physical dodge`);
approx(u.s.dodgeArts, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: arts dodge`);
approx(u.skill.timeLeft, 6, `T${tier}: 6 s from the move`, 0.05);
h.runUntil(() => !u.skill.active, 7);
approx(u.s.atk, u.base.atk * 1.13, `T${tier}: back`);
assert.equal(from(h, u, (c) => tagged(c, 'chen3:slash') && c.target === far).length, 0, `T${tier}: the farther one is never slashed`);
done(h);
}
});
test('S2: a slashed enemy that falls hands the slashes to the nearest one within 1.7 (+1 slash); with nobody left the slashing ends and she moves back onto her own tile; damage on a 2000-HP target', () => {
{
const { h, u } = field({ tier: 5, skill: 1 });
u.skill.gainSp(999);
const frail = h.spawn('enemy_frail', { pos: [10, 6] }), next = h.spawn('enemy_dummy', { pos: [11, 7] });
assert.ok(h.runUntil(() => u.skill.active, 2));
h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6);
const cuts = from(h, u, (c) => tagged(c, 'chen3:slash'));
assert.ok(!frail.alive, 'the first target fell');
const onFrail = cuts.filter((c) => c.target === frail).length;
assert.equal(cuts.length, 11, `11 slashes (${onFrail} on the first)`);
assert.ok(cuts.slice(onFrail).every((c) => c.target === next), 'the rest on the next');
assert.deepEqual([u.tileR, u.tileC], [11, 7], 'onto the last target\'s tile');
done(h);
}
{
const { h, u } = field({ tier: 5, skill: 1 });
u.skill.gainSp(999);
const frail = h.spawn('enemy_frail', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2));
h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6);
assert.ok(!frail.alive);
assert.ok(from(h, u, (c) => tagged(c, 'chen3:slash')).length < 10, 'ended early');
assert.deepEqual([u.tileR, u.tileC], [10, 5], 'back on her own tile');
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && c.move).length, 1, 'a 【移动】 in place');
assert.ok(u.skill.active && u.findBuff('skill:chen3:respawn'), 'the 6 s state still');
done(h);
}
});
test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each attack hits up to 3 ground enemies 3 times for 165 % / 180 % ATK arts; back to 1-1 after 20 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@max_target'], sk.bb.hp_ratio, sk.bb.projectile_min_atk_scale],
['ACTIVE_RANGE', '3-12', 20, elite ? 1.8 : 1.65, 3, 0.06, elite ? 5.5 : 5.3], `T${tier}`);
u.skill.gainSp(999);
const ground = [[10, 7], [9, 6], [11, 6], [10, 8]].map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [11, 5] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the 3-12`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-12`);
const n0 = from(h, u, (c) => c.dmg.isAttack).length;
h.runUntil(() => from(h, u, (c) => c.dmg.isAttack).length >= n0 + 9, 4);
const hits = from(h, u, (c) => c.dmg.isAttack).slice(n0, n0 + 9);
assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`);
assert.equal(new Set(hits.map((c) => c.target)).size, 3, `T${tier}: 3 targets`);
assert.ok(hits.every((c) => c.target !== fly && ground.includes(c.target)), `T${tier}: ground only`);
for (const c of hits) { approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); }
h.runUntil(() => !u.skill.active, 21);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`);
done(h);
}
});
test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
u.skill.gainSp(999);
const trigger = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2));
const t0 = h.b.time;
const mid = h.spawn('enemy_mid', { pos: [10, 8] }), big = h.spawn('enemy_big', { pos: [9, 8] });
const fly = h.spawn('enemy_flymid', { pos: [11, 9] });
const corner = h.spawn('enemy_dummy', { pos: [10, 10] });
const waveHits = (e) => from(h, u, (c) => tagged(c, 'chen3:wave') && c.target === e);
h.run(3);
const w = u.mem.chen3Wave;
approx(w.x, 5 + 1.5 * (h.b.time - t0), `T${tier}: 1.5 tiles/s along row 10`, 0.02);
assert.deepEqual([w.dr, w.dc, w.y], [0, 1, 10], `T${tier}: still heading right`);
assert.equal(waveHits(trigger).length, 1, `T${tier}: once per run`);
approx(waveHits(trigger)[0].t, t0, `T${tier}: at the cast`, 0.1);
// 10 000 HP: 6 % = 600 < 530 % ATK ⇒ the ATK floor; 1 000 000 HP: 6 % = 60 000 wins
for (const [e, amt] of [[mid, sk.bb.projectile_min_atk_scale * u.s.atk], [fly, sk.bb.projectile_min_atk_scale * u.s.atk], [big, 0.06 * 1e6]]) {
assert.equal(waveHits(e).length, 1, `T${tier}: ${e.defId} once`);
approx(waveHits(e)[0].amount, amt, `T${tier}: ${e.defId}`);
assert.deepEqual([waveHits(e)[0].type, waveHits(e)[0].dmg.isSkill], ['arts', true]);
}
// the field ends after column 10: it turns down there (the corner enemy, within 1.3, is hit again), then left before
// the 侵入点 at (9, 10) — and runs back along row 10
h.run(1);
assert.ok(waveHits(corner).length >= 2, `T${tier}: the corner one again after a turn (${waveHits(corner).length})`);
h.run(2);
assert.deepEqual([u.mem.chen3Wave.dr, u.mem.chen3Wave.dc], [0, -1], `T${tier}: heading left`);
assert.ok(u.mem.chen3Wave.x < 10 && Math.round(u.mem.chen3Wave.y) === 10, `T${tier}: back along row 10`);
h.runUntil(() => !u.skill.active, 20);
h.step();
assert.equal(u.mem.chen3Wave, null, `T${tier}: gone with the skill`);
const n = from(h, u, (c) => tagged(c, 'chen3:wave')).length;
h.run(3);
assert.equal(from(h, u, (c) => tagged(c, 'chen3:wave')).length, n, `T${tier}: no more wave hits`);
done(h);
}
});
+371
View File
@@ -0,0 +1,371 @@
// test/content/op_leizi2.test.js — the 自选 operator kit of 司霆惊蛰 (char_1043_leizi2, 6★ 解放者; kit
// server/sim/content/kits/ops/op-leizi2.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 or LIB-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 item by item.
// Run: node --test test/content/op_leizi2.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';
import { COLS } from '../../server/sim/constants.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 LEIZI2 = 'char_1043_leizi2';
const FORMS = BACKUPS.units[LEIZI2].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const LIBX = 'uniequip_002_leizi2';
const S1 = 'skchr_leizi2_1', S2 = 'skchr_leizi2_2', S3 = 'skchr_leizi2_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 tOf = (tier, elite, mod, i) => {
const base = formOf(tier, elite).talents.find((t) => t.index === i).bb;
const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === i) : 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_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }),
enemy_flyshooter: dummy('enemy_flyshooter', { atk: 100, range: 2.5, bat: 1, motion: 'FLY' }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LIBX].map((m) => [t, true, m]))];
const COUNT_IV = 31 / 30;
/** A battle with 司霆惊蛰 as uid 1 at (row, col) facing RIGHT; `rec` logs her damage with her ATK at that moment. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, seed = 5, silence = false } = {}) {
const log = [];
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LEIZI2, skillIndex: skill, uniEquipId: mod }, elite, row, col }],
setup(b) {
b.on('damaged', (c) => {
const u = b.allyUnits.find((a) => a.uid === 1);
if (c.source === u) log.push({ t: b.time, e: c.target, amount: c.amount, type: c.type, atk: u.s.atk, mul: u.s.atkScaleMul, tags: c.dmg?.tags || [], isAttack: !!c.dmg?.isAttack, isSplash: !!c.dmg?.isSplash, attackId: c.dmg?.attackId });
}, { priority: 1000 });
},
});
h.step();
const u = h.unit(1);
if (silence) h.b.addBuff(u, { key: 'test:silence', flags: { silence: true }, persist: true });
return { h, u, log };
}
const tagged = (x, t) => x.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: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-2, a 解放者 (no attack, block 0 while no skill runs), 起飞, melee physical ground-only, 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[LEIZI2], KITS[LEIZI2]);
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, silence: true });
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], [LEIZI2, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([form.stats.blockCnt, u.s.blockCnt, u.profile.sub, u.profile.noAttackUnlessSkill, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 0, 'librator', true, 'melee', false, 'phys', 1.2], `${label(f)}: 解放者`);
assert.deepEqual([u.profile.rampMax, u.profile.rampTime, u.profile.rampInit], [2, 40, elite && mod === LIBX ? 1 : 0], `${label(f)}: the trait's ramp`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-2`);
assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: 起飞`);
done(h);
}
}
// E2 Lv1 3206 / 306 / 417, E2 Lv60 3642 / 342 / 466; LIB-X +225 / +26 / +27 → +360 / +42 / +36 (HP / ATK / DEF)
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], [3206, 306, 3642, 342]);
assert.deepEqual([modOf(5, LIBX).attr, modOf(6, LIBX).attr], [{ maxHp: 225, atk: 26, def: 27 }, { maxHp: 360, atk: 42, def: 36 }]);
});
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(LEIZI2));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LEIZI2), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LEIZI2, skillIndex: 2, uniEquipId: LIBX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: LEIZI2, skillIndex: 2, uniEquipId: LIBX } } });
});
test('trait + LIB-X: ATK +5 % a second up to +200 % while no skill runs (LIB-X stages 1 / 3: +100 % from each deployment); she never attacks then', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1, silence: true });
const init = elite && mod === LIBX ? 1 : 0;
approx(u.s.atk, u.base.atk * (1 + init), `${label(f)}: at the deployment`);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(10.05);
approx(u.s.atk, u.base.atk * (1 + Math.min(2, init + 0.5)), `${label(f)}: after 10 s`);
h.run(40);
approx(u.s.atk, u.base.atk * 3, `${label(f)}: +200 % at most`);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, `${label(f)}: no attack`);
done(h);
}
});
test('T1 明断 起飞: while no skill runs ground enemies never target her (a ground shooter in range stays idle, a flying one hits her); a skill lands her (0.5 s with no attack, counted in its time) and she blocks 3; after it the take-off clip (0.933 s) passes before she is up again', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 1, silence: true });
const g = h.spawn('enemy_shooter', { pos: [10, 7] });
h.run(4);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source === g).length, 0, `T${tier}: the ground shooter cannot select her`);
const air = h.spawn('enemy_flyshooter', { pos: [11, 6] });
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source === air), `T${tier}: a flyer can`);
h.spawn('enemy_dummy', { pos: [10, 6] }); // S2's SEARCH: a ground enemy on her 1-2
h.b.removeBuff(u, 'test:silence');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: S2 (an enemy on her 1-2)`);
const cast = h.b.time;
assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [false, 3], `T${tier}: landed`);
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source === g), `T${tier}: now the ground shooter hits her`);
const first = h.hooksOf('attack').find((c) => c.attacker === u);
approx(first.t - cast, 0.5, `T${tier}: her first attack once the landing is over`, 0.05);
h.runUntil(() => !u.skill.active, 40);
approx(h.b.time - cast, skillOf(tier, elite, S2).duration, `T${tier}: the landing counts in the 36 s`, 0.05);
h.step();
assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [false, 0], `T${tier}: taking off`);
h.run(0.8);
assert.ok(!u.s.flags.liftoff, `T${tier}: still taking off`);
h.run(0.15);
assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [true, 0], `T${tier}: up again`);
done(h);
}
});
test('T1 明断 strikes: every 1.0333 s each tile of her range counts — a struck tile is struck again 8–12 counts later, a new one within 2–16 — for 100 % ATK arts on every enemy there, air units too, 0.15 s after the cast', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, LIBX]]) {
const t0 = tOf(tier, elite, mod, 0);
assert.deepEqual([t0.interval, t0.atk_scale_t, t0.times], [1, 1, 1]);
const { h, log } = field({ tier, elite, mod, skill: 0, silence: true });
const e = h.spawn('enemy_dummy', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(300);
for (const tgt of [e, fly]) {
const hits = log.filter((x) => x.e === tgt && tagged(x, 'leizi2:thunder'));
assert.ok(hits.length >= 20, `T${tier}: ${hits.length} strikes on ${tgt.defId}`);
const first = Math.round((hits[0].t - 0.15) / COUNT_IV);
assert.ok(first >= 2 && first <= 16, `T${tier}: the first after ${first} counts`);
approx(hits[0].t - 0.15, first * COUNT_IV, `T${tier}: on a count, landing 0.15 s later`, 0.02);
for (let i = 1; i < hits.length; i++) {
const k = (hits[i].t - hits[i - 1].t) / COUNT_IV;
assert.ok(Math.abs(k - Math.round(k)) < 0.03 && Math.round(k) >= 8 && Math.round(k) <= 12, `T${tier}: ${k} counts apart`);
}
for (const x of hits) { approx(x.amount, x.atk * x.mul, `T${tier}: 100 % ATK`); assert.equal(x.type, 'arts'); approx(x.mul, 1, 'no skill: ×1'); }
}
assert.equal(log.filter((x) => x.e === off).length, 0, `T${tier}: (10,7) is off her 1-2`);
done(h);
}
});
test('T1: ×1.07 (LIB-X stage 3: ×1.13) on everything she deals while a skill runs — an atkScaleMul buff for the skill\'s time', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, LIBX], [6, true, LIBX]]) {
const [tier, elite, mod] = f;
const up = tOf(tier, elite, mod, 0)['atk_scale[skill_up]'];
assert.equal(up, elite && mod === LIBX && tier === 6 ? 1.13 : 1.07, label(f));
const { h, u, log } = field({ tier, elite, mod, skill: 1 });
approx(u.s.atkScaleMul, 1, `${label(f)}: off`);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2));
approx(u.s.atkScaleMul, up, `${label(f)}: on`);
h.run(4);
const atk = log.filter((x) => x.isAttack);
assert.ok(atk.length > 0, `${label(f)}: attacks`);
for (const x of atk) approx(x.amount, x.atk * skillOf(tier, elite, S2).bb['attack@atk_scale_s2'] * up, `${label(f)}: S2 attack × ${up}`);
h.runUntil(() => !u.skill.active, 40);
approx(u.s.atkScaleMul, 1, `${label(f)}: off again`);
done(h);
}
});
test('T2 追责: every cast strikes each low tile of her range ring by ring from her tile (0.25 s apart; S1 0.07 s), landing 0.15 s later: 100 % ATK × skill bonus arts and 2 s 战栗 on every enemy there, air units too', () => {
const t1 = tOf(5, false, null, 1);
assert.deepEqual([t1.atk_scale_t2, t1.not_combat], [1, 2]);
for (const [skill, gap, pos, d] of [[2, 0.25, [10, 7], 2], [0, 0.07, [10, 6], 1], [2, 0.25, [11, 6], 2]]) {
const { h, u, log } = field({ tier: 5, skill });
const e = h.spawn(pos[0] === 11 ? 'enemy_fly' : 'enemy_dummy', { pos });
u.skill.gainSp(999);
if (skill === 0) h.spawn('enemy_dummy', { pos: [11, 5] }); // S1 (SEARCH) needs a ground enemy on her 1-2
assert.ok(h.runUntil(() => u.skill.active, 2), `S${skill + 1}: cast`);
const t0 = h.b.time;
h.run(1.2);
const hits = log.filter((x) => x.e === e && tagged(x, 'leizi2:accuse'));
assert.equal(hits.length, 1, `S${skill + 1}: one strike on ${pos}`);
approx(hits[0].t - t0, d * gap + 0.15, `S${skill + 1}: ring ${d}`, 0.04);
approx(hits[0].amount, hits[0].atk * 1.07, `S${skill + 1}: 100 % ATK × 1.07`);
assert.equal(hits[0].type, 'arts');
const tr = h.hooksOf('statusApplied').filter((c) => c.status === 'tremble' && c.target === e && c.source === u);
assert.equal(tr.length, 1, `S${skill + 1}: 战栗`);
approx(tr[0].duration, 2, 'for 2 s');
done(h);
}
// S2: one cast per low tile of the flood fill (19 on the flat stage at (10,5)) — each gives a +10 % stack at once
const { h, u } = field({ tier: 5, skill: 1 });
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2));
const low = u.rangeKeys.filter((k) => h.b.grid.isLow(Math.floor(k / COLS), k % COLS)).length;
assert.equal(low, 19, 'the flood fill');
h.run(0.8);
const st = u.findBuff('skill:leizi2:thunderAtk');
assert.ok(st && st.stacks >= 19 && st.stacks <= 25, `S2 stacks after the rings: ${st?.stacks}`);
done(h);
});
test('S1 浩气长存 (MANUAL, 3 charges, data SEARCH): a 0.5 s skill on the 3-19; 0.3 s in, a 3-2 to her left, front and right each hits every ground enemy on it for 260 % / 300 % ATK × 1.07 physical (one on her tile three times, none diagonal, no flyer)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, log } = field({ tier, elite, skill: 0, silence: true });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.bb['attack@atk_scale_s1'], sk.rangeId, u.skill.spCost, sk.initSp], ['SEARCH', 3, elite ? 3 : 2.6, '3-19', elite ? 13 : 14, 17], `T${tier}`);
const own = h.spawn('enemy_dummy', { pos: [10, 5] }), front = h.spawn('enemy_dummy', { pos: [10, 8] });
const left = h.spawn('enemy_dummy', { pos: [12, 5] }), right = h.spawn('enemy_dummy', { pos: [9, 5] });
const diag = h.spawn('enemy_dummy', { pos: [11, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] });
h.b.removeBuff(u, 'test:silence');
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
const t0 = h.b.time;
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: the 3-19`);
h.runUntil(() => !u.skill.active, 1);
approx(h.b.time - t0, 0.5, `T${tier}: 0.5 s`, 0.04);
const s1 = log.filter((x) => tagged(x, 'leizi2:s1'));
const n = (e) => s1.filter((x) => x.e === e).length;
assert.deepEqual([n(own), n(front), n(left), n(right), n(diag), n(fly)], [3, 1, 1, 1, 0, 0], `T${tier}: who is hit`);
for (const x of s1) {
approx(x.t - t0, 0.3, `T${tier}: 0.3 s in`, 0.04);
approx(x.amount, x.atk * sk.bb['attack@atk_scale_s1'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s1'] * 100} % × 1.07`);
assert.equal(x.type, 'phys');
}
assert.equal(log.filter((x) => x.isAttack).length, 0, `T${tier}: no normal attack`);
done(h);
}
});
test('S1: "充能耗尽前特性不重置" — the ramp is kept while a charge remains and reset by the last one', () => {
const { h, u } = field({ tier: 5, skill: 0, silence: true });
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(8.05);
u.skill.gainSp(999);
assert.equal(u.skill.charges, 3);
const ramp0 = u.trait.ramp;
approx(ramp0, 0.4, 'the ramp after 8 s');
h.b.removeBuff(u, 'test:silence');
assert.ok(h.runUntil(() => u.skill.activations === 1 && !u.skill.active, 2));
assert.ok(u.trait.ramp >= ramp0 - 1e-9, `kept after the first charge (${u.trait.ramp})`);
assert.ok(h.runUntil(() => u.skill.activations === 2 && !u.skill.active, 5));
assert.ok(u.trait.ramp >= ramp0 - 1e-9, `kept after the second (${u.trait.ramp})`);
approx(u.s.atk, u.base.atk * (1 + u.trait.ramp), 'ATK');
assert.ok(h.runUntil(() => u.skill.activations === 3 && !u.skill.active, 5));
assert.equal(u.skill.charges, 0);
assert.ok(u.trait.ramp < 0.06, `reset by the last (${u.trait.ramp})`);
done(h);
});
test('S2 正霆摄威 (MANUAL, data SEARCH): range = the low tiles 3 four-way steps from hers (high ground and the field\'s edge stop it), 3 targets with air units for 110 % / 125 % ATK × 1.07, +10 % ATK per lightning cast up to 25, all gone after 36 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb['attack@atk_scale_s2'], sk.bb['attack@max_target_s2'], sk.bb.thunder_atk, sk.bb.thunder_max_stack_cnt, sk.duration, sk.rangeId], [elite ? 1.25 : 1.1, 3, 0.1, 25, 36, null]);
const { h, u, log } = field({ tier, elite, skill: 1, row: 9, col: 3 });
assert.equal(u.skill.rule, 'SEARCH');
u.skill.gainSp(999);
const targets = [[9, 4], [10, 3], [9, 5]].map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`);
// from (9,3): row 8 is high, (10,2) / (11,2) / (12,2) are high, (9,2) is low (the goal), columns < 0 are off the field
const want = [];
for (let r = 9; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const t = h.b.grid.tile(r, c);
if (t.height === 'LOW' && Math.abs(r - 9) + Math.abs(c - 3) <= 3) want.push(`${r},${c}`);
}
const got = u.rangeKeys.map((k) => `${Math.floor(k / COLS)},${k % COLS}`).sort();
assert.deepEqual(got, want.sort(), `T${tier}: the flood fill`);
h.run(3);
const atk = log.filter((x) => x.isAttack);
const ids = new Set(atk.filter((x) => x.attackId === atk[0].attackId).map((x) => x.e));
assert.equal(ids.size, 3, `T${tier}: 3 targets`);
assert.ok(atk.some((x) => x.e === fly), `T${tier}: a flyer among them`);
for (const x of atk) approx(x.amount, x.atk * sk.bb['attack@atk_scale_s2'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s2'] * 100} % × 1.07`);
h.run(30);
const st = u.findBuff('skill:leizi2:thunderAtk');
assert.equal(st.stacks, 25, `T${tier}: 25 stacks at most`);
approx(u.s.atk, u.base.atk * (1 + u.trait.ramp + 2.5), `T${tier}: +250 %`);
h.runUntil(() => !u.skill.active, 10);
assert.equal(u.findBuff('skill:leizi2:thunderAtk'), null, `T${tier}: gone`);
assert.deepEqual([u.trait.ramp, u.liveRangeGrid], [0, formOf(tier, elite).rangeGrid], `T${tier}: the trait reset, 1-2 again`);
void targets;
done(h);
}
});
test('S3 天地通明 (MANUAL, data CUSTOM_RANGE on 2-1): range 2-1, attack interval 1.2 + 1.7 s, 210 % / 240 % ATK × 1.07 on the target and everyone within 1.65 (air too); then four currents', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.bb['attack@atk_scale_s3'], sk.bb.base_attack_time, sk.bb['attack@range_radius'], sk.bb['attack@atk_scale_current'], sk.bb.prob, sk.bb.not_combat, sk.duration, sk.rangeId],
[elite ? 2.4 : 2.1, 1.7, 1.65, elite ? 0.56 : 0.45, elite ? 0.1 : 0.07, 3, 24, '2-1']);
const { h, u, log } = field({ tier, elite, skill: 2 });
assert.equal(u.skill.rule, 'CUSTOM_RANGE');
u.skill.gainSp(999);
const fly0 = h.spawn('enemy_fly', { pos: [12, 5] }); // a flyer on the 2-1 casts it
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: a flyer on the 2-1`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 2-1`);
approx(u.s.interval, 2.9, `T${tier}: 2.9 s`);
const e = h.spawn('enemy_dummy', { pos: [10, 6] }), side = h.spawn('enemy_dummy', { pos: [10, 7] }), flyNear = h.spawn('enemy_fly', { pos: [11, 7] });
h.b.kill(fly0, null);
h.runUntil(() => log.some((x) => x.isAttack && x.e === e), 4);
const a0 = log.find((x) => x.isAttack && x.e === e);
const one = log.filter((x) => x.isAttack && x.attackId === a0.attackId);
assert.deepEqual(new Set(one.map((x) => x.e)), new Set([e, side, flyNear]), `T${tier}: the target and everyone within 1.65`);
for (const x of one) approx(x.amount, x.atk * sk.bb['attack@atk_scale_s3'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s3'] * 100} % × 1.07`);
done(h);
}
});
test('S3 currents: four from the target\'s spot (up, right, down, left; 0.28 s apart) at 1 tile/s for 3.1 s, bouncing off high ground and her own tile; 45 % / 56 % ATK × 1.07 arts on ground enemies of their tile, at most every 0.6 s per current; 战栗 7 % / 10 % for 3 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, log } = field({ tier, elite, skill: 2 });
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const fly = h.spawn('enemy_fly', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.active, 2));
h.runUntil(() => log.some((x) => x.isAttack && x.e === e), 4);
const t0 = log.find((x) => x.isAttack && x.e === e).t;
h.run(0.9);
const cur = u.mem.leiziCurrents.slice(0, 4);
assert.deepEqual(cur.map((c) => [c.dr, c.dc]), [[1, 0], [0, 1], [-1, 0], [0, -1]], `T${tier}: up, right, down, left`);
// spawned 0, 0.28, 0.56, 0.84 s after the hit, 1 tile/s: the up one has risen (now − t0), the left one (now − t0 − 0.84)
approx(cur[0].y, 10 + (h.b.time - t0), `T${tier}: the up current`, 0.03);
approx(cur[3].x, 7 - (h.b.time - t0 - 0.84), `T${tier}: the left current`, 0.05);
h.run(2.3);
// the left one bounced before her tile (10,5): never past x 5.5; the up one bounced off row 13 (high)
assert.ok(cur[3].x > 5.5 - 1e-9, `T${tier}: off her tile`);
assert.ok(cur[0].y < 12.5 + 1e-9 && cur[0].dr === -1, `T${tier}: off the high row 13`);
h.run(1);
assert.ok(!u.mem.leiziCurrents.some((c) => cur.includes(c)), `T${tier}: gone after 3.1 s`);
const zap = log.filter((x) => tagged(x, 'leizi2:current'));
assert.ok(zap.length > 0 && zap.every((x) => x.e !== fly), `T${tier}: ground only`);
for (const x of zap) { approx(x.amount, x.atk * sk.bb['attack@atk_scale_current'] * x.mul, `T${tier}: ${sk.bb['attack@atk_scale_current'] * 100} %`); assert.equal(x.type, 'arts'); }
approx(zap[0].mul, 1.07, `T${tier}: × 1.07 while S3 runs`);
done(h);
}
// 战栗: about prob of the current hits while S3 runs, 3 s each
const { h, u, log } = field({ tier: 6, elite: true, skill: 2, seed: 9 });
u.skill.gainSp(999);
for (const pos of [[10, 6], [10, 7], [9, 7], [11, 7], [10, 8], [12, 7], [9, 6]]) h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => u.skill.active, 2));
h.run(23);
const zaps = log.filter((x) => tagged(x, 'leizi2:current')).length;
const tr = h.hooksOf('statusApplied').filter((c) => c.status === 'tremble' && c.source === u && c.duration === 3);
assert.ok(zaps > 60, `${zaps} current hits`);
assert.ok(tr.length / zaps > 0.04 && tr.length / zaps < 0.18, `${tr.length} / ${zaps} ≈ 10 %`);
done(h);
});
+395
View File
@@ -0,0 +1,395 @@
// 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);
});