Files
Fractured/src/server/scripts/Pet/pet_mage.cpp
T
Docker Build e649402163 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>
2026-05-11 14:54:05 -04:00

541 lines
24 KiB
C++

/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Ordered alphabetically using scriptname.
* Scriptnames of files in this file should be prefixed with "npc_pet_mag_".
*/
#include "CombatAI.h"
#include "CreatureScript.h"
#include "Pet.h"
#include "Player.h"
#include "ScriptedCreature.h"
#include "SpellAuras.h"
#include "SpellMgr.h"
enum MageSpells
{
SPELL_MAGE_CLONE_ME = 45204,
SPELL_MAGE_MASTERS_THREAT_LIST = 58838,
SPELL_PET_HIT_SCALING = 61013,
SPELL_SUMMON_MIRROR_IMAGE1 = 58831,
SPELL_SUMMON_MIRROR_IMAGE2 = 58833,
SPELL_SUMMON_MIRROR_IMAGE3 = 58834,
SPELL_SUMMON_MIRROR_IMAGE_GLYPH = 65047
};
class DeathEvent : public BasicEvent
{
public:
DeathEvent(Creature& owner) : BasicEvent(), _owner(owner) { }
bool Execute(uint64 /*eventTime*/, uint32 /*diff*/) override
{
Unit::Kill(&_owner, &_owner);
return true;
}
private:
Creature& _owner;
};
struct npc_pet_mage_mirror_image : CasterAI
{
npc_pet_mage_mirror_image(Creature* creature) : CasterAI(creature) { }
uint32 selectionTimer;
ObjectGuid _ebonGargoyleGUID;
uint32 checktarget;
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]()
{
_delayAttack = false;
}, 1200ms);
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))
{
case SPELL_SUMMON_MIRROR_IMAGE1:
angle = 0.5f * M_PI;
break;
case SPELL_SUMMON_MIRROR_IMAGE2:
angle = M_PI;
break;
case SPELL_SUMMON_MIRROR_IMAGE3:
angle = 1.5f * M_PI;
break;
}
((Minion*)me)->SetFollowAngle(angle);
if (owner->IsInCombat())
me->NearTeleportTo(me->GetPositionX() + cos(angle)*dist, me->GetPositionY() + std::sin(angle)*dist, me->GetPositionZ(), me->GetOrientation(), false, false, false, false);
else
me->GetMotionMaster()->MoveFollow(owner, PET_FOLLOW_DIST, me->GetFollowAngle(), MOTION_SLOT_ACTIVE);
me->SetReactState(REACT_DEFENSIVE);
// Xinef: Inherit Master's Threat List (not yet implemented)
//owner->CastSpell((Unit*)nullptr, SPELL_MAGE_MASTERS_THREAT_LIST, true);
for (auto const& pair : owner->GetThreatMgr().GetThreatenedByMeList())
{
if (Unit* unit = pair.second->GetOwner())
unit->GetThreatMgr().AddThreat(me, pair.second->GetThreat());
}
_ebonGargoyleGUID.Clear();
// Xinef: copy caster auras
Unit::VisibleAuraMap const* visibleAuraMap = owner->GetVisibleAuras();
for (Unit::VisibleAuraMap::const_iterator itr = visibleAuraMap->begin(); itr != visibleAuraMap->end(); ++itr)
if (Aura* visAura = itr->second->GetBase())
{
// Ebon Gargoyle
if (visAura->GetId() == 49206 && me->GetUInt32Value(UNIT_CREATED_BY_SPELL) == SPELL_SUMMON_MIRROR_IMAGE1)
{
if (Unit* gargoyle = visAura->GetCaster())
_ebonGargoyleGUID = gargoyle->GetGUID();
continue;
}
SpellScriptsBounds bounds = sObjectMgr->GetSpellScriptsBounds(visAura->GetId());
if (bounds.first != bounds.second)
continue;
std::vector<int32> const* spellTriggered = sSpellMgr->GetSpellLinked(visAura->GetId() + SPELL_LINK_AURA);
if (!spellTriggered || !spellTriggered->empty())
continue;
if (Aura* newAura = me->AddAura(visAura->GetId(), me))
newAura->SetDuration(visAura->GetDuration());
}
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
{
if (me->IsInEvadeMode() || !me->IsAlive())
return;
Unit* owner = me->GetCharmerOrOwner();
me->CombatStop(true);
if (owner && !me->HasUnitState(UNIT_STATE_FOLLOW))
{
me->GetMotionMaster()->Clear(false);
me->GetMotionMaster()->MoveFollow(owner, PET_FOLLOW_DIST, me->GetFollowAngle(), MOTION_SLOT_ACTIVE);
}
}
void MySelectNextTarget()
{
if (_ebonGargoyleGUID)
{
Unit* gargoyle = ObjectAccessor::GetUnit(*me, _ebonGargoyleGUID);
if (gargoyle && gargoyle->GetAI())
gargoyle->GetAI()->AttackStart(me);
_ebonGargoyleGUID.Clear();
}
Unit* owner = me->GetOwner();
if (owner && owner->IsPlayer())
{
Unit* selection = owner->ToPlayer()->GetSelectedUnit();
if (selection && me->CanSeeOrDetect(selection))
{
me->GetThreatMgr().ResetAllThreat();
me->AddThreat(selection, 1000000.0f);
if (owner->IsInCombat())
AttackStart(selection);
}
if (!owner->IsInCombat() && !me->GetVictim())
EnterEvadeMode(EVADE_REASON_OTHER);
}
}
void Reset() override
{
selectionTimer = 0;
checktarget = 0;
}
void UpdateAI(uint32 diff) override
{
if (_delayAttack)
return;
events.Update(diff);
if (!me->IsInCombat() || !me->GetVictim())
{
MySelectNextTarget();
return;
}
checktarget += diff;
if (checktarget >= 1000)
{
if (!me->GetVictim()->IsAlive() || me->GetVictim()->HasBreakableByDamageCrowdControlAura() || !me->CanSeeOrDetect(me->GetVictim()))
{
MySelectNextTarget();
me->InterruptNonMeleeSpells(true);
return;
}
}
if (me->HasUnitState(UNIT_STATE_CASTING))
return;
if (uint32 queuedId = events.ExecuteEvent())
{
// 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()));
}
}
};
void AddSC_mage_pet_scripts()
{
RegisterCreatureAI(npc_pet_mage_mirror_image);
}