From 9be6f54c52d3720ffa411c074319f9c423eb5fd6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 12:55:01 -0400 Subject: [PATCH] fix: record control driven cast cancellations (#16) --- src/game/combatEvents.ts | 2 + src/game/combatStore.ts | 57 ++++++- ...ontrolCastCancellation.integration.test.ts | 160 ++++++++++++++++++ 3 files changed, 214 insertions(+), 5 deletions(-) create mode 100755 src/game/playerControlCastCancellation.integration.test.ts diff --git a/src/game/combatEvents.ts b/src/game/combatEvents.ts index c3a2f1b4..51d7be26 100644 --- a/src/game/combatEvents.ts +++ b/src/game/combatEvents.ts @@ -18,6 +18,8 @@ export interface StructuredCombatEvent { readonly sourceActorId: string; readonly targetActorId: string; readonly abilityId?: string; + /** Source ability that caused an interruption; abilityId remains the canceled cast. */ + readonly causeAbilityId?: string; readonly school?: DamageSchool; readonly rawAmount: number; readonly effectiveAmount: number; diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 67902adb..4973fc4a 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -1999,10 +1999,16 @@ function emitPlayerAbilityPresentation( if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" }); } -function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void { +function emitPlayerAbilityCancellation( + abilityId: string, + occurredAt: number, + origin: CombatPosition, + targetId: string | null = null, + targetPosition: CombatPosition | null = null, +): void { const ability = abilityById(abilityId); if (!ability) return; - emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin); + emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, targetId, targetPosition ?? origin); } function emitPlayerAbilityResolutionPresentation( @@ -20632,7 +20638,12 @@ export const useCombatStore = create((set, get) => ({ if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt }); } const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = []; - let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null; + let playerControl: { + readonly mechanic: EnemyControlMechanic; + readonly endsAt: number; + readonly sourceActorId: string; + readonly causeAbilityId: string; + } | null = null; const memberControls: Array<{ readonly memberId: string; readonly mechanic: EnemyControlMechanic; @@ -21516,7 +21527,9 @@ export const useCombatStore = create((set, get) => ({ const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now); if (!control) continue; if (actorId === PLAYER_AGGRO_ID) { - if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; + if (!playerControl || control.endsAt >= playerControl.endsAt) { + playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id }; + } } else memberControls.push({ memberId: actorId, ...control }); } } else if (effect.kind === "control") { @@ -21577,7 +21590,9 @@ export const useCombatStore = create((set, get) => ({ : auraMasteryDurationMs; const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs }; if (actorId === PLAYER_AGGRO_ID) { - if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; + if (!playerControl || control.endsAt >= playerControl.endsAt) { + playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id }; + } } else memberControls.push({ memberId: actorId, ...control }); namedAuras = [...applyCombatAura(namedAuras, { @@ -21788,11 +21803,31 @@ export const useCombatStore = create((set, get) => ({ }; changed = true; } + const controlInterruptedCast = playerControl && state.activeCast + ? { cast: state.activeCast, control: playerControl } + : null; if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) { set({ mobs, enemyPeriodicEffects, auras: namedAuras, + ...(controlInterruptedCast ? { + combatEvents: recordCombatEvent(state.combatEvents, { + occurredAt: now, + kind: "interrupt", + sourceActorId: controlInterruptedCast.control.sourceActorId, + targetActorId: controlInterruptedCast.cast.targetId ?? PLAYER_AURA_ENTITY_ID, + abilityId: controlInterruptedCast.cast.abilityId, + causeAbilityId: controlInterruptedCast.control.causeAbilityId, + school: controlInterruptedCast.cast.school, + rawAmount: 0, + effectiveAmount: 0, + overheal: 0, + absorbed: 0, + critical: false, + outcome: `control:${controlInterruptedCast.control.mechanic}`, + }), + } : {}), ...(playerControl ? { ...(state.activeCast @@ -21812,6 +21847,18 @@ export const useCombatStore = create((set, get) => ({ : {}), }); } + if (controlInterruptedCast) { + emitPlayerAbilityCancellation( + controlInterruptedCast.cast.abilityId, + now, + state.playerPosition, + controlInterruptedCast.cast.targetId, + controlInterruptedCast.cast.targetId + ? getMobPosition(controlInterruptedCast.cast.targetId) + ?? actorPosition(state, controlInterruptedCast.cast.targetId) + : null, + ); + } let appliedDamage = 0; for (const hit of incomingHits) { if (hit.actorId === PLAYER_AURA_ENTITY_ID diff --git a/src/game/playerControlCastCancellation.integration.test.ts b/src/game/playerControlCastCancellation.integration.test.ts new file mode 100755 index 00000000..e893f5c8 --- /dev/null +++ b/src/game/playerControlCastCancellation.integration.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; +import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; +import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import type { EnemyAttackDefinition } from "./enemyAttacks"; + +const TARGET_ID = "player-control-cancellation-target"; +const CONTROL_MOB_ID = "player-control-cancellation-source"; + +let presentationEvents: CombatPresentationEvent[] = []; +let publishedCombatEvents: StructuredCombatEvent[] = []; +let unsubscribePresentation = () => {}; +let unsubscribeCombatEvents = () => {}; + +function prepareCharacter(classId: "priest" | "warlock"): void { + useCombatStore.getState().initializeCharacter({ classId, level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player control cancellation target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +beforeEach(() => { + clearMobRuntimeRegistry(); + prepareCharacter("priest"); + presentationEvents = []; + publishedCombatEvents = []; + unsubscribePresentation = subscribeCombatPresentation((message) => { + if (message.kind === "event") presentationEvents.push(message.event); + }); + unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event)); +}); + +afterEach(() => { + unsubscribePresentation(); + unsubscribeCombatEvents(); +}); + +function startTargetingCast(abilityId: string, now = 1_000) { + const result = useCombatStore.getState().castAbility(abilityId, { + targetActorId: TARGET_ID, + origin: [0, 0, 0], + now, + }); + expect(result.ok).toBe(true); + expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId, targetId: TARGET_ID }); + return useCombatStore.getState().activeCast!; +} + +function deliverControl(now: number): void { + if (!useCombatStore.getState().mobs[CONTROL_MOB_ID]) { + const attack: EnemyAttackDefinition = { + id: "lineage-control-stun", + name: "Lineage Control Stun", + spellId: 987_654, + delivery: "projectile", + target: "primary", + school: "shadow", + animation: "cast", + castTimeMs: 0, + range: 30, + cooldownMs: 4_000, + initialCooldownMs: [0, 0], + repeatCooldownMs: [4_000, 4_000], + damageMultiplier: 0, + effect: { kind: "control", mechanic: "stun", durationMs: 2_000 }, + }; + useCombatStore.getState().registerMob(CONTROL_MOB_ID, { + name: "Lineage control source", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + attacks: [attack], + }); + setMobPosition(CONTROL_MOB_ID, [2, 0, 1]); + useCombatStore.getState().damageMob(CONTROL_MOB_ID, 1, now); + } + useCombatStore.getState().advanceMobCombat(now); +} + +function interruptionsFor(abilityId: string): StructuredCombatEvent[] { + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "interrupt" && event.abilityId === abilityId + )); +} + +describe("player cast cancellation from enemy control", () => { + it("records one stun interruption with the source, original target, and canceled cast identity", () => { + const cast = startTargetingCast("priest-smite"); + + deliverControl(1_000); + + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().controlMechanic).toBe("stun"); + expect(interruptionsFor(cast.abilityId)).toEqual([expect.objectContaining({ + occurredAt: 1_000, + sourceActorId: CONTROL_MOB_ID, + targetActorId: cast.targetId, + abilityId: cast.abilityId, + outcome: "control:stun", + causeAbilityId: "lineage-control-stun", + })]); + expect(presentationEvents.filter((event) => event.phase === "cast-cancel" + && event.abilityId === cast.abilityId)).toEqual([expect.objectContaining({ + targetActorId: cast.targetId, + occurredAt: 1_000, + })]); + + deliverControl(1_100); + + expect(interruptionsFor(cast.abilityId)).toHaveLength(1); + expect(publishedCombatEvents.filter((event) => event.kind === "interrupt" + && event.abilityId === cast.abilityId)).toHaveLength(1); + expect(presentationEvents.filter((event) => event.phase === "cast-cancel" + && event.abilityId === cast.abilityId)).toHaveLength(1); + }); + + it("cancels a channel once without creating a release", () => { + prepareCharacter("warlock"); + const cast = startTargetingCast("warlock-drain-life"); + expect(cast.mode).toBe("channel"); + + deliverControl(1_000); + + expect(interruptionsFor(cast.abilityId)).toHaveLength(1); + expect(presentationEvents.filter((event) => event.abilityId === cast.abilityId + && event.phase === "cast-cancel")).toHaveLength(1); + expect(presentationEvents.some((event) => event.abilityId === cast.abilityId + && event.phase === "release")).toBe(false); + }); + + it("does not fabricate a control interruption after normal completion or death", () => { + const completedCast = startTargetingCast("priest-smite"); + useCombatStore.getState().tick(0, completedCast.completesAt); + + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(interruptionsFor(completedCast.abilityId)).toHaveLength(0); + expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId + && event.phase === "release")).toBe(true); + expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId + && event.phase === "cast-cancel")).toBe(false); + + prepareCharacter("priest"); + const doomedCast = startTargetingCast("priest-smite", 4_000); + const health = useCombatStore.getState().health; + useCombatStore.getState().damagePlayer(health * 10, "physical", undefined, 4_001); + expect(useCombatStore.getState().health).toBe(0); + expect(useCombatStore.getState().activeCast).toBeNull(); + deliverControl(4_001); + expect(interruptionsFor(doomedCast.abilityId)).toHaveLength(0); + expect(presentationEvents.some((event) => event.abilityId === doomedCast.abilityId + && event.phase === "cast-cancel")).toBe(false); + }); +});