- 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.
270 lines
17 KiB
JavaScript
270 lines
17 KiB
JavaScript
// 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);
|
||
}
|
||
});
|