diff --git a/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_04.sql b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_04.sql new file mode 100644 index 0000000..5c79fc3 --- /dev/null +++ b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_04.sql @@ -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`); diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index c19ac32..383b265 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -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 \t -- create empty build // C BUILD EDIT \t\t -- rename / re-icon // C BUILD DELETE -- delete + drop parked pet -// C BUILD FAVORITE <0|1> -- toggle favorite flag // C BUILD LOAD -- swap to this build +// C BUILD UNLOAD -- clear active pointer +// C BUILD IMPORT -- copy a shared build +// into our own catalog // // Server replies push `R BUILDS` after every mutation. Format: // -// R BUILDS active=\t::::; ... +// R BUILDS active=\t::::::; ... +// +// `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(kBuildShareCharsetN) - 1)]; + return code; +} + +// Strict whitelist on incoming `C BUILD IMPORT ` 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("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("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(); + 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 rank = f[1].Get(); + 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("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("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(); + 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 rank = f[1].Get(); + 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(); + 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(); - uint8 fav = f[1].Get(); - bool haspet = !f[2].IsNull() && f[2].Get() != 0; + uint32 id = f[0].Get(); + bool haspet = !f[1].IsNull() && f[1].Get() != 0; + std::string code = f[2].IsNull() ? std::string() : f[2].Get(); std::string name = f[3].Get(); std::string icon = f[4].Get(); + + 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(earnedAE - int64(cost.ae)); + int32 const remainTE = static_cast(earnedTE - int64(cost.te)); + if (!first) body += ';'; first = false; - body += fmt::format("{}:{}:{}:{}:{}", id, - static_cast(fav), + // Wire format: + // :::::: + // 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(std::toupper(static_cast(c))); + + if (!IsValidShareCode(code)) { - *err = "BUILD FAVORITE malformed"; - return false; - } - uint32 buildId = static_cast(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 srcOwner = sf[1].Get(); + std::string srcName = sf[2].Get(); + std::string srcIcon = sf[3].Get(); + + 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() >= 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(); + + // 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") {