Correct combat events, rotations and explicit weapon mechanics #21
No files matched your search
@@ -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
@@ -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
@@ -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