Files
healer-man/src/game/combatResources.ts
T
2026-10-02 10:38:12 -04:00

172 lines
6.2 KiB
TypeScript

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<DeathKnightRuneType, "death">): 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<number>();
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));
}