/* * mod-paragon — Ability Essence (AE) / Talent Essence (TE) * * Inspired by Project Ascension's classless currencies (see Fandom: * Ability Essence / Talent Essence). Server-side only: values persist in * character_paragon_currency; optional per-spell AE costs live in world * table paragon_spell_ae_cost. Client UI for bars / advancement panels is * separate (see README). */ #include "CharacterDatabase.h" #include "Chat.h" #include "CommandScript.h" #include "Config.h" #include "Player.h" #include "RBAC.h" #include "ScriptMgr.h" #include "SharedDefines.h" #include "SpellAuras.h" #include "SpellInfo.h" #include "SpellMgr.h" #include "WorldDatabase.h" #include "WorldPacket.h" #include "Log.h" #include "DBCEnums.h" #include "DBCStores.h" #include #include #include #include #include #include #include #include using namespace Acore::ChatCommands; namespace { // Wire-format prefix shared with the ParagonAdvancement addon (Net.lua). char const* const kAddonPrefix = "PARAA"; struct ParagonCurrencyData { uint32 abilityEssence = 0; uint32 talentEssence = 0; }; std::unordered_map gParagonCurrencyCache; uint32 ComputeStartingAE(uint8 level) { uint32 base = sConfigMgr->GetOption("Paragon.Currency.AE.Start", 9); uint32 per = sConfigMgr->GetOption("Paragon.Currency.AE.PerLevel", 1); uint32 minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); if (level >= minLv) base += uint32(level - minLv + 1) * per; return base; } uint32 ComputeStartingTE(uint8 level) { uint32 base = sConfigMgr->GetOption("Paragon.Currency.TE.Start", 0); uint32 per = sConfigMgr->GetOption("Paragon.Currency.TE.PerLevel", 1); uint32 minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); if (level >= minLv) base += uint32(level - minLv + 1) * per; return base; } ParagonCurrencyData& GetOrCreateCacheEntry(uint32 lowGuid) { return gParagonCurrencyCache[lowGuid]; } void DbUpsertCurrency(uint32 lowGuid, uint32 ae, uint32 te) { CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_currency (guid, ability_essence, talent_essence) VALUES ({}, {}, {})", lowGuid, ae, te); } void LoadCurrencyFromDb(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); if (QueryResult r = CharacterDatabase.Query( "SELECT ability_essence, talent_essence FROM character_paragon_currency WHERE guid = {}", lowGuid)) { Field const* f = r->Fetch(); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = f[0].Get(); d.talentEssence = f[1].Get(); return; } uint32 const ae = ComputeStartingAE(player->GetLevel()); uint32 const te = ComputeStartingTE(player->GetLevel()); DbUpsertCurrency(lowGuid, ae, te); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = ae; d.talentEssence = te; } void SaveCurrencyToDb(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); auto itr = gParagonCurrencyCache.find(lowGuid); if (itr == gParagonCurrencyCache.end()) return; DbUpsertCurrency(lowGuid, itr->second.abilityEssence, itr->second.talentEssence); } void RemoveCacheEntry(uint32 lowGuid) { gParagonCurrencyCache.erase(lowGuid); } uint32 GetAE(Player* player) { if (!player) return 0; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return 0; return itr->second.abilityEssence; } uint32 GetTE(Player* player) { if (!player) return 0; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return 0; return itr->second.talentEssence; } // Pushes an addon-channel message to a single player. The 3.3.5 client // recognises CHAT_MSG_WHISPER+LANG_ADDON as an addon broadcast and fires // CHAT_MSG_ADDON locally, splitting payload on the first tab into // (prefix, body). The ParagonAdvancement addon listens for prefix "PARAA". void SendAddonMessage(Player* player, std::string const& body) { if (!player || !player->GetSession()) return; std::string payload = std::string(kAddonPrefix) + "\t" + body; WorldPacket data; ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); player->SendDirectMessage(&data); } // Helper: send "R CURRENCY " so the client can update its // authoritative balance. Safe to call any time the cached values change. void PushCurrency(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; // Defensive: if for any reason the in-memory cache has lost this player // (race during login, hot-reload, etc.) refuse to silently broadcast // (0, 0). Hydrate from DB first so the panel always sees real balances. if (gParagonCurrencyCache.find(player->GetGUID().GetCounter()) == gParagonCurrencyCache.end()) LoadCurrencyFromDb(player); SendAddonMessage(player, fmt::format("R CURRENCY {} {}", GetAE(player), GetTE(player))); } bool TrySpendAE(Player* player, uint32 amount) { if (!amount) return true; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return false; if (itr->second.abilityEssence < amount) return false; itr->second.abilityEssence -= amount; return true; } bool TrySpendTE(Player* player, uint32 amount) { if (!amount) return true; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return false; if (itr->second.talentEssence < amount) return false; itr->second.talentEssence -= amount; return true; } void GrantLevelUpEssence(Player* player, uint8 oldLevel, uint8 newLevel) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); uint32 const aePer = sConfigMgr->GetOption("Paragon.Currency.AE.PerLevel", 1); uint32 const tePer = sConfigMgr->GetOption("Paragon.Currency.TE.PerLevel", 1); ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter()); for (uint32 lvl = uint32(oldLevel) + 1; lvl <= uint32(newLevel); ++lvl) { if (lvl >= minLv) { d.abilityEssence += aePer; d.talentEssence += tePer; } } } uint32 LookupSpellAECost(uint32 spellId) { uint32 const fallback = sConfigMgr->GetOption("Paragon.Currency.AE.DefaultSpellCost", 2); QueryResult r = WorldDatabase.Query("SELECT ae_cost FROM paragon_spell_ae_cost WHERE spell_id = {}", spellId); if (!r) return fallback; return std::max(r->Fetch()[0].Get(), 1u); } // Matches client bake `lvl` (Spell.dbc SpellLevel; see _gen_paragon_advancement_spells_lua.py). uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info) { if (!info) return 1u; uint32 lv = info->SpellLevel; if (!lv) lv = info->BaseLevel; return std::max(1u, lv); } // Forward declaration: reset handlers below need PushSnapshot, which itself // is defined later (after PushSpellSnapshot / PushTalentSnapshot). void PushSnapshot(Player* pl); // Forward declaration: the login-time scoped sweep (defined a few helpers // down) calls into the chain-walker (defined further down). void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out); // ---- Panel-learn tracking (Overview + resets) ------------------------------ // // Spells / talent ranks bought through Character Advancement are stored in // character_paragon_panel_spells and character_paragon_panel_talents. The // addon snapshot lists ONLY these rows (intersected with the live player), // so racial spells, trainer defaults, etc. never appear in Overview. void DbInsertPanelSpell(uint32 lowGuid, uint32 spellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spells (guid, spell_id) VALUES ({}, {})", lowGuid, spellId); } void DbInsertPanelSpellChild(uint32 lowGuid, uint32 parentSpellId, uint32 childSpellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spell_children " "(guid, parent_spell_id, child_spell_id) VALUES ({}, {}, {})", lowGuid, parentSpellId, childSpellId); } // Persist an "active dependent we revoked" so we can re-revoke it after // AC's skill cascade re-grants it on the next login. See // `RevokeBlockedSpellsForPlayer` for the redo step. void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revokedSpellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spell_revoked " "(guid, parent_spell_id, revoked_spell_id) VALUES ({}, {}, {})", lowGuid, parentSpellId, revokedSpellId); } // Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the // player still has it. Call sites: // * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells` // ran during LoadFromDB and may have re-granted Blood Presence / // Death Coil / Death Grip / etc. before any of our hooks fired. // * `HandleParagonResetAbilities` is NOT a caller; reset clears the // table outright so the revoke list starts fresh on next purchase. void RevokeBlockedSpellsForPlayer(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); QueryResult r = CharacterDatabase.Query( "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (!r) return; uint32 removed = 0; do { uint32 const sid = r->Fetch()[0].Get(); if (pl->HasSpell(sid)) { pl->removeSpell(sid, SPEC_MASK_ALL, false); ++removed; } } while (r->NextRow()); if (removed) LOG_INFO("module", "Paragon panel: re-revoked {} skill-cascade dependents for {} on login", removed, pl->GetName()); } [[nodiscard]] static bool SkillLineAbilityIsSkillCascadeSigned(SkillLineAbilityEntry const* sla) { return sla && (sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_LEARN || sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_VALUE); } // SkillLine ids from every SkillLineAbility row that lists `spellId` // (any AcquireMethod). Used as the *anchor* side of cascade detection. // // Important: the old helper only kept rows with AcquireMethod // LEARNED_ON_SKILL_* . Spells like Blood Strike are usually tied to // their skill line via a trainer/default row (AcquireMethod 0). That // meant the anchor set was empty, so `IsSpellSkillLineCascadeDependent` // never matched Forceful Deflection / Blood Presence — those were stored // as innocent "children" and never revoked. // // Dependent detection still requires the *dependent* spell to have a // LEARNED_ON_SKILL_* row on one of these lines (same as // `learnSkillRewardedSpells`). [[nodiscard]] static std::unordered_set SkillLinesLinkedToSpell(uint32 spellId) { std::unordered_set out; SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); for (auto it = bounds.first; it != bounds.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!sla) continue; out.insert(sla->SkillLine); } return out; } // True when `depSpellId` is granted as a skill-line reward on one of the // same SkillLines as `anchorSpellId` (e.g. Blood Strike -> Forceful // Deflection / Blood Presence). Passives learned only via spell effects // (disease auras, etc.) typically return false here. [[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId) { std::unordered_set const anchorLines = SkillLinesLinkedToSpell(anchorSpellId); if (anchorLines.empty()) return false; SkillLineAbilityMapBounds db = sSpellMgr->GetSkillLineAbilityMapBounds(depSpellId); for (auto it = db.first; it != db.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!SkillLineAbilityIsSkillCascadeSigned(sla)) continue; if (anchorLines.count(sla->SkillLine)) return true; } return false; } // Older builds recorded skill-line cascade passives (Forceful Deflection, // Runic Focus, ...) as `panel_spell_children`. Strip those rows and // revoke the spell so the login sweep + reset logic match current policy. static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) { if (!pl) return; QueryResult r = CharacterDatabase.Query( "SELECT parent_spell_id, child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); if (!r) return; do { uint32 const parent = r->Fetch()[0].Get(); uint32 const child = r->Fetch()[1].Get(); if (!IsSpellSkillLineCascadeDependent(parent, child)) continue; CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", lowGuid, parent, child); if (pl->HasSpell(child)) { pl->removeSpell(child, SPEC_MASK_ALL, false); DbInsertPanelSpellRevoked(lowGuid, parent, child); } } while (r->NextRow()); } // Login-time scoped sweep for cascade-granted dependents that the // commit-time diff missed. // // Background: AC's `_addSpell` calls `LearnDefaultSkill(skill, 0)` once // for any SkillLineAbility-linked spell when the player doesn't yet have // the skill. `LearnDefaultSkill` -> `SetSkill` -> `learnSkillRewardedSpells` // then grants every reward of that skill as PLAYERSPELL_TEMPORARY (which // lives in m_spells but is NOT written to character_spell). On every // subsequent login `_LoadSkills` re-fires the cascade for any skill row // that exists in `character_skills`, silently re-granting Blood Presence // / Forceful Deflection / Death Coil / etc. -- including for characters // like Test whose first commit predates the per-purchase revoke list. // // Strategy: from the player's panel-purchased spells, derive the set of // SkillLines we've activated (via SkillLineAbility's SkillLine field). // Walk those SkillLines' rewards and revoke any spell (active or passive) // currently in m_spells that isn't in our allowlist (panel chain ranks + // non-cascade passive children). Persist each revoke into // `character_paragon_panel_spell_revoked` so the cheaper // `RevokeBlockedSpellsForPlayer` path handles it on every subsequent // login. // // Why this is safe (unlike the earlier blanket-temporary sweep): // * Only walks SkillLines that we caused to be activated. Racial // skills, weapon skills, Defense, etc. live in different SkillLines // and are never reached. // * Passives that are pure spell-effect side effects stay in // `character_paragon_panel_spell_children` and remain allowlisted. // * Skill-line cascade passives (Forceful Deflection, ...) are not // allowlisted children anymore; see `PruneSkillLineCascadeChildrenFromDb` // + `PanelLearnSpellChain`. void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) { if (!pl) return; bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); uint32 const lowGuid = pl->GetGUID().GetCounter(); PruneSkillLineCascadeChildrenFromDb(pl, lowGuid); // Build the allowlist: every chain rank of every panel-purchased spell, // plus every recorded passive child. std::unordered_set allowed; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { uint32 const base = r->Fetch()[0].Get(); CollectSpellChainIds(base, allowed); } while (r->NextRow()); } if (QueryResult r = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid)) { do { allowed.insert(r->Fetch()[0].Get()); } while (r->NextRow()); } if (allowed.empty()) return; // From the allowlist, derive which SkillLines we've activated. Use // every SkillLineAbility row for each spell (not only LEARNED_ON_SKILL_*), // matching `SkillLinesLinkedToSpell` / `_addSpell`'s LearnDefaultSkill path. std::unordered_set ourSkillLines; for (uint32 spellId : allowed) { SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); for (auto it = bounds.first; it != bounds.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!sla) continue; ourSkillLines.insert(sla->SkillLine); } } if (ourSkillLines.empty()) return; // For each activated SkillLine, walk its rewards and queue revokes for // active spells that aren't allowlisted but are currently in m_spells. std::vector toRevoke; for (uint32 skillLine : ourSkillLines) { for (SkillLineAbilityEntry const* ab : GetSkillLineAbilitiesBySkillLine(skillLine)) { if (!ab) continue; uint32 const sid = ab->Spell; if (!sid) continue; if (allowed.count(sid)) continue; if (!pl->HasSpell(sid)) continue; // not in m_spells right now SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); if (!info) continue; toRevoke.push_back(sid); } } if (toRevoke.empty()) { if (diag) LOG_INFO("module", "[paragon-diag] login sweep: no orphan cascade spells for {} " "(skillLines scanned={}, allowlist={})", pl->GetName(), ourSkillLines.size(), allowed.size()); return; } // Dedup (a spell can show up under multiple SkillLines). std::sort(toRevoke.begin(), toRevoke.end()); toRevoke.erase(std::unique(toRevoke.begin(), toRevoke.end()), toRevoke.end()); for (uint32 sid : toRevoke) { pl->removeSpell(sid, SPEC_MASK_ALL, false); // parent_spell_id = 0 -> "caught at login by scoped sweep, no // specific parent". Distinct PK from the (guid, parent, sid) // rows the commit-time diff inserts. DbInsertPanelSpellRevoked(lowGuid, 0u, sid); } LOG_INFO("module", "Paragon panel: scoped sweep revoked {} cascade spells for {} " "(skillLines scanned={}, allowlist={})", toRevoke.size(), pl->GetName(), ourSkillLines.size(), allowed.size()); } // Blood Elf racial "Arcane Torrent" is three different spell IDs in WotLK // (28730 mana, 25046 rogue energy, 50613 DK runic power), all on skill line // 756. The blanket SkillLineAbility overlay opened all three to class 12; // Paragon should keep only the mana version (matches primary power display). void RevokeDuplicateBloodElfArcaneTorrent(Player* pl) { if (!pl || pl->getRace() != RACE_BLOODELF) return; constexpr uint32 SPELL_ARCANE_TORRENT_ROGUE = 25046; constexpr uint32 SPELL_ARCANE_TORRENT_DK = 50613; for (uint32 sid : { SPELL_ARCANE_TORRENT_ROGUE, SPELL_ARCANE_TORRENT_DK }) if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); } // Snapshot of currently-known spell IDs (excluding entries marked removed). // Used by PanelLearnSpellChain to detect spells that AzerothCore's // addSpell machinery auto-learns alongside the spell we asked for. std::unordered_set SnapshotKnownSpells(Player* pl) { std::unordered_set out; if (!pl) return out; PlayerSpellMap const& spells = pl->GetSpellMap(); out.reserve(spells.size()); for (auto const& kv : spells) { PlayerSpell const* ps = kv.second; if (ps && ps->State != PLAYERSPELL_REMOVED) out.insert(kv.first); } return out; } // (suppression-via-placeholder helpers were removed: AzerothCore's auto- // learn for class spells comes via `learnSkillRewardedSpells` -> `_addSpell`, // not via `SPELL_EFFECT_LEARN_SPELL` effects on the parent. Pre-blocking // by spell id list didn't intercept the right path. The chat-toast // suppression now lives client-side in ParagonAdvancement_Net.lua; // the server just tells the client which ids to silence.) // Build the full chain id set for `baseSpellId` (every rank). Used both // by PanelLearnSpellChain and by the silence-window opener so the client // knows which "you have learned X" toasts to keep visible. void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) { if (!baseSpellId) return; uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); uint32 cur = firstId ? firstId : baseSpellId; while (cur) { if (!out.insert(cur).second) break; uint32 const n = sSpellMgr->GetNextSpellInChain(cur); if (!n || n == cur) break; cur = n; } } // Learn every rank of the spell chain that contains `baseSpellId` for which // the player meets the SpellLevel requirement, then record ONLY the // first-rank id in character_paragon_panel_spells. // // Recording only the chain head (one row per panel purchase) keeps reset // accounting clean: refundAE is computed once per chain via // LookupSpellAECost(firstRankId), and Player::removeSpell already cascades // across higher ranks. If we recorded every learned rank we'd refund N x // the cost for an N-rank chain and need to dedupe in reset. // // Side effect: AC's spell-learn machinery cascades through both the // `SPELL_EFFECT_LEARN_SPELL` effects and the SkillLineAbility map (when // the player gains a new skill via `LearnDefaultSkill` -> `SetSkill` -> // `learnSkillRewardedSpells`), so learning a single class spell can // auto-grant several side spells (e.g., Plague Strike pulls Death Coil, // Death Grip, Blood Plague, Blood Presence, Forceful Deflection, Runic // Focus). We diff the player's spell list before/after `learnSpell` to // classify those side effects: // * Passives that are pure spell-effect side effects (disease auras, // etc.) are kept; we record them in character_paragon_panel_spell_children // so reset unlearns them alongside the parent. // * Passives that are skill-line cascade rewards on the same SkillLine // as the rank being learned (Forceful Deflection with Blood Strike) // are revoked like actives — they are not panel children. // * Active dependents (Death Coil, Death Grip, Blood Presence, ...) are // revoked immediately via `removeSpell` so the player only ends up // with what they actually purchased. // The "you have learned X" / "you have unlearned X" chat toasts that // fire during this dance are silenced client-side via a SILENCE // addon-channel window opened around the whole commit (see // HandleCommit + ParagonAdvancement_Net.lua). void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) { if (!pl || !baseSpellId) return; uint32 const playerLevel = pl->GetLevel(); uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); uint32 const trackId = firstId ? firstId : baseSpellId; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::unordered_set chainIds; CollectSpellChainIds(trackId, chainIds); bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); if (diag) { LOG_INFO("module", "[paragon-diag] PanelLearnSpellChain start: player={} lvl={} class={} baseSpellId={} trackId={} chainSize={}", pl->GetName(), playerLevel, uint32(pl->getClass()), baseSpellId, trackId, chainIds.size()); } uint32 cur = trackId; while (cur) { SpellInfo const* info = sSpellMgr->GetSpellInfo(cur); if (!info) break; // Spell.dbc ranks are ordered by required level ascending, so the // first rank that exceeds the player's level terminates the walk. uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); if (playerLevel < reqLv) break; if (!pl->HasSpell(cur)) { std::unordered_set before = SnapshotKnownSpells(pl); if (diag) LOG_INFO("module", "[paragon-diag] pre-learn rank={} spellMapSize={}", cur, before.size()); pl->learnSpell(cur, false); std::unordered_set after = SnapshotKnownSpells(pl); if (diag) LOG_INFO("module", "[paragon-diag] post-learn rank={} spellMapSize={} delta={}", cur, after.size(), int32(after.size()) - int32(before.size())); // Diff: classify each new spell that wasn't in the chain we // asked for. Pure spell-effect passives stick (children); skill- // line cascade passives and actives get revoked. for (uint32 spellId : after) { if (before.count(spellId)) continue; // already known if (chainIds.count(spellId)) { if (diag) LOG_INFO("module", "[paragon-diag] +{} (chain rank, kept)", spellId); continue; } SpellInfo const* dep = sSpellMgr->GetSpellInfo(spellId); if (!dep) continue; if (dep->IsPassive()) { if (IsSpellSkillLineCascadeDependent(cur, spellId)) { pl->removeSpell(spellId, SPEC_MASK_ALL, false); DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", "[paragon-diag] +{} (skill-line passive dep, REVOKED, parent={})", spellId, trackId); } else { DbInsertPanelSpellChild(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", "[paragon-diag] +{} (passive dep, kept as child of {})", spellId, trackId); } } else { pl->removeSpell(spellId, SPEC_MASK_ALL, false); // Persist so we can re-revoke after the next login -- // AC's _LoadSkills -> learnSkillRewardedSpells will // re-grant skill-rewarded actives (Blood Presence, // Death Coil, Death Grip, ...) every time the player // logs in. DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", "[paragon-diag] +{} (active dep, REVOKED, parent={})", spellId, trackId); } } } uint32 const next = sSpellMgr->GetNextSpellInChain(cur); if (!next || next == cur) break; cur = next; } DbInsertPanelSpell(lowGuid, trackId); if (diag) LOG_INFO("module", "[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId); } void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank) { if (!rank) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", lowGuid, talentId); return; } CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_panel_talents (guid, talent_id, `rank`) VALUES ({}, {}, {})", lowGuid, talentId, rank); } uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId) { if (!pl) return 0; TalentEntry const* te = sTalentStore.LookupEntry(talentId); if (!te) return 0; uint32 best = 0; for (uint8 spec = 0; spec < pl->GetSpecsCount(); ++spec) for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) if (te->RankID[r] && pl->HasTalent(te->RankID[r], spec)) best = std::max(best, uint32(r + 1)); return best; } // ---- Commit handler -------------------------------------------------------- // // Wire format from Net.lua Net:Commit: // "C COMMIT s:,,... t::,..." // Both sub-lists are optional but the leading tags are not. Examples: // "C COMMIT s:5176,8921 t:" // "C COMMIT s: t:1234:1,5678:2" // We parse leniently and abort on any structural error. // // On success we push: // "R OK " followed by R SPELLS / R TALENTS snapshots. // On failure (insufficient AE/TE, unknown spell, etc.): // "R ERR " -- the panel restores its pending state. constexpr size_t kCommitMaxItems = 64; std::vector ParseCsvUInt(std::string_view csv) { std::vector out; out.reserve(8); size_t i = 0; while (i < csv.size()) { size_t end = csv.find(',', i); if (end == std::string_view::npos) end = csv.size(); if (end > i) { std::string tok(csv.substr(i, end - i)); try { out.push_back(uint32(std::stoul(tok))); } catch (...) { out.push_back(0); } } i = end + 1; } return out; } // Returns true on success; on failure, *err is filled and no state is mutated. // Send a SILENCE OPEN window to the client listing every spell id whose // learn/unlearn chat toast SHOULD remain visible. The client then suppresses // every "you have learned X" / "you have unlearned X" system message during // the window whose subject isn't on the allow-list. Window auto-closes // after a short timeout client-side; we also send an explicit CLOSE at the // end of the commit to release earlier. // // Allow-list contents: // * full chain ids for every spell the player explicitly purchased (so // the player still sees "Plague Strike (Rank 1..N) learned" toasts); // * every talent rank id the player explicitly purchased (so addToSpellBook // talents like Bladestorm/Starfall still toast normally). // Anything else learned/unlearned during the window -- the SkillLineAbility // cascade and our diff-revoke cleanup -- is silenced. void SendSilenceOpenForCommit(Player* pl, std::vector> const& spellsAndCosts, std::vector> const& talentDeltas) { if (!pl) return; std::unordered_set allow; for (auto const& kv : spellsAndCosts) CollectSpellChainIds(kv.first, allow); for (auto const& [tid, delta] : talentDeltas) { (void)delta; TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) continue; for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) if (te->RankID[r]) allow.insert(te->RankID[r]); } // Open the window even when the allow list is empty (still useful for // talent-only commits that cascade unrelated passives, etc.). std::string body = "R SILENCE OPEN"; if (!allow.empty()) { body += ' '; bool first = true; for (uint32 id : allow) { if (!first) body += ','; body += std::to_string(id); first = false; // Stay under the addon-channel chat budget (~250 chars). When // close to full, flush this batch and start a new OPEN message. // Client treats multiple OPEN as additive. if (body.size() > 220) { SendAddonMessage(pl, body); body = "R SILENCE OPEN "; first = true; } } } SendAddonMessage(pl, body); } void SendSilenceClose(Player* pl) { if (pl) SendAddonMessage(pl, "R SILENCE CLOSE"); } bool HandleCommit(Player* pl, std::string const& body, std::string* err) { // Strip leading "C COMMIT " (already stripped by caller, but be defensive) constexpr std::string_view kPrefix = "C COMMIT "; std::string_view rest = body; if (rest.substr(0, kPrefix.size()) == kPrefix) rest.remove_prefix(kPrefix.size()); // Find " t:" delimiter to split spells / talents sections. auto sPos = rest.find("s:"); auto tPos = rest.find(" t:"); if (sPos != 0 || tPos == std::string_view::npos) { *err = "malformed commit"; return false; } std::string_view spellsCsv = rest.substr(2, tPos - 2); std::string_view talentsCsv = rest.substr(tPos + 3); std::vector spellIds = ParseCsvUInt(spellsCsv); // Talents are "id:delta,id:delta,...". Parse into vector of pairs. std::vector> talentDeltas; { size_t i = 0; while (i < talentsCsv.size()) { size_t end = talentsCsv.find(',', i); if (end == std::string_view::npos) end = talentsCsv.size(); if (end > i) { std::string_view tok = talentsCsv.substr(i, end - i); size_t colon = tok.find(':'); if (colon == std::string_view::npos) { *err = "talent token missing colon"; return false; } uint32 tid = 0, delta = 0; try { tid = uint32(std::stoul(std::string(tok.substr(0, colon)))); delta = uint32(std::stoul(std::string(tok.substr(colon + 1)))); } catch (...) { *err = "talent token parse failed"; return false; } if (tid && delta) talentDeltas.emplace_back(tid, delta); } i = end + 1; } } if (spellIds.size() + talentDeltas.size() > kCommitMaxItems) { *err = "commit exceeds size cap"; return false; } // Pre-validate spells: must be valid SpellInfo, not already learned, // and afford their combined AE cost. uint32 totalAE = 0; std::vector> spellsAndCosts; spellsAndCosts.reserve(spellIds.size()); for (uint32 id : spellIds) { if (!id) continue; SpellInfo const* info = sSpellMgr->GetSpellInfo(id); if (!info) { *err = fmt::format("unknown spell {}", id); return false; } if (pl->HasSpell(id)) continue; // silently skip; not an error from the user's POV uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); if (pl->GetLevel() < reqLv) { *err = fmt::format("requires level {} for spell {}", reqLv, id); return false; } uint32 cost = LookupSpellAECost(id); spellsAndCosts.emplace_back(id, cost); totalAE += cost; } // (combined AE budget — spells + spell-granting talent ranks — is checked // once below after we know the talent AE total) // Pre-validate talents. addToSpellBook talents (Starfall, Bladestorm, …) // cost both AE and TE per rank; other talents cost TE only. uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 talentsTE = 0; uint32 talentsAE = 0; for (auto const& [tid, delta] : talentDeltas) { TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) { *err = fmt::format("unknown talent {}", tid); return false; } // Same tier gate as default Wrath UI: row 0 @ 10, +5 per row down. uint32 const minLevelForTier = 10u + te->Row * 5u; if (pl->GetLevel() < minLevelForTier) { *err = fmt::format("requires level {} for that talent tier", minLevelForTier); return false; } if (te->addToSpellBook) { talentsAE += delta * aePerRank; talentsTE += delta * tePerRank; } else talentsTE += delta * tePerRank; } if (GetTE(pl) < talentsTE) { *err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl)); return false; } if (GetAE(pl) < (totalAE + talentsAE)) { *err = fmt::format("not enough AE (need {} have {})", totalAE + talentsAE, GetAE(pl)); return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Open client-side silence window so the cascade dependents AC's // learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood // Presence/Forceful Deflection/Runic Focus/...) don't spam learn/ // unlearn toasts. Allow list = chain ranks of explicitly purchased // spells + talent rank ids. Closed below at the end of the commit. SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas); // Apply spells: each consumes its individual AE cost. PanelLearnSpellChain // also grants every higher rank up to the player's current level so the // panel matches "trainer-style" learning (e.g., buying Wrath at L60 yields // ranks 1-9 in one purchase). Only the chain head is recorded so reset // refunds the AE cost exactly once per purchase. for (auto const& [id, cost] : spellsAndCosts) { if (!TrySpendAE(pl, cost)) { *err = "AE spend failed mid-commit (race?)"; SendSilenceClose(pl); return false; } PanelLearnSpellChain(pl, id); } // Apply talents one rank at a time so each call goes through the // existing OnPlayerLearnTalents hook (which spends TE per rank, and // both AE+TE for addToSpellBook talents). // command=true bypasses talent-point and tier-spend requirements; // class-mask is bypassed in core for Paragon (Player::LearnTalent). for (auto const& [tid, delta] : talentDeltas) { TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) continue; // Find the player's current rank in this talent (0-indexed; 0 means // none, 1 means rank-1, etc.). Compare against MAX_TALENT_RANK. uint32 currentRank = 0; for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) { if (te->RankID[r] && pl->HasTalent(te->RankID[r], pl->GetActiveSpec())) { currentRank = r + 1; // keep scanning to find HIGHEST rank } } uint32 const targetRank = std::min(currentRank + delta, MAX_TALENT_RANK); for (uint32 r = currentRank; r < targetRank; ++r) pl->LearnTalent(tid, r, /*command=*/true); } for (auto const& [tid, delta] : talentDeltas) { (void)delta; uint32 const r = ComputeTalentRankAnySpec(pl, tid); DbUpsertPanelTalent(lowGuid, tid, r); } // One scoped sweep after all panel DB rows for this commit exist. Catches // cascade dependents the per-rank diff missed when anchor SkillLine rows // used a non-LEARN_* AcquireMethod (fixed in SkillLinesLinkedToSpell, but // this is cheap insurance for mixed commits / talent side effects). RevokeUnwantedCascadeSpellsForPlayer(pl); SaveCurrencyToDb(pl); SendSilenceClose(pl); return true; } // ---- Snapshot push (R SPELLS / R TALENTS) --------------------------------- // // Sends spell IDs and talent (id, rank) pairs that were purchased through // Character Advancement only (see character_paragon_panel_* tables). // // Addon-channel messages have a generous chat-packet size budget but smaller // is friendlier, so we chunk into ~180-char payload bodies. Format: // "R SPELLS ,,..." (multiple messages OK; client appends) // "R SPELLS_END" (sentinel: rebuild list now) // "R TALENTS :,..." (chunked similarly) // "R TALENTS_END" // The client clears its buffer when it sees the first "R SPELLS" / "R TALENTS" // after a "_END", so resending is idempotent. constexpr size_t kSnapshotChunkBudget = 180; void PushSpellSnapshot(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::string buf; buf.reserve(kSnapshotChunkBudget + 16); buf = "R SPELLS "; bool first = true; auto flush = [&](bool finalChunk) { if (buf.size() > 9) SendAddonMessage(pl, buf); buf = "R SPELLS "; first = true; if (finalChunk) SendAddonMessage(pl, "R SPELLS_END"); }; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) { do { uint32 const sid = r->Fetch()[0].Get(); if (!pl->HasSpell(sid)) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}", lowGuid, sid); continue; } SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); if (info && info->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) continue; std::string token = (first ? "" : ",") + std::to_string(sid); if (buf.size() + token.size() > kSnapshotChunkBudget) flush(/*finalChunk=*/false); buf.append(first ? std::to_string(sid) : token); first = false; } while (r->NextRow()); } flush(/*finalChunk=*/true); } void PushTalentSnapshot(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::string buf = "R TALENTS "; bool first = true; auto flush = [&](bool finalChunk) { if (buf.size() > 10) SendAddonMessage(pl, buf); buf = "R TALENTS "; first = true; if (finalChunk) SendAddonMessage(pl, "R TALENTS_END"); }; if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const dbRank = f[1].Get(); uint32 const actual = ComputeTalentRankAnySpec(pl, tid); if (!actual || !dbRank) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", lowGuid, tid); continue; } uint32 const shown = std::min(actual, dbRank); std::string token = (first ? "" : ",") + std::to_string(tid) + ":" + std::to_string(shown); if (buf.size() + token.size() > kSnapshotChunkBudget) flush(/*finalChunk=*/false); buf.append(first ? (std::to_string(tid) + ":" + std::to_string(shown)) : token); first = false; } while (r->NextRow()); } flush(/*finalChunk=*/true); } bool HandleParagonResetAbilities(Player* pl, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { *err = "Paragon currency disabled"; return false; } LoadCurrencyFromDb(pl); uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 refundAE = 0; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) { do { uint32 const sid = r->Fetch()[0].Get(); refundAE += LookupSpellAECost(sid); // Unlearn any passive dependents we tracked when this parent // was learned (Frost Fever, Blood Presence, Forceful // Deflection, ...). Done before removing the parent so the // server doesn't re-grant them via SPELL_EFFECT_LEARN_SPELL // re-application during the parent's removeSpell cascade. if (QueryResult cr = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children " "WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid)) { do { uint32 const cid = cr->Fetch()[0].Get(); if (pl->HasSpell(cid)) pl->removeSpell(cid, SPEC_MASK_ALL, false); } while (cr->NextRow()); } if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); } while (r->NextRow()); } // Best-effort revoke pass for active dependents the cascade may have // re-installed since last commit (Blood Presence, Death Coil, ...). // Without this, a player who upgraded to this build with orphan // dependents already in their spellbook would still see them after // Reset Abilities; with this they're swept the moment they reset. if (QueryResult rev = CharacterDatabase.Query( "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid)) { do { uint32 const sid = rev->Fetch()[0].Get(); if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); } while (rev->NextRow()); } CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spells WHERE guid = {}", lowGuid); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (refundAE) { ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence += refundAE; } SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE); return true; } bool HandleParagonResetTalents(Player* pl, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { *err = "Paragon currency disabled"; return false; } LoadCurrencyFromDb(pl); uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 refundAE = 0; uint32 refundTE = 0; uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const dbRank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te || !dbRank) continue; uint32 const actual = ComputeTalentRankAnySpec(pl, tid); uint32 const ranks = std::min(dbRank, actual); if (!ranks) continue; if (te->addToSpellBook) { refundAE += ranks * aePerRank; refundTE += ranks * tePerRank; } else refundTE += ranks * tePerRank; } while (r->NextRow()); } uint8 const origSpec = pl->GetActiveSpec(); for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) { pl->ActivateSpec(s); pl->resetTalents(true); } pl->ActivateSpec(origSpec); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_talents WHERE guid = {}", lowGuid); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence += refundAE; d.talentEssence += refundTE; SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); LOG_INFO("module", "Paragon panel: {} reset talents (+{} AE +{} TE refund)", pl->GetName(), refundAE, refundTE); return true; } bool HandleParagonResetAll(Player* pl, std::string* err) { std::string sub; if (!HandleParagonResetTalents(pl, err)) return false; if (!HandleParagonResetAbilities(pl, &sub)) { *err = sub; return false; } LOG_INFO("module", "Paragon panel: {} reset everything", pl->GetName()); return true; } void PushSnapshot(Player* pl) { if (!pl || pl->getClass() != CLASS_PARAGON) return; PushSpellSnapshot(pl); PushTalentSnapshot(pl); } class Paragon_Essence_PlayerScript : public PlayerScript { public: Paragon_Essence_PlayerScript() : PlayerScript("Paragon_Essence_PlayerScript", { PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_LOGOUT, PLAYERHOOK_ON_SAVE, PLAYERHOOK_ON_CREATE, PLAYERHOOK_ON_LEVEL_CHANGED, PLAYERHOOK_CAN_LEARN_TALENT, PLAYERHOOK_ON_PLAYER_LEARN_TALENTS, PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE }) { } void OnPlayerLogin(Player* player) override { LoadCurrencyFromDb(player); PushCurrency(player); PushSnapshot(player); // AC's character load sequence runs _LoadSkills (which fires // learnSkillRewardedSpells) and _LoadSpells before this hook, // so any active dependents we revoked at panel-commit time // (Blood Presence / Death Coil / Death Grip / ...) have been // silently re-granted by the skill cascade. Walk our persisted // revoke list and remove them again. // // `removeSpell` triggers SMSG_REMOVED_SPELL on the client which // generates "You have unlearned X" CHAT_MSG_SYSTEM toasts. The // chat frame buffers system messages while the loading screen // is up and only flushes them after PLAYER_ENTERING_WORLD, so // a paired OPEN/CLOSE we send here would already be CLOSED by // the time those buffered toasts reach the filter. We open the // silence window from the server side as a defensive measure, // but rely on the client to keep the window open across the // login flush via its own PA.Silence:Open({}) call in the // PLAYER_LOGIN handler. The window auto-closes via the addon's // fail-open timer (PA.Silence.WINDOW_SECONDS). if (player && player->getClass() == CLASS_PARAGON && sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { std::vector> emptySpells; std::vector> emptyTalents; SendSilenceOpenForCommit(player, emptySpells, emptyTalents); // Step 1: re-revoke spells we explicitly recorded last commit // (cheap, exact: just walks the persisted revoke table). RevokeBlockedSpellsForPlayer(player); // Step 2: scoped sweep across SkillLines we activated via panel // purchases. Catches cascade-granted active spells that the // commit-time diff missed -- e.g., legacy characters whose // first commit predates the per-purchase revoke list, or any // cascade re-fire on relog. Only walks our own SkillLines, so // racials / weapon skills / Defense rewards are never touched. RevokeUnwantedCascadeSpellsForPlayer(player); // Step 3: Blood Elf only -- strip rogue/DK Arcane Torrent clones // (skill-line overlay taught all three; see 2026_05_10_03.sql). RevokeDuplicateBloodElfArcaneTorrent(player); // Intentionally NOT calling SendSilenceClose here -- the chat // frame buffers system messages during the loading screen and // would flush them after the CLOSE arrives. The addon's // fail-open timer (8s) closes the window after the flush. } } void OnPlayerLogout(Player* player) override { SaveCurrencyToDb(player); RemoveCacheEntry(player->GetGUID().GetCounter()); } void OnPlayerSave(Player* player) override { SaveCurrencyToDb(player); } void OnPlayerCreate(Player* player) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); uint32 const ae = ComputeStartingAE(player->GetLevel()); uint32 const te = ComputeStartingTE(player->GetLevel()); DbUpsertCurrency(lowGuid, ae, te); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = ae; d.talentEssence = te; // Player isn't fully in-world here; OnPlayerLogin will push. } void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override { if (!player || player->getClass() != CLASS_PARAGON) return; // Cache is authoritative once OnPlayerLogin loaded it; reloading // from DB here would clobber any AE/TE the player has spent since // the last save (e.g., panel Lock-In followed by a level-up before // the next OnPlayerSave tick), effectively refunding the spend. // Hydrate ONLY if the cache is unexpectedly empty. uint32 const lowGuid = player->GetGUID().GetCounter(); if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end()) LoadCurrencyFromDb(player); GrantLevelUpEssence(player, oldLevel, player->GetLevel()); // Persist the grant immediately so a crash before next save doesn't // lose freshly-awarded essence. Cheap (single REPLACE). SaveCurrencyToDb(player); PushCurrency(player); } bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* talent, uint32 /*rank*/) override { if (!player || player->getClass() != CLASS_PARAGON) return true; if (!talent) return false; uint32 const minLevelForTier = 10u + talent->Row * 5u; if (player->GetLevel() < minLevelForTier) return false; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return true; // addToSpellBook talents cost both AE and TE per rank. if (talent->addToSpellBook) { uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); return GetAE(player) >= aeCost && GetTE(player) >= teCost; } uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); return GetTE(player) >= cost; } // ParagonAdvancement addon -> server: addon-channel chat from a Paragon // player that begins with our PARAA prefix is treated as a control // request. Supported requests: // "Q CURRENCY" -- push R CURRENCY back to refresh bars // "Q SNAPSHOT" -- push R SPELLS and R TALENTS for Overview // "C COMMIT s:... t:..." -- apply pending learns from the panel // "C RESET ABILITIES" / "C RESET TALENTS" / "C RESET ALL" / "C RESET EVERYTHING" void OnPlayerBeforeSendChatMessage(Player* player, uint32& /*type*/, uint32& lang, std::string& msg) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (lang != LANG_ADDON) return; std::string const expectedPrefix = std::string(kAddonPrefix) + "\t"; if (msg.compare(0, expectedPrefix.size(), expectedPrefix) != 0) return; std::string body = msg.substr(expectedPrefix.size()); if (body == "Q CURRENCY") { PushCurrency(player); return; } if (body == "Q SNAPSHOT") { PushSnapshot(player); return; } // Combat lockdown: any mutating control message ("C ...") is // rejected while the player is in combat. The client also // self-gates the buttons that would emit these (see // ParagonAdvancement.lua), but the authoritative check lives // here so a hand-crafted addon message can't bypass it. // Read-only "Q ..." queries above are allowed in combat so // the panel can keep its currency / snapshot displays current. if (!body.empty() && body[0] == 'C' && player->IsInCombat()) { SendAddonMessage(player, "R ERR cannot use Character Advancement while in combat"); return; } if (body.compare(0, 9, "C COMMIT ") == 0) { std::string err; if (HandleCommit(player, body, &err)) { SendAddonMessage(player, fmt::format("R OK {} {}", GetAE(player), GetTE(player))); PushCurrency(player); PushSnapshot(player); } else { SendAddonMessage(player, "R ERR " + err); LOG_INFO("module", "Paragon commit rejected for player {}: {}", player->GetName(), err); } return; } if (body == "C RESET ABILITIES") { std::string err; if (!HandleParagonResetAbilities(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body == "C RESET TALENTS") { std::string err; if (!HandleParagonResetTalents(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body == "C RESET ALL" || body == "C RESET EVERYTHING") { std::string err; if (!HandleParagonResetAll(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } } void OnPlayerLearnTalents(Player* player, uint32 talentId, uint32 /*talentRank*/, uint32 /*spellid*/) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; TalentEntry const* talent = sTalentStore.LookupEntry(talentId); bool const dualCost = talent && talent->addToSpellBook; if (dualCost) { uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); if (!TrySpendTE(player, teCost)) { LOG_ERROR("module", "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", talentId, player->GetName()); return; } if (!TrySpendAE(player, aeCost)) { ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter()); d.talentEssence += teCost; SaveCurrencyToDb(player); PushCurrency(player); LOG_ERROR("module", "Paragon AE spend failed post-learn for talent {} (player {}) — refunded TE; currency desync?", talentId, player->GetName()); return; } } else { uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); if (!TrySpendTE(player, cost)) { LOG_ERROR("module", "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", talentId, player->GetName()); return; } } PushCurrency(player); } }; class Paragon_Essence_CommandScript : public CommandScript { public: Paragon_Essence_CommandScript() : CommandScript("Paragon_Essence_CommandScript") { } ChatCommandTable GetCommands() const override { static ChatCommandTable paragonSubTable = { { "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, { "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, { "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, { "hat", HandleHat, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, }; static ChatCommandTable commandTable = { { "paragon", paragonSubTable }, }; return commandTable; } static bool HandleCurrency(ChatHandler* handler) { Player* pl = handler->GetPlayer(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Paragon currency is only tracked for Paragon characters.", false); return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false); return false; } LoadCurrencyFromDb(pl); handler->PSendSysMessage("Ability Essence: {} | Talent Essence: {}", GetAE(pl), GetTE(pl)); return true; } // .paragon runes — diagnostic dump of rune state (server-side truth) and // forced ResyncRunes packet to the client. Helps diagnose visual-vs-server // desync where the rune pip animates to ready but the spell cast still // returns SPELL_FAILED_NO_POWER because GetRuneCooldown(i) > 0 server-side. static bool HandleRunes(ChatHandler* handler) { Player* pl = handler->GetPlayer(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Only Paragon characters have a rune block to inspect.", false); return false; } for (uint8 i = 0; i < MAX_RUNES; ++i) { handler->PSendSysMessage("rune[{}] base={} cur={} cd={}ms grace={}ms", i, uint32(pl->GetBaseRune(i)), uint32(pl->GetCurrentRune(i)), uint32(pl->GetRuneCooldown(i)), uint32(pl->GetGracePeriod(i))); } for (uint8 t = 0; t < NUM_RUNE_TYPES; ++t) { handler->PSendSysMessage("regen[{}] = {} (1/cooldown_ms)", uint32(t), pl->GetFloatValue(PLAYER_RUNE_REGEN_1 + t)); } // Force a fresh client snapshot so we can see if visual catches up. pl->ResyncRunes(MAX_RUNES); handler->PSendSysMessage("|cff00ff00ResyncRunes packet sent.|r"); return true; } static bool HandleLearn(ChatHandler* handler, SpellInfo const* spell) { Player* pl = handler->GetPlayer(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Only Paragon characters use AE for this command.", false); return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false); return false; } if (!spell) return false; LoadCurrencyFromDb(pl); uint32 const cost = LookupSpellAECost(spell->Id); if (GetAE(pl) < cost) { handler->PSendSysMessage("Not enough Ability Essence (need {}, have {}).", cost, GetAE(pl)); return false; } if (!SpellMgr::IsSpellValid(spell)) { handler->SendErrorMessage("Invalid spell.", false); return false; } if (pl->HasSpell(spell->Id)) { handler->SendErrorMessage("You already know this spell.", false); return false; } if (!TrySpendAE(pl, cost)) return false; PanelLearnSpellChain(pl, spell->Id); SaveCurrencyToDb(pl); PushCurrency(pl); handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl)); return true; } // .paragon hat — diagnostic for Honor Among Thieves on a Paragon. // Prints HasTalent, HasAura, HasSpell, EquippedItemClass, ProcEvent // hookup, and re-applies the talent's passive aura if missing. // // HAT in 3.3.5: rank ids 51698 (R1) / 51700 (R2) / 51701 (R3). // Effect 0 is APPLY_AREA_AURA_PARTY -> SPELL_AURA_PROC_TRIGGER_SPELL // (52916) which targets the rogue (the original caster) when ANY // party member crits, and 52916 in turn casts 51699 to grant +1 CP. static bool HandleHat(ChatHandler* handler) { Player* pl = handler->GetPlayer(); if (!pl) return false; constexpr std::array kHatRanks = { 51698u, 51700u, 51701u }; constexpr uint32 kHatProc = 52916u; constexpr uint32 kHatTrigger = 51699u; handler->PSendSysMessage("|cff00ffff[paragon hat]|r class={} lvl={} group={}", uint32(pl->getClass()), pl->GetLevel(), pl->GetGroup() ? "yes" : "no"); uint32 ownedRank = 0; uint32 ownedRankId = 0; for (uint32 i = 0; i < kHatRanks.size(); ++i) { uint32 const id = kHatRanks[i]; bool hasTalent = pl->HasTalent(id, pl->GetActiveSpec()); bool hasAura = pl->HasAura(id); SpellInfo const* info = sSpellMgr->GetSpellInfo(id); handler->PSendSysMessage( " R{} ({}): HasTalent={} HasAura={} HasSpell={} info={}", i + 1, id, hasTalent ? "Y" : "n", hasAura ? "Y" : "n", pl->HasSpell(id) ? "Y" : "n", info ? "ok" : "MISSING"); if (hasTalent && !ownedRank) { ownedRank = i + 1; ownedRankId = id; } } SpellInfo const* procInfo = sSpellMgr->GetSpellInfo(kHatProc); SpellInfo const* trigInfo = sSpellMgr->GetSpellInfo(kHatTrigger); handler->PSendSysMessage(" proc52916 info={} trig51699 info={}", procInfo ? "ok" : "MISSING", trigInfo ? "ok" : "MISSING"); if (!ownedRank) { handler->PSendSysMessage("|cffff8000No HAT rank in active spec.|r"); return true; } SpellInfo const* rank = sSpellMgr->GetSpellInfo(ownedRankId); if (!rank) return true; // Inspect the talent rank effects so we can see exactly what AC // thinks should be applied. for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) { SpellEffectInfo const& eff = rank->Effects[i]; if (eff.Effect == 0) continue; handler->PSendSysMessage( " eff[{}] effect={} aura={} targetA={} trigger={} basePts={}", i, uint32(eff.Effect), uint32(eff.ApplyAuraName), uint32(eff.TargetA.GetTarget()), eff.TriggerSpell, eff.BasePoints); } // Force-apply the rank passive aura if it isn't already there. We // use Aura::TryRefreshStackOrCreate so we don't go through CheckCast // (which can fail silently for various reasons). if (!pl->HasAura(ownedRankId)) { Aura* a = Aura::TryRefreshStackOrCreate(rank, MAX_EFFECT_MASK, pl, pl); handler->PSendSysMessage(" force-apply rank aura -> {}", a ? "OK" : "FAILED"); } else { handler->PSendSysMessage(" rank aura already on player."); } // Dump the live SpellProcEntry the engine will use when checking // "should this aura proc". Confirms the ProcFlags fallback from // SpellInfo, and the SpellPhase / Hit gates. if (SpellProcEntry const* pe = sSpellMgr->GetSpellProcEntry(ownedRankId)) { handler->PSendSysMessage( " ProcEntry({}): ProcFlags=0x{:X} SpellTypeMask=0x{:X} SpellPhaseMask=0x{:X} HitMask=0x{:X} chance={} cd={}ms", ownedRankId, pe->ProcFlags, pe->SpellTypeMask, pe->SpellPhaseMask, pe->HitMask, pe->Chance, uint32(pe->Cooldown.count())); } else { handler->PSendSysMessage( " ProcEntry({}): NONE (proc system will not fire this aura)", ownedRankId); } // Walk the player's applied auras and report whether the engine // currently sees an effect-mask flag turned on for the rank's // PROC_TRIGGER_SPELL effect. If the aura is up but the flag is // off, the engine won't proc it. if (AuraApplication* aa = pl->GetAuraApplication(ownedRankId)) { handler->PSendSysMessage( " AuraApp: effectMask=0x{:X} effectsToApply=0x{:X} flags=0x{:X}", uint32(aa->GetEffectMask()), uint32(aa->GetEffectsToApply()), uint32(aa->GetFlags())); } else { handler->PSendSysMessage(" AuraApp: NONE on player"); } // Force-fire the trigger chain (52916 -> dummy -> 51699 -> +CP) on // the player's current target. If this works, the proc machinery // is at fault. If THIS fails, the trigger spells are at fault. if (Unit* tgt = pl->GetSelectedUnit()) { uint32 const cpBefore = pl->GetComboPoints(tgt); // Mimic spell_rog_honor_among_thieves::HandleProc: cast 52916 // FROM tgt -> tgt with the rogue/Paragon as original caster. tgt->CastSpell(tgt, kHatProc, true, nullptr, nullptr, pl->GetGUID()); uint32 const cpAfter = pl->GetComboPoints(tgt); handler->PSendSysMessage( " force-fire 52916 on {}: CP {} -> {}", tgt->GetName(), cpBefore, cpAfter); } else { handler->PSendSysMessage( " no target selected: pick a unit and run again to force-fire 52916."); } return true; } }; } // namespace (anonymous) void AddSC_paragon_essence() { new Paragon_Essence_PlayerScript(); new Paragon_Essence_CommandScript(); }