e649402163
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>
541 lines
24 KiB
C++
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);
|
|
}
|