Files
Fractured/modules/mod-paragon/src/Paragon_Essence.cpp
T
Docker Build d96123e661 mod-paragon: single Arcane Torrent for Paragon Blood Elves
Blood Elf racial skill line 756 grants three different Arcane Torrent spell
IDs (28730 mana, 25046 rogue energy, 50613 DK runic power). The blanket
SkillLineAbility overlay in 2026_05_10_02 OR'd class 12 into all three, so
Paragon Blood Elves auto-learned every variant and the spellbook listed three
identical "Arcane Torrent" entries.

Add db-world migration 2026_05_10_03.sql to clear the class-12 bit on the rogue
and DK rows only (SkillLineAbility IDs 13338 and 17510), leaving 28730 as the
sole Paragon-visible racial cast. OnPlayerLogin removes 25046/50613 if still
present so existing characters self-heal without a manual unlearn.

The fractured-tooling DBC overlay generator is updated in the same workspace
to skip those two rows when regenerating SkillLineAbility SQL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 15:34:02 -04:00

1966 lines
72 KiB
C++

/*
* 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 <fmt/format.h>
#include <algorithm>
#include <array>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
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<uint32, ParagonCurrencyData> gParagonCurrencyCache;
uint32 ComputeStartingAE(uint8 level)
{
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.Start", 9);
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
uint32 minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
if (level >= minLv)
base += uint32(level - minLv + 1) * per;
return base;
}
uint32 ComputeStartingTE(uint8 level)
{
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.Start", 0);
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.PerLevel", 1);
uint32 minLv = sConfigMgr->GetOption<uint32>("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<bool>("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<uint32>();
d.talentEssence = f[1].Get<uint32>();
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<bool>("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 <ae> <te>" 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<bool>("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<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
uint32 const aePer = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
uint32 const tePer = sConfigMgr->GetOption<uint32>("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<uint32>("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<uint32>(r->Fetch()[0].Get<uint32>(), 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<uint32>& 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<uint32>();
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<uint32> SkillLinesLinkedToSpell(uint32 spellId)
{
std::unordered_set<uint32> 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<uint32> 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>();
uint32 const child = r->Fetch()[1].Get<uint32>();
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<bool>("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<uint32> 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<uint32>();
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<uint32>());
} 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<uint32> 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<uint32> 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<uint32> SnapshotKnownSpells(Player* pl)
{
std::unordered_set<uint32> 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<uint32>& 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<uint32> chainIds;
CollectSpellChainIds(trackId, chainIds);
bool const diag = sConfigMgr->GetOption<bool>("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<uint32> before = SnapshotKnownSpells(pl);
if (diag)
LOG_INFO("module",
"[paragon-diag] pre-learn rank={} spellMapSize={}",
cur, before.size());
pl->learnSpell(cur, false);
std::unordered_set<uint32> 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:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,..."
// 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 <ae> <te>" followed by R SPELLS / R TALENTS snapshots.
// On failure (insufficient AE/TE, unknown spell, etc.):
// "R ERR <reason>" -- the panel restores its pending state.
constexpr size_t kCommitMaxItems = 64;
std::vector<uint32> ParseCsvUInt(std::string_view csv)
{
std::vector<uint32> 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<std::pair<uint32, uint32>> const& spellsAndCosts,
std::vector<std::pair<uint32, uint32>> const& talentDeltas)
{
if (!pl)
return;
std::unordered_set<uint32> 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<uint32> spellIds = ParseCsvUInt(spellsCsv);
// Talents are "id:delta,id:delta,...". Parse into vector of pairs.
std::vector<std::pair<uint32, uint32>> 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<std::pair<uint32, uint32>> 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<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("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<uint32>(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 <id>,<id>,..." (multiple messages OK; client appends)
// "R SPELLS_END" (sentinel: rebuild list now)
// "R TALENTS <tid>:<rank>,..." (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<uint32>();
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>();
uint32 const dbRank = f[1].Get<uint32>();
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<bool>("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<uint32>();
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<uint32>();
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<uint32>();
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<bool>("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<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("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>();
uint32 const dbRank = f[1].Get<uint32>();
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<bool>("Paragon.Currency.Enabled", true))
{
std::vector<std::pair<uint32, uint32>> emptySpells;
std::vector<std::pair<uint32, uint32>> 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<bool>("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<bool>("Paragon.Currency.Enabled", true))
return true;
// addToSpellBook talents cost both AE and TE per rank.
if (talent->addToSpellBook)
{
uint32 const aeCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
uint32 const teCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
return GetAE(player) >= aeCost && GetTE(player) >= teCost;
}
uint32 const cost = sConfigMgr->GetOption<uint32>("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<bool>("Paragon.Currency.Enabled", true))
return;
TalentEntry const* talent = sTalentStore.LookupEntry(talentId);
bool const dualCost = talent && talent->addToSpellBook;
if (dualCost)
{
uint32 const aeCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
uint32 const teCost = sConfigMgr->GetOption<uint32>("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<uint32>("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<bool>("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<bool>("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<uint32, 3> 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();
}