From 203356aca8be502847733bd89d3277af4c50c2a9 Mon Sep 17 00:00:00 2001 From: Docker Build Date: Fri, 8 May 2026 21:42:39 -0400 Subject: [PATCH] Paragon: AE/TE currency, Character Advancement commit/reset, panel SQL - mod-paragon: Paragon_Essence addon channel (PARAA), commit queue, resets, spell chain learn with passive child tracking, silence-window hints for cascade learns, trainer exemptions for pet/portal trainers - SQL: character_paragon_panel_* tables, paragon_spell_ae_cost world data - Core: Player Paragon class talent learn hook; Trainer skip for Paragon where appropriate - Ignore local build-worldserver.log Co-authored-by: Cursor --- .gitignore | 3 + .../mod-paragon/conf/mod_paragon.conf.dist | 10 +- .../base/character_paragon_panel_learned.sql | 34 + .../sql/world/base/paragon_spell_ae_cost.sql | 447 +++++++- modules/mod-paragon/src/Paragon_Essence.cpp | 978 +++++++++++++++++- src/server/game/Entities/Creature/Trainer.cpp | 40 + src/server/game/Entities/Player/Player.cpp | 5 +- 7 files changed, 1500 insertions(+), 17 deletions(-) create mode 100644 modules/mod-paragon/sql/characters/base/character_paragon_panel_learned.sql diff --git a/.gitignore b/.gitignore index 4c851e6..5e9c713 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,6 @@ local.properties !/modules/mod-ale/** # Team Docker: ALE needs Lua at configure time (see mod-ale docs) !docker-compose.override.yml + +# Local build artifacts (do not commit) +build-worldserver.log diff --git a/modules/mod-paragon/conf/mod_paragon.conf.dist b/modules/mod-paragon/conf/mod_paragon.conf.dist index ddfbd81..970f82e 100644 --- a/modules/mod-paragon/conf/mod_paragon.conf.dist +++ b/modules/mod-paragon/conf/mod_paragon.conf.dist @@ -23,9 +23,17 @@ Paragon.Currency.GrantLevelMin = 10 Paragon.Currency.AE.PerLevel = 1 Paragon.Currency.TE.PerLevel = 1 # Flat TE cost per successful talent rank learn (hook runs once per LearnTalent). +# Applies to passive / aura-only talents (addToSpellBook == 0). Paragon.Currency.TE.TalentLearnCost = 1 +# AE cost per rank for addToSpellBook talents (Starfall, Bladestorm, …). +# Those talents also charge TE.TalentLearnCost per rank — each rank costs +# both essences at the configured amounts. +Paragon.Currency.AE.TalentLearnCost = 1 # Default AE cost when spell is not listed in world.paragon_spell_ae_cost. -Paragon.Currency.AE.DefaultSpellCost = 2 +# (Phase 3a: every learnable spell baked into the Character Advancement panel +# has an explicit row of value 1 in that table, so this default only kicks in +# for spells learned via .paragon learn that aren't in the panel's bake.) +Paragon.Currency.AE.DefaultSpellCost = 1 # Diagnostics ---------------------------------------------------------------- # When enabled, dumps every Paragon's rune cooldown state to the server log diff --git a/modules/mod-paragon/sql/characters/base/character_paragon_panel_learned.sql b/modules/mod-paragon/sql/characters/base/character_paragon_panel_learned.sql new file mode 100644 index 0000000..7703435 --- /dev/null +++ b/modules/mod-paragon/sql/characters/base/character_paragon_panel_learned.sql @@ -0,0 +1,34 @@ +-- Spells and talents learned only through Character Advancement (Lock In / .paragon learn). +-- Apply to the character database (same as `characters`, `character_spell`, etc.). + +CREATE TABLE IF NOT EXISTS `character_paragon_panel_spells` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `spell_id` INT UNSIGNED NOT NULL, + PRIMARY KEY (`guid`, `spell_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: spells purchased via Character Advancement'; + +CREATE TABLE IF NOT EXISTS `character_paragon_panel_talents` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `talent_id` SMALLINT UNSIGNED NOT NULL, + `rank` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`guid`, `talent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: talent ranks purchased via Character Advancement'; + +-- Passive "dependent" spells that AzerothCore's `addSpell` machinery +-- (via spell_learn_spell + SPELL_EFFECT_LEARN_SPELL) auto-grants when a +-- panel-purchased spell is learned. We keep them learned (some are +-- required for the parent ability to function -- e.g. Frost Fever for +-- Icy Touch, Blood Plague for Plague Strike) but record them here so +-- Reset Abilities / Reset Everything can unlearn them alongside the +-- parent. Only passive auto-learns are tracked here; active dependents +-- (Death Coil, Death Grip, ...) are revoked at learn time and never +-- reach this table. +CREATE TABLE IF NOT EXISTS `character_paragon_panel_spell_children` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `parent_spell_id` INT UNSIGNED NOT NULL COMMENT 'character_paragon_panel_spells.spell_id', + `child_spell_id` INT UNSIGNED NOT NULL COMMENT 'auto-learned passive spell id', + PRIMARY KEY (`guid`, `parent_spell_id`, `child_spell_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: passive auto-learn dependents to unlearn on reset'; diff --git a/modules/mod-paragon/sql/world/base/paragon_spell_ae_cost.sql b/modules/mod-paragon/sql/world/base/paragon_spell_ae_cost.sql index 8c287b0..fc5d62c 100644 --- a/modules/mod-paragon/sql/world/base/paragon_spell_ae_cost.sql +++ b/modules/mod-paragon/sql/world/base/paragon_spell_ae_cost.sql @@ -1,11 +1,446 @@ --- Optional per-spell AE costs for Paragon spell purchases (.paragon learn). --- Apply to the *world* database. +-- Per-spell AE costs for Paragon spell purchases (.paragon learn / panel Lock In). +-- Auto-generated by tools/_gen_paragon_spell_ae_cost_sql.py. +-- Apply to the *world* database (AzerothCore's SQL updater handles this on worldserver start). +-- The flat 1-AE cost is a Phase 3 placeholder; tune individual rows here as the +-- economy gets balanced (e.g., 5 AE for top-rank baseline like Cyclone). CREATE TABLE IF NOT EXISTS `paragon_spell_ae_cost` ( `spell_id` INT UNSIGNED NOT NULL, - `ae_cost` SMALLINT UNSIGNED NOT NULL DEFAULT '2', + `ae_cost` SMALLINT UNSIGNED NOT NULL DEFAULT '1', PRIMARY KEY (`spell_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='mod-paragon: AE cost per spell'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: AE cost per spell'; + +-- Bulk-load: replace the entire table with the current bake. Manual edits +-- to specific rows will be lost when this script regenerates the file -- +-- track per-spell tuning in a separate INSERT ... ON DUPLICATE KEY UPDATE +-- file (e.g. paragon_spell_ae_cost_overrides.sql) if needed. +DELETE FROM `paragon_spell_ae_cost`; + +INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES + (10, 1), + (17, 1), + (53, 1), + (72, 1), + (75, 1), + (78, 1), + (99, 1), + (100, 1), + (116, 1), + (118, 1), + (120, 1), + (122, 1), + (130, 1), + (131, 1), + (132, 1), + (133, 1), + (136, 1), + (139, 1), + (168, 1), + (172, 1), + (324, 1), + (331, 1), + (339, 1), + (348, 1), + (370, 1), + (403, 1), + (408, 1), + (421, 1), + (453, 1), + (465, 1), + (467, 1), + (469, 1), + (475, 1), + (498, 1), + (527, 1), + (528, 1), + (543, 1), + (546, 1), + (552, 1), + (556, 1), + (585, 1), + (586, 1), + (587, 1), + (588, 1), + (589, 1), + (596, 1), + (603, 1), + (604, 1), + (605, 1), + (633, 1), + (635, 1), + (642, 1), + (676, 1), + (686, 1), + (687, 1), + (689, 1), + (693, 1), + (694, 1), + (698, 1), + (702, 1), + (703, 1), + (706, 1), + (710, 1), + (740, 1), + (755, 1), + (759, 1), + (770, 1), + (772, 1), + (774, 1), + (779, 1), + (781, 1), + (845, 1), + (853, 1), + (871, 1), + (879, 1), + (883, 1), + (921, 1), + (976, 1), + (980, 1), + (982, 1), + (1002, 1), + (1008, 1), + (1022, 1), + (1038, 1), + (1044, 1), + (1064, 1), + (1079, 1), + (1082, 1), + (1098, 1), + (1120, 1), + (1126, 1), + (1130, 1), + (1152, 1), + (1160, 1), + (1161, 1), + (1243, 1), + (1449, 1), + (1454, 1), + (1459, 1), + (1462, 1), + (1463, 1), + (1464, 1), + (1490, 1), + (1494, 1), + (1495, 1), + (1499, 1), + (1510, 1), + (1513, 1), + (1515, 1), + (1535, 1), + (1543, 1), + (1680, 1), + (1706, 1), + (1714, 1), + (1715, 1), + (1719, 1), + (1725, 1), + (1752, 1), + (1766, 1), + (1776, 1), + (1784, 1), + (1822, 1), + (1833, 1), + (1842, 1), + (1850, 1), + (1856, 1), + (1943, 1), + (1949, 1), + (1953, 1), + (1966, 1), + (1978, 1), + (2006, 1), + (2008, 1), + (2050, 1), + (2054, 1), + (2060, 1), + (2061, 1), + (2062, 1), + (2094, 1), + (2096, 1), + (2098, 1), + (2120, 1), + (2136, 1), + (2139, 1), + (2362, 1), + (2457, 1), + (2458, 1), + (2484, 1), + (2565, 1), + (2637, 1), + (2641, 1), + (2643, 1), + (2645, 1), + (2687, 1), + (2782, 1), + (2812, 1), + (2825, 1), + (2893, 1), + (2908, 1), + (2912, 1), + (2944, 1), + (2948, 1), + (2973, 1), + (2974, 1), + (2983, 1), + (3034, 1), + (3043, 1), + (3044, 1), + (3045, 1), + (3411, 1), + (3561, 1), + (3562, 1), + (3563, 1), + (3565, 1), + (3566, 1), + (3567, 1), + (3738, 1), + (4987, 1), + (5116, 1), + (5118, 1), + (5138, 1), + (5143, 1), + (5171, 1), + (5176, 1), + (5185, 1), + (5209, 1), + (5211, 1), + (5215, 1), + (5217, 1), + (5221, 1), + (5225, 1), + (5229, 1), + (5246, 1), + (5277, 1), + (5308, 1), + (5384, 1), + (5484, 1), + (5500, 1), + (5502, 1), + (5504, 1); + +INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES + (5675, 1), + (5676, 1), + (5697, 1), + (5730, 1), + (5740, 1), + (5782, 1), + (5938, 1), + (6117, 1), + (6143, 1), + (6196, 1), + (6197, 1), + (6201, 1), + (6229, 1), + (6343, 1), + (6346, 1), + (6353, 1), + (6366, 1), + (6495, 1), + (6552, 1), + (6572, 1), + (6673, 1), + (6770, 1), + (6785, 1), + (6789, 1), + (6940, 1), + (7294, 1), + (7302, 1), + (7384, 1), + (8004, 1), + (8017, 1), + (8024, 1), + (8033, 1), + (8042, 1), + (8050, 1), + (8056, 1), + (8075, 1), + (8092, 1), + (8122, 1), + (8129, 1), + (8143, 1), + (8170, 1), + (8177, 1), + (8181, 1), + (8184, 1), + (8190, 1), + (8227, 1), + (8232, 1), + (8512, 1), + (8647, 1), + (8676, 1), + (8921, 1), + (8936, 1), + (8998, 1), + (9005, 1), + (9484, 1), + (10059, 1), + (10326, 1), + (10595, 1), + (11416, 1), + (11417, 1), + (11418, 1), + (11419, 1), + (11420, 1), + (13159, 1), + (13161, 1), + (13163, 1), + (13165, 1), + (13795, 1), + (13809, 1), + (13813, 1), + (14752, 1), + (14914, 1), + (15237, 1), + (16689, 1), + (16857, 1), + (16914, 1), + (18499, 1), + (19740, 1), + (19742, 1), + (19746, 1), + (19750, 1), + (19752, 1), + (19801, 1), + (19876, 1), + (19878, 1), + (19879, 1), + (19880, 1), + (19882, 1), + (19883, 1), + (19884, 1), + (19885, 1), + (19888, 1), + (19891, 1), + (20043, 1), + (20154, 1), + (20164, 1), + (20165, 1), + (20166, 1), + (20217, 1), + (20230, 1), + (20252, 1), + (20484, 1), + (20736, 1), + (21084, 1), + (21562, 1), + (21849, 1), + (22568, 1), + (22570, 1), + (22812, 1), + (22842, 1), + (23028, 1), + (23920, 1), + (23922, 1), + (24275, 1), + (25780, 1), + (25782, 1), + (25894, 1), + (25898, 1), + (25899, 1), + (26573, 1), + (26679, 1), + (27243, 1), + (27681, 1), + (27683, 1), + (28176, 1), + (29166, 1), + (29722, 1), + (29858, 1), + (29893, 1), + (30451, 1), + (30455, 1), + (30482, 1), + (31224, 1), + (31789, 1), + (31801, 1), + (31884, 1), + (32182, 1), + (32223, 1), + (32266, 1), + (32267, 1), + (32271, 1), + (32272, 1), + (32375, 1), + (32379, 1), + (32546, 1), + (32645, 1), + (33076, 1), + (33690, 1), + (33691, 1), + (33745, 1), + (33763, 1), + (33786, 1), + (34026, 1), + (34074, 1), + (34428, 1), + (34433, 1), + (34477, 1), + (34600, 1), + (35715, 1), + (35717, 1), + (36936, 1), + (42650, 1), + (42955, 1), + (43265, 1), + (43987, 1), + (44614, 1), + (45438, 1), + (45462, 1), + (45477, 1), + (45524, 1), + (45529, 1), + (45902, 1), + (46584, 1), + (47476, 1), + (47528, 1), + (47541, 1), + (47568, 1), + (47897, 1), + (48018, 1), + (48020, 1), + (48045, 1), + (48263, 1), + (48265, 1), + (48266, 1), + (48707, 1), + (48721, 1), + (48743, 1), + (48792, 1), + (49020, 1), + (49358, 1), + (49359, 1), + (49360, 1), + (49361, 1), + (49576, 1), + (49998, 1), + (50464, 1), + (50842, 1), + (51722, 1), + (51723, 1), + (52610, 1); + +INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES + (53140, 1), + (53142, 1), + (53407, 1), + (53408, 1), + (53600, 1), + (53601, 1), + (53736, 1), + (54428, 1), + (55342, 1), + (55694, 1), + (56641, 1), + (57755, 1), + (57934, 1), + (57994, 1), + (60192, 1), + (61846, 1), + (62078, 1), + (62124, 1), + (62757, 1), + (64382, 1), + (64843, 1); --- Example (uncomment to use): --- INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES (55050, 2); diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 9a3ba23..a8ccad6 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -19,14 +19,25 @@ #include "SpellInfo.h" #include "SpellMgr.h" #include "WorldDatabase.h" +#include "WorldPacket.h" #include "Log.h" +#include "DBCStores.h" +#include +#include +#include #include +#include +#include +#include using namespace Acore::ChatCommands; namespace { +// Wire-format prefix shared with the ParagonAdvancement addon (Net.lua). +char const* const kAddonPrefix = "PARAA"; + struct ParagonCurrencyData { uint32 abilityEssence = 0; @@ -136,6 +147,39 @@ uint32 GetTE(Player* player) return itr->second.talentEssence; } +// Pushes an addon-channel message to a single player. The 3.3.5 client +// recognises CHAT_MSG_WHISPER+LANG_ADDON as an addon broadcast and fires +// CHAT_MSG_ADDON locally, splitting payload on the first tab into +// (prefix, body). The ParagonAdvancement addon listens for prefix "PARAA". +void SendAddonMessage(Player* player, std::string const& body) +{ + if (!player || !player->GetSession()) + return; + + std::string payload = std::string(kAddonPrefix) + "\t" + body; + WorldPacket data; + ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); + player->SendDirectMessage(&data); +} + +// Helper: send "R CURRENCY " so the client can update its +// authoritative balance. Safe to call any time the cached values change. +void PushCurrency(Player* player) +{ + if (!player || player->getClass() != CLASS_PARAGON) + return; + if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) + return; + + // Defensive: if for any reason the in-memory cache has lost this player + // (race during login, hot-reload, etc.) refuse to silently broadcast + // (0, 0). Hydrate from DB first so the panel always sees real balances. + if (gParagonCurrencyCache.find(player->GetGUID().GetCounter()) == gParagonCurrencyCache.end()) + LoadCurrencyFromDb(player); + + SendAddonMessage(player, fmt::format("R CURRENCY {} {}", GetAE(player), GetTE(player))); +} + bool TrySpendAE(Player* player, uint32 amount) { if (!amount) @@ -195,6 +239,788 @@ uint32 LookupSpellAECost(uint32 spellId) return std::max(r->Fetch()[0].Get(), 1u); } +// Matches client bake `lvl` (Spell.dbc SpellLevel; see _gen_paragon_advancement_spells_lua.py). +uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info) +{ + if (!info) + return 1u; + uint32 lv = info->SpellLevel; + if (!lv) + lv = info->BaseLevel; + return std::max(1u, lv); +} + +// Forward declaration: reset handlers below need PushSnapshot, which itself +// is defined later (after PushSpellSnapshot / PushTalentSnapshot). +void PushSnapshot(Player* pl); + +// ---- Panel-learn tracking (Overview + resets) ------------------------------ +// +// Spells / talent ranks bought through Character Advancement are stored in +// character_paragon_panel_spells and character_paragon_panel_talents. The +// addon snapshot lists ONLY these rows (intersected with the live player), +// so racial spells, trainer defaults, etc. never appear in Overview. + +void DbInsertPanelSpell(uint32 lowGuid, uint32 spellId) +{ + CharacterDatabase.DirectExecute( + "INSERT IGNORE INTO character_paragon_panel_spells (guid, spell_id) VALUES ({}, {})", + lowGuid, spellId); +} + +void DbInsertPanelSpellChild(uint32 lowGuid, uint32 parentSpellId, uint32 childSpellId) +{ + CharacterDatabase.DirectExecute( + "INSERT IGNORE INTO character_paragon_panel_spell_children " + "(guid, parent_spell_id, child_spell_id) VALUES ({}, {}, {})", + lowGuid, parentSpellId, childSpellId); +} + +// Snapshot of currently-known spell IDs (excluding entries marked removed). +// Used by PanelLearnSpellChain to detect spells that AzerothCore's +// addSpell machinery auto-learns alongside the spell we asked for. +std::unordered_set SnapshotKnownSpells(Player* pl) +{ + std::unordered_set out; + if (!pl) + return out; + PlayerSpellMap const& spells = pl->GetSpellMap(); + out.reserve(spells.size()); + for (auto const& kv : spells) + { + PlayerSpell const* ps = kv.second; + if (ps && ps->State != PLAYERSPELL_REMOVED) + out.insert(kv.first); + } + return out; +} + +// (suppression-via-placeholder helpers were removed: AzerothCore's auto- +// learn for class spells comes via `learnSkillRewardedSpells` -> `_addSpell`, +// not via `SPELL_EFFECT_LEARN_SPELL` effects on the parent. Pre-blocking +// by spell id list didn't intercept the right path. The chat-toast +// suppression now lives client-side in ParagonAdvancement_Net.lua; +// the server just tells the client which ids to silence.) + +// Build the full chain id set for `baseSpellId` (every rank). Used both +// by PanelLearnSpellChain and by the silence-window opener so the client +// knows which "you have learned X" toasts to keep visible. +void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) +{ + if (!baseSpellId) + return; + uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); + uint32 cur = firstId ? firstId : baseSpellId; + while (cur) + { + if (!out.insert(cur).second) + break; + uint32 const n = sSpellMgr->GetNextSpellInChain(cur); + if (!n || n == cur) + break; + cur = n; + } +} + +// Learn every rank of the spell chain that contains `baseSpellId` for which +// the player meets the SpellLevel requirement, then record ONLY the +// first-rank id in character_paragon_panel_spells. +// +// Recording only the chain head (one row per panel purchase) keeps reset +// accounting clean: refundAE is computed once per chain via +// LookupSpellAECost(firstRankId), and Player::removeSpell already cascades +// across higher ranks. If we recorded every learned rank we'd refund N x +// the cost for an N-rank chain and need to dedupe in reset. +// +// Side effect: AC's spell-learn machinery cascades through both the +// `SPELL_EFFECT_LEARN_SPELL` effects and the SkillLineAbility map (when +// the player gains a new skill via `LearnDefaultSkill` -> `SetSkill` -> +// `learnSkillRewardedSpells`), so learning a single class spell can +// auto-grant several side spells (e.g., Plague Strike pulls Death Coil, +// Death Grip, Blood Plague, Blood Presence, Forceful Deflection, Runic +// Focus). We diff the player's spell list before/after `learnSpell` to +// classify those side effects: +// * Passives (Frost Fever, Blood Presence, Forceful Deflection, Runic +// Focus, Blood Plague, ...) are kept since they're typically required +// for the parent ability to function. We record them in +// character_paragon_panel_spell_children so reset unlearns them +// alongside the parent. +// * Active dependents (Death Coil, Death Grip, ...) are revoked +// immediately via `removeSpell` so the player only ends up with what +// they actually purchased. +// The "you have learned X" / "you have unlearned X" chat toasts that +// fire during this dance are silenced client-side via a SILENCE +// addon-channel window opened around the whole commit (see +// HandleCommit + ParagonAdvancement_Net.lua). +void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) +{ + if (!pl || !baseSpellId) + return; + + uint32 const playerLevel = pl->GetLevel(); + uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); + uint32 const trackId = firstId ? firstId : baseSpellId; + uint32 const lowGuid = pl->GetGUID().GetCounter(); + + std::unordered_set chainIds; + CollectSpellChainIds(trackId, chainIds); + + uint32 cur = trackId; + while (cur) + { + SpellInfo const* info = sSpellMgr->GetSpellInfo(cur); + if (!info) + break; + + // Spell.dbc ranks are ordered by required level ascending, so the + // first rank that exceeds the player's level terminates the walk. + uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); + if (playerLevel < reqLv) + break; + + if (!pl->HasSpell(cur)) + { + std::unordered_set before = SnapshotKnownSpells(pl); + pl->learnSpell(cur, false); + std::unordered_set after = SnapshotKnownSpells(pl); + + // Diff: classify each new spell that wasn't in the chain we + // asked for. Passives stick (recorded as children); actives + // get revoked. + for (uint32 spellId : after) + { + if (before.count(spellId)) + continue; // already known + if (chainIds.count(spellId)) + continue; // a rank we asked for + + SpellInfo const* dep = sSpellMgr->GetSpellInfo(spellId); + if (!dep) + continue; + + if (dep->IsPassive()) + DbInsertPanelSpellChild(lowGuid, trackId, spellId); + else + pl->removeSpell(spellId, SPEC_MASK_ALL, false); + } + } + + uint32 const next = sSpellMgr->GetNextSpellInChain(cur); + if (!next || next == cur) + break; + cur = next; + } + + DbInsertPanelSpell(lowGuid, trackId); +} + +void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank) +{ + if (!rank) + { + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + lowGuid, talentId); + return; + } + + CharacterDatabase.DirectExecute( + "REPLACE INTO character_paragon_panel_talents (guid, talent_id, `rank`) VALUES ({}, {}, {})", + lowGuid, talentId, rank); +} + +uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId) +{ + if (!pl) + return 0; + + TalentEntry const* te = sTalentStore.LookupEntry(talentId); + if (!te) + return 0; + + uint32 best = 0; + for (uint8 spec = 0; spec < pl->GetSpecsCount(); ++spec) + for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) + if (te->RankID[r] && pl->HasTalent(te->RankID[r], spec)) + best = std::max(best, uint32(r + 1)); + + return best; +} + +// ---- Commit handler -------------------------------------------------------- +// +// Wire format from Net.lua Net:Commit: +// "C COMMIT s:,,... t::,..." +// Both sub-lists are optional but the leading tags are not. Examples: +// "C COMMIT s:5176,8921 t:" +// "C COMMIT s: t:1234:1,5678:2" +// We parse leniently and abort on any structural error. +// +// On success we push: +// "R OK " followed by R SPELLS / R TALENTS snapshots. +// On failure (insufficient AE/TE, unknown spell, etc.): +// "R ERR " -- the panel restores its pending state. + +constexpr size_t kCommitMaxItems = 64; + +std::vector ParseCsvUInt(std::string_view csv) +{ + std::vector out; + out.reserve(8); + size_t i = 0; + while (i < csv.size()) + { + size_t end = csv.find(',', i); + if (end == std::string_view::npos) + end = csv.size(); + if (end > i) + { + std::string tok(csv.substr(i, end - i)); + try { out.push_back(uint32(std::stoul(tok))); } + catch (...) { out.push_back(0); } + } + i = end + 1; + } + return out; +} + +// Returns true on success; on failure, *err is filled and no state is mutated. +// Send a SILENCE OPEN window to the client listing every spell id whose +// learn/unlearn chat toast SHOULD remain visible. The client then suppresses +// every "you have learned X" / "you have unlearned X" system message during +// the window whose subject isn't on the allow-list. Window auto-closes +// after a short timeout client-side; we also send an explicit CLOSE at the +// end of the commit to release earlier. +// +// Allow-list contents: +// * full chain ids for every spell the player explicitly purchased (so +// the player still sees "Plague Strike (Rank 1..N) learned" toasts); +// * every talent rank id the player explicitly purchased (so addToSpellBook +// talents like Bladestorm/Starfall still toast normally). +// Anything else learned/unlearned during the window -- the SkillLineAbility +// cascade and our diff-revoke cleanup -- is silenced. +void SendSilenceOpenForCommit(Player* pl, + std::vector> const& spellsAndCosts, + std::vector> const& talentDeltas) +{ + if (!pl) + return; + + std::unordered_set allow; + for (auto const& kv : spellsAndCosts) + CollectSpellChainIds(kv.first, allow); + + for (auto const& [tid, delta] : talentDeltas) + { + (void)delta; + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te) + continue; + for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) + if (te->RankID[r]) + allow.insert(te->RankID[r]); + } + + // Open the window even when the allow list is empty (still useful for + // talent-only commits that cascade unrelated passives, etc.). + std::string body = "R SILENCE OPEN"; + if (!allow.empty()) + { + body += ' '; + bool first = true; + for (uint32 id : allow) + { + if (!first) + body += ','; + body += std::to_string(id); + first = false; + + // Stay under the addon-channel chat budget (~250 chars). When + // close to full, flush this batch and start a new OPEN message. + // Client treats multiple OPEN as additive. + if (body.size() > 220) + { + SendAddonMessage(pl, body); + body = "R SILENCE OPEN "; + first = true; + } + } + } + SendAddonMessage(pl, body); +} + +void SendSilenceClose(Player* pl) +{ + if (pl) + SendAddonMessage(pl, "R SILENCE CLOSE"); +} + +bool HandleCommit(Player* pl, std::string const& body, std::string* err) +{ + // Strip leading "C COMMIT " (already stripped by caller, but be defensive) + constexpr std::string_view kPrefix = "C COMMIT "; + std::string_view rest = body; + if (rest.substr(0, kPrefix.size()) == kPrefix) + rest.remove_prefix(kPrefix.size()); + + // Find " t:" delimiter to split spells / talents sections. + auto sPos = rest.find("s:"); + auto tPos = rest.find(" t:"); + if (sPos != 0 || tPos == std::string_view::npos) + { + *err = "malformed commit"; + return false; + } + + std::string_view spellsCsv = rest.substr(2, tPos - 2); + std::string_view talentsCsv = rest.substr(tPos + 3); + + std::vector spellIds = ParseCsvUInt(spellsCsv); + + // Talents are "id:delta,id:delta,...". Parse into vector of pairs. + std::vector> talentDeltas; + { + size_t i = 0; + while (i < talentsCsv.size()) + { + size_t end = talentsCsv.find(',', i); + if (end == std::string_view::npos) + end = talentsCsv.size(); + if (end > i) + { + std::string_view tok = talentsCsv.substr(i, end - i); + size_t colon = tok.find(':'); + if (colon == std::string_view::npos) + { + *err = "talent token missing colon"; + return false; + } + uint32 tid = 0, delta = 0; + try + { + tid = uint32(std::stoul(std::string(tok.substr(0, colon)))); + delta = uint32(std::stoul(std::string(tok.substr(colon + 1)))); + } + catch (...) + { + *err = "talent token parse failed"; + return false; + } + if (tid && delta) + talentDeltas.emplace_back(tid, delta); + } + i = end + 1; + } + } + + if (spellIds.size() + talentDeltas.size() > kCommitMaxItems) + { + *err = "commit exceeds size cap"; + return false; + } + + // Pre-validate spells: must be valid SpellInfo, not already learned, + // and afford their combined AE cost. + uint32 totalAE = 0; + std::vector> spellsAndCosts; + spellsAndCosts.reserve(spellIds.size()); + for (uint32 id : spellIds) + { + if (!id) + continue; + SpellInfo const* info = sSpellMgr->GetSpellInfo(id); + if (!info) + { + *err = fmt::format("unknown spell {}", id); + return false; + } + if (pl->HasSpell(id)) + continue; // silently skip; not an error from the user's POV + uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); + if (pl->GetLevel() < reqLv) + { + *err = fmt::format("requires level {} for spell {}", reqLv, id); + return false; + } + uint32 cost = LookupSpellAECost(id); + spellsAndCosts.emplace_back(id, cost); + totalAE += cost; + } + // (combined AE budget — spells + spell-granting talent ranks — is checked + // once below after we know the talent AE total) + + // Pre-validate talents. addToSpellBook talents (Starfall, Bladestorm, …) + // cost both AE and TE per rank; other talents cost TE only. + uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + uint32 talentsTE = 0; + uint32 talentsAE = 0; + for (auto const& [tid, delta] : talentDeltas) + { + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te) + { + *err = fmt::format("unknown talent {}", tid); + return false; + } + // Same tier gate as default Wrath UI: row 0 @ 10, +5 per row down. + uint32 const minLevelForTier = 10u + te->Row * 5u; + if (pl->GetLevel() < minLevelForTier) + { + *err = fmt::format("requires level {} for that talent tier", minLevelForTier); + return false; + } + if (te->addToSpellBook) + { + talentsAE += delta * aePerRank; + talentsTE += delta * tePerRank; + } + else + talentsTE += delta * tePerRank; + } + if (GetTE(pl) < talentsTE) + { + *err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl)); + return false; + } + if (GetAE(pl) < (totalAE + talentsAE)) + { + *err = fmt::format("not enough AE (need {} have {})", totalAE + talentsAE, GetAE(pl)); + return false; + } + + uint32 const lowGuid = pl->GetGUID().GetCounter(); + + // Open client-side silence window so the cascade dependents AC's + // learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood + // Presence/Forceful Deflection/Runic Focus/...) don't spam learn/ + // unlearn toasts. Allow list = chain ranks of explicitly purchased + // spells + talent rank ids. Closed below at the end of the commit. + SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas); + + // Apply spells: each consumes its individual AE cost. PanelLearnSpellChain + // also grants every higher rank up to the player's current level so the + // panel matches "trainer-style" learning (e.g., buying Wrath at L60 yields + // ranks 1-9 in one purchase). Only the chain head is recorded so reset + // refunds the AE cost exactly once per purchase. + for (auto const& [id, cost] : spellsAndCosts) + { + if (!TrySpendAE(pl, cost)) + { + *err = "AE spend failed mid-commit (race?)"; + SendSilenceClose(pl); + return false; + } + PanelLearnSpellChain(pl, id); + } + + // Apply talents one rank at a time so each call goes through the + // existing OnPlayerLearnTalents hook (which spends TE per rank, and + // both AE+TE for addToSpellBook talents). + // command=true bypasses talent-point and tier-spend requirements; + // class-mask is bypassed in core for Paragon (Player::LearnTalent). + for (auto const& [tid, delta] : talentDeltas) + { + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te) + continue; + + // Find the player's current rank in this talent (0-indexed; 0 means + // none, 1 means rank-1, etc.). Compare against MAX_TALENT_RANK. + uint32 currentRank = 0; + for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) + { + if (te->RankID[r] && pl->HasTalent(te->RankID[r], pl->GetActiveSpec())) + { + currentRank = r + 1; + // keep scanning to find HIGHEST rank + } + } + + uint32 const targetRank = std::min(currentRank + delta, MAX_TALENT_RANK); + for (uint32 r = currentRank; r < targetRank; ++r) + pl->LearnTalent(tid, r, /*command=*/true); + } + + for (auto const& [tid, delta] : talentDeltas) + { + (void)delta; + uint32 const r = ComputeTalentRankAnySpec(pl, tid); + DbUpsertPanelTalent(lowGuid, tid, r); + } + + SaveCurrencyToDb(pl); + SendSilenceClose(pl); + return true; +} + +// ---- Snapshot push (R SPELLS / R TALENTS) --------------------------------- +// +// Sends spell IDs and talent (id, rank) pairs that were purchased through +// Character Advancement only (see character_paragon_panel_* tables). +// +// Addon-channel messages have a generous chat-packet size budget but smaller +// is friendlier, so we chunk into ~180-char payload bodies. Format: +// "R SPELLS ,,..." (multiple messages OK; client appends) +// "R SPELLS_END" (sentinel: rebuild list now) +// "R TALENTS :,..." (chunked similarly) +// "R TALENTS_END" +// The client clears its buffer when it sees the first "R SPELLS" / "R TALENTS" +// after a "_END", so resending is idempotent. + +constexpr size_t kSnapshotChunkBudget = 180; + +void PushSpellSnapshot(Player* pl) +{ + if (!pl) + return; + + uint32 const lowGuid = pl->GetGUID().GetCounter(); + std::string buf; + buf.reserve(kSnapshotChunkBudget + 16); + buf = "R SPELLS "; + bool first = true; + + auto flush = [&](bool finalChunk) + { + if (buf.size() > 9) + SendAddonMessage(pl, buf); + buf = "R SPELLS "; + first = true; + if (finalChunk) + SendAddonMessage(pl, "R SPELLS_END"); + }; + + if (QueryResult r = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) + { + do + { + uint32 const sid = r->Fetch()[0].Get(); + if (!pl->HasSpell(sid)) + { + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}", + lowGuid, sid); + continue; + } + + SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); + if (info && info->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) + continue; + + std::string token = (first ? "" : ",") + std::to_string(sid); + if (buf.size() + token.size() > kSnapshotChunkBudget) + flush(/*finalChunk=*/false); + buf.append(first ? std::to_string(sid) : token); + first = false; + } while (r->NextRow()); + } + flush(/*finalChunk=*/true); +} + +void PushTalentSnapshot(Player* pl) +{ + if (!pl) + return; + + uint32 const lowGuid = pl->GetGUID().GetCounter(); + std::string buf = "R TALENTS "; + bool first = true; + + auto flush = [&](bool finalChunk) + { + if (buf.size() > 10) + SendAddonMessage(pl, buf); + buf = "R TALENTS "; + first = true; + if (finalChunk) + SendAddonMessage(pl, "R TALENTS_END"); + }; + + if (QueryResult r = CharacterDatabase.Query( + "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) + { + do + { + Field const* f = r->Fetch(); + uint32 const tid = f[0].Get(); + uint32 const dbRank = f[1].Get(); + uint32 const actual = ComputeTalentRankAnySpec(pl, tid); + if (!actual || !dbRank) + { + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + lowGuid, tid); + continue; + } + + uint32 const shown = std::min(actual, dbRank); + + std::string token = + (first ? "" : ",") + std::to_string(tid) + ":" + std::to_string(shown); + if (buf.size() + token.size() > kSnapshotChunkBudget) + flush(/*finalChunk=*/false); + buf.append(first ? (std::to_string(tid) + ":" + std::to_string(shown)) : token); + first = false; + } while (r->NextRow()); + } + flush(/*finalChunk=*/true); +} + +bool HandleParagonResetAbilities(Player* pl, std::string* err) +{ + if (!pl || pl->getClass() != CLASS_PARAGON) + { + *err = "not a Paragon"; + return false; + } + + if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) + { + *err = "Paragon currency disabled"; + return false; + } + + LoadCurrencyFromDb(pl); + uint32 const lowGuid = pl->GetGUID().GetCounter(); + uint32 refundAE = 0; + + if (QueryResult r = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) + { + do + { + uint32 const sid = r->Fetch()[0].Get(); + refundAE += LookupSpellAECost(sid); + + // Unlearn any passive dependents we tracked when this parent + // was learned (Frost Fever, Blood Presence, Forceful + // Deflection, ...). Done before removing the parent so the + // server doesn't re-grant them via SPELL_EFFECT_LEARN_SPELL + // re-application during the parent's removeSpell cascade. + if (QueryResult cr = CharacterDatabase.Query( + "SELECT child_spell_id FROM character_paragon_panel_spell_children " + "WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid)) + { + do + { + uint32 const cid = cr->Fetch()[0].Get(); + if (pl->HasSpell(cid)) + pl->removeSpell(cid, SPEC_MASK_ALL, false); + } while (cr->NextRow()); + } + + if (pl->HasSpell(sid)) + pl->removeSpell(sid, SPEC_MASK_ALL, false); + } while (r->NextRow()); + } + + CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spells WHERE guid = {}", lowGuid); + CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); + + if (refundAE) + { + ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); + d.abilityEssence += refundAE; + } + + SaveCurrencyToDb(pl); + PushCurrency(pl); + PushSnapshot(pl); + LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE); + return true; +} + +bool HandleParagonResetTalents(Player* pl, std::string* err) +{ + if (!pl || pl->getClass() != CLASS_PARAGON) + { + *err = "not a Paragon"; + return false; + } + + if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) + { + *err = "Paragon currency disabled"; + return false; + } + + LoadCurrencyFromDb(pl); + uint32 const lowGuid = pl->GetGUID().GetCounter(); + uint32 refundAE = 0; + uint32 refundTE = 0; + 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 talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) + { + do + { + Field const* f = r->Fetch(); + uint32 const tid = f[0].Get(); + uint32 const dbRank = f[1].Get(); + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te || !dbRank) + continue; + uint32 const actual = ComputeTalentRankAnySpec(pl, tid); + uint32 const ranks = std::min(dbRank, actual); + if (!ranks) + continue; + if (te->addToSpellBook) + { + refundAE += ranks * aePerRank; + refundTE += ranks * tePerRank; + } + else + refundTE += ranks * tePerRank; + } while (r->NextRow()); + } + + uint8 const origSpec = pl->GetActiveSpec(); + for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) + { + pl->ActivateSpec(s); + pl->resetTalents(true); + } + pl->ActivateSpec(origSpec); + + CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_talents WHERE guid = {}", lowGuid); + + ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); + d.abilityEssence += refundAE; + d.talentEssence += refundTE; + + SaveCurrencyToDb(pl); + PushCurrency(pl); + PushSnapshot(pl); + LOG_INFO("module", "Paragon panel: {} reset talents (+{} AE +{} TE refund)", pl->GetName(), refundAE, refundTE); + return true; +} + +bool HandleParagonResetAll(Player* pl, std::string* err) +{ + std::string sub; + if (!HandleParagonResetTalents(pl, err)) + return false; + if (!HandleParagonResetAbilities(pl, &sub)) + { + *err = sub; + return false; + } + LOG_INFO("module", "Paragon panel: {} reset everything", pl->GetName()); + return true; +} + +void PushSnapshot(Player* pl) +{ + if (!pl || pl->getClass() != CLASS_PARAGON) + return; + PushSpellSnapshot(pl); + PushTalentSnapshot(pl); +} + class Paragon_Essence_PlayerScript : public PlayerScript { public: @@ -205,12 +1031,15 @@ public: PLAYERHOOK_ON_CREATE, PLAYERHOOK_ON_LEVEL_CHANGED, PLAYERHOOK_CAN_LEARN_TALENT, - PLAYERHOOK_ON_PLAYER_LEARN_TALENTS + PLAYERHOOK_ON_PLAYER_LEARN_TALENTS, + PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE }) { } void OnPlayerLogin(Player* player) override { LoadCurrencyFromDb(player); + PushCurrency(player); + PushSnapshot(player); } void OnPlayerLogout(Player* player) override @@ -239,6 +1068,7 @@ public: ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = ae; d.talentEssence = te; + // Player isn't fully in-world here; OnPlayerLogin will push. } void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override @@ -246,23 +1076,120 @@ public: if (!player || player->getClass() != CLASS_PARAGON) return; - LoadCurrencyFromDb(player); + // Cache is authoritative once OnPlayerLogin loaded it; reloading + // from DB here would clobber any AE/TE the player has spent since + // the last save (e.g., panel Lock-In followed by a level-up before + // the next OnPlayerSave tick), effectively refunding the spend. + // Hydrate ONLY if the cache is unexpectedly empty. + uint32 const lowGuid = player->GetGUID().GetCounter(); + if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end()) + LoadCurrencyFromDb(player); + GrantLevelUpEssence(player, oldLevel, player->GetLevel()); + + // Persist the grant immediately so a crash before next save doesn't + // lose freshly-awarded essence. Cheap (single REPLACE). + SaveCurrencyToDb(player); + PushCurrency(player); } - bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* /*talent*/, uint32 /*rank*/) override + bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* talent, uint32 /*rank*/) override { if (!player || player->getClass() != CLASS_PARAGON) return true; + if (!talent) + return false; + + uint32 const minLevelForTier = 10u + talent->Row * 5u; + if (player->GetLevel() < minLevelForTier) + return false; + if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return true; + // addToSpellBook talents cost both AE and TE per rank. + if (talent->addToSpellBook) + { + uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + return GetAE(player) >= aeCost && GetTE(player) >= teCost; + } uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); return GetTE(player) >= cost; } - void OnPlayerLearnTalents(Player* player, uint32 /*talentId*/, uint32 /*talentRank*/, uint32 /*spellid*/) override + // ParagonAdvancement addon -> server: addon-channel chat from a Paragon + // player that begins with our PARAA prefix is treated as a control + // request. Supported requests: + // "Q CURRENCY" -- push R CURRENCY back to refresh bars + // "Q SNAPSHOT" -- push R SPELLS and R TALENTS for Overview + // "C COMMIT s:... t:..." -- apply pending learns from the panel + // "C RESET ABILITIES" / "C RESET TALENTS" / "C RESET ALL" / "C RESET EVERYTHING" + void OnPlayerBeforeSendChatMessage(Player* player, uint32& /*type*/, uint32& lang, std::string& msg) override + { + if (!player || player->getClass() != CLASS_PARAGON) + return; + if (lang != LANG_ADDON) + return; + + std::string const expectedPrefix = std::string(kAddonPrefix) + "\t"; + if (msg.compare(0, expectedPrefix.size(), expectedPrefix) != 0) + return; + + std::string body = msg.substr(expectedPrefix.size()); + + if (body == "Q CURRENCY") + { + PushCurrency(player); + return; + } + if (body == "Q SNAPSHOT") + { + PushSnapshot(player); + return; + } + if (body.compare(0, 9, "C COMMIT ") == 0) + { + std::string err; + if (HandleCommit(player, body, &err)) + { + SendAddonMessage(player, fmt::format("R OK {} {}", GetAE(player), GetTE(player))); + PushCurrency(player); + PushSnapshot(player); + } + else + { + SendAddonMessage(player, "R ERR " + err); + LOG_INFO("module", "Paragon commit rejected for player {}: {}", + player->GetName(), err); + } + return; + } + if (body == "C RESET ABILITIES") + { + std::string err; + if (!HandleParagonResetAbilities(player, &err)) + SendAddonMessage(player, "R ERR " + err); + return; + } + if (body == "C RESET TALENTS") + { + std::string err; + if (!HandleParagonResetTalents(player, &err)) + SendAddonMessage(player, "R ERR " + err); + return; + } + if (body == "C RESET ALL" || body == "C RESET EVERYTHING") + { + std::string err; + if (!HandleParagonResetAll(player, &err)) + SendAddonMessage(player, "R ERR " + err); + return; + } + } + + void OnPlayerLearnTalents(Player* player, uint32 talentId, uint32 /*talentRank*/, uint32 /*spellid*/) override { if (!player || player->getClass() != CLASS_PARAGON) return; @@ -270,12 +1197,44 @@ public: if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; - uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); - if (!TrySpendTE(player, cost)) + TalentEntry const* talent = sTalentStore.LookupEntry(talentId); + bool const dualCost = talent && talent->addToSpellBook; + + if (dualCost) { - LOG_ERROR("module", "Paragon TE spend failed post-learn for player {} — currency desync?", player->GetName()); - return; + uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + if (!TrySpendTE(player, teCost)) + { + LOG_ERROR("module", + "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", + talentId, player->GetName()); + return; + } + if (!TrySpendAE(player, aeCost)) + { + ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter()); + d.talentEssence += teCost; + SaveCurrencyToDb(player); + PushCurrency(player); + LOG_ERROR("module", + "Paragon AE spend failed post-learn for talent {} (player {}) — refunded TE; currency desync?", + talentId, player->GetName()); + return; + } } + else + { + uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + if (!TrySpendTE(player, cost)) + { + LOG_ERROR("module", + "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", + talentId, player->GetName()); + return; + } + } + PushCurrency(player); } }; @@ -399,8 +1358,9 @@ public: if (!TrySpendAE(pl, cost)) return false; - pl->learnSpell(spell->Id, false); + PanelLearnSpellChain(pl, spell->Id); SaveCurrencyToDb(pl); + PushCurrency(pl); handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl)); return true; diff --git a/src/server/game/Entities/Creature/Trainer.cpp b/src/server/game/Entities/Creature/Trainer.cpp index 58b61aa..ed628bd 100644 --- a/src/server/game/Entities/Creature/Trainer.cpp +++ b/src/server/game/Entities/Creature/Trainer.cpp @@ -208,6 +208,46 @@ namespace Trainer bool Trainer::IsTrainerValidForPlayer(Player const* player) const { + // Paragon (class 12) learns class abilities exclusively through the + // Character Advancement panel (mod-paragon). Generic class trainers + // refuse interaction. Pet trainers, mount/profession trainers, and + // specialized portal/teleport trainers (mage portal NPCs) stay valid: + // - Pet trainers: pet-skill purchases for hunter pets aren't covered + // by the panel and should remain trainer-driven. + // - Portal/teleport trainers: identified at runtime as a Class-type + // trainer whose spells are ALL TELEPORT_UNITS or TRANS_DOOR + // effects. The big general mage trainer fails this check (it + // teaches Fireball, Frostbolt, etc.) and is correctly blocked. + if (player && player->getClass() == CLASS_PARAGON + && GetTrainerType() == Type::Class + && !_spells.empty()) + { + bool onlyPortalsAndTeleports = true; + for (Spell const& s : _spells) + { + SpellInfo const* info = sSpellMgr->GetSpellInfo(s.SpellId); + if (!info) + continue; + bool isPortalOrTeleport = false; + for (SpellEffectInfo const& eff : info->GetEffects()) + { + if (eff.Effect == SPELL_EFFECT_TELEPORT_UNITS + || eff.Effect == SPELL_EFFECT_TRANS_DOOR) + { + isPortalOrTeleport = true; + break; + } + } + if (!isPortalOrTeleport) + { + onlyPortalsAndTeleports = false; + break; + } + } + if (!onlyPortalsAndTeleports) + return false; + } + if (!GetTrainerRequirement()) return true; diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 5676e10..7c7bfe0 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -14052,7 +14052,10 @@ void Player::LearnTalent(uint32 talentId, uint32 talentRank, bool command /*= fa return; // xinef: prevent learn talent for different class (cheating) - if ((getClassMask() & talentTabInfo->ClassMask) == 0) + // mod-paragon: Paragon (class 12) can spec into any class's talent tree + // via the Character Advancement panel; bypass the class-mask check. + if (getClass() != CLASS_PARAGON + && (getClassMask() & talentTabInfo->ClassMask) == 0) return; // xinef: find current talent rank