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
@@ -103,6 +103,7 @@ export class SkillRuntime {
// the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown
this.manual = String(d.skillType ?? 'MANUAL').toUpperCase() === 'MANUAL';
this.opReadyAt = -Infinity; // no automatic cast before this battle time (AUTO_OP_COOLDOWN)
this.nextCastAt = -Infinity; // instant / charges with no attack of its own: no cast from the tick before (GitHub #298)
if (this.kind === 'passive') this.baseSpCost = 0;
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
this.sp = 0;
@@ -231,6 +232,7 @@ export class SkillRuntime {
this._trigKeys = null;
this._trigSet = null;
this.opReadyAt = -Infinity; // (Battle._deploy starts the operation cooldown of the battle-start deployment)
this.nextCastAt = -Infinity;
if (this.noSkill) return;
if (this.kind === 'passive') {
this._startPassive();
@@ -351,10 +353,9 @@ export class SkillRuntime {
if (this.active && this.isTimed) return;
// a cast "next attack" still waits for its attack: another charge now would be spent on the same attack
if (this.pending || this._opCooling()) return;
// an instant / charge cast with no attack of its own takes the unit's attack cycle (activate: atkCd ≥ interval after
// it — GitHub #298), so the casts checked here wait while that cycle runs. skill.tick runs before updateAlly: a
// remainder ≤ dt ends this tick and the cast claims it (else the attack about to be made would take the cycle first)
if (!this.isTimed && !this.spec.attack && u.atkCd > dt + 1e-9) return;
// an instant / charge skill with no attack of its own is cast at most once per attack interval of its unit
// (activate sets nextCastAt; GitHub #298): the casts checked here wait for it — the unit's attacks are not touched
if (!this.isTimed && !this.spec.attack && this.battle.time < this.nextCastAt - 1e-9) return;
if (TICK_RULES.has(this.rule)) {
if (this._tickRuleSatisfied()) this.activate(this.rule);
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
@@ -455,7 +456,7 @@ export class SkillRuntime {
if (this.pending || this._opCooling()) return false;
if (!this._defaultCondition()) return false;
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false;
this._beforeAttack = true; // the attack follows this cast in the same check (ai.js updateAlly): it owns the cycle
this._beforeAttack = true; // the attack follows this cast in the same check (ai.js updateAlly): attacks pace it
try { return this.activate('DEFAULT'); } finally { this._beforeAttack = false; }
}
@@ -506,12 +507,13 @@ export class SkillRuntime {
if (this.active && this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
if (!this.isTimed && !this.pending) {
this.end('instant');
// an instant / charge skill with no attack of its own (no spec.attack: a throw, a heal, a buff …) takes one attack
// cycle — its cast in place of an attack, so attack speed paces it (GitHub #298: 引星棘刺 S1 度算浪波, an SP_FULL
// cast, was recast every tick once 迅捷's refund refilled its bar at once, whatever the attack speed). [ASSUMED]
// one attack interval: PRTS prints no 前后摇 for such casts. A cast made right before an attack (DEFAULT) is
// followed by that attack as before; a cast onEnd opened again owns the cycle.
if (!this.active && !this.spec.attack && !this._beforeAttack && u.alive && u.s.interval > 0) u.atkCd = Math.max(u.atkCd, u.s.interval);
// an instant / charge skill with no attack of its own (no spec.attack: a throw, a heal, a buff, DP …) waits one
// attack interval of its unit before it is cast again from the tick (GitHub #298: 引星棘刺 S1 度算浪波, an SP_FULL
// cast, was recast every tick once 迅捷's refund refilled its bar at once, whatever the attack speed) — on the skill
// only: the cast is no attack, the unit's attacks / heals keep their own rhythm. [ASSUMED] one attack interval: PRTS
// prints no 前后摇 for such casts. A cast made right before an attack (DEFAULT) is paced by those attacks already;
// a cast onEnd opened again owns its own gap.
if (!this.active && !this.spec.attack && !this._beforeAttack && u.alive && u.s.interval > 0) this.nextCastAt = b.time + u.s.interval;
}
return true;
}