Paragon: cross-class talents + Warrior stance bypass + Mirror Image spellbook draw
Server-side cross-class wildcard pass for several talents that were
previously locked to a single SpellFamilyName, plus a server+client
Warrior stance bypass and a Paragon-aware Mirror Image rebuild that
mimics the owner's spellbook instead of stock Frostbolt/Fire Blast.
Talent expansions (Paragon owners only; stock classes unchanged):
- Cold Snap (11958): resets cooldown of any Frost-school spell.
- Nature's Swiftness (17116, 16188) + Predator's Swiftness (69369):
instant-cast on any Nature-school spell.
- Vampiric Embrace (15286): leech-heals from any single-target
Shadow-school spell.
- Fingers of Frost (44543/44545) + Frostbite (11071/12496/12497):
proc from any Frost-school chill effect (DK Howling Blast / Icy
Touch / Chains of Ice, Hunter Frost Trap, Shaman Frost Shock,
cross-class chill auras via SPELL_AURA_MOD_DECREASE_SPEED).
- Maelstrom Weapon (53817): now also affects Mage Fireball (133),
Frostbolt (116), and Arcane Blast (30451) at every rank, both
for cast-time/cost spellmod and for stack consumption.
Warrior stance bypass:
- SpellInfo::CheckShapeshift returns SPELL_CAST_OK whenever a
Paragon caster hits any Stances!=0 spell (no SpellFamilyName
gate). Stock classes still see the regular form rules.
- Client side: patch-enUS-4.MPQ now zeroes Stances on every
SPELLFAMILY_WARRIOR Spell.dbc row (105 spells) so the engine's
pre-cast "Must be in Battle/Defensive/Berserker Stance" check
no longer eats CMSG_CAST_SPELL packets for Paragons. Server
bypass enforces the actual decision; stock Warriors still
error mid-cast if they actually click while out of stance.
- patch-enUS-5.MPQ Lua tooltip post-processor recolors and
appends "(Paragon: bypassed)" to "Requires *Stance*" lines on
Warrior abilities, plus Paragon notes on Maelstrom Weapon and
Mirror Image tooltips. Action-bar UseAction wrapper routes
stance-gated Warrior spell clicks through CastSpellByName so
the stance-zero DBC + server bypass actually run.
Mirror Image:
- npc_pet_mage_mirror_image rebuilds its spell list from the
Paragon owner's spellbook on InitializeAI AND JustEngagedWith
(the second pass + events.Reset clears any stale events the
CasterAI base scheduler may have queued from stock 59637 /
59638 entries before the rebuild ran).
- Curated filter keeps single-target damaging spells (instant,
cast-time, or channeled) with a base cooldown <=10s, with the
"damaging" definition expanded to include
SPELL_EFFECT_TRIGGER_MISSILE and
SPELL_AURA_PERIODIC_TRIGGER_SPELL so Arcane Missiles
qualifies. Rejects passives, AoE, melee/ranged weapon strikes,
item/reagent/stance/equip-gated, and lower spell ranks.
- UpdateAI picks a random spell from the curated list per cast
and reschedules the next pick by the actually-cast spell's
cast/channel duration + 750ms breather, so a 5s Arcane
Missiles channel waits its full duration before re-rolling
rather than visually looping across four images.
Helpers:
- Unit::IsParagonWildcardCaller / Unit::ParagonFamilyMatches
used by Spell.cpp, SpellInfo.cpp, Player.cpp,
SpellAuraEffects.cpp, and the spell scripts.
- SpellInfo::CheckShapeshift signature gains an optional caster
pointer; all call sites updated.
SQL migrations under modules/mod-paragon/data/sql/db-world/updates/:
- 2026_05_11_01.sql Vampiric Embrace spell_proc relax + script
gate (CheckProc enforces stock for non-Paragon).
- 2026_05_11_02.sql Maelstrom Weapon spell_proc relax (initial,
superseded by _04 below for stack-consumption fix).
- 2026_05_11_03.sql Fingers of Frost / Frostbite spell_proc relax
and spell_script_names binding.
- 2026_05_11_04.sql Maelstrom Weapon spell_proc fixup: restore
SpellPhaseMask=1 (CAST) and AttributesMask=8
(REQ_SPELLMOD); previous _02 set 8/0 which
silently dropped every proc event.
Diagnostics from this debugging session demoted from LOG_INFO to
LOG_DEBUG (silent at default info level) so production logs stay
quiet but the probes remain available for reproducing future
regressions: pet_mage.cpp MirrorImage probe/kept/rebuild/init/
engage/cast lines and SpellInfo.cpp CheckShapeshift bypass line.
CLIENT-PATCHES.md updated to document the new Warrior stance DBC
patcher (_patch_spell_dbc_stances.py), the spell-tooltip post-
processor and stance UseAction wrapper in patch-enUS-5.MPQ, and
the Mirror Image / Maelstrom Weapon Paragon notes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7208,7 +7208,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
|
||||
return;
|
||||
|
||||
// Cannot be used in this stance/form
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) != SPELL_CAST_OK)
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) != SPELL_CAST_OK)
|
||||
return;
|
||||
|
||||
if (form_change) // check aura active state from other form
|
||||
@@ -7228,7 +7228,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
|
||||
if (form_change) // check aura compatibility
|
||||
{
|
||||
// Cannot be used in this stance/form
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) == SPELL_CAST_OK)
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) == SPELL_CAST_OK)
|
||||
return; // and remove only not compatible at form change
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,19 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
// Fractured / Paragon: single source of truth for the runtime "is this
|
||||
// caller eligible for the cross-class wildcard?" question. Centralizing
|
||||
// here keeps every dependent behavior (family-name skip in
|
||||
// SpellInfo::IsAffected, PERIODIC_LEECH disease counting in
|
||||
// GetDiseasesByCaster, instant-cast intercept in Spell::prepare for
|
||||
// Predator's / Nature's Swiftness, Vampiric Embrace CheckProc cross-family
|
||||
// path, etc.) flipping in lockstep when the config flag is toggled.
|
||||
bool IsParagonWildcardCaller(Unit const* listener)
|
||||
{
|
||||
return listener && listener->getClass() == CLASS_PARAGON
|
||||
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
|
||||
}
|
||||
|
||||
// Fractured / Paragon: cross-class wildcard helper used by ad-hoc
|
||||
// `switch (SpellFamilyName)` listener gates in Unit / SpellEffects /
|
||||
// SpellAuraEffects. Returns true when the listener is a CLASS_PARAGON
|
||||
@@ -85,8 +98,7 @@
|
||||
// to strict family-name equality.
|
||||
bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily)
|
||||
{
|
||||
if (listener && listener->getClass() == CLASS_PARAGON
|
||||
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY))
|
||||
if (IsParagonWildcardCaller(listener))
|
||||
return true;
|
||||
return expectedFamily == actualFamily;
|
||||
}
|
||||
@@ -6136,17 +6148,40 @@ AuraEffect* Unit::IsScriptOverriden(SpellInfo const* spell, int32 script) const
|
||||
|
||||
uint32 Unit::GetDiseasesByCaster(ObjectGuid casterGUID, uint8 mode)
|
||||
{
|
||||
static const AuraType diseaseAuraTypes[] =
|
||||
ObjectGuid drwGUID;
|
||||
|
||||
// Fractured / Paragon: when the caller (the unit whose strike is
|
||||
// counting diseases -- e.g. Death Strike heal, Blood Strike / Heart
|
||||
// Strike / Obliterate per-disease damage, Glyph of Scourge Strike
|
||||
// refresh) is a CLASS_PARAGON player AND Paragon.WildcardFamilyMatching
|
||||
// is on, also walk SPELL_AURA_PERIODIC_LEECH. That picks up Priest
|
||||
// Devouring Plague, which uses ApplyAuraName 53 (PERIODIC_LEECH) instead
|
||||
// of 3 (PERIODIC_DAMAGE) and is therefore invisible to the stock loop
|
||||
// even though its Dispel field is DISPEL_DISEASE. A full Spell.dbc scan
|
||||
// confirms Devouring Plague is the ONLY entry that satisfies both
|
||||
// `Dispel == DISPEL_DISEASE` and a leech periodic effect, so this does
|
||||
// not accidentally drag any other spell into the disease pool. Stock
|
||||
// (non-Paragon) callers fall through to the original 2-entry iteration
|
||||
// and observe identical behavior.
|
||||
bool paragonWildcardLeech = false;
|
||||
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
|
||||
{
|
||||
drwGUID = playerCaster->getRuneWeaponGUID();
|
||||
paragonWildcardLeech = IsParagonWildcardCaller(playerCaster);
|
||||
}
|
||||
|
||||
AuraType diseaseAuraTypes[4] =
|
||||
{
|
||||
SPELL_AURA_PERIODIC_DAMAGE, // Frost Fever and Blood Plague
|
||||
SPELL_AURA_LINKED, // Crypt Fever and Ebon Plague
|
||||
SPELL_AURA_NONE,
|
||||
SPELL_AURA_NONE
|
||||
};
|
||||
|
||||
ObjectGuid drwGUID;
|
||||
|
||||
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
|
||||
drwGUID = playerCaster->getRuneWeaponGUID();
|
||||
if (paragonWildcardLeech)
|
||||
{
|
||||
diseaseAuraTypes[2] = SPELL_AURA_PERIODIC_LEECH; // Priest Devouring Plague (Paragon-only)
|
||||
diseaseAuraTypes[3] = SPELL_AURA_NONE;
|
||||
}
|
||||
|
||||
uint32 diseases = 0;
|
||||
for (uint8 index = 0; diseaseAuraTypes[index] != SPELL_AURA_NONE; ++index)
|
||||
|
||||
@@ -2268,6 +2268,16 @@ private:
|
||||
ValuesUpdateCache _valuesUpdateCache;
|
||||
};
|
||||
|
||||
// Fractured / Paragon: returns true iff `listener` is a CLASS_PARAGON player
|
||||
// AND `Paragon.WildcardFamilyMatching` is enabled. Single source of truth for
|
||||
// the gate that controls every cross-class wildcard path (family-name skip in
|
||||
// SpellInfo::IsAffected, leech-aura disease counting in
|
||||
// Unit::GetDiseasesByCaster, the cross-school instant-cast intercept in
|
||||
// Spell::prepare for Predator's / Nature's Swiftness, the Vampiric Embrace
|
||||
// CheckProc cross-family path, etc.). Centralizing the check means runtime
|
||||
// kill-switching the wildcard config flips every behavior together.
|
||||
[[nodiscard]] bool IsParagonWildcardCaller(Unit const* listener);
|
||||
|
||||
// Fractured / Paragon: helper for ad-hoc `switch (SpellFamilyName)` listener
|
||||
// gates scattered across Unit / SpellEffects / SpellAuraEffects. When the
|
||||
// listener (i.e. the unit holding the gating talent / aura) is a Paragon
|
||||
|
||||
@@ -1630,7 +1630,7 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
|
||||
|
||||
// Xinef: Remove autoattack spells
|
||||
if (Spell* spell = target->GetCurrentSpell(CURRENT_MELEE_SPELL))
|
||||
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0) != SPELL_CAST_OK)
|
||||
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0, target) != SPELL_CAST_OK)
|
||||
spell->cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
#include "Vehicle.h"
|
||||
#include "World.h"
|
||||
#include "WorldPacket.h"
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
/// @todo: this import is not necessary for compilation and marked as unused by the IDE
|
||||
@@ -3540,29 +3541,49 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
|
||||
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
|
||||
m_casttime = 0;
|
||||
|
||||
// Fractured / Paragon: cross-class Predator's Swiftness (69369).
|
||||
// Stock 3.3.5 only ADD_PCT_MODIFIER's the cast time of Druid-family
|
||||
// Nature spells via class mask, so a Paragon with the buff cannot
|
||||
// instant-cast Shaman Chain Lightning / Lightning Bolt or any other
|
||||
// non-Druid Nature spell. The tooltip ("next Nature spell with a
|
||||
// base cast time below 10 sec becomes instant") expects all-Nature
|
||||
// behavior; honor that here for CLASS_PARAGON. We deliberately do
|
||||
// not touch the stock SpellMod path -- real Druids continue to hit
|
||||
// the existing class-mask code path unchanged.
|
||||
// Fractured / Paragon: cross-class "next Nature spell becomes instant"
|
||||
// intercept for the three buffs that share that semantic in 3.3.5:
|
||||
//
|
||||
// 69369 - Predator's Swiftness (Cataclysm proc payload triggered by
|
||||
// our spell_paragon_predatory_strikes; see Paragon_SC.cpp)
|
||||
// 17116 - Druid Nature's Swiftness
|
||||
// 16188 - Shaman Nature's Swiftness
|
||||
//
|
||||
// All three apply SPELL_AURA_ADD_PCT_MODIFIER on SPELLMOD_CASTING_TIME
|
||||
// gated by a Druid- or Shaman-only SpellClassMask, so a Paragon with the
|
||||
// buff cannot instant-cast a Nature spell from a different family
|
||||
// (e.g. a Druid NS Paragon casting Shaman Chain Lightning, or a Shaman
|
||||
// NS Paragon casting Druid Healing Touch). Tooltip text on all three
|
||||
// promises "next Nature spell with a base cast time below 10 sec becomes
|
||||
// instant"; honor that here for CLASS_PARAGON callers when the wildcard
|
||||
// config is on. The stock SpellMod path is untouched -- real Druids /
|
||||
// Shamans / proc consumers continue to hit the existing class-mask code
|
||||
// path unchanged.
|
||||
if (Player* paragonCaster = m_caster->ToPlayer())
|
||||
{
|
||||
constexpr uint32 SPELL_PARAGON_PREDATORY_SWIFTNESS = 69369;
|
||||
if (m_casttime > 0
|
||||
&& paragonCaster->getClass() == CLASS_PARAGON
|
||||
&& IsParagonWildcardCaller(paragonCaster)
|
||||
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
|
||||
&& m_spellInfo->CastTimeEntry
|
||||
&& !m_spellInfo->IsChanneled()
|
||||
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
|
||||
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS
|
||||
&& paragonCaster->HasAura(SPELL_PARAGON_PREDATORY_SWIFTNESS))
|
||||
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS)
|
||||
{
|
||||
m_casttime = 0;
|
||||
paragonCaster->RemoveAurasDueToSpell(SPELL_PARAGON_PREDATORY_SWIFTNESS);
|
||||
static constexpr std::array<uint32, 3> kParagonNatureInstantBuffs =
|
||||
{
|
||||
69369u, // Predator's Swiftness (Paragon proc payload)
|
||||
17116u, // Druid Nature's Swiftness
|
||||
16188u // Shaman Nature's Swiftness
|
||||
};
|
||||
for (uint32 buffId : kParagonNatureInstantBuffs)
|
||||
{
|
||||
if (paragonCaster->HasAura(buffId))
|
||||
{
|
||||
m_casttime = 0;
|
||||
paragonCaster->RemoveAurasDueToSpell(buffId);
|
||||
break; // consume only one buff per cast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5748,7 +5769,7 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
|
||||
if (checkForm)
|
||||
{
|
||||
// Cannot be used in this stance/form
|
||||
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm());
|
||||
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm(), m_caster);
|
||||
if (shapeError != SPELL_CAST_OK)
|
||||
return shapeError;
|
||||
|
||||
|
||||
@@ -1378,7 +1378,42 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* liste
|
||||
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
|
||||
return true;
|
||||
|
||||
return IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner);
|
||||
if (IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner))
|
||||
return true;
|
||||
|
||||
// Fractured / Paragon: explicit cross-family allowlist for specific
|
||||
// listener auras whose SpellClassMask cannot otherwise bridge classes.
|
||||
// The standard IsAffected wildcard relaxes SpellFamilyName equality but
|
||||
// still requires SpellClassMask & SpellFamilyFlags to overlap; for these
|
||||
// Paragon-only cross-class enablers the source spells live in different
|
||||
// families with non-overlapping class bits, so we whitelist by mod owner
|
||||
// spell ID + target spell first-rank ID. Stock classes never enter here
|
||||
// because IsParagonWildcardCaller short-circuits on non-Paragon owners.
|
||||
if (IsParagonWildcardCaller(listenerOwner))
|
||||
{
|
||||
switch (mod->spellId)
|
||||
{
|
||||
case 53817: // Shaman: Maelstrom Weapon
|
||||
{
|
||||
// Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to
|
||||
// benefit from the cast-time + cost reduction spellmod.
|
||||
if (SpellFamilyName == SPELLFAMILY_MAGE)
|
||||
{
|
||||
SpellInfo const* first = GetFirstRankSpell();
|
||||
uint32 firstId = first ? first->Id : Id;
|
||||
if (firstId == 133 /*Fireball*/
|
||||
|| firstId == 116 /*Frostbolt*/
|
||||
|| firstId == 30451 /*Arcane Blast*/)
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
|
||||
@@ -1463,7 +1498,7 @@ bool SpellInfo::IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInf
|
||||
}
|
||||
}
|
||||
|
||||
SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
|
||||
SpellCastResult SpellInfo::CheckShapeshift(uint32 form, Unit const* caster /*= nullptr*/) const
|
||||
{
|
||||
// talents that learn spells can have stance requirements that need ignore
|
||||
// (this requirement only for client-side stance show in talent description)
|
||||
@@ -1471,6 +1506,38 @@ SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
|
||||
(Effects[0].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[1].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[2].Effect == SPELL_EFFECT_LEARN_SPELL))
|
||||
return SPELL_CAST_OK;
|
||||
|
||||
// Fractured / Paragon: Paragons learn Warrior abilities through Advancement
|
||||
// without picking up Battle/Defensive/Berserker Stance, so stance-gated
|
||||
// Warrior spells (e.g. Whirlwind, Sunder Armor, Shield Slam) would otherwise
|
||||
// be uncastable. Bypass the stance check for Paragon casters on any spell
|
||||
// that has a non-zero Stances bitmask, regardless of SpellFamilyName.
|
||||
//
|
||||
// We previously gated this on SpellFamilyName == SPELLFAMILY_WARRIOR, but a
|
||||
// number of SPELLFAMILY_GENERIC spells (notably the iconic Warrior toolbox
|
||||
// -- Berserker Rage 18499, Sunder Armor 7405 / 11596 / 11597 / 25225 /
|
||||
// 47467, Charge 100 / 6178 / 11578, Pummel 6552 / 6554, Shield Bash 72 /
|
||||
// 1671 / 1672 / 29704, Retaliation 20230, Recklessness 1719, Shield Wall
|
||||
// 871, etc.) carry the Stances bitmask but live under SPELLFAMILY_GENERIC
|
||||
// (family 0). The previous narrower gate let those re-trigger the stance
|
||||
// failure for Paragons. Widening to "any non-zero Stances + Paragon" is
|
||||
// safe because:
|
||||
//
|
||||
// * The bypass returns SPELL_CAST_OK only when IsParagonWildcardCaller
|
||||
// is true -- stock classes never enter this branch.
|
||||
// * Druid form-gated spells (Cat Form / Bear Form / Moonkin / Tree)
|
||||
// still fire the Druid GCD/form rules elsewhere; CheckShapeshift is
|
||||
// about *requiring* a form to cast, which is exactly what we want
|
||||
// to bypass for Paragons (they never picked the form).
|
||||
// * Item enchant scrolls and other shapeshift-marked utility spells
|
||||
// remain unaffected because they aren't in a Paragon's spellbook.
|
||||
if (Stances != 0 && IsParagonWildcardCaller(caster))
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] CheckShapeshift bypass: spell={} family={} stances=0x{:x} form={}",
|
||||
Id, SpellFamilyName, Stances, form);
|
||||
return SPELL_CAST_OK;
|
||||
}
|
||||
|
||||
uint32 stanceMask = (form ? 1 << (form - 1) : 0);
|
||||
|
||||
if (stanceMask & StancesNot) // can explicitly not be casted in this stance
|
||||
|
||||
@@ -521,7 +521,7 @@ public:
|
||||
bool IsAuraExclusiveBySpecificWith(SpellInfo const* spellInfo) const;
|
||||
bool IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInfo) const;
|
||||
|
||||
SpellCastResult CheckShapeshift(uint32 form) const;
|
||||
SpellCastResult CheckShapeshift(uint32 form, Unit const* caster = nullptr) const;
|
||||
SpellCastResult CheckLocation(uint32 map_id, uint32 zone_id, uint32 area_id, Player* player = nullptr, bool strict = true) const;
|
||||
SpellCastResult CheckTarget(Unit const* caster, WorldObject const* target, bool implicit = true) const;
|
||||
SpellCastResult CheckExplicitTarget(Unit const* caster, WorldObject const* target, Item const* itemTarget = nullptr) const;
|
||||
|
||||
@@ -64,10 +64,227 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
uint32 dist = urand(1, 5);
|
||||
bool _delayAttack;
|
||||
|
||||
// Fractured / Paragon: when the owner is a Paragon character with the
|
||||
// wildcard config enabled, replace the stock Frostbolt + Fireblast
|
||||
// allowlist (loaded by CombatAI from creature_template_spell for
|
||||
// creature 31216) with a curated list of damaging spells from the
|
||||
// owner's spellbook. UpdateAI's override picks a random spell from
|
||||
// the list per cast so the rotation isn't deterministic.
|
||||
//
|
||||
// The image still casts as itself (not via the owner), so spell
|
||||
// coefficients apply to the image's stats -- spells naturally do less
|
||||
// damage than they would in the owner's hands. We accept that as the
|
||||
// cost of "free cross-class spell variety" rather than try to rebalance
|
||||
// every player spell here.
|
||||
static bool IsDamagingForMirrorImage(SpellInfo const* si)
|
||||
{
|
||||
// Direct damage effect.
|
||||
if (si->HasEffect(SPELL_EFFECT_SCHOOL_DAMAGE))
|
||||
return true;
|
||||
|
||||
// Spells like Arcane Missiles (TRIGGER_MISSILE) and most channeled
|
||||
// multi-tick nukes route their damage through a child spell, so the
|
||||
// parent has no SCHOOL_DAMAGE effect of its own. Accept that here.
|
||||
if (si->HasEffect(SPELL_EFFECT_TRIGGER_MISSILE))
|
||||
return true;
|
||||
|
||||
// DoTs and channels-as-aura (Mind Flay, Curse of Doom, Immolate,
|
||||
// Corruption, Vampiric Touch, Drain Life leech, etc.). Also accept
|
||||
// PERIODIC_TRIGGER_SPELL auras -- that's how Arcane Missiles fires
|
||||
// each individual missile (parent has Aura=23 -> child damaging
|
||||
// spell). Same pattern is used by Hunter Volley, Curse of Doom (in
|
||||
// some ranks), and similar tick-by-trigger spells.
|
||||
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
|
||||
{
|
||||
uint32 aura = si->Effects[i].ApplyAuraName;
|
||||
if (aura == SPELL_AURA_PERIODIC_DAMAGE
|
||||
|| aura == SPELL_AURA_PERIODIC_DAMAGE_PERCENT
|
||||
|| aura == SPELL_AURA_PERIODIC_LEECH
|
||||
|| aura == SPELL_AURA_PERIODIC_TRIGGER_SPELL)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void RebuildSpellsFromOwnerSpellbookForParagon(Player* owner)
|
||||
{
|
||||
SpellVct curated;
|
||||
curated.reserve(8);
|
||||
|
||||
uint32 scanned = 0, kept = 0, rejInactive = 0, rejPassive = 0, rejWeaponStrike = 0,
|
||||
rejNoDmg = 0, rejAoe = 0, rejGate = 0, rejLongCD = 0, rejLowRank = 0;
|
||||
|
||||
// For diagnosis: collect IDs of spells we'd expect to keep (Fireball,
|
||||
// Frostbolt, Lightning Bolt, Mind Blast, Shadow Bolt, etc.) but that
|
||||
// we instead reject. The sample is small so per-spell logging is OK.
|
||||
auto trackProbe = [&](uint32 spellId, char const* phase)
|
||||
{
|
||||
// Only log "interesting" spell IDs to avoid 177-line spam per image.
|
||||
// These are first-rank IDs of common cross-class single-target nukes.
|
||||
static constexpr uint32 probes[] = {
|
||||
133, 116, 30451, // Mage: Fireball, Frostbolt, Arcane Blast
|
||||
5143, // Mage: Arcane Missiles (channel via PERIODIC_TRIGGER_SPELL)
|
||||
403, 529, 8042, // Shaman: Lightning Bolt, Chain Lightning, Earth Shock
|
||||
585, 14914, // Priest: Smite, Holy Fire
|
||||
8092, 15407, // Priest: Mind Blast, Mind Flay
|
||||
686, 348, // Warlock: Shadow Bolt, Immolate (DoT w/ cast time)
|
||||
5176, 2912, // Druid: Wrath, Starfire
|
||||
635, // Paladin: Holy Light
|
||||
};
|
||||
for (uint32 probe : probes)
|
||||
{
|
||||
if (spellId == probe)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage probe spell={} phase={}",
|
||||
spellId, phase);
|
||||
return;
|
||||
}
|
||||
// Also walk rank chain: if the spellbook has rank N of probe,
|
||||
// probe matches via GetFirstRankSpell.
|
||||
if (SpellInfo const* si = sSpellMgr->GetSpellInfo(spellId))
|
||||
if (SpellInfo const* first = si->GetFirstRankSpell())
|
||||
if (first->Id == probe)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage probe spell={} (rank of {}) phase={}",
|
||||
spellId, probe, phase);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (auto const& kv : owner->GetSpellMap())
|
||||
{
|
||||
++scanned;
|
||||
uint32 spellId = kv.first;
|
||||
PlayerSpell const* ps = kv.second;
|
||||
if (!ps || ps->State == PLAYERSPELL_REMOVED || !ps->Active)
|
||||
{
|
||||
++rejInactive;
|
||||
trackProbe(spellId, "inactive");
|
||||
continue;
|
||||
}
|
||||
|
||||
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
|
||||
if (!spellInfo)
|
||||
continue;
|
||||
|
||||
// Spec (per user): damaging single-target spells, instant or
|
||||
// cast-time or channeled all OK, no melee/ranged "strike" style
|
||||
// weapon-attack abilities, and no long-cooldown spells (>10s) so
|
||||
// the image cycles through a varied rotation rather than blowing
|
||||
// a 2-min cooldown once.
|
||||
if (spellInfo->IsPassive()) { ++rejPassive; trackProbe(spellId, "passive"); continue; }
|
||||
if (!IsDamagingForMirrorImage(spellInfo)) { ++rejNoDmg; trackProbe(spellId, "noDmg"); continue; }
|
||||
if (spellInfo->IsAffectingArea()) { ++rejAoe; trackProbe(spellId, "aoe"); continue; }
|
||||
if (spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE
|
||||
|| spellInfo->DmgClass == SPELL_DAMAGE_CLASS_RANGED) { ++rejWeaponStrike; trackProbe(spellId, "weaponStrike"); continue; }
|
||||
// Reject anything with a base cooldown longer than 10s (either
|
||||
// RecoveryTime or CategoryRecoveryTime). A 0/very-short CD is
|
||||
// fine. The mage Mirror Image only lives for 30s, so anything
|
||||
// gated by a long CD would only ever fire once anyway.
|
||||
uint32 cd = std::max(spellInfo->RecoveryTime, spellInfo->CategoryRecoveryTime);
|
||||
if (cd > 10000) { ++rejLongCD; trackProbe(spellId, "longCD"); continue; }
|
||||
|
||||
// Skip spells the image would never realistically be able to
|
||||
// cast successfully or whose side-effects don't make sense on a
|
||||
// pet (totems, summons, item / reagent / focus requirements,
|
||||
// ranged-weapon / shapeshift / stealth gates, profession spells,
|
||||
// teleports, etc.).
|
||||
char const* gateReason = nullptr;
|
||||
if (spellInfo->RequiresSpellFocus) gateReason = "focus";
|
||||
else if (spellInfo->Reagent[0] > 0) gateReason = "reagent";
|
||||
else if (spellInfo->Stances || spellInfo->StancesNot) gateReason = "stance";
|
||||
else if (spellInfo->EquippedItemClass >= 0) gateReason = "equipped";
|
||||
else if (spellInfo->IsCooldownStartedOnEvent()) gateReason = "cdEvent";
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE)) gateReason = "attrPassive";
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) gateReason = "attrHidden";
|
||||
// SPELL_ATTR0_NOT_SHAPESHIFTED is intentionally NOT a gate -- it
|
||||
// means "cannot be cast while caster IS shapeshifted", not "this
|
||||
// spell requires a shapeshift". The attribute is set on every
|
||||
// standard caster nuke (Fireball, Frostbolt, Lightning Bolt,
|
||||
// Shadow Bolt, etc.) and Mirror Images are never shapeshifted,
|
||||
// so the runtime check trivially passes for them. Filtering on
|
||||
// it here was the bug that left the curated list empty.
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_ONLY_STEALTHED)) gateReason = "attrStealth";
|
||||
// SPELL_ATTR1_NO_AUTOCAST_AI is intentionally NOT a gate -- it is set
|
||||
// on most player nukes (Fireball / Lightning Bolt / Shadow Bolt) to
|
||||
// stop class pets from auto-casting them. Mirror Images are
|
||||
// server-curated player-spell mimics, so we WANT to auto-cast
|
||||
// those exact spells.
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE)) gateReason = "attrFailImmune";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON)) gateReason = "fxSummon";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON_PET)) gateReason = "fxSummonPet";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_TELEPORT_UNITS)) gateReason = "fxTeleport";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_TRANS_DOOR)) gateReason = "fxTransDoor";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_OPEN_LOCK)) gateReason = "fxOpenLock";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_INSTAKILL)) gateReason = "fxInstakill";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL)) gateReason = "fxLearn";
|
||||
if (gateReason) { ++rejGate; trackProbe(spellId, gateReason); continue; }
|
||||
|
||||
// Ignore spell ranks below the highest the player owns -- the
|
||||
// spellbook contains all learned ranks; we want only the latest.
|
||||
if (SpellInfo const* nextRank = spellInfo->GetNextRankSpell())
|
||||
if (owner->HasSpell(nextRank->Id))
|
||||
{ ++rejLowRank; trackProbe(spellId, "lowRank"); continue; }
|
||||
|
||||
++kept;
|
||||
curated.push_back(spellId);
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage kept spell={} ({})",
|
||||
spellId,
|
||||
spellInfo->SpellName[0] ? spellInfo->SpellName[0] : "?");
|
||||
}
|
||||
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild owner={} scanned={} kept={} "
|
||||
"rejInactive={} rejPassive={} rejNoDmg={} rejAoe={} rejWeaponStrike={} rejLongCD={} rejGate={} rejLowRank={}",
|
||||
owner->GetName(), scanned, kept,
|
||||
rejInactive, rejPassive, rejNoDmg, rejAoe, rejWeaponStrike, rejLongCD, rejGate, rejLowRank);
|
||||
|
||||
if (curated.empty())
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild for {} produced empty list, keeping stock 59637/59638",
|
||||
owner->GetName());
|
||||
return; // keep stock 59637 / 59638 fallback
|
||||
}
|
||||
|
||||
// Log the first few spell IDs we picked so we can verify the list.
|
||||
std::string sample;
|
||||
for (size_t i = 0; i < curated.size() && i < 8; ++i)
|
||||
{
|
||||
if (!sample.empty())
|
||||
sample += ',';
|
||||
sample += std::to_string(curated[i]);
|
||||
}
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild swapping spells for {} (sample: {})",
|
||||
owner->GetName(), sample);
|
||||
|
||||
spells.swap(curated);
|
||||
}
|
||||
|
||||
void InitializeAI() override
|
||||
{
|
||||
CasterAI::InitializeAI();
|
||||
|
||||
// Fractured / Paragon: do the spellbook rebuild EARLY -- before
|
||||
// owner->CastSpell(CLONE_ME) and before any threat-list inheritance,
|
||||
// because any of those can synchronously fire JustEngagedWith on the
|
||||
// image and cause CasterAI::JustEngagedWith to schedule events from
|
||||
// the stock [59638 Frostbolt, 59637 Fireblast] m_spells[] entries
|
||||
// before our swap takes effect. The override of JustEngagedWith
|
||||
// below also reasserts the swap + flushes events, so even if a later
|
||||
// combat-entry path fires JustEngagedWith again it picks up the
|
||||
// curated list.
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner))
|
||||
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
|
||||
|
||||
_delayAttack = true;
|
||||
me->m_Events.AddEventAtOffset([this]()
|
||||
{
|
||||
@@ -76,11 +293,21 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
|
||||
Unit* owner = me->GetOwner();
|
||||
if (!owner)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage InitializeAI: no owner, spells.size={} (stock)",
|
||||
spells.size());
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone Me!
|
||||
owner->CastSpell(me, SPELL_MAGE_CLONE_ME, true);
|
||||
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage InitializeAI: post-rebuild spells.size={} first={}",
|
||||
spells.size(),
|
||||
spells.empty() ? 0u : spells.front());
|
||||
|
||||
// xinef: Glyph of Mirror Image (4th copy)
|
||||
float angle = 0.0f;
|
||||
switch (me->GetUInt32Value(UNIT_CREATED_BY_SPELL))
|
||||
@@ -139,6 +366,37 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
me->m_Events.AddEventAtOffset(new DeathEvent(*me), 29500ms);
|
||||
}
|
||||
|
||||
void JustEngagedWith(Unit* who) override
|
||||
{
|
||||
// Fractured / Paragon: re-apply the spellbook rebuild here as well,
|
||||
// because the engagement can fire synchronously from inside
|
||||
// InitializeAI (via owner->CastSpell(CLONE_ME) or summon-side threat
|
||||
// propagation) BEFORE InitializeAI's own rebuild call has run.
|
||||
// Re-running it here is cheap and idempotent: the curated list is
|
||||
// re-derived from the owner's current spellbook, and we wipe any
|
||||
// previously-scheduled events so the stock 59637 / 59638 entries
|
||||
// CasterAI may already have queued get evicted before scheduling.
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner))
|
||||
{
|
||||
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
|
||||
events.Reset();
|
||||
}
|
||||
|
||||
std::string sample;
|
||||
for (size_t i = 0; i < spells.size() && i < 8; ++i)
|
||||
{
|
||||
if (!sample.empty()) sample += ',';
|
||||
sample += std::to_string(spells[i]);
|
||||
}
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage JustEngagedWith: spells.size={} sample=[{}] who={}",
|
||||
spells.size(), sample, who ? who->GetName() : "<null>");
|
||||
|
||||
CasterAI::JustEngagedWith(who);
|
||||
}
|
||||
|
||||
// Do not reload Creature templates on evade mode enter - prevent visual lost
|
||||
void EnterEvadeMode(EvadeReason /*why*/) override
|
||||
{
|
||||
@@ -217,10 +475,61 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
if (me->HasUnitState(UNIT_STATE_CASTING))
|
||||
return;
|
||||
|
||||
if (uint32 spellId = events.ExecuteEvent())
|
||||
if (uint32 queuedId = events.ExecuteEvent())
|
||||
{
|
||||
events.RescheduleEvent(spellId, spellId == 59637 ? 6500ms : 2500ms);
|
||||
me->CastSpell(me->GetVictim(), spellId, false);
|
||||
// Fractured / Paragon: when the curated spellbook list is in
|
||||
// play, pick a random spell from it for THIS cast instead of
|
||||
// using the EventMap-scheduled spellId directly. The events
|
||||
// queue (populated by CasterAI::JustEngagedWith) is otherwise
|
||||
// deterministic for our small list and the image ends up
|
||||
// rotating in lockstep; randomizing here makes each image
|
||||
// (and each cast) feel like a mage ad-libbing from the
|
||||
// player's repertoire.
|
||||
uint32 actualId = queuedId;
|
||||
bool isParagon = false;
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner) && !spells.empty())
|
||||
{
|
||||
actualId = spells[urand(0, uint32(spells.size()) - 1)];
|
||||
isParagon = true;
|
||||
}
|
||||
|
||||
// Reschedule the queue based on the spell we actually cast,
|
||||
// not the one originally queued. For channeled spells this
|
||||
// matters: Arcane Missiles is a 5s channel, so if we keep
|
||||
// rescheduling every 2.5s the image is always either mid-
|
||||
// channel or immediately re-rolling for another channel,
|
||||
// and over four images you see effectively continuous
|
||||
// Arcane Missiles. Wait for cast/channel to finish + a
|
||||
// small breather before picking again.
|
||||
Milliseconds nextDelay = (queuedId == 59637 ? 6500ms : 2500ms);
|
||||
if (isParagon)
|
||||
{
|
||||
if (SpellInfo const* picked = sSpellMgr->GetSpellInfo(actualId))
|
||||
{
|
||||
uint32 castMs = picked->CalcCastTime();
|
||||
uint32 chanMs = 0;
|
||||
if (picked->IsChanneled())
|
||||
{
|
||||
int32 dur = picked->GetDuration();
|
||||
if (dur > 0)
|
||||
chanMs = uint32(dur);
|
||||
}
|
||||
uint32 minMs = std::max(castMs, chanMs) + 750; // breather
|
||||
if (Milliseconds(minMs) > nextDelay)
|
||||
nextDelay = Milliseconds(minMs);
|
||||
}
|
||||
}
|
||||
events.RescheduleEvent(queuedId, nextDelay);
|
||||
|
||||
SpellCastResult castRes = me->CastSpell(me->GetVictim(), actualId, false);
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage cast spell={} victim={} result={} nextDelay={}ms",
|
||||
actualId,
|
||||
me->GetVictim() ? me->GetVictim()->GetName() : "<null>",
|
||||
uint32(castRes),
|
||||
uint32(nextDelay.count()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -490,12 +490,22 @@ class spell_mage_cold_snap : public SpellScript
|
||||
{
|
||||
Player* caster = GetCaster()->ToPlayer();
|
||||
// immediately finishes the cooldown on Frost spells
|
||||
|
||||
//
|
||||
// Fractured / Paragon: ParagonFamilyMatches() drops the
|
||||
// SpellFamilyName == SPELLFAMILY_MAGE gate when the caster is a
|
||||
// CLASS_PARAGON player AND Paragon.WildcardFamilyMatching is on,
|
||||
// so any Frost-school spell in the Paragon's spellbook with a real
|
||||
// recovery time (Howling Blast, Frost Shock, Frost Trap, etc.)
|
||||
// also gets its cooldown wiped. Stock Mage callers fall through to
|
||||
// strict family-name equality and observe identical behavior.
|
||||
PlayerSpellMap const& spellMap = caster->GetSpellMap();
|
||||
for (PlayerSpellMap::const_iterator itr = spellMap.begin(); itr != spellMap.end(); ++itr)
|
||||
{
|
||||
SpellInfo const* spellInfo = sSpellMgr->AssertSpellInfo(itr->first);
|
||||
if (spellInfo->SpellFamilyName == SPELLFAMILY_MAGE && (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST) && spellInfo->Id != SPELL_MAGE_COLD_SNAP && spellInfo->GetRecoveryTime() > 0)
|
||||
if (ParagonFamilyMatches(caster, SPELLFAMILY_MAGE, spellInfo->SpellFamilyName)
|
||||
&& (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST)
|
||||
&& spellInfo->Id != SPELL_MAGE_COLD_SNAP
|
||||
&& spellInfo->GetRecoveryTime() > 0)
|
||||
{
|
||||
SpellCooldowns::iterator citr = caster->GetSpellCooldownMap().find(spellInfo->Id);
|
||||
if (citr != caster->GetSpellCooldownMap().end() && citr->second.needSendToClient)
|
||||
@@ -946,6 +956,107 @@ class spell_mage_summon_water_elemental : public SpellScript
|
||||
}
|
||||
};
|
||||
|
||||
// 44543, 44545 - Fingers of Frost (talent ranks - the proc-trigger aura, NOT the
|
||||
// 74396 buff aura that is APPLIED when this talent fires).
|
||||
//
|
||||
// Stock spell_proc gates this talent by SpellFamilyName=MAGE plus a
|
||||
// SpellFamilyMask covering the Mage Frost spells that count as "chill-effect
|
||||
// dealers" (Frostbolt / Frost Nova / Cone of Cold / Blizzard / Frostfire Bolt /
|
||||
// Deep Freeze etc.). For Paragon characters with `Paragon.WildcardFamilyMatching`
|
||||
// enabled, we relax the spell_proc row to wildcard family/mask + SchoolMask=
|
||||
// FROST + SpellTypeMask=DAMAGE so that any Frost-school damage spell (DK Howling
|
||||
// Blast / Icy Touch, Hunter Frost Trap / Wing Clip-as-frost, Shaman Frost Shock,
|
||||
// Druid Hibernate damage payload, etc.) reaches this CheckProc; this script
|
||||
// then re-enforces the stock Mage allowlist for non-Paragon owners and lets
|
||||
// Paragons through unconditionally (the FROST + DAMAGE gate already happens at
|
||||
// the spell_proc layer, so any spell reaching us here is safe to accept).
|
||||
class spell_mage_fingers_of_frost_talent : public AuraScript
|
||||
{
|
||||
PrepareAuraScript(spell_mage_fingers_of_frost_talent);
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock Mage allowlist: re-derive from this talent's own effect-0
|
||||
// SpellClassMask so behavior matches the original auto-generated
|
||||
// proc filter exactly (no risk of mask drift across DBC versions).
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
|
||||
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
|
||||
return true;
|
||||
|
||||
return IsParagonWildcardCaller(GetUnitOwner());
|
||||
}
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_mage_fingers_of_frost_talent::CheckProc);
|
||||
}
|
||||
};
|
||||
|
||||
// 11071, 12496, 12497 - Frostbite (talent ranks - the proc-trigger aura that
|
||||
// chains into 12494 Frostbite freeze).
|
||||
//
|
||||
// Stock spell_proc (auto-generated from DBC) gates this talent by Mage family +
|
||||
// the talent's effect SpellClassMask (Mage Frost slow-applying spells). For
|
||||
// Paragon characters we relax the row to SchoolMask=FROST wildcard so that
|
||||
// chill-applying Frost spells from any class can reach this CheckProc; the
|
||||
// Paragon path additionally requires the proc spell to actually apply a slow
|
||||
// (SPELL_AURA_MOD_DECREASE_SPEED) so that pure damage Frost spells without a
|
||||
// chill component (e.g. raw Ice Lance on a non-frozen target) do NOT freeze.
|
||||
// Stock Mage owners get the original behavior re-enforced here.
|
||||
class spell_mage_frostbite : public AuraScript
|
||||
{
|
||||
PrepareAuraScript(spell_mage_frostbite);
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock Mage path: re-derive from this talent's own effect-0
|
||||
// SpellClassMask so behavior matches the original auto-generated
|
||||
// proc filter exactly (no risk of mask drift across DBC versions).
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
|
||||
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
|
||||
return true;
|
||||
|
||||
if (!IsParagonWildcardCaller(GetUnitOwner()))
|
||||
return false;
|
||||
|
||||
// Paragon path: any Frost-school spell that applies a chill effect
|
||||
// (decrease-speed aura). The spell_proc row already gates by
|
||||
// SchoolMask=FROST so we only need to verify chill semantics here.
|
||||
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
|
||||
{
|
||||
if (procSpell->Effects[i].ApplyAuraName == SPELL_AURA_MOD_DECREASE_SPEED)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also accept the Improved-Blizzard-style cross-class case where the
|
||||
// chill is applied by a separate triggered aura: if the proc spell's
|
||||
// damage hit landed and the target already has a chill from us, treat
|
||||
// it as eligible. Cheap and matches player expectations for Paragon.
|
||||
if (Unit* procTarget = eventInfo.GetProcTarget())
|
||||
{
|
||||
Unit::AuraEffectList const& slows = procTarget->GetAuraEffectsByType(SPELL_AURA_MOD_DECREASE_SPEED);
|
||||
for (AuraEffect const* slowEff : slows)
|
||||
if (slowEff->GetCasterGUID() == GetUnitOwner()->GetGUID())
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_mage_frostbite::CheckProc);
|
||||
}
|
||||
};
|
||||
|
||||
// 74396 - Fingers of Frost
|
||||
class spell_mage_fingers_of_frost : public AuraScript
|
||||
{
|
||||
@@ -1631,5 +1742,7 @@ void AddSC_mage_spell_scripts()
|
||||
RegisterSpellScript(spell_mage_polymorph_cast_visual);
|
||||
RegisterSpellScript(spell_mage_summon_water_elemental);
|
||||
RegisterSpellScript(spell_mage_fingers_of_frost);
|
||||
RegisterSpellScript(spell_mage_fingers_of_frost_talent);
|
||||
RegisterSpellScript(spell_mage_frostbite);
|
||||
RegisterSpellScript(spell_mage_magic_absorption);
|
||||
}
|
||||
|
||||
@@ -1005,12 +1005,38 @@ class spell_pri_vampiric_embrace : public AuraScript
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
// Not proc from Mind Sear
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
return !(procSpell->SpellFamilyFlags[1] & 0x80000);
|
||||
// Stock: filter Mind Sear (the damage-tick spell carries this
|
||||
// SpellFamilyFlags[1] bit; the channel itself is filtered by the
|
||||
// standard data-row mask). Kept as a bit-test so the stock priest
|
||||
// path is byte-identical to before this change.
|
||||
if (procSpell->SpellFamilyFlags[1] & 0x80000)
|
||||
return false;
|
||||
|
||||
// Fractured / Paragon: any single-target Shadow-school damage spell
|
||||
// procs Vampiric Embrace, not just Priest Shadow spells. The
|
||||
// SchoolMask=Shadow gate is enforced by the spell_proc data row
|
||||
// (SchoolMask=32). The data-row family/mask was wildcarded in
|
||||
// mod-paragon's 2026_05_11_01.sql update so this CheckProc fires for
|
||||
// cross-family Shadow spells; here we add the single-target
|
||||
// requirement (Mind Sear was already filtered above; this also
|
||||
// catches AoE Warlock Shadow spells like Seed of Corruption,
|
||||
// Hellfire, etc. that a Paragon could otherwise cast).
|
||||
if (IsParagonWildcardCaller(GetTarget()))
|
||||
return !procSpell->IsAffectingArea();
|
||||
|
||||
// Stock priest path: re-enforce the original Priest Shadow damage
|
||||
// gate that used to live entirely in the data row. Without this,
|
||||
// wildcarding the data row would let item-cast Shadow effects
|
||||
// (consumables, trinkets) accidentally proc VE on stock priests.
|
||||
if (procSpell->SpellFamilyName != SPELLFAMILY_PRIEST)
|
||||
return false;
|
||||
return (procSpell->SpellFamilyFlags[0] & 0x0280A010)
|
||||
|| (procSpell->SpellFamilyFlags[1] & 0x00002402)
|
||||
|| (procSpell->SpellFamilyFlags[2] & 0x00000008);
|
||||
}
|
||||
|
||||
void HandleProc(AuraEffect const* aurEff, ProcEventInfo& eventInfo)
|
||||
|
||||
@@ -1790,6 +1790,44 @@ class spell_sha_maelstrom_weapon : public AuraScript
|
||||
});
|
||||
}
|
||||
|
||||
// Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard
|
||||
// family/mask) so cross-class spells can reach this CheckProc. We
|
||||
// restore the original Shaman gating here for stock callers and add
|
||||
// the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist
|
||||
// mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp.
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock allowlist (Shaman): Lightning Bolt, Chain Lightning,
|
||||
// Lesser Healing Wave, Healing Wave, Hex. Encoded as the original
|
||||
// SpellFamilyMask values from the pre-relaxation spell_proc row
|
||||
// (Mask0 = 451, Mask1 = 32768).
|
||||
bool stockMatch = procSpell->SpellFamilyName == SPELLFAMILY_SHAMAN
|
||||
&& ((procSpell->SpellFamilyFlags[0] & 451u)
|
||||
|| (procSpell->SpellFamilyFlags[1] & 32768u));
|
||||
if (stockMatch)
|
||||
return true;
|
||||
|
||||
if (!IsParagonWildcardCaller(GetUnitOwner()))
|
||||
return false;
|
||||
|
||||
// Paragon path: also accept the curated Mage cast-time nukes.
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE)
|
||||
{
|
||||
SpellInfo const* first = procSpell->GetFirstRankSpell();
|
||||
uint32 firstId = first ? first->Id : procSpell->Id;
|
||||
if (firstId == 133 /*Fireball*/
|
||||
|| firstId == 116 /*Frostbolt*/
|
||||
|| firstId == 30451 /*Arcane Blast*/)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void HandleBonus(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
|
||||
{
|
||||
if (GetStackAmount() < int32(GetSpellInfo()->StackAmount))
|
||||
@@ -1805,6 +1843,7 @@ class spell_sha_maelstrom_weapon : public AuraScript
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_sha_maelstrom_weapon::CheckProc);
|
||||
OnEffectApply += AuraEffectApplyFn(spell_sha_maelstrom_weapon::HandleBonus, EFFECT_0, SPELL_AURA_ADD_PCT_MODIFIER, AURA_EFFECT_HANDLE_CHANGE_AMOUNT);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user