255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
|
import type { DeathKnightRuneState } from "./combatActors";
|
|
|
|
/**
|
|
* Reaping's talent chance and 30-second hidden Periodic Dummy interval come
|
|
* from the local generated 3.3.5a talent rows, owned by
|
|
* `scripts/generate-wow335-talents.mjs`. Rune selection/expiry and hit-versus-
|
|
* miss spend semantics follow the inspected AzerothCore-derived reference
|
|
* (the linked WotLKDB row reports interval 30s and duration n/a):
|
|
* https://wotlkdb.com/index.php?spell=56835
|
|
* https://github.com/mod-playerbots/azerothcore-wotlk/blob/e822d718322289dd036fb2daf8867ba41283bf9a/src/server/scripts/Spells/spell_dk.cpp#L2640-L2705
|
|
* https://github.com/mod-playerbots/azerothcore-wotlk/blob/e822d718322289dd036fb2daf8867ba41283bf9a/src/server/game/Spells/Spell.cpp#L5413-L5474
|
|
* This server handler is a fork reference, not a claim about pristine upstream
|
|
* TrinityCore behavior.
|
|
*/
|
|
export const WOW335_DEATH_KNIGHT_REAPING_TALENT_ID = "talent-2001";
|
|
export const WOW335_DEATH_KNIGHT_REAPING_PROC_AURA_TYPE = 226;
|
|
export const WOW335_DEATH_KNIGHT_REAPING_TRIGGER_SPELL_IDS = Object.freeze([
|
|
45902, // Blood Strike
|
|
50842, // Pestilence
|
|
]);
|
|
|
|
const reapingTalent = WOW335_TALENT_NODES.find(
|
|
(talent) => talent.id === WOW335_DEATH_KNIGHT_REAPING_TALENT_ID,
|
|
);
|
|
|
|
/** Reads the selected proc chance directly from the generated local 3.3.5a rank row. */
|
|
export function wow335DeathKnightReapingProcChancePercent(rank: number): number {
|
|
const selectedRank = Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0));
|
|
const row = reapingTalent?.rankEffects[selectedRank - 1];
|
|
const chance = row?.procChance ?? 0;
|
|
return Math.max(0, Math.min(100, Number.isFinite(chance) ? chance : 0));
|
|
}
|
|
|
|
/**
|
|
* The hidden Reaping proc aura uses a periodic dummy as its removal timer.
|
|
* This is not a normal aura duration: each successful proc resets the timer.
|
|
*/
|
|
export function wow335DeathKnightReapingAuraPeriodMs(rank: number): number {
|
|
const selectedRank = Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0));
|
|
const effect = reapingTalent?.rankEffects[selectedRank - 1]?.effects.find(
|
|
(candidate) => candidate.auraType === WOW335_DEATH_KNIGHT_REAPING_PROC_AURA_TYPE,
|
|
);
|
|
const periodMs = effect?.periodMs ?? 0;
|
|
return Math.max(0, Number.isFinite(periodMs) ? periodMs : 0);
|
|
}
|
|
|
|
export interface Wow335DeathKnightReapingRuneState {
|
|
/** Base-Blood slots currently converted by the active Reaping proc aura. */
|
|
readonly convertedRuneSlots: readonly number[];
|
|
/** The aura's shared 30-second removal timer, refreshed by each proc. */
|
|
readonly expiresAt: number | null;
|
|
}
|
|
|
|
export interface Wow335DeathKnightReapingProcPlan {
|
|
readonly newlyConvertedRuneSlot: number;
|
|
readonly state: Wow335DeathKnightReapingRuneState;
|
|
}
|
|
|
|
/**
|
|
* Plans the stock conversion after a Reaping proc succeeds. Exactly one
|
|
* original Blood slot that just entered cooldown is selected, in slot order,
|
|
* matching the core handler. The caller applies/records the plan with its rune
|
|
* state. The aura-owned removal timer comes from the rank's Periodic Dummy
|
|
* interval, not from the rune cooldown.
|
|
*/
|
|
export function wow335DeathKnightReapingProcPlan(
|
|
rank: number,
|
|
state: Wow335DeathKnightReapingRuneState,
|
|
runesBefore: readonly DeathKnightRuneState[],
|
|
runesAfter: readonly DeathKnightRuneState[],
|
|
now: number,
|
|
): Wow335DeathKnightReapingProcPlan | null {
|
|
const auraPeriodMs = wow335DeathKnightReapingAuraPeriodMs(rank);
|
|
if (auraPeriodMs <= 0 || !Number.isFinite(now)) return null;
|
|
const afterBySlot = new Map(runesAfter.map((rune) => [rune.slot, rune]));
|
|
const consumedBloodRune = [...runesBefore]
|
|
.sort((left, right) => left.slot - right.slot)
|
|
.find((before) => {
|
|
const after = afterBySlot.get(before.slot);
|
|
return before.baseType === "blood"
|
|
&& before.readyAt <= now
|
|
&& after?.baseType === "blood"
|
|
&& after.readyAt > now;
|
|
});
|
|
if (!consumedBloodRune) return null;
|
|
|
|
const activePreviousSlots = state.expiresAt !== null && state.expiresAt > now
|
|
? state.convertedRuneSlots
|
|
: [];
|
|
const convertedRuneSlots = [...new Set([...activePreviousSlots, consumedBloodRune.slot])]
|
|
.sort((left, right) => left - right);
|
|
return Object.freeze({
|
|
newlyConvertedRuneSlot: consumedBloodRune.slot,
|
|
state: Object.freeze({
|
|
convertedRuneSlots: Object.freeze(convertedRuneSlots),
|
|
expiresAt: now + auraPeriodMs,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export interface Wow335DeathKnightReapingRuneRestorePlan {
|
|
/** Rune slots whose Death type must be restored to their base Blood type. */
|
|
readonly restoredRuneSlots: readonly number[];
|
|
readonly state: Wow335DeathKnightReapingRuneState;
|
|
}
|
|
|
|
/** Extracts the active Reaping-owned rune conversions without claiming Blood Tap runes. */
|
|
export function wow335DeathKnightReapingStateForRunes(
|
|
runes: readonly DeathKnightRuneState[],
|
|
now: number,
|
|
): Wow335DeathKnightReapingRuneState {
|
|
const conversions = runes.flatMap((rune) => (rune.deathRuneConversions ?? [])
|
|
.filter((conversion) => conversion.source === "reaping"
|
|
&& Number.isFinite(conversion.expiresAt)
|
|
&& conversion.expiresAt > now)
|
|
.map((conversion) => ({ slot: rune.slot, expiresAt: conversion.expiresAt })));
|
|
return Object.freeze({
|
|
convertedRuneSlots: Object.freeze([...new Set(conversions.map((conversion) => conversion.slot))]
|
|
.sort((left, right) => left - right)),
|
|
expiresAt: conversions.length ? Math.max(...conversions.map((conversion) => conversion.expiresAt)) : null,
|
|
});
|
|
}
|
|
|
|
/** Applies a planned Reaping conversion while preserving other aura owners. */
|
|
export function wow335DeathKnightReapingApplyProcPlan(
|
|
runes: readonly DeathKnightRuneState[],
|
|
plan: Wow335DeathKnightReapingProcPlan | null,
|
|
): readonly DeathKnightRuneState[] {
|
|
if (!plan) return runes;
|
|
const affectedSlots = new Set([...plan.state.convertedRuneSlots]);
|
|
return Object.freeze(runes.map((rune) => {
|
|
if (!affectedSlots.has(rune.slot)) return rune;
|
|
const conversions = [...(rune.deathRuneConversions ?? [])]
|
|
.filter((conversion) => conversion.source !== "reaping");
|
|
conversions.push({ source: "reaping", expiresAt: plan.state.expiresAt! });
|
|
const expiresAt = Math.max(...conversions.map((conversion) => conversion.expiresAt));
|
|
return Object.freeze({
|
|
...rune,
|
|
type: "death" as const,
|
|
deathRuneExpiresAt: expiresAt,
|
|
deathRuneConversions: Object.freeze(conversions),
|
|
});
|
|
}));
|
|
}
|
|
|
|
/** Successful landed spending clears only Reaping ownership on the exact spent slots. */
|
|
export function wow335DeathKnightReapingRestoreLandedSpend(
|
|
runes: readonly DeathKnightRuneState[],
|
|
spentRuneSlots: readonly number[],
|
|
didLand: boolean,
|
|
): readonly DeathKnightRuneState[] {
|
|
if (!didLand || spentRuneSlots.length === 0) return runes;
|
|
const spent = new Set(spentRuneSlots);
|
|
return Object.freeze(runes.map((rune) => {
|
|
if (!spent.has(rune.slot)) return rune;
|
|
const conversions = (rune.deathRuneConversions ?? [])
|
|
.filter((conversion) => conversion.source !== "reaping");
|
|
if (conversions.length > 0) {
|
|
return Object.freeze({
|
|
...rune,
|
|
type: "death" as const,
|
|
deathRuneExpiresAt: Math.max(...conversions.map((conversion) => conversion.expiresAt)),
|
|
deathRuneConversions: Object.freeze(conversions),
|
|
});
|
|
}
|
|
const { deathRuneExpiresAt: _expiresAt, deathRuneConversions: _conversions, ...baseRune } = rune;
|
|
void _expiresAt;
|
|
void _conversions;
|
|
return Object.freeze({ ...baseRune, type: rune.baseType });
|
|
}));
|
|
}
|
|
|
|
/** Identifies rune slots that became unavailable in this use, ignoring pre-existing cooldowns. */
|
|
export function wow335DeathKnightSpentRuneSlots(
|
|
runesBefore: readonly DeathKnightRuneState[],
|
|
runesAfter: readonly DeathKnightRuneState[],
|
|
now: number,
|
|
): readonly number[] {
|
|
const afterBySlot = new Map(runesAfter.map((rune) => [rune.slot, rune]));
|
|
return Object.freeze(runesBefore
|
|
.filter((rune) => rune.readyAt <= now && (afterBySlot.get(rune.slot)?.readyAt ?? rune.readyAt) > now)
|
|
.map((rune) => rune.slot)
|
|
.sort((left, right) => left - right));
|
|
}
|
|
|
|
/**
|
|
* A successful ability spend restores an aura-converted Death Rune to its
|
|
* base type immediately. A miss retains the Death type. The caller supplies
|
|
* the exact spent slots from the committed rune-cost result.
|
|
*/
|
|
export function wow335DeathKnightReapingRestoreSpentRunes(
|
|
state: Wow335DeathKnightReapingRuneState,
|
|
spentRuneSlots: readonly number[],
|
|
didHit: boolean,
|
|
now: number,
|
|
): Wow335DeathKnightReapingRuneRestorePlan {
|
|
if (!Number.isFinite(now) || state.expiresAt === null || state.expiresAt <= now) {
|
|
return Object.freeze({
|
|
restoredRuneSlots: Object.freeze([]),
|
|
state: Object.freeze({ convertedRuneSlots: Object.freeze([]), expiresAt: null }),
|
|
});
|
|
}
|
|
if (!didHit) {
|
|
return Object.freeze({ restoredRuneSlots: Object.freeze([]), state });
|
|
}
|
|
const spent = new Set(spentRuneSlots);
|
|
const restoredRuneSlots = state.convertedRuneSlots.filter((slot) => spent.has(slot));
|
|
if (!restoredRuneSlots.length) {
|
|
return Object.freeze({ restoredRuneSlots: Object.freeze([]), state });
|
|
}
|
|
const restored = new Set(restoredRuneSlots);
|
|
const convertedRuneSlots = state.convertedRuneSlots.filter((slot) => !restored.has(slot));
|
|
return Object.freeze({
|
|
restoredRuneSlots: Object.freeze(restoredRuneSlots),
|
|
state: Object.freeze({
|
|
convertedRuneSlots: Object.freeze(convertedRuneSlots),
|
|
// The hidden periodic aura remains until its timer ticks even if every
|
|
// converted rune has already been restored by successful rune spends.
|
|
expiresAt: state.expiresAt,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Returns Reaping-owned rune slots that the caller must restore at aura expiry. */
|
|
export function wow335DeathKnightReapingExpiredRuneSlots(
|
|
state: Wow335DeathKnightReapingRuneState,
|
|
now: number,
|
|
): readonly number[] {
|
|
return state.expiresAt !== null && Number.isFinite(now) && now >= state.expiresAt
|
|
? state.convertedRuneSlots
|
|
: Object.freeze([]);
|
|
}
|
|
|
|
/** Resolves Reaping's generated trigger and chance; caller applies the proc plan. */
|
|
export function wow335DeathKnightReapingShouldProc(
|
|
rank: number,
|
|
event: {
|
|
readonly spellId: number;
|
|
/** True only for a resolved hit; misses and evades must pass false. */
|
|
readonly landed: boolean;
|
|
/** A deterministic roll in [0, 1), supplied by the calling combat path. */
|
|
readonly roll: number;
|
|
},
|
|
): boolean {
|
|
const chancePercent = wow335DeathKnightReapingProcChancePercent(rank);
|
|
if (chancePercent <= 0
|
|
|| !event.landed
|
|
|| !WOW335_DEATH_KNIGHT_REAPING_TRIGGER_SPELL_IDS.includes(event.spellId)
|
|
|| !Number.isFinite(event.roll)
|
|
|| event.roll < 0
|
|
|| event.roll >= 1) return false;
|
|
return event.roll < chancePercent / 100;
|
|
}
|