|
|
|
@@ -29,6 +29,8 @@
|
|
|
|
|
#include <fmt/format.h>
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <array>
|
|
|
|
|
#include <cctype>
|
|
|
|
|
#include <cstdlib>
|
|
|
|
|
#include <string_view>
|
|
|
|
|
#include <unordered_map>
|
|
|
|
|
#include <unordered_set>
|
|
|
|
@@ -1540,6 +1542,743 @@ void PushSnapshot(Player* 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 EDIT <id>\t<name>\t<icon> -- rename / re-icon
|
|
|
|
|
// C BUILD DELETE <id> -- delete + drop parked pet
|
|
|
|
|
// C BUILD FAVORITE <id> <0|1> -- toggle favorite flag
|
|
|
|
|
// C BUILD LOAD <id> -- swap to this build
|
|
|
|
|
//
|
|
|
|
|
// Server replies push `R BUILDS` after every mutation. Format:
|
|
|
|
|
//
|
|
|
|
|
// R BUILDS active=<id|->\t<id>:<fav>:<haspet>:<name>:<icon>; ...
|
|
|
|
|
//
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PushBuildCatalog(Player* pl)
|
|
|
|
|
{
|
|
|
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
|
|
|
uint32 const active = GetActiveBuildId(lowGuid);
|
|
|
|
|
|
|
|
|
|
std::string activeStr = active ? std::to_string(active) : std::string("-");
|
|
|
|
|
std::string body = fmt::format("R BUILDS active={}\t", activeStr);
|
|
|
|
|
|
|
|
|
|
if (QueryResult r = CharacterDatabase.Query(
|
|
|
|
|
"SELECT build_id, is_favorite, pet_number, name, icon "
|
|
|
|
|
"FROM character_paragon_builds WHERE guid = {} "
|
|
|
|
|
"ORDER BY is_favorite DESC, build_id ASC", lowGuid))
|
|
|
|
|
{
|
|
|
|
|
bool first = true;
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
Field const* f = r->Fetch();
|
|
|
|
|
uint32 id = f[0].Get<uint32>();
|
|
|
|
|
uint8 fav = f[1].Get<uint8>();
|
|
|
|
|
bool haspet = !f[2].IsNull() && f[2].Get<uint32>() != 0;
|
|
|
|
|
std::string name = f[3].Get<std::string>();
|
|
|
|
|
std::string icon = f[4].Get<std::string>();
|
|
|
|
|
if (!first)
|
|
|
|
|
body += ';';
|
|
|
|
|
first = false;
|
|
|
|
|
body += fmt::format("{}:{}:{}:{}:{}", id,
|
|
|
|
|
static_cast<unsigned>(fav),
|
|
|
|
|
haspet ? 1 : 0,
|
|
|
|
|
name, icon);
|
|
|
|
|
} while (r->NextRow());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SendAddonMessage(pl, body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
|
|
|
|
|
{
|
|
|
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
|
|
|
{
|
|
|
|
|
*err = "not a Paragon";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
auto tab = payload.find('\t');
|
|
|
|
|
if (tab == std::string::npos)
|
|
|
|
|
{
|
|
|
|
|
*err = "BUILD NEW malformed";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
std::string name = SanitizeBuildName(payload.substr(0, tab));
|
|
|
|
|
std::string icon = SanitizeBuildIcon(payload.substr(tab + 1));
|
|
|
|
|
if (name.empty())
|
|
|
|
|
{
|
|
|
|
|
*err = "build name is empty";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
|
|
|
|
|
|
|
|
// Soft cap: prevent a runaway script from creating thousands of
|
|
|
|
|
// empty builds. 64 is far above what fits in the UI grid; the
|
|
|
|
|
// client also enforces its own limit based on visible cells.
|
|
|
|
|
if (QueryResult cnt = CharacterDatabase.Query(
|
|
|
|
|
"SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid))
|
|
|
|
|
{
|
|
|
|
|
if (cnt->Fetch()[0].Get<uint32>() >= 64)
|
|
|
|
|
{
|
|
|
|
|
*err = "build limit reached (64)";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')",
|
|
|
|
|
lowGuid, name, icon);
|
|
|
|
|
|
|
|
|
|
PushBuildCatalog(pl);
|
|
|
|
|
LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err)
|
|
|
|
|
{
|
|
|
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
|
|
|
{
|
|
|
|
|
*err = "not a Paragon";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// payload is "<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>();
|
|
|
|
|
|
|
|
|
|
// If the build being deleted is currently active, clear the
|
|
|
|
|
// active pointer first so the player ends up in the "no active
|
|
|
|
|
// build" state. Their currently-learned spells/talents are
|
|
|
|
|
// preserved (the client warns them about this -- they keep the
|
|
|
|
|
// loadout but lose the named slot and the parked pet).
|
|
|
|
|
uint32 const active = GetActiveBuildId(lowGuid);
|
|
|
|
|
if (active == buildId)
|
|
|
|
|
SetActiveBuildId(lowGuid, 0);
|
|
|
|
|
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId);
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId);
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"DELETE FROM character_paragon_builds WHERE build_id = {} AND guid = {}",
|
|
|
|
|
buildId, lowGuid);
|
|
|
|
|
|
|
|
|
|
if (petNumber)
|
|
|
|
|
DeleteParkedPet(lowGuid, petNumber);
|
|
|
|
|
|
|
|
|
|
PushBuildCatalog(pl);
|
|
|
|
|
LOG_INFO("module", "Paragon build: {} deleted build {} (parked pet {})",
|
|
|
|
|
pl->GetName(), buildId, petNumber);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err)
|
|
|
|
|
{
|
|
|
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
|
|
|
{
|
|
|
|
|
*err = "not a Paragon";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
auto sp = payload.find(' ');
|
|
|
|
|
if (sp == std::string::npos)
|
|
|
|
|
{
|
|
|
|
|
*err = "BUILD FAVORITE malformed";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
uint32 buildId = static_cast<uint32>(std::strtoul(payload.substr(0, sp).c_str(), nullptr, 10));
|
|
|
|
|
int flag = std::atoi(payload.substr(sp + 1).c_str());
|
|
|
|
|
if (!buildId)
|
|
|
|
|
{
|
|
|
|
|
*err = "BUILD FAVORITE bad id";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
|
|
|
if (!BuildBelongsToPlayer(lowGuid, buildId))
|
|
|
|
|
{
|
|
|
|
|
*err = "build does not belong to player";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"UPDATE character_paragon_builds SET is_favorite = {} WHERE build_id = {}",
|
|
|
|
|
flag ? 1 : 0, buildId);
|
|
|
|
|
|
|
|
|
|
PushBuildCatalog(pl);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snapshot the player's current panel-purchased state into a build's
|
|
|
|
|
// recipe rows. Wipes the build's existing recipe rows first so this is
|
|
|
|
|
// idempotent. Reads `character_paragon_panel_spells` (already authoritative
|
|
|
|
|
// for purchased spells) and walks `Player::m_talents` per spec for
|
|
|
|
|
// purchased talents that ALSO appear in `character_paragon_panel_talents`
|
|
|
|
|
// (so we don't accidentally capture talents the player learned via some
|
|
|
|
|
// non-panel mechanism -- e.g. trainer dual-spec gift).
|
|
|
|
|
void SnapshotBuildFromCurrent(Player* pl, uint32 buildId)
|
|
|
|
|
{
|
|
|
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
|
|
|
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId);
|
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
|
|
|
"DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId);
|
|
|
|
|
|
|
|
|
|
if (QueryResult sp = CharacterDatabase.Query(
|
|
|
|
|
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
|
|
|
|
|
{
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
uint32 sid = sp->Fetch()[0].Get<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);
|
|
|
|
|
|
|
|
|
|
// No-op swap: target is already active. Refresh catalog so client
|
|
|
|
|
// UI re-syncs and bail.
|
|
|
|
|
if (activeId == targetId)
|
|
|
|
|
{
|
|
|
|
|
PushBuildCatalog(pl);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
// Phase 1: snapshot + park the current build's state, if any.
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
if (activeId)
|
|
|
|
|
{
|
|
|
|
|
SnapshotBuildFromCurrent(pl, activeId);
|
|
|
|
|
ParkActivePetForBuild(pl, activeId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
// Phase 2: reset all panel-bought spells/talents (refunds AE/TE
|
|
|
|
|
// through the existing reset path).
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
std::string sub;
|
|
|
|
|
if (!HandleParagonResetTalents(pl, &sub))
|
|
|
|
|
{
|
|
|
|
|
*err = sub;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!HandleParagonResetAbilities(pl, &sub))
|
|
|
|
|
{
|
|
|
|
|
*err = sub;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
// Phase 3: re-spend AE/TE on the target build's recipe.
|
|
|
|
|
// ParagonResetAbilities/Talents above already set the active
|
|
|
|
|
// build pointer to "wiped" by removing all panel rows, but it
|
|
|
|
|
// hasn't touched character_paragon_active_build. We update it
|
|
|
|
|
// last (after success) so a partial failure leaves the player
|
|
|
|
|
// in "no active build" state with refunded currency.
|
|
|
|
|
// -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Recipe spells: reuse PanelLearnSpellChain to drive the same
|
|
|
|
|
// commit path as the panel UI -- it inserts character_paragon_panel_spells
|
|
|
|
|
// / panel_spell_children / panel_spell_revoked rows internally. We
|
|
|
|
|
// explicitly TrySpendAE before each chain since PanelLearnSpellChain
|
|
|
|
|
// does NOT debit currency on its own (matches HandleCommit's pattern).
|
|
|
|
|
std::vector<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);
|
|
|
|
|
LOG_INFO("module", "Paragon build: {} loaded build {}", pl->GetName(), targetId);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class Paragon_Essence_PlayerScript : public PlayerScript
|
|
|
|
|
{
|
|
|
|
|
public:
|
|
|
|
@@ -1559,6 +2298,7 @@ public:
|
|
|
|
|
LoadCurrencyFromDb(player);
|
|
|
|
|
PushCurrency(player);
|
|
|
|
|
PushSnapshot(player);
|
|
|
|
|
PushBuildCatalog(player);
|
|
|
|
|
|
|
|
|
|
// AC's character load sequence runs _LoadSkills (which fires
|
|
|
|
|
// learnSkillRewardedSpells) and _LoadSpells before this hook,
|
|
|
|
@@ -1770,6 +2510,52 @@ public:
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
// Build catalog (saved Character Advancement loadouts).
|
|
|
|
|
// See PushBuildCatalog / HandleBuild* near the top of this file
|
|
|
|
|
// for wire format and behavior.
|
|
|
|
|
// ---------------------------------------------------------------
|
|
|
|
|
if (body == "Q BUILDS")
|
|
|
|
|
{
|
|
|
|
|
PushBuildCatalog(player);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (body.compare(0, 12, "C BUILD NEW ") == 0)
|
|
|
|
|
{
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!HandleBuildNew(player, body.substr(12), &err))
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (body.compare(0, 13, "C BUILD EDIT ") == 0)
|
|
|
|
|
{
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!HandleBuildEdit(player, body.substr(13), &err))
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (body.compare(0, 15, "C BUILD DELETE ") == 0)
|
|
|
|
|
{
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!HandleBuildDelete(player, body.substr(15), &err))
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (body.compare(0, 17, "C BUILD FAVORITE ") == 0)
|
|
|
|
|
{
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!HandleBuildFavorite(player, body.substr(17), &err))
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (body.compare(0, 13, "C BUILD LOAD ") == 0)
|
|
|
|
|
{
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!HandleBuildLoad(player, body.substr(13), &err))
|
|
|
|
|
SendAddonMessage(player, "R ERR " + err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (body == "C RESET PET TALENTS")
|
|
|
|
|
{
|
|
|
|
|
// Pet talent reset: deliberately bypasses the engine's
|
|
|
|
|