/** * Serializable, store-agnostic aura state and transitions. * * The combat store owns the aura array. Every operation in this module returns * new state, making the transitions safe to use from Zustand setters and easy * to replay in tests. */ export type CombatEntityId = string; export type AuraDisposition = "buff" | "debuff"; export type AuraStackBehavior = "refresh" | "replace" | "add"; export type DispelCategory = "magic" | "curse" | "poison" | "disease" | "enrage"; export type AuraModifierOperation = "flat" | "percent"; export type DamageSchool = | "physical" | "holy" | "fire" | "nature" | "frost" | "shadow" | "arcane"; export type AttackKind = "melee" | "ranged" | "spell"; export type ProcTrigger = | "hit" | "crit" | "damage-taken" | "dodge" | "parry" | "block" | "cast" | "heal" | "periodic" | "kill"; export type AuraModifier = | { readonly kind: "stat"; readonly stat: string; readonly operation: AuraModifierOperation; readonly value: number; readonly perStack?: boolean; } | { readonly kind: "resource"; readonly resource: string; readonly property: "maximum" | "regeneration" | "cost" | "generation"; readonly operation: AuraModifierOperation; readonly value: number; readonly perStack?: boolean; } | { readonly kind: "damage"; readonly direction: "dealt" | "taken"; readonly school?: DamageSchool; readonly attackKind?: AttackKind; readonly operation: AuraModifierOperation; readonly value: number; readonly perStack?: boolean; } | { readonly kind: "healing"; readonly direction: "done" | "taken"; readonly operation: AuraModifierOperation; readonly value: number; readonly perStack?: boolean; }; export interface AuraAbsorbDefinition { readonly id: string; readonly amount: number; /** Number of matching attacks this absorb fully negates instead of consuming a point pool. */ readonly hitCharges?: number; /** Omit to absorb all damage schools. */ readonly school?: DamageSchool; /** Optional school set for absorbs such as Anti-Magic Shell. */ readonly schools?: readonly DamageSchool[]; /** Fraction of each incoming packet this absorb can consume; defaults to 1. */ readonly fraction?: number; /** Defaults to true, granting `amount` of capacity for each aura stack. */ readonly perStack?: boolean; } export interface AuraAbsorbState { readonly id: string; readonly school?: DamageSchool; readonly schools?: readonly DamageSchool[]; readonly fraction?: number; readonly remaining: number; readonly remainingHits?: number; } export type AuraProcAction = | { readonly kind: "apply-aura"; readonly auraId: string; readonly target: "aura-source" | "aura-target" | "event-other"; } | { readonly kind: "resource"; readonly resource: string; readonly amount: number; readonly target: "aura-source" | "aura-target" | "event-other"; } | { readonly kind: "damage"; readonly amount: number; readonly school: DamageSchool; readonly target: "aura-source" | "aura-target" | "event-other"; } | { readonly kind: "heal"; readonly amount: number; readonly target: "aura-source" | "aura-target" | "event-other"; } | { /** Escape hatch for a named combat-store transition with custom data. */ readonly kind: "custom"; readonly id: string; readonly data?: Readonly>; }; export interface AuraProcDefinition { readonly id: string; readonly triggers: ProcTrigger | readonly ProcTrigger[]; /** Limit attack-triggered procs to the listed landed attack types. */ readonly attackKinds?: readonly AttackKind[]; /** Probability from 0 through 1. */ readonly chance: number; readonly internalCooldownMs?: number; /** Successful activations before exhaustion. Omit for unlimited charges. */ readonly charges?: number; readonly action: AuraProcAction; } export interface AuraProcState { readonly procId: string; readonly readyAt: number; /** Null means the proc has unlimited charges. */ readonly remainingCharges: number | null; } export interface AuraDefinition { readonly id: string; readonly name: string; /** Player-facing explanation shown by aura tooltips. */ readonly description?: string; readonly disposition: AuraDisposition; /** Spell artwork used by every generic aura HUD surface. */ readonly icon?: string; /** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */ readonly hideFromAuraStrip?: boolean; /** Null means the aura persists until explicitly removed. */ readonly durationMs: number | null; readonly maxStacks?: number; /** Initial stack count for charged buffs such as Inner Fire. */ readonly initialStacks?: number; readonly stackBehavior?: AuraStackBehavior; /** Defaults to true. False makes applications from all sources share one aura. */ readonly uniqueBySource?: boolean; /** A target can have only one aura from an exclusive stance/form/seal/etc. group. */ readonly exclusiveGroup?: string; /** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */ readonly utilityTags?: readonly string[]; /** Enemy control mechanics ignored while this aura is active. */ readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence")[]; /** Chance from 0 through 1 to reflect an incoming spell. */ readonly reflectSpellChance?: number; /** Portion of incoming target damage transferred to the aura source. */ readonly redirectDamagePercent?: number; /** Shared source-max-health cap for Paladin Divine Sacrifice redirection. */ readonly redirectDamageCapPercent?: number; readonly dispelCategory?: DispelCategory; readonly dispelPriority?: number; readonly modifiers?: readonly AuraModifier[]; readonly absorbs?: readonly AuraAbsorbDefinition[]; readonly procs?: readonly AuraProcDefinition[]; } export interface ActiveAura { readonly instanceId: string; readonly definition: AuraDefinition; readonly sourceId: CombatEntityId; readonly targetId: CombatEntityId; readonly appliedAt: number; readonly expiresAt: number | null; readonly stacks: number; readonly absorbs: readonly AuraAbsorbState[]; readonly procStates: readonly AuraProcState[]; /** Cumulative redirected damage for an aura that owns a shared redirect cap. */ readonly redirectedDamage?: number; /** Number of Fire critical hits landed during the current Combustion buff. */ readonly combustionCriticalHits?: number; } export interface ApplyAuraRequest { readonly definition: AuraDefinition; readonly sourceId: CombatEntityId; readonly targetId: CombatEntityId; readonly now: number; readonly stacks?: number; } export interface AuraApplicationResult { readonly auras: readonly ActiveAura[]; readonly aura: ActiveAura; readonly replaced?: ActiveAura; } export interface ExpireAurasResult { readonly active: readonly ActiveAura[]; readonly expired: readonly ActiveAura[]; } export interface DispelRequest { /** Friendly dispels remove debuffs; hostile dispels purge buffs. */ readonly relationship: "friendly" | "hostile"; readonly categories: readonly DispelCategory[]; readonly maxCount?: number; } export interface DispelResult { readonly auras: readonly ActiveAura[]; readonly removed: readonly ActiveAura[]; } export interface AbsorbDamageRequest { readonly targetId: CombatEntityId; readonly amount: number; readonly school: DamageSchool; readonly now: number; readonly countsAsAttack?: boolean; } export interface AbsorbConsumption { readonly auraInstanceId: string; readonly absorbId: string; readonly amount: number; } export interface AbsorbDamageResult { readonly auras: readonly ActiveAura[]; readonly absorbed: number; readonly remainingDamage: number; readonly consumptions: readonly AbsorbConsumption[]; } export interface ProcEvent { readonly trigger: ProcTrigger; /** Entity whose auras should react to this event. */ readonly subjectId: CombatEntityId; readonly otherId?: CombatEntityId; readonly abilityId?: string; readonly amount?: number; readonly school?: DamageSchool; readonly attackKind?: AttackKind; } export interface ProcRollContext { readonly aura: ActiveAura; readonly proc: AuraProcDefinition; readonly event: ProcEvent; } export type ProcRoll = (context: ProcRollContext) => number; export interface TriggeredAuraProc { readonly auraInstanceId: string; readonly auraId: string; readonly procId: string; readonly sourceId: CombatEntityId; readonly targetId: CombatEntityId; readonly event: ProcEvent; readonly action: AuraProcAction; } export interface EvaluateAuraProcsResult { readonly auras: readonly ActiveAura[]; readonly triggered: readonly TriggeredAuraProc[]; } const nonNegative = (value: number): number => Math.max(0, Number.isFinite(value) ? value : 0); function boundedPositiveInteger(value: number | undefined, fallback: number): number { if (value === undefined || !Number.isFinite(value)) return fallback; return Math.max(1, Math.floor(value)); } function auraIdentity( definition: AuraDefinition, sourceId: CombatEntityId, targetId: CombatEntityId, ): string { return definition.uniqueBySource === false ? `${targetId}:${definition.id}` : `${targetId}:${definition.id}:${sourceId}`; } function expiresAt(definition: AuraDefinition, now: number): number | null { return definition.durationMs === null ? null : now + nonNegative(definition.durationMs); } function newAbsorbStates(definition: AuraDefinition, stacks: number): readonly AuraAbsorbState[] { return (definition.absorbs ?? []).map((absorb) => ({ id: absorb.id, school: absorb.school, schools: absorb.schools, fraction: absorb.fraction, remaining: nonNegative(absorb.amount) * (absorb.perStack === false ? 1 : stacks), ...(absorb.hitCharges === undefined ? {} : { remainingHits: Math.max(0, Math.floor(nonNegative(absorb.hitCharges))) }), })); } function newProcStates(definition: AuraDefinition): readonly AuraProcState[] { return (definition.procs ?? []).map((proc) => ({ procId: proc.id, readyAt: 0, remainingCharges: proc.charges === undefined ? null : Math.max(0, Math.floor(nonNegative(proc.charges))), })); } function makeAura(request: ApplyAuraRequest, stacks: number): ActiveAura { const { definition, sourceId, targetId, now } = request; return { instanceId: auraIdentity(definition, sourceId, targetId), definition, sourceId, targetId, appliedAt: now, expiresAt: expiresAt(definition, now), stacks, absorbs: newAbsorbStates(definition, stacks), procStates: newProcStates(definition), ...(definition.redirectDamageCapPercent === undefined ? {} : { redirectedDamage: 0 }), }; } /** Apply, refresh, replace, or add stacks without mutating existing store state. */ export function applyAura( current: readonly ActiveAura[], request: ApplyAuraRequest, ): AuraApplicationResult { const { definition } = request; const maxStacks = boundedPositiveInteger(definition.maxStacks, 1); const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks ?? definition.initialStacks, 1)); const instanceId = auraIdentity(definition, request.sourceId, request.targetId); // Mage armors have several DBC effects that coexist until another armor // replaces them. Other exclusive groups retain their single-aura behavior. const exclusiveSpellName = definition.name.replace(/, Aura \d+\)$/, ")"); const exclusive = definition.exclusiveGroup ? current.filter((aura) => ( aura.targetId === request.targetId && aura.definition.exclusiveGroup === definition.exclusiveGroup && aura.instanceId !== instanceId && (definition.exclusiveGroup !== "mage:armor" || aura.definition.name.replace(/, Aura \d+\)$/, ")") !== exclusiveSpellName) )) : []; const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId)); const base = exclusiveIds.size > 0 ? current.filter((aura) => !exclusiveIds.has(aura.instanceId)) : current; const existingIndex = base.findIndex((aura) => aura.instanceId === instanceId); if (existingIndex < 0) { const aura = makeAura(request, requestedStacks); return { auras: [...base, aura], aura, ...(exclusive[0] ? { replaced: exclusive[0] } : {}) }; } const existing = base[existingIndex]; const behavior = definition.stackBehavior ?? "refresh"; const stacks = behavior === "add" ? Math.min(maxStacks, existing.stacks + requestedStacks) : behavior === "replace" ? requestedStacks : existing.stacks; const replacement = makeAura(request, stacks); const aura = behavior === "refresh" ? { ...replacement, stacks, // Refreshing duration does not reset an already-used proc charge or ICD. procStates: existing.procStates, } : replacement; const auras = [...base]; auras[existingIndex] = aura; return { auras, aura, replaced: existing }; } /** Remove timed auras whose expiry is at or before `now`. */ export function expireAuras(current: readonly ActiveAura[], now: number): ExpireAurasResult { const active: ActiveAura[] = []; const expired: ActiveAura[] = []; for (const aura of current) { (aura.expiresAt !== null && aura.expiresAt <= now ? expired : active).push(aura); } return { active, expired }; } /** * Friendly dispels remove matching debuffs; hostile dispels remove matching * buffs. Higher dispelPriority is selected first, then the oldest application. */ export function dispelAuras(current: readonly ActiveAura[], request: DispelRequest): DispelResult { const wantedDisposition: AuraDisposition = request.relationship === "friendly" ? "debuff" : "buff"; const categories = new Set(request.categories); const maxCount = Math.max(0, Math.floor(request.maxCount ?? 1)); const candidates = current .filter((aura) => aura.definition.disposition === wantedDisposition && aura.definition.dispelCategory !== undefined && categories.has(aura.definition.dispelCategory)) .sort((left, right) => (right.definition.dispelPriority ?? 0) - (left.definition.dispelPriority ?? 0) || left.appliedAt - right.appliedAt || left.instanceId.localeCompare(right.instanceId)) .slice(0, maxCount); const removedIds = new Set(candidates.map((aura) => aura.instanceId)); return { auras: current.filter((aura) => !removedIds.has(aura.instanceId)), removed: candidates, }; } function modifierStackMultiplier(modifier: AuraModifier, aura: ActiveAura): number { return modifier.perStack === false ? 1 : aura.stacks; } function resolveModifiedValue( base: number, current: readonly ActiveAura[], matches: (modifier: AuraModifier) => boolean, ): number { let flat = 0; let percent = 0; for (const aura of current) { for (const modifier of aura.definition.modifiers ?? []) { if (!matches(modifier)) continue; const value = modifier.value * modifierStackMultiplier(modifier, aura); if (modifier.operation === "flat") flat += value; else percent += value; } } return Math.max(0, (base + flat) * (1 + percent)); } export function resolveStatValue( base: number, current: readonly ActiveAura[], stat: string, ): number { return resolveModifiedValue(base, current, (modifier) => modifier.kind === "stat" && modifier.stat === stat); } export function resolveResourceValue( base: number, current: readonly ActiveAura[], resource: string, property: Extract["property"], ): number { return resolveModifiedValue(base, current, (modifier) => modifier.kind === "resource" && modifier.resource === resource && modifier.property === property); } export interface DamageModifierContext { readonly direction: "dealt" | "taken"; readonly school: DamageSchool; readonly attackKind: AttackKind; } export function resolveDamageValue( base: number, current: readonly ActiveAura[], context: DamageModifierContext, ): number { return resolveModifiedValue(base, current, (modifier) => modifier.kind === "damage" && modifier.direction === context.direction && (modifier.school === undefined || modifier.school === context.school) && (modifier.attackKind === undefined || modifier.attackKind === context.attackKind)); } export function resolveHealingValue( base: number, current: readonly ActiveAura[], direction: "done" | "taken", ): number { return resolveModifiedValue(base, current, (modifier) => modifier.kind === "healing" && modifier.direction === direction); } /** Consume matching absorbs in expiry/application order and return updated auras. */ export function absorbDamage( current: readonly ActiveAura[], request: AbsorbDamageRequest, ): AbsorbDamageResult { let remainingDamage = nonNegative(request.amount); const consumptions: AbsorbConsumption[] = []; const order = current .filter((aura) => aura.targetId === request.targetId && (aura.expiresAt === null || aura.expiresAt > request.now)) .sort((left, right) => (left.expiresAt ?? Number.POSITIVE_INFINITY) - (right.expiresAt ?? Number.POSITIVE_INFINITY) || left.appliedAt - right.appliedAt) .map((aura) => aura.instanceId); const next = [...current]; for (const auraId of order) { if (remainingDamage <= 0) break; const index = next.findIndex((aura) => aura.instanceId === auraId); if (index < 0) continue; const aura = next[index]; const absorbs = aura.absorbs.map((absorb) => { if (remainingDamage <= 0 || (absorb.school !== undefined && absorb.school !== request.school) || (absorb.schools !== undefined && !absorb.schools.includes(request.school))) { return absorb; } const packetFraction = Math.max(0, Math.min(1, absorb.fraction ?? 1)); const isChargedAttackAbsorb = absorb.remainingHits !== undefined && request.countsAsAttack === true && absorb.remainingHits > 0; if (absorb.remainingHits !== undefined && !isChargedAttackAbsorb) return absorb; const amount = isChargedAttackAbsorb ? remainingDamage * packetFraction : Math.min(absorb.remaining, remainingDamage * packetFraction); remainingDamage -= amount; if (amount > 0) { consumptions.push({ auraInstanceId: aura.instanceId, absorbId: absorb.id, amount }); } return { ...absorb, remaining: absorb.remaining - amount, ...(isChargedAttackAbsorb ? { remainingHits: absorb.remainingHits! - 1 } : {}), }; }); next[index] = { ...aura, absorbs }; } return { auras: next, absorbed: nonNegative(request.amount) - remainingDamage, remainingDamage, consumptions, }; } function procHasTrigger(proc: AuraProcDefinition, trigger: ProcTrigger): boolean { return (Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers]).includes(trigger); } /** Evaluate and update all eligible proc states for one combat event. */ export function evaluateAuraProcs( current: readonly ActiveAura[], event: ProcEvent, now: number, roll: ProcRoll = () => Math.random(), ): EvaluateAuraProcsResult { const triggered: TriggeredAuraProc[] = []; const auras = current.map((aura) => { if ( aura.targetId !== event.subjectId || (aura.expiresAt !== null && aura.expiresAt <= now) || !aura.definition.procs?.length ) { return aura; } let changed = false; const procStates = aura.procStates.map((state) => { const proc = aura.definition.procs?.find((candidate) => candidate.id === state.procId); if ( proc === undefined || !procHasTrigger(proc, event.trigger) || (proc.attackKinds !== undefined && (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) || state.readyAt > now || state.remainingCharges === 0 ) { return state; } const chance = Math.min(1, Math.max(0, proc.chance)); const rolled = roll({ aura, proc, event }); if (!Number.isFinite(rolled) || rolled < 0 || rolled >= chance) return state; changed = true; const nextCharges = state.remainingCharges === null ? null : Math.max(0, state.remainingCharges - 1); triggered.push({ auraInstanceId: aura.instanceId, auraId: aura.definition.id, procId: proc.id, sourceId: aura.sourceId, targetId: aura.targetId, event, action: proc.action, }); return { procId: state.procId, readyAt: now + nonNegative(proc.internalCooldownMs ?? 0), remainingCharges: nextCharges, }; }); return changed ? { ...aura, procStates } : aura; }); return { auras, triggered }; }