fix: record control driven cast cancellations (#16)
This commit is contained in:
1 parent
6a716fb8ca
commit
9be6f54c52
3 files changed
+214
-5
No files matched your search
@@ -18,6 +18,8 @@ export interface StructuredCombatEvent {
|
|||||||
readonly sourceActorId: string;
|
readonly sourceActorId: string;
|
||||||
readonly targetActorId: string;
|
readonly targetActorId: string;
|
||||||
readonly abilityId?: string;
|
readonly abilityId?: string;
|
||||||
|
/** Source ability that caused an interruption; abilityId remains the canceled cast. */
|
||||||
|
readonly causeAbilityId?: string;
|
||||||
readonly school?: DamageSchool;
|
readonly school?: DamageSchool;
|
||||||
readonly rawAmount: number;
|
readonly rawAmount: number;
|
||||||
readonly effectiveAmount: number;
|
readonly effectiveAmount: number;
|
||||||
|
|||||||
+52
-5
@@ -1999,10 +1999,16 @@ function emitPlayerAbilityPresentation(
|
|||||||
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
|
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);
|
const ability = abilityById(abilityId);
|
||||||
if (!ability) return;
|
if (!ability) return;
|
||||||
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, targetId, targetPosition ?? origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitPlayerAbilityResolutionPresentation(
|
function emitPlayerAbilityResolutionPresentation(
|
||||||
@@ -20632,7 +20638,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt });
|
if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt });
|
||||||
}
|
}
|
||||||
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
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<{
|
const memberControls: Array<{
|
||||||
readonly memberId: string;
|
readonly memberId: string;
|
||||||
readonly mechanic: EnemyControlMechanic;
|
readonly mechanic: EnemyControlMechanic;
|
||||||
@@ -21516,7 +21527,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now);
|
const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now);
|
||||||
if (!control) continue;
|
if (!control) continue;
|
||||||
if (actorId === PLAYER_AGGRO_ID) {
|
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 memberControls.push({ memberId: actorId, ...control });
|
||||||
}
|
}
|
||||||
} else if (effect.kind === "control") {
|
} else if (effect.kind === "control") {
|
||||||
@@ -21577,7 +21590,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
: auraMasteryDurationMs;
|
: auraMasteryDurationMs;
|
||||||
const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs };
|
const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs };
|
||||||
if (actorId === PLAYER_AGGRO_ID) {
|
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 memberControls.push({ memberId: actorId, ...control });
|
||||||
namedAuras = [...applyCombatAura(namedAuras, {
|
namedAuras = [...applyCombatAura(namedAuras, {
|
||||||
@@ -21788,11 +21803,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
const controlInterruptedCast = playerControl && state.activeCast
|
||||||
|
? { cast: state.activeCast, control: playerControl }
|
||||||
|
: null;
|
||||||
if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) {
|
if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) {
|
||||||
set({
|
set({
|
||||||
mobs,
|
mobs,
|
||||||
enemyPeriodicEffects,
|
enemyPeriodicEffects,
|
||||||
auras: namedAuras,
|
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
|
...(playerControl
|
||||||
? {
|
? {
|
||||||
...(state.activeCast
|
...(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;
|
let appliedDamage = 0;
|
||||||
for (const hit of incomingHits) {
|
for (const hit of incomingHits) {
|
||||||
if (hit.actorId === PLAYER_AURA_ENTITY_ID
|
if (hit.actorId === PLAYER_AURA_ENTITY_ID
|
||||||
|
|||||||
+160
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user