From 6f4a19a5c505f7721542b5dacffbf8d97bb49165 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:19:41 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WA:=20a=20stunned=20/=20frozen=20op?= =?UTF-8?q?erator=20keeps=20its=20natural=20SP=20recovery=20(only=20?= =?UTF-8?q?=E9=98=BB=E5=9B=9E=20stops=20it)=20=E2=80=94=20=E6=B4=9B?= =?UTF-8?q?=E6=B4=9B's=20S2=20self-stun=20no=20longer=20freezes=20her=20SP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 4 +- server/sim/skills.js | 12 +++-- test/sim/combat.test.js | 7 ++- test/sim/feedback3-stunsp.test.js | 76 +++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 test/sim/feedback3-stunsp.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 08f03bc1..eff403ca 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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). diff --git a/server/sim/skills.js b/server/sim/skills.js index f99c5029..591a710b 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -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()) { diff --git a/test/sim/combat.test.js b/test/sim/combat.test.js index 175850df..fac05af5 100644 --- a/test/sim/combat.test.js +++ b/test/sim/combat.test.js @@ -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'); diff --git a/test/sim/feedback3-stunsp.test.js b/test/sim/feedback3-stunsp.test.js new file mode 100644 index 00000000..b306144d --- /dev/null +++ b/test/sim/feedback3-stunsp.test.js @@ -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'); +});