Files
ark-stronghold-protocol/test/content/op_veen.test.js
T
sganggs ba66c2c32e feedback5 O3: 自选 operator kits for 逻各斯, 真言 and 维伊 (every skill, talent, trait and module at stages 1 / 3), the engine hook palsyTrigger
- kits/ops/op-logos.js (中坚术师; CCR-Δ, CCR-Y), op-mantra.js (本源术师; PRI-X), op-veen.js (秘术师; MSC-Y), registered in
  OPERATOR_KIT_FILES one line per file; numbers from data/backups.json, mechanics from the PRTS 备注 (cited in each header),
  [ASSUMED] items marked there. Data triggers kept; 维伊 S1 (an AUTO self buff) fires at full SP (the owner's rule).
- server/sim/ai.js: enemyAttack emits `palsyTrigger { enemy, buff, keep }` when a 麻痹 stack interrupts an attack (PRTS 真言
  备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"); `keep` leaves the stack (真言 噤声限域). Additive: no listener, no change.
  docs/SIM.md §3 / §5 rows; test/sim/palsy-trigger.test.js.
- Tests: test/content/op_logos.test.js, op_mantra.test.js, op_veen.test.js (every form, skill, talent, module, range,
  trigger, targeting, damage type).
- Golden: only the diy family moved — diy-003 … diy-007 now field 逻各斯 / 真言 / 维伊 in every form × module × skill (60
  configurations; diy-003 … 006 held the prototypes before), the prototype picks moved to diy-008 … 011 (same packing, new
  ids); diy-001 / 002 (推进之王) and roster, bonds, fields, matches, standins byte-identical.
2026-10-06 00:04:18 +08:00

270 lines
17 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_veen.test.js — the 自选 operator kit of 维伊 (char_4226_veen, 6★ 秘术师; kit
// server/sim/content/kits/ops/op-veen.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 MSC-Y
// “军械库” 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_veen.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 VEEN = 'char_4226_veen';
const FORMS = BACKUPS.units[VEEN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const MSCY = 'uniequip_002_veen';
const S1 = 'skchr_veen_1', S2 = 'skchr_veen_2', S3 = 'skchr_veen_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 talentOf = (tier, elite, mod, i) => {
const m = elite ? modOf(tier, mod) : null;
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).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' }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, MSCY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
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 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: VEEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const warTicks = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && (c.dmg?.tags || []).includes('veenWar'));
const stored = (u) => ({ s: u.trait.veen?.s ?? 0, t: u.trait.veen?.t ?? 0 });
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Run until she holds `t` 转置能量 and `s` stored energies (no enemy on the field). */
function storeUp(h, u, t, s) {
assert.ok(h.runUntil(() => stored(u).t === t && stored(u).s === s, 60), `stores up to ${t}/${s}`);
}
test('维伊 in every 自选 form: her operator kit (all three skills authored), stats + MSC-Y attributes, 3-14 range, ranged arts every 3 s that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[VEEN], KITS[VEEN]);
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], [VEEN, 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.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.base.cost], [1, 'ranged', 'arts', true, 3, 27], `${label(f)}: 秘术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
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 her`);
done(h);
}
}
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].trait.bb], [1142, 1320, { times: 9, merge_cnt: 3 }]);
assert.deepEqual([modOf(5, MSCY).attr, modOf(6, MSCY).attr], [{ maxHp: 80, atk: 85 }, { maxHp: 190, atk: 120 }]);
});
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(VEEN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(VEEN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } } });
});
test('trait + T1: with no target her attack check stores one energy (an attack action: the interval restarts — PRTS 分支特性信息), 3 merge into a 转置能量, at most 3 of those (then she stops); ATK +10 % while she holds one, ASPD +15 while she holds none; MSC-Y: ASPD +30 while she holds any energy', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = talentOf(tier, elite, mod, 0);
assert.deepEqual(t0, { atk: 0.1, attack_speed: 15 }, label(f));
const msc = elite && mod === MSCY ? 30 : 0;
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.sp = 0;
assert.deepEqual(stored(u), { s: 1, t: 0 }, `${label(f)}: her first attack check (at deployment) stores at once`);
approx(u.s.aspd, 100 + 15 + msc, `${label(f)}: no 转置能量: ASPD +15 (MSC-Y +30 while holding)`);
approx(u.s.atk, u.base.atk, `${label(f)}: no ATK bonus`);
const t1 = h.b.time;
h.runUntil(() => stored(u).s === 2, 10);
approx(h.b.time - t1, 3 * 100 / 115, `${label(f)}: the next one an attack interval later (set at the store, before MSC-Y)`, 0.03);
const t2 = h.b.time;
h.runUntil(() => stored(u).t === 1, 10);
approx(h.b.time - t2, 3 * 100 / (115 + msc), `${label(f)}: then at the interval of ASPD ${115 + msc}`, 0.03);
storeUp(h, u, 1, 0);
approx(u.s.aspd, 100 + msc, `${label(f)}: a 转置能量: the ASPD +15 goes`);
approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`);
storeUp(h, u, 3, 0);
h.run(30);
assert.deepEqual(stored(u), { s: 0, t: 3 }, `${label(f)}: no more storing at 3 转置能量`);
done(h);
}
});
test('the volley: 转置能量 first (300 % ATK each), the main attack (100 %), then the stored energies (100 %) — one target, all landing together, each with the ATK it left with (+10 % while a 转置能量 was held)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 1 });
u.skill.sp = 0;
storeUp(h, u, 1, 2);
const atk = u.s.atk;
approx(atk, u.base.atk * 1.1, `T${tier}: holding a 转置能量`);
const e = h.spawn('enemy_fly', { pos: [10, 7] });
const a0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length > a0, 4);
h.step();
const hits = atkHits(h, u).slice(a0);
assert.deepEqual(hits.map((c) => Math.round(c.amount / atk * 100) / 100), [3, 1, 1, 1], `T${tier}: 300 % + 100 % + 2 × 100 % at the cached ATK`);
assert.ok(hits.every((c) => c.target === e && c.t === hits[0].t && c.dmg.attackId === hits[0].dmg.attackId && c.type === 'arts'), `T${tier}: one attack, one target (a flyer), together`);
assert.deepEqual(stored(u), { s: 0, t: 0 }, `T${tier}: spent`);
h.run(0.2);
approx(u.s.atk, u.base.atk, `T${tier}: the ATK bonus gone after the volley`);
done(h);
}
});
test('T2 战争技艺: each hit adds a stack (a 转置能量 3) of 90 arts / s for 5 s, at most 3 (MSC-Y stage 3: 120 / s, at most 4); she never targets a marked enemy — with only marked ones in range she stores energy', () => {
for (const f of [[5, false, null], [5, true, MSCY], [6, true, MSCY]]) {
const [tier, elite, mod] = f;
const t1 = talentOf(tier, elite, mod, 1);
const s3m = elite && mod === MSCY && tier === 6;
assert.deepEqual(t1, s3m ? { 'attack@duration': 5, 'attack@value': 120, 'attack@max_stack_cnt': 4 } : { 'attack@duration': 5, 'attack@value': 90, 'attack@max_stack_cnt': 3 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.sp = 0;
h.run(3.1);
u.trait.veen = null; // (start the check with nothing stored)
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
h.runUntil(() => atkHits(h, u).length > 0, 4);
h.step();
const war = a.findBuff(`veen:war:${u.id}`);
assert.ok(war && war.stacks === 1, `${label(f)}: 1 stack`);
h.run(1.02);
const ticks = warTicks(h, u, a);
assert.equal(ticks.length, 1, `${label(f)}: a tick per second`);
approx(ticks[0].amount, t1['attack@value'], `${label(f)}: ${t1['attack@value']} arts`);
// marked: not targeted — she stores instead while it is her only enemy in range
const n0 = atkHits(h, u).length;
h.run(2.9);
assert.equal(atkHits(h, u).length, n0, `${label(f)}: no attack on a marked enemy`);
assert.ok(stored(u).s >= 1, `${label(f)}: her next attack check stored an energy instead`);
// another enemy: targeted at once; the mark lapses 5 s after the last stack
const b = h.spawn('enemy_dummy', { pos: [9, 7] });
h.runUntil(() => atkHits(h, u).length > n0, 4);
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === b), `${label(f)}: the unmarked one`);
assert.ok(h.runUntil(() => !a.findBuff(`veen:war:${u.id}`), 6), `${label(f)}: 5 s`);
assert.equal(warTicks(h, u, a).length, 5, `${label(f)}: 5 ticks`);
done(h);
}
// a 转置能量 gives 3 stacks; the cap
for (const [tier, mod, cap] of [[6, null, 3], [6, MSCY, 4]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
u.skill.sp = 0;
storeUp(h, u, 2, 0);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.runUntil(() => atkHits(h, u).length >= 3, 4);
h.step();
assert.equal(e.findBuff(`veen:war:${u.id}`).stacks, cap, `T6 ${mod ?? 'none'}: 2 × 3 + 1 stacks capped at ${cap}`);
h.run(1.02);
approx(warTicks(h, u, e)[0].amount, talentOf(6, true, mod, 1)['attack@value'] * cap, `${cap} stacks`);
done(h);
}
});
test('S1 “自呼号生发” (AUTO): ASPD +60 / +75 for 18 s, cast as soon as its SP is full (31 / 29 from 7 / 9) — no enemy needed', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.attack_speed], ['SP_FULL', elite ? 29 : 31, elite ? 9 : 7, 18, elite ? 75 : 60], `T${tier}`);
h.run(sk.spCost - sk.initSp - 0.5);
assert.equal(u.skill.activations, 0);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: at full SP, no enemy`);
const base = u.s.aspd - sk.bb.attack_speed;
assert.ok(base === 115 || base === 100, `T${tier}: +${sk.bb.attack_speed} on top of ${base}`);
approx(u.skill.timeLeft, 18, `T${tier}: 18 s`, 0.01);
done(h);
}
});
test('S2 “以鲜血洗去” (MANUAL, data DEFAULT): 28 s, attack interval 3.0 − 0.5 s; each released shot +9 % / +11 % ATK and +6 / +7 ASPD (a 转置能量 3 stacks), at most 8 / 9 — later shots of a volley carry the stacks of the earlier ones', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const b = sk.bb;
const per = b['attack@veen_s_2_buff[stack].atk'], perAs = b['attack@veen_s_2_buff[stack].attack_speed'], max = b['attack@veen_s_2_buff[stack].max_stack_cnt'];
assert.deepEqual([sk.duration, b.base_attack_time, per, perAs, max], [28, -0.5, elite ? 0.11 : 0.09, elite ? 7 : 6, elite ? 9 : 8], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
assert.equal(u.skill.rule, 'DEFAULT');
u.skill.sp = 0;
storeUp(h, u, 1, 1);
const base = u.base.atk;
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
h.step(2);
approx(u.s.interval, 2.5 * 100 / u.s.aspd, `T${tier}: base interval 2.5 s`);
const hits = atkHits(h, u);
h.runUntil(() => atkHits(h, u).length >= 3, 4);
const v = atkHits(h, u).slice(0, 3);
// 转置能量 (0 stacks, +10 %), main (3 stacks), stored (4 stacks) — every one +10 % (a 转置能量 was held)
approx(v[0].amount, base * (1 + 0.1) * 3, `T${tier}: 转置能量 before any stack`);
approx(v[1].amount, base * (1 + 0.1 + 3 * per), `T${tier}: the main shot after 3`);
approx(v[2].amount, base * (1 + 0.1 + 4 * per), `T${tier}: the stored one after 4`);
assert.ok(hits.length <= 3);
approx(u.s.aspd, 100 + 15 + 5 * perAs, `T${tier}: 5 stacks of ASPD (and T1's +15 again)`);
h.run(25);
assert.equal(u.findBuff('veen:s2stack').mods.atkPct, per * max, `T${tier}: at most ${max}`);
h.runUntil(() => !u.skill.active, 10);
assert.equal(u.findBuff('veen:s2stack'), null, `T${tier}: gone with the skill`);
void e;
done(h);
}
});
test('S3 “用赤铁铭记” (MANUAL, data DEFAULT): 18 / 21 bullets, ASPD +110 / +140; an attack spends 1 bullet per stored energy and 3 per 转置能量 (none without), its stored shots deal nothing and its main hit bounces that many times — every 0.5 s within 1.7 tiles, a new enemy first — for 130 % / 140 % of the cached ATK, each with a 战争技艺 stack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const b = sk.bb;
assert.deepEqual([b['attack@trigger_time'], b.attack_speed, b['attack@bounce_atk_scale'], b['attack@base_atk_scale']], [elite ? 21 : 18, elite ? 140 : 110, elite ? 1.4 : 1.3, 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
assert.equal(u.skill.rule, 'DEFAULT');
u.skill.sp = 0;
storeUp(h, u, 1, 1);
const atk = u.s.atk;
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const near = h.spawn('enemy_fly', { pos: [10, 8.2] });
const far = h.spawn('enemy_dummy', { pos: [10, 12] }); // out of her range and 3.8 from the target
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
assert.deepEqual([u.skill.kind, u.skill.ammoMax], ['ammo', b['attack@trigger_time']], `T${tier}`);
approx(u.s.aspd, 100 + b.attack_speed + 15, `T${tier}: ASPD (+ T1's 15: the volley spent the 转置能量)`, 1e-9);
h.run(2.2);
// the first attack: 1 + 3 = 4 bullets, the main hit then 4 bounces (e ↔ near: a new enemy first, then back)
assert.equal(u.skill.ammoLeft, b['attack@trigger_time'] - 4, `T${tier}: 4 bullets`);
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 4);
const hits = atkHits(h, u);
const id = hits[0].dmg.attackId;
const of = hits.filter((c) => c.dmg.attackId === id);
assert.equal(of.length, 5, `T${tier}: main + 4 bounces, no stored-shot damage`);
approx(of[0].amount, atk * b['attack@base_atk_scale'], `T${tier}: the main hit`);
assert.deepEqual(of.map((c) => c.target.id), [e.id, near.id, e.id, near.id, e.id], `T${tier}: a new enemy first, then between them`);
for (const c of of.slice(1)) approx(c.amount, atk * b['attack@bounce_atk_scale'], `T${tier}: bounce at the cached ATK`);
for (let i = 2; i < of.length; i++) approx(of[i].t - of[i - 1].t, 0.5, `T${tier}: 0.5 s apart`, 0.08);
assert.ok(!of.some((c) => c.target === far), `T${tier}: 1.7 tiles at most`);
assert.equal(near.findBuff(`veen:war:${u.id}`).stacks, 2, `T${tier}: a 战争技艺 stack per bounce`);
// attacks without stored energy spend nothing; the skill ends when the bullets run out
const left = u.skill.ammoLeft;
h.run(1.5);
assert.ok(u.skill.ammoLeft <= left);
h.runUntil(() => !u.skill.active, 300);
assert.equal(u.skill.ammoLeft, 0, `T${tier}: ended out of bullets`);
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u)[0].reason, 'ammo');
done(h);
}
});