Update Healer Man server

This commit is contained in:
phenom committed 2026-10-02 10:38:12 -04:00
1 parent 4c768fbec6
commit b16821e92e
330 files changed
+35406 -1760

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
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;
}