172 lines
6.2 KiB
TypeScript
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));
|
|
}
|