Files
healer-man/src/game/enemySpellBlocking.integration.test.ts
T
2026-10-06 22:25:34 -04:00

434 lines
18 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import {
abilitiesForClass,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
abilityAtLevel as resolveAbilityAtLevel,
} from "./abilityCatalog";
import { useCombatStore, stableCombatUnit } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { creatureTemplateReference } from "./azerothCoreReference";
import { clearMobRuntimeRegistry, registerMobRuntime, type CombatPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
const SOURCE_ENTRY = 1666;
const PLAYER_TARGET = "spell-block-player-source-npc";
const PARTY_TARGET = "spell-block-party-source-npc";
const TEST_PARTY_ID = "sourced-spell-block-test-party";
const SYNTHETIC_CRIT_AURA = "integration-test:guaranteed-critical";
const FRONT_POSITION: CombatPosition = [0, 0, -2];
const BACK_POSITION: CombatPosition = [0, 0, 2];
const PARTY_FRONT_POSITION: CombatPosition = [0, 0, -10];
const spatialActors = new Map<string, { readonly position: CombatPosition; readonly yaw: number }>();
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["warrior", "hunter", "mage"]);
});
function setSpatialActors(
entries: Readonly<Record<string, CombatPosition>>,
yaws: Readonly<Record<string, number>> = {},
): void {
spatialActors.clear();
for (const [id, position] of Object.entries(entries)) spatialActors.set(id, { position, yaw: yaws[id] ?? 0 });
registerCombatSpatialProvider({
actor: (id) => {
const actor = spatialActors.get(id);
return actor ? { id, ...actor } : null;
},
hasLineOfSight: () => true,
projectGroundPoint: (position) => position,
});
}
function registerSourceNpc(id: string): void {
const template = creatureTemplateReference(SOURCE_ENTRY);
if (!template) throw new Error(`Missing AzerothCore CreatureTemplate ${SOURCE_ENTRY}.`);
useCombatStore.getState().registerMob(id, {
name: template.name,
serverEntry: SOURCE_ENTRY,
level: 30,
maxHealth: 100_000,
xpReward: 0,
});
registerMobRuntime(id, [0, 0, 0], Math.PI);
spatialActors.set(id, { position: [0, 0, 0], yaw: Math.PI });
}
function applyGuaranteedCriticalAura(actorId: string, now: number): void {
useCombatStore.setState((state) => ({
auras: [...applyAura(state.auras, {
definition: {
id: SYNTHETIC_CRIT_AURA,
name: "Synthetic Integration Critical",
description: "Test-only fixture that makes one real spell cast critically hit.",
disposition: "buff",
durationMs: 60_000,
utilityTags: ["next-offensive-critical"],
},
sourceId: actorId,
targetId: actorId,
now,
}).auras],
}));
}
function preparePlayer(
classId: "warrior" | "hunter" | "mage",
targetId: string,
position = FRONT_POSITION,
playerYaw = 0,
): void {
useCombatStore.getState().initializeCharacter({ classId, level: 30 });
usePartyStore.getState().initializeParty(
TEST_PARTY_ID, 30, useCombatStore.getState().health, "damage", 1, "wow",
);
useCombatStore.getState().setPlayerPosition(position);
registerSourceNpc(targetId);
setSpatialActors({ [PLAYER_AGGRO_ID]: position, [targetId]: [0, 0, 0] }, {
[PLAYER_AGGRO_ID]: playerYaw,
[targetId]: Math.PI,
});
useCombatStore.getState().selectMob(targetId);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, [state.resourceName === "Rage" ? "rage" : "mana"]: state.maxResource },
}));
}
function nextCriticalBlockedTime(
sourceId: string,
abilityId: string,
targetId: string,
seedKind: "direct" | "special-crit",
firstTime = 1_000,
): number {
for (let now = firstTime; now < 200_000; now += 1) {
const seed = seedKind === "direct"
? `${sourceId}:${abilityId}:${targetId}:${now}:direct`
: `${sourceId}:${abilityId}:${targetId}:${now}:special-crit`;
const outcome = stableCombatUnit(`${seed}:outcome`);
if (stableCombatUnit(`${seed}:block`) < 0.05 && outcome > 0.2 && outcome < 0.8) return now;
}
throw new Error("Could not find a deterministic crit-and-block source-spell roll.");
}
function nextPlayerCriticalTime(abilityId: string, targetId: string): number {
for (let now = 1_000; now < 200_000; now += 1) {
const seed = `${PLAYER_AGGRO_ID}:${abilityId}:${targetId}:${now}:direct`;
const outcome = stableCombatUnit(`${seed}:outcome`);
if (outcome > 0.2 && outcome < 0.8) return now;
}
throw new Error("Could not find a deterministic landed critical spell roll.");
}
function playerDamageEvent(targetId: string, abilityId: string) {
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
candidate.kind === "damage"
&& candidate.sourceActorId === PLAYER_AGGRO_ID
&& candidate.targetActorId === targetId
&& candidate.abilityId === abilityId
));
if (!event) throw new Error(`Missing player damage event for ${abilityId} against ${targetId}.`);
return event;
}
function partyDamageEvent(targetId: string, actorId: string, abilityId: string) {
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
candidate.kind === "damage"
&& candidate.sourceActorId === actorId
&& candidate.targetActorId === targetId
&& candidate.abilityId === abilityId
));
if (!event) throw new Error(`Missing companion damage event for ${abilityId} against ${targetId}.`);
return event;
}
function completePlayerCastIfNeeded(now: number): void {
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, Math.max(now, activeCast.completesAt));
}
function setSelectedMobActiveCast(targetId: string, now: number): void {
const target = useCombatStore.getState().mobs[targetId];
if (!target) throw new Error(`Missing NPC target ${targetId}.`);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[targetId]: {
...state.mobs[targetId]!,
activeCast: {
attackId: "synthetic-block-gate-cast",
name: "Synthetic Block Gate Cast",
school: "fire",
interruptible: true,
targetActorId: PLAYER_AGGRO_ID,
sourcePosition: [0, 0, 0] as const,
targetPosition: FRONT_POSITION,
sourceYaw: 0,
startedAt: now - 1,
completesAt: now + 5_000,
},
},
},
}));
}
function abilityFor(classId: "warrior" | "hunter" | "mage", name: string) {
const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name);
if (!ability) throw new Error(`Missing ${classId} ability ${name}.`);
return ability;
}
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
spatialActors.clear();
});
describe("sourced direct-spell blocks against ordinary source NPCs", () => {
it("blocks a player's sourced physical melee spell after the hit, including a simultaneous critical hit", () => {
preparePlayer("warrior", PLAYER_TARGET);
const heroicStrike = abilityFor("warrior", "Heroic Strike");
const resolvedHeroicStrike = resolveAbilityAtLevel(heroicStrike, 30);
expect(resolvedHeroicStrike.wowSpellDefense).toMatchObject({
damageClass: 2,
schoolMask: 1,
attributes0: 327700,
attributes3: 1024,
directDamageFromEffects: true,
sourceProfile: "wow335",
});
const template = creatureTemplateReference(SOURCE_ENTRY)!;
expect(template.flagsExtra).toBeDefined();
expect(template.flagsExtra! & 0x10).toBe(0);
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
completePlayerCastIfNeeded(now);
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
landed: true,
outcome: "crit",
critical: true,
blocked: true,
blockedAmount: 15,
postHitBlockChancePercent: 5,
});
});
it("blocks a real generated WoW hunter companion's sourced physical ranged spell", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50 });
usePartyStore.getState().initializeParty(
TEST_PARTY_ID, 50, useCombatStore.getState().health, "damage", 1, "wow",
);
const steadyShotBase = abilityFor("hunter", "Steady Shot");
const steadyShot = resolveAbilityAtLevel(steadyShotBase, 50);
expect(steadyShot.wowSpellDefense).toMatchObject({
damageClass: 3,
schoolMask: 1,
attributes0: 4259858,
attributes3: 0,
directDamageFromEffects: true,
sourceProfile: "wow335",
});
const memberSeed = createConfiguredPartyMember({
name: "Sourced Spell Block Hunter",
classId: "hunter",
specialization: "Marksmanship",
specializationId: "hunter:marksmanship",
talentTreeId: "hunter-marksmanship",
talentRanks: {},
role: "damage",
}, 50, 0, TEST_PARTY_ID);
const learnedAbilities = abilitiesForClass("hunter")
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
const disabledCooldowns = Object.fromEntries(learnedAbilities
.filter((ability) => ability.id !== steadyShot.id)
.map((ability) => [ability.id, 100_000]));
const member = {
...memberSeed,
cooldowns: disabledCooldowns,
globalCooldownEndsAt: 0,
nextActionAt: 0,
status: "ready" as const,
resource: memberSeed.maxResource,
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
};
usePartyStore.setState({ members: [member], command: "attack", activeMobId: PARTY_TARGET });
registerSourceNpc(PARTY_TARGET);
// Synthetic engagement makes the companion AI enter its normal hostile-target rotation.
useCombatStore.getState().damageMob(PARTY_TARGET, 1, 900, {
actorId: PLAYER_AGGRO_ID,
role: "damage",
});
registerPartyRuntimePosition(member.id, PARTY_FRONT_POSITION);
updatePartyRuntimeTargetVisibility(member.id, PARTY_TARGET, true);
setSpatialActors({ [member.id]: PARTY_FRONT_POSITION, [PARTY_TARGET]: [0, 0, 0] }, {
[PARTY_TARGET]: Math.PI,
});
useCombatStore.getState().synchronizeActors();
const startAt = nextCriticalBlockedTime(
member.id,
steadyShot.id,
PARTY_TARGET,
"special-crit",
steadyShot.castTimeMs + 1_000,
)
- steadyShot.castTimeMs;
applyGuaranteedCriticalAura(member.id, startAt - 1);
advanceDungeonPartyCombat(startAt);
const activeCast = usePartyStore.getState().members[0]?.activeCast;
expect(activeCast).toMatchObject({ abilityId: steadyShot.id, targetId: PARTY_TARGET });
expect(activeCast?.completesAt).toBe(startAt + steadyShot.castTimeMs);
advanceDungeonPartyCombat(activeCast!.completesAt);
const event = partyDamageEvent(PARTY_TARGET, member.id, steadyShot.id);
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
outcome: "crit",
critical: true,
blocked: true,
blockedAmount: 15,
postHitBlockChancePercent: 9,
});
});
it("keeps source magic spells outside physical NPC blocking", () => {
preparePlayer("mage", PLAYER_TARGET, [0, 0, -10]);
const fireball = resolveAbilityAtLevel(abilityFor("mage", "Fireball"), 30);
expect(fireball.wowSpellDefense).toMatchObject({ damageClass: 1, schoolMask: 4, directDamageFromEffects: true });
expect(creatureTemplateReference(SOURCE_ENTRY)!.schoolImmunities).not.toContain("fire");
const fireballAt = nextPlayerCriticalTime(fireball.id, PLAYER_TARGET);
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, fireballAt - 1);
const fireballCast = useCombatStore.getState().castAbility(fireball.id, {
now: fireballAt,
targetActorId: PLAYER_TARGET,
});
expect(fireballCast, JSON.stringify(fireballCast)).toMatchObject({ ok: true });
completePlayerCastIfNeeded(fireballAt);
const fireballEvent = playerDamageEvent(PLAYER_TARGET, fireball.id);
expect(fireballEvent).toMatchObject({ school: "fire", critical: true, outcome: "crit", blocked: false });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
preparePlayer("hunter", PLAYER_TARGET, [0, 0, -10]);
const arcaneShot = resolveAbilityAtLevel(abilityFor("hunter", "Arcane Shot"), 30);
expect(arcaneShot.wowSpellDefense).toMatchObject({ damageClass: 3, schoolMask: 64, directDamageFromEffects: true });
const arcaneShotAt = nextPlayerCriticalTime(arcaneShot.id, PLAYER_TARGET);
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, arcaneShotAt - 1);
const arcaneShotCast = useCombatStore.getState().castAbility(arcaneShot.id, {
now: arcaneShotAt,
targetActorId: PLAYER_TARGET,
});
expect(arcaneShotCast, JSON.stringify(arcaneShotCast)).toMatchObject({ ok: true });
completePlayerCastIfNeeded(arcaneShotAt);
const arcaneShotEvent = playerDamageEvent(PLAYER_TARGET, arcaneShot.id);
expect(arcaneShotEvent).toMatchObject({ critical: true, outcome: "crit", blocked: false });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
});
it("requires the actual selected DBC rank and fails closed for unknown rank IDs", () => {
preparePlayer("warrior", PLAYER_TARGET);
const heroicStrike = abilityFor("warrior", "Heroic Strike");
const higherRank = heroicStrike.ranks?.at(-1);
expect(higherRank).toBeDefined();
expect(higherRank?.spellId).not.toBe(heroicStrike.dbcSpellId);
const validSeed = [...Array(20_000)].map((_, index) => `higher-rank:${index}`)
.find((seed) => stableCombatUnit(`${seed}:block`) < 0.05);
if (!validSeed) throw new Error("Could not find deterministic higher-rank block seed.");
const validRank = useCombatStore.getState().damageMob(
PLAYER_TARGET,
1_000,
50_000,
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
"physical",
{
attackKind: "melee",
abilityId: heroicStrike.id,
dbcSpellId: higherRank!.spellId,
seed: validSeed,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
},
);
expect(validRank.combatResult).toMatchObject({ blocked: true, blockedAmount: 15, postHitBlockChancePercent: 5 });
const unknown = useCombatStore.getState().damageMob(
PLAYER_TARGET,
1_000,
50_001,
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
"physical",
{
attackKind: "melee",
abilityId: heroicStrike.id,
dbcSpellId: 9_999_999,
seed: "unknown-selected-rank",
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
},
);
expect(unknown.combatResult?.blocked).toBe(false);
expect(unknown.combatResult?.postHitBlockChancePercent).toBeUndefined();
});
it("does not allow an attacker behind the source NPC to enter its spell block roll", () => {
preparePlayer("warrior", PLAYER_TARGET, BACK_POSITION, Math.PI);
const heroicStrike = abilityFor("warrior", "Heroic Strike");
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
completePlayerCastIfNeeded(now);
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
expect(event).toMatchObject({ critical: true, blocked: false });
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
});
it("does not allow a source NPC actively casting or controlled to block the physical spell", () => {
for (const gate of ["casting", "controlled"] as const) {
const targetId = `${PLAYER_TARGET}-${gate}`;
preparePlayer("warrior", targetId);
const heroicStrike = abilityFor("warrior", "Heroic Strike");
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, targetId, "direct");
if (gate === "casting") {
setSelectedMobActiveCast(targetId, now);
} else {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[targetId]: {
...state.mobs[targetId]!,
statuses: [{ kind: "stun", sourceAbilityId: "synthetic-spell-block-gate", endsAt: now + 5_000 }],
},
},
}));
}
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: targetId });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
completePlayerCastIfNeeded(now);
const event = playerDamageEvent(targetId, heroicStrike.id);
expect(event).toMatchObject({ critical: true, blocked: false });
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
}
});
});