import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura, resolveDamageValue, type ActiveAura } from "./combatAuras"; import { useCombatStore, type EnemyPeriodicCombatEffect } from "./combatStore"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount, } from "./enemyPeriodicDamage"; import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition, } from "./enemySourceAuras"; import type { EnemyAbilityEffect, EnemyAttackDefinition } from "./enemyAttacks"; const stockade = requireDungeonDefinition("stormwind-stockade"); interface RendSource { readonly serverEntry: number; readonly level: number; readonly damageMultiplier: number; readonly attack: EnemyAttackDefinition; } function importedRend(spellId: 6547 | 16509): RendSource { const entry = spellId === 6547 ? 1708 : 1707; const combat = stockade.entities[`entry-${entry}`]?.combat; const attack = combat?.attacks.find((candidate) => candidate.spellId === spellId); if (!combat || !attack) throw new Error(`Stockade entry ${entry} is missing sourced Rend ${spellId}.`); return { serverEntry: combat.serverEntry ?? entry, level: combat.level ?? 20, damageMultiplier: combat.damageMultiplier ?? 1, attack, }; } const rend6547 = importedRend(6547); const rend16509 = importedRend(16509); function attackEffects(attack: EnemyAttackDefinition): readonly Exclude[] { if (!attack.effect) throw new Error(`Stockade Rend ${attack.spellId} has no periodic effect.`); return attack.effect.kind === "composite" ? attack.effect.effects : [attack.effect]; } function sourcedDot(attack: EnemyAttackDefinition) { const dots = attackEffects(attack).filter((effect) => effect.kind === "dot"); expect(dots).toHaveLength(1); if (dots[0]?.kind !== "dot" || !dots[0].wowPeriodicSource) { throw new Error(`Stockade Rend ${attack.spellId} is missing its sourced periodic identity.`); } return dots[0]; } function initDefenders(statRuleset: "wow" | "rom", targetKind: "player" | "party") { useCombatStore.getState().initializeCharacter(statRuleset === "rom" ? { classId: "rom-priest", secondaryClassId: "rom-knight", level: 30, xp: 0, statRuleset: "rom" } : { classId: "warrior", level: 30, xp: 0, statRuleset: "wow" }); expect(useCombatStore.getState().statRuleset).toBe(statRuleset); useCombatStore.getState().setPlayerPosition(targetKind === "player" ? [0, 0, 0] : [40, 0, 0]); usePartyStore.getState().initializeParty("stockade-rend-periodic", 30, useCombatStore.getState().health, "damage", 5, statRuleset); const configuredMembers = usePartyStore.getState().members.map((partyMember, index) => createConfiguredPartyMember({ name: partyMember.name, classId: partyMember.classId, secondaryClassId: partyMember.secondaryClassId, raceId: partyMember.raceId, statRuleset, specialization: partyMember.specialization, specializationId: partyMember.specializationId, talentTreeId: partyMember.talentTreeId, role: partyMember.role, }, 30, index, "stockade-rend-periodic")); usePartyStore.setState({ members: configuredMembers }); const member = configuredMembers[0]; if (!member) throw new Error("Expected a Stockade Rend party defender."); usePartyStore.setState((state) => ({ members: state.members.map((partyMember) => ({ ...partyMember, // Verify the bleed's armor exception against a defender with substantial armor. gearStats: { ...partyMember.gearStats, armor: 50_000 }, })), })); expect(usePartyStore.getState().members.every((partyMember) => partyMember.statRuleset === statRuleset)).toBe(true); usePartyStore.getState().members.forEach((partyMember, index) => { registerPartyRuntimePosition(partyMember.id, targetKind === "party" && index === 0 ? [2, 0, 0] : [50 + index * 3, 0, 0]); }); if (targetKind === "player") { useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, armor: 50_000 } })); } useCombatStore.getState().synchronizeActors(); return member.id; } function registerRendCaster( source: RendSource, id: string, targetActorId: string, stopAfterApplication = true, enragedAtApplication = false, ): number { useCombatStore.getState().registerMob(id, { name: source.attack.name, serverEntry: source.serverEntry, level: source.level, damageMultiplier: source.damageMultiplier, maxHealth: 10_000, xpReward: 0, attacks: [{ ...source.attack, castTimeMs: 0, initialCooldownMs: [0, 0], repeatCooldownMs: [1, 1] }], }); setMobPosition(id, [targetActorId === PLAYER_AGGRO_ID ? 2 : 0, 0, 0]); if (enragedAtApplication) { const enrage = sourcedEnrageAura(id, 500); useCombatStore.setState((state) => ({ auras: [...state.auras, enrage] })); } useCombatStore.getState().selectMob(id); const sourceActor = targetActorId === PLAYER_AGGRO_ID ? undefined : { actorId: targetActorId, role: "damage" as const, abilityId: "stockade-rend-fixture" }; useCombatStore.getState().damageMob(id, 1, 1_000, sourceActor); let periodic: EnemyPeriodicCombatEffect | undefined; let appliedAt = 0; for (let now = 1_000; now < 2_500; now += 1) { useCombatStore.getState().advanceMobCombat(now); periodic = useCombatStore.getState().enemyPeriodicEffects.find((entry) => ( entry.sourceMob.id === id && entry.targetActorId === targetActorId )); if (periodic) { appliedAt = periodic.nextTickAt - periodic.intervalMs; break; } } if (!periodic) throw new Error(`Stockade Rend ${source.attack.spellId} did not land for ${id} within the bounded deterministic fixture.`); if (stopAfterApplication) { useCombatStore.setState((state) => { const mob = state.mobs[id]; return mob ? { mobs: { ...state.mobs, [id]: { ...mob, abilityReadyAt: { ...mob.abilityReadyAt, [source.attack.id]: Number.POSITIVE_INFINITY }, nextAttackAt: Number.POSITIVE_INFINITY, } } } : state; }); } return appliedAt; } function enemyDamageEvents(sourceId: string, targetId: string) { return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === sourceId && event.targetActorId === targetId); } function expectedTick(source: RendSource): number { const dot = sourcedDot(source.attack); const identity = dot.wowPeriodicSource!; const descriptor = enemyPeriodicDamageSource(identity.spellId, identity.sourceProfile); expect(descriptor).toBeDefined(); const amount = enemyPeriodicDamageTickAmount(source.serverEntry, source.level, descriptor!); expect(amount.status).toBe("resolved"); if (amount.status !== "resolved") throw new Error(`Stockade Rend source amount is unresolved: ${amount.reason}.`); return Math.round(amount.tickAmount * source.damageMultiplier); } function sourcedEnrageAura(casterId: string, appliedAt: number): ActiveAura { const contract = enemySourceAuraContract(8599, "ascension"); if (!contract) throw new Error("The pinned Ascension Enrage source contract is missing."); const definition = supportedEnemySourceAuraDefinition(contract); if (!definition) throw new Error("The pinned Ascension Enrage modifier is unsupported."); const aura = applyEnemySourceAura([], definition, casterId, casterId, appliedAt)[0]; if (!aura) throw new Error("The sourced Enrage aura could not be created."); return aura; } function enrageAdjustedTick(source: RendSource, casterId: string, appliedAt: number): number { return Math.round(resolveDamageValue(expectedTick(source), [sourcedEnrageAura(casterId, appliedAt)], { direction: "dealt", school: "physical", attackKind: "melee", })); } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useCombatStore.setState({ mobs: {}, enemyPeriodicEffects: [], auras: [], actors: {}, combatEvents: [] }); }); afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }); describe("source-backed Stockade Rend in combat", () => { it.each([ [rend6547, "player", "wow"], [rend6547, "player", "rom"], [rend6547, "party", "wow"], [rend6547, "party", "rom"], [rend16509, "player", "wow"], [rend16509, "player", "rom"], [rend16509, "party", "wow"], [rend16509, "party", "rom"], ] as const)("resolves sourced Stockade Rend against player and NPC defenders in both rulesets", (source, targetKind, statRuleset) => { const partyId = initDefenders(statRuleset, targetKind); const targetActorId = targetKind === "player" ? PLAYER_AGGRO_ID : partyId; const casterId = `stockade-rend-${source.attack.spellId}-${targetKind}-${statRuleset}`; const dot = sourcedDot(source.attack); expect(dot.wowPeriodicSource).toEqual({ spellId: source.attack.spellId, sourceProfile: "ascension-patch-d", }); expect(attackEffects(source.attack).map((effect) => effect.kind)).toEqual(["dot"]); registerRendCaster(source, casterId, targetActorId); const effect = useCombatStore.getState().enemyPeriodicEffects.find((entry) => ( entry.sourceMob.id === casterId && entry.targetActorId === targetActorId )); expect(effect).toBeDefined(); expect(effect).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 }); const firstTickAt = effect!.nextTickAt; expect(enemyDamageEvents(casterId, targetActorId)).toEqual([]); for (let tick = 1; tick <= 5; tick += 1) { useCombatStore.getState().advanceMobCombat(firstTickAt + (tick - 1) * 3_000); } const ticks = enemyDamageEvents(casterId, targetActorId); expect(ticks.map((event) => event.occurredAt)).toEqual(Array.from({ length: 5 }, (_, index) => firstTickAt + index * 3_000)); expect(ticks.map((event) => event.rawAmount)).toEqual(Array(5).fill(expectedTick(source))); expect(ticks.every((event) => event.effectiveAmount === event.rawAmount)).toBe(true); }); it("same-caster refresh restarts one Rend and preserves five full ticks", () => { initDefenders("wow", "player"); const source = rend6547; const casterId = "stockade-rend-refresh"; const firstApplicationAt = registerRendCaster(source, casterId, PLAYER_AGGRO_ID, false); const firstEffect = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId); expect(firstEffect).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 }); const initialNextTickAt = firstEffect!.nextTickAt; let refreshAt: number | undefined; for (let now = firstApplicationAt + 1; now < initialNextTickAt; now += 1) { useCombatStore.getState().advanceMobCombat(now); const current = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId); if (current && current.nextTickAt > initialNextTickAt) { refreshAt = now; break; } } expect(refreshAt).toBeDefined(); const refreshed = useCombatStore.getState().enemyPeriodicEffects.filter((entry) => entry.sourceMob.id === casterId); expect(refreshed).toHaveLength(1); expect(refreshed[0]).toMatchObject({ remainingTicks: 5, intervalMs: 3_000, nextTickAt: refreshAt! + 3_000 }); expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toEqual([]); for (let tick = 0; tick < 5; tick += 1) { useCombatStore.getState().advanceMobCombat(refreshed[0]!.nextTickAt + tick * 3_000); } const ticks = enemyDamageEvents(casterId, PLAYER_AGGRO_ID); expect(ticks).toHaveLength(5); expect(ticks.map((event) => event.occurredAt)).toEqual(Array.from({ length: 5 }, (_, index) => refreshAt! + 3_000 + index * 3_000)); }); it("keeps different casters' Rend effects independent on the same defender", () => { initDefenders("wow", "player"); registerRendCaster(rend6547, "stockade-rend-caster-a", PLAYER_AGGRO_ID); registerRendCaster(rend16509, "stockade-rend-caster-b", PLAYER_AGGRO_ID); const effects = useCombatStore.getState().enemyPeriodicEffects.filter((entry) => entry.targetActorId === PLAYER_AGGRO_ID); expect(effects).toHaveLength(2); expect(new Set(effects.map((entry) => entry.sourceMob.id))).toEqual(new Set([ "stockade-rend-caster-a", "stockade-rend-caster-b", ])); }); it("snapshots the sourced outgoing bonus when Rend is applied, even after Enrage expires", () => { initDefenders("wow", "player"); const casterId = "stockade-rend-enrage-snapshot"; const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID, true, true); const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt; const expected = enrageAdjustedTick(rend6547, casterId, appliedAt); expect(expected).toBeGreaterThan(expectedTick(rend6547)); useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.sourceId === casterId && aura.definition.id === "enemy-source-spell-8599" ? { ...aura, expiresAt: firstTickAt - 1 } : aura), })); useCombatStore.getState().advanceMobCombat(firstTickAt); expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toMatchObject([ { rawAmount: expected, effectiveAmount: expected }, ]); }); it("does not add a later Enrage bonus to an already applied Rend", () => { initDefenders("wow", "player"); const casterId = "stockade-rend-enrage-late"; const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID); const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt; const enrage = sourcedEnrageAura(casterId, appliedAt + 1); useCombatStore.setState((state) => ({ auras: [...state.auras, enrage] })); useCombatStore.getState().advanceMobCombat(firstTickAt); expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toMatchObject([ { rawAmount: expectedTick(rend6547), effectiveAmount: expectedTick(rend6547) }, ]); }); it("keeps target-side damage-taken modifiers dynamic across Rend ticks", () => { initDefenders("wow", "player"); const casterId = "stockade-rend-dynamic-target-defense"; const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID); const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt; const defendedAuras = applyAura(useCombatStore.getState().auras, { definition: { id: "stockade-rend-target-defense", name: "Temporary Rend defense", disposition: "buff", durationMs: 2_000, modifiers: [{ kind: "damage", direction: "taken", school: "physical", attackKind: "melee", operation: "percent", value: -0.5 }], }, sourceId: "stockade-rend-target-defense-source", targetId: PLAYER_AGGRO_ID, now: firstTickAt - 1_000, }).auras; useCombatStore.setState({ auras: defendedAuras }); useCombatStore.getState().advanceMobCombat(firstTickAt); useCombatStore.setState((state) => ({ auras: state.auras.filter((aura) => aura.definition.id !== "stockade-rend-target-defense"), })); useCombatStore.getState().advanceMobCombat(firstTickAt + 3_000); expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID).map((event) => event.effectiveAmount)).toEqual([ Math.round(expectedTick(rend6547) * 0.5), expectedTick(rend6547), ]); expect(appliedAt).toBeLessThan(firstTickAt); }); it("does not let a magic dispel remove Rend or suppress its final scheduled tick", () => { initDefenders("wow", "player"); const casterId = "stockade-rend-undispellable"; const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID); const aura = useCombatStore.getState().auras.find((entry) => entry.sourceId === casterId && entry.targetId === PLAYER_AGGRO_ID); expect(aura).toBeDefined(); expect(aura?.definition.dispelCategory).toBeUndefined(); expect(useCombatStore.getState().dispelActor(PLAYER_AGGRO_ID, ["magic"], 1, appliedAt + 1)).toBe(0); expect(useCombatStore.getState().enemyPeriodicEffects.some((entry) => entry.sourceMob.id === casterId)).toBe(true); for (let tick = 1; tick <= 5; tick += 1) { useCombatStore.getState().advanceMobCombat(appliedAt + tick * 3_000); } expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toHaveLength(5); expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID).at(-1)?.occurredAt).toBe(appliedAt + 15_000); }); });