feedback5 WC: a MANUAL skill whose running attack range strictly contains the own range casts as soon as an enemy is inside it (ACTIVE_RANGE, the owner's rule of 2026-10-05)

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.
This commit is contained in:
sganggs committed 2026-10-05 17:44:33 +08:00
1 parent ebfda7e657
commit 01fddd3021
23 files changed
+1921 -1708

No files matched your search

+2 -2
View File
@@ -91,7 +91,7 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
round: 3, wave, units: [
chess(1, 'chess_char_4_23_b', 9, 8), // 百炼嘉维尔 blocks the lower lane
chess(2, 'chess_char_4_20_b', 10, 4), // 远牙
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马
chess(3, 'chess_char_4_02_b', 11, 3), // 莫斯提马 (since 0.2.0 her S3 casts on its 3-15: at (11,4) it was spent early)
chess(4, 'chess_char_4_17_b', 12, 6), // 星熊 blocks the upper lane's way down
],
});
@@ -286,7 +286,7 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
round: 4, wave, spawns, units: [
chess(1, 'chess_char_4_23_b', 9, 3), // 百炼嘉维尔 near the goal
chess(2, 'chess_char_6_01_b', 10, 4), // 蕾缪安 (physical)
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马 (arts)
chess(3, 'chess_char_4_02_b', 10, 3), // 莫斯提马 (arts; her S3 casts on its 3-15 since 0.2.0: at (11,4) her arts landed 4 hits first)
],
});
let tr = null, phaseAt = null, kind = null, diedAt = null, formAt = null, maxBefore = null, firstFormLoss = null, earlyAttack = false;
+4 -2
View File
@@ -10,7 +10,9 @@
// 缩短) uses the official grid (skill rangeId, or the base grid grown by ability_range_forward_extend) while it runs —
// with every module of the loadout: a module's 攻击距离 widens it too, except where the skill's range ignores 攻击距离
// (信仰搅拌机 S3 with SPT-Y, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响") — and the DEFAULT trigger still reads the
// initial range (PRTS 卫戍协议/帮助 "技能就绪,且即将进行普通攻击"). A skill grid that only selects targets (荒芜拉普兰德
// initial range (PRTS 卫戍协议/帮助 "技能就绪,且即将进行普通攻击"), except where the owner's rule of 2026-10-05 makes it
// ACTIVE_RANGE (a running range that strictly contains the own one; not 烛煌's 4-11 vs 3-1 —
// test/sim/feedback5-active-range.test.js). A skill grid that only selects targets (荒芜拉普兰德
// S1: no rangeId, no 攻击范围 text) leaves the card on the unit's own range.
import { test } from 'node:test';
@@ -48,7 +50,7 @@ test('data: 烛煌 S3 众恶的焚场 is the default skill (index 2), skill rang
assert.equal(c.skill.index, 2);
assert.equal(c.rangeId, '3-1');
assert.equal(c.skill.rangeId, '4-11');
assert.equal(c.skill.trigger.rule, 'DEFAULT', 'an attack-range change keeps the basic strategy (§20.2)');
assert.equal(c.skill.trigger.rule, 'DEFAULT', 'an attack-range change keeps the basic strategy (§20.2); 4-11 does not contain 3-1, so no ACTIVE_RANGE');
}
assert.equal(C[BLAZE].skill.bb['attack@trigger_time'], 18);
assert.equal(C.chess_char_5_03_b.skill.bb['attack@trigger_time'], 21);
+93
View File
@@ -0,0 +1,93 @@
// 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);
});
+11 -9
View File
@@ -1,7 +1,8 @@
// test/sim/playtest6-skills.test.js — user playtest #6, workstream WE (skill triggers and buffs), with the real data and kits.
// #15 "技能范围比攻击范围大的技能在敌人进入技能范围时不触发": PRTS 卫戍协议/帮助 §作战阶段 技能操作 — "携带拥有技能范围的技能
// (非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能" (SKILL_RANGE);
// an attack-range change keeps the basic strategy; the class rows (重装 "不受技能范围影响,受到伤害时释放技能" …) cover every
// an attack-range change keeps the basic strategy (since 0.2.0 ACTIVE_RANGE when the running range strictly contains
// the own one — the owner's rule of 2026-10-05); the class rows (重装 "不受技能范围影响,受到伤害时释放技能" …) cover every
// MANUAL skill of the class; "自动操作具有3s冷却"; GDGLOW_SKILL_2 "全场存在可选目标时释放技能".
// #16 "古米的治疗队友的技能在身边队友受伤时并没触发,古米接敌之后才触发": 备用军粮 is 自动触发 (no 技能策略); PRTS 备注 "此技能
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式…直至古米完成一次普通攻击的治疗". Audit: 塞雷娅
@@ -87,20 +88,21 @@ test('#15 audit: every MANUAL chess skill with its own 技能范围 casts with a
assert.ok(n >= 20, `${n} SKILL_RANGE skills`);
});
test('#15 an attack-range change ("攻击范围扩大") keeps the basic strategy: 银灰 S3 waits for an enemy in his initial range', () => {
test('#15 an attack-range change ("攻击范围扩大") is officially the basic strategy; by the owner\'s rule of 2026-10-05 银灰 S3 (3-7 strictly containing his 3-12) casts on an enemy inside the expanded range (ACTIVE_RANGE)', () => {
const id = 'chess_char_4_22_a';
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 3 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_svrash_3');
assert.equal(u.skill.rule, 'DEFAULT');
assert.equal(u.skill.rule, 'ACTIVE_RANGE');
assert.equal(skillOf(id, 'skchr_svrash_3').trigger.rawRule, 'DEFAULT', 'the official strategy: the basic one (PRTS "非攻击距离增加")');
h.step();
fill(u);
const far = absoluteRangeKeys(u.def.skill.rangeGrid, 10, 3, u.dir, 0).find((k) => !u.rangeKeySet.has(k) && Math.floor(k / COLS) === 10);
h.spawn('enemy_d', { pos: [10, far % COLS] });
h.run(1);
assert.equal(u.skill.activations, 0, 'an enemy only in the expanded range: no cast (PRTS "非攻击距离增加")');
h.spawn('enemy_d', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'an enemy in the initial range: cast before the attack');
// a field tile of the expanded range outside his own (his row has none: 3-7 and 3-12 share it)
const far = absoluteRangeKeys(u.def.skill.rangeGrid, 10, 3, u.dir, 0).find((k) => !u.rangeKeySet.has(k) && Math.floor(k / COLS) >= 9 && Math.floor(k / COLS) <= 12);
assert.ok(far != null);
h.spawn('enemy_d', { pos: [Math.floor(far / COLS), far % COLS] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'an enemy only in the expanded range: cast, no attack needed');
assert.equal(starts(h, u)[0].reason, 'ACTIVE_RANGE');
done(h);
});