mod-paragon: fix panel cascade sweep revoking talent-granted spells
RevokeUnwantedCascadeSpellsForPlayer and RevokeBlockedSpellsForPlayer built their allowlist only from character_paragon_panel_spells and panel_spell_children. Many Character Advancement "abilities" (e.g. Scourge Strike) are panel talents stored in character_paragon_panel_talents, so learning Death Coil afterward activated DK skill lines and the sweep removed those spells as false orphans. Add BuildPanelOwnedSpellsAllowlist to union spell chains, talent rank spell IDs up to the purchased rank, and passive children. Also keep the prior fixes: clear stale panel_spell_revoked rows on purchase and skip+delete revoke entries that now match the allowlist on login. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -294,6 +294,98 @@ void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revo
|
|||||||
lowGuid, parentSpellId, revokedSpellId);
|
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<uint32> 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<uint32>& 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<uint32>(), 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>();
|
||||||
|
uint32 const rank = f[1].Get<uint32>();
|
||||||
|
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<uint32>(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<uint32>());
|
||||||
|
} while (r->NextRow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the
|
// Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the
|
||||||
// player still has it. Call sites:
|
// player still has it. Call sites:
|
||||||
// * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells`
|
// * `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.
|
// Death Coil / Death Grip / etc. before any of our hooks fired.
|
||||||
// * `HandleParagonResetAbilities` is NOT a caller; reset clears the
|
// * `HandleParagonResetAbilities` is NOT a caller; reset clears the
|
||||||
// table outright so the revoke list starts fresh on next purchase.
|
// 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)
|
void RevokeBlockedSpellsForPlayer(Player* pl)
|
||||||
{
|
{
|
||||||
if (!pl)
|
if (!pl)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
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<uint32> allowed;
|
||||||
|
BuildPanelOwnedSpellsAllowlist(lowGuid, allowed);
|
||||||
|
|
||||||
QueryResult r = CharacterDatabase.Query(
|
QueryResult r = CharacterDatabase.Query(
|
||||||
"SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}",
|
"SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}",
|
||||||
lowGuid);
|
lowGuid);
|
||||||
@@ -314,9 +424,15 @@ void RevokeBlockedSpellsForPlayer(Player* pl)
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
uint32 removed = 0;
|
uint32 removed = 0;
|
||||||
|
std::vector<uint32> stale; // allowlisted -> drop the row
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
uint32 const sid = r->Fetch()[0].Get<uint32>();
|
uint32 const sid = r->Fetch()[0].Get<uint32>();
|
||||||
|
if (allowed.count(sid))
|
||||||
|
{
|
||||||
|
stale.push_back(sid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (pl->HasSpell(sid))
|
if (pl->HasSpell(sid))
|
||||||
{
|
{
|
||||||
pl->removeSpell(sid, SPEC_MASK_ALL, false);
|
pl->removeSpell(sid, SPEC_MASK_ALL, false);
|
||||||
@@ -324,6 +440,34 @@ void RevokeBlockedSpellsForPlayer(Player* pl)
|
|||||||
}
|
}
|
||||||
} while (r->NextRow());
|
} 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)
|
if (removed)
|
||||||
LOG_INFO("module",
|
LOG_INFO("module",
|
||||||
"Paragon panel: re-revoked {} skill-cascade dependents for {} on login",
|
"Paragon panel: re-revoked {} skill-cascade dependents for {} on login",
|
||||||
@@ -459,28 +603,13 @@ void RevokeUnwantedCascadeSpellsForPlayer(Player* pl)
|
|||||||
|
|
||||||
PruneSkillLineCascadeChildrenFromDb(pl, lowGuid);
|
PruneSkillLineCascadeChildrenFromDb(pl, lowGuid);
|
||||||
|
|
||||||
// Build the allowlist: every chain rank of every panel-purchased spell,
|
// Allowlist: chain ranks of panel_spells + rank IDs of panel_talents
|
||||||
// plus every recorded passive child.
|
// + 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<uint32> allowed;
|
std::unordered_set<uint32> allowed;
|
||||||
if (QueryResult r = CharacterDatabase.Query(
|
BuildPanelOwnedSpellsAllowlist(lowGuid, allowed);
|
||||||
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}",
|
|
||||||
lowGuid))
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
uint32 const base = r->Fetch()[0].Get<uint32>();
|
|
||||||
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<uint32>());
|
|
||||||
} while (r->NextRow());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowed.empty())
|
if (allowed.empty())
|
||||||
return;
|
return;
|
||||||
@@ -768,6 +897,13 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
|
|||||||
|
|
||||||
DbInsertPanelSpell(lowGuid, trackId);
|
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)
|
if (diag)
|
||||||
LOG_INFO("module",
|
LOG_INFO("module",
|
||||||
"[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId);
|
"[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId);
|
||||||
|
|||||||
Reference in New Issue
Block a user