/* * 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 . */ /* * 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 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() : ""); 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() : "", uint32(castRes), uint32(nextDelay.count())); } } }; void AddSC_mage_pet_scripts() { RegisterCreatureAI(npc_pet_mage_mirror_image); }