Files
healer-man/src/game/combatAuras.ts
T
2026-09-28 22:46:45 -04:00

618 lines
21 KiB
TypeScript

/**
* 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<Record<string, string | number | boolean>>;
};
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<AuraModifier, { kind: "resource" }>["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 };
}