diff --git a/modules/mod-paragon/conf/mod_paragon.conf.dist b/modules/mod-paragon/conf/mod_paragon.conf.dist index 970f82e..98c106d 100644 --- a/modules/mod-paragon/conf/mod_paragon.conf.dist +++ b/modules/mod-paragon/conf/mod_paragon.conf.dist @@ -40,3 +40,10 @@ Paragon.Currency.AE.DefaultSpellCost = 1 # every 5 seconds (channel "module"). Use with `.paragon runes` for in-game # verification. Leave at 0 in production — it's noisy. Paragon.Diag.RuneTrace = 0 + +# When enabled, traces every PanelLearnSpellChain commit: chain ids, before/ +# after spell-map sizes for each rank learn, and the classification of every +# auto-granted side spell (chain rank / passive dep / active dep revoked). +# Use to diagnose "spell X reappears in spellbook on relog" style bugs. +# Leave at 0 in production. +Paragon.Diag.PanelLearn = 0 diff --git a/modules/mod-paragon/data/sql/db-characters/base/character_paragon_panel_learned.sql b/modules/mod-paragon/data/sql/db-characters/base/character_paragon_panel_learned.sql index 7703435..fbb99f2 100644 --- a/modules/mod-paragon/data/sql/db-characters/base/character_paragon_panel_learned.sql +++ b/modules/mod-paragon/data/sql/db-characters/base/character_paragon_panel_learned.sql @@ -32,3 +32,20 @@ CREATE TABLE IF NOT EXISTS `character_paragon_panel_spell_children` ( 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'; + +-- Active "dependent" spells that AzerothCore's `addSpell` / +-- `learnSkillRewardedSpells` machinery auto-grants alongside a +-- panel-purchased spell but that the player did NOT buy (e.g. Blood +-- Presence, Death Coil, Death Grip from purchasing Plague Strike). +-- We revoke them at panel-commit time, but AC's skill cascade re-runs +-- on every login (`Player::_LoadSkills` -> `learnSkillRewardedSpells`) +-- and silently re-grants them. We persist the revoke decisions here +-- and re-revoke at `OnPlayerLogin` so the spellbook stays in sync with +-- what the player actually purchased through Character Advancement. +CREATE TABLE IF NOT EXISTS `character_paragon_panel_spell_revoked` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `parent_spell_id` INT UNSIGNED NOT NULL COMMENT 'character_paragon_panel_spells.spell_id', + `revoked_spell_id` INT UNSIGNED NOT NULL COMMENT 'active spell id auto-granted by skill cascade and revoked', + PRIMARY KEY (`guid`, `parent_spell_id`, `revoked_spell_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: active auto-learn dependents to keep revoked across logins'; diff --git a/modules/mod-paragon/data/sql/db-characters/updates/2026_05_09_00.sql b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_09_00.sql new file mode 100644 index 0000000..ae764db --- /dev/null +++ b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_09_00.sql @@ -0,0 +1,20 @@ +-- mod-paragon: tracking table for active spell dependents the panel +-- revoked at commit time. AzerothCore's `Player::_LoadSkills` -> +-- `learnSkillRewardedSpells` re-grants skill-rewarded actives (Blood +-- Presence, Death Coil, Death Grip, ...) on every login. Persisting +-- the revoke decisions here lets `OnPlayerLogin` re-revoke them after +-- the cascade has run, so the spellbook stays in sync with what was +-- actually purchased through Character Advancement. +-- +-- This file lives under `updates/` so AC's DBUpdater applies it +-- incrementally on existing databases (the matching `CREATE TABLE +-- IF NOT EXISTS` block in base/character_paragon_panel_learned.sql +-- handles fresh installs). + +CREATE TABLE IF NOT EXISTS `character_paragon_panel_spell_revoked` ( + `guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid', + `parent_spell_id` INT UNSIGNED NOT NULL COMMENT 'character_paragon_panel_spells.spell_id', + `revoked_spell_id` INT UNSIGNED NOT NULL COMMENT 'active spell id auto-granted by skill cascade and revoked', + PRIMARY KEY (`guid`, `parent_spell_id`, `revoked_spell_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='mod-paragon: active auto-learn dependents to keep revoked across logins'; diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index a8ccad6..dc5ac41 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -16,15 +16,18 @@ #include "RBAC.h" #include "ScriptMgr.h" #include "SharedDefines.h" +#include "SpellAuras.h" #include "SpellInfo.h" #include "SpellMgr.h" #include "WorldDatabase.h" #include "WorldPacket.h" #include "Log.h" +#include "DBCEnums.h" #include "DBCStores.h" #include #include +#include #include #include #include @@ -254,6 +257,10 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info) // is defined later (after PushSpellSnapshot / PushTalentSnapshot). void PushSnapshot(Player* pl); +// Forward declaration: the login-time scoped sweep (defined a few helpers +// down) calls into the chain-walker (defined further down). +void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out); + // ---- Panel-learn tracking (Overview + resets) ------------------------------ // // Spells / talent ranks bought through Character Advancement are stored in @@ -276,6 +283,282 @@ void DbInsertPanelSpellChild(uint32 lowGuid, uint32 parentSpellId, uint32 childS lowGuid, parentSpellId, childSpellId); } +// Persist an "active dependent we revoked" so we can re-revoke it after +// AC's skill cascade re-grants it on the next login. See +// `RevokeBlockedSpellsForPlayer` for the redo step. +void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revokedSpellId) +{ + CharacterDatabase.DirectExecute( + "INSERT IGNORE INTO character_paragon_panel_spell_revoked " + "(guid, parent_spell_id, revoked_spell_id) VALUES ({}, {}, {})", + lowGuid, parentSpellId, revokedSpellId); +} + +// Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the +// player still has it. Call sites: +// * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells` +// ran during LoadFromDB and may have re-granted Blood Presence / +// Death Coil / Death Grip / etc. before any of our hooks fired. +// * `HandleParagonResetAbilities` is NOT a caller; reset clears the +// table outright so the revoke list starts fresh on next purchase. +void RevokeBlockedSpellsForPlayer(Player* pl) +{ + if (!pl) + return; + + uint32 const lowGuid = pl->GetGUID().GetCounter(); + QueryResult r = CharacterDatabase.Query( + "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", + lowGuid); + if (!r) + return; + + uint32 removed = 0; + do + { + uint32 const sid = r->Fetch()[0].Get(); + if (pl->HasSpell(sid)) + { + pl->removeSpell(sid, SPEC_MASK_ALL, false); + ++removed; + } + } while (r->NextRow()); + + if (removed) + LOG_INFO("module", + "Paragon panel: re-revoked {} skill-cascade dependents for {} on login", + removed, pl->GetName()); +} + +[[nodiscard]] static bool SkillLineAbilityIsSkillCascadeSigned(SkillLineAbilityEntry const* sla) +{ + return sla && (sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_LEARN + || sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_VALUE); +} + +// SkillLine ids from every SkillLineAbility row that lists `spellId` +// (any AcquireMethod). Used as the *anchor* side of cascade detection. +// +// Important: the old helper only kept rows with AcquireMethod +// LEARNED_ON_SKILL_* . Spells like Blood Strike are usually tied to +// their skill line via a trainer/default row (AcquireMethod 0). That +// meant the anchor set was empty, so `IsSpellSkillLineCascadeDependent` +// never matched Forceful Deflection / Blood Presence — those were stored +// as innocent "children" and never revoked. +// +// Dependent detection still requires the *dependent* spell to have a +// LEARNED_ON_SKILL_* row on one of these lines (same as +// `learnSkillRewardedSpells`). +[[nodiscard]] static std::unordered_set SkillLinesLinkedToSpell(uint32 spellId) +{ + std::unordered_set out; + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (auto it = bounds.first; it != bounds.second; ++it) + { + SkillLineAbilityEntry const* sla = it->second; + if (!sla) + continue; + out.insert(sla->SkillLine); + } + return out; +} + +// True when `depSpellId` is granted as a skill-line reward on one of the +// same SkillLines as `anchorSpellId` (e.g. Blood Strike -> Forceful +// Deflection / Blood Presence). Passives learned only via spell effects +// (disease auras, etc.) typically return false here. +[[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId) +{ + std::unordered_set const anchorLines = SkillLinesLinkedToSpell(anchorSpellId); + if (anchorLines.empty()) + return false; + + SkillLineAbilityMapBounds db = sSpellMgr->GetSkillLineAbilityMapBounds(depSpellId); + for (auto it = db.first; it != db.second; ++it) + { + SkillLineAbilityEntry const* sla = it->second; + if (!SkillLineAbilityIsSkillCascadeSigned(sla)) + continue; + if (anchorLines.count(sla->SkillLine)) + return true; + } + return false; +} + +// Older builds recorded skill-line cascade passives (Forceful Deflection, +// Runic Focus, ...) as `panel_spell_children`. Strip those rows and +// revoke the spell so the login sweep + reset logic match current policy. +static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) +{ + if (!pl) + return; + + QueryResult r = CharacterDatabase.Query( + "SELECT parent_spell_id, child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", + lowGuid); + if (!r) + return; + + do + { + uint32 const parent = r->Fetch()[0].Get(); + uint32 const child = r->Fetch()[1].Get(); + if (!IsSpellSkillLineCascadeDependent(parent, child)) + continue; + + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", + lowGuid, parent, child); + + if (pl->HasSpell(child)) + { + pl->removeSpell(child, SPEC_MASK_ALL, false); + DbInsertPanelSpellRevoked(lowGuid, parent, child); + } + } while (r->NextRow()); +} + +// Login-time scoped sweep for cascade-granted dependents that the +// commit-time diff missed. +// +// Background: AC's `_addSpell` calls `LearnDefaultSkill(skill, 0)` once +// for any SkillLineAbility-linked spell when the player doesn't yet have +// the skill. `LearnDefaultSkill` -> `SetSkill` -> `learnSkillRewardedSpells` +// then grants every reward of that skill as PLAYERSPELL_TEMPORARY (which +// lives in m_spells but is NOT written to character_spell). On every +// subsequent login `_LoadSkills` re-fires the cascade for any skill row +// that exists in `character_skills`, silently re-granting Blood Presence +// / Forceful Deflection / Death Coil / etc. -- including for characters +// like Test whose first commit predates the per-purchase revoke list. +// +// Strategy: from the player's panel-purchased spells, derive the set of +// SkillLines we've activated (via SkillLineAbility's SkillLine field). +// Walk those SkillLines' rewards and revoke any spell (active or passive) +// currently in m_spells that isn't in our allowlist (panel chain ranks + +// non-cascade passive children). Persist each revoke into +// `character_paragon_panel_spell_revoked` so the cheaper +// `RevokeBlockedSpellsForPlayer` path handles it on every subsequent +// login. +// +// Why this is safe (unlike the earlier blanket-temporary sweep): +// * Only walks SkillLines that we caused to be activated. Racial +// skills, weapon skills, Defense, etc. live in different SkillLines +// and are never reached. +// * Passives that are pure spell-effect side effects stay in +// `character_paragon_panel_spell_children` and remain allowlisted. +// * Skill-line cascade passives (Forceful Deflection, ...) are not +// allowlisted children anymore; see `PruneSkillLineCascadeChildrenFromDb` +// + `PanelLearnSpellChain`. +void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) +{ + if (!pl) + return; + + bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); + uint32 const lowGuid = pl->GetGUID().GetCounter(); + + PruneSkillLineCascadeChildrenFromDb(pl, lowGuid); + + // Build the allowlist: every chain rank of every panel-purchased spell, + // plus every recorded passive child. + std::unordered_set allowed; + if (QueryResult r = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", + lowGuid)) + { + do + { + uint32 const base = r->Fetch()[0].Get(); + CollectSpellChainIds(base, allowed); + } while (r->NextRow()); + } + if (QueryResult r = CharacterDatabase.Query( + "SELECT child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", + lowGuid)) + { + do + { + allowed.insert(r->Fetch()[0].Get()); + } while (r->NextRow()); + } + + if (allowed.empty()) + return; + + // From the allowlist, derive which SkillLines we've activated. Use + // every SkillLineAbility row for each spell (not only LEARNED_ON_SKILL_*), + // matching `SkillLinesLinkedToSpell` / `_addSpell`'s LearnDefaultSkill path. + std::unordered_set ourSkillLines; + for (uint32 spellId : allowed) + { + SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (auto it = bounds.first; it != bounds.second; ++it) + { + SkillLineAbilityEntry const* sla = it->second; + if (!sla) + continue; + ourSkillLines.insert(sla->SkillLine); + } + } + + if (ourSkillLines.empty()) + return; + + // For each activated SkillLine, walk its rewards and queue revokes for + // active spells that aren't allowlisted but are currently in m_spells. + std::vector toRevoke; + for (uint32 skillLine : ourSkillLines) + { + for (SkillLineAbilityEntry const* ab : GetSkillLineAbilitiesBySkillLine(skillLine)) + { + if (!ab) + continue; + + uint32 const sid = ab->Spell; + if (!sid) + continue; + if (allowed.count(sid)) + continue; + if (!pl->HasSpell(sid)) + continue; // not in m_spells right now + + SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); + if (!info) + continue; + + toRevoke.push_back(sid); + } + } + + if (toRevoke.empty()) + { + if (diag) + LOG_INFO("module", + "[paragon-diag] login sweep: no orphan cascade spells for {} " + "(skillLines scanned={}, allowlist={})", + pl->GetName(), ourSkillLines.size(), allowed.size()); + return; + } + + // Dedup (a spell can show up under multiple SkillLines). + std::sort(toRevoke.begin(), toRevoke.end()); + toRevoke.erase(std::unique(toRevoke.begin(), toRevoke.end()), toRevoke.end()); + + for (uint32 sid : toRevoke) + { + pl->removeSpell(sid, SPEC_MASK_ALL, false); + // parent_spell_id = 0 -> "caught at login by scoped sweep, no + // specific parent". Distinct PK from the (guid, parent, sid) + // rows the commit-time diff inserts. + DbInsertPanelSpellRevoked(lowGuid, 0u, sid); + } + + LOG_INFO("module", + "Paragon panel: scoped sweep revoked {} cascade spells for {} " + "(skillLines scanned={}, allowlist={})", + toRevoke.size(), pl->GetName(), ourSkillLines.size(), allowed.size()); +} + // 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. @@ -340,14 +623,15 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) // 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. +// * Passives that are pure spell-effect side effects (disease auras, +// etc.) are kept; we record them in character_paragon_panel_spell_children +// so reset unlearns them alongside the parent. +// * Passives that are skill-line cascade rewards on the same SkillLine +// as the rank being learned (Forceful Deflection with Blood Strike) +// are revoked like actives — they are not panel children. +// * Active dependents (Death Coil, Death Grip, Blood Presence, ...) 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 @@ -365,6 +649,14 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) std::unordered_set chainIds; CollectSpellChainIds(trackId, chainIds); + bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); + if (diag) + { + LOG_INFO("module", + "[paragon-diag] PanelLearnSpellChain start: player={} lvl={} class={} baseSpellId={} trackId={} chainSize={}", + pl->GetName(), playerLevel, uint32(pl->getClass()), baseSpellId, trackId, chainIds.size()); + } + uint32 cur = trackId; while (cur) { @@ -381,27 +673,73 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) if (!pl->HasSpell(cur)) { std::unordered_set before = SnapshotKnownSpells(pl); + if (diag) + LOG_INFO("module", + "[paragon-diag] pre-learn rank={} spellMapSize={}", + cur, before.size()); + pl->learnSpell(cur, false); std::unordered_set after = SnapshotKnownSpells(pl); + if (diag) + LOG_INFO("module", + "[paragon-diag] post-learn rank={} spellMapSize={} delta={}", + cur, after.size(), + int32(after.size()) - int32(before.size())); + // Diff: classify each new spell that wasn't in the chain we - // asked for. Passives stick (recorded as children); actives - // get revoked. + // asked for. Pure spell-effect passives stick (children); skill- + // line cascade passives and actives get revoked. for (uint32 spellId : after) { if (before.count(spellId)) continue; // already known if (chainIds.count(spellId)) - continue; // a rank we asked for + { + if (diag) + LOG_INFO("module", + "[paragon-diag] +{} (chain rank, kept)", spellId); + continue; + } SpellInfo const* dep = sSpellMgr->GetSpellInfo(spellId); if (!dep) continue; if (dep->IsPassive()) - DbInsertPanelSpellChild(lowGuid, trackId, spellId); + { + if (IsSpellSkillLineCascadeDependent(cur, spellId)) + { + pl->removeSpell(spellId, SPEC_MASK_ALL, false); + DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); + if (diag) + LOG_INFO("module", + "[paragon-diag] +{} (skill-line passive dep, REVOKED, parent={})", + spellId, trackId); + } + else + { + DbInsertPanelSpellChild(lowGuid, trackId, spellId); + if (diag) + LOG_INFO("module", + "[paragon-diag] +{} (passive dep, kept as child of {})", + spellId, trackId); + } + } else + { pl->removeSpell(spellId, SPEC_MASK_ALL, false); + // Persist so we can re-revoke after the next login -- + // AC's _LoadSkills -> learnSkillRewardedSpells will + // re-grant skill-rewarded actives (Blood Presence, + // Death Coil, Death Grip, ...) every time the player + // logs in. + DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); + if (diag) + LOG_INFO("module", + "[paragon-diag] +{} (active dep, REVOKED, parent={})", + spellId, trackId); + } } } @@ -412,6 +750,10 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) } DbInsertPanelSpell(lowGuid, trackId); + + if (diag) + LOG_INFO("module", + "[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId); } void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank) @@ -749,6 +1091,12 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) DbUpsertPanelTalent(lowGuid, tid, r); } + // One scoped sweep after all panel DB rows for this commit exist. Catches + // cascade dependents the per-rank diff missed when anchor SkillLine rows + // used a non-LEARN_* AcquireMethod (fixed in SkillLinesLinkedToSpell, but + // this is cheap insurance for mixed commits / talent side effects). + RevokeUnwantedCascadeSpellsForPlayer(pl); + SaveCurrencyToDb(pl); SendSilenceClose(pl); return true; @@ -916,8 +1264,25 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err) } while (r->NextRow()); } + // Best-effort revoke pass for active dependents the cascade may have + // re-installed since last commit (Blood Presence, Death Coil, ...). + // Without this, a player who upgraded to this build with orphan + // dependents already in their spellbook would still see them after + // Reset Abilities; with this they're swept the moment they reset. + if (QueryResult rev = CharacterDatabase.Query( + "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid)) + { + do + { + uint32 const sid = rev->Fetch()[0].Get(); + if (pl->HasSpell(sid)) + pl->removeSpell(sid, SPEC_MASK_ALL, false); + } while (rev->NextRow()); + } + CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spells WHERE guid = {}", lowGuid); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); + CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (refundAE) { @@ -1040,6 +1405,48 @@ public: LoadCurrencyFromDb(player); PushCurrency(player); PushSnapshot(player); + + // AC's character load sequence runs _LoadSkills (which fires + // learnSkillRewardedSpells) and _LoadSpells before this hook, + // so any active dependents we revoked at panel-commit time + // (Blood Presence / Death Coil / Death Grip / ...) have been + // silently re-granted by the skill cascade. Walk our persisted + // revoke list and remove them again. + // + // `removeSpell` triggers SMSG_REMOVED_SPELL on the client which + // generates "You have unlearned X" CHAT_MSG_SYSTEM toasts. The + // chat frame buffers system messages while the loading screen + // is up and only flushes them after PLAYER_ENTERING_WORLD, so + // a paired OPEN/CLOSE we send here would already be CLOSED by + // the time those buffered toasts reach the filter. We open the + // silence window from the server side as a defensive measure, + // but rely on the client to keep the window open across the + // login flush via its own PA.Silence:Open({}) call in the + // PLAYER_LOGIN handler. The window auto-closes via the addon's + // fail-open timer (PA.Silence.WINDOW_SECONDS). + if (player && player->getClass() == CLASS_PARAGON + && sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) + { + std::vector> emptySpells; + std::vector> emptyTalents; + SendSilenceOpenForCommit(player, emptySpells, emptyTalents); + + // Step 1: re-revoke spells we explicitly recorded last commit + // (cheap, exact: just walks the persisted revoke table). + RevokeBlockedSpellsForPlayer(player); + // Step 2: scoped sweep across SkillLines we activated via panel + // purchases. Catches cascade-granted active spells that the + // commit-time diff missed -- e.g., legacy characters whose + // first commit predates the per-purchase revoke list, or any + // cascade re-fire on relog. Only walks our own SkillLines, so + // racials / weapon skills / Defense rewards are never touched. + RevokeUnwantedCascadeSpellsForPlayer(player); + + // Intentionally NOT calling SendSilenceClose here -- the chat + // frame buffers system messages during the loading screen and + // would flush them after the CLOSE arrives. The addon's + // fail-open timer (8s) closes the window after the flush. + } } void OnPlayerLogout(Player* player) override @@ -1149,6 +1556,21 @@ public: PushSnapshot(player); return; } + + // Combat lockdown: any mutating control message ("C ...") is + // rejected while the player is in combat. The client also + // self-gates the buttons that would emit these (see + // ParagonAdvancement.lua), but the authoritative check lives + // here so a hand-crafted addon message can't bypass it. + // Read-only "Q ..." queries above are allowed in combat so + // the panel can keep its currency / snapshot displays current. + if (!body.empty() && body[0] == 'C' && player->IsInCombat()) + { + SendAddonMessage(player, + "R ERR cannot use Character Advancement while in combat"); + return; + } + if (body.compare(0, 9, "C COMMIT ") == 0) { std::string err; @@ -1250,6 +1672,7 @@ public: { "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, { "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, { "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "hat", HandleHat, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, }; static ChatCommandTable commandTable = @@ -1365,6 +1788,152 @@ public: handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl)); return true; } + + // .paragon hat — diagnostic for Honor Among Thieves on a Paragon. + // Prints HasTalent, HasAura, HasSpell, EquippedItemClass, ProcEvent + // hookup, and re-applies the talent's passive aura if missing. + // + // HAT in 3.3.5: rank ids 51698 (R1) / 51700 (R2) / 51701 (R3). + // Effect 0 is APPLY_AREA_AURA_PARTY -> SPELL_AURA_PROC_TRIGGER_SPELL + // (52916) which targets the rogue (the original caster) when ANY + // party member crits, and 52916 in turn casts 51699 to grant +1 CP. + static bool HandleHat(ChatHandler* handler) + { + Player* pl = handler->GetPlayer(); + if (!pl) + return false; + + constexpr std::array kHatRanks = { 51698u, 51700u, 51701u }; + constexpr uint32 kHatProc = 52916u; + constexpr uint32 kHatTrigger = 51699u; + + handler->PSendSysMessage("|cff00ffff[paragon hat]|r class={} lvl={} group={}", + uint32(pl->getClass()), pl->GetLevel(), + pl->GetGroup() ? "yes" : "no"); + + uint32 ownedRank = 0; + uint32 ownedRankId = 0; + for (uint32 i = 0; i < kHatRanks.size(); ++i) + { + uint32 const id = kHatRanks[i]; + bool hasTalent = pl->HasTalent(id, pl->GetActiveSpec()); + bool hasAura = pl->HasAura(id); + SpellInfo const* info = sSpellMgr->GetSpellInfo(id); + handler->PSendSysMessage( + " R{} ({}): HasTalent={} HasAura={} HasSpell={} info={}", + i + 1, id, + hasTalent ? "Y" : "n", + hasAura ? "Y" : "n", + pl->HasSpell(id) ? "Y" : "n", + info ? "ok" : "MISSING"); + if (hasTalent && !ownedRank) + { + ownedRank = i + 1; + ownedRankId = id; + } + } + + SpellInfo const* procInfo = sSpellMgr->GetSpellInfo(kHatProc); + SpellInfo const* trigInfo = sSpellMgr->GetSpellInfo(kHatTrigger); + handler->PSendSysMessage(" proc52916 info={} trig51699 info={}", + procInfo ? "ok" : "MISSING", + trigInfo ? "ok" : "MISSING"); + + if (!ownedRank) + { + handler->PSendSysMessage("|cffff8000No HAT rank in active spec.|r"); + return true; + } + + SpellInfo const* rank = sSpellMgr->GetSpellInfo(ownedRankId); + if (!rank) + return true; + + // Inspect the talent rank effects so we can see exactly what AC + // thinks should be applied. + for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) + { + SpellEffectInfo const& eff = rank->Effects[i]; + if (eff.Effect == 0) + continue; + handler->PSendSysMessage( + " eff[{}] effect={} aura={} targetA={} trigger={} basePts={}", + i, uint32(eff.Effect), uint32(eff.ApplyAuraName), + uint32(eff.TargetA.GetTarget()), eff.TriggerSpell, eff.BasePoints); + } + + // Force-apply the rank passive aura if it isn't already there. We + // use Aura::TryRefreshStackOrCreate so we don't go through CheckCast + // (which can fail silently for various reasons). + if (!pl->HasAura(ownedRankId)) + { + Aura* a = Aura::TryRefreshStackOrCreate(rank, MAX_EFFECT_MASK, pl, pl); + handler->PSendSysMessage(" force-apply rank aura -> {}", + a ? "OK" : "FAILED"); + } + else + { + handler->PSendSysMessage(" rank aura already on player."); + } + + // Dump the live SpellProcEntry the engine will use when checking + // "should this aura proc". Confirms the ProcFlags fallback from + // SpellInfo, and the SpellPhase / Hit gates. + if (SpellProcEntry const* pe = sSpellMgr->GetSpellProcEntry(ownedRankId)) + { + handler->PSendSysMessage( + " ProcEntry({}): ProcFlags=0x{:X} SpellTypeMask=0x{:X} SpellPhaseMask=0x{:X} HitMask=0x{:X} chance={} cd={}ms", + ownedRankId, + pe->ProcFlags, pe->SpellTypeMask, pe->SpellPhaseMask, + pe->HitMask, pe->Chance, + uint32(pe->Cooldown.count())); + } + else + { + handler->PSendSysMessage( + " ProcEntry({}): NONE (proc system will not fire this aura)", + ownedRankId); + } + + // Walk the player's applied auras and report whether the engine + // currently sees an effect-mask flag turned on for the rank's + // PROC_TRIGGER_SPELL effect. If the aura is up but the flag is + // off, the engine won't proc it. + if (AuraApplication* aa = pl->GetAuraApplication(ownedRankId)) + { + handler->PSendSysMessage( + " AuraApp: effectMask=0x{:X} effectsToApply=0x{:X} flags=0x{:X}", + uint32(aa->GetEffectMask()), + uint32(aa->GetEffectsToApply()), + uint32(aa->GetFlags())); + } + else + { + handler->PSendSysMessage(" AuraApp: NONE on player"); + } + + // Force-fire the trigger chain (52916 -> dummy -> 51699 -> +CP) on + // the player's current target. If this works, the proc machinery + // is at fault. If THIS fails, the trigger spells are at fault. + if (Unit* tgt = pl->GetSelectedUnit()) + { + uint32 const cpBefore = pl->GetComboPoints(tgt); + // Mimic spell_rog_honor_among_thieves::HandleProc: cast 52916 + // FROM tgt -> tgt with the rogue/Paragon as original caster. + tgt->CastSpell(tgt, kHatProc, true, nullptr, nullptr, pl->GetGUID()); + uint32 const cpAfter = pl->GetComboPoints(tgt); + handler->PSendSysMessage( + " force-fire 52916 on {}: CP {} -> {}", + tgt->GetName(), cpBefore, cpAfter); + } + else + { + handler->PSendSysMessage( + " no target selected: pick a unit and run again to force-fire 52916."); + } + + return true; + } }; } // namespace (anonymous) diff --git a/modules/mod-paragon/src/Paragon_SC.cpp b/modules/mod-paragon/src/Paragon_SC.cpp index c58a66d..44570a1 100644 --- a/modules/mod-paragon/src/Paragon_SC.cpp +++ b/modules/mod-paragon/src/Paragon_SC.cpp @@ -5,15 +5,20 @@ * so Paragon can reuse other classes' mechanics in narrowly scoped contexts. */ +#include "Chat.h" +#include "Config.h" +#include "GameTime.h" +#include "Log.h" +#include "ObjectGuid.h" #include "Player.h" #include "ScriptMgr.h" #include "SharedDefines.h" #include "UnitDefines.h" -#include "Config.h" -#include "Log.h" -#include "GameTime.h" -#include "ObjectGuid.h" +#include "WorldPacket.h" +#include "WorldSession.h" +#include +#include #include class Paragon_PlayerScript : public PlayerScript @@ -184,6 +189,45 @@ public: { player->ResyncRunes(MAX_RUNES); st.lastReadyMask = readyMask; + + // Authoritative rune CD pump (PARAA "R RUNES cd0 cd1 ... cd5", + // ms remaining per slot, 0 = ready). The 3.3.5 client engine + // class-gates SMSG_RESYNC_RUNES / SMSG_SPELL_GO RUNE_LIST to DK, + // so the Paragon RuneFrame sim drives the visual entirely off + // COMBAT_LOG_EVENT_UNFILTERED:SPELL_CAST_SUCCESS. The combat log + // arrives ~100–200ms after the server already started the + // cooldown, so the client's local timer trails the server. When + // the user spams a rune spell, the server's slot refreshes + // first, accepts the next cast, but the client UI still shows + // CD remaining → "leak-through" past a greyed icon. Pushing the + // actual remaining ms on every mask transition keeps the + // visual locked to server state. + std::string body = "R RUNES"; + for (uint8 i = 0; i < MAX_RUNES; ++i) + body += " " + std::to_string(player->GetRuneCooldown(i)); + std::string const payload = std::string(kParagonAddonPrefix) + "\t" + body; + WorldPacket runePkt; + ChatHandler::BuildChatPacket(runePkt, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); + player->SendDirectMessage(&runePkt); + } + + // Combo point pump: the 3.3.5 client engine class-gates SMSG_UPDATE_COMBO_POINTS + // to rogue / druid, so the Paragon UI sim never sees CP changes from + // Honor Among Thieves / Mutilate / etc. via either the engine state or + // the client-side combat-log inference (HAT's 51699 trigger fires with a + // null target and doesn't always emit SPELL_CAST_SUCCESS in the log). + // Push the count over PARAA whenever it changes; the addon's combo + // simulator listens for "R CP " and overwrites paragonCP, so the + // ComboFrame on the target frame paints reliably. + int8 const cp = player->GetComboPoints(); + if (cp != st.lastCp) + { + std::string const payload = std::string(kParagonAddonPrefix) + "\t" + + fmt::format("R CP {}", int32(cp)); + WorldPacket data; + ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); + player->SendDirectMessage(&data); + st.lastCp = cp; } if (!sConfigMgr->GetOption("Paragon.Diag.RuneTrace", false)) @@ -214,8 +258,11 @@ private: struct ParagonRuneSyncState { uint8 lastReadyMask{0xFFu}; // sentinel: no prior snapshot + int8 lastCp{-1}; // sentinel: no prior snapshot }; + static constexpr char const* kParagonAddonPrefix = "PARAA"; + static std::unordered_map runeSyncByGuid; };