Vampify 0.3.0
A TurtleWoW 1.12.1 addon that shows the healing returned by the Vampirism item stat. The game emits no event for that healing, so the addon computes it from the player's own outgoing damage using a formula measured in-game, and shows it live on a movable bar with a per-ability breakdown, an overheal split, automatic source detection from equipped gear, and optional scrolling combat text.
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
-- Vampify -- SavedVariables schema, defaults merge, and migration.
|
||||
--
|
||||
-- Migration policy: step FORWARD from a known older version; on an unknown or newer version,
|
||||
-- reset wholesale. A partial migration from a schema we do not know produces a config that looks
|
||||
-- valid and is not, which is worse than losing a frame position.
|
||||
|
||||
VampifyConfig = {}
|
||||
local C = VampifyConfig
|
||||
|
||||
C.DB_VERSION = 4
|
||||
|
||||
-- v1 put the display at CENTER/-150. Confirmed in-game 2026-08-10: on a pfUI layout that is right
|
||||
-- on top of the action bars, and at strata MEDIUM/level 1 the frame loses every overlap -- it
|
||||
-- reported shown, visible, alpha 1, and could not be found on screen.
|
||||
C.V1_POS = { point = "CENTER", x = 0, y = -150 }
|
||||
|
||||
C.DEFAULTS = {
|
||||
dbVersion = 3,
|
||||
pos = { point = "CENTER", x = 0, y = 200 },
|
||||
locked = false,
|
||||
minimap = true,
|
||||
mmAngle = 200, -- degrees around the minimap
|
||||
|
||||
-- v3: our own scrolling combat text, alongside (not instead of) Blizzard's. "showFct" (v2 and
|
||||
-- earlier) is gone -- superseded by sct.enabled + sct.mode, see the v2->v3 step below. Two
|
||||
-- overlapping on/off switches for the same feature is exactly the kind of SavedVariables bloat
|
||||
-- the project rules ask to avoid.
|
||||
sct = {
|
||||
enabled = false,
|
||||
mode = "own", -- "own" (this addon's SCT, free-floating anchor) | "bar" (this
|
||||
-- addon's SCT, anchored to the display bar, gui/sct.lua's
|
||||
-- reanchor()) | "blizzard" (gui/fct.lua). New enum value, no
|
||||
-- dbVersion bump: an existing "own"/"blizzard" profile is still
|
||||
-- valid, fillDefaults never touches an explicitly stored mode.
|
||||
pos = { point = "CENTER", x = -180, y = 0 },
|
||||
color = { r = 0.4, g = 0.9, b = 0.4 },
|
||||
duration = 1.5,
|
||||
-- Both slider-driven. No dbVersion bump is needed to add them: the defaults merge fills
|
||||
-- missing keys recursively, so an existing v3 profile simply gains them at their default.
|
||||
fontSize = 16, -- 8..48; the old 8..28 ceiling was reached in practice
|
||||
rise = 40, -- 20..320 px travelled per number; also widens the anti-overlap spread
|
||||
-- One number per CAST rather than per hit: an AoE on five targets shows its summed return
|
||||
-- once instead of five times. Display only -- the model still runs per hit, so totals are
|
||||
-- identical either way. On by default because it is what was asked for.
|
||||
coalesce = true,
|
||||
-- Text effect. Green numbers over grass are barely readable without one; an outline is
|
||||
-- what separates the glyph from whatever is behind it. "" | "OUTLINE" | "THICKOUTLINE".
|
||||
outline = "THICKOUTLINE",
|
||||
shadow = true,
|
||||
},
|
||||
-- Whether the display is wanted on screen. A stored PREFERENCE, not a reading of the frame:
|
||||
-- the frame's own IsShown() was the single definition of "is it on" until it turned out that
|
||||
-- recompute() (which runs on every loading screen and every committed gear scan) overwrites it,
|
||||
-- so a hide never survived the next portal. No dbVersion bump needed -- the defaults merge fills
|
||||
-- it in recursively, and `true` is what every existing profile was effectively running.
|
||||
shown = true,
|
||||
showOverheal = true,
|
||||
|
||||
-- Dev-only per-hit debug export (core/perhit.lua), toggled by /vf perhit on|off and the
|
||||
-- "Per-hit debug export" checkbox in gui/options.lua. DEFAULT ON: the module itself still
|
||||
-- defaults its in-memory state to disabled (core/perhit.lua's own comment), but the developer
|
||||
-- explicitly overrode that for THIS addon -- it is meant to run continuously like a background
|
||||
-- capture addon, not be armed by hand each session (follow-up change request, 2026-08-22). No dbVersion bump
|
||||
-- needed: the defaults merge below fills it in recursively for an existing profile, same as
|
||||
-- shown/showOverheal above -- and `true` for a MISSING key is exactly what a brand new
|
||||
-- default-on preference should read as (fillDefaults only ever fills a true nil, never a
|
||||
-- stored `false`, so a developer who explicitly turned it off keeps it off across reloads).
|
||||
perhitEnabled = true,
|
||||
}
|
||||
|
||||
C.CHAR_DEFAULTS = {
|
||||
dbVersion = 2,
|
||||
enabled = true,
|
||||
sessionHeal = 0,
|
||||
manual = {}, -- manual source overrides, /vf source add <percent>
|
||||
-- Runtime-extendable twin of VampifyConst.NO_TRIGGER, /vf exclude add|remove|list. Damage
|
||||
-- shields have no structural signal (see const.lua) -- a new one is only ever found once it
|
||||
-- shows up wrongly in the breakdown, so this list has to be growable without a client restart.
|
||||
-- Unioned with the hardcoded table at the check site (VampifyConst.triggersVampirism), never
|
||||
-- replacing it. No dbVersion bump needed: the defaults merge below fills it in recursively.
|
||||
noTrigger = {},
|
||||
|
||||
-- Persisted per-spell breakdown, two sets side by side (spec 2026-08-10 sec 3). Same shape as
|
||||
-- VampifyAggregate's in-memory spHeal/spDmg/spOver (and their lifetime twins): a plain spellId
|
||||
-- -> number table each. core/commands.lua points the aggregate's own tables AT these after
|
||||
-- login/reload, so a recorded hit is a SavedVariables write with no extra save step and no
|
||||
-- extra allocation -- see A.new's comment in core/aggregate.lua.
|
||||
-- session -- survives /reload and loading screens, cleared on a genuine login
|
||||
-- lifetime -- never cleared automatically; only /vf reset lifetime|both touches it
|
||||
-- No dbVersion bump needed: the defaults merge below fills both in recursively for an existing
|
||||
-- character db that predates this feature.
|
||||
--
|
||||
-- `hist` is the DAMAGE HISTOGRAM (core/histogram.lua) for the same two scopes: hit count per
|
||||
-- exact damage value, normal and AoE hits kept apart, with everything above the cap bundled
|
||||
-- into a count and a sum. It is what makes "what would one more source be worth" answerable
|
||||
-- exactly -- the floor makes healing non-linear in damage, so no total or average can answer
|
||||
-- it (see that file's header). Wired by reference the same way the per-spell tables are, so a
|
||||
-- recorded hit is already a SavedVariables write.
|
||||
--
|
||||
-- Bounded on purpose (VampifyHistogram.CAP): at most ~1000 exact keys per side, so a long
|
||||
-- session cannot grow this without limit the way a per-GUID table would. No dbVersion bump
|
||||
-- needed -- the defaults merge below fills it in recursively for a character db saved before
|
||||
-- this existed, including a half-filled one.
|
||||
-- `hits` is the per-spell HIT COUNT twin of heal/damage/overheal above, same key, same
|
||||
-- session/lifetime split, filled by the same VampifyAggregate.recordSpell call. No dbVersion
|
||||
-- bump needed here either: the defaults merge below fills it in as an empty table for a
|
||||
-- character db that predates the counter, so an existing profile's heal/damage/overheal HISTORY
|
||||
-- is kept while its hit counts start over at 0 -- there is nothing to backfill them from.
|
||||
-- healAoe/damageAoe/hitsAoe/splitSeen (2026-08-24, UI-redesign strand A; REDEFINED same day --
|
||||
-- see C.migrate's v<4 step and core/aggregate.lua's A.recordSpell for the full history): the
|
||||
-- ST/AoE split's persisted twin of heal/damage/hits above. Deliberately a SEPARATE, ADDITIVE
|
||||
-- set of tables rather than a reshape of heal/damage/hits -- those three keep their existing
|
||||
-- meaning (the GRAND total per spell) untouched by either version of this feature. The defaults
|
||||
-- merge below fills these four in as empty tables for a character db that predates the split
|
||||
-- entirely; core/aggregate.lua's A.spellSplit reads a spell with nothing in them as "ST equals
|
||||
-- the whole existing total, split unknown" (hasSplit=false) rather than guessing. See
|
||||
-- core/aggregate.lua's A.recordSpell and A.spellSplit for the read/write halves of this contract.
|
||||
--
|
||||
-- A VERSION BUMP WAS NEEDED after all, unlike most fields in this table: the split's DEFINITION
|
||||
-- changed the same day it shipped (deriveAoE-classification -> current-target-identity), so any
|
||||
-- data already collected under the first definition has to be discarded rather than silently
|
||||
-- reinterpreted under the second -- C.migrate's v<4 step does exactly that, leaving heal/damage/
|
||||
-- overheal/hits (the grand totals) untouched.
|
||||
session = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||
lifetime = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||
}
|
||||
|
||||
local function fillDefaults(db, defaults)
|
||||
for k, v in pairs(defaults) do
|
||||
if type(v) == "table" then
|
||||
if type(db[k]) ~= "table" then db[k] = {} end
|
||||
fillDefaults(db[k], v)
|
||||
elseif db[k] == nil then -- nil, NOT falsy: a stored `false` must survive
|
||||
db[k] = v
|
||||
end
|
||||
end
|
||||
return db
|
||||
end
|
||||
|
||||
local function copy(t)
|
||||
local out = {}
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == "table" then out[k] = copy(v) else out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function C.migrate(db, defaults, currentVersion)
|
||||
if type(db) ~= "table" then return copy(defaults) end
|
||||
local v = db.dbVersion
|
||||
if type(v) ~= "number" or v > currentVersion then
|
||||
return copy(defaults) -- unknown or from the future: reset, do not guess
|
||||
end
|
||||
-- Known older versions step forward here.
|
||||
if v < 2 then
|
||||
-- Move the display off the action bars -- but ONLY for users who never dragged it. A
|
||||
-- position the user chose is theirs; silently relocating it would be worse than the bug.
|
||||
if db.pos and db.pos.point == C.V1_POS.point
|
||||
and db.pos.x == C.V1_POS.x and db.pos.y == C.V1_POS.y then
|
||||
db.pos = nil -- fillDefaults below restores the v2 default
|
||||
end
|
||||
end
|
||||
if v < 3 then
|
||||
-- showFct only ever meant "show Blizzard's combat text". Carry an explicitly-set value
|
||||
-- into the new sct.enabled switch, and into sct.mode = "blizzard" so a user who had it ON
|
||||
-- keeps seeing exactly what they had -- our own SCT (mode "own") is a different visual and
|
||||
-- must not silently replace what they were already looking at. A false/never-set showFct
|
||||
-- needs no mode opinion; the "own" default from C.DEFAULTS is fine for a switch that was
|
||||
-- off anyway. showFct itself is then dropped -- see the comment on C.DEFAULTS.sct.
|
||||
db.sct = db.sct or {}
|
||||
if db.sct.enabled == nil and db.showFct ~= nil then
|
||||
db.sct.enabled = db.showFct
|
||||
if db.showFct == true then db.sct.mode = "blizzard" end
|
||||
end
|
||||
db.showFct = nil
|
||||
end
|
||||
if v < 4 then
|
||||
-- ST/AoE split REDEFINED (2026-08-24, the same day the split shipped): from "was this hit
|
||||
-- AoE-CLASSIFIED (deriveAoE, area-damage burst detection)" to "did this hit land on
|
||||
-- something OTHER than the player's current target" -- a completely different question (see
|
||||
-- core/aggregate.lua's A.recordSpell for the full history). Any healAoe/damageAoe/hitsAoe/
|
||||
-- splitSeen data already collected under the SUPERSEDED definition would silently mix two
|
||||
-- incompatible meanings with data collected under the new one if left in place -- worse than
|
||||
-- losing it, so it is discarded ONCE, cleanly, here, exactly like a v<2/v<3 step above steps
|
||||
-- an old schema forward rather than half-migrating it.
|
||||
--
|
||||
-- GRAND TOTALS ARE NOT TOUCHED: db.session.heal/damage/overheal/hits and their lifetime
|
||||
-- twins keep every number a player has already earned -- only the split's OWN bookkeeping
|
||||
-- (which of that total was ST vs AoE) resets to "unknown" (A.spellSplit's hasSplit reads
|
||||
-- false again for every spell until a fresh, correctly-classified hit lands). Guarded on
|
||||
-- db.session/db.lifetime existing at all: this migrate() function also runs on VampifyDB
|
||||
-- (global settings), which has neither field, and must not error there.
|
||||
if db.session then
|
||||
db.session.healAoe, db.session.damageAoe, db.session.hitsAoe, db.session.splitSeen =
|
||||
{}, {}, {}, {}
|
||||
end
|
||||
if db.lifetime then
|
||||
db.lifetime.healAoe, db.lifetime.damageAoe, db.lifetime.hitsAoe, db.lifetime.splitSeen =
|
||||
{}, {}, {}, {}
|
||||
end
|
||||
end
|
||||
|
||||
db.dbVersion = currentVersion
|
||||
return fillDefaults(db, defaults)
|
||||
end
|
||||
|
||||
if CreateFrame then
|
||||
local done = false
|
||||
local function load_()
|
||||
if done then return end
|
||||
done = true
|
||||
VampifyDB = C.migrate(VampifyDB, C.DEFAULTS, C.DB_VERSION)
|
||||
VampifyCharDB = C.migrate(VampifyCharDB, C.CHAR_DEFAULTS, C.DB_VERSION)
|
||||
end
|
||||
|
||||
local f = CreateFrame("Frame", "VampifyConfigFrame")
|
||||
-- ADDON_LOADED is the earliest event at which the SavedVariables tables exist, and it fires on
|
||||
-- every load path -- migrating any later would leave the addon reading an unmigrated table in
|
||||
-- the meantime, invisible until the schema changes and then corrupting.
|
||||
-- PLAYER_ENTERING_WORLD is kept as a belt-and-braces second chance; `done` makes it idempotent.
|
||||
-- (An earlier version of this comment claimed PLAYER_LOGIN does not fire on /reload. It does --
|
||||
-- see core/commands.lua's wireSession for the evidence. Nothing here depended on the claim, but
|
||||
-- it propagated from here into code that did.)
|
||||
f:RegisterEvent("ADDON_LOADED")
|
||||
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
f:SetScript("OnEvent", function()
|
||||
if event == "ADDON_LOADED" and arg1 ~= "Vampify" then return end
|
||||
load_()
|
||||
end)
|
||||
end
|
||||
|
||||
function C.get() return VampifyDB end
|
||||
function C.getChar() return VampifyCharDB end
|
||||
Reference in New Issue
Block a user