Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35406
-1760
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user