mirror of
https://github.com/akzkak/CombatLedger.git
synced 2026-11-10 22:57:33 +00:00
1168 lines
48 KiB
Lua
1168 lines
48 KiB
Lua
--[[
|
|
Aggregator - encounter lifecycle and all recorded combat data.
|
|
|
|
The live encounter (`current`), the session-long Overall (`overall`)
|
|
and saved History entries share one shape, so every window renders
|
|
any of them with the same code:
|
|
|
|
encounter = {
|
|
label, zone, startTime, timestamp, duration, isBoss, bossName, pullBy,
|
|
series, -- per-2s raid totals for the report graph
|
|
units = {
|
|
[guid] = { -- roster members only; pets merge into owners (option)
|
|
name, class, classToken, isPlayer, deaths,
|
|
damageDone = bucket, -- see NewBucket
|
|
damageTaken = bucket, -- .targets = attackers
|
|
healingDone = { total (effective), raw, overheal, unverified, spells, targets },
|
|
cleanses, debuffsGiven = { total (count), spells, targets },
|
|
},
|
|
},
|
|
}
|
|
|
|
A bucket holds total, spells[spellId], melee/offhand/petMelee/
|
|
petOffhand entries (auto-attacks have no spellId), per-target
|
|
sub-buckets of the same shape, and optional spellMisses/mit tables.
|
|
|
|
Every Record* writes into both `current` and `overall`: two
|
|
independent unit tables fed the same events. Record* functions are the
|
|
only mutators and each bumps dataVersion (see GetDataVersion).
|
|
]]
|
|
|
|
local CL = CombatLedger
|
|
|
|
local current = nil -- the live encounter, or nil if not in one
|
|
|
|
-- Bumped by every mutator (Record*, encounter start/end, resets,
|
|
-- restore); the UI skips redrawing windows whose data hasn't changed.
|
|
-- May over-count (a Record* that filters its event out still bumps it),
|
|
-- never under-count.
|
|
local dataVersion = 0
|
|
|
|
-- startTime is GetTime() (what every elapsed/rate calculation subtracts
|
|
-- from); startTimeReal is wall-clock time() for RestoreState's
|
|
-- staleness check.
|
|
local function NewEncounter()
|
|
return {
|
|
label = nil,
|
|
zone = (GetRealZoneText and GetRealZoneText()) or "",
|
|
startTime = GetTime(),
|
|
startTimeReal = time(),
|
|
units = {},
|
|
activeDuration = 0, -- only meaningful for `overall` (see GetOverallDuration) - sum of finished encounters' durations, so idle time between pulls doesn't dilute Overall DPS
|
|
pullBy = nil, -- { name, label } - set once, from whoever's action started this encounter
|
|
mobTally = {}, -- [guid] = damage dealt to it by tracked casters - label-only, not a real bar entry (mobs are deliberately excluded from units)
|
|
mobHealth = {}, -- [guid] = UnitHealthMax(guid), sampled once per mob - label-only, same reasoning as mobTally (see History.lua's ComputeLabel)
|
|
-- Raid-wide damage/healing/taken per fixed time bucket, for
|
|
-- UI_EncounterReport's graph. Bucketed rather than a per-event
|
|
-- log so saved encounters stay small.
|
|
series = {},
|
|
}
|
|
end
|
|
|
|
-- Series bucket width. Only `current` records a series; Overall isn't a
|
|
-- single fight to graph.
|
|
local TIMELINE_BUCKET_SECONDS = 2
|
|
|
|
local function RecordSeriesPoint(enc, kind, amount)
|
|
if not enc or not enc.startTime or not enc.series then return end
|
|
local elapsed = GetTime() - enc.startTime
|
|
local idx = math.floor(elapsed / TIMELINE_BUCKET_SECONDS) + 1
|
|
if idx < 1 then idx = 1 end
|
|
local bucket = enc.series[idx]
|
|
if not bucket then
|
|
bucket = { damage = 0, healing = 0, taken = 0 }
|
|
enc.series[idx] = bucket
|
|
end
|
|
bucket[kind] = (bucket[kind] or 0) + amount
|
|
end
|
|
|
|
local overall = NewEncounter() -- long-lived; only cleared by ResetData()
|
|
|
|
-- Death recap: a rolling RECAP_WINDOW-second list of hits taken per
|
|
-- tracked unit (the raw sequence, not totals), snapshotted on death.
|
|
-- Session only - not saved with encounters.
|
|
local RECAP_WINDOW = 10 -- seconds of history kept per tracked unit
|
|
local recapBuffers = {} -- [guid] = { {time, attacker, label, amount, isCrit}, ... }, oldest first
|
|
local lastDeathRecap = {} -- [guid] = { deathTime, hits = {...snapshot...} }
|
|
|
|
local function RecordRecapHit(guid, attacker, label, amount, isCrit)
|
|
local buf = recapBuffers[guid]
|
|
if not buf then
|
|
buf = {}
|
|
recapBuffers[guid] = buf
|
|
end
|
|
table.insert(buf, { time = GetTime(), attacker = attacker, label = label, amount = amount, isCrit = isCrit })
|
|
|
|
local cutoff = GetTime() - RECAP_WINDOW
|
|
while table.getn(buf) > 0 and buf[1].time < cutoff do
|
|
table.remove(buf, 1)
|
|
end
|
|
end
|
|
|
|
local function SnapshotDeathRecap(guid)
|
|
local buf = recapBuffers[guid]
|
|
local copy = {}
|
|
if buf then
|
|
local i
|
|
for i = 1, table.getn(buf) do
|
|
copy[i] = buf[i]
|
|
end
|
|
end
|
|
lastDeathRecap[guid] = { deathTime = GetTime(), hits = copy }
|
|
end
|
|
|
|
local function GetDeathRecap(guid)
|
|
return guid and lastDeathRecap[guid]
|
|
end
|
|
|
|
local function NewAvoided()
|
|
return { miss = 0, dodge = 0, parry = 0, block = 0, evade = 0, immune = 0, deflect = 0, other = 0 }
|
|
end
|
|
|
|
-- Melee entries carry their own avoidance counts (white-swing outcomes
|
|
-- from AUTO_ATTACK), so each melee row can show its own miss/dodge/parry
|
|
-- rates. Spell misses are kept separately in bucket.spellMisses.
|
|
local function NewMeleeEntry()
|
|
return { hits = 0, crits = 0, total = 0, critTotal = 0, min = nil, max = nil, avoided = NewAvoided() }
|
|
end
|
|
|
|
local function NewBucket()
|
|
return {
|
|
total = 0,
|
|
spells = {},
|
|
melee = NewMeleeEntry(),
|
|
-- Off-hand swings, kept apart from main-hand (different hit caps).
|
|
offhand = NewMeleeEntry(),
|
|
-- A merged pet's swings: same unit total as the owner, but its own
|
|
-- breakdown row rather than inflating the owner's "Auto Attack".
|
|
petMelee = NewMeleeEntry(),
|
|
petOffhand = NewMeleeEntry(),
|
|
-- Per-target sub-buckets (EnsureTargetEntry): who the damage
|
|
-- landed on (damageDone) or came from (damageTaken).
|
|
targets = {},
|
|
}
|
|
end
|
|
|
|
-- The unit a guid's data is recorded under: its owner while "Merge pets
|
|
-- into owners" is on, itself otherwise. Only bucketing uses this;
|
|
-- relevance/roster checks use the pet's own guid.
|
|
local function AttributedGuid(guid)
|
|
if CL.GetSetting("mergePets") == false then return guid end
|
|
if CL.GuidCache and CL.GuidCache.GetOwner then
|
|
local owner = CL.GuidCache.GetOwner(guid)
|
|
if owner then return owner end
|
|
end
|
|
return guid
|
|
end
|
|
|
|
local function EnsureUnit(units, guid)
|
|
local u = units[guid]
|
|
if not u then
|
|
local info = CL.GuidCache and CL.GuidCache.Resolve(guid)
|
|
u = {
|
|
name = (info and info.name) or guid,
|
|
class = info and info.class,
|
|
classToken = info and info.classToken,
|
|
isPlayer = info and info.isPlayer,
|
|
damageDone = NewBucket(),
|
|
-- targets = who was healed.
|
|
healingDone = { total = 0, overheal = 0, spells = {}, targets = {} },
|
|
damageTaken = NewBucket(),
|
|
-- Dispels: total is a count; spells are the auras removed.
|
|
cleanses = { total = 0, spells = {}, targets = {} },
|
|
-- Debuffs given: a count, from Events.lua's AURA_CAST +
|
|
-- DEBUFF_ADDED correlation.
|
|
debuffsGiven = { total = 0, spells = {}, targets = {} },
|
|
deaths = 0,
|
|
}
|
|
units[guid] = u
|
|
elseif u.name == guid then
|
|
-- Recorded before the client knew the name (GuidCache returned
|
|
-- nil); fill it in once it resolves.
|
|
local info = CL.GuidCache and CL.GuidCache.Resolve(guid)
|
|
if info then
|
|
u.name, u.class, u.classToken, u.isPlayer = info.name, info.class, info.classToken, info.isPlayer
|
|
end
|
|
end
|
|
return u
|
|
end
|
|
|
|
local function EnsureSpellEntry(spells, spellId, name, school)
|
|
local s = spells[spellId]
|
|
if not s then
|
|
s = { name = name, school = school, hits = 0, crits = 0, total = 0, critTotal = 0, min = nil, max = nil }
|
|
spells[spellId] = s
|
|
else
|
|
-- Fill in fields an earlier, less informed caller left empty.
|
|
if name and not s.name then s.name = name end
|
|
if school and not s.school then s.school = school end
|
|
end
|
|
return s
|
|
end
|
|
|
|
local function RecordHit(entry, amount, isCrit)
|
|
entry.hits = entry.hits + 1
|
|
entry.total = entry.total + amount
|
|
if isCrit then
|
|
entry.crits = entry.crits + 1
|
|
entry.critTotal = (entry.critTotal or 0) + amount
|
|
end
|
|
if not entry.min or amount < entry.min then entry.min = amount end
|
|
if not entry.max or amount > entry.max then entry.max = amount end
|
|
end
|
|
|
|
-- A spell entry's "directHits"/"tickHits" sub-entry. These are extra
|
|
-- detail recorded alongside the entry's combined totals (which everything
|
|
-- else reads), so the breakdown can show a spell's direct hits and DoT
|
|
-- ticks separately - a direct hit can crit, a tick can't.
|
|
local function EnsureSplitBucket(entry, key)
|
|
local b = entry[key]
|
|
if not b then
|
|
b = { hits = 0, crits = 0, total = 0, critTotal = 0, min = nil, max = nil }
|
|
entry[key] = b
|
|
end
|
|
return b
|
|
end
|
|
|
|
local lastFinished = nil -- frozen snapshot of the previous fight, shown as "Current Fight" between pulls
|
|
local lastFinishedTime = nil -- GetTime() when lastFinished was set - see ShouldLazyStart below
|
|
|
|
local bossTagCache = {} -- [enemyGuid] = encounter name or false, see BossNameCached - cleared per encounter
|
|
|
|
local function StartEncounter()
|
|
dataVersion = dataVersion + 1
|
|
if current then return end
|
|
current = NewEncounter()
|
|
bossTagCache = {}
|
|
lastFinished = nil -- a new fight is live - stop showing the frozen previous one
|
|
if CL.debug then
|
|
CL.Print("Encounter started.")
|
|
CL.LogLine("[REGEN] StartEncounter (lazy-start guard passed or real PLAYER_REGEN_DISABLED)")
|
|
end
|
|
end
|
|
|
|
-- Lazy start: Record* functions may start an encounter themselves when
|
|
-- none is live, since the first hit can arrive before (or, against
|
|
-- training dummies, without) PLAYER_REGEN_DISABLED. The danger is a
|
|
-- trailing event after a fight (a last DoT tick, a top-off heal) starting
|
|
-- a blank encounter that replaces the just-finished one in Current
|
|
-- Fight. Encounters only end once the player and the whole group are out
|
|
-- of combat, so anyone still fighting means a genuinely new pull.
|
|
local function IsPlayerInCombat()
|
|
local ok, playerCombat = pcall(UnitAffectingCombat, "player")
|
|
return ok and playerCombat and true or false
|
|
end
|
|
|
|
local function IsGroupFighting()
|
|
return IsPlayerInCombat() or (CL.GuidCache and CL.GuidCache.AnyGroupMemberInCombat())
|
|
end
|
|
|
|
-- Guard for damage-style events: allow when the player or group is
|
|
-- fighting, or when the last end was more than PHANTOM_GUARD_WINDOW ago
|
|
-- (dummies may never flag combat). Refuse only just after an end, where
|
|
-- an unflagged hit is most likely a trailing tick.
|
|
local PHANTOM_GUARD_WINDOW = 3 -- seconds after a fight ends where an unflagged hit is treated as a trailing event, not a new pull
|
|
local function ShouldLazyStart()
|
|
if IsGroupFighting() then return true end
|
|
if lastFinishedTime and (GetTime() - lastFinishedTime) < PHANTOM_GUARD_WINDOW then
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function GetCurrent()
|
|
return current
|
|
end
|
|
|
|
-- What "Current Fight" shows: the live encounter, or the last finished
|
|
-- one (kept for review) until the next pull starts.
|
|
local function GetCurrentDisplay()
|
|
return current or lastFinished
|
|
end
|
|
|
|
local function GetOverall()
|
|
return overall
|
|
end
|
|
|
|
-- Clears Overall and the finished fight Current Fight is still showing,
|
|
-- so the meter starts blank. A fight in progress restarts as a fresh
|
|
-- segment from now - emptied in place, so the table Threat.lua keys its
|
|
-- ledger on stays the same (resetting the meter doesn't reset real
|
|
-- threat). Saved History isn't touched. The automatic reset rules
|
|
-- (Events.lua) never call this mid-fight; only a manual reset does.
|
|
local function ResetData()
|
|
dataVersion = dataVersion + 1
|
|
overall = NewEncounter()
|
|
lastFinished = nil
|
|
if current then
|
|
local fresh = NewEncounter()
|
|
local k, v
|
|
for k in pairs(current) do current[k] = nil end
|
|
for k, v in pairs(fresh) do current[k] = v end
|
|
end
|
|
end
|
|
|
|
-- Adds fields that saves from older versions may lack, so Record* can
|
|
-- write into a restored unit without nil checks.
|
|
local function BackfillUnit(u)
|
|
if not u.damageDone then u.damageDone = NewBucket() end
|
|
if not u.damageDone.targets then u.damageDone.targets = {} end
|
|
if not u.healingDone then u.healingDone = { total = 0, overheal = 0, spells = {}, targets = {} } end
|
|
if not u.healingDone.targets then u.healingDone.targets = {} end
|
|
if not u.damageTaken then u.damageTaken = NewBucket() end
|
|
if not u.damageTaken.targets then u.damageTaken.targets = {} end
|
|
if not u.cleanses then u.cleanses = { total = 0, spells = {}, targets = {} } end
|
|
if not u.debuffsGiven then u.debuffsGiven = { total = 0, spells = {}, targets = {} } end
|
|
if not u.deaths then u.deaths = 0 end
|
|
end
|
|
|
|
local function BackfillEncounter(enc)
|
|
if not enc then return end
|
|
-- Encounter-level fields older saves may lack.
|
|
if not enc.mobTally then enc.mobTally = {} end
|
|
if not enc.mobHealth then enc.mobHealth = {} end
|
|
if not enc.series then enc.series = {} end
|
|
if not enc.units then return end
|
|
local guid, u
|
|
for guid, u in pairs(enc.units) do
|
|
BackfillUnit(u)
|
|
end
|
|
end
|
|
|
|
-- Adds src's numbers into dst, recursively (spells, melee entries,
|
|
-- avoided/mit/spellMisses sub-tables all nest the same way). min/max
|
|
-- combine as min/max; `school` is an id, not a count, so it's kept;
|
|
-- strings (names) keep dst's value.
|
|
local NON_ADDITIVE = { school = true }
|
|
local function MergeStats(dst, src)
|
|
local k, v
|
|
for k, v in pairs(src) do
|
|
if type(v) == "number" then
|
|
if k == "min" then
|
|
dst.min = dst.min and math.min(dst.min, v) or v
|
|
elseif k == "max" then
|
|
dst.max = dst.max and math.max(dst.max, v) or v
|
|
elseif NON_ADDITIVE[k] then
|
|
if dst[k] == nil then dst[k] = v end
|
|
else
|
|
dst[k] = (dst[k] or 0) + v
|
|
end
|
|
elseif type(v) == "table" then
|
|
if type(dst[k]) ~= "table" then dst[k] = {} end
|
|
MergeStats(dst[k], v)
|
|
elseif dst[k] == nil then
|
|
dst[k] = v
|
|
end
|
|
end
|
|
end
|
|
|
|
local TARGET_BUCKETS = { "damageDone", "damageTaken", "healingDone", "cleanses", "debuffsGiven" }
|
|
|
|
-- Re-keys every per-target table of an encounter by name, merging
|
|
-- same-named entries (see TargetKey) - run when a fight is saved to
|
|
-- History and on an Overall restored from an older, GUID-keyed save.
|
|
-- Totals are unchanged; only same-named mobs stop being separate rows.
|
|
local function CompactTargets(enc)
|
|
if not enc or not enc.units then return end
|
|
local guid, u
|
|
for guid, u in pairs(enc.units) do
|
|
local i
|
|
for i = 1, table.getn(TARGET_BUCKETS) do
|
|
local bucket = u[TARGET_BUCKETS[i]]
|
|
if bucket and bucket.targets then
|
|
local merged = {}
|
|
local key, t
|
|
for key, t in pairs(bucket.targets) do
|
|
local name = t.name or key
|
|
if merged[name] then
|
|
MergeStats(merged[name], t)
|
|
else
|
|
merged[name] = t
|
|
end
|
|
end
|
|
bucket.targets = merged
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Current and Overall are saved to CombatLedgerDB.liveState on
|
|
-- PLAYER_LOGOUT (which /reload also fires) and restored on the next
|
|
-- PLAYER_ENTERING_WORLD - see Events.lua.
|
|
local function SerializeState()
|
|
return { current = current, overall = overall }
|
|
end
|
|
|
|
-- A saved live encounter is only restored when it was saved within
|
|
-- RESTORE_STALE_SECONDS by wall clock (startTimeReal). Its startTime is a
|
|
-- GetTime() value, which keeps counting across a /reload but restarts
|
|
-- near 0 on a fresh client launch, so it can't tell a reload from a
|
|
-- relaunch by itself. Saves without startTimeReal are treated as stale.
|
|
-- Overall has no such issue: its duration is an accumulated number.
|
|
local RESTORE_STALE_SECONDS = 300
|
|
local function RestoreState(saved)
|
|
dataVersion = dataVersion + 1
|
|
if not saved then return end
|
|
if saved.overall then
|
|
overall = saved.overall
|
|
BackfillEncounter(overall)
|
|
CompactTargets(overall)
|
|
end
|
|
if saved.current and saved.current.startTimeReal
|
|
and (time() - saved.current.startTimeReal) < RESTORE_STALE_SECONDS
|
|
and saved.current.startTime and saved.current.startTime <= GetTime() then
|
|
current = saved.current
|
|
BackfillEncounter(current)
|
|
end
|
|
end
|
|
|
|
-- Overall's duration counts only time spent in encounters (finished
|
|
-- ones plus the live one), so idle time between pulls doesn't dilute
|
|
-- Overall rates.
|
|
local function GetOverallDuration()
|
|
local d = overall.activeDuration or 0
|
|
if current then
|
|
d = d + (GetTime() - current.startTime)
|
|
end
|
|
return d
|
|
end
|
|
|
|
-- Ends the live encounter. The duration runs to lastActivityTime (the
|
|
-- last relevant combat event, from Events.lua) rather than to now, so
|
|
-- time spent flagged in combat after the last hit - including the end
|
|
-- debounce - doesn't dilute rates. Only ever shortens, and only when
|
|
-- that time falls inside the encounter.
|
|
local function EndEncounter(lastActivityTime)
|
|
dataVersion = dataVersion + 1
|
|
if not current then return nil end
|
|
current.duration = GetTime() - current.startTime
|
|
if lastActivityTime and lastActivityTime >= current.startTime and lastActivityTime < GetTime() then
|
|
local trimmed = lastActivityTime - current.startTime
|
|
if trimmed > 0 and trimmed < current.duration then
|
|
current.duration = trimmed
|
|
end
|
|
end
|
|
current.timestamp = time()
|
|
overall.activeDuration = (overall.activeDuration or 0) + current.duration
|
|
local finished = current
|
|
current = nil
|
|
lastFinished = finished
|
|
lastFinishedTime = GetTime()
|
|
return finished
|
|
end
|
|
|
|
local function IsTrackedGuid(guid)
|
|
return guid and CL.GuidCache and CL.GuidCache.IsTracked(guid)
|
|
end
|
|
|
|
-- The non-roster side of a caster/target pair (the enemy), or nil.
|
|
local function EnemyGuidFor(casterGuid, targetGuid)
|
|
if casterGuid and targetGuid and IsTrackedGuid(AttributedGuid(casterGuid)) then
|
|
return AttributedGuid(targetGuid)
|
|
elseif casterGuid and targetGuid and IsTrackedGuid(AttributedGuid(targetGuid)) then
|
|
return AttributedGuid(casterGuid)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Each enemy is classified once per encounter (see Bosses.lua); the
|
|
-- first hit on each new enemy is still checked immediately, which keeps
|
|
-- pull attribution exact. Returns the encounter name or false.
|
|
local function BossNameCached(enemyGuid)
|
|
if not enemyGuid then return false end
|
|
local cached = bossTagCache[enemyGuid]
|
|
if cached == nil then
|
|
cached = CL.Bosses.Classify(enemyGuid) or false
|
|
bossTagCache[enemyGuid] = cached
|
|
end
|
|
return cached
|
|
end
|
|
|
|
-- Marks the live encounter as a boss fight named `name`. A BigWigs
|
|
-- encounter name (fromBigWigs) replaces a mob name taken from rank.
|
|
local function MarkBoss(name, fromBigWigs)
|
|
if not current or not name then return end
|
|
if current.isBoss and current.bossName and not fromBigWigs then return end
|
|
if current.bossName == name then return end
|
|
dataVersion = dataVersion + 1
|
|
current.isBoss = true
|
|
current.bossName = name
|
|
end
|
|
|
|
-- The melee entry (main/off-hand, own/pet) an auto-attack belongs in.
|
|
local function MeleeEntryFor(bucket, isPet, isOffhand)
|
|
if isPet then
|
|
return isOffhand and bucket.petOffhand or bucket.petMelee
|
|
end
|
|
return isOffhand and bucket.offhand or bucket.melee
|
|
end
|
|
|
|
-- Mitigation observed on a hit (see Events.lua's FillMitigation): amounts
|
|
-- absorbed/blocked/resisted plus how many hits each affected, and
|
|
-- glancing/crushing counts. Kept in a lazily created `mit` subtable on
|
|
-- whatever it's applied to (bucket, per-target entry, spell/melee
|
|
-- entry), so units that never see mitigation carry no extra tables.
|
|
local function AddMitAmount(m, key, amount)
|
|
if amount and amount > 0 then
|
|
m[key] = (m[key] or 0) + amount
|
|
m[key .. "Hits"] = (m[key .. "Hits"] or 0) + 1
|
|
end
|
|
end
|
|
|
|
local function ApplyMitigation(t, mit)
|
|
if not mit then return end
|
|
local m = t.mit
|
|
if not m then
|
|
m = {}
|
|
t.mit = m
|
|
end
|
|
AddMitAmount(m, "absorbed", mit.absorbed)
|
|
AddMitAmount(m, "blocked", mit.blocked)
|
|
AddMitAmount(m, "resisted", mit.resisted)
|
|
if mit.glancing then m.glancing = (m.glancing or 0) + 1 end
|
|
if mit.crushing then m.crushing = (m.crushing or 0) + 1 end
|
|
end
|
|
|
|
local function RecordDamageHit(entry, amount, isCrit, mit)
|
|
RecordHit(entry, amount, isCrit)
|
|
ApplyMitigation(entry, mit)
|
|
end
|
|
|
|
-- A per-target (damageDone.targets) or per-attacker (damageTaken.targets)
|
|
-- entry. Same shape as a damage bucket (spells/melee/offhand/pet
|
|
-- variants) so clicking it in the UI reuses the exact same per-ability
|
|
-- breakdown code as the unit-wide view, just scoped to this one unit.
|
|
--
|
|
-- Keyed by GUID in a live fight (two same-named adds stay apart), but by
|
|
-- NAME in Overall (units == overall.units): a session of trash would
|
|
-- otherwise grow one full breakdown per mob GUID ever hit, all of it
|
|
-- saved to disk on logout. Same idea as Skada's name-keyed targets.
|
|
local function TargetKey(units, guid)
|
|
local info = CL.GuidCache and CL.GuidCache.Resolve(guid)
|
|
local name = info and info.name
|
|
if name and units == overall.units then return name, name end
|
|
return guid, name or guid
|
|
end
|
|
|
|
local function EnsureTargetEntry(units, targets, guid)
|
|
local key, name = TargetKey(units, guid)
|
|
local t = targets[key]
|
|
if not t then
|
|
t = {
|
|
name = name,
|
|
total = 0, hits = 0, spells = {},
|
|
melee = NewMeleeEntry(), offhand = NewMeleeEntry(),
|
|
petMelee = NewMeleeEntry(), petOffhand = NewMeleeEntry(),
|
|
}
|
|
targets[key] = t
|
|
end
|
|
return t
|
|
end
|
|
|
|
-- Records one hit into a units table: Damage Done for a roster caster,
|
|
-- Damage Taken for a roster target. Mobs never get a unit of their own.
|
|
-- Either guid may be nil (environment damage has no caster).
|
|
local function RecordDamageInto(units, casterGuid, targetGuid, spellId, spellName, school, amount, isCrit, isOffhand, isPeriodic, mit)
|
|
if casterGuid then
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
u.damageDone.total = u.damageDone.total + amount
|
|
ApplyMitigation(u.damageDone, mit)
|
|
if spellId then
|
|
local entry = EnsureSpellEntry(u.damageDone.spells, spellId, spellName, school)
|
|
-- Fold in casts counted before this spell's first damage
|
|
-- (see RecordCastInto).
|
|
if u.pendingCasts and u.pendingCasts[spellId] then
|
|
entry.casts = (entry.casts or 0) + u.pendingCasts[spellId]
|
|
u.pendingCasts[spellId] = nil
|
|
end
|
|
RecordDamageHit(entry, amount, isCrit, mit)
|
|
RecordHit(EnsureSplitBucket(entry, isPeriodic and "tickHits" or "directHits"), amount, isCrit)
|
|
else
|
|
RecordDamageHit(MeleeEntryFor(u.damageDone, attributed ~= casterGuid, isOffhand), amount, isCrit, mit)
|
|
end
|
|
|
|
if targetGuid then
|
|
local t = EnsureTargetEntry(units, u.damageDone.targets, targetGuid)
|
|
t.total = t.total + amount
|
|
t.hits = t.hits + 1
|
|
ApplyMitigation(t, mit)
|
|
if spellId then
|
|
local entry = EnsureSpellEntry(t.spells, spellId, spellName, school)
|
|
RecordDamageHit(entry, amount, isCrit, mit)
|
|
RecordHit(EnsureSplitBucket(entry, isPeriodic and "tickHits" or "directHits"), amount, isCrit)
|
|
else
|
|
RecordDamageHit(MeleeEntryFor(t, attributed ~= casterGuid, isOffhand), amount, isCrit, mit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if targetGuid then
|
|
local attributed = AttributedGuid(targetGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
u.damageTaken.total = u.damageTaken.total + amount
|
|
ApplyMitigation(u.damageTaken, mit)
|
|
if spellId then
|
|
local entry = EnsureSpellEntry(u.damageTaken.spells, spellId, spellName, school)
|
|
RecordDamageHit(entry, amount, isCrit, mit)
|
|
RecordHit(EnsureSplitBucket(entry, isPeriodic and "tickHits" or "directHits"), amount, isCrit)
|
|
else
|
|
RecordDamageHit(MeleeEntryFor(u.damageTaken, attributed ~= targetGuid, isOffhand), amount, isCrit, mit)
|
|
end
|
|
|
|
-- damageTaken.targets = attackers.
|
|
if casterGuid then
|
|
local s = EnsureTargetEntry(units, u.damageTaken.targets, casterGuid)
|
|
s.total = s.total + amount
|
|
s.hits = s.hits + 1
|
|
ApplyMitigation(s, mit)
|
|
if spellId then
|
|
local entry = EnsureSpellEntry(s.spells, spellId, spellName, school)
|
|
RecordDamageHit(entry, amount, isCrit, mit)
|
|
RecordHit(EnsureSplitBucket(entry, isPeriodic and "tickHits" or "directHits"), amount, isCrit)
|
|
else
|
|
RecordDamageHit(MeleeEntryFor(s, false, isOffhand), amount, isCrit, mit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- `mit` (optional): see ApplyMitigation.
|
|
local function RecordDamage(casterGuid, targetGuid, spellId, spellName, school, amount, isCrit, isOffhand, isPeriodic, mit)
|
|
dataVersion = dataVersion + 1
|
|
if not current then
|
|
if not ShouldLazyStart() then return end
|
|
StartEncounter()
|
|
end
|
|
|
|
-- Pull attribution: the caster of the first hit involving a boss
|
|
-- "pulled" it. Set once; trash-only encounters never set it.
|
|
local bossName = not current.pullBy and casterGuid and BossNameCached(EnemyGuidFor(casterGuid, targetGuid))
|
|
if bossName then
|
|
local info = CL.GuidCache and CL.GuidCache.Resolve(casterGuid)
|
|
current.pullBy = { name = (info and info.name) or casterGuid, label = spellName or "Auto Attack" }
|
|
MarkBoss(bossName)
|
|
if CL.GetSetting("announcePulls") ~= false then
|
|
CL.Print("Pull: " .. current.pullBy.name .. " (" .. current.pullBy.label .. ")")
|
|
end
|
|
end
|
|
|
|
RecordDamageInto(current.units, casterGuid, targetGuid, spellId, spellName, school, amount, isCrit, isOffhand, isPeriodic, mit)
|
|
RecordDamageInto(overall.units, casterGuid, targetGuid, spellId, spellName, school, amount, isCrit, isOffhand, isPeriodic, mit)
|
|
-- Raw (un-merged) caster: threat belongs to the pet itself.
|
|
if CL.Threat then CL.Threat.NoteDamage(current, casterGuid, targetGuid, spellId, spellName, school, amount) end
|
|
|
|
if casterGuid and IsTrackedGuid(AttributedGuid(casterGuid)) then
|
|
RecordSeriesPoint(current, "damage", amount)
|
|
end
|
|
if targetGuid and IsTrackedGuid(AttributedGuid(targetGuid)) then
|
|
RecordSeriesPoint(current, "taken", amount)
|
|
end
|
|
|
|
-- Damage and max health per enemy, only so History.lua can label a
|
|
-- saved encounter after the toughest mob in it.
|
|
if casterGuid and targetGuid and IsTrackedGuid(AttributedGuid(casterGuid)) then
|
|
local targetAttributed = AttributedGuid(targetGuid)
|
|
if not IsTrackedGuid(targetAttributed) then
|
|
current.mobTally[targetAttributed] = (current.mobTally[targetAttributed] or 0) + amount
|
|
|
|
-- Max health doesn't change, so it's read once per mob per
|
|
-- encounter (0 = unreadable, not retried).
|
|
if UnitHealthMax and current.mobHealth[targetAttributed] == nil then
|
|
local ok, maxHp = pcall(UnitHealthMax, targetAttributed)
|
|
current.mobHealth[targetAttributed] = (ok and tonumber(maxHp)) or 0
|
|
end
|
|
end
|
|
end
|
|
|
|
if targetGuid then
|
|
local attributedTarget = AttributedGuid(targetGuid)
|
|
if IsTrackedGuid(attributedTarget) then
|
|
local attackerName = "Environment"
|
|
if casterGuid then
|
|
local info = CL.GuidCache and CL.GuidCache.Resolve(casterGuid)
|
|
attackerName = (info and info.name) or casterGuid
|
|
end
|
|
local label = spellName
|
|
if not label then
|
|
local isPet = (attributedTarget ~= targetGuid)
|
|
if isPet then
|
|
label = isOffhand and "Pet Off-Hand" or "Pet Auto Attack"
|
|
else
|
|
label = isOffhand and "Off-Hand" or "Auto Attack"
|
|
end
|
|
end
|
|
RecordRecapHit(attributedTarget, attackerName, label, amount, isCrit)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Cast counts (from AURA_CAST) for spells whose hits are DoT ticks, where
|
|
-- the hit count isn't the number of casts. Unit-wide entries only.
|
|
local function RecordCastInto(units, casterGuid, spellId, spellName)
|
|
if not casterGuid or not spellId then return end
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if not IsTrackedGuid(attributed) then return end
|
|
local u = EnsureUnit(units, attributed)
|
|
local entry = u.damageDone.spells[spellId]
|
|
if entry then
|
|
entry.casts = (entry.casts or 0) + 1
|
|
else
|
|
-- No damage entry yet (the cast lands before the first tick):
|
|
-- stash the count on the unit rather than creating an entry, so
|
|
-- non-damage casts (buffs) never become zero-damage rows.
|
|
-- RecordDamageInto folds it in when the entry is created.
|
|
u.pendingCasts = u.pendingCasts or {}
|
|
u.pendingCasts[spellId] = (u.pendingCasts[spellId] or 0) + 1
|
|
end
|
|
end
|
|
|
|
local function RecordCast(casterGuid, spellId, spellName)
|
|
dataVersion = dataVersion + 1
|
|
-- Never starts an encounter: AURA_CAST fires for every buff and heal
|
|
-- too, so a cast alone isn't evidence of a fight. The cost is that a
|
|
-- fight-opening DoT cast can go uncounted.
|
|
if not current then return end
|
|
RecordCastInto(current.units, casterGuid, spellId, spellName)
|
|
RecordCastInto(overall.units, casterGuid, spellId, spellName)
|
|
end
|
|
|
|
local VICTIMSTATE_KEY = {
|
|
[CL.VICTIMSTATE_MISS] = "miss",
|
|
[CL.VICTIMSTATE_DODGE] = "dodge",
|
|
[CL.VICTIMSTATE_PARRY] = "parry",
|
|
[CL.VICTIMSTATE_BLOCK] = "block",
|
|
[CL.VICTIMSTATE_EVADE] = "evade",
|
|
[CL.VICTIMSTATE_IMMUNE] = "immune",
|
|
[CL.VICTIMSTATE_DEFLECT] = "deflect",
|
|
}
|
|
|
|
-- Counts one avoided swing on a melee entry (and its mitigation, if any).
|
|
local function CountAvoided(entry, key, mit)
|
|
entry.avoided[key] = (entry.avoided[key] or 0) + 1
|
|
ApplyMitigation(entry, mit)
|
|
end
|
|
|
|
-- Recorded unit-wide and per target/attacker, so a breakdown filtered to
|
|
-- one target shows that target's misses too.
|
|
local function RecordAvoidanceInto(units, casterGuid, targetGuid, key, isOffhand, mit)
|
|
if casterGuid then
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
local isPet = attributed ~= casterGuid
|
|
CountAvoided(MeleeEntryFor(u.damageDone, isPet, isOffhand), key, mit)
|
|
ApplyMitigation(u.damageDone, mit)
|
|
if targetGuid then
|
|
local t = EnsureTargetEntry(units, u.damageDone.targets, targetGuid)
|
|
CountAvoided(MeleeEntryFor(t, isPet, isOffhand), key, mit)
|
|
ApplyMitigation(t, mit)
|
|
end
|
|
end
|
|
end
|
|
if targetGuid then
|
|
local attributed = AttributedGuid(targetGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
CountAvoided(MeleeEntryFor(u.damageTaken, attributed ~= targetGuid, isOffhand), key, mit)
|
|
ApplyMitigation(u.damageTaken, mit)
|
|
if casterGuid then
|
|
local s = EnsureTargetEntry(units, u.damageTaken.targets, casterGuid)
|
|
CountAvoided(MeleeEntryFor(s, false, isOffhand), key, mit)
|
|
ApplyMitigation(s, mit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- An auto-attack swing that did no damage: counted by outcome on the
|
|
-- melee entry (victimState, or "absorb" for a swing a shield soaked
|
|
-- entirely). `mit` carries a fully blocked/absorbed swing's amount.
|
|
local function RecordAvoidance(casterGuid, targetGuid, victimState, isOffhand, mit, fullAbsorb)
|
|
dataVersion = dataVersion + 1
|
|
if not current then
|
|
if not ShouldLazyStart() then return end
|
|
StartEncounter()
|
|
end
|
|
local key = fullAbsorb and "absorb" or VICTIMSTATE_KEY[victimState] or "other"
|
|
RecordAvoidanceInto(current.units, casterGuid, targetGuid, key, isOffhand, mit)
|
|
RecordAvoidanceInto(overall.units, casterGuid, targetGuid, key, isOffhand, mit)
|
|
end
|
|
|
|
-- Healing totals are EFFECTIVE healing (raw minus estimated overheal), so
|
|
-- bars, rates, spell shares and healing threat all ignore overheal.
|
|
-- raw/overheal/unverified ride alongside; min/max and the per-crit
|
|
-- averages use raw heal size, since that's what the spell actually did.
|
|
-- `unverified` = raw amount whose target health couldn't be read, so
|
|
-- it was counted as fully effective.
|
|
local function AddHeal(t, amount, effective, overheal, unverified)
|
|
t.total = t.total + effective
|
|
t.raw = (t.raw or 0) + amount
|
|
t.overheal = (t.overheal or 0) + overheal
|
|
if unverified then t.unverified = (t.unverified or 0) + amount end
|
|
end
|
|
|
|
local function RecordHealHit(entry, amount, effective, overheal, isCrit, unverified)
|
|
entry.hits = entry.hits + 1
|
|
AddHeal(entry, amount, effective, overheal, unverified)
|
|
if isCrit then
|
|
entry.crits = entry.crits + 1
|
|
entry.critTotal = (entry.critTotal or 0) + effective
|
|
entry.critRaw = (entry.critRaw or 0) + amount
|
|
end
|
|
if not entry.min or amount < entry.min then entry.min = amount end
|
|
if not entry.max or amount > entry.max then entry.max = amount end
|
|
end
|
|
|
|
-- SPELL_MISS_* missInfo -> outcome key (vmangos SpellMissInfo; 7 and 8
|
|
-- are both immune variants).
|
|
local SPELL_MISS_KEY = {
|
|
[1] = "miss", [2] = "resist", [3] = "dodge", [4] = "parry", [5] = "block",
|
|
[6] = "evade", [7] = "immune", [8] = "immune", [9] = "deflect",
|
|
[10] = "absorb", [11] = "reflect",
|
|
}
|
|
|
|
-- Spell misses live in bucket.spellMisses[spellId] (an avoided-style
|
|
-- count table), NOT as spell entries - many missed spells never deal
|
|
-- damage (Sunder Armor, Taunt, a resisted curse) and would otherwise
|
|
-- show up as zero-damage rows. The breakdown window joins them onto the
|
|
-- matching spell entry by spellId.
|
|
local function BumpSpellMiss(bucket, spellId, key)
|
|
local misses = bucket.spellMisses
|
|
if not misses then
|
|
misses = {}
|
|
bucket.spellMisses = misses
|
|
end
|
|
local av = misses[spellId]
|
|
if not av then
|
|
av = {}
|
|
misses[spellId] = av
|
|
end
|
|
av[key] = (av[key] or 0) + 1
|
|
end
|
|
|
|
local function RecordSpellMissInto(units, casterGuid, targetGuid, spellId, key)
|
|
if casterGuid then
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
BumpSpellMiss(u.damageDone, spellId, key)
|
|
if targetGuid then
|
|
BumpSpellMiss(EnsureTargetEntry(units, u.damageDone.targets, targetGuid), spellId, key)
|
|
end
|
|
end
|
|
end
|
|
if targetGuid then
|
|
local attributed = AttributedGuid(targetGuid)
|
|
if IsTrackedGuid(attributed) then
|
|
local u = EnsureUnit(units, attributed)
|
|
BumpSpellMiss(u.damageTaken, spellId, key)
|
|
if casterGuid then
|
|
BumpSpellMiss(EnsureTargetEntry(units, u.damageTaken.targets, casterGuid), spellId, key)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- A spell (including yellow melee specials like Sinister Strike) that
|
|
-- missed/was resisted/dodged/etc. - SPELL_MISS_SELF/OTHER.
|
|
local function RecordSpellMiss(casterGuid, targetGuid, spellId, missInfo)
|
|
dataVersion = dataVersion + 1
|
|
if not spellId then return end
|
|
if not current then
|
|
if not ShouldLazyStart() then return end
|
|
StartEncounter()
|
|
end
|
|
local key = SPELL_MISS_KEY[missInfo] or "other"
|
|
RecordSpellMissInto(current.units, casterGuid, targetGuid, spellId, key)
|
|
RecordSpellMissInto(overall.units, casterGuid, targetGuid, spellId, key)
|
|
end
|
|
|
|
local function RecordHealingInto(units, casterGuid, targetGuid, spellId, spellName, amount, effective, overheal, isCrit, unverified)
|
|
if not casterGuid then return end
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if not IsTrackedGuid(attributed) then return end
|
|
local u = EnsureUnit(units, attributed)
|
|
AddHeal(u.healingDone, amount, effective, overheal, unverified)
|
|
RecordHealHit(EnsureSpellEntry(u.healingDone.spells, spellId, spellName, nil), amount, effective, overheal, isCrit, unverified)
|
|
|
|
if targetGuid then
|
|
local key, name = TargetKey(units, targetGuid)
|
|
local t = u.healingDone.targets[key]
|
|
if not t then
|
|
t = { name = name, total = 0, hits = 0, overheal = 0, spells = {} }
|
|
u.healingDone.targets[key] = t
|
|
end
|
|
t.hits = t.hits + 1
|
|
AddHeal(t, amount, effective, overheal, unverified)
|
|
RecordHealHit(EnsureSpellEntry(t.spells, spellId, spellName, nil), amount, effective, overheal, isCrit, unverified)
|
|
end
|
|
end
|
|
|
|
-- amount = raw heal from the event; effective/overheal come from
|
|
-- Events.lua's target-health estimate (verified = health was readable).
|
|
local function RecordHealing(casterGuid, targetGuid, spellId, spellName, amount, effective, overheal, isCrit, verified)
|
|
dataVersion = dataVersion + 1
|
|
-- Starts an encounter only while the player or group is in combat
|
|
-- (a healer healing before landing a hit). Out-of-combat top-offs and
|
|
-- trailing HoT ticks never start one.
|
|
if not current then
|
|
if not IsGroupFighting() then return end
|
|
StartEncounter()
|
|
end
|
|
local unverified = not verified
|
|
RecordHealingInto(current.units, casterGuid, targetGuid, spellId, spellName, amount, effective, overheal, isCrit, unverified)
|
|
RecordHealingInto(overall.units, casterGuid, targetGuid, spellId, spellName, amount, effective, overheal, isCrit, unverified)
|
|
if CL.Threat then CL.Threat.NoteHealing(current, casterGuid, effective) end
|
|
|
|
if casterGuid and IsTrackedGuid(AttributedGuid(casterGuid)) then
|
|
RecordSeriesPoint(current, "healing", effective)
|
|
end
|
|
end
|
|
|
|
-- Records one occurrence into a count-only bucket ("cleanses" or
|
|
-- "debuffsGiven"): total and per-spell/per-target counts.
|
|
local function RecordCountEventInto(units, bucketKey, casterGuid, targetGuid, spellId, spellName)
|
|
if not casterGuid then return end
|
|
local attributed = AttributedGuid(casterGuid)
|
|
if not IsTrackedGuid(attributed) then return end
|
|
local u = EnsureUnit(units, attributed)
|
|
local bucket = u[bucketKey]
|
|
bucket.total = bucket.total + 1
|
|
RecordHit(EnsureSpellEntry(bucket.spells, spellId, spellName, nil), 1, false)
|
|
|
|
if targetGuid then
|
|
local key, name = TargetKey(units, targetGuid)
|
|
local t = bucket.targets[key]
|
|
if not t then
|
|
t = { name = name, total = 0, hits = 0, spells = {} }
|
|
bucket.targets[key] = t
|
|
end
|
|
t.total = t.total + 1
|
|
t.hits = t.hits + 1
|
|
RecordHit(EnsureSpellEntry(t.spells, spellId, spellName, nil), 1, false)
|
|
end
|
|
end
|
|
|
|
local function RecordCleanse(casterGuid, targetGuid, spellId, spellName)
|
|
dataVersion = dataVersion + 1
|
|
-- Same combat-gated start as RecordHealing.
|
|
if not current then
|
|
if not IsGroupFighting() then return end
|
|
StartEncounter()
|
|
end
|
|
RecordCountEventInto(current.units, "cleanses", casterGuid, targetGuid, spellId, spellName)
|
|
RecordCountEventInto(overall.units, "cleanses", casterGuid, targetGuid, spellId, spellName)
|
|
end
|
|
|
|
local function RecordDebuffGiven(casterGuid, targetGuid, spellId, spellName)
|
|
dataVersion = dataVersion + 1
|
|
if not current then
|
|
if not ShouldLazyStart() then return end
|
|
StartEncounter()
|
|
end
|
|
RecordCountEventInto(current.units, "debuffsGiven", casterGuid, targetGuid, spellId, spellName)
|
|
RecordCountEventInto(overall.units, "debuffsGiven", casterGuid, targetGuid, spellId, spellName)
|
|
end
|
|
|
|
local function RecordDeath(guid)
|
|
dataVersion = dataVersion + 1
|
|
local attributed = guid
|
|
-- Pet deaths aren't counted (a pet dying isn't its owner dying).
|
|
if CL.GuidCache and CL.GuidCache.GetOwner(guid) then return nil end
|
|
if not IsTrackedGuid(attributed) then return nil end
|
|
-- Same combat-gated start as RecordHealing: a groupmate dying while
|
|
-- only the group is fighting belongs to that fight, not just Overall.
|
|
if not current and IsGroupFighting() then StartEncounter() end
|
|
local u = EnsureUnit(overall.units, attributed)
|
|
u.deaths = u.deaths + 1
|
|
if current then
|
|
local uc = EnsureUnit(current.units, attributed)
|
|
uc.deaths = uc.deaths + 1
|
|
end
|
|
SnapshotDeathRecap(attributed)
|
|
return attributed
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Test mode: a fabricated encounter for previewing appearance settings.
|
|
-- Built with the same helpers as real data, so breakdowns work on it.
|
|
--------------------------------------------------------------------------
|
|
|
|
local testEncounter = nil -- cached once generated, not regenerated every refresh
|
|
|
|
-- Amounts scale with `power` (1.0 = top of every mode).
|
|
local function FakeHits(bucket, entry, count, avgAmount, critPct)
|
|
local i
|
|
for i = 1, count do
|
|
local isCrit = (math.random(100) <= critPct)
|
|
local amount = math.floor(avgAmount * (0.8 + math.random() * 0.4))
|
|
if isCrit then amount = math.floor(amount * 1.8) end
|
|
RecordHit(entry, amount, isCrit)
|
|
bucket.total = bucket.total + amount
|
|
end
|
|
end
|
|
|
|
local TEST_SPELLS_WARLOCK = { { id = 172, name = "Corruption", school = "Shadow" }, { id = 686, name = "Shadow Bolt", school = "Shadow" } }
|
|
local TEST_SPELLS_GENERIC = { { id = 133, name = "Fireball", school = "Fire" }, { id = 585, name = "Smite", school = "Holy" } }
|
|
|
|
local function FakeDamageDone(u, power)
|
|
local bucket = u.damageDone
|
|
FakeHits(bucket, bucket.melee, math.floor(18 * power) + 4, 220 * power, 18)
|
|
local spellList = (u.classToken == "WARLOCK") and TEST_SPELLS_WARLOCK or TEST_SPELLS_GENERIC
|
|
local i
|
|
for i = 1, table.getn(spellList) do
|
|
local sp = spellList[i]
|
|
local entry = EnsureSpellEntry(bucket.spells, sp.id, sp.name, sp.school)
|
|
FakeHits(bucket, entry, math.floor(10 * power) + 2, 350 * power, 22)
|
|
end
|
|
|
|
-- Two targets, so the Targets list and "All Enemies" row show.
|
|
local bossTotal = math.floor(bucket.total * 0.7)
|
|
bucket.targets["TESTBOSS"] = { name = "Training Dummy", total = bossTotal, hits = math.floor((bucket.melee.hits or 0) * 0.7), spells = {}, melee = NewMeleeEntry(), offhand = NewMeleeEntry(), petMelee = NewMeleeEntry(), petOffhand = NewMeleeEntry() }
|
|
bucket.targets["TESTADD1"] = { name = "Training Dummy's Friend", total = bucket.total - bossTotal, hits = math.floor((bucket.melee.hits or 0) * 0.3), spells = {}, melee = NewMeleeEntry(), offhand = NewMeleeEntry(), petMelee = NewMeleeEntry(), petOffhand = NewMeleeEntry() }
|
|
end
|
|
|
|
local function FakeHealingDone(u, power)
|
|
local bucket = u.healingDone
|
|
local entry = EnsureSpellEntry(bucket.spells, 2050, "Lesser Heal", "Holy")
|
|
local i
|
|
for i = 1, math.floor(12 * power) + 2 do
|
|
local isCrit = (math.random(100) <= 15)
|
|
local amount = math.floor(300 * power * (0.8 + math.random() * 0.4))
|
|
if isCrit then amount = math.floor(amount * 1.5) end
|
|
local overheal = math.floor(amount * 0.15)
|
|
RecordHealHit(entry, amount, amount - overheal, overheal, isCrit, false)
|
|
AddHeal(bucket, amount, amount - overheal, overheal, false)
|
|
end
|
|
|
|
-- Two recipients for the "Healed" list.
|
|
local tankTotal = math.floor(bucket.total * 0.6)
|
|
bucket.targets["TESTTANK"] = { name = "Kaladin", total = tankTotal, hits = math.floor(entry.hits * 0.6), overheal = 0, spells = {} }
|
|
bucket.targets["TESTSELF"] = { name = u.name, total = bucket.total - tankTotal, hits = entry.hits - math.floor(entry.hits * 0.6), overheal = 0, spells = {} }
|
|
end
|
|
|
|
local function FakeDamageTaken(u, power)
|
|
local bucket = u.damageTaken
|
|
FakeHits(bucket, bucket.melee, math.floor(8 * power) + 3, 150 * power, 10)
|
|
|
|
-- One attacker for the "Attackers" list.
|
|
bucket.targets["TESTBOSS"] = { name = "Training Dummy", total = bucket.total, hits = bucket.melee.hits, spells = {}, melee = NewMeleeEntry(), offhand = NewMeleeEntry(), petMelee = NewMeleeEntry(), petOffhand = NewMeleeEntry() }
|
|
end
|
|
|
|
-- Fills a count-only bucket with one ability and one recipient.
|
|
local function FakeCountEvent(bucket, spellId, spellName, count, recipientGuid, recipientName)
|
|
local entry = EnsureSpellEntry(bucket.spells, spellId, spellName, nil)
|
|
local i
|
|
for i = 1, count do
|
|
RecordHit(entry, 1, false)
|
|
bucket.total = bucket.total + 1
|
|
end
|
|
if count > 0 then
|
|
bucket.targets[recipientGuid] = { name = recipientName, total = count, hits = count, spells = {} }
|
|
end
|
|
end
|
|
|
|
local function FakeCleanses(u, power)
|
|
FakeCountEvent(u.cleanses, 4987, "Cleanse", math.floor(3 * power), "TESTTANK", "Kaladin")
|
|
end
|
|
|
|
local function FakeDebuffsGiven(u, power)
|
|
FakeCountEvent(u.debuffsGiven, 172, "Corruption", math.floor(4 * power), "TESTBOSS", "Training Dummy")
|
|
end
|
|
|
|
-- name, classToken, power (1.0 = tops every mode; the first entry plays
|
|
-- the player).
|
|
local TEST_ROSTER = {
|
|
{ name = "Kobeni", classToken = "WARLOCK", power = 1.0, isPlayer = true },
|
|
{ name = "Kaladin", classToken = "WARRIOR", power = 0.92 },
|
|
{ name = "Szeth", classToken = "ROGUE", power = 0.88 },
|
|
{ name = "Dalinar", classToken = "PALADIN", power = 0.85 },
|
|
{ name = "Shallan", classToken = "MAGE", power = 0.75 },
|
|
{ name = "Jasnah", classToken = "PRIEST", power = 0.7 },
|
|
{ name = "Adolin", classToken = "WARRIOR", power = 0.65 },
|
|
{ name = "Teft", classToken = "HUNTER", power = 0.6 },
|
|
{ name = "Renarin", classToken = "DRUID", power = 0.55 },
|
|
{ name = "Navani", classToken = "SHAMAN", power = 0.5 },
|
|
}
|
|
|
|
-- Built directly rather than via EnsureUnit, whose GuidCache.Resolve
|
|
-- expects real GUIDs.
|
|
local function NewTestUnit(r)
|
|
return {
|
|
name = r.name,
|
|
class = r.classToken,
|
|
classToken = r.classToken,
|
|
isPlayer = r.isPlayer,
|
|
damageDone = NewBucket(),
|
|
healingDone = { total = 0, overheal = 0, spells = {}, targets = {} },
|
|
damageTaken = NewBucket(),
|
|
cleanses = { total = 0, spells = {}, targets = {} },
|
|
debuffsGiven = { total = 0, spells = {}, targets = {} },
|
|
deaths = 0,
|
|
}
|
|
end
|
|
|
|
local function GenerateTestEncounter()
|
|
local enc = NewEncounter()
|
|
enc.label = "Test Data"
|
|
enc.duration = 60
|
|
local i
|
|
for i = 1, table.getn(TEST_ROSTER) do
|
|
local r = TEST_ROSTER[i]
|
|
local guid = "TESTUNIT" .. i
|
|
local u = NewTestUnit(r)
|
|
enc.units[guid] = u
|
|
FakeDamageDone(u, r.power)
|
|
FakeHealingDone(u, r.power)
|
|
FakeDamageTaken(u, r.power)
|
|
FakeCleanses(u, r.power)
|
|
FakeDebuffsGiven(u, r.power)
|
|
end
|
|
enc.units["TESTUNIT7"].deaths = 1 -- Adolin - one fake death, for previewing Deaths mode
|
|
return enc
|
|
end
|
|
|
|
local function GetTestEncounter()
|
|
if not testEncounter then
|
|
testEncounter = GenerateTestEncounter()
|
|
end
|
|
return testEncounter
|
|
end
|
|
|
|
local function ClearTestEncounter()
|
|
testEncounter = nil
|
|
end
|
|
|
|
CL.Aggregator = {
|
|
SERIES_BUCKET_SECONDS = TIMELINE_BUCKET_SECONDS,
|
|
StartEncounter = StartEncounter,
|
|
EndEncounter = EndEncounter,
|
|
GetCurrent = GetCurrent,
|
|
GetCurrentDisplay = GetCurrentDisplay,
|
|
GetOverall = GetOverall,
|
|
GetOverallDuration = GetOverallDuration,
|
|
ResetData = ResetData,
|
|
MarkBoss = MarkBoss,
|
|
CompactTargets = CompactTargets,
|
|
GetDataVersion = function() return dataVersion end,
|
|
GetDeathRecap = GetDeathRecap,
|
|
SnapshotDeathRecap = SnapshotDeathRecap, -- exposed for /cl testdeath - doesn't touch the real death counter
|
|
RecordDamage = RecordDamage,
|
|
RecordAvoidance = RecordAvoidance,
|
|
RecordSpellMiss = RecordSpellMiss,
|
|
RecordHealing = RecordHealing,
|
|
RecordCast = RecordCast,
|
|
RecordCleanse = RecordCleanse,
|
|
RecordDebuffGiven = RecordDebuffGiven,
|
|
RecordDeath = RecordDeath,
|
|
GetTestEncounter = GetTestEncounter,
|
|
ClearTestEncounter = ClearTestEncounter,
|
|
SerializeState = SerializeState,
|
|
RestoreState = RestoreState,
|
|
}
|