fix: record control driven cast cancellations (#16)

This commit is contained in:
Codex Bot authored and Codex (Root combat coordinator) committed 2026-10-15 09:12:08 -04:00
1 parent 6a716fb8ca
commit 9be6f54c52
3 files changed
+214 -5

No files matched your search

+2
View File
@@ -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;
+52 -5
View File
@@ -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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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
+160
View File
@@ -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);
});
});