Paragon: Builds QoL -- share codes, unload, remaining AE/TE on hover
- Replace the "favorite" toggle with import-by-share-code: every build gets a 6-char realm-unique alphanumeric code on creation; pasting one into the BuildsPane share box copies the recipe (name + icon + spells + talents) into the importer's catalog as a new build, with a fresh share code so the imported copy can be re-shared independently. - Add C BUILD UNLOAD verb so the client can clear a stale active-build pointer without forcing a swap. Wired to a new "Unload (clear active)" right-click context menu entry on the active build. - Per-build tooltip now shows "Remaining if loaded: X AE / Y TE", computed server-side as total_earned - recipe_cost. Negative renders red so the player sees insufficient-currency cases before clicking Load. Suppressed for the active build (HandleBuildLoad short-circuits on target == active so the line would be misleading). - Schema migration 2026_05_10_04.sql: drop is_favorite from character_paragon_builds and add share_code CHAR(6) UNIQUE NULL with lazy backfill on every PushBuildCatalog (so pre-migration rows pick up codes the first time the player opens the panel). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
-- mod-paragon Character Advancement: Builds catalog schema cleanup.
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Two changes:
|
||||
-- 1. Drop `is_favorite` -- the favorite flag and shift-click-to-favorite
|
||||
-- flow are removed. Builds are now ordered solely by build_id ASC.
|
||||
-- 2. Add `share_code` CHAR(6) -- a random alphanumeric token generated
|
||||
-- server-side at build creation that uniquely identifies a saved
|
||||
-- build across the realm. Players exchange codes out-of-band and
|
||||
-- use the BuildsPane "Load Build!" share box to import a copy of
|
||||
-- the build (name + icon + spell + talent recipe) into their own
|
||||
-- catalog. The copy gets a fresh share_code so re-sharing is
|
||||
-- always traceable to the latest owner; the original isn't touched.
|
||||
--
|
||||
-- The column is NULL-tolerant so any rows that pre-date this migration
|
||||
-- (created under 2026_05_10_03's schema) coexist cleanly. The server
|
||||
-- backfills NULLs lazily in PushBuildCatalog -- the next time a player
|
||||
-- opens the BuildsPane on a Paragon character, any of their builds that
|
||||
-- still have a NULL share_code will get one generated and persisted.
|
||||
--
|
||||
-- Charset: 31 unambiguous chars (A-Z minus I/O minus 0/1) gives 31^6 ~=
|
||||
-- 887M codes; collision retry on insert keeps probability of a duplicate
|
||||
-- vanishing for any realistic catalog size.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE `character_paragon_builds`
|
||||
DROP COLUMN `is_favorite`,
|
||||
ADD COLUMN `share_code` CHAR(6) NULL DEFAULT NULL
|
||||
COMMENT 'random alphanumeric token for import-by-code; lazily generated'
|
||||
AFTER `icon`,
|
||||
ADD UNIQUE INDEX `uk_share_code` (`share_code`);
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "Config.h"
|
||||
#include "Pet.h"
|
||||
#include "Player.h"
|
||||
#include "Random.h"
|
||||
#include "RBAC.h"
|
||||
#include "ScriptMgr.h"
|
||||
#include "SharedDefines.h"
|
||||
@@ -1556,12 +1557,28 @@ void PushSnapshot(Player* pl)
|
||||
// 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
|
||||
// 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>:<fav>:<haspet>:<name>:<icon>; ...
|
||||
// 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
|
||||
@@ -1574,6 +1591,72 @@ 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();
|
||||
QueryResult r = CharacterDatabase.Query(
|
||||
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code);
|
||||
if (!r)
|
||||
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();
|
||||
}
|
||||
|
||||
std::string SanitizeBuildName(std::string s)
|
||||
{
|
||||
std::string out;
|
||||
@@ -1650,37 +1733,212 @@ bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId)
|
||||
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, is_favorite, pet_number, name, icon "
|
||||
"SELECT build_id, pet_number, share_code, name, icon "
|
||||
"FROM character_paragon_builds WHERE guid = {} "
|
||||
"ORDER BY is_favorite DESC, build_id ASC", lowGuid))
|
||||
"ORDER BY 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;
|
||||
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;
|
||||
body += fmt::format("{}:{}:{}:{}:{}", id,
|
||||
static_cast<unsigned>(fav),
|
||||
// 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());
|
||||
}
|
||||
@@ -1724,12 +1982,15 @@ bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
|
||||
}
|
||||
}
|
||||
|
||||
std::string code = GenerateUniqueShareCode();
|
||||
CharacterDatabase.DirectExecute(
|
||||
"INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')",
|
||||
lowGuid, name, icon);
|
||||
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
|
||||
"VALUES ({}, '{}', '{}', '{}')",
|
||||
lowGuid, name, icon, code);
|
||||
|
||||
PushBuildCatalog(pl);
|
||||
LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name);
|
||||
LOG_INFO("module", "Paragon build: {} created build '{}' (share code {})",
|
||||
pl->GetName(), name, code);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1857,39 +2118,119 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err)
|
||||
// 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
|
||||
// - the code points to one of the requester's own 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;
|
||||
}
|
||||
auto sp = payload.find(' ');
|
||||
if (sp == std::string::npos)
|
||||
|
||||
// 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 = "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";
|
||||
*err = "share code must be 6 characters (A-Z minus I/O, 2-9)";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
||||
if (!BuildBelongsToPlayer(lowGuid, buildId))
|
||||
|
||||
// Look up the source build by share_code. We need the source's
|
||||
// owner guid so we can short-circuit "import your own build"
|
||||
// before consuming a slot or copying recipe rows.
|
||||
QueryResult srcRow = CharacterDatabase.Query(
|
||||
"SELECT build_id, guid, name, icon "
|
||||
"FROM character_paragon_builds WHERE share_code = '{}'", code);
|
||||
if (!srcRow)
|
||||
{
|
||||
*err = "build does not belong to player";
|
||||
*err = "no build with that code";
|
||||
return false;
|
||||
}
|
||||
Field const* sf = srcRow->Fetch();
|
||||
uint32 srcBuildId = sf[0].Get<uint32>();
|
||||
uint32 srcOwner = sf[1].Get<uint32>();
|
||||
std::string srcName = sf[2].Get<std::string>();
|
||||
std::string srcIcon = sf[3].Get<std::string>();
|
||||
|
||||
if (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.
|
||||
std::string newCode = GenerateUniqueShareCode();
|
||||
CharacterDatabase.DirectExecute(
|
||||
"UPDATE character_paragon_builds SET is_favorite = {} WHERE build_id = {}",
|
||||
flag ? 1 : 0, buildId);
|
||||
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
|
||||
"VALUES ({}, '{}', '{}', '{}')",
|
||||
lowGuid, srcName, srcIcon, 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.
|
||||
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);
|
||||
|
||||
PushBuildCatalog(pl);
|
||||
LOG_INFO("module",
|
||||
"Paragon build: {} imported '{}' (src build {} owner {}) "
|
||||
"as new build {} with code {}",
|
||||
pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2541,10 +2882,10 @@ public:
|
||||
SendAddonMessage(player, "R ERR " + err);
|
||||
return;
|
||||
}
|
||||
if (body.compare(0, 17, "C BUILD FAVORITE ") == 0)
|
||||
if (body.compare(0, 15, "C BUILD IMPORT ") == 0)
|
||||
{
|
||||
std::string err;
|
||||
if (!HandleBuildFavorite(player, body.substr(17), &err))
|
||||
if (!HandleBuildImport(player, body.substr(15), &err))
|
||||
SendAddonMessage(player, "R ERR " + err);
|
||||
return;
|
||||
}
|
||||
@@ -2555,6 +2896,22 @@ public:
|
||||
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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user