import { createDeathKnightRunes, type CombatResourcePoolState, type DeathKnightRuneState, type DeathKnightRuneType, } from "./combatActors"; export const MANA_FIVE_SECOND_RULE_MS = 5_000; export const OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS = 0.15; export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000; export interface CombatResourceAdvanceRules { readonly inCombat: boolean; readonly actorLevel: number; readonly now: number; readonly deltaSeconds: number; readonly manaSpiritRegenerationPerSecond?: number; } export function advanceCombatResourcePool( pool: CombatResourcePoolState, rules: CombatResourceAdvanceRules, ): CombatResourcePoolState { const delta = Math.max(0, Math.min(1, Number.isFinite(rules.deltaSeconds) ? rules.deltaSeconds : 0)); let rate = pool.regenerationPerSecond; if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) { rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0)); } if (pool.id === "mana" && !rules.inCombat) { rate = Math.max(rate, pool.maximum * OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS / 5); } if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) { rate = pool.id === "rage" ? -3 : -2.5; } return Object.freeze({ ...pool, current: Math.max(0, Math.min(pool.maximum, pool.current + rate * delta)), }); } export interface RuneCost { readonly blood?: number; readonly frost?: number; readonly unholy?: number; } export function availableDeathKnightRunes( runes: readonly DeathKnightRuneState[], now: number, ): readonly DeathKnightRuneState[] { return normalizeDeathKnightRunes(runes, now).filter((rune) => rune.readyAt <= now); } export function normalizeDeathKnightRunes( runes: readonly DeathKnightRuneState[], now: number, ): readonly DeathKnightRuneState[] { return Object.freeze(runes.map((rune) => { if (rune.deathRuneConversions !== undefined) { const activeConversions = rune.deathRuneConversions.filter((conversion) => ( Number.isFinite(conversion.expiresAt) && conversion.expiresAt > now )); if (activeConversions.length > 0) { const latestExpiry = Math.max(...activeConversions.map((conversion) => conversion.expiresAt)); if (rune.type === "death" && rune.deathRuneExpiresAt === latestExpiry && activeConversions.length === rune.deathRuneConversions.length) return rune; return Object.freeze({ ...rune, type: "death", deathRuneExpiresAt: latestExpiry, deathRuneConversions: Object.freeze(activeConversions), }); } const { deathRuneExpiresAt: _legacyExpiry, deathRuneConversions: _conversions, ...baseRune } = rune; void _legacyExpiry; void _conversions; return Object.freeze({ ...baseRune, type: rune.baseType }); } if (rune.deathRuneExpiresAt === undefined) return rune; if (rune.deathRuneExpiresAt > now) { // Older snapshots only stored Blood Tap's latest expiration. Treat that // legacy shape as a Blood Tap owner before any newer conversion touches it. return Object.freeze({ ...rune, deathRuneConversions: Object.freeze([{ source: "blood-tap" as const, expiresAt: rune.deathRuneExpiresAt, }]), }); } const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune; void _expiredAt; return Object.freeze({ ...baseRune, type: rune.baseType }); })); } export function canActivateDeathKnightBloodTapRune( runes: readonly DeathKnightRuneState[], now: number, ): boolean { return normalizeDeathKnightRunes(runes, now).some((rune) => rune.baseType === "blood" && rune.type === "blood"); } export function activateDeathKnightBloodTapRune( runes: readonly DeathKnightRuneState[], now: number, durationMs: number, ): readonly DeathKnightRuneState[] { const current = [...normalizeDeathKnightRunes(runes, now)]; const candidate = current .filter((rune) => rune.baseType === "blood" && rune.type === "blood") .sort((left, right) => Number(right.readyAt > now) - Number(left.readyAt > now) || right.readyAt - left.readyAt || left.slot - right.slot)[0]; if (!candidate) return Object.freeze(current); return Object.freeze(current.map((rune) => rune.slot === candidate.slot ? Object.freeze({ ...rune, type: "death" as const, readyAt: Math.min(rune.readyAt, now), deathRuneExpiresAt: now + Math.max(1, durationMs), deathRuneConversions: Object.freeze([{ source: "blood-tap" as const, expiresAt: now + Math.max(1, durationMs), }]), }) : rune)); } function runeMatches(rune: DeathKnightRuneState, type: Exclude): boolean { return rune.type === type || rune.type === "death"; } export function spendDeathKnightRunes( runes: readonly DeathKnightRuneState[] | undefined, cost: RuneCost, now: number, rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS, ): readonly DeathKnightRuneState[] | null { const current = [...normalizeDeathKnightRunes( runes?.length === 6 ? runes : createDeathKnightRunes(now), now, )]; const spent = new Set(); for (const type of ["blood", "frost", "unholy"] as const) { const count = Math.max(0, Math.trunc(cost[type] ?? 0)); for (let index = 0; index < count; index += 1) { const candidate = current.find((rune) => ( !spent.has(rune.slot) && rune.readyAt <= now && runeMatches(rune, type) )); if (!candidate) return null; spent.add(candidate.slot); } } return Object.freeze(current.map((rune) => spent.has(rune.slot) ? Object.freeze({ ...rune, readyAt: now + Math.max(1, rechargeMs) }) : rune)); } export function activateDeathKnightRunes( runes: readonly DeathKnightRuneState[], count: number, ): readonly DeathKnightRuneState[] { let remaining = Math.max(0, Math.trunc(count)); return Object.freeze([...runes] .sort((left, right) => right.readyAt - left.readyAt || left.slot - right.slot) .map((rune) => { if (remaining <= 0 || rune.readyAt <= 0) return rune; remaining -= 1; return Object.freeze({ ...rune, readyAt: 0 }); }) .sort((left, right) => left.slot - right.slot)); }