-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCalc.lua
More file actions
308 lines (282 loc) · 13.2 KB
/
Calc.lua
File metadata and controls
308 lines (282 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
local addonName, ns = ...
ns.calc = {}
-- Bear form id in TBC Classic. Shapeshift index 1 for druids is Bear Form
-- (learned at level 10 and the only relevant tank form here). We treat
-- Dire Bear Form (same index, rank 2) identically.
local BEAR_FORM_ID = 1
local function clampNonNeg(v)
if v < 0 then return 0 end
return v
end
local function isBearForm()
local formId = GetShapeshiftFormID and GetShapeshiftFormID()
return formId == BEAR_FORM_ID
end
-- Shield check via offhand slot (17 / SecondaryHandSlot) rather than via
-- GetBlockChance. Warriors and Paladins carry passive block chance from
-- talents (Shield Specialization, Redoubt, Anticipation) even with no
-- shield equipped, so GetBlockChance > 0 is a false positive — they would
-- get a CRUSHABLE verdict for a cap they cannot reach. Checking equipLoc
-- against INVTYPE_SHIELD is locale-independent and directly answers the
-- question we actually care about: can this character block right now?
local function hasShieldEquipped()
if not GetInventoryItemLink then return false end
local itemLink = GetInventoryItemLink("player", 17)
if not itemLink or not GetItemInfo then return false end
local _, _, _, _, _, _, _, _, equipLoc = GetItemInfo(itemLink)
return equipLoc == "INVTYPE_SHIELD"
end
-- Pick the display mode based on what the character can actually do right
-- now, not on their class:
-- "druid-special" — druids, always; block doesn't exist on their attack
-- table, so we show Defense/Armor goals instead.
-- "block" — a shield is equipped, so the 102.4% cap is
-- reachable and we render the UNCRUSHABLE verdict.
-- "no-verdict" — anyone else (DPS without shield, casters, …). We
-- still show Miss/Dodge/Parry for reference but no
-- UNCRUSHABLE verdict, because the cap is unreachable
-- without block and meaningless in that context.
local function determineMode(classFile)
if classFile == "DRUID" then return "druid-special" end
if hasShieldEquipped() then return "block" end
return "no-verdict"
end
-- Compute the anti-crit cap state vs a +3 raid boss. The boss crits at 5.6%
-- (5% base + 0.6% from the 15 weapon-skill diff) and anti-crit means
-- offsetting that to 0% via three additive sources:
-- 1. Defense skill > 350 → 0.04% per point
-- 2. Resilience rating → ~1% per 39.42 rating; read directly via
-- GetCombatRatingBonus, which already
-- converts rating to %
-- 3. Class talent: druid SotF → 3% flat (passive, assumed talented for
-- any druid that opens this addon)
-- Returns nil for non-tank shapes (no shield + non-druid). See docs/adr/0004
-- for the derivation and TBC-vs-WotLK note on Resilience applying vs PvE.
local function computeAntiCrit(classFile, mode, defSkill)
if mode ~= "block" and mode ~= "druid-special" then return nil end
local fromDefense = math.max(0, (defSkill - 350) * 0.04)
local fromTalents = (classFile == "DRUID") and ns.SOTF_CRIT_REDUCTION or 0
local fromResil = 0
local resilRating = 0
if ns.CR_RESILIENCE then
if GetCombatRatingBonus then
fromResil = GetCombatRatingBonus(ns.CR_RESILIENCE) or 0
end
if GetCombatRating then
resilRating = GetCombatRating(ns.CR_RESILIENCE) or 0
end
end
local total = fromDefense + fromTalents + fromResil
return {
target = ns.BOSS_CRIT_VS_PLUS3,
fromDefense = fromDefense,
fromTalents = fromTalents,
fromResilience = fromResil,
resilienceRating = resilRating,
total = total,
ok = total >= ns.BOSS_CRIT_VS_PLUS3,
shortBy = math.max(0, ns.BOSS_CRIT_VS_PLUS3 - total),
}
end
-- Estimate the avoidance delta a single buff adds, per class. Only models
-- effects that directly touch a component of the avoidance table — crit
-- rating, haste, armor, stamina etc. from the same items are ignored.
--
-- Returns a table with miss/dodge/parry/block entries (defaulted to 0).
local function deltaForBuff(key, agiPerDodge, currentAgi)
if key == "flaskFort" then
-- +10 Defense Rating → ~4.228 Defense Skill → +0.04% on each of
-- miss/dodge/parry/block per skill gained.
local pct = (10 / ns.DEFENSE_RATING_PER_SKILL) * 0.04
return { miss = pct, dodge = pct, parry = pct, block = pct }
elseif key == "motw" then
-- Gift of the Wild (rank 3): +14 agility flat.
return { dodge = 14 / agiPerDodge }
elseif key == "elixirMajorAgility" then
-- +35 agility; the +20 crit rating on the same elixir does not
-- touch the avoidance table.
return { dodge = 35 / agiPerDodge }
elseif key == "scrollAgility" then
-- Scroll of Agility VIII: +25 agility.
return { dodge = 25 / agiPerDodge }
elseif key == "bok" then
-- Greater Blessing of Kings: +10% all stats, multiplicative on
-- the pre-buff value. Using current agility as the multiplicand
-- is exact when no other % stat buffs are active and a close
-- approximation when they are (the error is the cross-term).
return { dodge = (currentAgi * 0.1) / agiPerDodge }
elseif key == "holyShield" then
-- Paladin's Holy Shield: +30% Block chance while the buff is up.
-- The class gate sits in the UI (only paladins see the toggle),
-- so no class check is needed here.
--
-- Libram of Repentance (equipped in the ranged slot = 18) adds
-- another ~5.326% block chance specifically while Holy Shield is
-- active. When HS is actually on, GetBlockChance already includes
-- this — we only stack it onto the planned delta when simulating
-- HS being on.
local blockDelta = 30.0
if GetInventoryItemID then
local itemId = GetInventoryItemID("player", 18)
if itemId == ns.LIBRAM_OF_REPENTANCE_ITEM_ID then
blockDelta = blockDelta + ns.LIBRAM_OF_REPENTANCE_BLOCK_DELTA
end
end
return { block = blockDelta }
elseif key == "shieldBlock" then
-- Warrior's Shield Block: +75% Block chance for 5s. With
-- Improved Shield Block uptime approaches 100% in practice.
return { block = 75.0 }
end
return {}
end
-- Sum the deltas of all planned buffs that are NOT currently active.
-- Active buffs are already reflected in GetDodgeChance etc., so simulating
-- them would double-count.
function ns.calc:SimulatePlannedDelta(classFile, plannedSet, activeSet)
local zero = { miss = 0, dodge = 0, parry = 0, block = 0, count = 0 }
if not plannedSet then return zero end
local agiPerDodge = ns.AGI_PER_DODGE_PCT[classFile or ""] or ns.DEFAULT_AGI_PER_DODGE_PCT
local currentAgi = 0
if UnitStat then
local stat = UnitStat("player", 2) -- 2 = Agility
currentAgi = stat or 0
end
local total = { miss = 0, dodge = 0, parry = 0, block = 0, count = 0 }
for key, enabled in pairs(plannedSet) do
if enabled and not (activeSet and activeSet[key]) then
local d = deltaForBuff(key, agiPerDodge, currentAgi)
total.miss = total.miss + (d.miss or 0)
total.dodge = total.dodge + (d.dodge or 0)
total.parry = total.parry + (d.parry or 0)
total.block = total.block + (d.block or 0)
total.count = total.count + 1
end
end
return total
end
-- Compute a full defensive snapshot for the current player vs a +3 raid
-- boss (the only TBC target where crushing blows exist, which is the
-- scenario the 102.4% uncrushable cap was designed for).
--
-- ctx fields consumed:
-- classFile — "WARRIOR" | "PALADIN" | "DRUID" | … | nil (auto-detect)
-- activeSet — { [buffKey] = true } currently applied buffs
-- plannedSet — { [buffKey] = true } user-toggled planned buffs
--
-- Returned table shape:
-- {
-- classFile, classInfo,
-- mode = "block" | "druid-special" | "no-verdict",
-- inBearForm = bool, -- druid only
-- defenseSkill,
-- miss, dodge, parry, block, total,
-- isUncrushable = bool | nil, -- nil when not applicable
-- shortBy = number | nil, -- how far below 102.4 we are
-- components = { miss = {...}, dodge = {...}, parry = {...}, block = {...} },
-- antiCrit = { target, fromDefense, fromTalents, fromResilience,
-- resilienceRating, total, ok, shortBy } | nil,
-- simulated = { -- with planned buffs applied
-- delta = { miss, dodge, parry, block, count },
-- total, isUncrushable, shortBy,
-- },
-- notes = { "string", ... },
-- }
function ns.calc:ComputeSnapshot(ctx)
ctx = ctx or {}
local classFile = ctx.classFile or (UnitClass and select(2, UnitClass("player")))
local classInfo = ns.classInfo[classFile or ""] or { label = classFile or "Unknown" }
local mode = determineMode(classFile)
local snap = {
classFile = classFile,
classInfo = classInfo,
mode = mode,
notes = {},
components = {},
}
-- Defense Skill: UnitDefense returns (base, modifier); their sum is what
-- the combat table actually uses.
local defBase, defMod = UnitDefense("player")
local defSkill = (defBase or 0) + (defMod or 0)
snap.defenseSkill = defSkill
if classFile == "DRUID" then
snap.inBearForm = isBearForm()
if not snap.inBearForm then
table.insert(snap.notes, "Switch to Bear Form to read your actual tanking stats.")
end
end
-- Avoidance components vs +3 raid boss, computed in character-sheet
-- space so the sum compares directly against TARGET_CAP = 102.4%.
-- That target already absorbs the 2.4% the server removes via a
-- 0.04%-per-skill-deficit applied to each of Miss/Dodge/Parry/Block;
-- subtracting it again here would be a double-count. See ADR 0003
-- postscript for the derivation and the five peer sources that
-- corroborate this interpretation.
local miss = clampNonNeg(ns.BASE_MISS + (defSkill - 350) * 0.04)
local dodge = GetDodgeChance() or 0
local parry = GetParryChance() or 0
local block = 0
if mode == "block" then
block = GetBlockChance() or 0
end
snap.miss = miss
snap.dodge = dodge
snap.parry = parry
snap.block = block
snap.total = miss + dodge + parry + block
snap.components.miss = { value = miss, label = "Miss", formula = "5 + (def-350)*0.04" }
snap.components.dodge = { value = dodge, label = "Dodge", formula = "GetDodgeChance()" }
snap.components.parry = { value = parry, label = "Parry", formula = "GetParryChance()" }
snap.components.block = {
value = block,
label = "Block",
formula = "GetBlockChance()",
applicable = mode == "block",
}
-- The UNCRUSHABLE verdict only applies to block-capable characters
-- (shield-wearers). Druids have no block slot on their attack table,
-- non-block classes can't close the 102.4% gap. Crushing blows are a
-- +3-boss mechanic so all of this is framed against that target.
local verdictApplies = mode == "block"
if verdictApplies then
snap.isUncrushable = snap.total >= ns.TARGET_CAP
if not snap.isUncrushable then
snap.shortBy = ns.TARGET_CAP - snap.total
end
else
-- Druid-special and no-verdict: no avoidance verdict applies. The
-- anti-crit cap (computed below) is the actionable goal for druids;
-- no-verdict characters get only the breakdown.
snap.isUncrushable = nil
end
-- Anti-crit cap breakdown (defense skill + resilience + talents vs the
-- 5.6% boss crit chance). Computed for all tank-shaped characters so
-- warriors/paladins running PvP gear with Resilience can see whether
-- they're crit-immune even below 490 defense skill. nil for non-tanks.
snap.antiCrit = computeAntiCrit(classFile, mode, defSkill)
-- Planned-buff projection. Runs regardless of mode so a non-block
-- class still sees "with planned buffs you'd gain X%" if they care,
-- but the UNCRUSHABLE verdict on the projection is only meaningful
-- in block mode at +3.
--
-- If the character can't block (no shield or druid), zero the block
-- delta so a planned Flask / Shield Block / Holy Shield doesn't
-- inflate the projected total with a bonus that wouldn't apply.
local delta = self:SimulatePlannedDelta(classFile, ctx.plannedSet, ctx.activeSet)
if mode ~= "block" then
delta.block = 0
end
local simTotal = snap.total + delta.miss + delta.dodge + delta.parry + delta.block
local simulated = {
delta = delta,
total = simTotal,
}
if verdictApplies then
simulated.isUncrushable = simTotal >= ns.TARGET_CAP
if not simulated.isUncrushable then
simulated.shortBy = ns.TARGET_CAP - simTotal
end
end
snap.simulated = simulated
return snap
end