fix: 重置松果重新部署后的S2攻击成长
This commit is contained in:
1 parent
1db8e51023
commit
8ebdcf5d17
5 files changed
+120
-3
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
} }],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user