618 lines
21 KiB
TypeScript
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 };
|
|
}
|