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(); beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "hunter", "mage"]); }); function setSpatialActors( entries: Readonly>, yaws: Readonly> = {}, ): 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 => 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(); } }); });