diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b96861..1ac3a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to TotemBar are documented here. +## v0.4.1 — 2026-08-31 + +### Fixed +- A totem destroyed by the enemy inside the ~0.4s window between a Totemic Recall wipe and its + refusal being attributed now stays dead instead of having its countdown resurrected by the + refused-recall restore (`core/cast.lua`). + ## v0.4.0 — 2026-08-30 ### Added diff --git a/README.md b/README.md index 237b114..56dc4ea 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,17 @@ server data, not guesswork. No dependencies; adopts the TotemBar in action: round slots with duration and pulse rings, totems placed in the world -## What's new — v0.4.0 · 2026-08-30 +## What's new — v0.4.1 · 2026-08-31 -- **Destroyed totems are detected.** A totem killed before its timer ran out used to keep - counting down; now the death line (and, with SuperWoW/nampower, the cast and death events) - clears its countdown, ring, pulse and range tint. Live retest still pending. -- **No phantom countdown on a cast refused for mana.** The Clearcasting exemption never - applied to totems and had silently disabled the mana gate. -- **A refused Totemic Recall keeps your timers.** Only the Recall that actually went out clears - them; a press the server rejects restores what it had cleared. +- **A totem destroyed inside the recall attribution window stays dead.** A totem the enemy + destroyed in the ~0.4s window between a Totemic Recall wipe and its refusal being attributed + no longer has its countdown resurrected by the refused-recall restore. **Version history** — details in [CHANGELOG.md](CHANGELOG.md): +- **v0.4.1** (2026-08-31) — a totem destroyed by the enemy inside the recall attribution + window now stays dead instead of coming back when a refused Totemic Recall restores its + timers. - **v0.4.0** (2026-08-30) — a totem destroyed before its timer ran out is now detected and cleared (countdown, ring, pulse and range tint together) instead of counting down as if it still stood; a totem cast refused for lack of mana diff --git a/TotemBar.toc b/TotemBar.toc index 00dd32c..c158cd5 100644 --- a/TotemBar.toc +++ b/TotemBar.toc @@ -2,7 +2,7 @@ ## Title: TotemBar ## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro. ## Author: ShempError -## Version: 0.4.0 +## Version: 0.4.1 ## SavedVariables: TotemBarDB core\totemdata.lua diff --git a/core/cast.lua b/core/cast.lua index 28022ca..bdc5e60 100644 --- a/core/cast.lua +++ b/core/cast.lua @@ -1307,6 +1307,21 @@ if CreateFrame then TotemBar.TOTEM_ELEMENTS, name, owner, playerName) if element then TotemBar.evictDestroyedTotem(element) + return + end + -- Recall-attribution race (see "Recall-attribution vs. + -- destruction race" above): activeTotems has nothing for + -- this element because a recall wipe already cleared it and + -- is still awaiting attribution -- re-run the same owner/ + -- name match against that pending snapshot so a later + -- restoreRecalledTotems doesn't bring the dead totem back. + local snapshotRecs = TotemBar.pendingRecallSnapshotRecs() + if snapshotRecs then + local snapElement = TotemBar.elementForOwnedTotemName(snapshotRecs, + TotemBar.TOTEM_ELEMENTS, name, owner, playerName) + if snapElement then + TotemBar.evictFromRecallSnapshot(snapElement) + end end return end @@ -1315,22 +1330,20 @@ if CreateFrame then local element = TotemBar.elementForDiesLineCandidate(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, diesName) if element then - local rec = TotemBar.activeTotems[element] - local exists, guid, health, deadOrGhost = nil, nil, nil, nil - if type(UnitExists) == "function" then - exists, guid = UnitExists(rec.guid) - end - if exists then - if type(UnitHealth) == "function" then - health = UnitHealth(rec.guid) - end - if type(UnitIsDeadOrGhost) == "function" then - deadOrGhost = UnitIsDeadOrGhost(rec.guid) - end - end - if TotemBar.totemDestroyed(exists, health, deadOrGhost) then + if TotemBar.verifyDiesLineDestruction(TotemBar.activeTotems[element]) then TotemBar.evictDestroyedTotem(element) end + return + end + -- Same recall-attribution race as the owned-name branch + -- above, for the generic " dies." fallback. + local snapshotRecs = TotemBar.pendingRecallSnapshotRecs() + if snapshotRecs then + local snapElement = TotemBar.elementForDiesLineCandidate(snapshotRecs, + TotemBar.TOTEM_ELEMENTS, diesName) + if snapElement and TotemBar.verifyDiesLineDestruction(snapshotRecs[snapElement]) then + TotemBar.evictFromRecallSnapshot(snapElement) + end end end return @@ -1346,6 +1359,7 @@ if CreateFrame then -- events cannot cause a silent miss. local element = TotemBar.elementForGuid(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, arg1, TotemBar.guidsEqual) + local unitName, ownerName, playerName = nil, nil, nil if not element and arg1 and type(UnitName) == "function" then -- GUID-free fallback (2026-08-20 event tap: UnitName/owner -- DO resolve at the exact death instant on this client, for @@ -1353,9 +1367,9 @@ if CreateFrame then -- see the CHAT_MSG_COMBAT_FRIENDLY_DEATH/UNIT_DIED fixture -- pair) -- covers a totem whose GUID was never latched by -- either latch path at all. - local unitName = UnitName(arg1) - local ownerName = UnitName(arg1 .. "owner") - local playerName = UnitName("player") + unitName = UnitName(arg1) + ownerName = UnitName(arg1 .. "owner") + playerName = UnitName("player") element = TotemBar.elementForOwnedTotemName(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, unitName, ownerName, playerName) end @@ -1368,6 +1382,29 @@ if CreateFrame then -- moment later" bug for the one case it exists to short- -- circuit fastest. TotemBar.evictDestroyedTotem(element) + return + end + -- Recall-attribution race (see "Recall-attribution vs. + -- destruction race" above): re-run both matches against the + -- pending recall snapshot, reusing whatever UnitName lookups + -- already ran above instead of re-querying the (by-now-gone) + -- unit a second time. + local snapshotRecs = TotemBar.pendingRecallSnapshotRecs() + if snapshotRecs then + local snapElement = TotemBar.elementForGuid(snapshotRecs, + TotemBar.TOTEM_ELEMENTS, arg1, TotemBar.guidsEqual) + if not snapElement and arg1 and type(UnitName) == "function" then + if not unitName then + unitName = UnitName(arg1) + ownerName = UnitName(arg1 .. "owner") + playerName = UnitName("player") + end + snapElement = TotemBar.elementForOwnedTotemName(snapshotRecs, + TotemBar.TOTEM_ELEMENTS, unitName, ownerName, playerName) + end + if snapElement then + TotemBar.evictFromRecallSnapshot(snapElement) + end end return end @@ -1514,6 +1551,80 @@ function TotemBar.restoreRecalledTotems() return restored end +-- ===== Recall-attribution vs. destruction race ===== +-- +-- recallWipeActiveTotems above clears TotemBar.activeTotems immediately and +-- keeps the pre-wipe records in TotemBar.lastRecallWipe.recs until the recall +-- is attributed. If a totem that was JUST wiped dies server-side inside that +-- same CAST_FAIL_WINDOW, the destruction handlers below (CHAT_MSG_COMBAT_ +-- FRIENDLY_DEATH / UNIT_DIED) look it up in TotemBar.activeTotems -- which is +-- already empty for it -- so the eviction is a silent no-op and sets no +-- tombstone. If the recall is THEN attributed as refused, restoreRecalledTotems +-- puts the snapshot record back and the dead totem's countdown resumes. These +-- two helpers let the destruction handlers reach into the still-pending +-- snapshot with the exact same name/guid matches they already run against +-- activeTotems, so a totem that died mid-attribution stays dead either way. + +-- The pending recall snapshot's records table, or nil if there is no recall +-- wipe awaiting attribution right now (none at all, or it is already older +-- than CAST_FAIL_WINDOW -- same staleness rule restoreRecalledTotems uses; +-- an old snapshot means any subsequent event is unrelated to it). +function TotemBar.pendingRecallSnapshotRecs() + local wiped = TotemBar.lastRecallWipe + if not wiped or not wiped.at or not wiped.recs then + return nil + end + if (GetTime() - wiped.at) > TotemBar.CAST_FAIL_WINDOW then + return nil + end + return wiped.recs +end + +-- Evicts `element` from the pending recall snapshot (see above) instead of +-- from TotemBar.activeTotems -- the wipe already cleared that table, so +-- TotemBar.evictDestroyedTotem would no-op here. Removes the record from +-- TotemBar.lastRecallWipe.recs so a later restoreRecalledTotems leaves this +-- element alone, and tombstones it exactly like evictDestroyedTotem does, so +-- pfUI's libtotem can't resurrect the display for it either. +function TotemBar.evictFromRecallSnapshot(element) + local wiped = TotemBar.lastRecallWipe + if not wiped or not wiped.recs then + return + end + local rec = wiped.recs[element] + if not rec then + return + end + if rec.start and rec.duration then + TotemBar.destroyedTombstone[element] = rec.start + rec.duration + end + wiped.recs[element] = nil +end + +-- Pure-ish (queries the live unit): runs the SAME "already exists, and either +-- zero health or dead-or-ghost" verification the CHAT_MSG_COMBAT_FRIENDLY_ +-- DEATH generic " dies." fallback used inline before this was pulled +-- out -- shared now so the recall-snapshot branch below runs the identical +-- check instead of a second hand-copied one. +function TotemBar.verifyDiesLineDestruction(rec) + if not rec then + return false + end + local exists, guid, health, deadOrGhost = nil, nil, nil, nil + if type(UnitExists) == "function" then + exists, guid = UnitExists(rec.guid) + end + if exists then + if type(UnitHealth) == "function" then + health = UnitHealth(rec.guid) + end + if type(UnitIsDeadOrGhost) == "function" then + deadOrGhost = UnitIsDeadOrGhost(rec.guid) + end + end + return TotemBar.totemDestroyed(exists, health, deadOrGhost) +end + -- Is at least one totem currently out? Used to avoid wasting Totemic Recall's -- own 6-second cooldown on a no-op cast: recalling with nothing out still puts -- Recall on cooldown, so a fresh set placed right after can't be recalled for