6a1f8eec89
- 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>
5671 lines
226 KiB
C++
5671 lines
226 KiB
C++
/*
|
||
* mod-paragon — Ability Essence (AE) / Talent Essence (TE)
|
||
*
|
||
* Inspired by Project Ascension's classless currencies (see Fandom:
|
||
* Ability Essence / Talent Essence). Server-side only: values persist in
|
||
* character_paragon_currency; optional per-spell AE costs live in world
|
||
* table paragon_spell_ae_cost. Client UI for bars / advancement panels is
|
||
* separate (see README).
|
||
*/
|
||
|
||
#include "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 19–22 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();
|
||
}
|