Files
ark-stronghold-protocol/test/sim/feedback5-mystic-store.test.js
sganggs 8f62f3e360 feedback5 WE1: 秘术师 stores its energy at the attack check, not one interval later (GitHub #181)
PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时…若无有效目标且能量储存数未满,则改为储存一份
攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的情况下才进入待机状态", "攻击被打断且弹道未能成功生成的情况下,储存的
能量不会被消耗"; PRTS 异常效果 缴械 "秘术师储存能量同样无法进行".

- ai.js updateAlly: a ready unit (able to act, not disarmed) whose attack check finds no valid target — no target, or
  its profile's canAttack false (a 秘术师 kit's target rule: 深靛's bound enemies, 维伊's marked ones) — calls the
  profile's storeEnergy; a stored energy restarts the attack interval, a full store idles with the attack ready. A
  DEFAULT cast at the check that leaves no target stores in the same check.
- professions.js mystic: storeEnergy (up to bb times), releaseEnergy (performAttack, after beforeAttack: a cancelled
  shot keeps them) and hitsFn(b, u, info) — the energies leave with the attack and land with its main hit; the old
  separate store timer (it started only once the attack was ready and never restarted the interval) is gone.
  [ASSUMED] a redeployment holds no energy.
- 维伊 / 黑键 kits: their own tick stores become the kit's storeEnergy on the shared check; 深靛 / 维伊 no longer write
  hadTarget themselves.

Golden: roster-003, roster-021, roster-022, bond-preciShip-high (深靛 stores at her check); matches solo-FUNNY-1,
solo-NORMAL-2, solo-HARD-2, solo-ABYSS-1, solo-ABYSS-2, coop2-NORMAL-3, coop2-HARD-4, coop4-FUNNY-6, coop4-ABYSS-7,
coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-ABYSS-10-boosted, coop2-NORMAL-14-standins (each fields 深靛);
diy-032 (维伊) and diy-114 (黑键): the first store after a redeployment comes at the next tick's attack check instead of
the end of the redeploy tick (with the old tick store added back as a fallback the diy family matches the old digests).
fields, standins unchanged.

Tests: test/sim/feedback5-mystic-store.test.js (new, 4); op_veen, op_ebnhlz, kits_t1t2, kits_alt_t1 pass unchanged.
Docs: SIM.md §Professions (mystic row, profile hooks).
2026-10-06 14:53:56 +08:00

119 lines
6.5 KiB
JavaScript

