feedback3 WA: a stunned / frozen operator keeps its natural SP recovery (only 阻回 stops it) — 洛洛's S2 self-stun no longer freezes her SP

This commit is contained in:
sganggs committed 2026-10-04 17:19:41 +08:00
1 parent 8252be248f
commit 6f4a19a5c5
4 files changed
+92 -7

No files matched your search

+3 -1
View File
@@ -781,7 +781,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
### 7.1 Runtime rules (skills.js)
- SP types: `time` (+`s.spRecovery`/s), `attack` (+1 per attack), `hurt` (+1 per hit taken); starts at `initSp`; **no SP
gain while a duration/ammo/toggle skill is active, while stunned, or with the `noSp` flag**. Attack-type SP: attacks made
gain while a duration/ammo/toggle skill is active, or with the `noSp` flag** (阻回); a stunned / frozen / levitated unit
(`canAct` false) neither attacks nor casts, but its time SP keeps recovering (PRTS 技能: only 阻回 pauses the SP cooldown;
PRTS 异常效果 晕眩 names no SP effect — community report #18). Attack-type SP: attacks made
by the skill (the pending "next attack" of an instant/charge skill, every shot of a timed skill including the one that
ends it) recover nothing, so a cost-N skill fires every **N+1** attacks (AK). Charges (`maxChargeTime > 1`):
SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full).
+8 -4
View File
@@ -1,8 +1,9 @@
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
//
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
// gain of any kind — time, attack, hurt or granted).
// No SP gain while a duration/ammo/toggle skill is active, or while the unit has the noSp flag (阻回: no SP gain of any
// kind — time, attack, hurt or granted). A stunned / frozen / levitated unit (canAct false) neither attacks nor casts, but
// its time SP keeps recovering (PRTS: only 阻回 pauses the SP cooldown; 晕眩 does not — community report #18).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger):
// DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by
@@ -274,11 +275,14 @@ export class SkillRuntime {
} else if (this.active && this.kind === 'passive' && this.spec.onTick) {
this._call('onTick', { dt });
}
if (!u.canAct) return;
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp) {
// natural SP recovery stops only under 阻回 (noSp; a running timed skill holds it too) — not while 晕眩 / 冻结 / 浮空
// keep the unit from acting: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停"; PRTS 异常效果 STUNNED
// "无法攻击、释放技能、阻挡敌人类单位" says nothing of SP (community report #18: 洛洛's S2 self-stun froze her SP)
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp && u.alive && u.deployed && !u.hidden) {
const rate = u.s.spRecovery;
if (rate > 0) this.gainSp(rate * dt, 'time');
}
if (!u.canAct) return;
// a DEFAULT cast bound to an ally condition (塞雷娅 S1) replaces the attack about to be made: should the condition
// have failed before that attack (the ally healed meanwhile), the cast is withdrawn — no heal mode stays behind
if (this.pending && this.triggerAllies && this.rule !== 'SKILL_RANGE' && !this._allyTriggerSatisfied()) {
+5 -2
View File
@@ -33,7 +33,7 @@ test('stun stops enemy movement and attacks; immunity honoured; statusApplied fi
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 0));
});
test('stunned operator neither attacks nor gains SP; silence blocks skill activation', () => {
test('stunned operator neither attacks nor casts but keeps its natural SP recovery; silence blocks skill activation', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ skill: { spCost: 5, initSp: 0, duration: 3, bb: { atk: 0.5 } } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
@@ -42,9 +42,12 @@ test('stunned operator neither attacks nor gains SP; silence blocks skill activa
const u = h.unit('t_guard');
h.b.applyStatus(u, 'stun', { duration: 2 });
const atk0 = u.stats.attacks;
const sp0 = u.skill.sp;
h.run(1.9);
assert.equal(u.stats.attacks, atk0);
assert.ok(u.skill.sp < 0.2);
// PRTS 技能: only 阻回 pauses the SP cooldown ("在阻回状态或技力条已满时…计时暂停"); 晕眩 (PRTS 异常效果 STUNNED) does not
approx(u.skill.sp - sp0, 1.9, 0.02);
assert.equal(u.skill.activations, 0, 'no cast while stunned');
h.b.applyStatus(u, 'silence', { duration: 20 });
h.run(8);
assert.ok(u.stats.attacks > atk0, 'attacks after stun');
+76
View File
@@ -0,0 +1,76 @@
// test/sim/feedback3-stunsp.test.js — community report #18: 洛洛's S2 self-stun lasted the right time, but her SP did not
// recover while she was stunned (skills.js returned before the natural recovery when the unit could not act).
// Official: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停" — only 阻回 pauses natural recovery; PRTS 异常效果
// STUNNED "无法攻击、释放技能、阻挡敌人类单位" (gamedata_const ba.stun "无法移动、阻挡、攻击及使用技能") names no SP effect.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`);
const dummy = enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 });
test('洛洛 S2 自负此轭: the data matches skill_table (lv 4 / 7), the stun lasts the overload, and her SP recovers while she is stunned', () => {
const ds = getDefaultSource();
// skill_table skchr_rockr_2: level 4 duration 40, spCost 45, initSp 15, atk 0.4, attack_speed 45, scale 1.6; level 7
// (the elite piece) 40 / 40 / 20, 0.5 / 60 / 1.7 — the chess status skillLevel 4 / 7
for (const [id, lv, cost, init, atk, aspd, scale] of [['chess_char_2_10_a', 4, 45, 15, 0.4, 45, 1.6], ['chess_char_2_10_b', 7, 40, 20, 0.5, 60, 1.7]]) {
const sk = ds.rawChess(id).skill;
assert.equal(sk.skillId, 'skchr_rockr_2', id);
assert.equal(sk.level, lv, id);
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.attack_speed, sk.bb.scale], [40, cost, init, atk, aspd, scale], id);
}
const h = makeBattle({
defs: { enemies: { e_dummy: dummy } }, units: [{ chessId: 'chess_char_2_10_a', row: 10, col: 4, carryState: { sp: 999 } }],
enemies: [{ key: 'e_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 200, hooks: ['statusApplied', 'skillStart'],
});
const u = h.unit('chess_char_2_10_a');
h.step();
assert.ok(u.skill.active, 'cast at once');
h.runUntil(() => !u.skill.active, 45);
h.step();
const stun = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u);
close(stun.duration, 20, 0.05, 'stunned as long as the overload (the second half of the 40 s)');
assert.ok(!u.canAct);
const sp0 = u.skill.sp;
assert.ok(sp0 < 0.2, `the cast spent the SP, got ${sp0}`);
h.run(10);
assert.ok(!u.canAct, 'still stunned');
close(u.skill.sp - sp0, 10, 0.1, 'SP +1/s while stunned (it stayed at 0 before)');
h.run(10.5);
assert.ok(u.canAct, 'the stun is over after 20 s');
close(u.skill.sp, 20.5, 0.2);
// ready 45 s after the skill end (not 45 s after the stun): cast again then
const casts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
assert.equal(casts(), 1);
h.run(25);
assert.equal(casts(), 2, 'recast 45 s after the end of the skill');
});
test('frozen or stunned operators keep their natural SP recovery; 阻回 (noSp) still stops it; no cast while stunned', () => {
const op = (id) => chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100 }, skill: { duration: 5, spCost: 30, initSp: 0, bb: { atk: 1 } } });
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { e_dummy: dummy } },
units: [{ chessId: 't_a', row: 9, col: 3 }, { chessId: 't_b', row: 10, col: 3 }, { chessId: 't_c', row: 11, col: 3, carryState: { sp: 25 } }],
enemies: [{ key: 'e_dummy', pos: [11, 4] }], content: 'generic', autoFinish: false, timeLimit: 60,
});
h.step();
const [a, b, c] = ['t_a', 't_b', 't_c'].map((id) => h.unit(id));
h.b.applyStatus(a, 'cold', { duration: 10 });
h.b.applyStatus(a, 'cold', { duration: 10 }); // cold on cold → 冻结 (3 s)
assert.ok(a.findBuff('freeze'), 'frozen');
h.b.addBuff(b, { key: 't:noSp', duration: 3, flags: { noSp: true } });
h.b.applyStatus(c, 'stun', { duration: 8 });
const [a0, b0] = [a.skill.sp, b.skill.sp];
h.run(2.9);
close(a.skill.sp - a0, 2.9, 0.05, 'frozen: +1/s');
close(b.skill.sp - b0, 0, 1e-9, '阻回: nothing');
// t_c fills its 30 SP while stunned (25 + 5 s) but casts only once the stun is over (no cast while stunned)
h.run(4.5);
assert.ok(!c.canAct && c.skill.ready && c.skill.activations === 0, 'ready, stunned, no cast');
h.run(1);
assert.ok(c.canAct);
h.run(1);
assert.equal(c.skill.activations, 1, 'cast once it can act again');
});