Files
Vampify/core/histogram.lua
T
ShempError b3a282aeb5 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.
2026-08-25 18:05:45 +02:00

269 lines
15 KiB
Lua

-- Vampify -- the distribution of triggering hits, and what an upgrade would be worth on it.
-- Pure Lua, no WoW API, offline-tested.
--
-- WHY A DISTRIBUTION AND NOT AN AGGREGATE. Every Vampirism source heals max(1, trunc(pct*D/100))
-- independently (core/model.lua), so healing is NOT linear in damage: while pct*D/100 < 1 -- that
-- is, while D < 100/pct -- a source pays its floor of 1 whatever its percentage is. Two runs with
-- identical total damage and identical hit counts therefore return different healing, and would
-- answer "what is one more source worth" differently, purely because their hits were sized
-- differently. No total, no average and no hit count can recover that. The hit-size distribution
-- can, and exactly.
--
-- What that buys the player: below the threshold, EVERY source pays 1, so many small percentages
-- beat few large ones; above it, they are near enough interchangeable (and truncation tips the
-- balance slightly the other way -- see G.compare). Which regime a given player is actually in is
-- invisible from any number the addon showed before this, and it is exactly the question "should I
-- take the +1% enchant or the bigger item" turns on.
--
-- ALLOCATION BUDGET PER RECORDED HIT IS ZERO, like core/aggregate.lua's: AoE grinding produces
-- 30-60 damage events per second, and G.add does one table-index increment and nothing else.
VampifyHistogram = {}
local G = VampifyHistogram
-- Exact bucketing up to and including this damage value; everything above is bundled into a count
-- and a sum. The bound exists because the key space is the damage space -- unbounded in principle,
-- and a table keyed by it would grow for the whole session.
--
-- WHY 1000. Exactness only MATTERS below the floor threshold 100/pct_min: above it every source is
-- paid its percentage, the non-linearity is gone, and a count plus a sum reproduce the healing to
-- within the truncated remainder. The smallest Vampirism source that exists is 1% (the bracer and
-- boot enchants, VampifyConst.SPELL_IDS), so the highest threshold any real source can have is
-- 100/1 = 100 for a normal hit, and 100/(1*0.7) ~= 143 for an AoE one (the damping is applied to
-- the damage, so it moves the threshold up with it). 1000 is seven times that -- the entire
-- floor-sensitive range is counted exactly, with room for a hypothetical source well under 1%, and
-- the exact table still cannot exceed 999 keys per side.
--
-- Above the cap the bundle is represented by its MEAN hit, weighted by the count. That is an
-- approximation, and the size of its error is worth being explicit about: truncation loses between
-- 0 and 1 HP per source per hit, so the bundle's healing can be off by up to (sources x overflow
-- hits) HP in total -- but it is off in the SAME way for the baseline and for every hypothetical
-- compared against it, so the DELTAS G.compare reports stay trustworthy even where the absolute
-- figure drifts. The floor cannot bind inside the bundle at all (every member is above the cap,
-- and so is their mean), which is the property the cap was chosen for.
G.CAP = 1000
function G.new()
return { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 }
end
-- Empties in place -- never by replacing h.n/h.a with fresh tables. These may be ALIASED to a
-- VampifyCharDB sub-table (core/commands.lua's wireSession, the same wiring the per-spell tables
-- use), and reassigning them would orphan the persisted table instead of clearing it. Same
-- reasoning as VampifyAggregate.resetSpells.
function G.reset(h)
if not h then return end
for k in pairs(h.n) do h.n[k] = nil end
for k in pairs(h.a) do h.a[k] = nil end
h.nOverN, h.nOverSum, h.aOverN, h.aOverSum = 0, 0, 0, 0
end
-- Records one TRIGGERING hit. The caller owes this function the same filtering the healing path
-- applies (core/commands.lua drops VampifyConst.triggersVampirism == false before it gets here);
-- what this function refuses on its own account is the one condition that is a property of the
-- damage rather than of the spell: damage <= 1, on which the proc does not fire at all
-- (core/model.lua). Floored FIRST, so the key and the value the model is later handed for that key
-- are the same number.
function G.add(h, damage, isAoE)
if not h or not damage then return end
damage = math.floor(damage)
if damage <= 1 then return end
if damage > G.CAP then
if isAoE then
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
else
h.nOverN, h.nOverSum = (h.nOverN or 0) + 1, (h.nOverSum or 0) + damage
end
return
end
local t = isAoE and h.a or h.n
t[damage] = (t[damage] or 0) + 1
end
-- Moves one already-recorded hit from the normal side to the AoE side. Exists for exactly one
-- caller: core/commands.lua's retroactive AoE correction, which learns from a SECOND target that a
-- burst already credited as normal damage was area damage after all. The healing is corrected
-- there; without this the histogram would keep saying "normal" about a hit that was paid as AoE,
-- and the baseline computed from it would no longer reproduce what was actually credited -- which
-- is the one property the whole comparison rests on.
--
-- Does nothing if no such hit is on the normal side: inventing one on the AoE side would be worse
-- than leaving the miscount, and the caller's own guards (it corrects only hits it credited) mean
-- the miss is a bug elsewhere, not something to paper over here.
function G.reclassify(h, damage)
if not h or not damage then return end
damage = math.floor(damage)
if damage <= 1 then return end
if damage > G.CAP then
if not h.nOverN or h.nOverN <= 0 then return end
h.nOverN, h.nOverSum = h.nOverN - 1, h.nOverSum - damage
if h.nOverN <= 0 then h.nOverN, h.nOverSum = 0, 0 end
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
return
end
local c = h.n[damage]
if not c or c <= 0 then return end
-- nil, not 0: a zero-count key is a key, and keeping them would defeat the cap's whole point.
if c <= 1 then h.n[damage] = nil else h.n[damage] = c - 1 end
h.a[damage] = (h.a[damage] or 0) + 1
end
-- ---- reading the distribution back ---------------------------------------------------------------
--
-- Everything below computes healing through VampifyModel -- never with its own copy of the
-- formula. A second implementation of max(1, trunc(pct*D/100)) would drift from the real one the
-- first time the model moves, and the drift would be invisible: both halves would still look
-- self-consistent.
local function accumulate(t, overN, overSum, sources, factor, r)
for d, c in pairs(t) do
r.hits = r.hits + c
r.heal = r.heal + VampifyModel.healPerSources(d, sources, factor) * c
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(d, sources, factor) * c
end
if overN and overN > 0 then
-- The bundle, as its mean hit repeated overN times -- see G.CAP's comment for what that
-- approximates and how far off it can be.
local mean = overSum / overN
r.hits = r.hits + overN
r.heal = r.heal + VampifyModel.healPerSources(mean, sources, factor) * overN
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(mean, sources, factor) * overN
end
end
-- Total healing the recorded hits would return for `sources` (an array of PERCENT NUMBERS, the
-- same shape core/commands.lua's sourcePercents has), and how much of it came from the FLOOR
-- rather than from the percentages. `out` is reused rather than rebuilt, like every other result
-- table in this addon.
function G.totals(h, sources, out)
out = out or {}
out.hits, out.heal, out.floorHeal, out.floorShare = 0, 0, 0, 0
if not h then return out end
accumulate(h.n, h.nOverN, h.nOverSum, sources, 1, out)
accumulate(h.a, h.aOverN, h.aOverSum, sources, VampifyConst.FORMULA.aoeFactor, out)
if out.heal > 0 then out.floorShare = out.floorHeal / out.heal end
return out
end
-- NOTE (2026-08-24): this file USED to also carry G.splitHeal, an ST/AoE split read over h.n/h.a
-- (deriveAoE's area-damage classification). Removed the same day the UI's ST/AoE split was
-- redefined to be about WHICH UNIT WAS HIT (current target vs. everything else), not about area-damage
-- classification -- see core/aggregate.lua's A.recordSpell and A.splitTotals for the full history.
-- This histogram has no per-target dimension at all (it is keyed by damage VALUE, isAoE-classified
-- normal/AoE only), so it cannot answer the new question and G.splitHeal became dead code; deleted
-- rather than left unused. G.totals/G.compare below are UNCHANGED and still correct -- the
-- upgrade-preview feature they back legitimately needs the AoE-DAMPING classification this histogram
-- already keeps, which is a real, separate, still-measured server mechanic untouched by the split
-- redefinition.
-- Scratch buffers for the hypothetical source lists and their results. Module-level and reused:
-- G.compare runs from a slash command and an options-panel refresh, not from the damage path, but
-- there is no reason for it to allocate four tables every time either.
local addBuf, upBuf, swapBuf = {}, {}, {}
local addOut, upOut, swapOut = {}, {}, {}
local function copyInto(buf, sources)
VampifyConst.resetList(buf) -- table.insert's stale `n` would otherwise show through
local n = sources and table.getn(sources) or 0
for i = 1, n do table.insert(buf, sources[i]) end
return buf
end
-- "What would an upgrade be worth?", answered on the hits that actually fell rather than on a
-- model example. Three hypotheticals against the current baseline:
-- (a) ONE MORE source at p percent -- a spare enchant slot, another Vampirism piece
-- (c) the WEAKEST existing source SWAPPED for one p points stronger
-- (b) the STRONGEST existing source, p points stronger -- informative bound, not a decision
--
-- (a) VERSUS (c) IS THE REAL DECISION, and it is what the verdict below is computed from. Every
-- Vampirism slot the player owns is already filled; a gear upgrade therefore does not ADD a
-- percentage, it REPLACES one. And the piece a player replaces is the one holding them back --
-- their WEAKEST Vampirism item, not their best. Both choices buy the same nominal point of total
-- percentage, and they are worth wildly different amounts of healing, because a weak source is
-- precisely the one still pinned to the floor: it is the only place where p points can be spent
-- and return nothing at all (1% -> 2% of a 30 damage hit is 0.3 -> 0.6, both truncate to 0, both
-- pay the same floor of 1), while a whole new source pays a full floor of 1 regardless.
--
-- (b) IS KEPT AS A BOUND, not as advice. Raising the STRONGEST source is the most generous thing
-- p points can do -- that source is the likeliest to be clear of the floor, so it converts the
-- points at full value. It answers "what is the very best a point could ever be worth here", which
-- is worth showing, but nobody can buy it: it would mean finding a higher rank of the item you are
-- already best served by. Reporting it as the alternative to (a) -- which this function did before
-- the swap case existed -- overstated what an upgrade is worth exactly when the hits are small,
-- which is the regime this whole feature exists to expose.
--
-- (a) and the raise cases are NOT symmetric even far above the floor, and the asymmetry runs the
-- other way there: trunc(x+y) >= trunc(x) + trunc(y), so folding p into an existing source lets
-- two truncated remainders combine into a whole point that two separate sources each throw away.
-- Hence the reversal the feature is really about -- (a) wins on small hits, the swap on big ones,
-- and far above the floor the two converge to within a rounding error of each other.
--
-- TIES for weakest raise exactly ONE source (strict > below): swapping one piece of gear is the
-- move being priced, and upgrading every tied source at once would price a shopping trip.
function G.compare(h, sources, p, out)
out = out or {}
p = p or 1
G.totals(h, sources, out) -- fills hits / heal / floorHeal / floorShare
out.p = p
copyInto(addBuf, sources)
table.insert(addBuf, p)
out.addHeal = G.totals(h, addBuf, addOut).heal
out.addDelta = out.addHeal - out.heal
out.addPct = 0
if out.heal > 0 then out.addPct = out.addDelta / out.heal * 100 end
local n = sources and table.getn(sources) or 0
local best, bestI, worst, worstI
for i = 1, n do
if best == nil or sources[i] > best then best, bestI = sources[i], i end
-- Strict <, so the FIRST of several equally weak sources wins and only that one is
-- swapped -- see the header. A <= here would keep sliding to the last tied index; still
-- one source, but it would make which piece is named depend on list order for no reason.
if worst == nil or sources[i] < worst then worst, worstI = sources[i], i end
end
-- (c) the swap: the weakest source replaced by one p points stronger.
out.swapIndex, out.swapFrom = worstI, worst
if worstI then
out.swapTo = worst + p
copyInto(swapBuf, sources)
swapBuf[worstI] = swapBuf[worstI] + p
out.swapHeal = G.totals(h, swapBuf, swapOut).heal
out.swapDelta = out.swapHeal - out.heal
out.swapPct = 0
if out.heal > 0 then out.swapPct = out.swapDelta / out.heal * 100 end
else
-- Nothing equipped: there is no piece to replace. Same reasoning as (b) below -- "+0" would
-- read as a measurement rather than as "the question does not apply".
out.swapTo, out.swapHeal, out.swapDelta, out.swapPct = nil, out.heal, 0, 0
end
out.upIndex, out.upFrom = bestI, best
if bestI then
out.upTo = best + p
copyInto(upBuf, sources)
upBuf[bestI] = upBuf[bestI] + p
out.upHeal = G.totals(h, upBuf, upOut).heal
out.upDelta = out.upHeal - out.heal
out.upPct = 0
if out.heal > 0 then out.upPct = out.upDelta / out.heal * 100 end
else
-- Nothing equipped: there is no source to make stronger, and saying "+0" would read as a
-- measurement rather than as "the question does not apply".
out.upTo, out.upHeal, out.upDelta, out.upPct = nil, out.heal, 0, 0
end
-- The verdict, decided between (a) and (c) ONLY -- the two things a player can actually go and
-- do. (b) is deliberately not in the running: it is an upper bound on what a point is worth,
-- not an option, and letting it win would answer a question nobody asked.
if out.addDelta > out.swapDelta then
out.winner, out.winnerBy = "add", out.addDelta - out.swapDelta
elseif out.swapDelta > out.addDelta then
out.winner, out.winnerBy = "swap", out.swapDelta - out.addDelta
else
out.winner, out.winnerBy = "tie", 0
end
return out
end