A deliberate deviation from the official 技能策略 (community report: skills with a larger range waited for the normal range). tools/build-data.mjs resolveTrigger: a MANUAL operator skill still on the basic strategy (no class row, no TRIGGER_DEVIATIONS entry) whose running range (its rangeId grid, or the own range grown by ability_range_forward_extend; never 被动效果:攻击范围扩大) strictly contains the operator's range gets rule ACTIVE_RANGE, customRangeGrid = that range, rawRule the official DEFAULT; validateAll checks it. server/sim/skills.js: ACTIVE_RANGE checks the DEFAULT condition (a targetable enemy or a blocked one; a heal skill an injured ally) on that range, grown by the permanent rangeExtend, every tick. Data rebuilt offline: only skills[].trigger changed (33 skills, 66 chess records, 9 stand-in form records). 深巡 S2 (§21.29), 重装 TAKE_DAMAGE, the SEARCH / CUSTOM_RANGE / SP_FULL rows, 烛煌 S2 / S3 (not containing her 3-1) and 引星棘刺 S3 keep their rule. Docs: DESIGN §5.6, DATA, SIM, PLAYING, kits README, notes. Tests: new test/sim/feedback5-active-range.test.js; data, playtest6-skills (银灰 S3), kits_alt_t2 (蒂比 S1), kits_t5 (百炼嘉维尔 S2) updated; feedback1-transform's fixtures move 莫斯提马 one tile (her S3 now casts on its 3-15). Golden: 27 roster, 25 bond, 21 field and 14 match scenarios moved — every one fields an operator with one of the 33 skills, which now casts when an enemy enters its running range instead of the initial range.
94 lines
5.6 KiB
JavaScript
94 lines
5.6 KiB
JavaScript
// test/sim/feedback5-active-range.test.js — the owner's rule of 2026-10-05 (a deliberate deviation, like DESIGN §21.29):
|
|
// a MANUAL skill on the basic strategy whose attack range while it runs strictly contains the operator's own range casts
|
|
// as soon as an enemy (a heal skill: an injured ally) is inside that larger range — trigger ACTIVE_RANGE, resolved by
|
|
// tools/build-data.mjs resolveTrigger (customRangeGrid = the running range) and checked every tick by server/sim/skills.js.
|
|
// Community report: 「有的干员开技能后的攻击范围比平时攻击范围大,但是怪走到平时的攻击范围内才会开技能」.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
|
import { getDefaultSource } from '../../server/sim/simdata.js';
|
|
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
|
|
import { COLS } from '../../server/sim/constants.js';
|
|
|
|
const ds = getDefaultSource();
|
|
const BASE = [[0, 0], [0, 1]]; // 1-1
|
|
const WIDE = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 1], [-1, 1]]; // strictly contains 1-1
|
|
const op = (o = {}) => chessRec({
|
|
id: 't_op', rangeGrid: BASE, ...o,
|
|
skill: { spCost: 1, initSp: 1, duration: 5, trigger: { rule: 'ACTIVE_RANGE', rawRule: 'DEFAULT', customRangeGrid: WIDE }, ...(o.skill || {}) },
|
|
});
|
|
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
|
const arena = (o = {}) => makeBattle({
|
|
defs: { chess: { t_op: op(o.chess), t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 1e4 } }) }, enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } },
|
|
units: [{ chessId: 't_op', row: 10, col: 4, dir: 'RIGHT' }, ...(o.units || [])], enemies: o.enemies || [],
|
|
hooks: ['skillStart', 'attack'], captureNoisy: true, autoFinish: false, timeLimit: 30, seed: 3, content: 'generic',
|
|
});
|
|
const casts = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
|
|
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
|
|
|
|
test('ACTIVE_RANGE: an enemy inside the running range only casts the skill at once (no attack); nobody, or an enemy outside it, does not', () => {
|
|
// (10,7) = [0,3]: the running range only — out of reach of the 1-1 attack
|
|
const h = arena({ enemies: [{ key: 'e', pos: [10, 7] }] });
|
|
const u = h.unit('t_op');
|
|
assert.equal(u.skill.rule, 'ACTIVE_RANGE');
|
|
assert.ok(h.runUntil(() => casts(h, u).length > 0, 2), 'cast');
|
|
assert.equal(casts(h, u)[0].reason, 'ACTIVE_RANGE');
|
|
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'before any attack (DEFAULT would wait for one)');
|
|
done(h);
|
|
const none = arena();
|
|
none.run(5);
|
|
assert.equal(casts(none, none.unit('t_op')).length, 0, 'no enemy: no cast');
|
|
done(none);
|
|
const far = arena({ enemies: [{ key: 'e', pos: [10, 8] }] }); // [0,4]: outside the running range too
|
|
far.run(5);
|
|
assert.equal(casts(far, far.unit('t_op')).length, 0, 'an enemy outside the running range: no cast');
|
|
done(far);
|
|
});
|
|
|
|
test('ACTIVE_RANGE: the DEFAULT condition on the larger range — no flyer for a ground-only unit; the permanent 攻击距离 grows it', () => {
|
|
const fly = arena({ enemies: [{ key: 'f', pos: [10, 7], route: 2 }] });
|
|
fly.run(5);
|
|
assert.equal(casts(fly, fly.unit('t_op')).length, 0, 'a melee unit cannot target the flyer: no cast');
|
|
done(fly);
|
|
// [0,4] is in the running range grown by a permanent rangeExtend +1 (modules / talents "攻击距离+1": baseRangeExtend)
|
|
const ext = arena({ enemies: [{ key: 'e', pos: [10, 8] }] });
|
|
const u = ext.unit('t_op');
|
|
ext.step();
|
|
ext.b.addBuff(u, { key: 'test:range', mods: { rangeExtend: 1 }, persist: true });
|
|
assert.ok(ext.runUntil(() => casts(ext, u).length > 0, 2), 'cast on the grown range');
|
|
done(ext);
|
|
});
|
|
|
|
test('ACTIVE_RANGE heal skill: an injured ally inside the running range only casts it; a healthy one does not', () => {
|
|
const medic = { profession: 'MEDIC', subProfessionId: 'physician', rangeGrid: BASE, skill: { trigger: { rule: 'ACTIVE_RANGE', rawRule: 'DEFAULT', customRangeGrid: WIDE } } };
|
|
const h = arena({ chess: medic, units: [{ chessId: 't_ally', row: 10, col: 7 }] });
|
|
const u = h.unit('t_op'), a = h.unit('t_ally');
|
|
h.run(3);
|
|
assert.equal(casts(h, u).length, 0, 'nobody to heal: no cast');
|
|
a.hp = a.s.maxHp / 2;
|
|
assert.ok(h.runUntil(() => casts(h, u).length > 0, 2), 'an injured ally at [0,3]: cast');
|
|
done(h);
|
|
});
|
|
|
|
test('real data: 莫斯提马 S3 序时之匙 (3-15 over her 3-6) casts on its running range; 白面鸮 S2 脑啡肽 (y-7 over y-2, a heal) resolves to it too', () => {
|
|
const MOST = 'chess_char_4_02_a', PLOSIS = 'chess_char_4_21_a';
|
|
const d = ds.getChess(MOST);
|
|
assert.equal(d.skill.trigger.rule, 'ACTIVE_RANGE');
|
|
assert.equal(ds.getChess(PLOSIS).skill.trigger.rule, 'ACTIVE_RANGE');
|
|
// an enemy on a field tile of 3-15 that 3-6 lacks (she stands at (10,3) facing right)
|
|
const base = new Set(absoluteRangeKeys(d.rangeGrid, 10, 3, 'RIGHT', 0));
|
|
const k = absoluteRangeKeys(d.skill.trigger.grid, 10, 3, 'RIGHT', 0).find((x) => !base.has(x) && ((x / COLS) | 0) >= 9 && ((x / COLS) | 0) <= 12);
|
|
assert.ok(k != null);
|
|
const h = makeBattle({
|
|
defs: { enemies: { e: dummy('e') } }, units: [{ chessId: MOST, row: 10, col: 3, dir: 'RIGHT' }],
|
|
enemies: [{ key: 'e', pos: [(k / COLS) | 0, k % COLS] }], hooks: ['skillStart'], autoFinish: false, timeLimit: 30,
|
|
});
|
|
const u = h.unit(MOST);
|
|
h.step();
|
|
u.skill.gainSp(1000);
|
|
assert.ok(h.runUntil(() => u.skill.active, 2), '莫斯提马 S3 cast');
|
|
assert.equal(casts(h, u)[0].reason, 'ACTIVE_RANGE');
|
|
done(h);
|
|
});
|