feedback7 R22-GHSIM: #298 rework — the one-interval gap of an instant skill with no attack of its own sits on the skill (nextCastAt), not on the operator's attack cooldown: its casts no longer replace the attacks / heals that were due; golden: back to 2 moves against the code before #298 (roster-033, roster-034)

Review of fb7-gh-sim (MERGE AFTER FIXES, high): 4128965a wrote u.atkCd after such a cast and gated the tick casts on it,
so each cast took the attack that was due — 度算浪波 with 迅捷: 15 casts, 0 attacks in 20 s (silenced: 15 attacks);
冲锋号令·γ型 and 医疗无人机 lost one attack / heal per cast; after an idle cast the next attack waited the armed cooldown.
PRTS 度算浪波 is an automatic instant throw, not an attack.

Now (skills.js): such a cast (instant / charges, no spec.attack, not the DEFAULT cast right before an attack) sets
skill.nextCastAt = now + the unit's attack interval, and the casts checked in skill.tick wait for it; the attack cooldown
is not touched (reset on deployment, like opReadyAt). Measured (20 s, 引星棘刺 S1, 迅捷 229 layers): 12 casts — the first at
4 s as SP fills (#124), then one per 1.36 s — and 14 attacks, as many as silenced; without 迅捷 3 casts and 14 attacks; an
idle cast does not delay the next attack (1 tick). [ASSUMED] one attack interval stays.

Tests: bonds_addon «迅捷 refills 引星棘刺 S1 at once …» now asserts the casts take no attack (attacks = the silenced run's;
fails on 4128965a: 0 vs 4, and before it: 180 casts in 6 s); op_wang waits skill.nextCastAt; playtest6-skills (锏) back to
its original timing. test/content + test/sim: 2835 pass. Docs: SIM.md §7, PLAYING.md 技能自动释放 (no attack is taken).

Golden: against 4128965a's files roster (21) bonds (9) fields (9) matches (16) standins (6) diy (22) scenarios move back; against the
same code with the gap disabled only roster-033 and roster-034 differ (引星棘刺 S1 recast within one interval) — the
84-scenario move of 4128965a came from the attack cooldown, now gone.
This commit is contained in:
sganggs committed 2026-10-08 21:06:45 +08:00
1 parent 38d4c3e4b5
commit db29acf907
12 files changed
+1528 -1493

No files matched your search

+13 -11
View File
@@ -71,9 +71,9 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
assert.equal(off.s_x, 0);
});
test('迅捷 refills 引星棘刺 S1 at once: the instant cast still takes one attack cycle each, so attack speed paces it (GitHub #298)', () => {
test('迅捷 refills 引星棘刺 S1 at once: it is cast at most once per attack interval, and her attacks go on untouched (GitHub #298)', () => {
// S1 度算浪波: AUTO, no duration, no attack of its own, SP_FULL, cost 6; 迅捷 refunds 12 + 15 SP at ≥ 40 layers
const casts = (aspd = 0, enemy = true) => {
const run = ({ aspd = 0, enemy = true, silence = false } = {}) => {
const h = makeBattle({
seed: 1, bonds: { swiftShip: bond(2, 229, 3) }, autoFinish: false, timeLimit: 30,
units: [{ chessId: 'chess_char_5_15_b', skillIndex: 0, row: 10, col: 4 }],
@@ -83,21 +83,23 @@ test('迅捷 refills 引星棘刺 S1 at once: the instant cast still takes one a
h.step();
const u = h.unit('chess_char_5_15_b');
if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } });
if (silence) h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } });
if (enemy) assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 'an enemy stays in range');
u.atkCd = 0;
u.skill.gainSp(u.skill.spCost, 'test');
const n0 = u.skill.activations, a0 = u.stats.attacks;
h.run(2);
h.run(6);
checkInvariants(h.b);
return { n: u.skill.activations - n0, attacks: u.stats.attacks - a0, interval: u.s.interval };
};
const base = casts();
assert.ok(base.n >= 2 && base.n <= 3, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 2 s, not every tick`);
assert.equal(base.attacks, 0, 'each cast takes the attack cycle');
const fast = casts(500);
assert.ok(fast.n >= 8 && fast.n <= 9, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`);
const alone = casts(0, false);
assert.ok(alone.n >= 2 && alone.n <= 3, `no enemy: still fires at full SP (#124), one per cycle — ${alone.n}`);
const base = run(), muted = run({ silence: true });
// the first at once, then one per interval (1.36 s): 5 in 6 s — every tick before (180)
assert.ok(base.n >= 4 && base.n <= 5, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 6 s`);
assert.ok(base.attacks > 0 && base.attacks === muted.attacks, `the casts take no attack (${base.attacks} vs ${muted.attacks} silenced)`);
const fast = run({ aspd: 500 }), fastMuted = run({ aspd: 500, silence: true });
assert.ok(fast.n >= 23 && fast.n <= 25, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`);
assert.equal(fast.attacks, fastMuted.attacks, 'still no attack taken');
const alone = run({ enemy: false });
assert.ok(alone.n >= 4 && alone.n <= 5 && alone.attacks === 0, `no enemy: still fires at full SP (#124), one per interval — ${alone.n}`);
});
test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => {
+2 -2
View File
@@ -269,8 +269,8 @@ test('her stock (the owner\'s decision of 2026-10-06): the placed 棋子 occupy
assert.deepEqual([u.skill.activations, w.stock], [1, 7], `${S}: 立即获得两枚棋子`);
h.step();
assert.ok(!u.s.flags.noSp, `${S}: below the cap, no 阻回`);
assert.ok(u.atkCd > 0, `${S}: the cast took her attack cycle (GitHub #298)`);
h.run(u.atkCd); // the next cast waits it out
assert.ok(u.skill.nextCastAt > h.b.time, `${S}: the next cast waits one attack interval (GitHub #298)`);
h.run(u.skill.nextCastAt - h.b.time); // wait it out
u.skill.gainSp(999);
h.step();
assert.deepEqual([u.skill.activations, w.stock], [2, 8], `${S}: 最多拥有8枚`);