/* * 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 "Pet.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 #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); } // Drop any panel_spell_revoked rows whose `revoked_spell_id` falls inside // `chainIds`. Called from `PanelLearnSpellChain` right after the panel // purchase row is committed so a freshly bought spell can't be unlearned // on the next login by a stale (pre-purchase) revoke entry. Without this, // the very first login after the purchase would walk the revoke table, // hit the ghost row, `removeSpell` the freshly-paid-for ability, and // then `PushSpellSnapshot` (which deletes panel_spells rows whose spell // the player no longer has) would erase the purchase from the panel // record entirely -- losing both the spell and the AE refund hook. void DbDeletePanelSpellRevokedForChain(uint32 lowGuid, std::unordered_set const& chainIds) { if (chainIds.empty()) return; std::string in; in.reserve(chainIds.size() * 8); bool first = true; for (uint32 sid : chainIds) { if (!first) in += ","; in += std::to_string(sid); first = false; } CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_revoked " "WHERE guid = {} AND revoked_spell_id IN ({})", lowGuid, in); } // Build the allowlist of spell IDs the player legitimately owns through // Character Advancement: every chain rank of every spell in panel_spells, // every rank-spell-id up to the purchased rank of every talent in // panel_talents, and every recorded panel_spell_children id. Both // `RevokeUnwantedCascadeSpellsForPlayer` and `RevokeBlockedSpellsForPlayer` // need exactly this set; without the talent contribution, buying a spell // after a talent (e.g., DK Death Coil after Scourge Strike) caused the // post-commit sweep to revoke the talent-granted spell because it didn't // appear in panel_spells. See ParagonAdvancement_TalentData.lua: many // "abilities" the player perceives as spells (Scourge Strike id=2216, // Bladestorm, Starfall, ...) are panel TALENTS that grant a spell rank // via Player::LearnTalent. void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set& allowed) { if (!lowGuid) return; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { CollectSpellChainIds(r->Fetch()[0].Get(), allowed); } while (r->NextRow()); } 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 rank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te || !rank) continue; // panel_talents.rank is 1-based ("1 means rank-1 owned"). Allow // every rank id from 0..rank-1 so a partial-rank purchase still // protects all lower ranks the player rolled through. uint32 const cap = std::min(rank, MAX_TALENT_RANK); for (uint32 i = 0; i < cap; ++i) if (te->RankID[i]) allowed.insert(te->RankID[i]); } 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()); } } // 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. // // Allowlist-aware: a revoked row whose `revoked_spell_id` is now part of // a panel_spells chain (or recorded as a passive child) is *stale* -- it // was inserted before the player legitimately purchased that spell, so // re-running `removeSpell` on it would zap a paid-for ability. Such rows // are skipped and dropped from the table so they can't fire again. This // is the self-heal path for the pre-fix bug where buying a spell that // had previously been caught by the login sweep left the (0, sid) ghost // row in place; on every subsequent login that ghost would unlearn the // freshly bought spell, and `PushSpellSnapshot`'s !HasSpell branch would // then delete the panel_spells row, vanishing the purchase entirely. void RevokeBlockedSpellsForPlayer(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents // + panel_spell_children. Talents matter here because many Wrath // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted. std::unordered_set allowed; BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); QueryResult r = CharacterDatabase.Query( "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (!r) return; uint32 removed = 0; std::vector stale; // allowlisted -> drop the row do { uint32 const sid = r->Fetch()[0].Get(); if (allowed.count(sid)) { stale.push_back(sid); continue; } if (pl->HasSpell(sid)) { pl->removeSpell(sid, SPEC_MASK_ALL, false); ++removed; } } while (r->NextRow()); if (!stale.empty()) { // Build IN-list. `stale` is bounded by the player's revoked rows. std::sort(stale.begin(), stale.end()); stale.erase(std::unique(stale.begin(), stale.end()), stale.end()); std::string in; in.reserve(stale.size() * 8); bool first = true; for (uint32 sid : stale) { if (!first) in += ","; in += std::to_string(sid); first = false; } CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_revoked " "WHERE guid = {} AND revoked_spell_id IN ({})", lowGuid, in); LOG_INFO("module", "Paragon panel: dropped {} stale revoke rows for {} " "(spell now owned via panel purchase)", stale.size(), pl->GetName()); } 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); // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents // + panel_spell_children. Talents matter here because many Wrath // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted: // a Death Coil purchase otherwise activates the DK skill line and // sweeps Scourge Strike (55090) out from under the talent. std::unordered_set allowed; BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); 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); // Clear any stale revoke rows that targeted a rank in this chain. A // prior login sweep (before the purchase) or an earlier commit-time // diff (e.g., this chain was revoked as a cascade dependent of a // *different* purchase the user has since reset/refunded) may have // left rows that would otherwise re-fire `removeSpell` next login. DbDeletePanelSpellRevokedForChain(lowGuid, chainIds); 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); } // ============================================================================ // Build catalog (saved Character Advancement loadouts). // ---------------------------------------------------------------------------- // See data/sql/db-characters/updates/2026_05_10_03.sql for the schema and // the architectural overview. The "active build" pointer is a per-character // row in `character_paragon_active_build`; if no row exists the player has // no active build (free-floating loadout, default state). // // Wire format (PARAA addon channel): // // Q BUILDS -- request catalog // C BUILD NEW \t -- create empty build // C BUILD EDIT \t\t -- rename / re-icon // C BUILD DELETE -- delete + drop parked pet // C BUILD FAVORITE <0|1> -- toggle favorite flag // C BUILD LOAD -- swap to this build // // Server replies push `R BUILDS` after every mutation. Format: // // R BUILDS active=\t::::; ... // // Names and icon paths are sanitized server-side: name = ASCII printable // up to 32 chars (no '\t', '\r', '\n', ';', ':' since those are wire // separators); icon = filename suffix only (no slashes), capped at 64 // chars. The client renders the icon as // "Interface\\Icons\\". // ---------------------------------------------------------------------------- constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark"; constexpr std::size_t kBuildNameMaxLen = 32; constexpr std::size_t kBuildIconMaxLen = 64; std::string SanitizeBuildName(std::string s) { std::string out; out.reserve(s.size()); for (char c : s) { // Reject wire separators and control characters. Keep printable // ASCII + space; everything else dropped silently. WoW's font // engine handles UTF-8 input but our wire format is ; : \t so // we conservatively limit to ASCII to keep the serializer // simple and the parser unambiguous. if (c == '\t' || c == '\r' || c == '\n' || c == ';' || c == ':') continue; if (c < 0x20 || c == 0x7F) continue; out += c; } if (out.size() > kBuildNameMaxLen) out.resize(kBuildNameMaxLen); // Trim leading/trailing whitespace. auto notSpace = [](unsigned char c) { return !std::isspace(c); }; auto first = std::find_if(out.begin(), out.end(), notSpace); auto last = std::find_if(out.rbegin(), out.rend(), notSpace).base(); if (first >= last) return std::string(); return std::string(first, last); } std::string SanitizeBuildIcon(std::string s) { std::string out; out.reserve(s.size()); for (char c : s) { // Icon file paths are alnum + underscore + hyphen + dot only. // No slashes (we always prepend "Interface\\Icons\\" client-side). if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') out += c; } if (out.size() > kBuildIconMaxLen) out.resize(kBuildIconMaxLen); if (out.empty()) out = kDefaultBuildIcon; return out; } uint32 GetActiveBuildId(uint32 lowGuid) { if (QueryResult r = CharacterDatabase.Query( "SELECT build_id FROM character_paragon_active_build WHERE guid = {}", lowGuid)) return r->Fetch()[0].Get(); return 0; } void SetActiveBuildId(uint32 lowGuid, uint32 buildId) { if (buildId) CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_active_build (guid, build_id) VALUES ({}, {})", lowGuid, buildId); else CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_active_build WHERE guid = {}", lowGuid); } bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId) { if (!buildId) return false; QueryResult r = CharacterDatabase.Query( "SELECT 1 FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); return r != nullptr; } void PushBuildCatalog(Player* pl) { if (!pl || pl->getClass() != CLASS_PARAGON) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const active = GetActiveBuildId(lowGuid); std::string activeStr = active ? std::to_string(active) : std::string("-"); std::string body = fmt::format("R BUILDS active={}\t", activeStr); if (QueryResult r = CharacterDatabase.Query( "SELECT build_id, is_favorite, pet_number, name, icon " "FROM character_paragon_builds WHERE guid = {} " "ORDER BY is_favorite DESC, build_id ASC", lowGuid)) { bool first = true; do { Field const* f = r->Fetch(); uint32 id = f[0].Get(); uint8 fav = f[1].Get(); bool haspet = !f[2].IsNull() && f[2].Get() != 0; std::string name = f[3].Get(); std::string icon = f[4].Get(); if (!first) body += ';'; first = false; body += fmt::format("{}:{}:{}:{}:{}", id, static_cast(fav), haspet ? 1 : 0, name, icon); } while (r->NextRow()); } SendAddonMessage(pl, body); } bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } auto tab = payload.find('\t'); if (tab == std::string::npos) { *err = "BUILD NEW malformed"; return false; } std::string name = SanitizeBuildName(payload.substr(0, tab)); std::string icon = SanitizeBuildIcon(payload.substr(tab + 1)); if (name.empty()) { *err = "build name is empty"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Soft cap: prevent a runaway script from creating thousands of // empty builds. 64 is far above what fits in the UI grid; the // client also enforces its own limit based on visible cells. if (QueryResult cnt = CharacterDatabase.Query( "SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid)) { if (cnt->Fetch()[0].Get() >= 64) { *err = "build limit reached (64)"; return false; } } CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')", lowGuid, name, icon); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name); return true; } bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } // payload is "\t\t" auto t1 = payload.find('\t'); if (t1 == std::string::npos) { *err = "BUILD EDIT malformed"; return false; } auto t2 = payload.find('\t', t1 + 1); if (t2 == std::string::npos) { *err = "BUILD EDIT malformed"; return false; } uint32 buildId = static_cast(std::strtoul(payload.substr(0, t1).c_str(), nullptr, 10)); std::string name = SanitizeBuildName(payload.substr(t1 + 1, t2 - t1 - 1)); std::string icon = SanitizeBuildIcon(payload.substr(t2 + 1)); if (!buildId) { *err = "BUILD EDIT bad id"; return false; } if (name.empty()) { *err = "build name is empty"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, buildId)) { *err = "build does not belong to player"; return false; } CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET name = '{}', icon = '{}' WHERE build_id = {}", name, icon, buildId); PushBuildCatalog(pl); return true; } // Permanently delete a parked pet (as if the player abandoned it at the // stable master). This is intentionally destructive -- the client warns // the user before reaching this code path. Mirrors the engine's // PET_SAVE_AS_DELETED behavior in DeleteFromDB but scoped to the rows // we know about (the pet itself is unsummoned and not present in // PetStable.CurrentPet, so this is purely a DB cleanup). void DeleteParkedPet(uint32 lowGuid, uint32 petNumber) { if (!petNumber) return; CharacterDatabase.DirectExecute( "DELETE FROM character_pet WHERE owner = {} AND id = {}", lowGuid, petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_aura WHERE guid = {}", petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_spell WHERE guid = {}", petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_spell_cooldown WHERE guid = {}", petNumber); } bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } uint32 const buildId = static_cast(std::strtoul(payload.c_str(), nullptr, 10)); if (!buildId) { *err = "BUILD DELETE bad id"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Look up parked pet (and verify ownership) in a single query. QueryResult r = CharacterDatabase.Query( "SELECT pet_number FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (!r) { *err = "build not found"; return false; } Field const* f = r->Fetch(); uint32 petNumber = f[0].IsNull() ? 0 : f[0].Get(); // If the build being deleted is currently active, clear the // active pointer first so the player ends up in the "no active // build" state. Their currently-learned spells/talents are // preserved (the client warns them about this -- they keep the // loadout but lose the named slot and the parked pet). uint32 const active = GetActiveBuildId(lowGuid); if (active == buildId) SetActiveBuildId(lowGuid, 0); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (petNumber) DeleteParkedPet(lowGuid, petNumber); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} deleted build {} (parked pet {})", pl->GetName(), buildId, petNumber); return true; } bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } auto sp = payload.find(' '); if (sp == std::string::npos) { *err = "BUILD FAVORITE malformed"; return false; } uint32 buildId = static_cast(std::strtoul(payload.substr(0, sp).c_str(), nullptr, 10)); int flag = std::atoi(payload.substr(sp + 1).c_str()); if (!buildId) { *err = "BUILD FAVORITE bad id"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, buildId)) { *err = "build does not belong to player"; return false; } CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET is_favorite = {} WHERE build_id = {}", flag ? 1 : 0, buildId); PushBuildCatalog(pl); return true; } // Snapshot the player's current panel-purchased state into a build's // recipe rows. Wipes the build's existing recipe rows first so this is // idempotent. Reads `character_paragon_panel_spells` (already authoritative // for purchased spells) and walks `Player::m_talents` per spec for // purchased talents that ALSO appear in `character_paragon_panel_talents` // (so we don't accidentally capture talents the player learned via some // non-panel mechanism -- e.g. trainer dual-spec gift). void SnapshotBuildFromCurrent(Player* pl, uint32 buildId) { uint32 const lowGuid = pl->GetGUID().GetCounter(); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId); if (QueryResult sp = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { uint32 sid = sp->Fetch()[0].Get(); CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_build_spells (build_id, spell_id) VALUES ({}, {})", buildId, sid); } while (sp->NextRow()); } // Per-spec talents: query the engine's per-spec talent state via // ActivateSpec round-trips, intersected with the panel-bought set // so we only record talents the player actually paid for. std::unordered_set panelTalents; if (QueryResult pt = CharacterDatabase.Query( "SELECT talent_id FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { panelTalents.insert(pt->Fetch()[0].Get()); } while (pt->NextRow()); } if (panelTalents.empty()) return; uint8 const origSpec = pl->GetActiveSpec(); for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) { if (s != origSpec) pl->ActivateSpec(s); // Walk PlayerTalentMap and record the rank of each panel-known // talent. The engine stores individual rank ids in the talent // map; we resolve back to the (talentId, rank) pair by walking // sTalentStore. for (uint32 i = 0; i < sTalentStore.GetNumRows(); ++i) { TalentEntry const* te = sTalentStore.LookupEntry(i); if (!te) continue; if (!panelTalents.count(te->TalentID)) continue; uint8 rank = 0; for (int8 r = MAX_TALENT_RANK - 1; r >= 0; --r) { if (te->RankID[r] && pl->HasTalent(te->RankID[r], s)) { rank = static_cast(r + 1); break; } } if (!rank) continue; CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_build_talents " "(build_id, spec, talent_id, `rank`) VALUES ({}, {}, {}, {})", buildId, s, te->TalentID, rank); } } if (pl->GetActiveSpec() != origSpec) pl->ActivateSpec(origSpec); } // Park the currently-summoned hunter pet (if any) into PET_SAVE_NOT_IN_SLOT // and bind the resulting pet_number to `buildId` so that on swap-back the // same pet (with its name, talents, exp) can be re-summoned. Non-hunter // pets (warlock demon, DK ghoul, mage water elemental) are NOT parked // because the engine re-summons those from a fresh template each cast, // so there's nothing to preserve. void ParkActivePetForBuild(Player* pl, uint32 buildId) { if (!pl || !buildId) return; Pet* pet = pl->GetPet(); if (!pet || pet->getPetType() != HUNTER_PET) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const petNumber = pet->GetCharmInfo() ? pet->GetCharmInfo()->GetPetNumber() : 0; pl->RemovePet(pet, PET_SAVE_NOT_IN_SLOT); if (!petNumber) return; CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET pet_number = {} WHERE build_id = {}", petNumber, buildId); LOG_INFO("module", "Paragon build: parked pet #{} for build {} (player {})", petNumber, buildId, lowGuid); } // Reverse of ParkActivePetForBuild: if `buildId` has a parked pet, // move that pet from PET_SAVE_NOT_IN_SLOT back to PET_SAVE_AS_CURRENT // and re-summon it next to the player. Mirrors the engine's // HandleStableSwapPet flow (NPCHandler.cpp:576). void RestoreParkedPetForBuild(Player* pl, uint32 buildId) { if (!pl || !buildId) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); QueryResult r = CharacterDatabase.Query( "SELECT pet_number FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (!r) return; uint32 petNumber = r->Fetch()[0].IsNull() ? 0 : r->Fetch()[0].Get(); if (!petNumber) return; // Refuse mid-combat / mid-instance restores -- these would race with // the worldserver's spawn replication and can leave a "ghost pet" // whose guid the client never receives. The build-load path is // already gated on combat upstream; this is a defense-in-depth // check. if (pl->IsInCombat() || pl->GetMap()->IsBattlegroundOrArena()) { LOG_INFO("module", "Paragon build: skipping pet restore for build {} (combat/arena)", buildId); return; } // If a pet is already current (shouldn't happen because the swap // path parks first, but defensively handle), park it to NOT_IN_SLOT // so we don't create two CurrentPet rows. if (Pet* existing = pl->GetPet()) pl->RemovePet(existing, PET_SAVE_NOT_IN_SLOT); // DB: flip the parked pet's slot to AS_CURRENT. CharacterDatabase.DirectExecute( "UPDATE character_pet SET slot = {} WHERE owner = {} AND id = {}", static_cast(PET_SAVE_AS_CURRENT), lowGuid, petNumber); // In-memory PetStable: move the matching UnslottedPets entry into // CurrentPet so subsequent SummonPet() calls resolve correctly. PetStable* ps = pl->GetPetStable(); if (ps) { // First, if there's a stale CurrentPet from the existing-park // step above, push it back to UnslottedPets in memory. if (ps->CurrentPet) { ps->UnslottedPets.push_back(std::move(*ps->CurrentPet)); ps->CurrentPet.reset(); } for (auto it = ps->UnslottedPets.begin(); it != ps->UnslottedPets.end(); ++it) { if (it->PetNumber == petNumber) { ps->CurrentPet = std::move(*it); ps->UnslottedPets.erase(it); break; } } } // Match HandleStableSwapPet (NPCHandler.cpp:576): when a petnumber is // specified, the `current` flag is ignored by GetLoadPetInfo, so use // false to mirror the engine convention. Pet* newPet = new Pet(pl, HUNTER_PET); if (!newPet->LoadPetFromDB(pl, 0, petNumber, false)) { delete newPet; // Revert DB on failure so we don't strand the pet in CURRENT. CharacterDatabase.DirectExecute( "UPDATE character_pet SET slot = {} WHERE owner = {} AND id = {}", static_cast(PET_SAVE_NOT_IN_SLOT), lowGuid, petNumber); LOG_INFO("module", "Paragon build: pet restore failed for build {} pet #{}", buildId, petNumber); return; } // pet_number column on the build row is now stale (the pet is // current, not parked). Clear it so a subsequent park can rewrite. CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET pet_number = NULL WHERE build_id = {}", buildId); } bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } uint32 const targetId = static_cast(std::strtoul(payload.c_str(), nullptr, 10)); if (!targetId) { *err = "BUILD LOAD bad id"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, targetId)) { *err = "build does not belong to player"; return false; } if (pl->IsInCombat()) { *err = "cannot swap builds while in combat"; return false; } uint32 const activeId = GetActiveBuildId(lowGuid); // No-op swap: target is already active. Refresh catalog so client // UI re-syncs and bail. if (activeId == targetId) { PushBuildCatalog(pl); return true; } // ------------------------------------------------------------- // Phase 1: snapshot + park the current build's state, if any. // ------------------------------------------------------------- if (activeId) { SnapshotBuildFromCurrent(pl, activeId); ParkActivePetForBuild(pl, activeId); } // ------------------------------------------------------------- // Phase 2: reset all panel-bought spells/talents (refunds AE/TE // through the existing reset path). // ------------------------------------------------------------- std::string sub; if (!HandleParagonResetTalents(pl, &sub)) { *err = sub; return false; } if (!HandleParagonResetAbilities(pl, &sub)) { *err = sub; return false; } // ------------------------------------------------------------- // Phase 3: re-spend AE/TE on the target build's recipe. // ParagonResetAbilities/Talents above already set the active // build pointer to "wiped" by removing all panel rows, but it // hasn't touched character_paragon_active_build. We update it // last (after success) so a partial failure leaves the player // in "no active build" state with refunded currency. // ------------------------------------------------------------- // Recipe spells: reuse PanelLearnSpellChain to drive the same // commit path as the panel UI -- it inserts character_paragon_panel_spells // / panel_spell_children / panel_spell_revoked rows internally. We // explicitly TrySpendAE before each chain since PanelLearnSpellChain // does NOT debit currency on its own (matches HandleCommit's pattern). std::vector recipeSpells; if (QueryResult sp = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}", targetId)) { do { recipeSpells.push_back(sp->Fetch()[0].Get()); } while (sp->NextRow()); } // Recipe talents: collected up front so we can pre-flight AE+TE. uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); struct RecipeTalent { uint8 spec; uint32 tid; uint8 rank; }; std::vector recipeTalents; uint32 talentTeCost = 0; uint32 talentAeCost = 0; if (QueryResult pt = CharacterDatabase.Query( "SELECT spec, talent_id, `rank` FROM character_paragon_build_talents " "WHERE build_id = {} ORDER BY spec ASC, talent_id ASC", targetId)) { do { Field const* f = pt->Fetch(); RecipeTalent rt{ f[0].Get(), f[1].Get(), f[2].Get() }; TalentEntry const* te = sTalentStore.LookupEntry(rt.tid); if (!te || !rt.rank) continue; recipeTalents.push_back(rt); talentTeCost += static_cast(rt.rank) * tePerRank; if (te->addToSpellBook) talentAeCost += static_cast(rt.rank) * aePerRank; } while (pt->NextRow()); } uint32 spellAeCost = 0; for (uint32 sid : recipeSpells) spellAeCost += LookupSpellAECost(sid); uint32 const totalAe = spellAeCost + talentAeCost; if (GetAE(pl) < totalAe) { *err = fmt::format("not enough AE to load build (need {} have {})", totalAe, GetAE(pl)); SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); return false; } if (GetTE(pl) < talentTeCost) { *err = fmt::format("not enough TE to load build (need {} have {})", talentTeCost, GetTE(pl)); SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); return false; } // Apply spells (TrySpendAE per chain mirrors HandleCommit; the // PanelLearnSpellChain call records DB rows for us). for (uint32 sid : recipeSpells) { uint32 cost = LookupSpellAECost(sid); if (!TrySpendAE(pl, cost)) break; PanelLearnSpellChain(pl, sid); } // Apply talents per spec. OnPlayerLearnTalents (this same script) // debits TE per rank and AE+TE for addToSpellBook talents -- so we // do NOT pre-deduct currency here, only invoke LearnTalent. if (!recipeTalents.empty()) { uint8 const origSpec = pl->GetActiveSpec(); uint8 lastSpec = 0xFF; for (RecipeTalent const& rt : recipeTalents) { if (rt.spec != lastSpec) { pl->ActivateSpec(rt.spec); lastSpec = rt.spec; } for (uint8 r = 0; r < rt.rank; ++r) pl->LearnTalent(rt.tid, r, /*command=*/true); // Mirror HandleCommit's panel-talent persistence (the // OnPlayerLearnTalents hook spends currency but does NOT // write to character_paragon_panel_talents). DbUpsertPanelTalent(lowGuid, rt.tid, rt.rank); } if (pl->GetActiveSpec() != origSpec) pl->ActivateSpec(origSpec); } SetActiveBuildId(lowGuid, targetId); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); // ------------------------------------------------------------- // Phase 4: restore the target build's parked pet (if any). // ------------------------------------------------------------- RestoreParkedPetForBuild(pl, targetId); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} loaded build {}", pl->GetName(), targetId); return true; } 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); PushBuildCatalog(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" // "C RESET PET TALENTS" -- free + instant pet talent reset (no popup, // no gold). Routes to Player::ResetPetTalents // which itself calls Pet::resetTalents and // refreshes the talent points. 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; } // --------------------------------------------------------------- // Build catalog (saved Character Advancement loadouts). // See PushBuildCatalog / HandleBuild* near the top of this file // for wire format and behavior. // --------------------------------------------------------------- if (body == "Q BUILDS") { PushBuildCatalog(player); return; } if (body.compare(0, 12, "C BUILD NEW ") == 0) { std::string err; if (!HandleBuildNew(player, body.substr(12), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 13, "C BUILD EDIT ") == 0) { std::string err; if (!HandleBuildEdit(player, body.substr(13), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 15, "C BUILD DELETE ") == 0) { std::string err; if (!HandleBuildDelete(player, body.substr(15), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 17, "C BUILD FAVORITE ") == 0) { std::string err; if (!HandleBuildFavorite(player, body.substr(17), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 13, "C BUILD LOAD ") == 0) { std::string err; if (!HandleBuildLoad(player, body.substr(13), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body == "C RESET PET TALENTS") { // Pet talent reset: deliberately bypasses the engine's // gold-cost confirmation flow. Player::ResetPetTalents // wraps Pet::resetTalents (which only refunds and unlearns; // does NOT charge gold or dismiss the pet) and re-sends the // talent UI to the client. Pre-conditions: // - the player must own a HUNTER_PET (the only pet kind // with a talent tree in 3.3.5) // - the pet must have spent at least 1 talent point // If either fails Player::ResetPetTalents returns silently; // we ack with R OK so the client UI can refresh either way. Pet* pet = player->GetPet(); if (!pet || pet->getPetType() != HUNTER_PET) { SendAddonMessage(player, "R ERR No active hunter pet to reset."); return; } player->ResetPetTalents(); SendAddonMessage(player, "R OK PET TALENTS RESET"); 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(); }