From 8ebdcf5d1787436f7f226380c4a13583c717e939 Mon Sep 17 00:00:00 2001 From: Xinglan233 <130824939+Xinglan233@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:55:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=8D=E7=BD=AE=E6=9D=BE=E6=9E=9C?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E9=83=A8=E7=BD=B2=E5=90=8E=E7=9A=84S2?= =?UTF-8?q?=E6=94=BB=E5=87=BB=E6=88=90=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 9 ++ docs/design/engine.md | 6 ++ docs/history/0.2.3.md | 13 +++ .../kits/ops/chess_char_3_10-pinecn.js | 11 ++- test/content/kits_pinecn.test.js | 84 +++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 test/content/kits_pinecn.test.js diff --git a/docs/SIM.md b/docs/SIM.md index d297a48e..819cff10 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1561,3 +1561,12 @@ yet — enemy content must pick one); 抵抗 covers the control statuses of `RES “余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG. + + +### Per-deployment skill counters + +An operator-specific ramp that resets on deployment uses its own `unit.mem` counter, cleared by its `deploy` hook. +Do not reset `SkillRuntime.activations`: it is the cumulative sequence used by skill lifecycle/reentry guards. +松果 S2 uses `pinecnS2Uses` this way (PRTS 松果 S2 备注); each new deployment starts at the first blackboard step, +while further casts within that deployment advance and cap normally. S1 and her deployment SP-recovery talent keep +their existing behavior. diff --git a/docs/design/engine.md b/docs/design/engine.md index 104a829f..4784b695 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -224,3 +224,9 @@ the hook-depth guard on scaled hit-count HP; the guard remains unchanged for oth as well as by its aura tick, including the first hit and immediately after the last companion leaves. Sources and the feedback items requiring no simulation change are recorded in [§28.25](../history/0.2.3.md#2825-community-feedback-of-2026-10-10). + + +**Pinecone S2 (松果 电能过载).** The ATK ramp counts casts since the current deployment and resets whenever she +is deployed again. Its counter belongs to that unit's kit; the engine's `SkillRuntime.activations` remains a +cumulative activation sequence. Source: [PRTS 松果](https://prts.wiki/w/松果), S2 备注; rank-specific steps are read +from `data/chess.json` blackboards. No mode rule carries this ramp through a new deployment. diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 5fd8b3f9..fc540d84 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -479,3 +479,16 @@ imports and typecheck pass. This round changes no production UI and does not rer the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged. - **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md (local identity recovery and handshake). + + +### 28.28 Pinecone S2 resets its ATK ramp on deployment — `kits/ops/chess_char_3_10-pinecn.js` + +- Cause: the kit selected its ATK step from `SkillRuntime.activations`, which counts all activations in the battle; + the first cast after automatic redeployment therefore used a later step. +- Source: [PRTS 松果](https://prts.wiki/w/松果), S2 备注: the count starts from this deployment. Rank-4/rank-7 steps + come from `data/chess.json` skill blackboards. The mode's redeployment/carry-state rules do not preserve this count. +- A unit-local S2 counter is cleared in her existing deploy hook. Growth within one deployment and the fourth-step + cap remain; S1, range, trigger, talent and the global cumulative activation sequence are unchanged. No new + [ASSUMED] rule. This touches only Pinecone's kit, not 凛冬's separate deployment lifecycle (issue #315). +- Regression: `test/content/kits_pinecn.test.js`, normal, elite without module and elite RPR-X; ramp/cap, later + paid redeployment, natural enemy knock-out and two copies with independent counters. These are model tests. diff --git a/server/sim/content/kits/ops/chess_char_3_10-pinecn.js b/server/sim/content/kits/ops/chess_char_3_10-pinecn.js index e7e4d544..1ca044e0 100644 --- a/server/sim/content/kits/ops/chess_char_3_10-pinecn.js +++ b/server/sim/content/kits/ops/chess_char_3_10-pinecn.js @@ -15,8 +15,9 @@ export default { const kit = { skill: { kind: 'duration', - onStart({ battle, unit, skill }) { - const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))]; + onStart({ battle, unit }) { + const n = (unit.mem.pinecnS2Uses = num(unit.mem.pinecnS2Uses) + 1); + const atk = steps[Math.min(steps.length - 1, n - 1)]; battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } }); }, onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); }, @@ -33,7 +34,11 @@ export default { }), talents: [{ install(battle, unit) { battle.on('deploy', (ctx) => { - if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } }); + if (ctx.unit !== unit) return; + // PRTS 松果 S2 备注 (https://prts.wiki/w/松果): the ramp counts uses since this deployment. + // Keep Skill.activations cumulative: the engine uses it as an activation sequence. + unit.mem.pinecnS2Uses = 0; + battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } }); }, { owner: unit }); } }], }; diff --git a/test/content/kits_pinecn.test.js b/test/content/kits_pinecn.test.js new file mode 100644 index 00000000..f1662579 --- /dev/null +++ b/test/content/kits_pinecn.test.js @@ -0,0 +1,84 @@ +// 松果 S2: the ATK ramp belongs to a deployment, while Skill.activations stays cumulative. +// Sources: PRTS 松果 S2 备注; data/chess.json rank-4 / rank-7 blackboards. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const FORMS = [ + { label: 'normal', id: 'chess_char_3_10_a', ramp: [0.25, 0.45, 0.65, 0.85, 0.85] }, + { label: 'elite without module', id: 'chess_char_3_10_b', moduleId: 'none', ramp: [0.4, 0.6, 0.8, 1, 1] }, + { label: 'elite RPR-X', id: 'chess_char_3_10_b', ramp: [0.4, 0.6, 0.8, 1, 1] }, +]; +const close = (actual, expected, msg) => assert.ok(Math.abs(actual - expected) < 1e-6, `${msg}: ${actual} ≈ ${expected}`); +const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }; +function field(form, { atk = 0, units = null } = {}) { + return makeBattle({ + seed: 5, timeLimit: 600, autoFinish: false, + flags: { startOpCooldown: 3, dpInit: 99, dpPerSec: 10, dpMax: 999 }, + units: units ?? [{ chessId: form.id, moduleId: form.moduleId, uid: 1, row: 10, col: 4 }], + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, atk, range: 5, bat: 2 }) } }, + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); +} + +for (const form of FORMS) { + test(`松果 S2 ${form.label}: natural casts grow within one deployment and stop at the fourth step`, () => { + const h = field(form), u = h.unit(1); + h.step(); + assert.equal(u.skill.id, 'skchr_pinecn_2'); + const wide = u.rangeKeys.length; + for (const [i, atkPct] of form.ramp.entries()) { + assert.ok(h.runUntil(() => u.skill.activations === i + 1, 60), `cast ${i + 1}`); + close(u.s.atk, u.base.atk * (1 + atkPct), `cast ${i + 1} ATK`); + assert.ok(u.rangeKeys.length < wide, 'the skill still shortens her range'); + assert.ok(h.runUntil(() => !u.skill.active, 21), 'the duration ends'); + close(u.s.atk, u.base.atk, 'ATK restored'); + assert.equal(u.rangeKeys.length, wide, 'range restored'); + } + done(h); + }); + + test(`松果 S2 ${form.label}: a knock-out during the fourth cast restarts the ramp on redeployment`, () => { + const h = field(form), u = h.unit(1); + assert.ok(h.runUntil(() => u.skill.activations === 4, 240), 'four natural casts'); + close(u.s.atk, u.base.atk * (1 + form.ramp[3]), 'at the cap before the knock-out'); + h.b.kill(u, h.enemy('enemy_dummy')); + assert.equal(u.findBuff('skill:pinecn_atk'), null, 'the active skill buff leaves with her'); + assert.ok(h.runUntil(() => u.alive && u.deployed, 90), 'automatic paid redeployment'); + assert.equal(u.skill.activations, 4, 'the engine activation sequence is still cumulative'); + assert.ok(h.runUntil(() => u.skill.activations === 5, 30), 'first natural cast after redeployment'); + close(u.s.atk, u.base.atk * (1 + form.ramp[0]), 'first ATK step again'); + assert.ok(h.runUntil(() => u.skill.activations === 6, 60), 'second natural cast after redeployment'); + close(u.s.atk, u.base.atk * (1 + form.ramp[1]), 'second ATK step again'); + done(h); + }); +} + +test('松果 S2 normal: natural enemy damage, knock-out and redeployment give +25% on both first casts', () => { + const h = field(FORMS[0], { atk: 225 }), u = h.unit(1); + assert.ok(h.runUntil(() => u.skill.activations === 1, 30), 'first cast'); + close(u.s.atk, 735, '588 × 1.25'); + assert.ok(h.runUntil(() => !u.alive, 40), 'enemy attacks knock her out'); + assert.ok(h.runUntil(() => u.alive && u.deployed, 90), 'automatically redeployed'); + assert.equal(u.skill.activations, 1, 'the earlier cast remains in the engine sequence'); + assert.ok(h.runUntil(() => u.skill.activations === 2, 30), 'next natural cast'); + close(u.s.atk, 735, 'new deployment: 588 × 1.25'); + done(h); +}); + +test('松果 S2: redeploying one piece does not restart another piece\'s ramp', () => { + const h = field(FORMS[0], { units: [ + { chessId: FORMS[0].id, uid: 1, row: 10, col: 4 }, + { chessId: FORMS[0].id, uid: 2, row: 11, col: 4 }, + ] }); + const a = h.unit(1), b = h.unit(2); + assert.ok(h.runUntil(() => a.skill.activations === 2 && b.skill.activations === 2, 90)); + h.b.retreat(a, { reason: 'raid' }); + assert.ok(h.b.redeploy(a, { free: true }), 'only the first piece redeployed'); + close(b.s.atk, b.base.atk * 1.45, 'the second piece keeps its running second step'); + assert.ok(h.runUntil(() => a.skill.activations === 3, 30)); + close(a.s.atk, a.base.atk * 1.25, 'the first piece starts again'); + assert.ok(h.runUntil(() => b.skill.activations === 3, 60)); + close(b.s.atk, b.base.atk * 1.65, 'the second piece progresses to its third step'); + done(h); +});