- op-shwaz.js 黑: 破甲箭头 (one roll per attack, raised by S1 / S2 / S3; ×atk_scale, then defDown on a landed hit; numbers from the base talent + ARC-Y's named change — the data folds ARC-Y's hidden trait talent into talent 0 at stage 1), 交叉火力 (field aura; ARC-X stage 3 "携带": her player's snipers for the battle), ARC-Y 正前方 ×1.05 undodgeable, S3 3-2 + interval +0.4; triggers from the data (S3 SKILL_RANGE — reported). - op-orchd2.js 焰狐龙梓兰: 三连击 normal attack cut to 33.3 % after DEF (PRTS 特性备注), 强击瓶专家 stacks per round, 翔虫机动 (respawn −15 / −18 s, ATK +15 / 20 % for 30 s on a redeploy at her mark), S1 刚射 / 刚连射 (charges, refund without a target, stun per arrow), S2 飞翔瞪射 (free cast at every deployment, 起飞, three volleys on the 4-4, landing on the 1-3, timed by the official skeleton), S3 龙之箭 (3 s charge, piercing arrow at 10 tiles/s, strikes every 0.25 tile, push). - op-bgsnow.js 鸿雪: S1 toggle + proc, S2 点题 (立即, 普通 + 2 × 持续伤害), S3 3-1 with 正前方3格 scale, ARC-Y as 黑's; her placed “打字机” pieces get their kit at her install (25 s life, 40 s redeploy × S2's 65 / 60 %, 5 DP; the skill copies; 弱点速记 DEF cut 18 / 23 %, ARC-X stage 3 28 / 33 % with 八格). - kits/index.js OPERATOR_KIT_FILES: one line per file. Tests: test/content/op_shwaz / op_orchd2 / op_bgsnow (23). - Golden: only the diy family moved — diy-003 … diy-006 now hold the 66 new 自选 configurations (黑, 焰狐龙梓兰, 鸿雪) in place of the prototype picks, diy-007 … diy-012 are new (the prototype battles moved down); every other family is byte-identical. test/golden/README.md: 12 diy battles, 61 of 156.
341 lines
22 KiB
JavaScript
341 lines
22 KiB
JavaScript
// test/content/op_orchd2.test.js — the 自选 operator kit of 焰狐龙梓兰 (char_1048_orchd2, 6★ 重射手; kit
|
||
// server/sim/content/kits/ops/op-orchd2.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 ARC-X
|
||
// “梓兰特制箭靶” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the skill clips' moments from the kit's
|
||
// CLIP (the official skeleton); the fidelity checklist of kits/README.md item by item.
|
||
// Run: node --test test/content/op_orchd2.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 } from '../../shared/diy.js';
|
||
import { CLIP, ARROW, NORMAL_HITS } from '../../server/sim/content/kits/ops/op-orchd2.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 ORCHD = 'char_1048_orchd2';
|
||
const FORMS = BACKUPS.units[ORCHD].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const X = 'uniequip_002_orchd2';
|
||
const S1 = 'skchr_orchd2_1', S2 = 'skchr_orchd2_2', S3 = 'skchr_orchd2_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);
|
||
/** The form's talents with the module's changes (by talentIndex — no hidden trait part here). */
|
||
const talentsOf = (tier, elite, mod) => {
|
||
const out = formOf(tier, elite).talents.map((t) => ({ ...t, bb: { ...t.bb }, bbStr: { ...t.bbStr } }));
|
||
for (const ch of modOf(tier, mod)?.talentChanges ?? []) {
|
||
const t = out.find((x) => x.index === ch.talentIndex);
|
||
Object.assign(t.bb, ch.bb); Object.assign(t.bbStr, ch.bbStr ?? {});
|
||
}
|
||
return out;
|
||
};
|
||
const tBb = (tier, elite, mod, key) => talentsOf(tier, elite, mod).find((t) => t.bb[key] != null).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_def: dummy('enemy_def', { def: 100 }),
|
||
enemy_heavy: dummy('enemy_heavy', { mass: 5 }), enemy_gun: dummy('enemy_gun', { atk: 50, range: 2.5, bat: 1 }),
|
||
};
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X].map((m) => [t, true, m]))];
|
||
const POWER = 'power_attack_count';
|
||
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'attack'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ORCHD, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const mine = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||
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'}`;
|
||
/** Damage events grouped by attack (attackId). */
|
||
const rounds = (list) => { const m = new Map(); for (const c of list) { const k = c.dmg.attackId; if (!m.has(k)) m.set(k, []); m.get(k).push(c); } return [...m.values()]; };
|
||
|
||
test('焰狐龙梓兰 in every 自选 form: her kit, the form\'s stats + ARC-X, redeploy 70 − 25 (ARC-X) − 15 / 18 (翔虫机动) s, 3-6, a 三连击 sniper that hits air, the data triggers', () => {
|
||
assert.equal(OPERATOR_KITS[ORCHD], KITS[ORCHD]);
|
||
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], [ORCHD, 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`);
|
||
const rt = tBb(tier, elite, mod, 'respawn_time').respawn_time;
|
||
assert.equal(u.base.respawnTime, form.stats.respawnTime + (m?.attr.respawnTime ?? 0) + rt, `${label(f)}: redeploy time`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.hits, u.base.bat], [1, 'ranged', true, 'phys', NORMAL_HITS, 1.6], `${label(f)}: 重射手, 三连击`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`);
|
||
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
|
||
// S2 is cast at every deployment: airborne right after the first step
|
||
assert.equal(!!u.s.flags.liftoff, skill === 1, `${label(f)}: ground-targetable unless airborne`);
|
||
done(h);
|
||
}
|
||
}
|
||
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'SKILL_RANGE', 'DEFAULT']);
|
||
assert.deepEqual([55, 30, 27], [[5, false, null], [5, true, X], [6, true, X]].map(([t, e, m]) => formOf(t, e).stats.respawnTime + (modOf(t, m)?.attr.respawnTime ?? 0) + tBb(t, e, m, 'respawn_time').respawn_time));
|
||
assert.ok(KITTED_CHARS.includes(ORCHD) && [5, 6].every((t) => diyPool(t, { data: { chess: CHESS, backups: BACKUPS }, kitted: KITTED_CHARS }).includes(ORCHD)), 'offered at tiers 5 / 6');
|
||
});
|
||
|
||
test('trait (PRTS 特性备注): a normal attack is 3 hits of 100 % ATK, each ×0.333 after DEF; skill attacks are not cut; air units are hit', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
|
||
const cut = tBb(tier, elite, mod, 'attack@damage_scale')['attack@damage_scale'];
|
||
assert.equal(cut, 0.333);
|
||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||
u.skill.sp = 0;
|
||
const e = h.spawn('enemy_def', { pos: [10, 7] });
|
||
h.run(20);
|
||
const r = rounds(mine(h, u).filter((c) => c.dmg.isAttack && c.target === e));
|
||
assert.ok(r.length >= 5, `T${tier}: ${r.length} attacks`);
|
||
for (const g of r) {
|
||
assert.equal(g.length, NORMAL_HITS, `T${tier}: 三连击`);
|
||
for (const c of g) { approx(c.amount, (u.s.atk - 100) * cut, `T${tier}: (ATK − DEF) × 33.3 %`); assert.deepEqual([c.type, c.dmg.isSkill], ['phys', false]); }
|
||
}
|
||
h.b.kill(e, null);
|
||
const f = h.spawn('enemy_fly', { pos: [11, 6] });
|
||
h.run(5);
|
||
assert.ok(mine(h, u).some((c) => c.target === f), `T${tier}: the flyer is attacked`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 刚射 (MANUAL, charges 2 / 3, data DEFAULT): one charge — 4 arrows of 100 % / 120 %; a charge left — it is spent for 刚连射 1.167 s later, 5 arrows of 140 % / 160 %; no normal attack until the clip ends; no target for 刚连射 ⇒ the charge comes back; each 刚连射 arrow 20 % to stun 2 s', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
const pw = tBb(tier, elite, mod, POWER).power_attack_scale;
|
||
assert.deepEqual([sk.bb.atk_scale_1, sk.bb.atk_scale_2, sk.bb.stun_prob, sk.bb.stun, sk.maxChargeTime, sk.spCost, sk.initSp],
|
||
elite ? [1.2, 1.6, 0.2, 2, 3, 5, 10] : [1, 1.4, 0.2, 2, 2, 7, 7], `T${tier}`);
|
||
// (a) one charge: the 4 arrows only, the next normal attack once the clip and the attack interval are over
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 });
|
||
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.maxCharges, u.skill.charges], ['charges', 'DEFAULT', sk.maxChargeTime, Math.floor(sk.initSp / sk.spCost)], `T${tier}`);
|
||
u.skill.charges = 1;
|
||
h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
|
||
const t0 = h.b.time;
|
||
assert.equal(u.skill.charges, 0, `T${tier}: one charge spent`);
|
||
h.run(3);
|
||
const first = rounds(mine(h, u).filter((c) => c.dmg.isSkill));
|
||
assert.deepEqual(first.map((g) => g.length), [4], `T${tier}: one round of 4 arrows`);
|
||
for (const c of first[0]) approx(c.amount, u.s.atk * sk.bb.atk_scale_1 * pw, `T${tier}: ×atk_scale_1 (×强击瓶专家)`);
|
||
const plain = h.hooksOf('attack').filter((c) => c.attacker === u && !c.isSkill && c.t > t0 + 1e-6);
|
||
assert.ok(plain.length && plain[0].t >= t0 + Math.max(CLIP.s1End1 - CLIP.s1Shot, 1.6) - 0.04, `T${tier}: the next normal attack after the clip`);
|
||
done(h);
|
||
}
|
||
// (b) full charges: 刚连射 too, 1.167 s after the first shot, both charges spent, no normal attack inside the 2.0 s clip
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 });
|
||
u.skill.gainSp(999);
|
||
const ch0 = u.skill.charges;
|
||
h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
|
||
const t1 = h.b.time;
|
||
assert.equal(u.skill.charges, ch0 - 2, `T${tier}: two charges for 刚射 + 刚连射`);
|
||
h.run(3);
|
||
const shots = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
|
||
approx(shots[1].t - shots[0].t, CLIP.s1BurstShot - CLIP.s1Shot, `T${tier}: 1.167 s apart`, 0.04);
|
||
const sec = rounds(mine(h, u).filter((c) => c.dmg.isSkill));
|
||
assert.deepEqual(sec.map((g) => g.length), [4, 5], `T${tier}: 4 then 5 arrows`);
|
||
for (const c of sec[1]) approx(c.amount, u.s.atk * sk.bb.atk_scale_2 * pw, `T${tier}: ×atk_scale_2`);
|
||
const plain = h.hooksOf('attack').filter((c) => c.attacker === u && !c.isSkill);
|
||
assert.ok(plain.length && plain[0].t >= t1 + CLIP.s1End2 - CLIP.s1Shot - 0.04, `T${tier}: no normal attack inside the 2.0 s clip (${plain[0]?.t} vs ${t1})`);
|
||
done(h);
|
||
}
|
||
// (c) its target gone before 刚连射: nothing more, the charge back
|
||
{
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 });
|
||
u.skill.gainSp(999);
|
||
const ch0 = u.skill.charges;
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
|
||
h.b.kill(e, null);
|
||
h.run(1.5);
|
||
assert.equal(u.skill.charges, ch0 - 1, `T${tier}: the second charge is back`);
|
||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length, 1, `T${tier}: no 刚连射`);
|
||
assert.equal(u.findBuff('orchd2:lock'), null, `T${tier}: the skill stopped (no clip left)`);
|
||
done(h);
|
||
}
|
||
}
|
||
// (d) the stun: every 刚连射 arrow rolls stun_prob for `stun` s
|
||
const { h, u } = field({ tier: 6, elite: true, mod: X, skill: 0, seed: 9 });
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
for (let i = 0; i < 60; i++) {
|
||
u.skill.gainSp(999);
|
||
const n = u.skill.activations;
|
||
h.runUntil(() => u.skill.activations > n, 5);
|
||
h.run(3.5);
|
||
}
|
||
const arrows = mine(h, u).filter((c) => c.dmg.tags?.includes('orchd2:burst'));
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === e);
|
||
assert.equal(arrows.length, 60 * 5);
|
||
assert.ok(Math.abs(stuns.length / arrows.length - 0.2) < 0.06, `stun rate ${stuns.length} / ${arrows.length}`);
|
||
for (const c of stuns) assert.equal(c.duration, 2);
|
||
done(h);
|
||
});
|
||
|
||
test('S2 飞翔瞪射: cast free at every deployment (SP untouched); 起飞 (no ground enemy blocks / hits her) and no normal attack; volleys of 3 / 4 / 5 arrows ×135 % / 150 % at every target of the 4-4 (air too), landing ×255 % / 270 % on the 1-3; the first cast 0.833 s longer; then the data\'s SKILL_RANGE', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const pw = tBb(tier, elite, mod, POWER).power_attack_scale;
|
||
assert.deepEqual([sk.bb['attack@atk_scale_loop'], sk.bb['attack@atk_scale_end'], sk.duration, sk.maxChargeTime, sk.initSp, sk.spCost, sk.rangeId],
|
||
elite ? [1.5, 2.7, 4.2, 3, 14, 20, '4-4'] : [1.35, 2.55, 4.2, 2, 11, 20, '4-4'], `T${tier}`);
|
||
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 300, autoFinish: false, seed: 6, flags: { dpPerSec: 0, dpMax: 999 },
|
||
hooks: ['damaged', 'skillStart', 'skillEnd', 'attack'], captureNoisy: true,
|
||
enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_fly', pos: [11, 9] }, { key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_gun', pos: [11, 5] }],
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ORCHD, skillIndex: 1, uniEquipId: mod }, elite, row: 10, col: 5 }] });
|
||
h.step();
|
||
const u = h.unit(1);
|
||
const [front, fly, near, gun] = h.b.enemies;
|
||
assert.deepEqual([u.skill.active, h.hooksOf('skillStart')[0]?.reason, u.skill.charges, u.skill.sp], [true, 'deploy', 0, sk.initSp], `T${tier}: the free deploy cast`);
|
||
assert.ok(u.s.flags.liftoff && u.s.flags.blockFly, `T${tier}: airborne`);
|
||
const lead = CLIP.s2BeginFirst; // the first cast of the deployment
|
||
const landAt = lead + 3 * CLIP.s2Loop + CLIP.s2EndShot;
|
||
h.run(landAt + 0.3);
|
||
const shots = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||
assert.equal(shots.length, 4, `T${tier}: three volleys and the landing — no normal attack`);
|
||
shots.slice(0, 3).forEach((c, i) => approx(c.t, lead + i * CLIP.s2Loop + CLIP.s2LoopShot, `T${tier}: volley ${i + 1}`, 0.04));
|
||
approx(shots[3].t, landAt, `T${tier}: landing`, 0.04);
|
||
const dmg = mine(h, u);
|
||
const vol = rounds(dmg.filter((c) => c.dmg.attackId !== dmg.at(-1).dmg.attackId));
|
||
assert.deepEqual(vol.map((g) => g.length), [6, 8, 10], `T${tier}: 3 / 4 / 5 arrows at each of the two 4-4 targets`);
|
||
for (const g of vol) {
|
||
assert.deepEqual([...new Set(g.map((c) => c.target))].sort((a, b) => a.id - b.id), [front, fly].sort((a, b) => a.id - b.id), `T${tier}: the 4-4 targets (the flyer too)`);
|
||
for (const c of g) approx(c.amount, u.base.atk * sk.bb['attack@atk_scale_loop'] * pw, `T${tier}: ×loop`);
|
||
}
|
||
const land = dmg.filter((c) => c.dmg.attackId === dmg.at(-1).dmg.attackId);
|
||
assert.deepEqual(land.map((c) => c.target), [near], `T${tier}: the landing hits the 1-3 only`);
|
||
approx(land[0].amount, u.base.atk * sk.bb['attack@atk_scale_end'] * pw, `T${tier}: ×end`);
|
||
assert.ok(!u.s.flags.liftoff, `T${tier}: landed`);
|
||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === gun && c.target === u && c.t < landAt - 1e-6).length, 0, `T${tier}: the ground shooter could not hit her airborne`);
|
||
assert.ok(h.runUntil(() => !u.skill.active, 2));
|
||
approx(h.hooksOf('skillEnd')[0].t, sk.duration + (CLIP.s2BeginFirst - CLIP.s2Begin), `T${tier}: 4.2 + 0.833 s`, 0.05);
|
||
h.run(3);
|
||
assert.ok(h.hooksOf('damaged').some((c) => c.source === gun && c.target === u), `T${tier}: on the ground she is hit`);
|
||
// later casts: SKILL_RANGE on 4-4 (the 1-3 neighbour alone does not cast it), the 0.667 s start
|
||
for (const e of [front, fly]) h.b.kill(e, null);
|
||
u.skill.charges = 1;
|
||
u.skill.sp = 0;
|
||
h.run(4);
|
||
assert.equal(u.skill.activations, 1, `T${tier}: nobody on the 4-4, no cast`);
|
||
h.spawn('enemy_dummy', { pos: [9, 8] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: an enemy on the 4-4 casts it`);
|
||
const tc = h.b.time;
|
||
assert.equal(h.hooksOf('skillStart').at(-1).reason, 'SKILL_RANGE');
|
||
h.runUntil(() => !u.skill.active, 6);
|
||
approx(h.b.time - tc, sk.duration, `T${tier}: 4.2 s`, 0.06);
|
||
const v2 = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= tc - 1e-6);
|
||
approx(v2[0].t - tc, CLIP.s2Begin + CLIP.s2LoopShot, `T${tier}: first volley 0.833 s in`, 0.04);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 龙之箭: the arrow leaves 3.1 s after the cast (3.767 s for the first cast), flies straight ahead out of the field, hits every enemy on its way (air too, beyond her range) for ×240 % / 300 % physical then ×30 % / 40 % arts each 0.25 tile, pushes the light ones (中等力度) — not the off-line ones; no normal attack until its clip ends; her range stays', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const pw = tBb(tier, elite, mod, POWER).power_attack_scale;
|
||
assert.deepEqual([sk.bb.atk_scale, sk.bb.atk_scale_magic, sk.bb.wait_duration, sk.bb.dist_interval, sk.bb.force, sk.bb.knockback_duration, sk.maxChargeTime],
|
||
elite ? [3, 0.4, 1.5, 0.25, 1, 1, 2] : [2.4, 0.3, 1.5, 0.25, 1, 1, 1], `T${tier}`);
|
||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 2, col: 2 });
|
||
assert.deepEqual([u.skill.kind, u.skill.rule], [elite ? 'charges' : 'instant', 'DEFAULT']);
|
||
const light = h.spawn('enemy_dummy', { pos: [10, 3] });
|
||
const heavy = h.spawn('enemy_heavy', { pos: [10, 6] }); // outside her 3-6 range
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 8] });
|
||
const off = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||
const tc = h.b.time, n0 = mine(h, u).length;
|
||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: range unchanged`);
|
||
const shot = tc + CLIP.s3BeginFirst + sk.bb.wait_duration + CLIP.s3EndShot;
|
||
h.run(shot - tc + 2);
|
||
const arrow = mine(h, u).slice(n0).filter((c) => c.dmg.tags?.includes('orchd2:arrow'));
|
||
approx(Math.min(...arrow.map((c) => c.t)), shot + 1 / 30, `T${tier}: shot at +3.767 s (first cast; the tile next to her is reached the tick after)`, 0.04);
|
||
for (const e of [light, heavy, fly]) {
|
||
const on = arrow.filter((c) => c.target === e);
|
||
const ph = on.filter((c) => c.type === 'phys'), ar = on.filter((c) => c.type === 'arts');
|
||
assert.ok(ph.length >= 4 && ph.length === ar.length, `T${tier}: ${e.defId}: ${ph.length} strikes`);
|
||
for (const c of ph) approx(c.amount, u.base.atk * sk.bb.atk_scale * pw, `T${tier}: physical`);
|
||
for (const c of ar) approx(c.amount, u.base.atk * sk.bb.atk_scale_magic * pw, `T${tier}: arts`);
|
||
}
|
||
assert.equal(arrow.filter((c) => c.target === off).length, 0, `T${tier}: off the line, untouched`);
|
||
approx(light.x, 3 + 2.14, `T${tier}: the light one pushed 2.14 tiles (中力 − weight 0)`, 0.02);
|
||
assert.equal(heavy.x, 6, `T${tier}: weight 5 does not move`);
|
||
const plain = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > tc + 1e-6);
|
||
assert.ok(plain.every((c) => c.t >= tc + CLIP.s3BeginFirst + sk.bb.wait_duration + CLIP.s3End - 0.04), `T${tier}: no normal attack inside the clip`);
|
||
assert.ok(!u.skill.active, `T${tier}: the clip is over`);
|
||
// a later cast: 3.1 s
|
||
for (const e of [heavy, fly, off]) h.b.kill(e, null);
|
||
h.b.relocate?.(light, 10, 3);
|
||
light.x = 3; light.y = 10;
|
||
u.skill.gainSp(999);
|
||
const a0 = u.skill.activations;
|
||
assert.ok(h.runUntil(() => u.skill.activations > a0, 5));
|
||
const t2 = h.b.time, n1 = mine(h, u).length;
|
||
h.run(4.5);
|
||
const later = mine(h, u).slice(n1).filter((c) => c.dmg.tags?.includes('orchd2:arrow'));
|
||
approx(later[0].t - t2, CLIP.s3Begin + sk.bb.wait_duration + CLIP.s3EndShot + 1 / 30, `T${tier}: shot at +3.1 s`, 0.04);
|
||
done(h);
|
||
}
|
||
assert.deepEqual([ARROW.speed, ARROW.radius], [10, 0.5]);
|
||
});
|
||
|
||
test('T1 强击瓶专家: from the first cast of a deployment the next 50 attack rounds ×115 % (a 三连击 is one round), none before, none after; a redeploy starts over', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
|
||
const pb = tBb(tier, elite, mod, POWER);
|
||
assert.deepEqual([pb.power_attack_count, pb.power_attack_scale], [50, 1.15]);
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 1 });
|
||
const cut = tBb(tier, elite, mod, 'attack@damage_scale')['attack@damage_scale'];
|
||
u.skill.charges = 0; u.skill.sp = 0;
|
||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } });
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
h.run(8);
|
||
const before = rounds(mine(h, u));
|
||
assert.ok(before.length >= 4 && before.every((g) => g.every((c) => Math.abs(c.amount - u.s.atk * cut) < 1e-6)), `T${tier}: no bonus before a cast`);
|
||
u.skill.addCharge(1);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
|
||
const n0 = mine(h, u).length;
|
||
h.run(1.6 * 60);
|
||
const after = rounds(mine(h, u).slice(n0));
|
||
const boosted = after.filter((g) => g[0].amount > (g[0].dmg.isSkill ? u.s.atk * skillOf(tier, elite, S1).bb.atk_scale_1 : u.s.atk * cut) * 1.01);
|
||
assert.equal(boosted.length, 50, `T${tier}: 50 rounds`);
|
||
assert.ok(after.indexOf(boosted.at(-1)) === 49 && after.length > 51, `T${tier}: the first 50, then none`);
|
||
assert.equal(u.mem.orchdPower, 0);
|
||
h.b.retreat(u);
|
||
h.b.redeploy(u);
|
||
assert.equal(u.mem.orchdPower, 0, `T${tier}: a redeploy starts over`);
|
||
assert.ok(e.alive);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 翔虫机动: knocked out, she comes back after 70 − 15 s (ARC-X: −25 −15 / −18) on her tile with ATK +15 % (ARC-X stage 3: +20 %) for 30 s; never at the first deployment', () => {
|
||
for (const f of [[5, false, null], [5, true, X], [6, true, X]]) {
|
||
const [tier, elite, mod] = f;
|
||
const wb = tBb(tier, elite, mod, 'atk_duration');
|
||
assert.deepEqual([wb.atk, wb.atk_duration], [tier === 6 && mod === X ? 0.2 : 0.15, 30], label(f));
|
||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||
assert.equal(u.findBuff('talent:orchd2:wirebug'), null, `${label(f)}: not at the first deployment`);
|
||
h.b.players[0].dp = 999;
|
||
const at = [u.tileR, u.tileC];
|
||
h.b.kill(u, null);
|
||
approx(u.respawnAt - u.deathAt, u.base.respawnTime, label(f));
|
||
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1), `${label(f)}: back`);
|
||
approx(h.b.time - u.deathAt, u.base.respawnTime, `${label(f)}: after ${u.base.respawnTime} s`, 0.05);
|
||
assert.deepEqual([u.tileR, u.tileC], at, `${label(f)}: on her tile`);
|
||
const b = u.findBuff('talent:orchd2:wirebug');
|
||
assert.deepEqual(b?.mods, { atkPct: wb.atk }, label(f));
|
||
approx(b.timeLeft, wb.atk_duration, label(f), 0.01);
|
||
approx(u.s.atk, u.base.atk * (1 + wb.atk), label(f));
|
||
h.run(31);
|
||
assert.equal(u.findBuff('talent:orchd2:wirebug'), null, `${label(f)}: gone after 30 s`);
|
||
done(h);
|
||
}
|
||
});
|