/* * mod-paragon — Paragon (class 12) class hooks * * See README for design. This file wires Player::IsClass / HasActivePowerType * so Paragon can reuse other classes' mechanics in narrowly scoped contexts. */ #include "Chat.h" #include "Config.h" #include "Creature.h" #include "CreatureData.h" #include "GameTime.h" #include "Log.h" #include "ObjectGuid.h" #include "Pet.h" #include "Player.h" #include "ScriptMgr.h" #include "SharedDefines.h" #include "SpellScript.h" #include "SpellScriptLoader.h" #include "WorldPacket.h" #include "WorldSession.h" #include #include #include class Paragon_PlayerScript : public PlayerScript { public: Paragon_PlayerScript() : PlayerScript("Paragon_PlayerScript", { PLAYERHOOK_ON_PLAYER_IS_CLASS, PLAYERHOOK_ON_PLAYER_HAS_ACTIVE_POWER_TYPE, PLAYERHOOK_ON_UPDATE, PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_LOGOUT, PLAYERHOOK_ON_AFTER_UPDATE_MAX_POWER }) { LOG_INFO("module", "[paragon] Paragon_PlayerScript registered " "(MultiResource.HasActivePowers={})", sConfigMgr->GetOption("Paragon.MultiResource.HasActivePowers", true)); } [[nodiscard]] Optional OnPlayerIsClass(Player const* player, Classes unitClass, ClassContext context) override { if (!player || player->getClass() != CLASS_PARAGON) return std::nullopt; // ============================================================ // Ability stack -- claim ALL nine vanilla classes. // ============================================================ // CLASS_CONTEXT_ABILITY is read by every class-specific spell // gate in core / scripts: DK rune mechanics (Spell.cpp, // SpellEffects.cpp, spell_dk.cpp, SpellAuraEffects.cpp), // Warrior Titan's Grip / Bladestorm (Player.cpp 3783, 15432, // PlayerUpdates.cpp 1547), Paladin Rebuke (Player.cpp 15441), // Shaman dual-wield bookkeeping (Player.cpp 5028), Hunter pet // / Hunter's Mark gates (spell_item.cpp 3718), Druid Insect // Swarm / Wild Growth (SpellAuraEffects.cpp 2153, 2232), // Priest Spirit of Redemption out-of-bounds check (Unit.cpp // 14238), Rogue pickpocketing (LootHandler.cpp 86/165/385, // Vehicle.cpp 80). Paragon learns abilities from every class // through Character Advancement, so claiming all of them lets // every gated spell script execute its class-specific branch // for our players. The only downside is double-pathed scripts // (e.g. a spell with both warrior and rogue branches) will // pick whichever the script tests first -- acceptable. if (context == CLASS_CONTEXT_ABILITY) return true; // ============================================================ // Reactive melee states. // ============================================================ // Warrior dodge -> AURA_STATE_DEFENSE (Overpower window). // Hunter parry -> AURA_STATE_HUNTER_PARRY (Counterattack). // We intentionally do NOT claim CLASS_ROGUE here: // Unit::ProcDamageAndSpellFor (Unit.cpp 12824) skips the // generic AURA_STATE_DEFENSE update on dodge for rogues so // Riposte can take over. Claiming rogue would silently kill // Overpower for Paragon, and Riposte already works for us via // the warrior-style state we already grant. if (context == CLASS_CONTEXT_ABILITY_REACTIVE) { if (unitClass == CLASS_WARRIOR || unitClass == CLASS_HUNTER) return true; } // ============================================================ // Pet ownership contexts. // ============================================================ // CLASS_CONTEXT_PET is read by Pet::AddToWorld, Pet::CreateBase // AtCreatureInfo, Pet::InitStatsForLevel (twice -- the // MAX_PET_TYPE bootstrap branch and the per-class attack-time // scaling), Pet::IsPermanentPetFor, Player::SummonPet, // Player::CanResummonPet, Spell::EffectTameCreature, // SpellEffects.cpp (CreateTamedPet debug effects, Eyes of the // Beast), spell_generic.cpp 1760 (charm-as-pet conversion), // and PlayerGossip.cpp's hunter stable check. // // The cleanest disambiguation is by the *active pet's* shape: // HUNTER_PET -> hunter (beast tame) // SUMMON_PET + DEMON type -> warlock (Imp/VW/Succ/...) // SUMMON_PET + UNDEAD type -> DK ghoul / Army of Dead // SUMMON_PET + ELEMENTAL type -> mage water / shaman fire // For HUNTER specifically the no-pet case is also claimed so // Tame Beast's EffectTameCreature gate passes during cast. if (context == CLASS_CONTEXT_PET) { Pet const* activePet = const_cast(player)->GetPet(); // Hunter beast: claim during taming OR when a HUNTER_PET is // already active. This is what makes Tame Beast / Call Pet // / pet stable / Counterattack pet aura feedback work. if (unitClass == CLASS_HUNTER) { if (!activePet || activePet->getPetType() == HUNTER_PET) return true; return std::nullopt; } // All other classes only claim when an active SUMMON_PET is // present. We then disambiguate by the creature's type // because warlock / DK / mage / shaman all use SUMMON_PET. if (!activePet || activePet->getPetType() != SUMMON_PET) return std::nullopt; CreatureTemplate const* tmpl = activePet->GetCreatureTemplate(); if (!tmpl) return std::nullopt; switch (unitClass) { case CLASS_WARLOCK: // Drives Master Demonologist / Demonic Knowledge / // Demonic Pact propagation, last-pet-spell tracking // (Pet.cpp 112), and IsPermanentPetFor (Pet.cpp // 2288) so demon pets persist across logins. if (tmpl->type == CREATURE_TYPE_DEMON) return true; break; case CLASS_DEATH_KNIGHT: // Risen Ghoul + Army of the Dead. Player.cpp 14354 // and Pet.cpp 243 / 1046 / 2290 read this; without // it the ghoul is invisible to the owner mid-load // and ScriptedAI hooks on the ghoul mis-route. if (tmpl->type == CREATURE_TYPE_UNDEAD) return true; break; case CLASS_MAGE: // Glyph-of-Eternal-Water permanent Water Elemental // (entry 510, 37994). Used by Pet.cpp 1047/2292. if (tmpl->type == CREATURE_TYPE_ELEMENTAL) return true; break; case CLASS_SHAMAN: // Fire Elemental / Earth Elemental. The base // engine spawns these as creatures rather than // proper Pet instances in most code paths, so the // claim mostly matters for the Pet.cpp 1045 stat // bootstrap when one is loaded as a SUMMON_PET. if (tmpl->type == CREATURE_TYPE_ELEMENTAL) return true; break; default: break; } return std::nullopt; } // Warlock pet-charm context (Enslave Demon -- Unit.cpp 14828, // 14894, 15025). Without this claim, charming a demon as a // Paragon doesn't get the warlock-flavor charm semantics // (faction-set-on-charm, action-bar layout, charm-break logic). if (unitClass == CLASS_WARLOCK && context == CLASS_CONTEXT_PET_CHARM) return true; // ============================================================ // Equipment contexts. // ============================================================ // CLASS_CONTEXT_EQUIP_RELIC: PlayerStorage.cpp 224-240 + // 2475-2493. Routes Librams/Idols/Totems/Misc/Sigils into // EQUIPMENT_SLOT_RANGED for the matching class. Claim every // relic-bearing class so a Paragon can drop any of them into // the ranged slot. if (context == CLASS_CONTEXT_EQUIP_RELIC) { switch (unitClass) { case CLASS_PALADIN: case CLASS_DRUID: case CLASS_SHAMAN: case CLASS_WARLOCK: case CLASS_DEATH_KNIGHT: return true; default: break; } } // CLASS_CONTEXT_EQUIP_ARMOR_CLASS: PlayerStorage.cpp 2326, // 2330, 2503-2523. At level 40 each class auto-learns its // top armor proficiency. Paragon should pick up plate (via // paladin/DK), shields (paladin/warrior/shaman), mail // (hunter/shaman), and leather (rogue) so the level-40 train // event grants Paragon full proficiency and we don't have to // hand-curate it through the Paragon proficiency SQL. if (context == CLASS_CONTEXT_EQUIP_ARMOR_CLASS) { switch (unitClass) { case CLASS_PALADIN: case CLASS_WARRIOR: case CLASS_DEATH_KNIGHT: case CLASS_HUNTER: case CLASS_SHAMAN: case CLASS_DRUID: case CLASS_ROGUE: return true; default: break; } } // CLASS_CONTEXT_EQUIP_SHIELDS: PlayerStorage.cpp 2467-2469. // Lets a Paragon equip shields without a paladin/warrior/ // shaman skill gate. if (context == CLASS_CONTEXT_EQUIP_SHIELDS) { switch (unitClass) { case CLASS_PALADIN: case CLASS_WARRIOR: case CLASS_SHAMAN: return true; default: break; } } // CLASS_CONTEXT_WEAPON_SWAP: PlayerStorage.cpp 1920, 2838 -- // rogue uses cooldown spell 6123 instead of 6119 on weapon // swap (Quick Draw / Combat Potency interactions). Claim // rogue so Paragon picks up the same cooldown spell. if (context == CLASS_CONTEXT_WEAPON_SWAP && unitClass == CLASS_ROGUE) return true; // ============================================================ // Contexts we DELIBERATELY DO NOT claim: // ============================================================ // CLASS_CONTEXT_STATS -- Paragon has its own STR/AGI->AP and // INT/SPI->SP curves wired in StatSystem.cpp's CLASS_PARAGON // branch (level*2 + STR + AGI - 20 etc.). Claiming any // vanilla class here would override our curves with theirs. // // CLASS_CONTEXT_INIT, _TELEPORT, _QUEST, _TAXI, _SKILL, // _GRAVEYARD, _CLASS_TRAINER, _TALENT_POINT_CALC -- all // used by DK Ebon Hold / druid Moonglade starting-zone // scripts. Paragon doesn't go through those zones and we // don't want our players bound to Acherus or trapped in // the DK starting quest gates. return std::nullopt; } [[nodiscard]] bool OnPlayerHasActivePowerType(Player const* player, Powers power) override { if (!player || player->getClass() != CLASS_PARAGON) return false; if (power == POWER_RUNIC_POWER || power == POWER_RUNE) return true; if (sConfigMgr->GetOption("Paragon.MultiResource.HasActivePowers", true)) { switch (power) { case POWER_MANA: case POWER_RAGE: case POWER_ENERGY: case POWER_FOCUS: return true; default: break; } } return false; } // ChrClasses.dbc says POWER_RUNE has no pool (Unit::GetCreatePowers returns 0 // for POWER_RUNE), so Player::InitStatsForLevel and the level-reset path // both clobber MaxPower(POWER_RUNE)=0 every login. The 3.3.5 client greys // the action button for any rune-cost spell when UnitPowerMax(player, // SPELL_POWER_RUNES) is 0 — visual rune pips can still animate via // PLAYER_RUNE_REGEN_*, but the spell stays unusable. // // Fix: pin POWER_RUNE max=8 (and current=8 in the easy spots) for Paragon // anywhere UpdateMaxPower runs. Rune availability is still authoritatively // tracked in m_runes; this just keeps the client engine happy. void OnPlayerAfterUpdateMaxPower(Player* player, Powers& power, float& value) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (power == POWER_RUNE) value = 8.0f; else if (power == POWER_RUNIC_POWER) value = 1000.0f; } // Login: re-claim MaxPower for POWER_RUNE / POWER_RUNIC_POWER and seed // the rune state to the client. Player::LoadFromDB runs InitStatsForLevel // which calls Unit::SetMaxPower with the value from GetCreatePowers — and // GetCreatePowers returns 0 for POWER_RUNE (engine assumption: only DK has // runes; class is identified via getClass(), not IsClass()). So we have to // overwrite MaxPower here once the player is fully loaded. ResyncRunes is // a one-shot push so the rune frame is correct on first appearance. void OnPlayerLogin(Player* player) override { if (!player || player->getClass() != CLASS_PARAGON) return; player->SetMaxPower(POWER_RUNE, 8); if (player->GetMaxPower(POWER_RUNIC_POWER) <= 0) player->SetMaxPower(POWER_RUNIC_POWER, 1000); player->ResyncRunes(MAX_RUNES); ParagonRuneSyncState seed; seed.lastReadyMask = 0; for (uint8 i = 0; i < MAX_RUNES; ++i) if (player->GetRuneCooldown(i) == 0) seed.lastReadyMask |= (1u << i); runeSyncByGuid[player->GetGUID()] = seed; } void OnPlayerLogout(Player* player) override { if (!player) return; runeSyncByGuid.erase(player->GetGUID()); } // Per-tick rune state pump for Paragon. // // Two responsibilities: // // 1. UNIT_FIELD_POWER5 (POWER_RUNE) tracks the live ready-rune count. The // 3.3.5 client's local "is this action usable" check on rune-cost // spells reads UnitPower(player, SPELL_POWER_RUNES); for DK that's // engine-managed but Paragon must own it. // // 2. Push SMSG_RESYNC_RUNES *only* on ready-bitmask transitions (rune // consumed → on cd, or finished cd → ready). We deliberately do NOT // push during the cooldown — Player::ResyncRunes encodes the per-rune // "passed cooldown" byte as `255 - cd_ms * 51`, which is monotonic // only for cd values in seconds (0..5). With cd in milliseconds the // byte oscillates wildly tick-to-tick (cd=10000 → 207, cd=9000 → 7, // cd=8000 → 63). Frequent resyncs feed the client garbage and freeze // the rune frame. One mask-change resync is enough; sweeps run on the // client's own 10s timer started from SMSG_SPELL_GO's CAST_FLAG_RUNE_LIST // payload (which uses correct encoding) plus a Lua-side // GetRuneCooldown poll in RuneFrame.lua. void OnPlayerUpdate(Player* player, uint32 /*p_time*/) override { if (!player || player->getClass() != CLASS_PARAGON) return; uint8 readyMask = 0; uint8 readyCount = 0; for (uint8 i = 0; i < MAX_RUNES; ++i) { if (player->GetRuneCooldown(i) == 0) { readyMask |= (1u << i); ++readyCount; } } if (uint32(readyCount) != player->GetPower(POWER_RUNE)) player->SetPower(POWER_RUNE, readyCount); ParagonRuneSyncState& st = runeSyncByGuid[player->GetGUID()]; if (readyMask != st.lastReadyMask) { player->ResyncRunes(MAX_RUNES); st.lastReadyMask = readyMask; // Authoritative rune CD pump (PARAA "R RUNES cd0 cd1 ... cd5", // ms remaining per slot, 0 = ready). The 3.3.5 client engine // class-gates SMSG_RESYNC_RUNES / SMSG_SPELL_GO RUNE_LIST to DK, // so the Paragon RuneFrame sim drives the visual entirely off // COMBAT_LOG_EVENT_UNFILTERED:SPELL_CAST_SUCCESS. The combat log // arrives ~100–200ms after the server already started the // cooldown, so the client's local timer trails the server. When // the user spams a rune spell, the server's slot refreshes // first, accepts the next cast, but the client UI still shows // CD remaining → "leak-through" past a greyed icon. Pushing the // actual remaining ms on every mask transition keeps the // visual locked to server state. std::string body = "R RUNES"; for (uint8 i = 0; i < MAX_RUNES; ++i) body += " " + std::to_string(player->GetRuneCooldown(i)); std::string const payload = std::string(kParagonAddonPrefix) + "\t" + body; WorldPacket runePkt; ChatHandler::BuildChatPacket(runePkt, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); player->SendDirectMessage(&runePkt); } // Combo point pump: the 3.3.5 client engine class-gates SMSG_UPDATE_COMBO_POINTS // to rogue / druid, so the Paragon UI sim never sees CP changes from // Honor Among Thieves / Mutilate / etc. via either the engine state or // the client-side combat-log inference (HAT's 51699 trigger fires with a // null target and doesn't always emit SPELL_CAST_SUCCESS in the log). // Push the count over PARAA whenever it changes; the addon's combo // simulator listens for "R CP " and overwrites paragonCP, so the // ComboFrame on the target frame paints reliably. int8 const cp = player->GetComboPoints(); if (cp != st.lastCp) { std::string const payload = std::string(kParagonAddonPrefix) + "\t" + fmt::format("R CP {}", int32(cp)); WorldPacket data; ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); player->SendDirectMessage(&data); st.lastCp = cp; } if (!sConfigMgr->GetOption("Paragon.Diag.RuneTrace", false)) return; static thread_local time_t lastLogged = 0; time_t const wall = GameTime::GetGameTime().count(); if (wall - lastLogged < 5) return; lastLogged = wall; std::string out; for (uint8 i = 0; i < MAX_RUNES; ++i) { char buf[64]; snprintf(buf, sizeof(buf), "[%u: type=%u cur=%u cd=%u]", i, uint32(player->GetBaseRune(i)), uint32(player->GetCurrentRune(i)), uint32(player->GetRuneCooldown(i))); out += buf; } LOG_INFO("module", "[paragon-diag] {} runes: {} mask=0x{:02x}", player->GetName(), out, uint32(readyMask)); } private: struct ParagonRuneSyncState { uint8 lastReadyMask{0xFFu}; // sentinel: no prior snapshot int8 lastCp{-1}; // sentinel: no prior snapshot }; static constexpr char const* kParagonAddonPrefix = "PARAA"; static std::unordered_map runeSyncByGuid; }; std::unordered_map Paragon_PlayerScript::runeSyncByGuid; // Arcane Torrent (28730) for Paragon: Blood Elf racial skill line 756 has // three Arcane Torrent variants in stock WotLK (28730 mana, 25046 rogue // energy, 50613 DK runic power). For Paragon Blood Elves we keep only 28730 // (see migration 2026_05_10_03.sql) and turn it into a "combined" version: // the stock spell already silences nearby enemies and energizes mana via its // own effects; this script adds energy, rage, and runic power energize on // top when the caster is class 12, so a single button refunds whichever // resource pool the player is actually using. Non-Paragon casters are // untouched and keep learning their stock racial variant. class spell_paragon_arcane_torrent : public SpellScript { PrepareSpellScript(spell_paragon_arcane_torrent); void HandleAfterCast() { Unit* caster = GetCaster(); if (!caster || !caster->IsPlayer()) return; Player* player = caster->ToPlayer(); if (player->getClass() != CLASS_PARAGON) return; // Stock energize amounts from spell_dbc: // 25046 Arcane Torrent (Energy) -> 15 energy // 50613 Arcane Torrent (Runic Power) -> 15 displayed RP (= 150 // internal; AC stores RP scaled 10x, see Player::SetMaxPower // POWER_RUNIC_POWER, 1000). // Rage uses the same 10x internal scaling as runic power (see // Player.cpp:Regenerate where rage decay is `-20` for "2 rage by // tick"), so 15 displayed rage = 150 internal. // ModifyPower no-ops on pools the player has no max for, so this is // safe even before the Paragon picks up energy/rage/RP abilities. constexpr int32 kEnergyGain = 15; constexpr int32 kRageGain = 150; constexpr int32 kRunicPowerGain = 150; SpellInfo const* spellInfo = GetSpellInfo(); uint32 const spellId = spellInfo ? spellInfo->Id : 28730u; caster->EnergizeBySpell(player, spellId, kEnergyGain, POWER_ENERGY); caster->EnergizeBySpell(player, spellId, kRageGain, POWER_RAGE); caster->EnergizeBySpell(player, spellId, kRunicPowerGain, POWER_RUNIC_POWER); } void Register() override { AfterCast += SpellCastFn(spell_paragon_arcane_torrent::HandleAfterCast); } }; // Predatory Strikes (16972 / 16974 / 16975) for Paragon: re-implements the // Cataclysm-era proc behavior of the talent so a Paragon's damaging // finishers (Eviscerate / Envenom / Ferocious Bite / Rip / Rupture) can // roll Predator's Swiftness (69369) -- the same buff that real druids // get from the Cata redesign of this talent. Combined with the // Spell::prepare interception in core (Spell.cpp), 69369 makes the // Paragon's NEXT Nature-school spell with a base cast time below 10s // instant cast: Chain Lightning, Lightning Bolt, Healing Touch, Wrath, // Nourish, etc. -- not just the Druid-family Nature subset that the // stock SPELLMOD_CASTING_TIME mask on 69369 covers. // // Filter logic: // - Source spell must consume combo points (NeedsComboPoints() — gates // out non-finisher combo-point builders). // - "Damaging finisher": SPELL_ATTR1_FINISHING_MOVE_DAMAGE (Eviscerate, // Envenom, Ferocious Bite, ...) OR a SPELL_ATTR1_FINISHING_MOVE_DURATION // finisher that applies periodic damage (Rip, Rupture). Duration // finishers that only heal (Recuperate) or only buff / CC / armor shred // (Slice and Dice, Savage Roar, Kidney Shot, Maim, Expose Armor) are // rejected. // // Chance per combo point matches the Cataclysm tuning that the user's // client tooltip text reflects: rank 1 = 3% per CP, rank 2 = 5% per CP, // rank 3 = 7% per CP. At 5 CP that is 15% / 25% / 35%, capped at 100%. // // Combo-point read happens during PROC_SPELL_PHASE_CAST, which fires in // Spell::cast → Spell::ProcReflectProcs / Unit::ProcDamageAndSpellFor // BEFORE Spell::_handle_finish_phase clears the player's combo points // (see Spell.cpp:_handle_finish_phase clearing combo points). So // player->GetComboPoints() inside HandleProc returns the pre-clear value. class spell_paragon_predatory_strikes : public AuraScript { PrepareAuraScript(spell_paragon_predatory_strikes); static constexpr uint32 SPELL_PARAGON_PREDATORS_SWIFTNESS = 69369; bool Validate(SpellInfo const* /*spellInfo*/) override { return ValidateSpellInfo({ SPELL_PARAGON_PREDATORS_SWIFTNESS }); } bool CheckProc(ProcEventInfo& eventInfo) { SpellInfo const* spellInfo = eventInfo.GetSpellInfo(); if (!spellInfo || !spellInfo->NeedsComboPoints()) return false; if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DAMAGE)) return true; if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DURATION)) { bool periodicHeal = false; bool periodicDamage = false; for (SpellEffectInfo const& eff : spellInfo->Effects) { if (eff.Effect != SPELL_EFFECT_APPLY_AURA && eff.Effect != SPELL_EFFECT_APPLY_AREA_AURA_PARTY && eff.Effect != SPELL_EFFECT_PERSISTENT_AREA_AURA) continue; switch (eff.ApplyAuraName) { case SPELL_AURA_PERIODIC_HEAL: case SPELL_AURA_PERIODIC_HEALTH_FUNNEL: case SPELL_AURA_OBS_MOD_HEALTH: periodicHeal = true; break; case SPELL_AURA_PERIODIC_DAMAGE: case SPELL_AURA_PERIODIC_DAMAGE_PERCENT: case SPELL_AURA_PERIODIC_LEECH: periodicDamage = true; break; default: break; } } if (periodicHeal) return false; return periodicDamage; } return false; } void HandleProc(ProcEventInfo& eventInfo) { PreventDefaultAction(); Unit* actor = eventInfo.GetActor(); Player* player = actor ? actor->ToPlayer() : nullptr; if (!player || player->getClass() != CLASS_PARAGON) return; uint8 const cp = player->GetComboPoints(); if (cp == 0) return; SpellInfo const* talent = GetSpellInfo(); if (!talent) return; uint32 pctPerCP = 0; switch (talent->Id) { case 16972: pctPerCP = 3; break; case 16974: pctPerCP = 5; break; case 16975: pctPerCP = 7; break; default: return; } uint32 const chance = std::min(100u, pctPerCP * uint32(cp)); if (!roll_chance_i(int32(chance))) return; player->CastSpell(player, SPELL_PARAGON_PREDATORS_SWIFTNESS, true); } void Register() override { DoCheckProc += AuraCheckProcFn(spell_paragon_predatory_strikes::CheckProc); OnProc += AuraProcFn(spell_paragon_predatory_strikes::HandleProc); } }; void AddSC_paragon() { new Paragon_PlayerScript(); RegisterSpellScript(spell_paragon_arcane_torrent); RegisterSpellScript(spell_paragon_predatory_strikes); }