Files
healer-man/src/game/romNativeRecovery.ts
T
2026-10-04 13:13:35 -04:00

141 lines
5.6 KiB
TypeScript

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<RomNativeDerivedStats, "healthRecovery" | "manaRecovery" | "usesMana">,
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;
}