179 lines
7.7 KiB
Lua
179 lines
7.7 KiB
Lua
-- BulwarkFrame -- pure calculation core.
|
|
--
|
|
-- Deliberately free of any WoW API: everything here is arithmetic over three inputs the
|
|
-- display layer supplies (max health, the aura's stack count, the talent rank). That keeps
|
|
-- the error-prone part testable offline under real Lua 5.0.3, which is where the 1.12 traps
|
|
-- (#table, string methods via ':', string.match) actually bite.
|
|
--
|
|
-- The constants below are not folklore. All three were measured in-game on 2026-07-28:
|
|
-- * the pool is encoded as the stack count of aura 58127, capped at 100
|
|
-- * a full pool is 20% of max health => 1 stack = MaxHP/500 (4588 HP -> 9.176 points)
|
|
-- * the absorb rate at talent rank 3 is 15.0% (median over 477 hits)
|
|
-- The rate is NOT hardcoded here: rank 1/2/3 map to 5/10/15%, and a set bonus can be layered
|
|
-- on top, because the T2.5 bonus ("+3% absorption") is not yet understood -- see rateForRank.
|
|
|
|
BulwarkFrameCalc = {}
|
|
local C = BulwarkFrameCalc
|
|
|
|
local MAX_STACKS = 100 -- the aura caps here; more is not possible
|
|
|
|
local function clamp(v, lo, hi)
|
|
if v < lo then return lo end
|
|
if v > hi then return hi end
|
|
return v
|
|
end
|
|
|
|
local function round(v) return math.floor(v + 0.5) end
|
|
|
|
-- ---- points ------------------------------------------------------------------------------
|
|
|
|
-- maxPool(maxHP): the full buffer in absorb points -- 20% of max health.
|
|
-- Written as maxHP/5 rather than maxHP*0.20 so it is a single exact division; the stack scale
|
|
-- below is derived FROM it, so the bar and the numbers can never drift apart.
|
|
function C.maxPool(maxHP)
|
|
if type(maxHP) ~= "number" or maxHP <= 0 then return 0 end
|
|
return maxHP / 5
|
|
end
|
|
|
|
-- stackPoints(maxHP): what one stack of aura 58127 is worth, in absorb points.
|
|
function C.stackPoints(maxHP)
|
|
return C.maxPool(maxHP) / MAX_STACKS
|
|
end
|
|
|
|
-- poolPoints(stacks, maxHP): the current buffer in absorb points.
|
|
function C.poolPoints(stacks, maxHP)
|
|
if type(stacks) ~= "number" then return 0 end
|
|
return C.maxPool(maxHP) * (clamp(stacks, 0, MAX_STACKS) / MAX_STACKS)
|
|
end
|
|
|
|
function C.roundPoints(p)
|
|
if type(p) ~= "number" then return 0 end
|
|
return round(p)
|
|
end
|
|
|
|
-- ---- the headline number ------------------------------------------------------------------
|
|
|
|
-- threshold(points, rate): how large a single incoming hit would have to be for the buffer to
|
|
-- swallow `points` of it -- i.e. to be drained by that one hit.
|
|
--
|
|
-- UNIT NOTE, easy to misread: this is POST-MITIGATION damage. The buffer sits behind armor,
|
|
-- block, resist and Stoneskin (all verified 2026-07-28), so the number is directly comparable
|
|
-- to the damage figures in the combat log, NOT to a boss's raw swing.
|
|
--
|
|
-- Returns nil for a missing or zero rate (talent not learned) instead of dividing by zero: an
|
|
-- "inf" on screen is worse than an empty frame.
|
|
function C.threshold(points, rate)
|
|
if type(rate) ~= "number" or rate <= 0 then return nil end
|
|
if type(points) ~= "number" then return nil end
|
|
return points / rate
|
|
end
|
|
|
|
-- ---- bar fill -----------------------------------------------------------------------------
|
|
|
|
-- barFraction(stacks): how full the pool bar is, 0..1.
|
|
--
|
|
-- This is exactly stacks/100 -- not a shortcut but the exact answer. The pair shown on the bar
|
|
-- is (pool/rate) over (maxPool/rate); both the rate and max health cancel out, so the fill
|
|
-- carries NO rounding error even though the two displayed numbers are rounded.
|
|
function C.barFraction(stacks)
|
|
if type(stacks) ~= "number" then return 0 end
|
|
return clamp(stacks, 0, MAX_STACKS) / MAX_STACKS
|
|
end
|
|
|
|
-- timeFraction(timeLeft, duration): how full the expiry bar is, 0..1.
|
|
-- A refresh can report slightly more than the nominal duration -> clamped, not trusted.
|
|
function C.timeFraction(timeLeft, duration)
|
|
if type(timeLeft) ~= "number" or type(duration) ~= "number" or duration <= 0 then return 0 end
|
|
return clamp(timeLeft / duration, 0, 1)
|
|
end
|
|
|
|
-- ---- colours ------------------------------------------------------------------------------
|
|
-- One flat colour for the whole filled area. No gradient inside a bar.
|
|
|
|
C.POOL_THRESHOLDS = { red = 0.30, yellow = 0.70 } -- fractions
|
|
C.TIME_THRESHOLDS = { red = 2.0, yellow = 5.0 } -- seconds remaining
|
|
|
|
C.COLORS = {
|
|
red = { 0.80, 0.15, 0.15 },
|
|
yellow = { 0.85, 0.75, 0.20 },
|
|
green = { 0.25, 0.75, 0.30 },
|
|
}
|
|
|
|
function C.poolColorName(fraction, cfg)
|
|
cfg = cfg or C.POOL_THRESHOLDS
|
|
if type(fraction) ~= "number" then return "red" end
|
|
if fraction <= (cfg.red or 0.30) then return "red" end
|
|
if fraction <= (cfg.yellow or 0.70) then return "yellow" end
|
|
return "green"
|
|
end
|
|
|
|
-- Time buckets run the other way round: MORE seconds left is better.
|
|
function C.timeColorName(timeLeft, cfg)
|
|
cfg = cfg or C.TIME_THRESHOLDS
|
|
if type(timeLeft) ~= "number" then return "red" end
|
|
if timeLeft < (cfg.red or 2.0) then return "red" end
|
|
if timeLeft <= (cfg.yellow or 5.0) then return "yellow" end
|
|
return "green"
|
|
end
|
|
|
|
-- colorRGB(name): never returns nil -- an unknown name yields a visible neutral colour rather
|
|
-- than a Lua error deep inside a SetStatusBarColor call.
|
|
function C.colorRGB(name)
|
|
local c = C.COLORS[name or ""]
|
|
if not c then return 0.7, 0.7, 0.7 end
|
|
return c[1], c[2], c[3]
|
|
end
|
|
|
|
-- ---- formatting ---------------------------------------------------------------------------
|
|
|
|
-- fmtThresholdPair(curPoints, maxPoints, rate) -> "2820 / 6120"
|
|
-- Rounds the RESULT, not the inputs. Rounding the point values first shifts the number by a
|
|
-- few points; the two orders are not interchangeable (pinned by a test).
|
|
function C.fmtThresholdPair(curPoints, maxPoints, rate)
|
|
local a, b = C.threshold(curPoints, rate), C.threshold(maxPoints, rate)
|
|
if not a or not b then return "--" end
|
|
return round(a) .. " / " .. round(b)
|
|
end
|
|
|
|
-- fmtShieldPair(curPoints, maxPoints) -> "459 / 918"
|
|
--
|
|
-- The other reading of the same buffer. fmtThresholdPair answers "how large may a single hit be
|
|
-- before this is drained"; this one answers "how much damage does it still absorb". They differ by
|
|
-- the absorb rate -- at rank 3 by a factor of about seven -- so which one is on screen has to be a
|
|
-- deliberate choice, not an accident.
|
|
--
|
|
-- Note it takes no rate: the shield value is the pool itself. That also makes it the honest
|
|
-- fallback when the talent rank is unknown, where fmtThresholdPair can only return "--".
|
|
function C.fmtShieldPair(curPoints, maxPoints)
|
|
if type(curPoints) ~= "number" or type(maxPoints) ~= "number" then return "--" end
|
|
return round(curPoints) .. " / " .. round(maxPoints)
|
|
end
|
|
|
|
function C.fmtTime(t)
|
|
if type(t) ~= "number" or t < 0 then t = 0 end
|
|
return string.format("%.1f s", t)
|
|
end
|
|
|
|
-- ---- absorb rate --------------------------------------------------------------------------
|
|
|
|
C.RANK_RATE = { [0] = 0, [1] = 0.05, [2] = 0.10, [3] = 0.15 }
|
|
|
|
-- rateForRank(rank, addBonus, multBonus): the active absorb rate.
|
|
--
|
|
-- An unknown rank yields 0, never a silent 15%: a threshold computed from a guessed rate is
|
|
-- worse than no threshold, because it looks authoritative.
|
|
--
|
|
-- The T2.5 set bonus is documented as "+3% absorption" and it is NOT established whether that
|
|
-- means 15 -> 18 (additive) or 15 * 1.03 (multiplicative). Both readings are expressible and
|
|
-- neither is assumed; the caller decides once measurement settles it.
|
|
function C.rateForRank(rank, addBonus, multBonus)
|
|
local base = C.RANK_RATE[rank or -1]
|
|
-- `<= 0` and not just `not base`: rank 0 maps to a rate of 0, and 0 is truthy in Lua, so a
|
|
-- bare nil check would let an additive set bonus resurrect a rate for a talent the character
|
|
-- has not learned -- exactly the confidently wrong number this guard exists to prevent.
|
|
if not base or base <= 0 then return 0 end
|
|
if type(addBonus) == "number" then base = base + addBonus end
|
|
if type(multBonus) == "number" then base = base * multBonus end
|
|
return base
|
|
end
|