Files
ark-stronghold-protocol/test/content/op_orchd2.test.js
T
sganggs 4075338517 feedback5 O6: 自选 operator kits for 黑, 焰狐龙梓兰 and 鸿雪 (+ her “打字机”) — every skill, talent, trait and module at every form
- 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.
2026-10-06 00:10:34 +08:00

341 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/content/op_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);
}
});