import type { CharacterStatRuleset } from "./characterStatRuleset"; import type { ActiveAura } from "./combatAuras"; import type { RomNativeDerivedStats } from "./romNativeStatsTypes"; export type RomNativeRecoveryActorKind = "player" | "party-npc"; /** Runtime remainder for the source's 2 s player / 6 s owned-NPC recovery pulses. */ export interface RomNativeRecoveryClock { readonly ruleset: "rom"; readonly actorKind: RomNativeRecoveryActorKind; /** Elapsed milliseconds since the last pulse, always below the actor period. */ readonly elapsedMs: number; } export interface RomNativeRecoveryClockAdvance { readonly clock: RomNativeRecoveryClock | undefined; /** Pulse locations measured from the start of this update interval. */ readonly pulseOffsetsMs: readonly number[]; } export interface RomNativeRecoveryPulseAmounts { readonly health: number; readonly mana: number; } export const ROM_NATIVE_PLAYER_RECOVERY_PERIOD_MS = 2_000; export const ROM_NATIVE_OWNED_NPC_RECOVERY_PERIOD_MS = 6_000; export function romNativeRecoveryPeriodMs(actorKind: RomNativeRecoveryActorKind): number { return actorKind === "player" ? ROM_NATIVE_PLAYER_RECOVERY_PERIOD_MS : ROM_NATIVE_OWNED_NPC_RECOVERY_PERIOD_MS; } /** * Advances the source pulse clock without borrowing time across ruleset or actor changes. * An uninitialized clock starts accumulating immediately; a mismatched clock is reset * and deliberately does not credit the interval that crossed the mode boundary. */ export function advanceRomNativeRecoveryClock( previous: RomNativeRecoveryClock | undefined, statRuleset: CharacterStatRuleset, actorKind: RomNativeRecoveryActorKind, deltaMs: number, ): RomNativeRecoveryClockAdvance { if (statRuleset !== "rom") return { clock: undefined, pulseOffsetsMs: [] }; const period = romNativeRecoveryPeriodMs(actorKind); const compatible = previous?.ruleset === "rom" && previous.actorKind === actorKind; if (previous && !compatible) { return { clock: Object.freeze({ ruleset: "rom", actorKind, elapsedMs: 0 }), pulseOffsetsMs: [], }; } const elapsedBefore = compatible && Number.isFinite(previous.elapsedMs) ? Math.max(0, Math.min(period - Number.EPSILON, previous.elapsedMs)) : 0; const safeDelta = Number.isFinite(deltaMs) ? Math.max(0, deltaMs) : 0; const total = elapsedBefore + safeDelta; const pulseCount = Math.floor(total / period); const pulseOffsetsMs = Array.from({ length: pulseCount }, (_, index) => ( Math.max(0, period - elapsedBefore) + index * period )); return { clock: Object.freeze({ ruleset: "rom", actorKind, elapsedMs: total % period }), pulseOffsetsMs, }; } /** One native HP/MP pulse; point values intentionally remain fractional. */ export function romNativeRecoveryPulseAmounts( stats: Pick, actorKind: RomNativeRecoveryActorKind, inCombat: boolean, sitting = false, ): RomNativeRecoveryPulseAmounts { const sourceHealthRecovery = Number.isFinite(stats.healthRecovery) ? Math.max(0, stats.healthRecovery) : 0; const sourceManaRecovery = stats.usesMana && Number.isFinite(stats.manaRecovery) ? Math.max(0, stats.manaRecovery) : 0; if (inCombat) return Object.freeze({ health: 0, mana: sourceManaRecovery / 24 }); const idleFactor = actorKind === "party-npc" || sitting ? 0.66 : 1 / 3; return Object.freeze({ health: sourceHealthRecovery * idleFactor, mana: sourceManaRecovery * idleFactor, }); } /** * Simulator representation of explicit source RoM resource-over-time bindings. * These are kept separate from the character's source ReMP pulse calculation. * Unmarked/shared WoW regeneration auras do not pass this source-ID gate. */ export function romNativePeriodicManaEffectPerSecond( auras: readonly ActiveAura[], maximumMana: number, ): number { let flatPointsPerSecond = 0; let percentOfMaximumPerSecond = 0; for (const aura of auras) { if (!aura.definition.id.startsWith("rom-resource-over-time-")) continue; for (const modifier of aura.definition.modifiers ?? []) { const amount = modifier.value * (modifier.perStack === false ? 1 : aura.stacks); if (modifier.kind === "resource" && modifier.resource === "mana" && modifier.property === "regeneration" && modifier.operation === "flat") { flatPointsPerSecond += amount; } else if (modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.operation === "flat") { percentOfMaximumPerSecond += amount; } } } const safeMaximum = Number.isFinite(maximumMana) ? Math.max(0, maximumMana) : 0; return flatPointsPerSecond + safeMaximum * percentOfMaximumPerSecond; } /** Integrates explicit RoM periodic Mana effects (including drains) over their live interval. */ export function romNativePeriodicManaEffectDuring( auras: readonly ActiveAura[], intervalStartAt: number, intervalEndAt: number, maximumMana: number, ): number { if (!Number.isFinite(intervalStartAt) || !Number.isFinite(intervalEndAt) || intervalEndAt <= intervalStartAt) return 0; let total = 0; for (const aura of auras) { if (!aura.definition.id.startsWith("rom-resource-over-time-")) continue; const activeStart = Math.max(intervalStartAt, aura.appliedAt); const activeEnd = Math.min(intervalEndAt, aura.expiresAt ?? intervalEndAt); if (activeEnd <= activeStart) continue; const perSecond = romNativePeriodicManaEffectPerSecond([aura], maximumMana); total += perSecond * (activeEnd - activeStart) / 1_000; } return total; }