// test/sim/feedback5-mystic-store.test.js — the 秘术师 energy store at the attack check (GitHub #181; PRTS 分支特性信息 秘术师
// "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存
// 一份攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的情况下才进入待机状态", "攻击被打断且弹道未能成功生成的情况下,储存的
// 能量不会被消耗"; PRTS 异常效果 缴械 "秘术师储存能量同样无法进行") — server/sim/professions.js installMystic (storeEnergy /
// releaseEnergy / hitsFn), server/sim/ai.js updateAlly / performAttack, 深靛's kit (a bound enemy is no valid target).
// Run: node --test test/sim/feedback5-mystic-store.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const ds = getDefaultSource();
const ID = 'chess_char_1_17_a'; // 深靛 (normal: trait times 3)
const ELITE = 'chess_char_1_17_b'; // 深靛 elite (MSC-X: times 4)
const TICK = 1 / 30;
const approx = (a, b, msg, eps = TICK + 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, ...o });
/** Her record without the talent's random bind (柔光缚目 prob 0): the binds of these tests are placed by hand. */
const noBind = (id) => {
const r = ds.rawChess(id);
return { [id]: { ...r, talents: r.talents.map((t) => ({ ...t, bb: { ...t.bb, prob: 0 } })) } };
};
function field({ id = ID, enemies = [], setup } = {}) {
return makeBattle({
seed: 3, autoFinish: false, timeLimit: 300, hooks: ['attack', 'damaged'], captureNoisy: true, setup,
defs: { chess: noBind(id), enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies,
});
}
const attacksOf = (h, u) => h.hooksOf('attack').filter((c) => c.attacker === u);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
test('no target: her first attack check stores at once (an attack action: the interval restarts), then one energy per attack interval up to the trait times; full ⇒ idle with the attack ready', () => {
for (const [id, max] of [[ID, 3], [ELITE, 4]]) {
const h = field({ id });
const u = h.unit(id);
h.step();
assert.equal(u.trait.stored, 1, `${id}: the attack is ready at deployment — stored at the first check`);
const iv = u.s.interval;
approx(u.atkCd, iv, `${id}: storing took the attack interval`, 1e-9);
let last = h.b.time;
for (let n = 2; n <= max; n++) {
assert.ok(h.runUntil(() => u.trait.stored === n, iv + 1), `${id}: energy ${n}`);
approx(h.b.time - last, iv, `${id}: energy ${n} one interval after the last`);
last = h.b.time;
}
h.run(3 * iv);
assert.equal(u.trait.stored, max, `${id}: at most ${max}`);
assert.equal(u.atkCd, 0, `${id}: full — idle, the attack ready`);
assert.equal(attacksOf(h, u).length, 0);
done(h);
}
});
test('a target in range while she holds energies: attacked at once, the energies leave with that attack and land with its main hit, one arts attack hit each', () => {
const h = field({ enemies: [{ key: 'e', pos: [10, 6], time: 12 }] });
const u = h.unit(ID);
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 15));
approx(h.b.time, 12, 'attacked in the tick the enemy appeared (the full store idled with the attack ready)', 2 * TICK);
assert.equal(u.trait.stored, 0, 'released with the attack (before the bolt lands)');
h.run(1);
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
assert.equal(hits.length, 1 + 3, 'the main hit + 3 energies');
assert.ok(hits.every((c) => c.type === 'arts' && c.dmg.attackId === hits[0].dmg.attackId), 'arts, one attack');
assert.ok(hits.every((c) => Math.abs(c.amount - hits[0].amount) < 1e-6), 'each energy hits as the main hit');
done(h);
});
test('a bind on her only target is no valid target: her next attack check stores an energy, and the freed enemy waits for the interval the store took (GitHub #181)', () => {
const h = field({ enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(ID);
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 2));
const t0 = h.b.time, iv = u.s.interval;
assert.equal(u.trait.stored, 0);
const [e] = h.enemies();
h.b.applyStatus(e, 'bind', { duration: 4 });
assert.ok(h.runUntil(() => u.trait.stored === 1, iv + 1));
approx(h.b.time - t0, iv, 'stored at her next attack check, during the bind');
assert.equal(attacksOf(h, u).length, 1, 'no attack at the bound enemy');
assert.ok(h.runUntil(() => attacksOf(h, u).length === 2, iv + 1));
approx(h.b.time - t0, 2 * iv, 'the bind ended at +4 s; her next check comes an interval after the store', 2 * TICK + 1e-6);
assert.equal(u.trait.stored, 0, 'the energy left with that attack');
h.run(1);
const n = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && c.t > t0 + 2 * iv - 0.1).length;
assert.equal(n, 2, 'the main hit + the stored energy');
done(h);
});
test('disarmed (缴械) or stunned: no storing; an attack cancelled before its shot keeps the energies; a redeployment holds none [ASSUMED]', () => {
const h = field();
const u = h.unit(ID);
h.step();
assert.equal(u.trait.stored, 1);
h.b.addBuff(u, { key: 'test:disarm', duration: 10, flags: { disarm: true } });
h.run(9.5);
assert.equal(u.trait.stored, 1, 'disarmed: "秘术师储存能量同样无法进行"');
h.run(1);
assert.equal(u.trait.stored, 2, 'stored at the first check after the disarm');
h.b.applyStatus(u, 'stun', { duration: 10 });
h.run(9.5);
assert.equal(u.trait.stored, 2, 'stunned: no attack check');
h.run(4);
assert.equal(u.trait.stored, 3);
// an attack whose shot never forms (beforeAttack empties it): the energies stay
let cancel = true;
h.b.on('beforeAttack', (ctx) => { if (ctx.attacker === u && cancel) ctx.targets = []; });
h.spawn('e', { pos: [10, 6] });
h.run(0.5);
assert.equal(attacksOf(h, u).length, 0);
assert.equal(u.trait.stored, 3, '"攻击被打断且弹道未能成功生成的情况下,储存的能量不会被消耗"');
cancel = false;
// knocked out holding them: the redeployment starts empty
h.b.retreat(u);
h.b.redeploy(u, { free: true });
assert.equal(u.trait.stored, 0, 'a redeployment holds no energy');
done(h);
});