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:
1 parent
8252be248f
commit
6f4a19a5c5
4 files changed
+92
-7
No files matched your search
+3
-1
@@ -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).
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user