From 7de018f7eb9cdb8bfeffde101d9083f8938eafa4 Mon Sep 17 00:00:00 2001 From: Docker Build Date: Sun, 10 May 2026 02:35:55 -0400 Subject: [PATCH] Paragon: add Builds catalog (saved loadouts with pet park/unpark) Server-side Character Advancement now stores named, icon-tagged build recipes (panel-purchased spells + per-spec talent ranks) and atomically swaps between them by snapshotting the active build, refunding AE/TE through HandleParagonReset{Talents,Abilities}, and re-spending on the target recipe. Hunter pets attached to a build are parked to PET_SAVE_NOT_IN_SLOT (mirroring HandleStableSwapPet) so name, talents, and exp survive swaps; non-hunter pets (warlock demon, DK ghoul, mage water elemental) are NOT parked because the engine resummons them from a fresh template each cast. New PARAA verbs: Q BUILDS / C BUILD NEW / C BUILD EDIT / C BUILD DELETE / C BUILD FAVORITE / C BUILD LOAD. The catalog is pushed on login and after every mutation as a single addon message. Schema (mod-paragon migration 2026_05_10_03.sql): - character_paragon_builds (build_id PK, guid, name, icon, is_favorite, pet_number, created_at) - character_paragon_build_spells (build_id, spell_id) - character_paragon_build_talents (build_id, spec, talent_id, rank) - character_paragon_active_build (guid PK, build_id) The talent recipe table is spec-keyed so a build remembers tank/dps dual-spec layouts independently. Swaps are blocked while in combat. Co-authored-by: Cursor --- .../fractured-dev-extras/CLIENT-PATCHES.md | 14 +- .../db-characters/updates/2026_05_10_03.sql | 62 ++ modules/mod-paragon/src/Paragon_Essence.cpp | 786 ++++++++++++++++++ 3 files changed, 857 insertions(+), 5 deletions(-) create mode 100644 modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_03.sql diff --git a/contrib/fractured-dev-extras/CLIENT-PATCHES.md b/contrib/fractured-dev-extras/CLIENT-PATCHES.md index 218218e..81e3cf8 100644 --- a/contrib/fractured-dev-extras/CLIENT-PATCHES.md +++ b/contrib/fractured-dev-extras/CLIENT-PATCHES.md @@ -15,16 +15,20 @@ This file is the table of contents and install guide. |---|---|---| | `patch-enUS-4.MPQ` | ~5 MB | DBC + GlueXML bake. Adds `CLASS_PARAGON` (id 12), the character-create slot, glue strings, game-table DBCs, and a patched `Spell.dbc`: **(1)** `RuneCostID` zeroed on every rune-cost spell so non–Death Knight clients still send DK casts (rune costs are shown via `RuneFrame.lua`); **(2)** `Reagent[]` / `ReagentCount[]` zeroed on every spell whose `SpellFamilyName` is non-zero (all class abilities), while profession crafts (`SpellFamilyName == 0`) keep their materials. Both edits mirror server load-time corrections so client preflight and server validation stay aligned. Required for character creation as Paragon to even show up. | | `patch-enUS-5.MPQ` | ~57 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). | -| `patch-enUS-6.MPQ` | ~123 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), and bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast. | +| `patch-enUS-6.MPQ` | ~134 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast, and a **Builds** page (full-pane overlay opened from the bottom-row Builds button) for saving named, icon-tagged loadouts: New Build (+) icon picker reuses `MACRO_ICON_FILENAMES`, right-click for edit/delete, shift-left-click to favorite (favorites bubble to the top), left-click pops a Load Build confirm. Build swaps reset + refund AE/TE, re-spend on the saved recipe, and **park hunter pets** to `PET_SAVE_NOT_IN_SLOT` so their name/talents/exp are preserved across swaps. | | `Wow.exe` | ~7.5 MB | 3.3.5a (build 12340) client byte-patched to skip the MPQ signature check so custom `patch-enUS-N.MPQ` files load. Diff against stock is a few bytes; everything else is unchanged. | Server and client work as a pair: the addon talks to `mod-paragon` on the worldserver via `WHISPER` addon-channel messages with the `PARAA` prefix (currency push, spell/talent snapshot, commit, combo points, rune -cooldowns, learn-toast silence window, and **`C RESET PET TALENTS`** -for hunter pet talent resets from the Character Advancement PETS tab). -Mismatched versions usually -manifest as the panel rendering blank or AE/TE reading 0/0. +cooldowns, learn-toast silence window, **`C RESET PET TALENTS`** +for hunter pet talent resets from the Character Advancement PETS tab, +and the **build catalog** verbs `Q BUILDS` / `C BUILD NEW` / `C BUILD +EDIT` / `C BUILD DELETE` / `C BUILD FAVORITE` / `C BUILD LOAD` for the +saved-loadout system on the Builds page). Build swaps require the +matching worldserver image because the swap path is server-driven +(snapshot → reset → re-spend → pet park/unpark). Mismatched versions +usually manifest as the panel rendering blank or AE/TE reading 0/0. --- diff --git a/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_03.sql b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_03.sql new file mode 100644 index 0000000..3f61065 --- /dev/null +++ b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_03.sql @@ -0,0 +1,62 @@ +-- mod-paragon Character Advancement: Build catalog (saved loadouts). +-- ---------------------------------------------------------------------------- +-- A "build" is a named, icon-tagged loadout of panel-purchased spells and +-- talent ranks. Each Paragon character can save many builds and swap +-- between them via the Builds page in the Character Advancement panel. +-- +-- Swap workflow (see HandleBuildLoad in Paragon_Builds.cpp): +-- 1. If a build is currently active, snapshot the player's current +-- panel-purchased spells + per-spec talent ranks into that build's +-- recipe rows (overwriting the stored recipe). +-- 2. If the active build's hunter pet is currently summoned, unsummon +-- it to PET_SAVE_NOT_IN_SLOT and store its `pet_number` on the +-- active build row so it can be restored on swap-back. +-- 3. Reset all panel-bought abilities and talents (refunding AE/TE). +-- 4. Re-buy each spell + talent in the target build's recipe (charging +-- AE/TE; aborts if insufficient AE/TE -- player keeps refunded +-- currency in that case and active becomes NULL). +-- 5. Move the target build's parked pet (if any) back to current. +-- 6. Update active_build pointer. +-- +-- Pet ownership: a parked pet sits in `character_pet` with slot=100 +-- (PET_SAVE_NOT_IN_SLOT), exactly like the engine's stable-master +-- offload, but tied to the build via `pet_number` instead of any +-- in-game stable slot. Build deletion drops the parked pet rows +-- entirely (PET_SAVE_AS_DELETED equivalent) -- player is warned. +-- ---------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `character_paragon_builds` ( + `build_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `name` VARCHAR(32) NOT NULL, + `icon` VARCHAR(64) NOT NULL DEFAULT 'INV_Misc_QuestionMark', + `is_favorite` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `pet_number` INT UNSIGNED NULL COMMENT 'character_pet.id of parked hunter pet, NULL when no pet bound to this build', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`build_id`), + KEY `idx_guid` (`guid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: saved Character Advancement build catalog'; + +CREATE TABLE IF NOT EXISTS `character_paragon_build_spells` ( + `build_id` INT UNSIGNED NOT NULL, + `spell_id` INT UNSIGNED NOT NULL, + PRIMARY KEY (`build_id`, `spell_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: per-build recipe -- panel-purchased spells'; + +CREATE TABLE IF NOT EXISTS `character_paragon_build_talents` ( + `build_id` INT UNSIGNED NOT NULL, + `spec` TINYINT UNSIGNED NOT NULL COMMENT '0 = primary spec, 1 = secondary (dual spec)', + `talent_id` SMALLINT UNSIGNED NOT NULL, + `rank` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`build_id`, `spec`, `talent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: per-build recipe -- panel-purchased talent ranks per spec'; + +CREATE TABLE IF NOT EXISTS `character_paragon_active_build` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `build_id` INT UNSIGNED NOT NULL COMMENT 'character_paragon_builds.build_id (per-character active pointer)', + PRIMARY KEY (`guid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: pointer to whichever build is currently loaded (one row per Paragon character)'; diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 68ce235..c19ac32 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -29,6 +29,8 @@ #include #include #include +#include +#include #include #include #include @@ -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 \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 +// +// Server replies push `R BUILDS` after every mutation. Format: +// +// R BUILDS active=\t::::; ... +// +// 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\\". +// ---------------------------------------------------------------------------- + +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(); + 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(); + uint8 fav = f[1].Get(); + bool haspet = !f[2].IsNull() && f[2].Get() != 0; + std::string name = f[3].Get(); + std::string icon = f[4].Get(); + if (!first) + body += ';'; + first = false; + body += fmt::format("{}:{}:{}:{}:{}", id, + static_cast(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() >= 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 "\t\t" + 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(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(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(); + + // 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(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(); + 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 panelTalents; + if (QueryResult pt = CharacterDatabase.Query( + "SELECT talent_id FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) + { + do { panelTalents.insert(pt->Fetch()[0].Get()); } 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(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(); + 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(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(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(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 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()); } + while (sp->NextRow()); + } + + // Recipe talents: collected up front so we can pre-flight AE+TE. + uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + + struct RecipeTalent { uint8 spec; uint32 tid; uint8 rank; }; + std::vector 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(), f[1].Get(), f[2].Get() }; + TalentEntry const* te = sTalentStore.LookupEntry(rt.tid); + if (!te || !rt.rank) + continue; + recipeTalents.push_back(rt); + talentTeCost += static_cast(rt.rank) * tePerRank; + if (te->addToSpellBook) + talentAeCost += static_cast(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