diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 5a208a1..7f5831c 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -294,6 +294,98 @@ void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revo lowGuid, parentSpellId, revokedSpellId); } +// Drop any panel_spell_revoked rows whose `revoked_spell_id` falls inside +// `chainIds`. Called from `PanelLearnSpellChain` right after the panel +// purchase row is committed so a freshly bought spell can't be unlearned +// on the next login by a stale (pre-purchase) revoke entry. Without this, +// the very first login after the purchase would walk the revoke table, +// hit the ghost row, `removeSpell` the freshly-paid-for ability, and +// then `PushSpellSnapshot` (which deletes panel_spells rows whose spell +// the player no longer has) would erase the purchase from the panel +// record entirely -- losing both the spell and the AE refund hook. +void DbDeletePanelSpellRevokedForChain(uint32 lowGuid, + std::unordered_set const& chainIds) +{ + if (chainIds.empty()) + return; + + std::string in; + in.reserve(chainIds.size() * 8); + bool first = true; + for (uint32 sid : chainIds) + { + if (!first) + in += ","; + in += std::to_string(sid); + first = false; + } + + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_revoked " + "WHERE guid = {} AND revoked_spell_id IN ({})", + lowGuid, in); +} + +// Build the allowlist of spell IDs the player legitimately owns through +// Character Advancement: every chain rank of every spell in panel_spells, +// every rank-spell-id up to the purchased rank of every talent in +// panel_talents, and every recorded panel_spell_children id. Both +// `RevokeUnwantedCascadeSpellsForPlayer` and `RevokeBlockedSpellsForPlayer` +// need exactly this set; without the talent contribution, buying a spell +// after a talent (e.g., DK Death Coil after Scourge Strike) caused the +// post-commit sweep to revoke the talent-granted spell because it didn't +// appear in panel_spells. See ParagonAdvancement_TalentData.lua: many +// "abilities" the player perceives as spells (Scourge Strike id=2216, +// Bladestorm, Starfall, ...) are panel TALENTS that grant a spell rank +// via Player::LearnTalent. +void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set& allowed) +{ + if (!lowGuid) + return; + + if (QueryResult r = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", + lowGuid)) + { + do + { + CollectSpellChainIds(r->Fetch()[0].Get(), allowed); + } while (r->NextRow()); + } + + if (QueryResult r = CharacterDatabase.Query( + "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", + lowGuid)) + { + do + { + Field const* f = r->Fetch(); + uint32 const tid = f[0].Get(); + uint32 const rank = f[1].Get(); + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te || !rank) + continue; + // panel_talents.rank is 1-based ("1 means rank-1 owned"). Allow + // every rank id from 0..rank-1 so a partial-rank purchase still + // protects all lower ranks the player rolled through. + uint32 const cap = std::min(rank, MAX_TALENT_RANK); + for (uint32 i = 0; i < cap; ++i) + if (te->RankID[i]) + allowed.insert(te->RankID[i]); + } 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()); + } +} + // Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the // player still has it. Call sites: // * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells` @@ -301,12 +393,30 @@ void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revo // 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. +// +// Allowlist-aware: a revoked row whose `revoked_spell_id` is now part of +// a panel_spells chain (or recorded as a passive child) is *stale* -- it +// was inserted before the player legitimately purchased that spell, so +// re-running `removeSpell` on it would zap a paid-for ability. Such rows +// are skipped and dropped from the table so they can't fire again. This +// is the self-heal path for the pre-fix bug where buying a spell that +// had previously been caught by the login sweep left the (0, sid) ghost +// row in place; on every subsequent login that ghost would unlearn the +// freshly bought spell, and `PushSpellSnapshot`'s !HasSpell branch would +// then delete the panel_spells row, vanishing the purchase entirely. void RevokeBlockedSpellsForPlayer(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); + + // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents + // + panel_spell_children. Talents matter here because many Wrath + // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted. + std::unordered_set allowed; + BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); + QueryResult r = CharacterDatabase.Query( "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); @@ -314,9 +424,15 @@ void RevokeBlockedSpellsForPlayer(Player* pl) return; uint32 removed = 0; + std::vector stale; // allowlisted -> drop the row do { uint32 const sid = r->Fetch()[0].Get(); + if (allowed.count(sid)) + { + stale.push_back(sid); + continue; + } if (pl->HasSpell(sid)) { pl->removeSpell(sid, SPEC_MASK_ALL, false); @@ -324,6 +440,34 @@ void RevokeBlockedSpellsForPlayer(Player* pl) } } while (r->NextRow()); + if (!stale.empty()) + { + // Build IN-list. `stale` is bounded by the player's revoked rows. + std::sort(stale.begin(), stale.end()); + stale.erase(std::unique(stale.begin(), stale.end()), stale.end()); + + std::string in; + in.reserve(stale.size() * 8); + bool first = true; + for (uint32 sid : stale) + { + if (!first) + in += ","; + in += std::to_string(sid); + first = false; + } + + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_revoked " + "WHERE guid = {} AND revoked_spell_id IN ({})", + lowGuid, in); + + LOG_INFO("module", + "Paragon panel: dropped {} stale revoke rows for {} " + "(spell now owned via panel purchase)", + stale.size(), pl->GetName()); + } + if (removed) LOG_INFO("module", "Paragon panel: re-revoked {} skill-cascade dependents for {} on login", @@ -459,28 +603,13 @@ void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) PruneSkillLineCascadeChildrenFromDb(pl, lowGuid); - // Build the allowlist: every chain rank of every panel-purchased spell, - // plus every recorded passive child. + // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents + // + panel_spell_children. Talents matter here because many Wrath + // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted: + // a Death Coil purchase otherwise activates the DK skill line and + // sweeps Scourge Strike (55090) out from under the talent. 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()); - } + BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); if (allowed.empty()) return; @@ -768,6 +897,13 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) DbInsertPanelSpell(lowGuid, trackId); + // Clear any stale revoke rows that targeted a rank in this chain. A + // prior login sweep (before the purchase) or an earlier commit-time + // diff (e.g., this chain was revoked as a cascade dependent of a + // *different* purchase the user has since reset/refunded) may have + // left rows that would otherwise re-fire `removeSpell` next login. + DbDeletePanelSpellRevokedForChain(lowGuid, chainIds); + if (diag) LOG_INFO("module", "[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId);