Files
Fractured/modules/mod-paragon/src/Paragon_Essence.cpp
T
Docker Build 6a1f8eec89 Paragon tester hunter BiS, mount cast QoL, learn all mounts RBAC, trade cap 11
- mod-paragon: .paragon tester bis hunter (Sanctified Ahn'Kahar Blood Hunter + Windrunner's Heartseeker), bis gems kits, AGI bow vs ranged/gun/crossbow, ranged for spi/hybrid weapons.
- .learn all mounts: RBAC 916 + db_auth migration 2026_05_12_00.sql.
- Cast-time mount spells: allow start/complete while moving; block in combat; interrupt mount cast on combat enter; relax movement prevention for NPCs/units.
- MaxPrimaryTradeSkill default 11 (all WotLK primary professions) in WorldConfig + worldserver.conf.dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 23:02:30 -04:00

5671 lines
226 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 "Bag.h"
#include "CharacterDatabase.h"
#include "Chat.h"
#include "CommandScript.h"
#include "Language.h"
#include "Config.h"
#include "Pet.h"
#include "Player.h"
#include "Random.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 "ObjectMgr.h"
#include "Log.h"
#include "DBCEnums.h"
#include "DBCStores.h"
#include <fmt/format.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdlib>
#include <string>
#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);
}
// Reconciles the player's AE/TE cache against what they SHOULD have
// based on level (ComputeStartingAE/TE) minus what they've spent through
// Character Advancement (sum over character_paragon_panel_spells +
// character_paragon_panel_talents). Updates the cache + DB if either
// direction drifts:
// * actual < expected: top up (handles per-level grants automatically;
// also self-heals from admin commands / crashes that lost essence).
// * actual > expected: clamp down (prevents .modify-style cheese, ghost
// panel rows that were rolled back, or any path that left more
// essence than the level allowed).
// Logs at INFO when drift is corrected so we can spot abuse patterns.
//
// Cheap (two SELECTs of small per-character tables) and safe to call from
// OnPlayerLogin and OnPlayerLevelChanged. SAFE TO CALL ANY TIME the panel
// DB is in a steady state (i.e. NOT mid-HandleCommit).
void ReconcileEssenceForPlayer(Player* pl)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint8 const level = pl->GetLevel();
// Sum AE / TE spent through panel purchases. Mirrors the cost lookups
// used by HandleCommit so reconciliation matches the spend math byte-
// for-byte (no off-by-one if config keys are tweaked at runtime).
uint32 spentAE = 0;
uint32 spentTE = 0;
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do
{
spentAE += LookupSpellAECost(r->Fetch()[0].Get<uint32>());
} while (r->NextRow());
}
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 rank = f[1].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te || !rank)
continue;
spentTE += rank * tePerRank;
if (te->addToSpellBook)
spentAE += rank * aePerRank;
} while (r->NextRow());
}
uint32 const expectedTotalAE = ComputeStartingAE(level);
uint32 const expectedTotalTE = ComputeStartingTE(level);
uint32 const expectedBalAE = expectedTotalAE > spentAE ? expectedTotalAE - spentAE : 0;
uint32 const expectedBalTE = expectedTotalTE > spentTE ? expectedTotalTE - spentTE : 0;
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
if (d.abilityEssence == expectedBalAE && d.talentEssence == expectedBalTE)
return;
LOG_INFO("module",
"Paragon essence reconciled for {} (lvl {}): AE {}->{} TE {}->{} (spent AE={} TE={}, expected total AE={} TE={})",
pl->GetName(), uint32(level),
d.abilityEssence, expectedBalAE,
d.talentEssence, expectedBalTE,
spentAE, spentTE,
expectedTotalAE, expectedTotalTE);
d.abilityEssence = expectedBalAE;
d.talentEssence = expectedBalTE;
SaveCurrencyToDb(pl);
}
// Forward declaration: reset handlers below need PushSnapshot, which itself
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
void PushSnapshot(Player* pl);
// Forward declarations: reset handlers below clear the active build pointer
// and re-push the build catalog so the cell border drops client-side. Both
// helpers live with the rest of the build catalog code further down.
void SetActiveBuildId(uint32 lowGuid, uint32 buildId);
void PushBuildCatalog(Player* pl);
bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId);
void SnapshotBuildFromCurrent(Player* pl, uint32 buildId);
// 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);
}
// 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<uint32> 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<uint32>& 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<uint32>(), 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>();
uint32 const rank = f[1].Get<uint32>();
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<uint32>(rank, MAX_TALENT_RANK);
for (uint32 i = 0; i < cap; ++i)
{
uint32 const rankSpell = te->RankID[i];
if (!rankSpell)
continue;
allowed.insert(rankSpell);
// Some talents (Mangle, Feral Charge, Mutilate, ...) own a
// *passive* RankID spell whose effects then LEARN_SPELL the
// actual active abilities the player gets to use. RankID
// alone isn't enough -- the granted spells live on the
// class skill line, so the login cascade sweep would see
// them as "not allowlisted" and revoke them, and from the
// user's POV the ability vanishes on relog.
//
// Walk every effect of this rank's spell, expand each
// SPELL_EFFECT_LEARN_SPELL trigger through CollectSpellChainIds
// so the whole rank chain of the granted spell stays
// protected too (e.g. Mangle Bear rank 1 33878 ->
// 33986 -> ... -> 48566; we want all of them on the
// allowlist so a high-level Paragon druid keeps the rank
// appropriate to their level).
SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(rankSpell);
if (!rankInfo)
continue;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grantId = rankInfo->Effects[e].TriggerSpell;
if (!grantId)
continue;
CollectSpellChainIds(grantId, 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());
}
}
// 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<uint32> allowed;
BuildPanelOwnedSpellsAllowlist(lowGuid, allowed);
QueryResult r = CharacterDatabase.Query(
"SELECT parent_spell_id, revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}",
lowGuid);
if (!r)
return;
// Cache panel_spells for this guid so we can reattach migrating passive
// rows to a still-owned parent (per "passives stick" policy below).
std::unordered_set<uint32> ownedPanelSpells;
if (QueryResult ps = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do { ownedPanelSpells.insert(ps->Fetch()[0].Get<uint32>()); } while (ps->NextRow());
}
uint32 removed = 0;
uint32 migrated = 0;
std::vector<uint32> stale; // allowlisted -> drop the row
std::vector<uint32> passiveStaleAll; // passive revokes -> drop unconditionally
std::vector<std::pair<uint32, uint32>> passiveMigrate; // (parent, child) -> insert as child
do
{
Field const* f = r->Fetch();
uint32 const parent = f[0].Get<uint32>();
uint32 const sid = f[1].Get<uint32>();
// Legacy migration: previous builds revoked passive cascade
// rewards (Forceful Deflection, Runic Focus, ...). New policy is
// that all cascade-granted passives stick. Drop those rows
// and, where we have a still-owned parent, reattach the passive
// as a panel_spell_child so future reset/unlearn drops it
// alongside the parent.
SpellInfo const* info = sSpellMgr->GetSpellInfo(sid);
if (info && info->IsPassive())
{
if (parent && ownedPanelSpells.count(parent))
passiveMigrate.emplace_back(parent, sid);
passiveStaleAll.push_back(sid);
++migrated;
continue;
}
// Same migration for meta-skill cascade spells. Earlier builds
// (and this one until just now) revoked the rune-enchant spells
// (Razorice, Cinderglacier, Rune of the Fallen Crusader, ...)
// when a Paragon learned Runeforging via the panel, because
// they're active spells and the default classifier treats
// unknown active cascades as leaks. New policy: anything on
// SKILL_RUNEFORGING is part of the Runeforging meta-skill
// package and stays. Drop the revoked row and, if we have a
// still-owned parent (typically Runeforging itself, 53428),
// re-record as a child so refund/unlearn still cleans them up.
bool isMetaSkillRevoke = false;
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(sid);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
isMetaSkillRevoke = true;
break;
}
}
}
if (isMetaSkillRevoke)
{
if (parent && ownedPanelSpells.count(parent))
passiveMigrate.emplace_back(parent, sid);
passiveStaleAll.push_back(sid);
++migrated;
continue;
}
if (allowed.count(sid))
{
stale.push_back(sid);
continue;
}
if (pl->HasSpell(sid))
{
pl->removeSpell(sid, SPEC_MASK_ALL, false);
++removed;
}
} while (r->NextRow());
for (auto const& kv : passiveMigrate)
DbInsertPanelSpellChild(lowGuid, kv.first, kv.second);
if (!passiveStaleAll.empty())
{
std::sort(passiveStaleAll.begin(), passiveStaleAll.end());
passiveStaleAll.erase(std::unique(passiveStaleAll.begin(), passiveStaleAll.end()), passiveStaleAll.end());
std::string in;
in.reserve(passiveStaleAll.size() * 8);
bool first = true;
for (uint32 sid : passiveStaleAll)
{
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);
}
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());
if (migrated)
LOG_INFO("module",
"Paragon panel: migrated {} passive revokes to children for {} (legacy)",
migrated, 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;
}
// Forward: defined after `CollectSpellChainIds` (needs the chain walker).
[[nodiscard]] static bool IsDepGrantedByLearnSpellOnAnyChainRank(uint32 anchorChainHead, uint32 depSpellId);
// Plague Strike / Icy Touch teach their disease passives through the same
// DK weapon skill-line machinery as true cascade rewards (Forceful
// Deflection, …) — there is often no SPELL_EFFECT_LEARN_SPELL row on the
// strike itself (the debuff spell is reached via TRIGGER_SPELL at cast
// time). Without an explicit carve-out, `IsSpellSkillLineCascadeDependent`
// returns true and we revoke Blood Plague / Frost Fever right after the
// learnSpell diff inserts them.
[[nodiscard]] static bool IsParagonStrikeTiedDiseasePassive(uint32 anchorChainHead, uint32 depSpellId)
{
uint32 const dHead = sSpellMgr->GetFirstSpellInChain(depSpellId);
uint32 const depH = dHead ? dHead : depSpellId;
// On-target debuff spellbook rows (wrong for our panel attach, but still
// strike-tied) plus the correct passive spellbook entries (59879 / 59921).
// Without the latter, `PruneSkillLineCascadeChildrenFromDb` classifies
// (45462,59879) as a skill-line cascade child and strips the forced attach
// immediately after `PanelLearnSpellChain` returns.
if (anchorChainHead == 45462 && (depH == 55078 || depH == 59879)) // Plague Strike -> Blood Plague
return true;
if (anchorChainHead == 45477 && (depH == 55095 || depH == 59921)) // Icy Touch -> Frost Fever
return true;
return false;
}
// 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.
//
// IMPORTANT: some passives (Blood Plague from Plague Strike) sit on the
// same SkillLine as the anchor AND carry LEARNED_ON_SKILL_* rows, so the
// naive skill-line intersection would classify them as "cascade" and
// revoke them. Those spells are still legitimate spell-effect grants when
// Plague Strike's SPELL_EFFECT_LEARN_SPELL points at them — we exclude
// that case first via `IsDepGrantedByLearnSpellOnAnyChainRank`.
[[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId)
{
uint32 const anchorHead = sSpellMgr->GetFirstSpellInChain(anchorSpellId);
uint32 const head = anchorHead ? anchorHead : anchorSpellId;
if (IsDepGrantedByLearnSpellOnAnyChainRank(head, depSpellId))
return false;
if (IsParagonStrikeTiedDiseasePassive(head, depSpellId))
return false;
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;
}
// Allowlist for ACTIVE spells we explicitly want kept as
// panel_spell_children, even though the general policy is "actives in
// children = legacy garbage, drop them" (see
// PruneSkillLineCascadeChildrenFromDb).
//
// The original kAttached set was 100% passives (Frost Fever, Blood
// Plague, Forceful Deflection, Runic Focus). For those, "passive ==
// keep" was a perfect proxy. Runeforging changed that: the 8 basic
// rune-enchant spells (53344, 53343, 53341, 53331, 53342, 53323,
// 54447, 54446) are ACTIVE casts that we DO want to attach to the
// Runeforging panel purchase so:
// * The Lua-substitute Runeforge UI can cast them (HasActiveSpell).
// * Refunding Runeforging cleans them up via the standard
// panel_spell_children unlearn path.
//
// Without this allowlist, PruneSkillLineCascadeChildrenFromDb runs
// immediately after PanelLearnSpellChain attaches them, sees them as
// non-passive, drops them, and inserts panel_spell_revoked rows --
// stranding the player with no usable runeforging menu.
//
// Every entry here MUST also appear in PanelLearnSpellChain::kAttached
// AND in OnPlayerLogin's kFixup list (or a shared source if those ever
// get factored out). The pair ordering is (parentHead, attachedSpell),
// matching kAttached / kFixup.
struct IntentionalActiveAttached { uint32 parent; uint32 child; };
static IntentionalActiveAttached const kIntentionalActiveAttached[] = {
{ 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Rune of Razorice
{ 53428, 53341 }, // Runeforging -> Rune of Cinderglacier
{ 53428, 53331 }, // Runeforging -> Rune of Lichbane
{ 53428, 53342 }, // Runeforging -> Rune of Spellshattering
{ 53428, 53323 }, // Runeforging -> Rune of Swordshattering
{ 53428, 54447 }, // Runeforging -> Rune of Spellbreaking
{ 53428, 54446 }, // Runeforging -> Rune of Swordbreaking
};
[[nodiscard]] static bool IsIntentionalActiveAttachedChild(uint32 parent, uint32 child)
{
for (auto const& e : kIntentionalActiveAttached)
if (e.parent == parent && e.child == child)
return true;
return false;
}
// Current policy: cascade-granted passives stick as panel_spell_children;
// only actives get revoked. This pass exists to scrub *legacy* rows that
// older logic inserted incorrectly — specifically, any active spell that
// ended up in panel_spell_children from a build that classified things
// differently. Passive children are always retained, as are entries
// whitelisted via kIntentionalActiveAttached (Runeforging rune-enchants
// are active casts that we deliberately attach as children).
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>();
SpellInfo const* info = sSpellMgr->GetSpellInfo(child);
if (info && info->IsPassive())
continue; // passives always stay
if (IsIntentionalActiveAttachedChild(parent, child))
continue; // intentional active attachment
// Active in children -> legacy garbage. Drop the row, revoke the
// spell, and persist into panel_spell_revoked so the login sweep
// catches future cascade re-fires.
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);
// 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<uint32> 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<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.
// Passive cascade rewards (Forceful Deflection, Runic Focus, ...) are
// intentionally retained — the panel-purchase commit recorded them as
// panel_spell_children so reset/queue-unlearn will drop them with the
// parent, but the login sweep MUST NOT strip them from the spellbook.
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;
if (info->IsPassive())
continue; // policy: passives always stay
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;
}
}
// Riding-skill gating for spells whose effective rank in the spellbook
// depends on the player's flying skill (currently the Druid forms
// Flight Form 33943 / Swift Flight Form 40120). Returns true when the
// player is allowed to learn this specific spell id at this moment.
//
// 33943 (Flight Form, 150% flight) requires 34090 Expert Riding.
// 40120 (Swift Flight Form, 280% flight) requires 34091 Artisan Riding.
//
// Any other spell id is always allowed (returns true). Used by
// `PanelLearnSpellChain` so a Paragon panel purchase / level-up cascade
// silently skips the unaffordable rank but keeps walking the chain --
// e.g. a player with Expert Riding only gets Flight Form, never Swift,
// even though both ranks are in the same SpellChain.dbc graph.
[[nodiscard]] bool IsParagonSpellAllowedByRidingSkill(Player* pl, uint32 spellId)
{
if (!pl)
return true;
if (spellId == 33943)
return pl->HasSpell(34090) || pl->HasSpell(34091); // expert OR artisan
if (spellId == 40120)
return pl->HasSpell(34091); // artisan required for swift
return true;
}
// Walk a rank chain and learn every rank up to the player's current
// level (and not past riding-skill gates), without any of the
// PanelLearnSpellChain panel/AE bookkeeping. Used by talent-grant
// cascades (Mangle / Feral Charge / Mutilate / etc.) where the talent
// LEARN_SPELL effect grants the rank-1 ability and stock would have
// upgraded it via Player::learnSkillRewardedSpells -- but the Paragon
// class-skill cascade is intentionally disabled (Player.cpp guard), so
// nothing else picks up the higher ranks. Idempotent: skips ranks the
// player already has, so safe to re-run on level-up / login.
void TeachLevelGatedAbilityChainNoPanel(Player* pl, uint32 chainHead)
{
if (!pl || !chainHead)
return;
uint32 const playerLevel = pl->GetLevel();
uint32 const firstId = sSpellMgr->GetFirstSpellInChain(chainHead);
uint32 cur = firstId ? firstId : chainHead;
while (cur)
{
SpellInfo const* info = sSpellMgr->GetSpellInfo(cur);
if (!info)
break;
uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info);
if (playerLevel < reqLv)
break;
if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur))
pl->learnSpell(cur, false);
uint32 const next = sSpellMgr->GetNextSpellInChain(cur);
if (!next || next == cur)
break;
cur = next;
}
}
// Walk every SPELL_EFFECT_LEARN_SPELL on `talentRankSpellId` (the
// `TalentEntry::RankID[r]` of a talent rank) and, for each granted
// spell, run TeachLevelGatedAbilityChainNoPanel so the player ends up
// with the highest rank their level can support. Mangle, Feral Charge,
// Mutilate, etc. all fit this pattern.
void CascadeRanksForTalentLearnSpellEffects(Player* pl, uint32 talentRankSpellId)
{
if (!pl || !talentRankSpellId)
return;
SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(talentRankSpellId);
if (!rankInfo)
return;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grantId = rankInfo->Effects[e].TriggerSpell;
if (!grantId)
continue;
TeachLevelGatedAbilityChainNoPanel(pl, grantId);
}
}
// True when `depSpellId` (any rank in its chain) is the target of a
// SPELL_EFFECT_LEARN_SPELL on any rank of the anchor purchase chain
// (`anchorChainHead` is the chain head / panel_spells.spell_id).
// Distinguishes Blood Plague (taught directly by Plague Strike ranks)
// from Forceful Deflection (skill-line reward only, no LEARN_SPELL on
// the Blood Strike ranks).
[[nodiscard]] static bool IsDepGrantedByLearnSpellOnAnyChainRank(uint32 anchorChainHead, uint32 depSpellId)
{
if (!anchorChainHead || !depSpellId)
return false;
std::unordered_set<uint32> anchorRanks;
CollectSpellChainIds(anchorChainHead, anchorRanks);
if (anchorRanks.empty())
anchorRanks.insert(anchorChainHead);
std::unordered_set<uint32> learnGrantIds;
for (uint32 rnk : anchorRanks)
{
SpellInfo const* si = sSpellMgr->GetSpellInfo(rnk);
if (!si)
continue;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (si->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grant = si->Effects[e].TriggerSpell;
if (!grant)
continue;
CollectSpellChainIds(grant, learnGrantIds);
}
}
if (learnGrantIds.empty())
return false;
std::unordered_set<uint32> depRanks;
CollectSpellChainIds(depSpellId, depRanks);
if (depRanks.empty())
depRanks.insert(depSpellId);
for (uint32 d : depRanks)
if (learnGrantIds.count(d))
return true;
return false;
}
// 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. SPELL_EFFECT_LEARN_SPELL
// grants are detected automatically; DK Plague Strike / Icy Touch disease
// passives (Blood Plague / Frost Fever) share the weapon skill line with
// their strikes and need an explicit carve-out — see
// `IsParagonStrikeTiedDiseasePassive` inside `IsSpellSkillLineCascadeDependent`.
// * 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) && IsParagonSpellAllowedByRidingSkill(pl, 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.
// * Passives (Forceful Deflection, Runic Focus, Blood Plague
// 59879, ...) ALWAYS stick. Recorded as panel_spell_children
// so reset/queue-unlearn drop them with the parent.
// * Actives (Blood Presence stance, Death Coil, Death Grip,
// ...) are revoked + persisted. AC's `_LoadSkills` re-fires
// `learnSkillRewardedSpells` on every login and would
// silently re-grant them otherwise.
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;
// Meta-skill cascade carve-out. Runeforging (776) is a
// CLASS-category skill that, once granted, is supposed to
// cascade ALL its rune-enchant spells (Rune of the Fallen
// Crusader, Razorice, Cinderglacier, Lichbane, Spell-/
// Sword-shattering, Spell-/Sword-breaking, Stoneskin
// Gargoyle, Nerubian Carapace) for the player to choose
// from at a runeforge anvil. Those rune-enchants are
// ACTIVE spells, so the default policy below would
// revoke them and the player would learn Runeforging
// for nothing. Treat the whole cluster the same way we
// treat passive deps: persist as children of the panel
// purchase so refund/unlearn drops them too, but do NOT
// revoke them.
//
// Detection: walk the dep's own SkillLineAbility entries
// and check for SKILL_RUNEFORGING. This auto-handles all
// 10 rune-enchant spells without an ID-by-ID allowlist.
bool isMetaSkillCascade = false;
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
isMetaSkillCascade = true;
break;
}
}
}
if (dep->IsPassive() || isMetaSkillCascade)
{
DbInsertPanelSpellChild(lowGuid, trackId, spellId);
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} ({} dep, kept as child of {})",
spellId,
isMetaSkillCascade ? "meta-skill" : "passive",
trackId);
}
else
{
pl->removeSpell(spellId, SPEC_MASK_ALL, false);
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);
// ------------------------------------------------------------------
// Forced passive attachments. Some Wrath spells *should* come with a
// passive entry in the spellbook (so the player can read what the
// disease/aura does), but the engine only adds those passives via
// `learnSkillRewardedSpells`, which fires exactly ONCE per skill —
// the first time the player learns a spell on that skill line. If
// the player bought Blood Strike before Plague Strike, the cascade
// ran for Blood Strike, revoked the disease passives, and a later
// Plague Strike purchase finds the skill already known and never
// re-grants them.
//
// For each (chain head -> attached spell) pair below: if the player
// does not already have the attached spell, learnSpell it (silently
// -- the silence window is still open) and record it as a panel
// child of `trackId` so reset/queue-unlearn drop it alongside the
// parent. If the attached spell currently has a stale revoke row
// pointing at it (left over from the cascade run for a different
// parent), that row is dropped so the next login doesn't unlearn it.
// ------------------------------------------------------------------
{
// Static, intentionally tiny: every entry is a hand-curated
// spell-effect attachment that the spellbook UX expects to
// travel with the parent. Add new entries sparingly.
// IMPORTANT: 55078 / 55095 are the on-target *debuff* spell IDs for
// Blood Plague / Frost Fever (cast on enemies by Plague Strike /
// Icy Touch via SPELL_EFFECT_TRIGGER_SPELL). They are NOT marked
// passive in Spell.dbc, so the client renders them as castable
// spellbook icons. The correct *passive* spellbook entries the
// player is supposed to see are 59879 / 59921 (the descriptive
// "Passive disease" rows; SPELL_ATTR0_PASSIVE bit set).
// After the Paragon class-skill cascade guard landed in
// Player::learnSkillRewardedSpells, NONE of the DK skill-line
// cascade rewards are auto-granted any more — so passives that
// used to ride along on a class skill cascade (Forceful
// Deflection on Blood Strike, Runic Focus on Icy Touch) must be
// explicitly attached here, the same way Blood Plague / Frost
// Fever are. Add new entries when a panel-purchased active is
// expected to come with a passive spellbook entry that no
// SPELL_EFFECT_LEARN_SPELL on the parent provides.
struct AttachedPassive { uint32 parentHead; uint32 attachedSpell; };
static AttachedPassive const kAttached[] = {
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive entry)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
{ 45477, 61455 }, // Icy Touch -> Runic Focus (parry from spell power)
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection (parry from strength)
// Runeforging -> 8 basic rune-enchants. The
// SkillLineAbility rows for these (skill 776) all ship
// with AcquireMethod = 0 in the DBC (i.e. NOT auto-learn-
// on-skill-grant). For stock DKs the engine's hardcoded
// runeforging UI hand-rolls the cast for whichever rune
// the player picks, but for our Lua-substitute UI the
// server's HandleCastSpellOpcode / HasActiveSpell gate
// rejects the cast unless the spell is in the spellbook.
// Force-attach them as panel children so:
// 1. The player actually owns the spells (cast works).
// 2. Refunding Runeforging cleans them up via the
// standard panel_spell_children unlearn path.
// The two ADVANCED runes (Stoneskin Gargoyle 62158 and
// Nerubian Carapace 70164) are intentionally NOT listed:
// retail gates them behind item drops from heroic
// dungeons / Naxx / ICC, and our SkillLineAbility rows
// for them already use AcquireMethod=0 so the player
// gets them when they pick up the appropriate item, not
// for free with Runeforging itself.
{ 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Rune of Razorice
{ 53428, 53341 }, // Runeforging -> Rune of Cinderglacier
{ 53428, 53331 }, // Runeforging -> Rune of Lichbane
{ 53428, 53342 }, // Runeforging -> Rune of Spellshattering
{ 53428, 53323 }, // Runeforging -> Rune of Swordshattering
{ 53428, 54447 }, // Runeforging -> Rune of Spellbreaking
{ 53428, 54446 }, // Runeforging -> Rune of Swordbreaking
};
// Self-heal: a previous build of mod-paragon (briefly shipped)
// attached the on-target debuff IDs (55078 / 55095) instead of
// the passive spellbook IDs (59879 / 59921). Drop any such row
// and unlearn the spell so the player isn't left with a phantom
// "castable" Blood Plague / Frost Fever icon in their spellbook.
struct LegacyAttached { uint32 parentHead; uint32 wrongSpell; };
static LegacyAttached const kLegacyWrong[] = {
{ 45462, 55078 },
{ 45477, 55095 },
};
for (auto const& lw : kLegacyWrong)
{
if (lw.parentHead != trackId)
continue;
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}",
lowGuid, trackId, lw.wrongSpell);
if (pl->HasSpell(lw.wrongSpell))
pl->removeSpell(lw.wrongSpell, SPEC_MASK_ALL, false);
}
for (auto const& ap : kAttached)
{
if (ap.parentHead != trackId)
continue;
if (pl->HasSpell(ap.attachedSpell))
{
DbInsertPanelSpellChild(lowGuid, trackId, ap.attachedSpell);
continue;
}
pl->learnSpell(ap.attachedSpell, false);
DbInsertPanelSpellChild(lowGuid, trackId, ap.attachedSpell);
// Drop any stale revoke row pointing at the just-attached
// spell. Otherwise the login sweep would unlearn it and
// PushSpellSnapshot's !HasSpell branch would then orphan
// the panel_spell_children row.
std::unordered_set<uint32> attachedChain;
CollectSpellChainIds(ap.attachedSpell, attachedChain);
if (attachedChain.empty())
attachedChain.insert(ap.attachedSpell);
DbDeletePanelSpellRevokedForChain(lowGuid, attachedChain);
if (diag)
LOG_INFO("module",
"[paragon-diag] forced-attach +{} as child of {} (skill cascade missed it)",
ap.attachedSpell, 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);
}
// Player::HasTalent(spell, spec) ignores `spec` and only tests the active
// spec — useless for dual-spec. Walk the talent map + specMask instead.
[[nodiscard]] static bool PlayerTalentRankSpellKnownInAnySpec(Player* pl, uint32 rankSpellId)
{
if (!pl || !rankSpellId)
return false;
PlayerTalentMap const& tm = pl->GetTalentMap();
auto itr = tm.find(rankSpellId);
if (itr == tm.end() || !itr->second)
return false;
PlayerTalent const* const pt = itr->second;
if (pt->State == PLAYERSPELL_REMOVED)
return false;
uint8 const mask = pt->specMask;
for (uint8 s = 0; s < pl->GetSpecsCount(); ++s)
if (mask & (1u << s))
return true;
return false;
}
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 r = 0; r < MAX_TALENT_RANK; ++r)
if (te->RankID[r] && PlayerTalentRankSpellKnownInAnySpec(pl, te->RankID[r]))
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>,... u:<id>,..."
// The " u:" spell-unlearn section is optional (omitted by older clients).
// Both s: and t: leading tags are required. Examples:
// "C COMMIT s:5176,8921 t:"
// "C COMMIT s: t:1234:1,5678:2"
// "C COMMIT s: t: u:45477"
// 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);
// * chain ids + tracked passive children for spells intentionally unlearned
// in this commit (so "You have unlearned …" for those stays visible).
// 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,
std::vector<uint32> const& unlearnTrackIds = {},
std::vector<uint32> const& talentUnlearnIds = {})
{
if (!pl)
return;
std::unordered_set<uint32> allow;
for (auto const& kv : spellsAndCosts)
CollectSpellChainIds(kv.first, allow);
uint32 const lowGuid = pl->GetGUID().GetCounter();
for (uint32 trackId : unlearnTrackIds)
{
if (!trackId)
continue;
CollectSpellChainIds(trackId, allow);
if (QueryResult cr = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {}",
lowGuid, trackId))
{
do
{
uint32 const cid = cr->Fetch()[0].Get<uint32>();
if (cid)
allow.insert(cid);
} while (cr->NextRow());
}
}
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]);
}
// Talent unlearns: each rank id is about to fire SMSG_REMOVED_SPELL.
// Whitelist them so the "You have unlearned <rank-1 name>" toast
// shown to the user is not suppressed.
for (uint32 tid : talentUnlearnIds)
{
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");
}
// ---------------------------------------------------------------------------
// Buff-cheese cleanup. When a panel-purchased spell is unlearned (queue
// unlearn, build swap, full reset) any aura the player cast on themselves
// with that spell goes away with the spell. Without this, a Paragon could
// cast Power Word: Fortitude on themselves, queue PW:F for unlearn on
// Learn All to recoup the AE, and keep the buff for free until next zone
// change.
//
// Filters: only the player's *own* self-cast auras are touched. Buffs on
// the same player from another caster (a real priest in the group, paladin
// blessings, etc.) are left alone, and any auras the player cast on others
// are not affected by this sweep -- crowd-cleansing on every panel mutation
// would be more annoying than the cheese it closes.
//
// The chain walk is so any rank's aura goes regardless of which rank was
// active when it was cast (cast PW:F R8, then unlearn the chain whose head
// is R1 -> we still have to look at R8 to find the active aura).
// ---------------------------------------------------------------------------
void RemoveSelfCastAurasForChain(Player* pl, uint32 chainAnyRankId)
{
if (!pl || !chainAnyRankId)
return;
std::unordered_set<uint32> chainIds;
CollectSpellChainIds(chainAnyRankId, chainIds);
if (chainIds.empty())
chainIds.insert(chainAnyRankId);
ObjectGuid const myGuid = pl->GetGUID();
for (uint32 rankId : chainIds)
pl->RemoveOwnedAura(rankId, myGuid);
}
// Blanket sweep of self-cast non-passive auras. Used at the tail of
// HandleBuildLoad (build swap) so any stale self-buffs cast prior to the
// swap are cleared, even if the spell that produced them is also in the
// new build's recipe. There is no useful semantic of "preserve buffs across
// loadout swap" -- the swap is meant to be a clean state transition, and
// keeping arbitrary buffs across it is exactly the cheese vector for
// any spell that is in the OUTGOING recipe but not the INCOMING one.
//
// Skipped:
// - auras whose caster is not the player (party buffs, NPC debuffs)
// - passives -- spellbook-driven; removing them just makes the engine's
// CastPassiveAuras / spellbook_apply_passives re-grant them on the
// next tick. Pointless churn.
void SweepSelfCastSpellAuras(Player* pl)
{
if (!pl)
return;
ObjectGuid const myGuid = pl->GetGUID();
std::vector<uint32> toRemove;
toRemove.reserve(8);
for (auto const& kv : pl->GetOwnedAuras())
{
Aura const* aura = kv.second;
if (!aura)
continue;
if (aura->GetCasterGUID() != myGuid)
continue;
SpellInfo const* si = aura->GetSpellInfo();
if (!si || si->IsPassive())
continue;
toRemove.push_back(kv.first);
}
for (uint32 spellId : toRemove)
pl->RemoveOwnedAura(spellId, myGuid);
}
// Removes one Character Advancement spell purchase (chain head in
// character_paragon_panel_spells). Refunds that row's AE cost, unlearns
// tracked passive children then the parent chain, and clears matching
// panel_* DB rows (mirrors the per-spell portion of HandleParagonResetAbilities).
// `spellId` may be any rank id from the bake; normalized to GetFirstSpellInChain.
bool PanelUnlearnSpellPurchase(Player* pl, uint32 spellId, std::string* err)
{
if (!pl || !spellId)
{
if (err)
*err = "bad player or spell";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint32 const head = sSpellMgr->GetFirstSpellInChain(spellId);
uint32 const sid = head ? head : spellId;
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
lowGuid, sid))
{
if (err)
*err = fmt::format("spell {} is not a panel purchase", sid);
return false;
}
uint32 const refund = LookupSpellAECost(sid);
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);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
std::unordered_set<uint32> chainIds;
CollectSpellChainIds(sid, chainIds);
DbDeletePanelSpellRevokedForChain(lowGuid, chainIds);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}",
lowGuid, sid);
// Buff-cheese close: drop any self-cast aura whose source is this chain.
// (See RemoveSelfCastAurasForChain comment.)
RemoveSelfCastAurasForChain(pl, sid);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence += refund;
return true;
}
// Removes one Character Advancement talent purchase entirely. Refunds all
// ranks worth of TE (and AE for addToSpellBook talents), drops every rank
// spell from m_spells / m_talents across all specs, deletes the panel_talents
// row. Symmetric counterpart of PanelUnlearnSpellPurchase.
bool PanelUnlearnTalentPurchase(Player* pl, uint32 talentId, uint32* outRefundAE,
uint32* outRefundTE, std::string* err)
{
if (outRefundAE) *outRefundAE = 0;
if (outRefundTE) *outRefundTE = 0;
if (!pl || !talentId)
{
if (err) *err = "bad player or talent";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
QueryResult q = CharacterDatabase.Query(
"SELECT `rank` FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}",
lowGuid, talentId);
if (!q)
{
if (err) *err = fmt::format("talent {} is not a panel purchase", talentId);
return false;
}
uint32 const dbRank = q->Fetch()[0].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(talentId);
if (!te)
{
if (err) *err = fmt::format("unknown talent {}", talentId);
return false;
}
// Use the player's *actual* rank across specs, capped at the DB record.
// Refund matches what was actually spent: a partial-rank purchase that
// got reset out of one spec but not another should refund what was
// recorded in panel_talents, not the engine state.
uint32 const actual = ComputeTalentRankAnySpec(pl, talentId);
uint32 const refundRanks = std::min(dbRank, actual ? actual : dbRank);
if (!refundRanks)
{
// Player has no rank but row exists -> stale. Drop it and skip
// refund so we don't double-credit a previous reset.
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}",
lowGuid, talentId);
return true;
}
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
uint32 const refundTE = refundRanks * tePerRank;
uint32 const refundAE = te->addToSpellBook ? (refundRanks * aePerRank) : 0;
// Wipe every rank across all specs. Important caveats:
// * Player::removeSpell only touches m_spells. The m_talents entry
// (PlayerTalent + specMask) is NOT cleared, so HasTalent /
// `HasBeastMasteryInAnySpec` keep returning true after.
// Player::resetTalents pairs `_removeTalentAurasAndSpells` +
// `_removeTalent` for that reason — mirror it here.
// * `addToSpellBook` talents (Bladestorm/Starfall/...) also live in
// the spellbook and need a removeSpell so the icon goes away.
// * Some talents trigger `IsAdditionalTalentSpell` extras via
// SPELL_EFFECT_LEARN_SPELL — strip those too (matches resetTalents).
for (uint8 ri = 0; ri < MAX_TALENT_RANK; ++ri)
{
uint32 const rankSpell = te->RankID[ri];
if (!rankSpell)
continue;
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(rankSpell);
pl->_removeTalentAurasAndSpells(rankSpell);
if (te->addToSpellBook && spellInfo
&& !spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE)
&& !spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL))
{
if (pl->HasSpell(rankSpell))
pl->removeSpell(rankSpell, SPEC_MASK_ALL, false);
}
if (spellInfo)
{
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
uint32 const trig = spellInfo->Effects[i].TriggerSpell;
if (spellInfo->Effects[i].Effect == SPELL_EFFECT_LEARN_SPELL && trig
&& sSpellMgr->IsAdditionalTalentSpell(trig)
&& pl->HasSpell(trig))
{
pl->removeSpell(trig, SPEC_MASK_ALL, false);
}
}
}
// Drop the m_talents row so HasTalent / HasBeastMasteryInAnySpec /
// OnPlayerLearnTalents bookkeeping stop seeing the talent.
pl->_removeTalent(rankSpell, SPEC_MASK_ALL);
}
// Push the engine-side talent state to the client so the talent UI
// (and the +talent-points pool) reflects the unlearn immediately.
pl->SendTalentsInfoData(false);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}",
lowGuid, talentId);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence += refundAE;
d.talentEssence += refundTE;
if (outRefundAE) *outRefundAE = refundAE;
if (outRefundTE) *outRefundTE = refundTE;
return true;
}
// Forward declarations for helpers defined later in this TU. HandleCommit
// is far enough above them in the file that we'd need to either rearrange
// or declare upfront; declarations are smaller surface area.
bool HasBeastMasteryInAnySpec(Player* pl);
void MaybeForcePetTalentResetForBeastMasteryLoss(Player* pl, bool hadBeastMasteryBefore);
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;
std::string_view unlearnCsv;
std::string_view talentUnlearnCsv;
// Layout after " t:" is one of:
// <talents> (no unlearns at all)
// <talents> u:<spell-unlearn-ids> (legacy)
// <talents> u:<...> tu:<talent-unlearn-ids> (current)
// <talents> tu:<talent-unlearn-ids> (no spell unlearns)
// The " u:" / " tu:" tokens are kept distinct (note the leading space)
// so a substring match for " u:" never collides with " tu:".
size_t const uPos = rest.find(" u:", tPos);
size_t const tuPos = rest.find(" tu:", tPos);
auto inRange = [](size_t v) { return v != std::string_view::npos; };
if (inRange(uPos) && inRange(tuPos))
{
talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3));
unlearnCsv = rest.substr(uPos + 3, tuPos - (uPos + 3));
talentUnlearnCsv = rest.substr(tuPos + 4);
}
else if (inRange(uPos))
{
talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3));
unlearnCsv = rest.substr(uPos + 3);
}
else if (inRange(tuPos))
{
talentsCsv = rest.substr(tPos + 3, tuPos - (tPos + 3));
talentUnlearnCsv = rest.substr(tuPos + 4);
}
else
talentsCsv = rest.substr(tPos + 3);
std::vector<uint32> spellIds = ParseCsvUInt(spellsCsv);
std::vector<uint32> unlearnRaw = ParseCsvUInt(unlearnCsv);
std::vector<uint32> talentUnlearnRaw = ParseCsvUInt(talentUnlearnCsv);
// 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;
}
}
std::unordered_set<uint32> unlearnTrackSet;
std::vector<uint32> unlearnTracks;
for (uint32 raw : unlearnRaw)
{
if (!raw)
continue;
uint32 const head = sSpellMgr->GetFirstSpellInChain(raw);
uint32 const tid = head ? head : raw;
if (unlearnTrackSet.insert(tid).second)
unlearnTracks.push_back(tid);
}
// Dedupe talent unlearns (same talent twice in one commit is a no-op).
std::unordered_set<uint32> talentUnlearnSet;
std::vector<uint32> talentUnlearns;
talentUnlearns.reserve(talentUnlearnRaw.size());
for (uint32 tid : talentUnlearnRaw)
{
if (!tid)
continue;
if (talentUnlearnSet.insert(tid).second)
talentUnlearns.push_back(tid);
}
if (spellIds.size() + talentDeltas.size() + unlearnTracks.size() + talentUnlearns.size() > kCommitMaxItems)
{
*err = "commit exceeds size cap";
return false;
}
uint32 unlearnRefundAE = 0;
for (uint32 tid : unlearnTracks)
{
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
pl->GetGUID().GetCounter(), tid))
{
*err = fmt::format("cannot unlearn {} (not a panel purchase)", tid);
return false;
}
unlearnRefundAE += LookupSpellAECost(tid);
}
// Pre-validate talent unlearns. Each must be a panel purchase, must
// not also appear in talentDeltas (can't learn + unlearn in one
// commit), and contributes its full ranks * costPerRank to the
// commit's refund pool. addToSpellBook talents add AE to the pool;
// all talents add TE.
uint32 talentUnlearnRefundAE = 0;
uint32 talentUnlearnRefundTE = 0;
{
uint32 const tePerRank_pre = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank_pre = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
for (uint32 tid : talentUnlearns)
{
for (auto const& [td, _delta] : talentDeltas)
{
if (td == tid)
{
*err = "cannot learn and unlearn the same talent in one commit";
return false;
}
}
QueryResult r = CharacterDatabase.Query(
"SELECT `rank` FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}",
pl->GetGUID().GetCounter(), tid);
if (!r)
{
*err = fmt::format("cannot unlearn talent {} (not a panel purchase)", tid);
return false;
}
uint32 const rank = r->Fetch()[0].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
{
*err = fmt::format("unknown talent {}", tid);
return false;
}
talentUnlearnRefundTE += rank * tePerRank_pre;
if (te->addToSpellBook)
talentUnlearnRefundAE += rank * aePerRank_pre;
}
}
// 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 const learnHead = sSpellMgr->GetFirstSpellInChain(id);
uint32 const learnTrack = learnHead ? learnHead : id;
if (unlearnTrackSet.count(learnTrack))
{
*err = "cannot learn and unlearn the same spell in one commit";
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) + talentUnlearnRefundTE < talentsTE)
{
*err = fmt::format("not enough TE (need {} have {} plus {} from talent unlearns in this commit)",
talentsTE, GetTE(pl), talentUnlearnRefundTE);
return false;
}
if (GetAE(pl) + unlearnRefundAE + talentUnlearnRefundAE < (totalAE + talentsAE))
{
*err = fmt::format("not enough AE (need {} total; you have {} plus {} from spell unlearns and {} from talent unlearns in this commit)",
totalAE + talentsAE, GetAE(pl), unlearnRefundAE, talentUnlearnRefundAE);
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 + chains/children for intentional unlearns
// (spells + talents).
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks, talentUnlearns);
// Capture BM pre-state for the pet-talent-respec check below. Beast
// Mastery is a talent, so only the talent unlearn path can flip the
// value within this commit.
bool const hadBeastMasteryPre = !talentUnlearns.empty() && HasBeastMasteryInAnySpec(pl);
// Apply unlearns first so refunded AE/TE is available for spends.
for (uint32 tid : unlearnTracks)
{
if (!PanelUnlearnSpellPurchase(pl, tid, err))
{
SendSilenceClose(pl);
return false;
}
}
for (uint32 tid : talentUnlearns)
{
if (!PanelUnlearnTalentPurchase(pl, tid, /*outRefundAE*/nullptr,
/*outRefundTE*/nullptr, err))
{
SendSilenceClose(pl);
return false;
}
}
// If a talent that puts an active aura on the player (e.g. an
// addToSpellBook talent like Improved Devotion Aura) just got
// refunded, the aura should go with it. Mirrors the build-swap
// sweep. Cheap when no talents were unlearned.
if (!talentUnlearns.empty())
{
SweepSelfCastSpellAuras(pl);
MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPre);
}
// 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);
// Fractured / Paragon: talents that LEARN_SPELL an ability
// (Mangle, Feral Charge, Mutilate, ...) only directly grant the
// rank-1 ability spell. Stock classes auto-rank-up the granted
// spell via Player::learnSkillRewardedSpells on level-up, but
// that path is intentionally disabled for Paragon class skill
// lines (Player.cpp guard) -- so without this cascade the
// ability stays at rank 1 forever (Mangle Bear 33878 instead
// of 33986 / 33987 / 48563 / 48564). Walk every LEARN_SPELL
// target on this rank's RankID spell and grant the highest
// rank the player's level allows.
if (TalentEntry const* freshTe = sTalentStore.LookupEntry(tid))
if (uint32 rankSpell = freshTe->RankID[r])
CascadeRanksForTalentLearnSpellEffects(pl, rankSpell);
}
}
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;
}
// Note: we deliberately do NOT filter SPELL_ATTR0_DO_NOT_DISPLAY
// here. Earlier builds did, on the theory that hidden spells
// shouldn't appear in the spellbook-style Overview tab. That
// turned out to be wrong: cascade-granted hidden passives
// (Forceful Deflection, Frost Fever, ...) live in
// panel_spell_children, not in panel_spells -- so the only
// entries that ever land in this query are the chain heads
// the player explicitly purchased. Those MUST appear in the
// Overview even if their DBC entry is hidden, because they
// are the player's actual purchases (e.g. Runeforging 53428
// is hidden in the DBC but is the entire Runeforging panel
// purchase). Filtering them out left chars whose only buy
// was Runeforging with an empty Overview tab -- looked like
// a regression but was actually the existing snapshot logic
// mismatching the panel's user-facing semantics.
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);
// Buff-cheese close: drop any self-cast aura sourced from this
// chain. Same rationale as PanelUnlearnSpellPurchase, applied
// here so a full Reset Abilities (or the reset phase of a build
// swap) doesn't leave Power Word: Fortitude / Inner Fire / etc.
// ticking after the spell is gone from the spellbook.
RemoveSelfCastAurasForChain(pl, sid);
} 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;
}
// Reset detaches the player from any active build. The build's
// saved recipe is preserved in DB so the player can re-load it,
// but until they do, the next swap MUST NOT auto-snapshot the
// (now empty/partial) panel state into that build -- which is
// exactly what would happen if we left the pointer set. We push
// the catalog so the cell that previously had the "active"
// border drops it client-side; if this reset is being invoked
// mid-swap (HandleBuildLoad), the swap's final PushBuildCatalog
// restores the correct activeId at the tail.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE);
return true;
}
// ---------------------------------------------------------------------------
// Beast Mastery (Hunter 51-pt talent, spell 53270): grants +4 pet talent
// points while learned. If the player loses the talent (Reset Talents,
// build swap to a non-BM recipe, ...) we must wipe the pet's current talent
// allocation -- otherwise the 4 extra slots they spent while specced into
// BM keep their effects after the talent is gone, which is straight cheese.
//
// Detection is "had it before, doesn't have it after". Beast Mastery is a
// single-rank talent (spell 53270 on talent id 2139 in our client bake); the
// rank spell must be looked up via the talent map's specMask — Player::HasTalent
// ignores its spec argument and only checks the active spec, which misses BM
// learned on the inactive dual-spec page.
// ---------------------------------------------------------------------------
constexpr uint32 kSpellBeastMastery = 53270;
bool HasBeastMasteryInAnySpec(Player* pl)
{
return PlayerTalentRankSpellKnownInAnySpec(pl, kSpellBeastMastery);
}
void MaybeForcePetTalentResetForBeastMasteryLoss(Player* pl, bool hadBeastMasteryBefore)
{
if (!pl || !hadBeastMasteryBefore)
return;
if (HasBeastMasteryInAnySpec(pl))
return; // still learned somewhere -> no cheese to close
Pet* pet = pl->GetPet();
if (!pet || pet->getPetType() != HUNTER_PET)
return; // only hunter pets have a talent tree in 3.3.5
// Free, instant: refunds spent pet talents and re-pushes the talent UI.
// Same call the addon's "C RESET PET TALENTS" verb uses.
pl->ResetPetTalents();
LOG_INFO("module",
"Paragon panel: {} lost Beast Mastery -> pet talents force-reset",
pl->GetName());
}
bool HandleParagonResetTalents(Player* pl, std::string* err, bool autoResetPetIfBmLost = true)
{
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;
}
// Capture pre-reset BM state so we can detect a "had it, lost it"
// transition once the engine's resetTalents pass below has wiped
// every spec. Skipped when the caller wants to handle the check
// themselves (HandleBuildLoad does it post-recipe-apply so it can
// also clear BM lost across a swap into a non-BM build).
bool const hadBeastMasteryPre = autoResetPetIfBmLost && HasBeastMasteryInAnySpec(pl);
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;
// See HandleParagonResetAbilities: detach from the active build
// so the next swap doesn't overwrite its saved recipe with the
// (post-reset) state.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
// Pet-talent cheese close: if BM was learned before this reset and
// resetTalents has now wiped it out of every spec, force a free pet
// talent respec. Skipped when the caller opted out (HandleBuildLoad
// defers this to its own post-recipe-apply check).
if (autoResetPetIfBmLost)
MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPre);
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 <name>\t<icon> -- create empty build
// C BUILD SAVE_CURRENT <name>\t<icon> -- create build from current
// panel state and set active
// C BUILD EDIT <id>\t<name>\t<icon> -- rename / re-icon
// C BUILD DELETE <id> -- delete + drop parked pet;
// if <id> is the active build,
// also full panel reset (unlearn
// + AE/TE refund) like RESET ALL
// C BUILD LOAD <id> -- swap to this build
// C BUILD UNLOAD -- clear active pointer
// C BUILD IMPORT <sharecode> -- copy a shared build
// into our own catalog
//
// Server replies push `R BUILDS` after every mutation. Format:
//
// R BUILDS active=<id|->\t<id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>; ...
//
// `sharecode` is a 6-character random alphanumeric token unique across
// the realm, generated at build creation. It's how players exchange
// builds: paste the code into the BuildsPane share box on a friend's
// client and the IMPORT command copies the recipe (name + icon + spell
// rows + talent rows) into the friend's catalog as a new build with a
// fresh sharecode (so the imported copy can be re-shared independently).
//
// `remainAE` / `remainTE` are SIGNED int32s representing the AE / TE
// the player would have unspent IF they loaded this build right now
// (load = refund all currently-learned panel spells/talents, then
// re-spend on the target recipe). Negative means the recipe costs
// more than the player has earned -- the client renders that case in
// red so the player knows they can't afford the load.
//
// 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\\<icon>".
// ----------------------------------------------------------------------------
constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark";
constexpr std::size_t kBuildNameMaxLen = 32;
constexpr std::size_t kBuildIconMaxLen = 64;
// Share code charset: upper-case alphanumeric minus visually-ambiguous
// glyphs (I, O, 0, 1) so codes spoken aloud or copied by hand are
// unambiguous. 31 chars ^ 6 positions = ~887M unique codes; collision
// retry on insert keeps practical collision probability vanishingly
// small for any realistic per-realm catalog.
constexpr std::size_t kBuildShareCodeLen = 6;
constexpr char const kBuildShareCharset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
constexpr std::size_t kBuildShareCharsetN = sizeof(kBuildShareCharset) - 1;
std::string GenerateBuildShareCode()
{
std::string code;
code.reserve(kBuildShareCodeLen);
for (std::size_t i = 0; i < kBuildShareCodeLen; ++i)
code += kBuildShareCharset[urand(0, static_cast<uint32>(kBuildShareCharsetN) - 1)];
return code;
}
// Strict whitelist on incoming `C BUILD IMPORT <code>` payloads. The
// SQL we'd feed into the lookup query interpolates the value via
// fmt::format (matching the rest of this file's style), so we vet
// length and charset up front and reject anything that isn't 6
// characters drawn from the same alphabet GenerateBuildShareCode emits.
bool IsValidShareCode(std::string const& s)
{
if (s.size() != kBuildShareCodeLen)
return false;
for (char c : s)
{
bool ok = false;
for (std::size_t i = 0; i < kBuildShareCharsetN; ++i)
{
if (c == kBuildShareCharset[i])
{
ok = true;
break;
}
}
if (!ok)
return false;
}
return true;
}
// Generate a fresh share code, retrying on collision against the
// existing rows. With a 31^6 alphabet and even 1M rows the probability
// of a single random pick colliding is < 0.001%, so 8 retries is far
// more than enough headroom; the loop is purely defensive.
std::string GenerateUniqueShareCode()
{
for (int attempt = 0; attempt < 8; ++attempt)
{
std::string code = GenerateBuildShareCode();
if (QueryResult r = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code))
continue;
if (QueryResult r2 = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
continue;
return code;
}
// Worst-case fallback: append a numeric uniquifier from build_id
// sequence. We can't produce a guaranteed-unique 6-char code if
// ~887M codes are taken (impossible at any realistic scale), so
// collapse to the last attempt and let the unique index reject
// duplicates if the universe is broken.
return GenerateBuildShareCode();
}
// After a successful Learn All while a build is active: freeze the
// previous share_code + recipe into character_paragon_build_share_archive*
// (so Discord-posted codes keep importing that exact loadout), then
// snapshot the panel into the live build rows and assign a fresh code
// for the owner's current recipe.
void PersistActiveBuildSnapshotAfterLearnAllCommit(Player* pl, uint32 buildId)
{
if (!pl || !buildId)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (!BuildBelongsToPlayer(lowGuid, buildId))
return;
std::string oldCode;
if (QueryResult row = CharacterDatabase.Query(
"SELECT COALESCE(NULLIF(share_code, ''), '') AS sc "
"FROM character_paragon_builds WHERE build_id = {} AND guid = {}",
buildId, lowGuid))
oldCode = row->Fetch()[0].Get<std::string>();
if (!oldCode.empty())
{
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_spells WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_talents WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive (share_code, name, icon) "
"SELECT share_code, name, icon FROM character_paragon_builds "
"WHERE build_id = {} AND guid = {}", buildId, lowGuid);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_spells (share_code, spell_id) "
"SELECT '{}', spell_id FROM character_paragon_build_spells WHERE build_id = {}",
oldCode, buildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_talents (share_code, spec, talent_id, `rank`) "
"SELECT '{}', spec, talent_id, `rank` FROM character_paragon_build_talents WHERE build_id = {}",
oldCode, buildId);
}
SnapshotBuildFromCurrent(pl, buildId);
std::string const newCode = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' WHERE build_id = {} AND guid = {}",
newCode, buildId, lowGuid);
LOG_INFO("module",
"Paragon build: {} persisted active build {} after commit (share now {})",
pl->GetName(), buildId, newCode);
}
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<uint32>();
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;
}
// ----------------------------------------------------------------------------
// Cost / remaining-AE-TE helpers (used by PushBuildCatalog tooltip data).
// ----------------------------------------------------------------------------
// "Remaining if loaded" = (total earned by this player so far)
// - (cost of recipe stored in this build).
//
// The wire push computes both halves and ships the *remaining* numbers
// per build; the client just renders them. Computing total_earned from
// (current pool + currently-spent on panel) keeps us from having to
// add a dedicated "earned" counter to character_paragon_currency.
//
// Note on per-spec accuracy: character_paragon_panel_talents is keyed
// (guid, talent_id) -- it stores the highest rank in any spec, not a
// per-spec breakdown. If a Paragon char has DIFFERENT talent allocations
// in spec 0 vs spec 1 they may have paid TE multiple times for the same
// talent_id, but the "spent" walk only sees one row. This undercounts
// total_earned in that edge case, which makes "remaining if loaded"
// show conservatively LOW for builds that include cross-spec talents.
// The character_paragon_build_talents table IS keyed (build_id, spec,
// talent_id) so the BUILD cost side is always accurate. Net effect:
// the tooltip might say a build needs 2 more AE than it really does
// for a small fraction of players. That's preferable to over-promising
// and having the load fail with "not enough TE" mid-flight.
struct BuildCost
{
uint32 ae = 0;
uint32 te = 0;
};
BuildCost ComputeBuildRecipeCost(uint32 buildId)
{
BuildCost out{};
if (!buildId)
return out;
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
// Spell side: each panel spell costs LookupSpellAECost. The recipe
// table doesn't carry rank info because PanelLearnSpellChain grants
// every higher rank of a chain in one charge, so one row = one
// chain-head buy = one cost lookup.
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}",
buildId))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} while (r->NextRow());
}
// Talent side: charge tePerRank * rank for every (spec, talent_id)
// row. addToSpellBook talents (the few that grant an active spell
// like Starfall / Bladestorm / Mirror Image) charge AE on top.
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}", buildId))
{
do
{
Field const* f = r->Fetch();
uint32 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Sum the AE/TE the player has currently SPENT on panel-bought spells
// and talents. Refunding everything via Reset would return exactly this
// total to the unspent pool, so total_earned == current + spent.
BuildCost ComputeCurrentlySpentOnPanel(uint32 lowGuid)
{
BuildCost out{};
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 spell_id FROM character_paragon_panel_spells WHERE guid = {}",
lowGuid))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} 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 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Lazily assign share codes to any of this player's builds that still
// have a NULL share_code (rows created under the pre-2026_05_10_04
// schema). Runs at the top of every PushBuildCatalog so by the time
// the wire response is built every row has a non-NULL code. Cheap in
// steady-state -- the SELECT returns zero rows once backfilled.
void BackfillBuildShareCodes(uint32 lowGuid)
{
QueryResult r = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE guid = {} AND (share_code IS NULL OR share_code = '')", lowGuid);
if (!r)
return;
do
{
uint32 buildId = r->Fetch()[0].Get<uint32>();
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' "
"WHERE build_id = {}", code, buildId);
} while (r->NextRow());
}
void PushBuildCatalog(Player* pl)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
BackfillBuildShareCodes(lowGuid);
uint32 const active = GetActiveBuildId(lowGuid);
// total_earned (approx) = current unspent pool + amount currently
// spent on panel learns. Refunding everything via Reset would
// return exactly the spent portion, so we model "remaining if
// loaded" as `total_earned - build_cost`. See the long comment
// on ComputeCurrentlySpentOnPanel for the per-spec edge case.
BuildCost const spent = ComputeCurrentlySpentOnPanel(lowGuid);
int64 const earnedAE = int64(GetAE(pl)) + int64(spent.ae);
int64 const earnedTE = int64(GetTE(pl)) + int64(spent.te);
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, pet_number, share_code, name, icon "
"FROM character_paragon_builds WHERE guid = {} "
"ORDER BY build_id ASC", lowGuid))
{
bool first = true;
do
{
Field const* f = r->Fetch();
uint32 id = f[0].Get<uint32>();
bool haspet = !f[1].IsNull() && f[1].Get<uint32>() != 0;
std::string code = f[2].IsNull() ? std::string() : f[2].Get<std::string>();
std::string name = f[3].Get<std::string>();
std::string icon = f[4].Get<std::string>();
BuildCost const cost = ComputeBuildRecipeCost(id);
// Signed: negative means the recipe costs more than the
// player has earned to date (insufficient). Clamp at int32
// bounds out of paranoia though realistic catalogs stay
// far inside.
int32 const remainAE = static_cast<int32>(earnedAE - int64(cost.ae));
int32 const remainTE = static_cast<int32>(earnedTE - int64(cost.te));
if (!first)
body += ';';
first = false;
// Wire format:
// <id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>
// Sharecode is always 6 chars after backfill; remainAE/TE
// are signed (formatted with %+d-equivalent via fmt's "{}",
// which renders a leading '-' for negatives and bare digits
// for non-negatives, matching the client's "%-?%d+" parse).
body += fmt::format("{}:{}:{}:{}:{}:{}:{}", id,
haspet ? 1 : 0,
code,
remainAE, remainTE,
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<uint32>() >= 64)
{
*err = "build limit reached (64)";
return false;
}
}
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, name, icon, code);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon build: {} created build '{}' (share code {})",
pl->GetName(), name, code);
return true;
}
// "Save current loadout as a new build". Driven by the Overview pane's
// "Save as Build" button. Equivalent to HandleBuildNew + an immediate
// SnapshotBuildFromCurrent into the new row, plus a SetActiveBuildId
// flip. Does NOT touch panel rows / currency / learned spells -- the
// player's state is already what they want, we just file it under a
// named slot. The previously-active build (if any) keeps its last
// committed recipe; loading it later restores that snapshot exactly
// as the normal swap flow does.
bool HandleBuildSaveCurrent(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
{
*err = "not a Paragon";
return false;
}
if (pl->IsInCombat())
{
*err = "cannot save builds while in combat";
return false;
}
auto tab = payload.find('\t');
if (tab == std::string::npos)
{
*err = "BUILD SAVE_CURRENT 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();
if (QueryResult cnt = CharacterDatabase.Query(
"SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid))
{
if (cnt->Fetch()[0].Get<uint32>() >= 64)
{
*err = "build limit reached (64)";
return false;
}
}
std::string insertName = name;
std::string insertIcon = icon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
std::string const code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, insertName, insertIcon, code);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", code);
if (!idRow)
{
*err = "save failed (could not allocate build_id)";
return false;
}
uint32 const newBuildId = idRow->Fetch()[0].Get<uint32>();
SnapshotBuildFromCurrent(pl, newBuildId);
SetActiveBuildId(lowGuid, newBuildId);
PushBuildCatalog(pl);
LOG_INFO("module",
"Paragon build: {} saved current loadout as build {} '{}' (share {})",
pl->GetName(), newBuildId, name, code);
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 "<id>\t<name>\t<icon>"
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<uint32>(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<uint32>(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<uint32>();
// Deleting the *active* build is a hard reset: unlearn everything the
// Character Advancement panel bought, refund all AE/TE into the
// unspent pool, and clear the active pointer (same net effect as
// C RESET ALL). Deleting a non-active slot only removes the saved
// recipe row + any parked pet bound to that slot.
uint32 const active = GetActiveBuildId(lowGuid);
if (active == buildId)
{
std::string resetErr;
if (!HandleParagonResetAll(pl, &resetErr))
{
*err = resetErr;
return false;
}
}
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;
}
// Import a build from another player's catalog by share code. Copies
// the recipe (name + icon + per-spec talent rows + spell rows) into a
// new owned build for the requester with a freshly-generated share
// code. Crucially does NOT auto-load the imported build -- the player
// finds it in their catalog and clicks it like any other saved build,
// matching the "import_only" UX choice. The original build owner's
// row is untouched.
//
// Errors (sent back as "R ERR ..." for the addon channel):
// - malformed code (length / charset)
// - code not found (neither live nor archived)
// - the code points to one of the requester's own live-catalog builds
// - the requester is at the 64-build cap
bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
{
*err = "not a Paragon";
return false;
}
// Trim whitespace and uppercase the input so users don't have to
// type the code in exact case. The wire-charset is upper-only so
// forcing upper preserves the lookup hit rate even if the player
// typed a lower-case 'a'.
std::string code = payload;
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
auto first = std::find_if(code.begin(), code.end(), notSpace);
auto last = std::find_if(code.rbegin(), code.rend(), notSpace).base();
code = (first < last) ? std::string(first, last) : std::string();
for (char& c : code)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!IsValidShareCode(code))
{
*err = "share code must be 6 characters (A-Z minus I/O, 2-9)";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
// Live catalog first, then retired codes frozen in
// character_paragon_build_share_archive* (same code keeps importing
// the recipe that was current when the owner last committed).
QueryResult srcRow = CharacterDatabase.Query(
"SELECT build_id, guid, name, icon "
"FROM character_paragon_builds WHERE share_code = '{}'", code);
bool fromArchive = false;
uint32 srcBuildId = 0;
uint32 srcOwner = 0;
std::string srcName;
std::string srcIcon;
if (srcRow)
{
Field const* sf = srcRow->Fetch();
srcBuildId = sf[0].Get<uint32>();
srcOwner = sf[1].Get<uint32>();
srcName = sf[2].Get<std::string>();
srcIcon = sf[3].Get<std::string>();
}
else if (QueryResult arch = CharacterDatabase.Query(
"SELECT name, icon FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
{
fromArchive = true;
Field const* af = arch->Fetch();
srcName = af[0].Get<std::string>();
srcIcon = af[1].Get<std::string>();
}
else
{
*err = "no build with that code";
return false;
}
if (!fromArchive && srcOwner == lowGuid)
{
*err = "this build is already in your catalog";
return false;
}
if (QueryResult cnt = CharacterDatabase.Query(
"SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid))
{
if (cnt->Fetch()[0].Get<uint32>() >= 64)
{
*err = "build limit reached (64)";
return false;
}
}
// Insert the new owned build first so we have its build_id to
// attach the copied recipe rows to. Server-generated share code
// is fresh -- the imported copy can be re-shared independently.
//
// Pet handling: we deliberately do NOT copy `pet_number`. A parked
// hunter pet belongs to the source player's character and lives
// in `character_pet` under their owner guid; cloning the row
// would either steal the pet (corrupting the source player's
// stable / stable-master state) or summon a pet the importer
// can't legally own. The new row leaves `pet_number = NULL`
// (column default), so when the importer first loads this build
// and HandleBuildLoad reaches Phase 4, RestoreParkedPetForBuild
// sees NULL and no-ops -- the player must tame their own pet
// (Tame Beast comes via the recipe if the source build had it),
// and on next swap-away ParkActivePetForBuild will bind THEIR
// pet to the row exactly like a locally-created build.
std::string newCode = GenerateUniqueShareCode();
std::string insertName = srcName;
std::string insertIcon = srcIcon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, insertName, insertIcon, newCode);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", newCode);
if (!idRow)
{
*err = "import failed (could not allocate build_id)";
return false;
}
uint32 newBuildId = idRow->Fetch()[0].Get<uint32>();
// Copy recipe rows row-by-row via INSERT...SELECT so we don't
// need to materialize them in C++. Using a literal `newBuildId`
// for the copy so the foreign reference is correct on the new
// owner's row.
if (!fromArchive)
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}",
newBuildId, srcBuildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}",
newBuildId, srcBuildId);
}
else
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_share_archive_spells "
"WHERE share_code = '{}'",
newBuildId, code);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_share_archive_talents "
"WHERE share_code = '{}'",
newBuildId, code);
}
PushBuildCatalog(pl);
// Hunter-pet hint: imports never carry a parked pet (see comment
// before the INSERT above). If the recipe contains Tame Beast
// (spell 1515) we surface a one-line system message so the player
// knows they need to tame their own pet before that build feels
// "complete". Other classes' pet-summon spells (Summon Imp, Raise
// Dead, ...) re-summon a fresh entity each cast so they don't
// need any heads-up.
QueryResult petCheck = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_spells "
"WHERE build_id = {} AND spell_id = 1515 LIMIT 1", newBuildId);
if (petCheck && pl->GetSession())
{
std::string const msg = fmt::format(
"|cffffd200[Paragon]|r Imported \"{}\" includes Tame Beast. "
"Tame your own pet after loading this build -- the source "
"player's pet was not transferred.", srcName);
ChatHandler(pl->GetSession()).SendSysMessage(msg.c_str());
}
if (fromArchive)
LOG_INFO("module",
"Paragon build: {} imported archived code '{}' as new build {} (share {})",
pl->GetName(), code, newBuildId, newCode);
else
LOG_INFO("module",
"Paragon build: {} imported '{}' (src build {} owner {}) "
"as new build {} with code {}",
pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode);
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<uint32>();
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<uint32> panelTalents;
if (QueryResult pt = CharacterDatabase.Query(
"SELECT talent_id FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
{
do { panelTalents.insert(pt->Fetch()[0].Get<uint32>()); } 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<uint8>(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<uint32>();
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<int>(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<int>(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<uint32>(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);
bool const sameBuild = (activeId == targetId);
// Capture pre-swap Beast Mastery state. Phase 2's reset-then-reapply
// wipes every talent before the new recipe runs, so we can't use
// HandleParagonResetTalents' built-in BM check here -- it would fire
// mid-swap before the new build's BM (if any) is re-learned. Defer
// the pet-talent reset to the very end of the swap, when the final
// talent layout is committed (see Phase 5 below).
bool const hadBeastMasteryPreSwap = HasBeastMasteryInAnySpec(pl);
// -------------------------------------------------------------
// Phase 1: snapshot + park the current build's state.
//
// Cross-build swap: capture the outgoing build's panel state into
// its recipe rows so swapping back later restores it; park any
// active hunter pet so we can re-summon the same instance.
//
// Same-build "revert": skip the snapshot (we WANT the saved
// recipe to remain authoritative -- this command's whole purpose
// is to discard pending edits), but still park the pet so the
// reset+re-spend cycle below doesn't destroy it.
// -------------------------------------------------------------
if (activeId)
{
if (!sameBuild)
SnapshotBuildFromCurrent(pl, activeId);
ParkActivePetForBuild(pl, activeId);
}
// -------------------------------------------------------------
// Phase 2: reset all panel-bought spells/talents (refunds AE/TE
// through the existing reset path). autoResetPetIfBmLost=false
// because we're going to re-learn BM in Phase 3 if the new recipe
// includes it -- the BM-loss check is deferred to Phase 5.
// -------------------------------------------------------------
std::string sub;
if (!HandleParagonResetTalents(pl, &sub, /*autoResetPetIfBmLost=*/false))
{
*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<uint32> 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<uint32>()); }
while (sp->NextRow());
}
// Recipe talents: collected up front so we can pre-flight AE+TE.
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
struct RecipeTalent { uint8 spec; uint32 tid; uint8 rank; };
std::vector<RecipeTalent> 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<uint8>(), f[1].Get<uint32>(), f[2].Get<uint8>() };
TalentEntry const* te = sTalentStore.LookupEntry(rt.tid);
if (!te || !rt.rank)
continue;
recipeTalents.push_back(rt);
talentTeCost += static_cast<uint32>(rt.rank) * tePerRank;
if (te->addToSpellBook)
talentAeCost += static_cast<uint32>(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);
// -------------------------------------------------------------
// Phase 5: cross-swap cleanup.
//
// - Pet-talent cheese close. We deferred this from Phase 2 so the
// check sees the FINAL talent layout. ResetPetTalents requires a
// summoned hunter pet, which is why this runs AFTER Phase 4's
// RestoreParkedPetForBuild (so the parked pet is back when we
// check) -- otherwise a player swapping into a non-BM build with
// a parked-while-summoned BM pet would skip the reset and arrive
// at the new build with stale +4 pet talents allocated.
//
// - Self-cast aura sweep. The build swap is meant to be a clean
// state transition; any buff the player cast on themselves
// before the swap drops here. Closes the Power-Word:-Fortitude
// style cheese vector for buffs whose source spell is in the
// OUTGOING recipe but not the INCOMING one (per-spell unlearns
// during Phase 2 already drop their auras, but this makes the
// overall semantic predictable for the whole swap).
// -------------------------------------------------------------
MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPreSwap);
SweepSelfCastSpellAuras(pl);
LOG_INFO("module", "Paragon build: {} {} build {}",
pl->GetName(), sameBuild ? "reverted to snapshot of" : "loaded", 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);
// Verify AE/TE matches what the player's level + panel spend
// permit. Self-heals admin / crash drift in either direction
// and is a no-op (just two small SELECTs) when the balance is
// already correct. Has to run BEFORE PushCurrency so the
// client's first balance update of the session is the
// reconciled one.
ReconcileEssenceForPlayer(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<bool>("Paragon.Currency.Enabled", true))
{
std::vector<std::pair<uint32, uint32>> emptySpells;
std::vector<std::pair<uint32, uint32>> emptyTalents;
SendSilenceOpenForCommit(player, emptySpells, emptyTalents);
uint32 const lowGuid = player->GetGUID().GetCounter();
// Step 1: explicit per-row revoke pass. Cheap — just walks the
// persisted revoke table — and the only step that catches
// (active dep, parent) rows the diff recorded at commit time.
RevokeBlockedSpellsForPlayer(player);
// Step 2: legacy passive-attach migration. MUST run before the
// scoped sweep so any cascade re-fire from `learnSpell` here
// (Blood Presence / Death Coil / Forceful Deflection / ...)
// is caught by Step 3 instead of leaking into the spellbook.
//
// 2a) A brief intermediate build of mod-paragon attached the
// on-target *debuff* IDs (55078 / 55095) as panel children
// of Plague Strike / Icy Touch. Those debuff rows render
// as castable spellbook icons because they aren't passive
// in Spell.dbc. Drop the panel_spell_children row and
// unlearn the debuff. No-op for unaffected characters.
struct LegacyBad { uint32 parent; uint32 child; };
static LegacyBad const kLegacy[] = {
{ 45462, 55078 },
{ 45477, 55095 },
};
for (auto const& lb : kLegacy)
{
if (CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {} LIMIT 1",
lowGuid, lb.parent, lb.child))
{
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}",
lowGuid, lb.parent, lb.child);
if (player->HasSpell(lb.child))
player->removeSpell(lb.child, SPEC_MASK_ALL, false);
LOG_INFO("module",
"Paragon panel: stripped legacy debuff-as-passive {} (parent {}) for {}",
lb.child, lb.parent, player->GetName());
}
}
// 2b) Re-attach the correct passive spellbook entries for any
// panel-purchased parent that is missing them. After the
// class-skill cascade guard in
// Player::learnSkillRewardedSpells, the cascade no longer
// fires for Paragons, so these attachments are the ONLY
// source for the disease passive icons (Blood Plague /
// Frost Fever) and the small DK weapon passives (Forceful
// Deflection from Blood Strike, Runic Focus from Icy
// Touch). Existing characters predating the guard may
// have FD/RF in their spellbook from the cascade but no
// panel_spell_children row tying them to the parent;
// re-running learnSpell when they already have the spell
// just records the child row and is a no-op otherwise.
struct LegacyFix { uint32 parent; uint32 correctChild; };
static LegacyFix const kFixup[] = {
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive)
{ 45477, 61455 }, // Icy Touch -> Runic Focus
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection
// Runeforging -> 8 basic rune-enchants. Mirror of
// PanelLearnSpellChain::kAttached: the SLA rows for
// these (skill 776) ship with AcquireMethod=0 so the
// engine's normal cascade never grants them, and for
// the substitute Lua runeforging UI to actually be
// able to cast them HasActiveSpell needs to return
// true. Existing Paragon characters that bought
// Runeforging before this fix landed get them
// retro-granted on their next login.
{ 53428, 53344 }, // Runeforging -> Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Razorice
{ 53428, 53341 }, // Runeforging -> Cinderglacier
{ 53428, 53331 }, // Runeforging -> Lichbane
{ 53428, 53342 }, // Runeforging -> Spellshattering
{ 53428, 53323 }, // Runeforging -> Swordshattering
{ 53428, 54447 }, // Runeforging -> Spellbreaking
{ 53428, 54446 }, // Runeforging -> Swordbreaking
};
for (auto const& lf : kFixup)
{
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells "
"WHERE guid = {} AND spell_id = {} LIMIT 1",
lowGuid, lf.parent))
continue;
if (player->HasSpell(lf.correctChild))
{
DbInsertPanelSpellChild(lowGuid, lf.parent, lf.correctChild);
continue;
}
player->learnSpell(lf.correctChild, false);
DbInsertPanelSpellChild(lowGuid, lf.parent, lf.correctChild);
std::unordered_set<uint32> chain;
CollectSpellChainIds(lf.correctChild, chain);
if (chain.empty())
chain.insert(lf.correctChild);
DbDeletePanelSpellRevokedForChain(lowGuid, chain);
}
// Step 3: scoped sweep across SkillLines we activated via panel
// purchases. Final pass — catches every cascade leak: those
// the commit-time diff missed, those re-fired by `_LoadSkills`,
// AND those re-fired by Step 2's legacy `learnSpell` calls.
// Only walks SkillLines we activated, so racials / weapon
// skills / Defense are never touched.
RevokeUnwantedCascadeSpellsForPlayer(player);
// Step 4: 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);
// Single source of truth: ComputeStartingAE/TE(newLevel) - spent.
// Subsumes the old GrantLevelUpEssence per-level delta AND catches
// drift in both directions (cheese clamp + restore-from-loss).
// SaveCurrencyToDb runs inside Reconcile when drift is detected;
// call it once more here so a no-drift level-up still flushes any
// pending cache changes from this session.
ReconcileEssenceForPlayer(player);
SaveCurrencyToDb(player);
PushCurrency(player);
// Fractured / Paragon: rank-up cascade for level-up. Without this,
// higher ranks of panel-purchased spells AND talent-LEARN_SPELL
// granted abilities (Mangle, Feral Charge, Mutilate, ...) never
// appear on ding because Player::learnSkillRewardedSpells is
// disabled for the class skill line on Paragon (intentional, to
// keep the panel as the sole authority over class abilities).
//
// Cheap re-walks: PanelLearnSpellChain / TeachLevelGated... both
// skip ranks the player already has, so the only real work each
// level-up is adding the one new rank the player just qualified
// for (if any).
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do
{
uint32 const head = r->Fetch()[0].Get<uint32>();
PanelLearnSpellChain(player, head);
} while (r->NextRow());
}
if (QueryResult tr = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
{
do
{
Field* f = tr->Fetch();
uint32 const talentId = f[0].Get<uint32>();
uint32 const rank = f[1].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(talentId);
if (!te)
continue;
uint32 const cap = std::min<uint32>(rank, MAX_TALENT_RANK);
for (uint32 i = 0; i < cap; ++i)
if (uint32 rankSpell = te->RankID[i])
CascadeRanksForTalentLearnSpellEffects(player, rankSpell);
} while (tr->NextRow());
}
}
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"
// "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);
// If the player has a build loaded, the commit just
// mutated their panel state -- archive the previous
// share_code + recipe (so old Discord codes still
// import that frozen loadout), snapshot the new panel
// into the live build, assign a fresh share_code for
// the new recipe, and re-push the catalog.
uint32 const lowGuid = player->GetGUID().GetCounter();
uint32 const activeId = GetActiveBuildId(lowGuid);
if (activeId)
{
PersistActiveBuildSnapshotAfterLearnAllCommit(player, activeId);
PushBuildCatalog(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, 21, "C BUILD SAVE_CURRENT ") == 0)
{
std::string err;
if (!HandleBuildSaveCurrent(player, body.substr(21), &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, 15, "C BUILD IMPORT ") == 0)
{
std::string err;
if (!HandleBuildImport(player, body.substr(15), &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;
}
// "C BUILD UNLOAD" -- clears the active-build pointer without
// touching learned spells/talents or parking pets. Recovery
// path for a stale active pointer (e.g. a load that was
// interrupted between Phase 3 and Phase 4 in HandleBuildLoad,
// leaving the row pointing at a build whose recipe was already
// re-applied). Player retains current learns; the catalog push
// refreshes the UI so the "Active" glow + tooltip clear.
if (body == "C BUILD UNLOAD")
{
if (player->getClass() != CLASS_PARAGON)
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
SetActiveBuildId(lowGuid, 0);
PushBuildCatalog(player);
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<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);
}
};
// --- Paragon tester inventory helpers (ICC 25H-style curated lists; edit ids here for your fork) ---
// Paragon can equip any armor weight; BiS picks below intentionally mix plate/mail/leather/cloth per slot.
uint32 ParagonTesterSelectLargestUsableBagItemId(Player const* player)
{
static constexpr uint32 candidates[] = { 51809, 41600, 41599, 38082 };
for (uint32 id : candidates)
{
ItemTemplate const* proto = sObjectMgr->GetItemTemplate(id);
if (!proto || proto->Class != ITEM_CLASS_CONTAINER || proto->InventoryType != INVTYPE_BAG)
continue;
if (player->CanUseItem(proto) == EQUIP_ERR_OK)
return id;
}
return 0;
}
uint32 ParagonTesterGrantItemList(Player* target, uint32 const* ids, size_t count, ChatHandler* handler)
{
uint32 granted = 0;
for (size_t i = 0; i < count; ++i)
{
uint32 const itemId = ids[i];
if (!itemId)
continue;
ItemTemplate const* proto = sObjectMgr->GetItemTemplate(itemId);
if (!proto)
{
handler->PSendSysMessage("Paragon tester kit: item {} is not defined (skipped).", itemId);
continue;
}
ItemPosCountVec dest;
InventoryResult const msg = target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, 1);
if (msg != EQUIP_ERR_OK || dest.empty())
{
handler->PSendSysMessage("Paragon tester kit: cannot store {} ({}) — free bag space?", itemId, uint32(msg));
continue;
}
if (Item* item = target->StoreNewItem(dest, itemId, true))
{
item->SetBinding(false);
++granted;
}
}
return granted;
}
struct ParagonTesterStackedGrant
{
uint32 itemId;
uint32 count;
};
// Grants stackable/consumable items in one StoreNewItem per line (gems, scrolls, scopes, etc.).
uint32 ParagonTesterGrantStackedItemList(Player* target, ParagonTesterStackedGrant const* grants, size_t grantCount, ChatHandler* handler)
{
uint32 totalPieces = 0;
for (size_t i = 0; i < grantCount; ++i)
{
uint32 const itemId = grants[i].itemId;
uint32 count = grants[i].count;
if (!itemId || !count)
continue;
ItemTemplate const* proto = sObjectMgr->GetItemTemplate(itemId);
if (!proto)
{
handler->PSendSysMessage("Paragon tester kit: item {} is not defined (skipped).", itemId);
continue;
}
uint32 noSpaceForCount = 0;
ItemPosCountVec dest;
InventoryResult const msg = target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, count, &noSpaceForCount);
if (msg != EQUIP_ERR_OK)
count -= noSpaceForCount;
if (!count || dest.empty())
{
handler->PSendSysMessage("Paragon tester kit: cannot store {} x{} ({}) — free bag space?", itemId, grants[i].count, uint32(msg));
continue;
}
if (Item* item = target->StoreNewItem(dest, itemId, true))
{
item->SetBinding(false);
totalPieces += count;
}
}
return totalPieces;
}
void ParagonTesterClearNonEquipmentInventory(Player* player)
{
for (uint8 bagSlot = INVENTORY_SLOT_BAG_START; bagSlot < INVENTORY_SLOT_BAG_END; ++bagSlot)
{
if (Bag* bag = player->GetBagByPos(bagSlot))
{
for (uint32 j = 0; j < bag->GetBagSize(); ++j)
{
if (bag->GetItemByPos(j))
player->DestroyItem(bagSlot, j, true);
}
}
if (player->GetItemByPos(INVENTORY_SLOT_BAG_0, bagSlot))
player->DestroyItem(INVENTORY_SLOT_BAG_0, bagSlot, true);
}
for (uint8 slot = INVENTORY_SLOT_ITEM_START; slot < INVENTORY_SLOT_ITEM_END; ++slot)
{
if (player->GetItemByPos(INVENTORY_SLOT_BAG_0, slot))
player->DestroyItem(INVENTORY_SLOT_BAG_0, slot, true);
}
}
void ParagonTesterStringToLowerAscii(std::string& s)
{
for (char& c : s)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
std::string ParagonTesterNormalizeWeaponTypeKey(std::string_view raw)
{
std::string t;
for (unsigned char ch : raw)
{
if (ch == ' ' || ch == '\t' || ch == '-' || ch == '_' || ch == '/')
continue;
t.push_back(static_cast<char>(std::tolower(ch)));
}
return t;
}
// If any bag slot 1922 is free and the item is a container, move it from inventory onto that slot (Player.cpp pattern).
bool ParagonTesterTryEquipBagToFirstEmptySlot(Player* player, Item* bag)
{
if (!player || !bag)
return false;
ItemTemplate const* proto = bag->GetTemplate();
if (!proto || proto->Class != ITEM_CLASS_CONTAINER || proto->InventoryType != INVTYPE_BAG)
return false;
uint16 eDest = 0;
if (player->CanEquipItem(NULL_SLOT, eDest, bag, false) != EQUIP_ERR_OK)
return false;
uint8 const srcBag = bag->GetBagSlot();
uint8 const srcSlot = bag->GetSlot();
player->RemoveItem(srcBag, srcSlot, true);
player->EquipItem(eDest, bag, true);
return true;
}
// Curated ICC-era ids (db_world item_template). Extend as needed for your fork.
bool ParagonTesterResolveWeaponKit(std::string statRaw, std::string typeRaw, std::vector<uint32>& out, std::string& err)
{
out.clear();
ParagonTesterStringToLowerAscii(statRaw);
// trim stat
while (!statRaw.empty() && statRaw.front() == ' ')
statRaw.erase(statRaw.begin());
while (!statRaw.empty() && statRaw.back() == ' ')
statRaw.pop_back();
std::string stat = statRaw;
if (stat == "strength")
stat = "str";
else if (stat == "agility" || stat == "dex" || stat == "dexterity")
stat = "agi";
else if (stat == "intellect")
stat = "int";
else if (stat == "spirit")
stat = "spi";
else if (stat == "apsp" || stat == "spellstrike")
stat = "hybrid";
std::string const wkey = ParagonTesterNormalizeWeaponTypeKey(typeRaw);
if (stat.empty() || wkey.empty())
{
err = "usage: .paragon tester weapons <stat> <type> — see `.paragon tester weapons` with no args for help.";
return false;
}
auto push = [&](std::initializer_list<uint32> ids)
{
for (uint32 id : ids)
if (id)
out.push_back(id);
};
if (stat == "str")
{
// "2h sword", "2h/sword", "2h axe" → 2hsword / 2haxe after normalize (slashes stripped like spaces).
if (wkey == "2hsword" || wkey == "twohandsword")
push({ 50730 }); // Glorenzelg (2H sword)
else if (wkey == "2haxe" || wkey == "twohandaxe")
push({ 50709 }); // Bryntroll (2H axe)
else if (wkey == "2hmace" || wkey == "twohandmace")
push({ 50603 }); // Cryptmaker (2H mace)
else if (wkey == "1hsword" || wkey == "onehandsword")
push({ 50737 }); // Havoc's Call (1H sword)
else if (wkey == "1haxe" || wkey == "onehandaxe")
push({ 50654 }); // Scourgeborne Waraxe (1H axe)
else if (wkey == "1hmace" || wkey == "onehandmace" || wkey == "1hhammer")
push({ 50738 }); // Mithrios (1H mace)
else if (wkey == "2h" || wkey == "twohand" || wkey == "zwei" || wkey == "great" || wkey == "polearm")
push({ 50730 });
else if (wkey == "dual" || wkey == "dw" || wkey == "dualwield" || wkey == "dualwielding")
push({ 50738, 50737 });
else if (wkey == "sword" || wkey == "swords" || wkey == "1h")
push({ 50737 });
else if (wkey == "mace" || wkey == "hammer")
push({ 50738 });
else if (wkey == "axe")
push({ 50654 });
else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown")
push({ 50733 });
else
{
err = fmt::format(
"unknown STR weapon type \"{}\" (try 2h sword, 2h axe, 2h mace, 1h sword, 1h axe, 1h mace, 2h, dual, sword, mace, axe, ranged).",
typeRaw);
return false;
}
return true;
}
if (stat == "agi")
{
if (wkey == "2h" || wkey == "twohand" || wkey == "polearm")
push({ 50735 });
else if (wkey == "dual" || wkey == "dw" || wkey == "dualwield" || wkey == "daggers")
push({ 50736, 50676 });
else if (wkey == "dagger")
push({ 50736 });
else if (wkey == "sword" || wkey == "swords" || wkey == "1h")
push({ 50672 });
else if (wkey == "fist" || wkey == "fistweapon" || wkey == "claw")
push({ 50676 });
else if (wkey == "staff" || wkey == "staves")
push({ 50731 }); // caster staff; use as generic high-ilvl staff for testers
else if (wkey == "bow")
push({ 51940 }); // Windrunner's Heartseeker (hunter-style bow)
else if (wkey == "ranged" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown")
push({ 50733 }); // Fal'inrush (BiS gun)
else
{
err = fmt::format("unknown AGI weapon type \"{}\" (try 2h, dual, dagger, sword, fist, staff, bow, ranged).", typeRaw);
return false;
}
return true;
}
if (stat == "int")
{
if (wkey == "staff" || wkey == "staves")
push({ 50731 });
else if (wkey == "wand")
push({ 50684 });
else if (wkey == "mhoh" || wkey == "ohmh" || wkey == "dual" || wkey == "dw" || wkey == "moh")
push({ 50732, 50734 });
else if (wkey == "mh" || wkey == "mainhand" || wkey == "sword" || wkey == "mace" || wkey == "dagger")
push({ 50732 });
else if (wkey == "oh" || wkey == "offhand")
push({ 50734 });
else if (wkey == "shield")
push({ 50729 });
else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow")
push({ 50733 });
else
{
err = fmt::format("unknown INT weapon type \"{}\" (try staff, wand, mhoh, mh, oh, shield, ranged).", typeRaw);
return false;
}
return true;
}
if (stat == "spi")
{
if (wkey == "staff" || wkey == "staves")
push({ 50725 });
else if (wkey == "wand")
push({ 50684 });
else if (wkey == "mace" || wkey == "mh")
push({ 50732 });
else if (wkey == "mhoh" || wkey == "ohmh")
push({ 50732, 50734 });
else if (wkey == "shield")
push({ 50729 });
else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown")
push({ 50733 });
else
{
err = fmt::format("unknown SPI weapon type \"{}\" (try staff, wand, mace, mhoh, shield, ranged).", typeRaw);
return false;
}
return true;
}
if (stat == "tank")
{
if (wkey == "shield")
push({ 50729 });
else if (wkey == "sword" || wkey == "swords" || wkey == "1h")
push({ 50738 });
else if (wkey == "mace" || wkey == "hammer")
push({ 50738 });
else if (wkey == "swordboard" || wkey == "sb" || wkey == "mit" || wkey == "1hshield" || wkey == "threat")
push({ 50738, 50729 });
else if (wkey == "dual" || wkey == "dw")
push({ 50738, 50737 });
else if (wkey == "2h" || wkey == "twohand")
push({ 50730 });
else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown")
push({ 50733 });
else if (wkey == "sigil" || wkey == "relic")
push({ 50462 });
else
{
err = fmt::format("unknown tank weapon type \"{}\" (try swordboard, shield, sword, mace, dual, 2h, ranged, sigil).", typeRaw);
return false;
}
return true;
}
if (stat == "hybrid")
{
if (wkey == "staff")
push({ 50731 });
else if (wkey == "wand")
push({ 50684 });
else if (wkey == "shield")
push({ 50729 });
else if (wkey == "2h" || wkey == "twohand")
push({ 50735 });
else if (wkey == "dual" || wkey == "dw" || wkey == "mhoh" || wkey == "default")
push({ 50732, 50734 });
else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown")
push({ 50733 });
else
{
err = fmt::format("unknown hybrid weapon type \"{}\" (try mhoh, staff, wand, shield, 2h, ranged).", typeRaw);
return false;
}
return true;
}
err = fmt::format("unknown stat \"{}\" (use str, agi, int, spi, tank, hybrid).", statRaw);
return false;
}
// Order: head, shoulders, chest, hands, legs, bracers, belt, boots, neck, cloak, weapons…, trinkets, rings.
// Item ids verified against stock AC db_world item_template (3.3.5a).
static constexpr uint32 kTesterBisStr[] = {
50712, 51229, 50656, 50675, 50624, // helm, shoulders (51229), chest, hands, legs — not 51211 (that id is Ymirjar legs)
54580, 50620, 54578, 54581, 50653, // Umbrage + Coldwraith + Apocalypse's Advance + Penumbra + Shadowvault
50730, 50733,
50363, 54590,
50657, 54576,
};
static constexpr uint32 kTesterBisAgi[] = {
51242, 51299, 51298, 51243, 51241, // Frost Witch (mail) + Lasherweave (leather) mix
50670, 50688, 50607, 50633, 50653,
50736, 50733,
50363, 54590,
50657, 54576,
};
static constexpr uint32 kTesterBisInt[] = {
51281, 51245, 51283, 51280, 51246, // Bloodmage cloth + Frost Witch (mail) shoulders/legs
50686, 50702, 50699, 50724, 50628,
50732, 50734, 50684,
50346, 50360,
50610, 50664,
};
static constexpr uint32 kTesterBisSpi[] = {
51237, 51257, 51239, 51256, 51258, // Resto Frost Witch (mail) + Crimson Acolyte (cloth)
50686, 50702, 50699, 50724, 50628,
50725,
50360, 50366,
50610, 50664,
};
static constexpr uint32 kTesterBisTank[] = {
51306, 51309, 51305, 51307, 51308, // Sanctified Scourgelord (plate, DK tank profile)
50611, 50620, 50625, 50609, 50677,
50738, 50729, 50462,
50364, 54591,
50404, 50657,
};
// AP main-hand + SP off-hand + mail enhancer T10 (ICC); for hybrid battlemage-style testers.
static constexpr uint32 kTesterBisHybrid[] = {
51242, 51240, 51244, 51243, 51241,
54580, 50620, 54578, 54581, 50653,
50732, 50734,
50363, 50346,
50657, 50610,
};
// Sanctified Ahn'Kahar Blood Hunter (277) + ICC phys offsets; ranged slot only (Windrunner's Heartseeker).
static constexpr uint32 kTesterBisHunter[] = {
51286, 51288, 51289, 51285, 51287,
50670, 50688, 50607, 50633, 50653,
0, 51940,
50363, 54590,
50657, 54576,
};
// ICC-era gems (stacked), enchant scrolls, belt buckle, leg armor/spellthread, Sons of Hodir shoulders, Ebon Blade / Kirin Tor helms.
// Item ids from db_world item_template; tweak counts for your fork.
static constexpr uint32 kGemStack = 20;
static constexpr uint32 kGemStackMed = 12;
static constexpr uint32 kGemStackSmall = 8;
static constexpr uint32 kScrollPair = 2;
static constexpr uint32 kMetaCount = 3;
static constexpr uint32 kBeltBuckle = 4;
static constexpr uint32 kLegKit = 4;
static constexpr uint32 kAugmentPair = 2;
static constexpr uint32 kScopeKit = 4;
static constexpr ParagonTesterStackedGrant kTesterGemsStr[] = {
{ 40111, kGemStack }, { 40117, kGemStack }, { 40114, kGemStack }, { 40116, kGemStackMed }, { 40118, kGemStackMed },
{ 40119, kGemStackSmall }, { 40142, kGemStackMed }, { 40143, kGemStackMed }, { 40153, kGemStackMed }, { 40162, kGemStackMed },
{ 41285, kMetaCount }, { 41398, 2 },
{ 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair },
{ 44458, kScrollPair }, { 41611, kBeltBuckle }, { 38374, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair },
};
static constexpr ParagonTesterStackedGrant kTesterGemsAgi[] = {
{ 40112, kGemStack }, { 40117, kGemStack }, { 40114, kGemStackMed }, { 40142, kGemStackMed }, { 40152, kGemStackMed },
{ 40153, kGemStackMed }, { 40155, kGemStackMed }, { 40125, kGemStackMed }, { 41398, kMetaCount }, { 41285, 2 },
{ 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair },
{ 44458, kScrollPair }, { 38986, kScrollPair }, { 41611, kBeltBuckle }, { 38373, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair },
};
static constexpr ParagonTesterStackedGrant kTesterGemsInt[] = {
{ 40113, kGemStack }, { 40155, kGemStackMed }, { 40153, kGemStackMed }, { 40133, kGemStackMed }, { 40119, kGemStackSmall },
{ 40125, kGemStackMed }, { 41285, kMetaCount },
{ 44467, kScrollPair }, { 44470, kScrollPair }, { 38979, kScrollPair }, { 39003, kScrollPair }, { 39006, kScrollPair },
{ 44465, kScrollPair }, { 38973, kScrollPair }, { 41611, kBeltBuckle }, { 41602, kLegKit }, { 50338, kAugmentPair }, { 50368, kAugmentPair },
};
static constexpr ParagonTesterStackedGrant kTesterGemsSpi[] = {
{ 40113, kGemStackMed }, { 40133, kGemStack }, { 40120, kGemStackMed }, { 40119, kGemStackSmall }, { 40155, kGemStackMed },
{ 41285, kMetaCount },
{ 44470, kScrollPair }, { 38853, kScrollPair }, { 38961, kScrollPair }, { 38979, kScrollPair }, { 39006, kScrollPair },
{ 44465, kScrollPair }, { 41611, kBeltBuckle }, { 41601, kLegKit }, { 50336, kAugmentPair }, { 50370, kAugmentPair },
};
static constexpr ParagonTesterStackedGrant kTesterGemsTank[] = {
{ 40119, kGemStack }, { 40138, kGemStackMed }, { 40115, kGemStackMed }, { 40118, kGemStackMed }, { 40143, kGemStackMed },
{ 41285, 2 },
{ 38945, kScrollPair }, { 44489, kScrollPair }, { 38849, kScrollPair }, { 39006, kScrollPair }, { 44465, kScrollPair },
{ 41611, kBeltBuckle }, { 38373, kLegKit }, { 50337, kAugmentPair }, { 50369, kAugmentPair },
};
static constexpr ParagonTesterStackedGrant kTesterGemsHybrid[] = {
{ 40113, kGemStackMed }, { 40111, kGemStackMed }, { 40114, kGemStackMed }, { 40153, kGemStackMed }, { 40142, kGemStackMed },
{ 40155, kGemStackMed }, { 41285, kMetaCount }, { 41398, 2 },
{ 44467, kScrollPair }, { 44493, kScrollPair }, { 44815, kScrollPair }, { 44470, kScrollPair }, { 44465, kScrollPair },
{ 39006, kScrollPair }, { 39003, kScrollPair }, { 41611, kBeltBuckle }, { 41602, 2 }, { 38373, 2 }, { 38374, 2 },
{ 50338, kAugmentPair }, { 50335, kAugmentPair }, { 50368, 1 }, { 50367, 1 },
};
// Hunter / physical ranged: scopes (engineering attach) + hit/agi gems + physical scrolls.
static constexpr ParagonTesterStackedGrant kTesterGemsRanged[] = {
{ 44739, kScopeKit }, { 41167, kScopeKit }, { 41146, 2 },
{ 40112, kGemStack }, { 40117, kGemStack }, { 40125, kGemStack }, { 40142, kGemStackMed }, { 40152, kGemStackMed },
{ 40153, kGemStackMed }, { 41398, kMetaCount }, { 41285, 2 },
{ 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair },
{ 44458, kScrollPair }, { 38986, kScrollPair }, { 41611, kBeltBuckle }, { 38373, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair },
};
class Paragon_Essence_CommandScript : public CommandScript
{
public:
Paragon_Essence_CommandScript() : CommandScript("Paragon_Essence_CommandScript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable testerBisGemsTable =
{
{ "str", HandleTesterBisGemsStr, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "agi", HandleTesterBisGemsAgi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "int", HandleTesterBisGemsInt, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "spi", HandleTesterBisGemsSpi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "tank", HandleTesterBisGemsTank, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "hybrid", HandleTesterBisGemsHybrid, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "ranged", HandleTesterBisGemsRanged, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "", HandleTesterBisGemsHelp, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
};
static ChatCommandTable testerBisTable =
{
{ "str", HandleTesterBisStr, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "agi", HandleTesterBisAgi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "int", HandleTesterBisInt, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "spi", HandleTesterBisSpi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "tank", HandleTesterBisTank, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "hybrid", HandleTesterBisHybrid, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "hunter", HandleTesterBisHunter, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "gems", testerBisGemsTable },
};
static ChatCommandTable testerTable =
{
{ "bis", testerBisTable },
{ "bags", HandleTesterBags, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "weapons", HandleTesterWeapons, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
{ "clearinv", HandleTesterClearInv, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No },
};
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 },
{ "recalibrate", HandlePanelRecalibrate, rbac::RBAC_PERM_COMMAND_MODIFY, Console::No },
{ "tester", testerTable },
};
static ChatCommandTable commandTable =
{
{ "paragon", paragonSubTable },
};
return commandTable;
}
static bool HandleTesterBisKit(ChatHandler* handler, uint32 const* ids, size_t count, char const* label)
{
Player* target = handler->getSelectedPlayerOrSelf();
if (!target)
{
handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
uint32 const n = ParagonTesterGrantItemList(target, ids, count, handler);
handler->PSendSysMessage("Paragon tester {} BiS: granted {} items to {}.", label, n, target->GetName());
return n > 0;
}
static bool HandleTesterBisStr(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisStr, sizeof(kTesterBisStr) / sizeof(kTesterBisStr[0]), "STR");
}
static bool HandleTesterBisAgi(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisAgi, sizeof(kTesterBisAgi) / sizeof(kTesterBisAgi[0]), "AGI");
}
static bool HandleTesterBisInt(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisInt, sizeof(kTesterBisInt) / sizeof(kTesterBisInt[0]), "INT");
}
static bool HandleTesterBisSpi(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisSpi, sizeof(kTesterBisSpi) / sizeof(kTesterBisSpi[0]), "SPI");
}
static bool HandleTesterBisTank(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisTank, sizeof(kTesterBisTank) / sizeof(kTesterBisTank[0]), "tank");
}
static bool HandleTesterBisHybrid(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisHybrid, sizeof(kTesterBisHybrid) / sizeof(kTesterBisHybrid[0]), "hybrid");
}
static bool HandleTesterBisHunter(ChatHandler* handler)
{
return HandleTesterBisKit(handler, kTesterBisHunter, sizeof(kTesterBisHunter) / sizeof(kTesterBisHunter[0]), "hunter (ranged)");
}
static bool HandleTesterBisGemsHelp(ChatHandler* handler)
{
handler->SendSysMessage(
"Paragon tester gems: .paragon tester bis gems <category>\n"
" category: str | agi | int | spi | tank | hybrid | ranged\n"
" Grants stacked ICC-era gems, enchant scrolls, Eternal Belt Buckle, leg armor/spellthread, "
"Sons of Hodir shoulder inscriptions, and helm arcanums. "
"ranged adds engineering scopes (Diamond-cut Refractor, Heartseeker, Sun) plus AGI/hit gems.");
return false;
}
static bool HandleTesterBisGemsKit(ChatHandler* handler, ParagonTesterStackedGrant const* grants, size_t count, char const* label)
{
Player* target = handler->getSelectedPlayerOrSelf();
if (!target)
{
handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
uint32 const n = ParagonTesterGrantStackedItemList(target, grants, count, handler);
handler->PSendSysMessage("Paragon tester {} gems/enchants: granted {} item pieces (stacked lines) to {}.",
label,
n,
target->GetName());
return n > 0;
}
static bool HandleTesterBisGemsStr(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsStr, sizeof(kTesterGemsStr) / sizeof(kTesterGemsStr[0]), "STR");
}
static bool HandleTesterBisGemsAgi(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsAgi, sizeof(kTesterGemsAgi) / sizeof(kTesterGemsAgi[0]), "AGI");
}
static bool HandleTesterBisGemsInt(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsInt, sizeof(kTesterGemsInt) / sizeof(kTesterGemsInt[0]), "INT");
}
static bool HandleTesterBisGemsSpi(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsSpi, sizeof(kTesterGemsSpi) / sizeof(kTesterGemsSpi[0]), "SPI");
}
static bool HandleTesterBisGemsTank(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsTank, sizeof(kTesterGemsTank) / sizeof(kTesterGemsTank[0]), "tank");
}
static bool HandleTesterBisGemsHybrid(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsHybrid, sizeof(kTesterGemsHybrid) / sizeof(kTesterGemsHybrid[0]), "hybrid");
}
static bool HandleTesterBisGemsRanged(ChatHandler* handler)
{
return HandleTesterBisGemsKit(handler, kTesterGemsRanged, sizeof(kTesterGemsRanged) / sizeof(kTesterGemsRanged[0]), "RANGED");
}
static bool HandleTesterBags(ChatHandler* handler)
{
Player* target = handler->getSelectedPlayerOrSelf();
if (!target)
{
handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
uint32 const bagId = ParagonTesterSelectLargestUsableBagItemId(target);
if (!bagId)
{
handler->SendSysMessage("Paragon tester bags: no bag template this character can use (check item ids).");
return false;
}
uint32 granted = 0;
uint32 equipped = 0;
for (int i = 0; i < 4; ++i)
{
ItemPosCountVec dest;
if (target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, bagId, 1) != EQUIP_ERR_OK || dest.empty())
{
handler->PSendSysMessage("Paragon tester bags: could only add {} bag(s); inventory full?", granted);
break;
}
if (Item* item = target->StoreNewItem(dest, bagId, true))
{
item->SetBinding(false);
++granted;
if (ParagonTesterTryEquipBagToFirstEmptySlot(target, item))
++equipped;
}
}
ItemTemplate const* proto = sObjectMgr->GetItemTemplate(bagId);
handler->PSendSysMessage("Paragon tester bags: added {} x {} ({}); auto-equipped {} to bag bar for {}.",
granted,
bagId,
proto ? proto->Name1.c_str() : "?",
equipped,
target->GetName());
return granted > 0;
}
static bool HandleTesterWeapons(ChatHandler* handler, Tail tail)
{
std::string_view sv = tail;
while (!sv.empty() && sv.front() == ' ')
sv.remove_prefix(1);
if (sv.empty())
{
handler->SendSysMessage(
"Paragon tester weapons: .paragon tester weapons <stat> <type>\n"
" stat: str | agi | int | spi | tank | hybrid (aliases: strength, agility, intellect, spirit, apsp)\n"
" type: depends on stat — e.g. str: 2h sword, 2h axe, 2h mace, 1h sword, 1h axe, 1h mace (or 2h/sword), dual, ranged | "
"agi: … bow (hunter bow) or ranged/gun/crossbow (Fal'inrush) | int: staff, wand, mhoh, shield, ranged | "
"spi/hybrid: … ranged");
return false;
}
std::size_t const sp = sv.find(' ');
if (sp == std::string_view::npos)
{
handler->SendSysMessage("Paragon tester weapons: need both <stat> and <type> (see help with no args).");
return false;
}
std::string stat(sv.substr(0, sp));
sv.remove_prefix(sp + 1);
while (!sv.empty() && sv.front() == ' ')
sv.remove_prefix(1);
if (sv.empty())
{
handler->SendSysMessage("Paragon tester weapons: missing <type> after stat.");
return false;
}
std::string weaponType(sv.begin(), sv.end());
Player* target = handler->getSelectedPlayerOrSelf();
if (!target)
{
handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
std::vector<uint32> ids;
std::string err;
if (!ParagonTesterResolveWeaponKit(stat, weaponType, ids, err))
{
handler->PSendSysMessage("Paragon tester weapons: {}", err);
return false;
}
uint32 const n = ParagonTesterGrantItemList(target, ids.data(), ids.size(), handler);
handler->PSendSysMessage("Paragon tester weapons [{} / {}]: granted {} item(s) to {}.",
stat,
weaponType,
n,
target->GetName());
return n > 0;
}
static bool HandleTesterClearInv(ChatHandler* handler)
{
Player* target = handler->getSelectedPlayerOrSelf();
if (!target)
{
handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
ParagonTesterClearNonEquipmentInventory(target);
handler->PSendSysMessage("Paragon tester clearinv: removed backpack + bag contents (equipment untouched) for {}.",
target->GetName());
return true;
}
// Full Character Advancement reset for the selected player (or self):
// unlearn all panel spells/talents, clear panel DB + active build pointer,
// then clamp AE/TE to the level-correct totals (same math as login
// reconciliation). Does not delete saved build catalog rows — only
// clears the active build link like RESET ALL from the addon.
static bool HandlePanelRecalibrate(ChatHandler* handler)
{
Player* target = handler->getSelectedPlayerOrSelf();
if (!target || target->getClass() != CLASS_PARAGON)
{
handler->SendErrorMessage("Target must be a Paragon character.", false);
return false;
}
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
{
handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false);
return false;
}
LoadCurrencyFromDb(target);
std::string err;
if (!HandleParagonResetAll(target, &err))
{
handler->PSendSysMessage("Paragon recalibrate failed: {}", err);
return false;
}
ReconcileEssenceForPlayer(target);
PushCurrency(target);
PushSnapshot(target);
PushBuildCatalog(target);
handler->PSendSysMessage(
"Paragon recalibrate complete for {} (level {}): panel cleared, AE={}, TE={} (level-correct).",
target->GetName(),
uint32(target->GetLevel()),
GetAE(target),
GetTE(target));
return true;
}
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();
}