/* * 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 "GameTime.h" #include "Log.h" #include "ObjectGuid.h" #include "Player.h" #include "ScriptMgr.h" #include "SharedDefines.h" #include "UnitDefines.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", false)); } [[nodiscard]] Optional OnPlayerIsClass(Player const* player, Classes unitClass, ClassContext context) override { if (!player || player->getClass() != CLASS_PARAGON) return std::nullopt; // Death Knight rune / runic power ability stack (narrow on purpose). if (unitClass == CLASS_DEATH_KNIGHT && context == CLASS_CONTEXT_ABILITY) return true; // Warrior ability stack: enables warrior-spec ability gates anywhere // they're checked. None of the currently-traced sites in core/scripts // gate on (CLASS_WARRIOR, CLASS_CONTEXT_ABILITY), so this is a safe // forward-compatible claim. Rage generation itself is gated on // HasActivePowerType(POWER_RAGE) and is wired below. if (unitClass == CLASS_WARRIOR && context == CLASS_CONTEXT_ABILITY) return true; // Reactive melee states: Overpower-on-dodge (warrior), Counterattack window (hunter). // We intentionally do NOT claim CLASS_ROGUE here: that context skips the generic // AURA_STATE_DEFENSE update on dodge (Riposte path) in Unit::ProcDamageAndSpellFor. if (context == CLASS_CONTEXT_ABILITY_REACTIVE) { if (unitClass == CLASS_WARRIOR || unitClass == CLASS_HUNTER) return true; } 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", false)) { 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; void AddSC_paragon() { new Paragon_PlayerScript(); }