diff --git a/RelationshipsDashboard.lua b/RelationshipsDashboard.lua new file mode 100644 index 0000000..2ccd21f --- /dev/null +++ b/RelationshipsDashboard.lua @@ -0,0 +1,4248 @@ +------------------------------------------------------------------------------- +-- RelationshipsDashboard - Character Stats, GearScore & Repair Cost for Turtle WoW / OctoWoW +-- Compatible with WoW 1.12.1 (Interface 11200) / Lua 5.0 +------------------------------------------------------------------------------- + +-- Constants +local RELATIONSHIPS_DASHBOARD_VERSION = "1.0.0"; + +-- Current unit being displayed. Defaults to "player"; switched to "target" +-- when the InspectFrame opens on another player. +RD_UNIT = "player"; + +-- Equipment slot IDs (vanilla 1.12) +local EQUIPMENT_SLOTS = { + 1, -- HeadSlot + 2, -- NeckSlot + 3, -- ShoulderSlot + 5, -- ChestSlot + 6, -- WaistSlot + 7, -- LegsSlot + 8, -- FeetSlot + 9, -- WristSlot + 10, -- HandsSlot + 11, -- Finger0Slot + 12, -- Finger1Slot + 13, -- Trinket0Slot + 14, -- Trinket1Slot + 15, -- BackSlot + 16, -- MainHandSlot + 17, -- OffHandSlot + 18, -- RangedSlot +}; + +-- Slot names for display +local SLOT_NAMES = { + [1] = "Head", + [2] = "Neck", + [3] = "Shoulder", + [5] = "Chest", + [6] = "Waist", + [7] = "Legs", + [8] = "Feet", + [9] = "Wrist", + [10] = "Hands", + [11] = "Ring 1", + [12] = "Ring 2", + [13] = "Trinket 1", + [14] = "Trinket 2", + [15] = "Back", + [16] = "Main Hand", + [17] = "Off Hand", + [18] = "Ranged", +}; + +-- Note: The GearScore formula (rarity * itemLevel) does not use slot weights or quality multipliers. +-- These constants are kept for potential future use in advanced scoring modes. +-- Current GearScore formula: score = rarity * itemLevel * (twoHand ? 2 : 1) + +-- Resistance indices in vanilla WoW +-- 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane +local RESISTANCE_INDICES = { + arcane = 6, + fire = 2, + nature = 3, + frost = 4, + shadow = 5, +}; + +-- School indices for spell crit/hit (vanilla 1.12) +-- 1=Physical, 2=Holy, 3=Fire, 4=Nature, 5=Frost, 6=Shadow, 7=Arcane +local SPELL_SCHOOL_INDEX = 6; -- Use Arcane as representative for generic spell stats + +-- Tooltip scanner for item info +local RelationshipsDashboardTooltip = nil; + +------------------------------------------------------------------------------- +-- Durability persistence system: Tracks durability loss from deaths, combat +-- hits, and repair events. When GetInventoryItemDurability() fails (which is +-- common on Turtle WoW / OctoWoW outside repair NPCs), we use these stored +-- estimates as a fallback so the dashboard always shows meaningful values. +-- +-- How it works: +-- 1. On PLAYER_ENTERING_WORLD and after each update, try to read actual +-- durability via GetInventoryItemDurability or tooltip scanning. +-- 2. When actual values are available, store them in RD_SavedDurability. +-- 3. When a player dies (PLAYER_DEAD), reduce all items' estimated +-- durability by 10% (standard WoW death penalty = 10% of max dur). +-- 4. While in combat, increment hit counters. On UNIT_HEALTH damage taken, +-- apply small durability decay proportional to hits received. +-- 5. At repair NPCs, use GetRepairAllCost() as exact ground truth. +-- 6. When displaying, use actual if available, else use saved estimates. +------------------------------------------------------------------------------- +-- NOTE: RD_SavedDurability is intentionally GLOBAL (no `local`) so that it +-- can be persisted through the SavedVariable RelationshipsDashboardDB_SavedDurability +-- across /reload and sessions. Rebound in OnLoad after saved vars load. +RD_SavedDurability = {}; -- [slotId] = { cur = X, max = Y } +local RD_InCombat = false; +local RD_CombatHitCount = 0; +local RD_LastHealth = 0; +local RD_InBattleground = false; -- True while in a BG (no durability loss in BGs) +local RD_InDuel = false; -- True during a duel (no durability loss in duels) + +-- Track ghost state so we can detect spirit-healer resurrection +-- (25% durability loss to all equipped + inventory items). +RD_WasGhost = false; +RD_PendingSpiritRez = false; +RD_LastMoney = 0; -- Track for detecting non-merchant repair (e.g. guild bank repair) +-- True while a repair-CAPABLE merchant window is open. Used to decide when a +-- durability reading of "full" can be trusted (see CalculateDurabilityAndRepair). +RD_AtRepairMerchant = false; +-- Per-character multiplier that scales our away-from-merchant repair estimate so +-- it matches the exact cost the game charges. Learned automatically the first +-- time the player opens a repair merchant with damaged gear. Persisted in +-- RelationshipsDashboardDB.repairCalibration. +RD_RepairCalibration = 1.0; +local RD_REPAIR_CALIBRATION_VERSION = 2; + +-- Battleground zone names for vanilla WoW 1.12 (English only — these match GetRealZoneText()) +local RD_BATTLEGROUND_ZONES = { + -- Blizzard vanilla battlegrounds + ["Alterac Valley"] = true, + ["Warsong Gulch"] = true, + ["Arathi Basin"] = true, + -- Turtle WoW / OctoWoW custom or renamed battlegrounds + ["Gurubashi Arena"] = true, + ["Silithus"] = false, -- world PvP zone, NOT a real BG; do not skip durability here + ["Warsong Battleground"] = true, + ["Sunnyglade Valley"] = true, -- Turtle WoW custom BG + ["Blackrock Depths (Arena)"] = true, + ["Lower Blackrock Spire"] = false, -- not a BG (guard against false positives) +}; + +-- Extra localized BG names Turtle/OctoWoW clients ship with. Kept separate +-- so the vanilla table above stays authoritative; both are consulted. +local RD_BATTLEGROUND_ZONES_EXTRA = { + -- German + ["Alteractal"] = true, + ["Kriegshymnenschlucht"] = true, + ["Arathibecken"] = true, + -- French + ["Vallee d'Alterac"] = true, + ["Vall\195\169e d'Alterac"] = true, + ["Goulet des Chanteguerres"] = true, + ["Bassin d'Arathi"] = true, + -- Spanish / Portuguese + ["Valle de Alterac"] = true, + ["Garganta Grito de Guerra"] = true, + ["Cuenca de Arathi"] = true, + -- Russian + [chr and chr(1040) or "\208\144"] = false, -- placeholder, real names below + ["\208\144\208\187\209\140\209\130\208\181\209\128\208\176\208\186\209\129\208\186\208\176\209\143 \208\180\208\190\208\187\208\184\208\189\208\176"] = true, +}; + +-- Slots that can have durability (armor + weapons, NOT accessories) +local RD_DURABILITY_SLOTS = { + [1] = true, -- HeadSlot + [3] = true, -- ShoulderSlot + [5] = true, -- ChestSlot + [7] = true, -- LegsSlot + [8] = true, -- FeetSlot + [9] = true, -- WristSlot + [10] = true, -- HandsSlot + [15] = true, -- BackSlot (some cloaks have durability) + [16] = true, -- MainHandSlot + [17] = true, -- OffHandSlot + [18] = true, -- RangedSlot +}; + +-- Default max durability for items when we can't read it +-- Vanilla WoW items typically have 35-165 max durability based on slot +local RD_DEFAULT_MAX_DURABILITY = { + [1] = 85, -- HeadSlot + [3] = 85, -- ShoulderSlot + [5] = 135, -- ChestSlot + [7] = 105, -- LegsSlot + [8] = 85, -- FeetSlot + [9] = 55, -- WristSlot + [10] = 85, -- HandsSlot + [15] = 55, -- BackSlot + [16] = 105, -- MainHandSlot + [17] = 105, -- OffHandSlot + [18] = 85, -- RangedSlot +}; + +-- Helper: Reset tooltip for fresh scan +-- In vanilla 1.12, tooltips can get "stuck" with stale data +-- Always call this before SetInventoryItem or SetHyperlink +local function RelationshipsDashboard_ResetTooltip() + if RelationshipsDashboardTooltip then + RelationshipsDashboardTooltip:SetOwner(WorldFrame, "ANCHOR_NONE"); + RelationshipsDashboardTooltip:ClearLines(); + end +end + +-- Player class cached on login +local RelationshipsDashboard_PlayerClass = nil; + +------------------------------------------------------------------------------- +-- Helper: Check if the player is currently in a battleground +-- In vanilla WoW 1.12, players do NOT take durability loss from death or +-- combat while in a battleground. We check using zone name and also +-- MiniMapBattlefieldFrame visibility as a fallback. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_IsInBattleground() + -- Method 1: Zone name check (English + a few localized variants) + local zone = GetRealZoneText and GetRealZoneText() or nil; + if zone and (RD_BATTLEGROUND_ZONES[zone] or RD_BATTLEGROUND_ZONES_EXTRA[zone]) then + return true; + end + local subZone = GetSubZoneText and GetSubZoneText() or nil; + if subZone and (RD_BATTLEGROUND_ZONES[subZone] or RD_BATTLEGROUND_ZONES_EXTRA[subZone]) then + return true; + end + -- Method 2: PvP zone info reports "combat" for active battlefields + if GetZonePVPInfo then + local pvpType = GetZonePVPInfo(); + if pvpType == "combat" then + return true; + end + end + -- Method 3: Ask the battlefield queue system directly. In 1.12 there are + -- up to MAX_BATTLEFIELD_QUEUES (3) slots. A status of "active" means the + -- player is currently inside that battleground. This is the most reliable + -- signal on Turtle WoW / OctoWoW even when zone strings do not match. + if GetBattlefieldStatus then + local maxQueues = MAX_BATTLEFIELD_QUEUES or 3; + for i = 1, maxQueues do + local status = GetBattlefieldStatus(i); + if status == "active" then + return true; + end + end + end + -- Method 4: MiniMapBattlefieldFrame is shown when inside a BG (fallback) + if MiniMapBattlefieldFrame and MiniMapBattlefieldFrame:IsVisible() then + local status = MiniMapBattlefieldFrame.status; + if status == "active" then + return true; + end + end + return false; +end + +-- Snapshot of equipped durability captured on BG entry so we can restore +-- exactly if any event mutates our tracker mid-BG (deaths, spirit rez, etc). +RD_BG_DurabilitySnapshot = nil; + +local function RelationshipsDashboard_SnapshotDurabilityForBG() + RD_BG_DurabilitySnapshot = {}; + for slotId, _ in pairs(RD_DURABILITY_SLOTS) do + local saved = RD_SavedDurability and RD_SavedDurability[slotId]; + if saved and saved.max then + RD_BG_DurabilitySnapshot[slotId] = { cur = saved.cur, max = saved.max }; + end + end +end + +local function RelationshipsDashboard_RestoreDurabilityFromBGSnapshot() + if not RD_BG_DurabilitySnapshot then return; end + for slotId, snap in pairs(RD_BG_DurabilitySnapshot) do + local saved = RD_SavedDurability and RD_SavedDurability[slotId]; + if saved then + saved.cur = snap.cur; + saved.max = snap.max; + end + end +end +-- Tracked mana values for when druid shifts to feral forms +-- In vanilla 1.12, UnitMana(RD_UNIT) returns the current form's power only +-- We store the caster-form mana to display it alongside Energy/Rage +local RelationshipsDashboard_LastKnownMana = 0; +local RelationshipsDashboard_LastKnownMaxMana = 0; + +------------------------------------------------------------------------------- +-- Helper: Check if player's class uses mana (has a mana pool regardless of form) +-- This is class-based, not form-based, so druids in feral forms still return true +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetUnitClass() + -- Get class for RD_UNIT (defaults to "player", switches to "target" during inspect) + local _, class = UnitClass(RD_UNIT); + if not class or class == "" then + if RD_UNIT == "player" then + if not RelationshipsDashboard_PlayerClass then + _, RelationshipsDashboard_PlayerClass = UnitClass("player"); + end + class = RelationshipsDashboard_PlayerClass; + end + end + -- Cache player class when we can + if RD_UNIT == "player" and class and class ~= "" then + RelationshipsDashboard_PlayerClass = class; + end + return class; +end + +local function RelationshipsDashboard_ClassUsesMana() + local class = RelationshipsDashboard_GetUnitClass(); + return class == "MAGE" + or class == "WARLOCK" + or class == "PRIEST" + or class == "DRUID" + or class == "SHAMAN" + or class == "PALADIN" + or class == "HUNTER"; +end + +------------------------------------------------------------------------------- +-- Helper: Check if unit is currently displaying mana (powerType == 0) +------------------------------------------------------------------------------- +local function RelationshipsDashboard_IsManaUser() + local powerType = UnitPowerType(RD_UNIT); + return powerType == 0; +end + +------------------------------------------------------------------------------- +-- Helper: Check if unit uses spell power (caster class) +------------------------------------------------------------------------------- +local function RelationshipsDashboard_IsCaster() + local class = RelationshipsDashboard_GetUnitClass(); + return class == "MAGE" + or class == "WARLOCK" + or class == "PRIEST" + or class == "DRUID" + or class == "SHAMAN" + or class == "PALADIN"; +end + +------------------------------------------------------------------------------- +-- Helper: Check if unit uses ranged weapons (Hunter primarily) +------------------------------------------------------------------------------- +local function RelationshipsDashboard_HasRanged() + local class = RelationshipsDashboard_GetUnitClass(); + return class == "HUNTER" + or class == "WARRIOR" + or class == "ROGUE"; +end + +------------------------------------------------------------------------------- +-- Dynamic layout system: Re-anchors all visible rows sequentially after +-- class visibility is applied. This eliminates gaps from hidden rows +-- (e.g., hidden Ranged stats in Enhancements, hidden Block in Defenses). +-- +-- Layout order definition: +-- Each section = { headerName, { row1, row2, ... } } +-- Rows are re-anchored TOPLEFT to the previous visible element's BOTTOMLEFT. +-- Section headers get a -4 spacing gap from the previous row. +------------------------------------------------------------------------------- +local RelationshipsDashboard_LayoutSections = { + { "", { + "RelationshipsDashboardGearScoreRow", + "RelationshipsDashboardAvgILvlRow", + }}, + { "RDHeaderGeneral", { + "RelationshipsDashboardHealthRow", + "RelationshipsDashboardPowerRow", + "RelationshipsDashboardManaRow", + "RelationshipsDashboardArmorRow", + "RelationshipsDashboardMovementRow", + "RelationshipsDashboardDurabilityRow", + "RelationshipsDashboardRepairCostRow", + }}, + { "RDHeaderAttributes", { + "RelationshipsDashboardStrengthRow", + "RelationshipsDashboardAgilityRow", + "RelationshipsDashboardStaminaRow", + "RelationshipsDashboardIntellectRow", + "RelationshipsDashboardSpiritRow", + }}, + { "RDHeaderOffense", { + "RelationshipsDashboardMeleeDmgRow", + "RelationshipsDashboardMeleeAPRow", + "RelationshipsDashboardMeleeSpeedRow", + "RelationshipsDashboardWeaponDPSRow", + "RelationshipsDashboardWeaponSkillRow", + "RelationshipsDashboardMeleeHitRow", + "RelationshipsDashboardMeleeCritRow", + }}, + { "RDHeaderEnhancements", { + "RelationshipsDashboardRangedAPRow", + "RelationshipsDashboardRangedCritRow", + "RelationshipsDashboardRangedSpeedRow", + "RelationshipsDashboardRangedDmgRow", + "RelationshipsDashboardSpellPowerRow", + "RelationshipsDashboardHealingPowerRow", + "RelationshipsDashboardSpellHitRow", + "RelationshipsDashboardSpellCritRow", + "RelationshipsDashboardManaRegenRow", + }}, + { "RDHeaderDefenses", { + "RelationshipsDashboardDefenseRow", + "RelationshipsDashboardDodgeRow", + "RelationshipsDashboardParryRow", + "RelationshipsDashboardBlockRow", + "RelationshipsDashboardAvoidanceRow", + }}, + { "RDHeaderResistances", { + "RelationshipsDashboardArcaneResRow", + "RelationshipsDashboardFireResRow", + "RelationshipsDashboardNatureResRow", + "RelationshipsDashboardFrostResRow", + "RelationshipsDashboardShadowResRow", + }}, +}; + +------------------------------------------------------------------------------- +-- Helper: iterate every frame that lives inside the scrollable stat content. +-- Vanilla 1.12 ScrollFrames do not clip nested child Frames reliably, so these +-- frames need explicit level management and viewport culling. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ForEachScrollElement(callback) + for secIdx = 1, table.getn(RelationshipsDashboard_LayoutSections) do + local section = RelationshipsDashboard_LayoutSections[secIdx]; + local headerName = section[1]; + local rowNames = section[2]; + + if headerName and headerName ~= "" then + local header = getglobal(headerName); + if header then + callback(header); + end + end + + for rowIdx = 1, table.getn(rowNames) do + local row = getglobal(rowNames[rowIdx]); + if row then + callback(row); + end + end + end +end + +local function RelationshipsDashboard_SetLayoutShown(element, shown) + if not element then return; end + + -- rdLayoutShown is the real layout intent. Do NOT infer layout from + -- IsShown(), because viewport clipping also uses Hide()/Show() and can + -- temporarily hide otherwise-valid rows while they are scrolled out. + element.rdLayoutShown = shown; + element.rdIntendedShown = shown; + + if shown then + if not element:IsShown() then element:Show(); end + else + if element:IsShown() then element:Hide(); end + end +end + +------------------------------------------------------------------------------- +-- Hard clip workaround for Vanilla/Turtle clients. +-- The ScrollFrame viewport clips the ScrollChild's own regions, but child Frames +-- inside that ScrollChild can still draw outside the viewport. We hide any row +-- or header that is not fully inside the scroll viewport, preventing text from +-- drawing above the title bar or below the panel. +------------------------------------------------------------------------------- +function RelationshipsDashboard_UpdateViewportClip() + if not RelationshipsDashboardScrollFrame then return; end + + local viewTop = RelationshipsDashboardScrollFrame:GetTop(); + local viewBottom = RelationshipsDashboardScrollFrame:GetBottom(); + if not viewTop or not viewBottom then return; end + + -- Vanilla/Turtle 1.12: SetAlpha(0) does NOT cascade to child FontStrings + -- inside a Frame, so alpha-clipping leaves overlapping labels visible when + -- rows scroll out of the viewport. We must Hide()/Show() the actual row + -- frames instead. To avoid re-showing rows that ApplyClassVisibility + -- intentionally hid (e.g. Ranged stats for casters), we consult the + -- `rdIntendedShown` flag set at the end of ApplyClassVisibility. + RelationshipsDashboard_ForEachScrollElement(function(element) + -- Never override an intentional class-based hide. + if element.rdIntendedShown == false then + if element:IsShown() then element:Hide(); end + return; + end + + local elemTop = element:GetTop(); + local elemBottom = element:GetBottom(); + local inView = elemTop and elemBottom + and elemTop <= (viewTop + 0.5) + and elemBottom >= (viewBottom - 0.5); + + if inView then + if not element:IsShown() then element:Show(); end + element:SetAlpha(1); + else + if element:IsShown() then element:Hide(); end + end + end); +end + +------------------------------------------------------------------------------- +-- Show/Hide stats based on class role, then re-anchor all visible rows +-- so hidden rows don't leave gaps in the layout. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ApplyClassVisibility() + local class = RelationshipsDashboard_GetUnitClass(); + local isCaster = RelationshipsDashboard_IsCaster(); + local hasRanged = RelationshipsDashboard_HasRanged(); + local isManaUser = RelationshipsDashboard_IsManaUser(); + local classUsesMana = RelationshipsDashboard_ClassUsesMana(); + + -- Reset every header/row to its normal layout state before applying + -- class-specific hides. This deliberately undoes viewport clipping hides; + -- clipping is re-applied after the rows are re-anchored below. + RelationshipsDashboard_ForEachScrollElement(function(element) + RelationshipsDashboard_SetLayoutShown(element, true); + end); + + -- ManaRow: Show for classes that have a mana pool, but only when + -- the current power type is NOT mana (druid in feral form shows + -- Energy/Rage on PowerRow + Mana on separate ManaRow). + -- When already showing mana on PowerRow, hide the duplicate ManaRow. + if classUsesMana and not isManaUser then + -- Class has mana but current form shows different power (e.g., druid bear/cat) + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardManaRow, true); + else + -- Either already showing mana on PowerRow, or class doesn't use mana at all + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardManaRow, false); + end + + -- Ranged stats: show for Hunter/Warrior/Rogue, hide for others + if hasRanged then + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedAPRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedCritRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedSpeedRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedDmgRow, true); + else + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedAPRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedCritRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedSpeedRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardRangedDmgRow, false); + end + + -- Spell stats: show for mana users/casters, hide for pure melee + if isCaster or classUsesMana then + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellPowerRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardHealingPowerRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellHitRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellCritRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardManaRegenRow, true); + else + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellPowerRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardHealingPowerRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellHitRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardSpellCritRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardManaRegenRow, false); + end + + -- Melee stats: hide for pure casters (Mage, Warlock, Priest) + if class == "MAGE" or class == "WARLOCK" or class == "PRIEST" then + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeDmgRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeAPRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeSpeedRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardWeaponDPSRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardWeaponSkillRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeHitRow, false); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeCritRow, false); + else + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeDmgRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeAPRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeSpeedRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardWeaponDPSRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardWeaponSkillRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeHitRow, true); + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardMeleeCritRow, true); + end + + -- Block: only show for Warrior/Paladin/Shaman (classes that can use shields) + if class == "WARRIOR" or class == "PALADIN" or class == "SHAMAN" then + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardBlockRow, true); + else + RelationshipsDashboard_SetLayoutShown(RelationshipsDashboardBlockRow, false); + end + + -- ==== Dynamic re-anchoring: eliminate gaps from hidden rows ==== + -- Walk through all sections and rows, re-anchor each visible element + -- to the bottom of the previous visible element. + local prevElement = nil; -- last visible frame we anchored to + local stripeOn = false; -- alternating row stripe state + + for secIdx = 1, table.getn(RelationshipsDashboard_LayoutSections) do + local section = RelationshipsDashboard_LayoutSections[secIdx]; + local headerName = section[1]; + local rowNames = section[2]; + local header = getglobal(headerName); + + -- Count visible rows in this section BEFORE deciding whether to show its header. + -- Without this, class-hidden sections still render a title bar that visually + -- collides with rows from adjacent sections after re-anchoring. + local sectionHasVisibleRow = false; + for rowIdx = 1, table.getn(rowNames) do + local r = getglobal(rowNames[rowIdx]); + if r and r.rdLayoutShown ~= false then + sectionHasVisibleRow = true; + break; + end + end + + if header and sectionHasVisibleRow then + if prevElement then + -- Section header: anchor below previous visible element with gap + header:ClearAllPoints(); + header:SetPoint("TOPLEFT", prevElement, "BOTTOMLEFT", 0, -4); + else + -- First section: anchor to content top + header:ClearAllPoints(); + header:SetPoint("TOPLEFT", RelationshipsDashboardContent, "TOPLEFT", 0, 0); + end + RelationshipsDashboard_SetLayoutShown(header, true); + prevElement = header; + elseif header then + -- Every row in this section is class-hidden — hide the title too. + RelationshipsDashboard_SetLayoutShown(header, false); + end + + for rowIdx = 1, table.getn(rowNames) do + local rowName = rowNames[rowIdx]; + local row = getglobal(rowName); + if row and row.rdLayoutShown ~= false then + row:ClearAllPoints(); + if prevElement then + row:SetPoint("TOPLEFT", prevElement, "BOTTOMLEFT", 0, 0); + else + -- No previous element (first row, no section header): anchor to content top + row:SetPoint("TOPLEFT", RelationshipsDashboardContent, "TOPLEFT", 0, 0); + end + prevElement = row; + + -- Apply alternating row stripes + local bg = getglobal(rowName .. "BG"); + if bg then + if stripeOn then + bg:Show(); + bg:SetAlpha(0.04); + row.stripeOn = true; + else + bg:Hide(); + row.stripeOn = false; + end + end + stripeOn = not stripeOn; + end + end + end + + -- Update scroll child height based on last visible element. + -- IMPORTANT: do NOT clamp tall content down to the visible viewport. If the + -- ScrollChild is shorter than the actual rows, Vanilla lets nested row Frames + -- bleed outside the frame while the ScrollFrame has the wrong scroll range. + if prevElement then + local scrollFrame = RelationshipsDashboardScrollFrame; + local scrollVisibleHeight = scrollFrame:GetHeight() or 400; + local contentTop = RelationshipsDashboardContent:GetTop() or 0; + local elemBottom = prevElement:GetBottom() or 0; + local usedHeight = math.ceil(contentTop - elemBottom) + 10; + if usedHeight < 100 then + usedHeight = 100; + end + if usedHeight < scrollVisibleHeight then + usedHeight = scrollVisibleHeight; + end + RelationshipsDashboardContent:SetHeight(usedHeight); + + if scrollFrame.UpdateScrollChildRect then + scrollFrame:UpdateScrollChildRect(); + end + + local scrollBar = getglobal("RelationshipsDashboardScrollFrameScrollBar"); + if scrollBar then + local minVal, maxVal = scrollBar:GetMinMaxValues(); + if scrollBar:GetValue() > maxVal then + scrollBar:SetValue(maxVal); + elseif scrollBar:GetValue() < minVal then + scrollBar:SetValue(minVal); + end + end + end + + -- Snapshot which elements are legitimately shown right now (post class-filter + -- + relayout). UpdateViewportClip uses this to decide which rows may be + -- Show()n back into the viewport during scrolling, and which must stay + -- hidden because a class role rule hid them. + RelationshipsDashboard_ForEachScrollElement(function(element) + element.rdIntendedShown = (element.rdLayoutShown ~= false); + end); + + RelationshipsDashboard_BoostMaskLevels(); + RelationshipsDashboard_UpdateViewportClip(); +end + +------------------------------------------------------------------------------- +-- Helper: Extract item ID from an item link string +-- Vanilla 1.12 item link format: |cffXXXXXX|Hitem:itemId:enchantId:suffixId:uniqueId|h[Name]|h|r +-- We need to parse out the numeric item ID for database lookup +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ExtractItemID(itemLink) + if not itemLink then return nil; end + local _, _, itemID = strfind(itemLink, "item:(%d+):%d+:%d+:%d+"); + if itemID then + return tonumber(itemID); + end + return nil; +end + +------------------------------------------------------------------------------- +-- Helper: Safe wrapper for SetInventoryItem that catches errors +------------------------------------------------------------------------------- +local function RelationshipsDashboardTooltip_SetInventoryItemSafe(unit, slot) + if not RelationshipsDashboardTooltip then return false; end + local ok, err = pcall(function() RelationshipsDashboardTooltip:SetInventoryItem(unit, slot); end); + return ok; +end + +------------------------------------------------------------------------------- +-- Helper: Format money (copper) to gold/silver/copper string +------------------------------------------------------------------------------- +local function RelationshipsDashboard_FormatMoney(copper) + if copper <= 0 then + return "|cff00ff000g 0s 0c|r"; + end + local gold = floor(copper / 10000); + local silver = floor(mod(copper, 10000) / 100); + local copperRem = mod(copper, 100); + local result = ""; + if gold > 0 then + result = result .. "|cffffff00" .. gold .. "g|r "; + end + if silver > 0 or gold > 0 then + result = result .. "|cffc0c0c0" .. silver .. "s|r "; + end + result = result .. "|cffcc8844" .. copperRem .. "c|r"; + return result; +end + +------------------------------------------------------------------------------- +-- Helper: Format percentage +------------------------------------------------------------------------------- +local function RelationshipsDashboard_FormatPercent(value) + return string.format("%.2f%%", value); +end + +------------------------------------------------------------------------------- +-- Helper: Format decimal +------------------------------------------------------------------------------- +local function RelationshipsDashboard_FormatDecimal(value) + return string.format("%.2f", value); +end + +------------------------------------------------------------------------------- +-- Helper: Get item quality from item link +-- Item links in vanilla: |Hitem:itemId:enchantId:suffixId:uniqueId|h[name]|h[qualityCode] +-- Actually in 1.12: |cffXXXXXX|Hitem:itemId:enchant:suffix:unique|h[Name]|h|r +-- The color code maps to quality: +-- |cff9d9d9d = 0 (Poor), |cffffffff = 1 (Common), |cff1eff00 = 2 (Uncommon) +-- |cff0070dd = 3 (Rare), |cffa335ee = 4 (Epic), |cffff8000 = 5 (Legendary) +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetItemQualityFromLink(itemLink) + if not itemLink then return 0; end + -- Match the color prefix to determine quality + local quality = 0; + if strfind(itemLink, "|cff9d9d9d") then + quality = 0; + elseif strfind(itemLink, "|cffffffff") then + quality = 1; + elseif strfind(itemLink, "|cff1eff00") then + quality = 2; + elseif strfind(itemLink, "|cff0070dd") then + quality = 3; + elseif strfind(itemLink, "|cffa335ee") then + quality = 4; + elseif strfind(itemLink, "|cffff8000") then + quality = 5; + else + -- Fallback: try to use GetItemQualityColor if we can parse itemId + -- This shouldn't normally happen + quality = 1; + end + return quality; +end + +------------------------------------------------------------------------------- +-- Helper: Get item level using the built-in item database +-- Primary method: Look up itemID in RelationshipsDashboardDB_ItemLevels +-- Fallback method: Try GetItemInfo (works for cached items) +-- Last resort: Tooltip scanning +-- This is the proven approach for vanilla 1.12 where GetItemInfo is unreliable +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetItemLevel(itemLink) + if not itemLink then return 0; end + + -- Method 1: Extract itemID and look up in our database (most reliable) + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + if itemID and RelationshipsDashboardDB_ItemLevels then + local dbLevel = RelationshipsDashboardDB_ItemLevels[itemID]; + if dbLevel and dbLevel > 0 then + return dbLevel; + end + end + + -- Method 2: Try GetItemInfo (works for items cached in current session) + local _, _, _, itemLevel = GetItemInfo(itemLink); + if itemLevel and itemLevel > 0 then + return itemLevel; + end + + -- Method 3: Tooltip scanning as last resort + if RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + local ok, err = pcall(function() RelationshipsDashboardTooltip:SetHyperlink(itemLink); end); + if ok then + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, iLvl = strfind(text, "Item Level (%d+)"); + if iLvl then + return tonumber(iLvl); + end + end + end + end + end + end + + return 0; +end + +------------------------------------------------------------------------------- +-- Helper: Check if an item is a two-handed weapon +-- Uses itemEquipLoc from GetItemInfo (equipped items are always cached) +-- INVTYPE_2HWEAPON is the equip location for two-hand weapons +------------------------------------------------------------------------------- +local function RelationshipsDashboard_IsTwoHandWeapon(itemLink) + if not itemLink then return false; end + -- Vanilla 1.12 GetItemInfo returns: name(1), link(2), quality(3), iLevel(4), + -- minLevel(5), type(6), subType(7), stackCount(8), equipLoc(9), texture(10). + -- The equipLoc is the 9th return, not the 8th. + local _, _, _, _, _, _, _, _, itemEquipLoc = GetItemInfo(itemLink); + if itemEquipLoc == "INVTYPE_2HWEAPON" then + return true; + end + return false; +end + +------------------------------------------------------------------------------- +-- Calculate GearScore based on equipped items +-- Formula: score = rarity * itemLevel per item, x2 bonus for two-hand weapons +-- Also calculates average item level from the database +------------------------------------------------------------------------------- +local function RelationshipsDashboard_CalculateGearScore() + local totalScore = 0; + local totalILvl = 0; + local equippedCount = 0; -- items with a known item level (for avg ilvl) + local slotCount = 0; -- items equipped at all + + -- GearScoreLite-style quality scalar (rewards higher rarities much more, + -- so a low-level player in white/green gear can't out-score a mid-level + -- character in blue/purple gear like on real WoW). + local qualityScalar = { + [0] = 0.00, + [1] = 0.005, -- Common (white) + [2] = 0.25, -- Uncommon (green) + [3] = 0.50, -- Rare (blue) + [4] = 0.75, -- Epic (purple) + [5] = 1.00, -- Legendary (orange) + }; + + -- Slot weights approximate the TBC / WotLK GearScore standard + local slotWeight = { + [1] = 1.0000, [2] = 0.5625, [3] = 0.7500, [5] = 1.0000, + [6] = 0.7500, [7] = 1.0000, [8] = 0.7500, [9] = 0.5625, + [10] = 0.7500, [11] = 0.5625, [12] = 0.5625, [13] = 0.5625, + [14] = 0.5625, [15] = 0.5625, [16] = 1.0000, [17] = 1.0000, + [18] = 0.3164, + }; + + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink then + slotCount = slotCount + 1; + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + local itemLevel = 0; + if itemID and RelationshipsDashboardDB_ItemLevels then + itemLevel = RelationshipsDashboardDB_ItemLevels[itemID] or 0; + end + if itemLevel == 0 then + local _, _, _, giItemLevel = GetItemInfo(itemLink); + if giItemLevel and giItemLevel > 0 then + itemLevel = giItemLevel; + end + end + + -- Fallback for items missing from the DB and uncached from + -- GetItemInfo (common on Turtle WoW custom items, ESPECIALLY the + -- 2H weapon the player just equipped). Approximate from the + -- wearer's level so the score still reflects the equip/unequip. + if itemLevel == 0 then + local plvl = UnitLevel(RD_UNIT) or 1; + if plvl < 1 then plvl = 1; end + itemLevel = plvl; + end + + local quality = RelationshipsDashboard_GetItemQualityFromLink(itemLink); + local qf = qualityScalar[quality] or 0.005; + local sw = slotWeight[slotId] or 0.5625; + + -- Discount very low ilvl items (matches retail formula behavior) + local ilvlBase = itemLevel - 4; + if ilvlBase < 0 then ilvlBase = 0; end + + -- 9th return of GetItemInfo is equipLoc in vanilla 1.12. + local _, _, _, _, _, _, _, _, itemEquipLoc = GetItemInfo(itemLink); + local twoHandMult = 1; + if itemEquipLoc == "INVTYPE_2HWEAPON" then + twoHandMult = 2; + end + + -- Scale factor tuned so a full T2 (ilvl 76 epic) player lands + -- around ~2800 GearScore, a full BiS pre-raid ~2000, greens + -- at level 60 ~800-1000, and level 20 greens ~150. + local itemScore = ilvlBase * qf * sw * twoHandMult * 8; + + totalScore = totalScore + itemScore; + totalILvl = totalILvl + itemLevel; + equippedCount = equippedCount + 1; + end + end + + local avgILvl = 0; + if equippedCount > 0 then + avgILvl = totalILvl / equippedCount; + end + + return floor(totalScore), avgILvl, equippedCount; +end + +------------------------------------------------------------------------------- +-- Calculate durability and repair costs +-- Primary method: GetInventoryItemDurability (vanilla 1.12 API, works without repair NPC) +-- Fallback: Tooltip scanning for "Durability: XX / XX" pattern +-- Repair cost is estimated from durability ratio, item quality, item level, and slot type +-- When a repair merchant is open, GetRepairAllCost() provides the exact cost +------------------------------------------------------------------------------- + +-- Slots that never have durability in vanilla WoW (accessories) +local SLOTS_NO_DURABILITY = { + [2] = true, -- NeckSlot + [11] = true, -- Finger0Slot + [12] = true, -- Finger1Slot + [13] = true, -- Trinket0Slot + [14] = true, -- Trinket1Slot +}; + +-- Slot type multipliers for repair cost estimation +-- These represent the relative cost weight of each armor slot +-- Based on vanilla WoW's internal repair cost distribution +local SLOT_REPAIR_MULTIPLIER = { + [1] = 0.88, -- HeadSlot + [3] = 0.75, -- ShoulderSlot + [5] = 1.00, -- ChestSlot (highest) + [6] = 0.50, -- WaistSlot (no durability in vanilla, but just in case) + [7] = 0.88, -- LegsSlot + [8] = 0.75, -- FeetSlot + [9] = 0.50, -- WristSlot + [10] = 0.75, -- HandsSlot + [15] = 0.50, -- BackSlot (usually no durability, but some cloaks do) + [16] = 0.38, -- MainHandSlot + [17] = 0.38, -- OffHandSlot + [18] = 0.38, -- RangedSlot +}; + +-- DBC-backed repair multipliers from modern Classic Era DurabilityCosts.dbc. +-- Server formula used by newer cores: +-- repairCopper = lostDurability * DurabilityCosts[itemLevel][class/subclass] +-- * DurabilityQuality[(quality + 1) * 2] +-- This is much closer to WoW/Turtle/OctoWoW repair totals than a generic +-- ilvl/slot approximation, and it naturally takes the equipped gear type, +-- quality, and item level into account. +local RD_DBC_REPAIR_ARMOR = { + 1,1,1,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5, + 5,5,5,6,6,6,7,7,7,8,8,9,9,10,11,12,13,14,15,16, + 17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55, + 57,59,61,63,65,67,69,71,73,75,77,79,81,83,85,87,89,91,93,95, + 97,99,101,103,105,107,109,111,113,115,117,119,121,123,125,127,129,131,133,135, +}; + +local RD_DBC_REPAIR_SHIELD = { + 1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,6, + 6,6,7,7,7,8,8,9,9,10,11,12,13,14,15,16,17,19,21,23, + 25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55,57,59,61,63, + 65,67,69,71,73,75,77,79,81,83,85,87,89,91,93,95,97,99,101,103, + 105,107,109,111,113,115,117,119,121,123,125,127,129,131,133,135,137,139,42,43, +}; + +local RD_DBC_REPAIR_WEAPON = { + 1,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5, + 5,6,6,6,7,7,7,8,8,9,9,10,11,12,13,14,15,16,17,19, + 21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55,57,59, + 61,63,65,67,69,71,73,75,77,79,81,83,85,87,89,91,93,95,97,99, + 101,103,105,107,109,111,113,115,117,119,121,123,125,127,129,131,133,135,137,139, +}; + +local RD_DBC_REPAIR_WEAPON_2H = { + 1,1,1,1,1,3,3,3,3,4,4,4,4,6,6,6,6,7,7,7, + 7,9,9,9,10,10,10,12,12,13,13,15,16,18,19,21,22,24,25,28, + 31,34,37,40,43,46,49,52,55,58,61,64,67,70,73,76,79,82,85,88, + 91,94,97,100,103,106,109,112,115,118,121,124,127,130,133,136,139,142,145,148, + 151,154,157,160,163,166,169,172,175,178,181,184,187,190,193,196,199,202,205,208, +}; + +local RD_DBC_REPAIR_WEAPON_SMALL = { + 1,1,1,1,1,1,1,1,1,2,2,2,2,3,3,3,3,3,3,3, + 3,4,4,4,5,5,5,6,6,6,6,7,8,9,9,10,11,12,12,14, + 15,17,18,20,21,23,24,26,27,29,30,32,33,35,36,38,39,41,42,44, + 45,47,48,50,51,53,54,56,57,59,60,62,63,65,66,68,69,71,72,74, + 75,77,78,80,81,83,84,86,87,89,90,92,93,95,96,98,99,101,102,104, +}; + +local RD_DBC_REPAIR_FISHING = { + 1,1,1,1,1,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4, + 4,4,4,4,5,5,5,6,6,6,7,7,8,8,8,9,9,9,9,9, + 9,10,10,10,11,11,11,12,12,12,13,13,13,14,14,14,14,14,14,15, + 15,15,16,16,16,17,17,17,18,18,18,18,18,18,19,19,19,20,20,20, + 21,21,21,22,22,22,23,23,23,23,23,23,24,24,24,25,25,25,26,26, +}; + +local RD_DBC_REPAIR_QUALITY = { + [0] = 0.60, -- Poor + [1] = 0.80, -- Common + [2] = 1.00, -- Uncommon + [3] = 1.25, -- Rare + [4] = 2.50, -- Epic + [5] = 3.00, -- Legendary +}; + +local function RelationshipsDashboard_GetRepairMultiplier(slotId, itemLink, itemLevel) + local ilvl = itemLevel or 1; + if ilvl < 1 then ilvl = 1; end + if ilvl > 100 then ilvl = 100; end + ilvl = floor(ilvl + 0.5); + + local _, _, _, _, _, itemType, itemSubType, _, itemEquipLoc = GetItemInfo(itemLink); + local subTypeLower = itemSubType and strlower(itemSubType) or ""; + + if itemEquipLoc == "INVTYPE_SHIELD" or strfind(subTypeLower, "shield") then + return RD_DBC_REPAIR_SHIELD[ilvl] or 0; + end + + if itemType == "Armor" + or itemEquipLoc == "INVTYPE_HEAD" + or itemEquipLoc == "INVTYPE_SHOULDER" + or itemEquipLoc == "INVTYPE_CHEST" + or itemEquipLoc == "INVTYPE_ROBE" + or itemEquipLoc == "INVTYPE_WAIST" + or itemEquipLoc == "INVTYPE_LEGS" + or itemEquipLoc == "INVTYPE_FEET" + or itemEquipLoc == "INVTYPE_WRIST" + or itemEquipLoc == "INVTYPE_HAND" + or itemEquipLoc == "INVTYPE_CLOAK" + or (slotId ~= 16 and slotId ~= 17 and slotId ~= 18) then + return RD_DBC_REPAIR_ARMOR[ilvl] or 0; + end + + if strfind(subTypeLower, "fishing") then + return RD_DBC_REPAIR_FISHING[ilvl] or 0; + end + if strfind(subTypeLower, "dagger") or strfind(subTypeLower, "wand") then + return RD_DBC_REPAIR_WEAPON_SMALL[ilvl] or 0; + end + if itemEquipLoc == "INVTYPE_2HWEAPON" + or strfind(subTypeLower, "two%-hand") + or strfind(subTypeLower, "staff") + or strfind(subTypeLower, "staves") + or strfind(subTypeLower, "polearm") + or strfind(subTypeLower, "crossbow") then + return RD_DBC_REPAIR_WEAPON_2H[ilvl] or 0; + end + + return RD_DBC_REPAIR_WEAPON[ilvl] or 0; +end + +local RelationshipsDashboard_CalculateInventoryRepairCost; + +local function RelationshipsDashboard_CalculateDurabilityAndRepair() + local totalCurrentDur = 0; + local totalMaxDur = 0; + local repairCost = 0; + local slotsWithDurability = 0; + + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local hasDurability = false; + local curDur, maxDur; + + -- Skip slots that never have durability + if SLOTS_NO_DURABILITY[slotId] then + -- Skip accessory slots entirely + elseif not RD_DURABILITY_SLOTS[slotId] then + -- Skip slots we don't track (e.g. waist in vanilla has no dur) + else + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + local existingSaved = RD_SavedDurability[slotId]; + if existingSaved and existingSaved.itemID and itemID and existingSaved.itemID ~= itemID then + RD_SavedDurability[slotId] = nil; + end + -- Method 1: GetInventoryItemDurability (vanilla 1.12 API) + -- Returns currentDurability, maxDurability for the slot. + -- This is the most reliable method and works without a repair NPC. + if GetInventoryItemDurability then + curDur, maxDur = GetInventoryItemDurability(slotId); + if curDur and maxDur and maxDur > 0 then + -- Only TRUST this reading when it reports actual damage + -- (curDur < maxDur) or when a repair merchant is open. + -- On Turtle/OctoWoW the API frequently reports FULL + -- durability outside a merchant (notably right after a + -- /reload or login). Trusting a bogus "full" reading + -- would wipe our tracked estimate and falsely snap the + -- dashboard back to 100%. When it looks full and we are + -- not at a merchant, fall through to the saved estimate. + if curDur < maxDur or RD_AtRepairMerchant then + hasDurability = true; + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + end + end + end + + -- Method 2: Tooltip scanning for durability (fallback) + if not hasDurability and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe("player", slotId); + + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, dCur, dMax = strfind(text, "Durability:%s*(%d+)%s*/%s*(%d+)"); + if dCur and dMax then + curDur = tonumber(dCur); + maxDur = tonumber(dMax); + -- Same trust rule as the API path: a "full" + -- reading outside a repair merchant may be a + -- server quirk, so keep the saved estimate + -- instead of resetting to 100%. + if maxDur and maxDur > 0 + and (curDur < maxDur or RD_AtRepairMerchant) then + hasDurability = true; + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + end + break; + end + end + end + end + end + + -- Method 3: Use saved/estimated durability (fallback when API fails) + -- This ensures we always show a meaningful value even when not + -- at a repair NPC and the vanilla API returns nothing. + if not hasDurability then + local saved = RD_SavedDurability[slotId]; + if saved and saved.max and saved.max > 0 then + -- Use the saved estimate + curDur = saved.cur; + maxDur = saved.max; + saved.itemID = itemID; + hasDurability = true; + else + -- No saved data: item is likely at full durability + -- (newly equipped or first time reading) + -- Use default max durability for the slot + local defaultMax = RD_DEFAULT_MAX_DURABILITY[slotId] or 85; + maxDur = defaultMax; + curDur = defaultMax; -- Assume full when unknown + hasDurability = true; + -- Save for future tracking + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + end + end + else + -- No item in this slot — clear saved data + RD_SavedDurability[slotId] = nil; + end + end + + if hasDurability and maxDur > 0 then + totalCurrentDur = totalCurrentDur + curDur; + totalMaxDur = totalMaxDur + maxDur; + slotsWithDurability = slotsWithDurability + 1; + + -- Estimate repair cost for this item when merchant is not open + -- Vanilla WoW repair formula is approximately: + -- cost = (maxDur - curDur) * slotMultiplier * qualityFactor * (itemLevel / 50) + -- where qualityFactor depends on item quality and slotMultiplier varies by slot + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + local quality = RelationshipsDashboard_GetItemQualityFromLink(itemLink); + local itemLevel = RelationshipsDashboard_GetItemLevel(itemLink); + local durLost = maxDur - curDur; + + if durLost > 0 then + local dmultiplier = RelationshipsDashboard_GetRepairMultiplier(slotId, itemLink, itemLevel); + local qFactor = RD_DBC_REPAIR_QUALITY[quality] or 1.0; + local slotRepairCost = floor((durLost * dmultiplier * qFactor) + 0.5); + repairCost = repairCost + slotRepairCost; + end + end + end + end + + repairCost = repairCost + RelationshipsDashboard_CalculateInventoryRepairCost(); + + -- If a repair merchant is open, GetRepairAllCost() gives the exact cost. + -- This is handled separately in RelationshipsDashboard_UpdateRepairFromMerchant + -- and applied in UpdateStats when RelationshipsDashboard_MerchantRepairCost > 0. + + local avgDurability = 100; + if totalMaxDur > 0 then + avgDurability = (totalCurrentDur / totalMaxDur) * 100; + end + + return avgDurability, repairCost, slotsWithDurability; +end + +-- Ensure RD_SavedDurability[slotId] exists for an equipped durability item so +-- that death / spirit-healer / combat penalties can decrement it even when the +-- character panel has never been opened yet (in which case CalculateDurability +-- would not have populated saved state yet). +local function RelationshipsDashboard_EnsureSavedDurability(slotId) + local saved = RD_SavedDurability[slotId]; + local itemLink = GetInventoryItemLink("player", slotId); + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + if saved and saved.max and saved.max > 0 and (not itemID or not saved.itemID or saved.itemID == itemID) then + saved.itemID = itemID; + return saved; + end + if saved and itemID and saved.itemID and saved.itemID ~= itemID then + RD_SavedDurability[slotId] = nil; + end + local curDur, maxDur; + if GetInventoryItemDurability then + curDur, maxDur = GetInventoryItemDurability(slotId); + end + if not (curDur and maxDur and maxDur > 0) and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe("player", slotId); + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, dCur, dMax = strfind(text, "Durability:%s*(%d+)%s*/%s*(%d+)"); + if dCur and dMax then + curDur = tonumber(dCur); + maxDur = tonumber(dMax); + break; + end + end + end + end + end + if not (curDur and maxDur and maxDur > 0) then + maxDur = RD_DEFAULT_MAX_DURABILITY[slotId] or 85; + curDur = maxDur; + end + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + return RD_SavedDurability[slotId]; +end + +local function RelationshipsDashboard_ReadContainerDurability(bag, slot) + local curDur, maxDur; + if GetContainerItemDurability then + local ok, c, m = pcall(function() return GetContainerItemDurability(bag, slot); end); + if ok then + curDur = c; + maxDur = m; + end + end + if not (curDur and maxDur and maxDur > 0) and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + local ok = pcall(function() RelationshipsDashboardTooltip:SetBagItem(bag, slot); end); + if ok then + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, dCur, dMax = strfind(text, "Durability:%s*(%d+)%s*/%s*(%d+)"); + if dCur and dMax then + curDur = tonumber(dCur); + maxDur = tonumber(dMax); + break; + end + end + end + end + end + end + return curDur, maxDur; +end + +local function RelationshipsDashboard_GetSavedBagKey(bag, slot) + return "bag" .. bag .. ":" .. slot; +end + +local function RelationshipsDashboard_GetDefaultMaxForItemLink(itemLink) + local _, _, _, _, _, _, itemSubType, _, itemEquipLoc = GetItemInfo(itemLink); + local subTypeLower = itemSubType and strlower(itemSubType) or ""; + if itemEquipLoc == "INVTYPE_CHEST" or itemEquipLoc == "INVTYPE_ROBE" then return RD_DEFAULT_MAX_DURABILITY[5]; end + if itemEquipLoc == "INVTYPE_LEGS" then return RD_DEFAULT_MAX_DURABILITY[7]; end + if itemEquipLoc == "INVTYPE_HEAD" then return RD_DEFAULT_MAX_DURABILITY[1]; end + if itemEquipLoc == "INVTYPE_SHOULDER" then return RD_DEFAULT_MAX_DURABILITY[3]; end + if itemEquipLoc == "INVTYPE_FEET" then return RD_DEFAULT_MAX_DURABILITY[8]; end + if itemEquipLoc == "INVTYPE_WRIST" then return RD_DEFAULT_MAX_DURABILITY[9]; end + if itemEquipLoc == "INVTYPE_HAND" then return RD_DEFAULT_MAX_DURABILITY[10]; end + if itemEquipLoc == "INVTYPE_CLOAK" then return RD_DEFAULT_MAX_DURABILITY[15]; end + if itemEquipLoc == "INVTYPE_WEAPON" or itemEquipLoc == "INVTYPE_WEAPONMAINHAND" or itemEquipLoc == "INVTYPE_WEAPONOFFHAND" then return 90; end + if itemEquipLoc == "INVTYPE_2HWEAPON" or itemEquipLoc == "INVTYPE_RANGED" or itemEquipLoc == "INVTYPE_RANGEDRIGHT" or itemEquipLoc == "INVTYPE_SHIELD" then return 100; end + if strfind(subTypeLower, "shield") then return 100; end + return nil; +end + +local function RelationshipsDashboard_EstimateSingleRepairCost(slotId, itemLink, curDur, maxDur) + if not itemLink or not curDur or not maxDur or maxDur <= 0 then return 0; end + local durLost = maxDur - curDur; + if durLost <= 0 then return 0; end + local quality = RelationshipsDashboard_GetItemQualityFromLink(itemLink); + local itemLevel = RelationshipsDashboard_GetItemLevel(itemLink); + if not itemLevel or itemLevel <= 0 then + itemLevel = UnitLevel("player") or 1; + end + local dmultiplier = RelationshipsDashboard_GetRepairMultiplier(slotId, itemLink, itemLevel); + local qFactor = RD_DBC_REPAIR_QUALITY[quality] or 1.0; + return floor((durLost * dmultiplier * qFactor) + 0.5); +end + +RelationshipsDashboard_CalculateInventoryRepairCost = function() + local repairCost = 0; + if not GetContainerNumSlots or not GetContainerItemLink then return 0; end + + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) or 0; + for slot = 1, numSlots do + local itemLink = GetContainerItemLink(bag, slot); + if itemLink then + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + local key = RelationshipsDashboard_GetSavedBagKey(bag, slot); + local saved = RD_SavedDurability[key]; + if saved and itemID and saved.itemID and saved.itemID ~= itemID then + saved = nil; + RD_SavedDurability[key] = nil; + end + + local curDur, maxDur = RelationshipsDashboard_ReadContainerDurability(bag, slot); + if curDur and maxDur and maxDur > 0 then + if curDur < maxDur or RD_AtRepairMerchant then + RD_SavedDurability[key] = { cur = curDur, max = maxDur, itemID = itemID }; + saved = RD_SavedDurability[key]; + end + end + + if saved and saved.max and saved.max > 0 then + saved.itemID = itemID; + repairCost = repairCost + RelationshipsDashboard_EstimateSingleRepairCost(16, itemLink, saved.cur, saved.max); + end + else + RD_SavedDurability[RelationshipsDashboard_GetSavedBagKey(bag, slot)] = nil; + end + end + end + + return repairCost; +end + +local function RelationshipsDashboard_SyncEquippedDurabilityFromGame(allowFull) + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + if RD_DURABILITY_SLOTS[slotId] and not SLOTS_NO_DURABILITY[slotId] then + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + local curDur, maxDur; + if GetInventoryItemDurability then + curDur, maxDur = GetInventoryItemDurability(slotId); + end + if curDur and maxDur and maxDur > 0 and (allowFull or curDur < maxDur) then + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + elseif RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe("player", slotId); + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, dCur, dMax = strfind(text, "Durability:%s*(%d+)%s*/%s*(%d+)"); + if dCur and dMax then + curDur = tonumber(dCur); + maxDur = tonumber(dMax); + if maxDur and maxDur > 0 and (allowFull or curDur < maxDur) then + RD_SavedDurability[slotId] = { cur = curDur, max = maxDur, itemID = itemID }; + end + break; + end + end + end + end + end + else + RD_SavedDurability[slotId] = nil; + end + end + end +end + +------------------------------------------------------------------------------- +-- Apply death penalty: Reduce all equipped items' estimated durability by 10% +-- In vanilla WoW, dying costs 10% durability on all equipped items. +-- This is applied even to items that already have 0 durability (they go negative +-- in the internal representation, but we clamp to 0 for display). +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ApplyDeathPenalty() + -- No durability loss in battlegrounds or duels. Re-check live BG status + -- here in case ZONE_CHANGED has not yet updated the cached flag (some + -- Turtle WoW / OctoWoW builds fire PLAYER_DEAD before ZONE_CHANGED on + -- battleground entries that kill on-arrival). + if not RD_InBattleground then + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + end + if RD_InBattleground then + -- Belt-and-suspenders: if we somehow had already applied wear before + -- realizing we were in a BG, restore the snapshot taken on entry. + RelationshipsDashboard_RestoreDurabilityFromBGSnapshot(); + return; + end + if RD_InDuel then return; end + for i = 1, table.getn(EQUIPMENT_SLOTS) do + + local slotId = EQUIPMENT_SLOTS[i]; + if RD_DURABILITY_SLOTS[slotId] and not SLOTS_NO_DURABILITY[slotId] then + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + -- Make sure we have a baseline for this slot even if the + -- character panel has never been opened this session. + local saved = RelationshipsDashboard_EnsureSavedDurability(slotId); + if saved and saved.max and saved.max > 0 then + -- Death penalty: lose 10% of max durability + local loss = floor(saved.max * 0.10); + if loss < 1 then loss = 1; end + saved.cur = saved.cur - loss; + if saved.cur < 0 then saved.cur = 0; end + end + end + end + end +end + +------------------------------------------------------------------------------- +-- Apply combat wear: Small durability loss from taking hits in combat. +-- Each time the player takes damage while in combat, there's a chance +-- of durability loss on armor pieces. In vanilla WoW, durability loss from +-- combat hits is approximately 0.1-0.5 durability per hit depending on damage. +-- We use a simplified model: every N damage events, lose 1 durability point +-- distributed across armor slots. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ApplyCombatWear(damageAmount) + if not RD_InCombat then return; end + -- No durability loss in battlegrounds or duels + if not RD_InBattleground then + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + end + if RD_InBattleground then return; end + if RD_InDuel then return; end + + if not damageAmount or damageAmount <= 0 then return; end + + RD_CombatHitCount = RD_CombatHitCount + 1; + + -- Every 20 combat hits, apply 1 durability point loss to a random armor slot + -- This approximates vanilla WoW's combat durability wear rate + -- (roughly 0.05-0.1 dur per hit depending on damage magnitude) + if RD_CombatHitCount >= 20 then + RD_CombatHitCount = 0; + + -- Pick armor slots to lose durability (not weapons, those don't decay from hits) + local armorSlots = { 1, 3, 5, 7, 8, 9, 10, 15 }; -- Head, Shoulder, Chest, Legs, Feet, Wrist, Hands, Back + -- Pick a random slot + local slotIdx = random(1, table.getn(armorSlots)); + local slotId = armorSlots[slotIdx]; + + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + local saved = RelationshipsDashboard_EnsureSavedDurability(slotId); + if saved and saved.max and saved.max > 0 then + saved.cur = saved.cur - 1; + if saved.cur < 0 then saved.cur = 0; end + end + end + end +end + +------------------------------------------------------------------------------- +-- Reset all saved durability to full (used when player repairs at a merchant) +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ResetDurabilityToFull() + for key, saved in pairs(RD_SavedDurability) do + if saved and saved.max and saved.max > 0 then + saved.cur = saved.max; -- Fully repaired + end + end +end + +------------------------------------------------------------------------------- +-- Apply spirit-healer resurrection penalty. +-- Spirit Healers resurrect a dead player at the cost of 25% durability loss +-- to ALL items (equipped + inventory). We can only reliably estimate the +-- equipped portion; inventory durability isn't cheaply readable in 1.12. +-- Characters level 10 and under do NOT lose durability from spirit rez. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ApplySpiritHealerPenalty() + -- No durability loss in battlegrounds or duels (duels never trigger a + -- spirit-healer rez, but guard defensively in case the state races). + if not RD_InBattleground then + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + end + if RD_InBattleground then + RelationshipsDashboard_RestoreDurabilityFromBGSnapshot(); + return; + end + if RD_InDuel then return; end + if UnitLevel("player") <= 10 then return; end + + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + if RD_DURABILITY_SLOTS[slotId] and not SLOTS_NO_DURABILITY[slotId] then + local itemLink = GetInventoryItemLink("player", slotId); + if itemLink then + local saved = RelationshipsDashboard_EnsureSavedDurability(slotId); + if saved and saved.max and saved.max > 0 then + -- Spirit healer: lose 25% of max durability on top of the + -- 10% death penalty already applied by PLAYER_DEAD. + local loss = floor(saved.max * 0.25); + if loss < 1 then loss = 1; end + saved.cur = saved.cur - loss; + if saved.cur < 0 then saved.cur = 0; end + end + end + end + end + + -- Spirit healer repair bills include durability loss on damaged items in + -- bags as well. Track the equipped part exactly and bag items when the + -- client exposes their durability; otherwise seed a reasonable max from + -- equip type so the repair total does not ignore inventory damage. + if GetContainerNumSlots and GetContainerItemLink then + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) or 0; + for slot = 1, numSlots do + local itemLink = GetContainerItemLink(bag, slot); + if itemLink then + local itemID = RelationshipsDashboard_ExtractItemID(itemLink); + local key = RelationshipsDashboard_GetSavedBagKey(bag, slot); + local curDur, maxDur = RelationshipsDashboard_ReadContainerDurability(bag, slot); + if not (curDur and maxDur and maxDur > 0) then + maxDur = RelationshipsDashboard_GetDefaultMaxForItemLink(itemLink); + curDur = maxDur; + end + if curDur and maxDur and maxDur > 0 then + local saved = RD_SavedDurability[key]; + if not saved or not saved.max or saved.max <= 0 or (itemID and saved.itemID and saved.itemID ~= itemID) then + saved = { cur = curDur, max = maxDur, itemID = itemID }; + RD_SavedDurability[key] = saved; + end + local loss = floor(saved.max * 0.25); + if loss < 1 then loss = 1; end + saved.cur = saved.cur - loss; + if saved.cur < 0 then saved.cur = 0; end + end + end + end + end + end +end + +------------------------------------------------------------------------------- +-- Try to get actual repair cost from an open merchant +-- This is called when MERCHANT_SHOW / MERCHANT_UPDATE events fire +-- Uses GetRepairAllCost() - the correct vanilla 1.12 API +-- Returns repairAllCost, canRepair (only works at a repair-capable merchant) +------------------------------------------------------------------------------- +local RelationshipsDashboard_MerchantRepairCost = 0; + +-- Tracks the previous non-zero repair cost while a repair merchant is open. +-- When the reported cost transitions from >0 to 0, or the player's gold drops +-- by approximately that amount, we know a repair just happened and can safely +-- reset saved durability to full WITHOUT needing a /reload. +RD_PrevMerchantRepairCost = 0; + +local function RelationshipsDashboard_UpdateRepairFromMerchant() + -- GetRepairAllCost is the correct vanilla 1.12 API function name + -- It only returns a value when a repair-capable merchant window is open + if not GetRepairAllCost then return; end + + local cost, canRepair = GetRepairAllCost(); + -- Track whether a repair-capable merchant is currently open so the rest + -- of the addon knows it can trust "full" durability readings. + if canRepair then + RD_AtRepairMerchant = true; + else + RD_AtRepairMerchant = false; + end + + if not canRepair then + -- Merchant can't repair at all. Clear the transient state so a later + -- MERCHANT_UPDATE at a real repair NPC starts from a clean slate. + RelationshipsDashboard_MerchantRepairCost = 0; + RD_PrevMerchantRepairCost = 0; + return; + end + + -- CRITICAL: refresh actual durability from the API BEFORE we do anything + -- else. At a repair merchant GetInventoryItemDurability is reliable, so we + -- want RD_SavedDurability to reflect ground truth before we (a) calibrate + -- against the reported cost, or (b) decide the player just repaired. + RelationshipsDashboard_SyncEquippedDurabilityFromGame(true); + + if cost and cost > 0 then + RelationshipsDashboard_MerchantRepairCost = cost; + RD_PrevMerchantRepairCost = cost; + -- Calibrate our away-from-merchant estimate against the exact cost + -- the game reports. We recompute our DBC-based estimate for the CURRENT + -- (freshly-updated) damaged gear and store a narrow ratio, so future + -- estimates match this server while still staying grounded in gear data. + local _, rawEstimate = RelationshipsDashboard_CalculateDurabilityAndRepair(); + if rawEstimate and rawEstimate > 0 then + local factor = cost / rawEstimate; + if factor < 0.5 then factor = 0.5; end + if factor > 2.0 then factor = 2.0; end + RD_RepairCalibration = factor; + if RelationshipsDashboardDB then + RelationshipsDashboardDB.repairCalibration = factor; + RelationshipsDashboardDB.repairCalibrationVersion = RD_REPAIR_CALIBRATION_VERSION; + end + end + else + -- Repair cost is 0 at a repair-capable merchant. Two possibilities: + -- (a) Player just clicked Repair All / repaired every item. + -- (b) All gear was already at full durability when they opened + -- the merchant. + -- Either way the correct visible state is "everything full". Reset. + RelationshipsDashboard_MerchantRepairCost = 0; + RD_PrevMerchantRepairCost = 0; + RelationshipsDashboard_ResetDurabilityToFull(); + end +end + +------------------------------------------------------------------------------- +-- Get melee hit chance +-- In vanilla 1.12, melee hit rating doesn't exist as a rating. +-- Hit chance comes from gear bonuses (+X% hit) and the base 5% against same level. +-- We scan the paper doll tooltip for hit chance, or use GetCombatRatingBonus if available. +-- For vanilla: scan tooltip of CharacterDamageFrame or use stat bonus calculations. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetMeleeHitChance() + -- In vanilla 1.12, there's no direct API for hit chance from gear + -- The best approach is to calculate from hit rating on gear if available, + -- or scan the character's melee hit bonus from tooltip + + -- Try using GetCombatRating / GetCombatRatingBonus if available (Turtle WoW extension) + if GetCombatRating and GetCombatRatingBonus then + -- CR_HIT_MELEE = 6 in later patches, may not exist in 1.12 + local hitRating = GetCombatRating(6); + if hitRating then + local hitBonus = GetCombatRatingBonus(6); + if hitBonus then + return 5.0 + hitBonus; -- Base 5% + gear bonus + end + end + end + + -- Fallback: try scanning the paper doll melee tooltip + -- In vanilla, hovering over the melee damage stat shows hit chance + if RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + -- Set to the melee damage tooltip + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, 16); -- Main hand + + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + -- Look for hit chance modifiers on weapon tooltip + local _, _, hitVal = strfind(text, "improves your chance to hit by (%d+)%%"); + if hitVal then + return 5.0 + tonumber(hitVal); + end + local _, _, hitVal2 = strfind(text, "%+(%d+)%% Hit"); + if hitVal2 then + return 5.0 + tonumber(hitVal2); + end + end + end + end + end + + -- Final fallback: base hit chance is 5% for melee against same-level target + -- We'll try to accumulate +hit from equipment via tooltip scanning + local hitBonus = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "%+(%d+)%% chance to hit"); + if val then hitBonus = hitBonus + tonumber(val); end + local _, _, val2 = strfind(text, "Equip: Increases your chance to hit by (%d+)%%"); + if val2 then hitBonus = hitBonus + tonumber(val2); end + end + end + end + end + end + return 5.0 + hitBonus; +end + +------------------------------------------------------------------------------- +-- Get spell hit chance +-- Similar approach to melee hit - scan gear for +spell hit bonuses +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetSpellHitChance() + -- Try GetCombatRatingBonus if available + if GetCombatRating and GetCombatRatingBonus then + -- CR_HIT_SPELL = 8 + local hitRating = GetCombatRating(8); + if hitRating then + local hitBonus = GetCombatRatingBonus(8); + if hitBonus then + return 5.0 + hitBonus; -- Base 5% + gear bonus + end + end + end + + -- Fallback: scan equipment for +spell hit + local hitBonus = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "chance to hit with spells by (%d+)%%"); + if val then hitBonus = hitBonus + tonumber(val); end + local _, _, val2 = strfind(text, "%+(%d+)%% Spell Hit"); + if val2 then hitBonus = hitBonus + tonumber(val2); end + local _, _, val3 = strfind(text, "Increases your chance to hit with spells by (%d+)%%"); + if val3 then hitBonus = hitBonus + tonumber(val3); end + end + end + end + end + end + return 5.0 + hitBonus; +end + +------------------------------------------------------------------------------- +-- Get spell crit chance +-- In vanilla 1.12, there's GetSpellCritChance() available as a global +-- Or we can use UnitStat-based formulas and intellect contribution +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetSpellCritChance() + -- Method 1: Try GetSpellCritChance (may be available in Turtle WoW's extended API) + if GetSpellCritChance then + local crit = GetSpellCritChance(SPELL_SCHOOL_INDEX); + if crit and crit > 0 then + return crit; + end + end + + -- Method 2: Base spell crit + Intellect contribution + -- Base spell crit by class: + local _, class = UnitClass(RD_UNIT); + local baseSpellCrit = 0; + if class == "MAGE" then baseSpellCrit = 3.2; + elseif class == "WARLOCK" then baseSpellCrit = 2.6; + elseif class == "PRIEST" then baseSpellCrit = 2.4; + elseif class == "DRUID" then baseSpellCrit = 2.4; + elseif class == "SHAMAN" then baseSpellCrit = 2.4; + elseif class == "PALADIN" then baseSpellCrit = 2.4; + else baseSpellCrit = 0; end + + -- Intellect contribution: roughly 1% crit per 59.5 intellect at level 60 + local level = UnitLevel(RD_UNIT); + local intStat = UnitStat(RD_UNIT, 4); -- Intellect + local intPerCrit = 59.5; + if level < 60 then + intPerCrit = 59.5 * (level / 60); + end + local intCritBonus = intStat / intPerCrit; + + -- Scan gear for +spell crit + local gearCritBonus = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "critical strike chance with spells by (%d+)%%"); + if val then gearCritBonus = gearCritBonus + tonumber(val); end + local _, _, val2 = strfind(text, "%+(%d+)%% Spell Crit"); + if val2 then gearCritBonus = gearCritBonus + tonumber(val2); end + local _, _, val3 = strfind(text, "Improves your chance to get a critical strike with spells by (%d+)%%"); + if val3 then gearCritBonus = gearCritBonus + tonumber(val3); end + end + end + end + end + end + + return baseSpellCrit + intCritBonus + gearCritBonus; +end + +------------------------------------------------------------------------------- +-- Get melee crit chance +-- In vanilla 1.12: base melee crit + agility contribution + gear bonuses +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetMeleeCritChance() + -- Method 1: Try GetCritChance (may be available in Turtle WoW) + if GetCritChance then + local crit = GetCritChance(); + if crit and crit > 0 then + return crit; + end + end + + -- Method 2: Calculate from base + agility + gear + local _, class = UnitClass(RD_UNIT); + local level = UnitLevel(RD_UNIT); + + -- Base crit rate by class (approximate) + local baseCrit = 5.0; -- All classes have 5% base melee crit in vanilla + + -- Agility per crit varies by class and level + -- At level 60: Warrior=20, Rogue=~29, Hunter=~53, Paladin=~20, etc. + local agiPerCrit = 20; + if class == "WARRIOR" then agiPerCrit = 20; + elseif class == "ROGUE" then agiPerCrit = 29; + elseif class == "HUNTER" then agiPerCrit = 53; + elseif class == "PALADIN" then agiPerCrit = 20; + elseif class == "DRUID" then agiPerCrit = 20; + elseif class == "SHAMAN" then agiPerCrit = 20; + end + + -- Scale for level (these values are for level 60) + if level > 0 and level < 60 then + agiPerCrit = agiPerCrit * (level / 60); + if agiPerCrit < 1 then agiPerCrit = 1; end + end + + local agiStat = UnitStat(RD_UNIT, 2); -- Agility + local agiCritBonus = agiStat / agiPerCrit; + + -- Scan gear for +melee crit + local gearCritBonus = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "critical strike chance by (%d+)%%"); + if val then gearCritBonus = gearCritBonus + tonumber(val); end + local _, _, val2 = strfind(text, "%+(%d+)%% Crit"); + if val2 then gearCritBonus = gearCritBonus + tonumber(val2); end + local _, _, val3 = strfind(text, "Improves your chance to get a critical strike by (%d+)%%"); + if val3 then gearCritBonus = gearCritBonus + tonumber(val3); end + end + end + end + end + end + + return baseCrit + agiCritBonus + gearCritBonus; +end + +------------------------------------------------------------------------------- +-- Get ranged crit chance +-- For Hunters primarily - based on agility + gear +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetRangedCritChance() + -- Method 1: Try GetRangedCritChance (Turtle WoW extension) + if GetRangedCritChance then + local crit = GetRangedCritChance(); + if crit and crit > 0 then + return crit; + end + end + + -- Method 2: Similar to melee crit but with ranged-specific agi ratios + local _, class = UnitClass(RD_UNIT); + local level = UnitLevel(RD_UNIT); + local baseCrit = 5.0; + + -- Ranged crit from agility (Hunter-focused) + local agiPerCrit = 53; -- Hunter value + if class ~= "HUNTER" then + agiPerCrit = 40; -- Other classes + end + if level > 0 and level < 60 then + agiPerCrit = agiPerCrit * (level / 60); + if agiPerCrit < 1 then agiPerCrit = 1; end + end + + local agiStat = UnitStat(RD_UNIT, 2); + local agiCritBonus = agiStat / agiPerCrit; + + -- Scan gear for +ranged crit + local gearCritBonus = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "critical strike chance with ranged by (%d+)%%"); + if val then gearCritBonus = gearCritBonus + tonumber(val); end + local _, _, val2 = strfind(text, "%+(%d+)%% Ranged Crit"); + if val2 then gearCritBonus = gearCritBonus + tonumber(val2); end + end + end + end + end + end + + return baseCrit + agiCritBonus + gearCritBonus; +end + +------------------------------------------------------------------------------- +-- Get spell power (highest school bonus) +-- In vanilla 1.12, spell power is per-school. We show the highest. +-- Uses GetSpellBonusDamage if available, or scans gear. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetSpellPower() + -- Method 1: Try GetSpellBonusDamage (Turtle WoW extension) + if GetSpellBonusDamage then + local maxPower = 0; + -- School indices: 2=Holy, 3=Fire, 4=Nature, 5=Frost, 6=Shadow, 7=Arcane + for school = 2, 7 do + local power = GetSpellBonusDamage(school); + if power and power > maxPower then + maxPower = power; + end + end + return maxPower; + end + + -- Method 2: Scan gear for +spell damage + local spellPower = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "%+(%d+) Spell Damage"); + if val then spellPower = spellPower + tonumber(val); end + local _, _, val2 = strfind(text, "up to (%d+) additional spell damage"); + if val2 then spellPower = spellPower + tonumber(val2); end + local _, _, val3 = strfind(text, "Increases damage done by magical spells and effects by up to (%d+)"); + if val3 then spellPower = spellPower + tonumber(val3); end + local _, _, val4 = strfind(text, "%+(%d+) Fire Damage"); + if val4 then spellPower = spellPower + tonumber(val4); end + local _, _, val5 = strfind(text, "%+(%d+) Frost Damage"); + if val5 then spellPower = spellPower + tonumber(val5); end + local _, _, val6 = strfind(text, "%+(%d+) Arcane Damage"); + if val6 then spellPower = spellPower + tonumber(val6); end + local _, _, val7 = strfind(text, "%+(%d+) Shadow Damage"); + if val7 then spellPower = spellPower + tonumber(val7); end + local _, _, val8 = strfind(text, "%+(%d+) Nature Damage"); + if val8 then spellPower = spellPower + tonumber(val8); end + local _, _, val9 = strfind(text, "%+(%d+) Holy Damage"); + if val9 then spellPower = spellPower + tonumber(val9); end + end + end + end + end + end + return spellPower; +end + +------------------------------------------------------------------------------- +-- Get healing power +-- In vanilla 1.12, healing power is separate from spell damage +-- Uses GetSpellBonusHealing if available, or scans gear. +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetHealingPower() + -- Method 1: Try GetSpellBonusHealing (Turtle WoW extension) + if GetSpellBonusHealing then + local healing = GetSpellBonusHealing(); + if healing then + return healing; + end + end + + -- Method 2: Scan gear for +healing + local healingPower = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "healing spells by up to (%d+)"); + if val then healingPower = healingPower + tonumber(val); end + local _, _, val2 = strfind(text, "%+(%d+) Healing"); + if val2 then healingPower = healingPower + tonumber(val2); end + local _, _, val3 = strfind(text, "Increases healing done by spells and effects by up to (%d+)"); + if val3 then healingPower = healingPower + tonumber(val3); end + end + end + end + end + end + return healingPower; +end + +------------------------------------------------------------------------------- +-- Get mana regeneration (spirit-based formula) +-- In vanilla 1.12, mana regen comes from spirit (out of FSR) and gear bonuses +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetManaRegen() + -- Method 1: Try GetManaRegen (Turtle WoW extension) + if GetManaRegen then + local base, casting = GetManaRegen(); + if base then + return base; + end + end + + -- Method 2: Calculate from spirit + -- Spirit-based mana regen formula (out of combat, not casting): + -- Regen per tick (2 seconds) varies by class: + -- Priest: Spirit * 0.125 + -- Mage: Spirit * 0.125 + -- Warlock: Spirit * 0.1 + -- Druid: Spirit * 0.125 + -- Shaman: Spirit * 0.1 + -- Paladin: Spirit * 0.1 + -- Per second = tickRegen / 2 + local _, class = UnitClass(RD_UNIT); + local spirit = UnitStat(RD_UNIT, 5); + + -- Only show for mana classes + local powerType = UnitPowerType(RD_UNIT); + if powerType ~= 0 then + return 0; -- Not a mana user + end + + local spiritFactor = 0; + if class == "PRIEST" then spiritFactor = 0.125; + elseif class == "MAGE" then spiritFactor = 0.125; + elseif class == "WARLOCK" then spiritFactor = 0.1; + elseif class == "DRUID" then spiritFactor = 0.125; + elseif class == "SHAMAN" then spiritFactor = 0.1; + elseif class == "PALADIN" then spiritFactor = 0.1; + else return 0; end + + local baseRegen = spirit * spiritFactor / 2; -- per second + + -- Scan gear for +mana regen + local gearRegen = 0; + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, val = strfind(text, "mana every (%d+) sec"); + if val then + gearRegen = gearRegen + (tonumber(val) / 5); -- Convert per-5sec to per-second + end + local _, _, val2 = strfind(text, "%+(%d+) mana per 5 sec"); + if val2 then + gearRegen = gearRegen + (tonumber(val2) / 5); + end + local _, _, val3 = strfind(text, "Allows (%d+) mana per 5 sec"); + if val3 then + gearRegen = gearRegen + (tonumber(val3) / 5); + end + end + end + end + end + end + + return baseRegen + gearRegen; +end + +------------------------------------------------------------------------------- +-- Get weapon skill for current weapon +-- In vanilla 1.12, weapon skill affects hit/miss/glancing blow calculations +-- We show the skill for the currently equipped main hand weapon type +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetWeaponSkill() + local itemLink = GetInventoryItemLink(RD_UNIT, 16); -- Main hand + if not itemLink then return "--"; end + + local _, _, _, _, _, itemType, itemSubType = GetItemInfo(itemLink); + if not itemSubType then return "--"; end + + -- Map weapon subtypes to skill line indices + -- In vanilla, skill lines are accessed by name + -- The skill value = min(level * 5, baseSkill + racialBonus + gearBonus) + local skillName = nil; + if itemSubType == "Axes" or itemSubType == "One-Handed Axes" then skillName = "Axes"; + elseif itemSubType == "Two-Handed Axes" then skillName = "Two-Handed Axes"; + elseif itemSubType == "Maces" or itemSubType == "One-Handed Maces" then skillName = "Maces"; + elseif itemSubType == "Two-Handed Maces" then skillName = "Two-Handed Maces"; + elseif itemSubType == "Swords" or itemSubType == "One-Handed Swords" then skillName = "Swords"; + elseif itemSubType == "Two-Handed Swords" then skillName = "Two-Handed Swords"; + elseif itemSubType == "Daggers" then skillName = "Daggers"; + elseif itemSubType == "Fist Weapons" then skillName = "Unarmed"; + elseif itemSubType == "Polearms" then skillName = "Polearms"; + elseif itemSubType == "Staves" then skillName = "Staves"; + elseif itemSubType == "Bows" then skillName = "Bows"; + elseif itemSubType == "Crossbows" then skillName = "Crossbows"; + elseif itemSubType == "Guns" then skillName = "Guns"; + elseif itemSubType == "Thrown" then skillName = "Thrown"; + end + + if not skillName then return "--"; end + + -- Try to get skill value from the skills API + -- In vanilla 1.12, we iterate skill lines + local numSkills = GetNumSkillLines(); + if numSkills then + for i = 1, numSkills do + local skillName2, _, _, skillRank = GetSkillLineInfo(i); + if skillName2 == skillName then + return skillRank or "--"; + end + end + end + + -- Fallback: max skill for level = level * 5 + local level = UnitLevel(RD_UNIT); + return level * 5 .. " (max)"; +end + +------------------------------------------------------------------------------- +-- Get defense skill +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetDefense() + -- Method 1: Try GetDefense (Turtle WoW) + if GetDefense then + local base, mod = GetDefense("player"); + if base then + return (base + (mod or 0)); + end + end + + -- Method 2: From skill lines + local numSkills = GetNumSkillLines(); + if numSkills then + for i = 1, numSkills do + local skillName, _, _, skillRank = GetSkillLineInfo(i); + if skillName == "Defense" then + return skillRank or 0; + end + end + end + + return 0; +end + +------------------------------------------------------------------------------- +-- Get block chance and value +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetBlockChance() + if GetBlockChance then + return GetBlockChance(); + end + -- Only warriors and paladins can block (with shield equipped) + return 0; +end + +------------------------------------------------------------------------------- +-- Get parry chance +------------------------------------------------------------------------------- +local function RelationshipsDashboard_GetParryChance() + if GetParryChance then + return GetParryChance(); + end + -- Base parry is 5% for most melee classes + local _, class = UnitClass(RD_UNIT); + if class == "WARRIOR" or class == "PALADIN" or class == "ROGUE" or class == "HUNTER" or class == "DRUID" then + return 5.0; + end + return 0; +end + +------------------------------------------------------------------------------- +-- Main Stats Update Function +-- Called on show and on relevant events +------------------------------------------------------------------------------- +------------------------------------------------------------------------------- +-- INSPECT ESTIMATION +-- In vanilla 1.12 the client does NOT expose another player's computed stats +-- (UnitStat/UnitArmor/UnitAttackPower/UnitResistance/GetDodgeChance etc. all +-- return the *player's* own values for a target). The only reliable target +-- data available while inspecting is the equipped item set (item links + +-- tooltips) plus level and class. So when inspecting we ESTIMATE every stat +-- from the target's gear and level. Values are approximate. +------------------------------------------------------------------------------- + +-- Approximate un-geared primary stats at level 60, tuned to real vanilla +-- values (roughly the human baseline). Race adjustments applied below. +-- Index: 1=STR 2=AGI 3=STA 4=INT 5=SPI +local RD_BASE_STATS_60 = { + WARRIOR = { 105, 75, 105, 30, 45 }, + PALADIN = { 100, 60, 95, 65, 75 }, + HUNTER = { 60, 115, 90, 60, 65 }, + ROGUE = { 85, 115, 85, 35, 50 }, + PRIEST = { 40, 50, 70, 115, 125 }, + SHAMAN = { 80, 60, 90, 85, 90 }, + MAGE = { 35, 50, 65, 120, 115 }, + WARLOCK = { 50, 55, 80, 105, 105 }, + DRUID = { 70, 60, 80, 95, 95 }, +}; + +-- Per-race stat modifiers at level 60 relative to the human baseline above. +-- Values are typical vanilla deltas; missing entries default to 0. +-- Index: 1=STR 2=AGI 3=STA 4=INT 5=SPI +local RD_RACE_MODS_60 = { + Human = { 0, 0, 0, 0, 0 }, + Dwarf = { 5, -4, 1, -1, -1 }, + Gnome = { -5, 2, -1, 3, 0 }, + NightElf = { -4, 4, 0, 0, 0 }, + Orc = { 3, -3, 1, -3, 2 }, + Undead = { -1, -2, 0, -2, 5 }, + Tauren = { 5, -4, 1, -3, -2 }, + Troll = { 1, 2, 0, -4, 1 }, + Scourge = { -1, -2, 0, -2, 5 }, -- alt name for Undead + Goblin = { -3, 3, -1, 3, 0 }, + HighElf = { -4, 4, 0, 1, -1 }, +}; + +-- Rough Agility-per-1%-crit (and used for dodge) by class. +local RD_AGI_PER_CRIT = { + WARRIOR = 20, PALADIN = 20, DRUID = 20, SHAMAN = 20, + ROGUE = 29, HUNTER = 53, MAGE = 20, PRIEST = 20, WARLOCK = 20, +}; + +-- Classes that can parry in vanilla. +local RD_CAN_PARRY = { + WARRIOR = true, PALADIN = true, HUNTER = true, ROGUE = true, SHAMAN = true, +}; + +-- Passive racial resistances (vanilla / Turtle / OctoWoW). +-- Applied on top of gear resistances during inspect. Keys must match the +-- normalized race name (spaces stripped) produced in UpdateStatsInspect. +local RD_RACE_RES = { + Dwarf = { frost = 10 }, + Gnome = { arcane = 10 }, + NightElf = { nature = 10 }, + Tauren = { nature = 10 }, + Troll = { nature = 10 }, + Undead = { shadow = 10 }, + Scourge = { shadow = 10 }, + HighElf = { arcane = 10 }, + Goblin = { fire = 5 }, +}; + +-- Passive racial dodge (% points) — Night Elf Quickness. +local RD_RACE_DODGE = { + NightElf = 1, +}; + +-- Passive racial health multiplier — Tauren Endurance (+5% base health). +-- Not applied to Stamina directly because health is read from UnitHealthMax, +-- but kept here for completeness / future use. +local RD_RACE_HEALTH_MULT = { + Tauren = 1.05, +}; + +-- Parse a buff/racial tooltip line and add any recognized primary-stat, +-- resistance, spell-power, AP, crit, hit, or mp5 bonuses into `est`. +-- Mirrors the item parser but omits weapon-slot specific fields. +-- Also handles the two common buff phrasings: +-- "+X to Strength" (Mark of the Wild, Blessing of Kings post-scaling, etc.) +-- "Increases Strength by X" +-- "+X Strength" (item-style, same as gear) +local function RD_ParseBuffLineInto(est, text) + if not text then return; end + local v; + + local function addPrimary(stat, amt) + if not amt then return; end + amt = tonumber(amt); if not amt then return; end + if stat == "str" then est.str = est.str + amt; + elseif stat == "agi" then est.agi = est.agi + amt; + elseif stat == "sta" then est.sta = est.sta + amt; + elseif stat == "int" then est.int = est.int + amt; + elseif stat == "spi" then est.spi = est.spi + amt; + elseif stat == "all" then est.allStats = est.allStats + amt; end + end + + -- "+X " (Blessing of Might reads "+X Attack Power", Fortitude + -- reads "+X Stamina" on Turtle, etc.) + _,_,v = strfind(text, "%+(%d+) Strength"); addPrimary("str", v); + _,_,v = strfind(text, "%+(%d+) Agility"); addPrimary("agi", v); + _,_,v = strfind(text, "%+(%d+) Stamina"); addPrimary("sta", v); + _,_,v = strfind(text, "%+(%d+) Intellect"); addPrimary("int", v); + _,_,v = strfind(text, "%+(%d+) Spirit"); addPrimary("spi", v); + _,_,v = strfind(text, "%+(%d+) to All Stats"); addPrimary("all", v); + if not v then _,_,v = strfind(text, "%+(%d+) All Stats"); addPrimary("all", v); end + + -- "Increases by X" (Arcane Intellect, Divine Spirit, PW:F, etc.) + _,_,v = strfind(text, "[Ii]ncreases Strength by (%d+)"); addPrimary("str", v); + _,_,v = strfind(text, "[Ii]ncreases Agility by (%d+)"); addPrimary("agi", v); + _,_,v = strfind(text, "[Ii]ncreases Stamina by (%d+)"); addPrimary("sta", v); + _,_,v = strfind(text, "[Ii]ncreases Intellect by (%d+)"); addPrimary("int", v); + _,_,v = strfind(text, "[Ii]ncreases Spirit by (%d+)"); addPrimary("spi", v); + _,_,v = strfind(text, "[Ii]ncreases all attributes by (%d+)"); addPrimary("all", v); + _,_,v = strfind(text, "all party members by (%d+)"); -- Mark/Gift of the Wild form: "...by X" + if v and strfind(text, "attribute") then addPrimary("all", v); end + + -- Resistances (Shadow Protection, Prayer of Shadow, etc.) + _,_,v = strfind(text, "%+(%d+) Arcane Resistance"); if v then est.resArcane = est.resArcane + v; end + _,_,v = strfind(text, "%+(%d+) Fire Resistance"); if v then est.resFire = est.resFire + v; end + _,_,v = strfind(text, "%+(%d+) Nature Resistance"); if v then est.resNature = est.resNature + v; end + _,_,v = strfind(text, "%+(%d+) Frost Resistance"); if v then est.resFrost = est.resFrost + v; end + _,_,v = strfind(text, "%+(%d+) Shadow Resistance"); if v then est.resShadow = est.resShadow + v; end + _,_,v = strfind(text, "%+(%d+) to All Resistances"); if v then est.allRes = est.allRes + v; end + _,_,v = strfind(text, "resistance to all schools of magic by (%d+)"); + if v then est.allRes = est.allRes + v; end + _,_,v = strfind(text, "[Ss]hadow resistance by (%d+)"); if v then est.resShadow = est.resShadow + v; end + _,_,v = strfind(text, "[Ff]rost resistance by (%d+)"); if v then est.resFrost = est.resFrost + v; end + _,_,v = strfind(text, "[Ff]ire resistance by (%d+)"); if v then est.resFire = est.resFire + v; end + _,_,v = strfind(text, "[Nn]ature resistance by (%d+)"); if v then est.resNature = est.resNature + v; end + _,_,v = strfind(text, "[Aa]rcane resistance by (%d+)"); if v then est.resArcane = est.resArcane + v; end + + -- Attack power (Battle Shout, Trueshot Aura, Blessing of Might) + _,_,v = strfind(text, "melee attack power by (%d+)"); if v then est.ap = est.ap + v; end + _,_,v = strfind(text, "ranged attack power by (%d+)"); if v then est.rap = est.rap + v; end + _,_,v = strfind(text, "attack power by (%d+)"); + if v and not strfind(text, "ranged") then est.ap = est.ap + v; end + _,_,v = strfind(text, "%+(%d+) Attack Power"); if v then est.ap = est.ap + v; end + + -- Spell power / healing (Arcane Brilliance variants, Blessing of Wisdom, etc. + -- typical is "+X Spell Damage" or "+X Healing") + _,_,v = strfind(text, "%+(%d+) Spell Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Spell Power"); if v then est.spellDmg = est.spellDmg + v; est.healing = est.healing + v; end + _,_,v = strfind(text, "%+(%d+) Healing"); if v then est.healing = est.healing + v; end + + -- Crit % (Rallying Cry, Moonkin Aura, etc.) + _,_,v = strfind(text, "critical strike with spells by (%d+)%%"); + if v then est.critSpell = est.critSpell + v; end + _,_,v = strfind(text, "critical strike by (%d+)%%"); + if v then est.critMelee = est.critMelee + v; end + _,_,v = strfind(text, "critical hit chance by (%d+)%%"); + if v then est.critMelee = est.critMelee + v; est.critSpell = est.critSpell + v; end + + -- Mp5 (Blessing of Wisdom) + _,_,v = strfind(text, "Restores (%d+) mana per 5"); if v then est.mp5 = est.mp5 + v; end + _,_,v = strfind(text, "(%d+) mana every 5"); if v then est.mp5 = est.mp5 + v; end + + -- Armor (Inner Fire, Devotion Aura, Frost Armor, etc.) + _,_,v = strfind(text, "[Ii]ncreases armor by (%d+)"); if v then est.armor = est.armor + v; end + _,_,v = strfind(text, "%+(%d+) Armor"); if v then est.armor = est.armor + v; end + + -- Defense (Blessing of Sanctuary etc.) + _,_,v = strfind(text, "%+(%d+) Defense"); if v then est.defense = est.defense + v; end + + -- Dodge (Cat form etc.) + _,_,v = strfind(text, "chance to dodge by (%d+)%%"); if v then est.dodge = est.dodge + v; end +end + +-- Walk every visible buff on the inspected unit and fold recognized stat +-- bonuses into `est`. Uses the standard UnitBuff / GameTooltip:SetUnitBuff +-- flow available in 1.12. Silently skipped if the APIs are unavailable. +local function RelationshipsDashboard_ScanInspectBuffs(est) + if not est then return; end + if not RelationshipsDashboardTooltip then return; end + if not UnitBuff then return; end + + for i = 1, 32 do + local buffTex = UnitBuff(RD_UNIT, i); + if not buffTex then break; end + RelationshipsDashboard_ResetTooltip(); + -- SetUnitBuff exists on 1.12 clients that support inspect; guard it. + local ok = pcall(function() + RelationshipsDashboardTooltip:SetUnitBuff(RD_UNIT, i); + end); + if ok then + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local tl = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + local text = tl and tl:GetText(); + RD_ParseBuffLineInto(est, text); + end + end + end +end + + +-- Scan every equipped item on the inspected unit and sum up the stat bonuses +-- printed on their tooltips. Returns a table of accumulated values. +local function RelationshipsDashboard_ScanInspectGear() + local est = { + str = 0, agi = 0, sta = 0, int = 0, spi = 0, allStats = 0, + armor = 0, + resArcane = 0, resFire = 0, resNature = 0, resFrost = 0, resShadow = 0, allRes = 0, + ap = 0, rap = 0, + critMelee = 0, critSpell = 0, hitMelee = 0, hitSpell = 0, + spellDmg = 0, healing = 0, + defense = 0, dodge = 0, parry = 0, block = 0, blockValue = 0, + mp5 = 0, + mhMin = 0, mhMax = 0, mhSpeed = 0, + ohMin = 0, ohMax = 0, ohSpeed = 0, + rMin = 0, rMax = 0, rSpeed = 0, + }; + + if not RelationshipsDashboardTooltip then return est; end + + -- Parse a single tooltip text line and fold any recognized stat bonuses + -- into `est`. `slotId` is passed through so weapon/ranged lines can be + -- routed correctly. Kept local so both the item-body loop and the + -- set-bonus loop can share exactly the same parsing (no divergence). + local function parseStatLine(text, slotId) + if not text then return; end + local v; + + -- Primary attributes + _,_,v = strfind(text, "%+(%d+) Strength"); if v then est.str = est.str + v; end + _,_,v = strfind(text, "%+(%d+) Agility"); if v then est.agi = est.agi + v; end + _,_,v = strfind(text, "%+(%d+) Stamina"); if v then est.sta = est.sta + v; end + _,_,v = strfind(text, "%+(%d+) Intellect"); if v then est.int = est.int + v; end + _,_,v = strfind(text, "%+(%d+) Spirit"); if v then est.spi = est.spi + v; end + _,_,v = strfind(text, "%+(%d+) to All Stats"); if v then est.allStats = est.allStats + v; end + if not v then _,_,v = strfind(text, "%+(%d+) All Stats"); if v then est.allStats = est.allStats + v; end end + + -- Armor + _,_,v = strfind(text, "(%d+) Armor"); + if v and not strfind(text, "Penetration") then est.armor = est.armor + v; end + + -- Resistances + _,_,v = strfind(text, "%+(%d+) Arcane Resistance"); if v then est.resArcane = est.resArcane + v; end + _,_,v = strfind(text, "%+(%d+) Fire Resistance"); if v then est.resFire = est.resFire + v; end + _,_,v = strfind(text, "%+(%d+) Nature Resistance"); if v then est.resNature = est.resNature + v; end + _,_,v = strfind(text, "%+(%d+) Frost Resistance"); if v then est.resFrost = est.resFrost + v; end + _,_,v = strfind(text, "%+(%d+) Shadow Resistance"); if v then est.resShadow = est.resShadow + v; end + _,_,v = strfind(text, "%+(%d+) to All Resistances"); if v then est.allRes = est.allRes + v; end + if not v then _,_,v = strfind(text, "%+(%d+) All Resistances"); if v then est.allRes = est.allRes + v; end end + + -- Attack power (ranged first) + _,_,v = strfind(text, "ranged attack power by (%d+)"); + if v then + est.rap = est.rap + v; + else + _,_,v = strfind(text, "attack power by (%d+)"); if v then est.ap = est.ap + v; end + _,_,v = strfind(text, "%+(%d+) Attack Power"); if v then est.ap = est.ap + v; end + end + + -- Crit + _,_,v = strfind(text, "critical strike with spells by (%d+)%%"); + if v then + est.critSpell = est.critSpell + v; + else + _,_,v = strfind(text, "critical strike by (%d+)%%"); if v then est.critMelee = est.critMelee + v; end + end + + -- Hit + _,_,v = strfind(text, "chance to hit with spells by (%d+)%%"); + if v then + est.hitSpell = est.hitSpell + v; + else + _,_,v = strfind(text, "chance to hit by (%d+)%%"); if v then est.hitMelee = est.hitMelee + v; end + end + + -- Spell damage / healing + _,_,v = strfind(text, "damage and healing done by magical spells and effects by up to (%d+)"); + if v then + est.spellDmg = est.spellDmg + v; + est.healing = est.healing + v; + else + _,_,v = strfind(text, "damage done by magical spells and effects by up to (%d+)"); + if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "healing done by spells and effects by up to (%d+)"); + if v then est.healing = est.healing + v; end + _,_,v = strfind(text, "healing spells by up to (%d+)"); + if v then est.healing = est.healing + v; end + _,_,v = strfind(text, "%+(%d+) Healing"); if v then est.healing = est.healing + v; end + _,_,v = strfind(text, "%+(%d+) Spell Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Spell Power"); if v then est.spellDmg = est.spellDmg + v; est.healing = est.healing + v; end + _,_,v = strfind(text, "%+(%d+) Fire Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Frost Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Arcane Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Shadow Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Nature Damage"); if v then est.spellDmg = est.spellDmg + v; end + _,_,v = strfind(text, "%+(%d+) Holy Damage"); if v then est.spellDmg = est.spellDmg + v; end + end + + -- Defense + _,_,v = strfind(text, "%+(%d+) Defense"); if v then est.defense = est.defense + v; end + _,_,v = strfind(text, "Increased Defense %+(%d+)"); if v then est.defense = est.defense + v; end + + -- Dodge / Parry / Block + _,_,v = strfind(text, "chance to dodge an attack by (%d+)%%"); if v then est.dodge = est.dodge + v; end + _,_,v = strfind(text, "%+(%d+)%% Dodge"); if v then est.dodge = est.dodge + v; end + _,_,v = strfind(text, "chance to parry an attack by (%d+)%%"); if v then est.parry = est.parry + v; end + _,_,v = strfind(text, "%+(%d+)%% Parry"); if v then est.parry = est.parry + v; end + _,_,v = strfind(text, "chance to block attacks with a shield by (%d+)%%"); if v then est.block = est.block + v; end + _,_,v = strfind(text, "block value of your shield by (%d+)"); if v then est.blockValue = est.blockValue + v; end + + -- Mana per 5 + _,_,v = strfind(text, "Restores (%d+) mana per 5"); if v then est.mp5 = est.mp5 + v; end + if not v then _,_,v = strfind(text, "%+(%d+) mana per 5"); if v then est.mp5 = est.mp5 + v; end end + if not v then _,_,v = strfind(text, "(%d+) [Mm]ana [Pp]er 5"); if v then est.mp5 = est.mp5 + v; end end + if not v then _,_,v = strfind(text, "(%d+) mana every 5"); if v then est.mp5 = est.mp5 + v; end end + + -- Turtle/1.12 additional tooltip variants + _,_,v = strfind(text, "[Ii]ncreases healing done by .- up to (%d+)"); + if v then est.healing = est.healing + v; end + _,_,v = strfind(text, "%+(%d+) [Ss]pell [Cc]rit"); + if v then est.critSpell = est.critSpell + v; end + _,_,v = strfind(text, "%+(%d+)%% [Ss]pell [Cc]rit"); + if v then est.critSpell = est.critSpell + v; end + _,_,v = strfind(text, "[Ii]ncreases attack power in Cat, Bear.- forms by (%d+)"); + if v then est.ap = est.ap + v; end -- Druid feral AP + _,_,v = strfind(text, "Feral Attack Power[:%s]+(%d+)"); + if v then est.ap = est.ap + v; end + -- Turtle common enchant / item phrasing without "Equip:" prefix + _,_,v = strfind(text, "%+(%d+) [Ww]eapon [Dd]amage"); + if v and (slotId == 16 or slotId == 17 or slotId == 18) then + if slotId == 16 then est.mhMin = est.mhMin + v; est.mhMax = est.mhMax + v; + elseif slotId == 17 then est.ohMin = est.ohMin + v; est.ohMax = est.ohMax + v; + else est.rMin = est.rMin + v; est.rMax = est.rMax + v; end + end + _,_,v = strfind(text, "Increases the block value of your shield by (%d+)"); + if v then est.blockValue = est.blockValue + v; end + + -- Weapon / ranged damage + speed (only for the weapon slots) + if slotId == 16 or slotId == 17 or slotId == 18 then + local _, _, dMin, dMax = strfind(text, "(%d+) %- (%d+) Damage"); + local _, _, spd = strfind(text, "Speed (%d+%.%d+)"); + if slotId == 16 then + if dMin then est.mhMin = tonumber(dMin); est.mhMax = tonumber(dMax); end + if spd then est.mhSpeed = tonumber(spd); end + elseif slotId == 17 then + if dMin then est.ohMin = tonumber(dMin); est.ohMax = tonumber(dMax); end + if spd then est.ohSpeed = tonumber(spd); end + else + if dMin then est.rMin = tonumber(dMin); est.rMax = tonumber(dMax); end + if spd then est.rSpeed = tonumber(spd); end + end + end + end + + -- Sets whose bonuses we have already folded in. Each set's bonus lines + -- are printed on EVERY equipped piece's tooltip, so we must apply them + -- exactly once per set no matter how many pieces the target wears. + local processedSets = {}; + + for i = 1, table.getn(EQUIPMENT_SLOTS) do + local slotId = EQUIPMENT_SLOTS[i]; + local itemLink = GetInventoryItemLink(RD_UNIT, slotId); + if itemLink then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, slotId); + + local inSet = false; -- currently inside a set block? + local setSkip = false; -- skip stats for this block? + local setEquipped = 0; -- how many pieces of this set are equipped + for j = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. j); + local text = textLine and textLine:GetText(); + if text then + -- Set header of the form "SetName (equipped/total)". + -- Also matches "Set: SetName (equipped/total)". + local _, _, eqStr, totStr = strfind(text, "%((%d+)/(%d+)%)"); + if eqStr and totStr then + inSet = true; + setEquipped = tonumber(eqStr) or 0; + -- Derive a stable set name key by stripping the count. + local name = gsub(text, "%s*%(%d+/%d+%)%s*$", ""); + name = gsub(name, "^%s*[Ss]et:%s*", ""); + name = gsub(name, "^%s+", ""); + name = gsub(name, "%s+$", ""); + if name == "" then name = "unnamed"; end + if processedSets[name] then + setSkip = true; + else + processedSets[name] = true; + setSkip = false; + end + -- Header line itself carries no stats. + elseif inSet then + -- Inside a set block. Bonus lines can look like: + -- "(3) +25 Strength" + -- "(3) Set: +25 Strength" + -- " Set: (3) +25 Strength" + -- "Set 3: +25 Strength" (Turtle/custom) + -- Piece-list lines have no "(N)" marker. + -- + -- Detection strategy: + -- 1) If we can find a required-piece count anywhere + -- in the line, treat it as a bonus line and apply + -- when the target has enough pieces equipped. + -- 2) As a fallback (in case a tooltip format hides + -- the count), use the FontString color: vanilla + -- renders ACTIVE set bonus lines in green + -- (~0,1,0) and inactive ones in gray (~0.5s). + -- Green => apply, gray => skip. This makes us + -- robust to header/format differences on + -- Turtle WoW / OctoWoW where the plain regex + -- previously dropped every set bonus during + -- inspect. + local _, _, reqStr = strfind(text, "%((%d+)%)"); + if not reqStr then + _,_,reqStr = strfind(text, "[Ss]et%s+(%d+)%s*:"); + end + + local isBonus = false; + local active = false; + if reqStr then + isBonus = true; + local req = tonumber(reqStr) or 99; + active = req <= setEquipped; + end + + -- Color-based override / fallback. GetTextColor() may + -- not exist on very old clients — pcall for safety. + local ok, r, g, b = pcall(function() + return textLine:GetTextColor(); + end); + if ok and type(r) == "number" and type(g) == "number" and type(b) == "number" then + -- Green = active set bonus. + if g > 0.6 and r < 0.4 and b < 0.4 then + isBonus = true; + active = true; + -- Gray = inactive set bonus (only override when we + -- already thought it was a bonus line). + elseif isBonus and r > 0.4 and r < 0.7 + and g > 0.4 and g < 0.7 + and b > 0.4 and b < 0.7 + and math.abs(r - g) < 0.1 and math.abs(g - b) < 0.1 then + active = false; + end + end + + if isBonus and active and not setSkip then + parseStatLine(text, slotId); + end + else + -- Normal item body line. + parseStatLine(text, slotId); + end + end + end + end + end + + + + -- Fold "all stats" / "all resistances" into the individual buckets. + if est.allStats > 0 then + est.str = est.str + est.allStats; est.agi = est.agi + est.allStats; + est.sta = est.sta + est.allStats; est.int = est.int + est.allStats; + est.spi = est.spi + est.allStats; + end + if est.allRes > 0 then + est.resArcane = est.resArcane + est.allRes; est.resFire = est.resFire + est.allRes; + est.resNature = est.resNature + est.allRes; est.resFrost = est.resFrost + est.allRes; + est.resShadow = est.resShadow + est.allRes; + end + + return est; +end + +-- Helper: format an estimated number. +local function RD_EstNum(n) + return tostring(floor(n + 0.5)); +end + +-- Helper: format an estimated percentage. +local function RD_EstPct(n) + return string.format("%.1f%%", n); +end + +-- Populate every dashboard row with estimates derived from the inspected +-- unit's equipped gear and level. Called from UpdateStats when RD_UNIT is not +-- "player". +function RelationshipsDashboard_UpdateStatsInspect() + local _, class = UnitClass(RD_UNIT); + class = class or "WARRIOR"; + local level = UnitLevel(RD_UNIT) or 1; + if level < 1 then level = 1; end + + local est = RelationshipsDashboard_ScanInspectGear(); + + -- Fold active buffs on the target into the same estimate bucket. This + -- covers Mark/Gift of the Wild, PW:Fortitude, Divine Spirit, Arcane + -- Intellect, Blessing of Kings/Might/Wisdom, Battle Shout, Trueshot + -- Aura, Shadow/Nature/etc. Protection, Inner Fire, and similar. The + -- personal panel gets these automatically via UnitStat; inspect does + -- not, so we parse buff tooltips. Re-fold allStats/allRes afterwards + -- because Gift of the Wild etc. add to those buckets. + local buffAllStatsBefore = est.allStats; + local buffAllResBefore = est.allRes; + RelationshipsDashboard_ScanInspectBuffs(est); + local dAll = est.allStats - buffAllStatsBefore; + if dAll > 0 then + est.str = est.str + dAll; est.agi = est.agi + dAll; + est.sta = est.sta + dAll; est.int = est.int + dAll; + est.spi = est.spi + dAll; + end + local dRes = est.allRes - buffAllResBefore; + if dRes > 0 then + est.resArcane = est.resArcane + dRes; est.resFire = est.resFire + dRes; + est.resNature = est.resNature + dRes; est.resFrost = est.resFrost + dRes; + est.resShadow = est.resShadow + dRes; + end + + + -- Base primary stats scaled by level, plus per-race offset, plus gear. + local anchors = RD_BASE_STATS_60[class] or RD_BASE_STATS_60.WARRIOR; + local _, raceKey = UnitRace(RD_UNIT); + if not raceKey then raceKey = UnitRace(RD_UNIT) or "Human"; end + -- Normalize race key: strip spaces (e.g. "Night Elf" -> "NightElf") and + -- collapse to the table's naming. + if raceKey then raceKey = gsub(raceKey, "%s+", ""); end + local raceMods = RD_RACE_MODS_60[raceKey] or RD_RACE_MODS_60.Human; + -- Vanilla base stats don't grow linearly from level 1. Blizzard's + -- curve is roughly quadratic: growth per level starts small and + -- accelerates. Fit: at L1 primaries are ~20, at L60 they match the + -- class anchor. Approximate with (L-1)/59 raised to ~1.4 so mid-level + -- (30-45) values match Turtle character sheets more closely than + -- straight linear scaling did. + local function baseStat(idx) + local anchor = anchors[idx] + (raceMods[idx] or 0); + if level >= 60 then return anchor; end + local low = 20; -- level-1 baseline + if anchor < low then low = anchor; end + local t = (level - 1) / 59; -- 0..1 + if t < 0 then t = 0; end + -- power curve; exp=1.35 matches vanilla growth within a couple points + local frac = t ^ 1.35; + local b = floor(low + (anchor - low) * frac + 0.5); + if b < 1 then b = 1; end + return b; + end + local totStr = baseStat(1) + est.str; + local totAgi = baseStat(2) + est.agi; + local totSta = baseStat(3) + est.sta; + local totInt = baseStat(4) + est.int; + local totSpi = baseStat(5) + est.spi; + + -- Passive racial resistances (Dwarf +10 Frost, Gnome +10 Arcane, + -- Undead +10 Shadow, NightElf/Tauren/Troll +10 Nature, etc.). + local raceRes = RD_RACE_RES[raceKey]; + if raceRes then + est.resArcane = est.resArcane + (raceRes.arcane or 0); + est.resFire = est.resFire + (raceRes.fire or 0); + est.resNature = est.resNature + (raceRes.nature or 0); + est.resFrost = est.resFrost + (raceRes.frost or 0); + est.resShadow = est.resShadow + (raceRes.shadow or 0); + end + -- Passive racial dodge (Night Elf Quickness = +1%). + est.dodge = est.dodge + (RD_RACE_DODGE[raceKey] or 0); + + + -- ==== General ==== + -- Health and mana ARE visible for a target, so use the real values. + local health = UnitHealth(RD_UNIT); + local maxHealth = UnitHealthMax(RD_UNIT); + if maxHealth and maxHealth > 0 then + RelationshipsDashboardHealthRowValue:SetText(health .. " / " .. maxHealth); + else + RelationshipsDashboardHealthRowValue:SetText("--"); + end + RelationshipsDashboardHealthRowValue:SetTextColor(0.2, 1, 0.2); + + local powerType = UnitPowerType(RD_UNIT); + local power = UnitMana(RD_UNIT); + local maxPower = UnitManaMax(RD_UNIT); + if powerType == 1 then RelationshipsDashboardPowerRowLabel:SetText("Rage:"); + elseif powerType == 2 then RelationshipsDashboardPowerRowLabel:SetText("Focus:"); + elseif powerType == 3 then RelationshipsDashboardPowerRowLabel:SetText("Energy:"); + else RelationshipsDashboardPowerRowLabel:SetText("Mana:"); end + if maxPower and maxPower > 0 then + RelationshipsDashboardPowerRowValue:SetText(power .. " / " .. maxPower); + else + RelationshipsDashboardPowerRowValue:SetText("--"); + end + RelationshipsDashboardPowerRowValue:SetTextColor(0.3, 0.6, 1); + + if RelationshipsDashboard_ClassUsesMana() and powerType ~= 0 then + RelationshipsDashboardManaRowLabel:SetText("Mana:"); + RelationshipsDashboardManaRowValue:SetText("--"); + RelationshipsDashboardManaRowValue:SetTextColor(0.3, 0.6, 1); + end + + -- Armor = item armor + Agility contribution (2 per Agi in vanilla). + local armorEst = est.armor + totAgi * 2; + RelationshipsDashboardArmorRowValue:SetText(RD_EstNum(armorEst)); + RelationshipsDashboardArmorRowValue:SetTextColor(1, 1, 1); + + -- Movement / durability / repair are not knowable for another player. + RelationshipsDashboardMovementRowValue:SetText("--"); + RelationshipsDashboardDurabilityRowValue:SetText("--"); + RelationshipsDashboardDurabilityRowValue:SetTextColor(1, 1, 1); + RelationshipsDashboardRepairCostRowValue:SetText("--"); + + -- GearScore + average item level work off the target's gear directly. + local gearScore, avgILvl = RelationshipsDashboard_CalculateGearScore(); + if gearScore and gearScore > 0 then + RelationshipsDashboardGearScoreRowValue:SetText(tostring(gearScore)); + else + RelationshipsDashboardGearScoreRowValue:SetText("--"); + end + if gearScore >= 3500 then RelationshipsDashboardGearScoreRowValue:SetTextColor(1, 0.5, 0); + elseif gearScore >= 2000 then RelationshipsDashboardGearScoreRowValue:SetTextColor(0.8, 0.2, 1); + elseif gearScore >= 1200 then RelationshipsDashboardGearScoreRowValue:SetTextColor(0, 0.44, 0.87); + elseif gearScore >= 500 then RelationshipsDashboardGearScoreRowValue:SetTextColor(0.12, 1, 0); + else RelationshipsDashboardGearScoreRowValue:SetTextColor(1, 1, 1); end + if avgILvl and avgILvl > 0 then + RelationshipsDashboardAvgILvlRowValue:SetText(string.format("%.1f", avgILvl)); + else + RelationshipsDashboardAvgILvlRowValue:SetText("--"); + end + + -- ==== Attributes ==== + RelationshipsDashboardStrengthRowValue:SetText(RD_EstNum(totStr)); + RelationshipsDashboardAgilityRowValue:SetText(RD_EstNum(totAgi)); + RelationshipsDashboardStaminaRowValue:SetText(RD_EstNum(totSta)); + RelationshipsDashboardIntellectRowValue:SetText(RD_EstNum(totInt)); + RelationshipsDashboardSpiritRowValue:SetText(RD_EstNum(totSpi)); + + -- ==== Offense ==== + -- Melee attack power by class formula. + local ap; + if class == "WARRIOR" or class == "PALADIN" then ap = level * 3 + totStr * 2 - 20; + elseif class == "SHAMAN" then ap = level * 2 + totStr * 2 - 20; + elseif class == "DRUID" then ap = totStr * 2 - 20; + elseif class == "ROGUE" or class == "HUNTER" then ap = level * 2 + totStr + totAgi - 20; + else ap = totStr - 10; end + if ap < 0 then ap = 0; end + ap = ap + est.ap; + est.apTotal = ap; + + -- Melee damage: weapon base + AP contribution (AP/14 per second). + if est.mhSpeed > 0 and est.mhMax > 0 then + local swingBonus = (ap / 14) * est.mhSpeed; + local effMin = est.mhMin + swingBonus; + local effMax = est.mhMax + swingBonus; + RelationshipsDashboardMeleeDmgRowValue:SetText(floor(effMin) .. " - " .. floor(effMax)); + RelationshipsDashboardWeaponDPSRowValue:SetText(string.format("%.1f", (effMin + effMax) / 2 / est.mhSpeed)); + else + RelationshipsDashboardMeleeDmgRowValue:SetText("--"); + RelationshipsDashboardWeaponDPSRowValue:SetText("--"); + end + + RelationshipsDashboardMeleeAPRowValue:SetText(RD_EstNum(ap)); + + if est.mhSpeed > 0 then + if est.ohSpeed > 0 then + RelationshipsDashboardMeleeSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(est.mhSpeed) .. " / " .. RelationshipsDashboard_FormatDecimal(est.ohSpeed)); + else + RelationshipsDashboardMeleeSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(est.mhSpeed)); + end + else + RelationshipsDashboardMeleeSpeedRowValue:SetText("--"); + end + + -- Weapon skill: player skill API is unavailable for a target, so show the + -- level cap (level * 5). + RelationshipsDashboardWeaponSkillRowValue:SetText(tostring(level * 5) .. " (max)"); + + RelationshipsDashboardMeleeHitRowValue:SetText(RD_EstPct(est.hitMelee)); + + local agiPerCrit = RD_AGI_PER_CRIT[class] or 20; + local meleeCrit = 5 + totAgi / agiPerCrit + est.critMelee; + RelationshipsDashboardMeleeCritRowValue:SetText(RD_EstPct(meleeCrit)); + + -- Ranged Attack Power (Turtle/1.12): Hunters get level*2 + Agi*2 - 20 + -- (2 RAP per Agility, not 1); other classes just use Agility - 10. + local rap; + if class == "HUNTER" then rap = level * 2 + totAgi * 2 - 20; + else rap = totAgi - 10; end + if rap < 0 then rap = 0; end + rap = rap + est.rap; + RelationshipsDashboardRangedAPRowValue:SetText(RD_EstNum(rap)); + + -- Ranged crit: base 5% + Agi/agiPerCrit + gear ranged crit (falls back + -- to melee crit bucket because most 1.12 items list a single crit stat). + local rangedCrit = 5 + totAgi / agiPerCrit + est.critMelee; + RelationshipsDashboardRangedCritRowValue:SetText(RD_EstPct(rangedCrit)); + + if est.rSpeed > 0 then + RelationshipsDashboardRangedSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(est.rSpeed)); + else + RelationshipsDashboardRangedSpeedRowValue:SetText("--"); + end + if est.rMax > 0 then + RelationshipsDashboardRangedDmgRowValue:SetText(est.rMin .. " - " .. est.rMax); + else + RelationshipsDashboardRangedDmgRowValue:SetText("--"); + end + + RelationshipsDashboardSpellPowerRowValue:SetText(RD_EstNum(est.spellDmg)); + RelationshipsDashboardHealingPowerRowValue:SetText(RD_EstNum(est.healing)); + RelationshipsDashboardSpellHitRowValue:SetText(RD_EstPct(est.hitSpell)); + + -- Spell crit per Intellect varies by class in vanilla/Turtle. + -- Values are Int-per-1%-crit at level 60. + local intPerSpellCrit; + if class == "MAGE" then intPerSpellCrit = 59.5; + elseif class == "PRIEST" then intPerSpellCrit = 59.2; + elseif class == "WARLOCK" then intPerSpellCrit = 60.6; + elseif class == "DRUID" then intPerSpellCrit = 60.0; + elseif class == "SHAMAN" then intPerSpellCrit = 59.2; + elseif class == "PALADIN" then intPerSpellCrit = 54.0; + else intPerSpellCrit = 60.0; end + local spellCrit = 5 + totInt / intPerSpellCrit + est.critSpell; + RelationshipsDashboardSpellCritRowValue:SetText(RD_EstPct(spellCrit)); + + -- Mana regen: spirit-based (if a mana class) + gear mp5. + local spiritFactor = 0; + if class == "PRIEST" or class == "MAGE" or class == "DRUID" then spiritFactor = 0.125; + elseif class == "WARLOCK" or class == "SHAMAN" or class == "PALADIN" then spiritFactor = 0.1; end + local manaRegen = 0; + if spiritFactor > 0 then manaRegen = totSpi * spiritFactor / 2; end + manaRegen = manaRegen + est.mp5 / 5; + if manaRegen > 0 then + RelationshipsDashboardManaRegenRowValue:SetText(string.format("%.1f/s", manaRegen)); + else + RelationshipsDashboardManaRegenRowValue:SetText("--"); + end + + -- ==== Defenses ==== + local defense = level * 5 + est.defense; + RelationshipsDashboardDefenseRowValue:SetText(RD_EstNum(defense)); + + local dodge = totAgi / agiPerCrit + est.dodge; + RelationshipsDashboardDodgeRowValue:SetText(RD_EstPct(dodge)); + + if RD_CAN_PARRY[class] then + RelationshipsDashboardParryRowValue:SetText(RD_EstPct(5 + est.parry)); + else + RelationshipsDashboardParryRowValue:SetText("--"); + end + + if est.block > 0 or est.blockValue > 0 then + RelationshipsDashboardBlockRowValue:SetText(RD_EstPct(est.block)); + else + RelationshipsDashboardBlockRowValue:SetText("--"); + end + + -- Avoidance: rough miss (5%) + dodge + parry + block. + local avoidance = 5 + dodge + (RD_CAN_PARRY[class] and (5 + est.parry) or 0) + est.block; + RelationshipsDashboardAvoidanceRowValue:SetText(RD_EstPct(avoidance)); + + -- ==== Resistances ==== + RelationshipsDashboardArcaneResRowValue:SetText(RD_EstNum(est.resArcane)); + RelationshipsDashboardFireResRowValue:SetText(RD_EstNum(est.resFire)); + RelationshipsDashboardNatureResRowValue:SetText(RD_EstNum(est.resNature)); + RelationshipsDashboardFrostResRowValue:SetText(RD_EstNum(est.resFrost)); + RelationshipsDashboardShadowResRowValue:SetText(RD_EstNum(est.resShadow)); + + -- Apply class-specific visibility (hide irrelevant stats). + RelationshipsDashboard_ApplyClassVisibility(); +end + +------------------------------------------------------------------------------- +-- Update throttling / coalescing. +-- +-- RelationshipsDashboard_UpdateStats_Impl() is the heavy per-frame refresh +-- (iterates every equipment slot, scans tooltips for durability, walks buffs, +-- computes GearScore and repair costs). Vanilla WoW fires the events that +-- trigger it (UNIT_HEALTH, UNIT_MANA, UNIT_INVENTORY_CHANGED, PLAYER_MONEY, +-- etc.) in dense bursts — sometimes many per frame — which produces a +-- visible frame-time spike each time the panel is open. +-- +-- To fix that we split UpdateStats into two entry points: +-- * RelationshipsDashboard_UpdateStats() -> mark dirty, defer to next tick +-- * RelationshipsDashboard_UpdateStatsNow() -> run immediately (used when +-- the panel first opens so the +-- user never sees stale data) +-- +-- The OnUpdate handler flushes at most once per RD_UPDATE_THROTTLE seconds, +-- which collapses a whole burst of events into a single refresh. +------------------------------------------------------------------------------- +local RD_UPDATE_THROTTLE = 0.15; -- seconds between coalesced refreshes +RD_StatsDirty = false; +RD_StatsLastUpdate = 0; +RD_StatsAccum = 0; + +function RelationshipsDashboard_UpdateStatsNow() + RD_StatsDirty = false; + RD_StatsAccum = 0; + RelationshipsDashboard_UpdateStats_Impl(); +end + +function RelationshipsDashboard_UpdateStats() + if not RelationshipsDashboardFrame:IsVisible() then + return; + end + -- Defer: OnUpdate will flush this within RD_UPDATE_THROTTLE seconds. + RD_StatsDirty = true; +end + +function RelationshipsDashboard_FlushPendingStats(elapsed) + if not RD_StatsDirty then return; end + RD_StatsAccum = RD_StatsAccum + (elapsed or 0); + if RD_StatsAccum < RD_UPDATE_THROTTLE then return; end + RD_StatsAccum = 0; + RD_StatsDirty = false; + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats_Impl(); + end +end + +function RelationshipsDashboard_UpdateStats_Impl() + + if not RelationshipsDashboardFrame:IsVisible() then + return; + end + + -- Update title text to reflect the unit being displayed + if RelationshipsDashboardTitleText then + local uName = UnitName(RD_UNIT); + if RD_UNIT == "player" or not uName or uName == "" then + RelationshipsDashboardTitleText:SetText("Character Dashboard"); + else + local _, uClass = UnitClass(RD_UNIT); + local uLevel = UnitLevel(RD_UNIT); + local lvlStr = (uLevel and uLevel > 0) and (" (Lvl " .. uLevel .. ")") or ""; + RelationshipsDashboardTitleText:SetText(uName .. lvlStr .. (uClass and (" - " .. uClass) or "")); + end + end + + -- When inspecting another player, the client does not expose their computed + -- stats. Estimate everything from their equipped gear + level instead. + if RD_UNIT ~= "player" then + RelationshipsDashboard_UpdateStatsInspect(); + return; + end + + -- ==== Section: General ==== + -- Health + local health = UnitHealth(RD_UNIT); + local maxHealth = UnitHealthMax(RD_UNIT); + if maxHealth > 0 then + RelationshipsDashboardHealthRowValue:SetText(health .. " / " .. maxHealth); + else + RelationshipsDashboardHealthRowValue:SetText("--"); + end + RelationshipsDashboardHealthRowValue:SetTextColor(0.2, 1, 0.2); + + -- Power (Mana/Rage/Energy) + -- When a mana-capable class is in a non-mana form (e.g., druid bear/cat), + -- PowerRow shows the form's power (Rage/Energy), and ManaRow shows base Mana. + local powerType = UnitPowerType(RD_UNIT); + local power = UnitMana(RD_UNIT); + local maxPower = UnitManaMax(RD_UNIT); + local classUsesMana = RelationshipsDashboard_ClassUsesMana(); + + -- Track mana values when in caster form for use when shifted + if powerType == 0 then + -- Currently in caster form with mana bar visible + RelationshipsDashboard_LastKnownMana = power; + RelationshipsDashboard_LastKnownMaxMana = maxPower; + end + + if powerType == 1 then + RelationshipsDashboardPowerRowLabel:SetText("Rage:"); + RelationshipsDashboardPowerRowValue:SetText(power .. " / " .. maxPower); + RelationshipsDashboardPowerRowValue:SetTextColor(1, 0.2, 0.2); + elseif powerType == 2 then + RelationshipsDashboardPowerRowLabel:SetText("Focus:"); + RelationshipsDashboardPowerRowValue:SetText(power .. " / " .. maxPower); + RelationshipsDashboardPowerRowValue:SetTextColor(1, 0.8, 0); + elseif powerType == 3 then + RelationshipsDashboardPowerRowLabel:SetText("Energy:"); + RelationshipsDashboardPowerRowValue:SetText(power .. " / " .. maxPower); + RelationshipsDashboardPowerRowValue:SetTextColor(1, 1, 0); + else + RelationshipsDashboardPowerRowLabel:SetText("Mana:"); + if maxPower > 0 then + RelationshipsDashboardPowerRowValue:SetText(power .. " / " .. maxPower); + else + RelationshipsDashboardPowerRowValue:SetText("--"); + end + RelationshipsDashboardPowerRowValue:SetTextColor(0.3, 0.6, 1); + end + + -- ManaRow: Show base mana for mana-capable classes in non-mana forms + -- (e.g., druid in bear/cat form shows Rage/Energy on PowerRow + Mana on ManaRow) + -- When already in caster form, ManaRow is hidden by ApplyClassVisibility + if classUsesMana and powerType ~= 0 then + local mana = RelationshipsDashboard_LastKnownMana or 0; + local maxMana = RelationshipsDashboard_LastKnownMaxMana or 0; + -- Try to get current mana directly (some vanilla clients allow this) + -- UnitMana with powerType 0 doesn't work when shifted, so use stored values + RelationshipsDashboardManaRowLabel:SetText("Mana:"); + if maxMana > 0 then + RelationshipsDashboardManaRowValue:SetText(mana .. " / " .. maxMana); + else + RelationshipsDashboardManaRowValue:SetText("--"); + end + RelationshipsDashboardManaRowValue:SetTextColor(0.3, 0.6, 1); + end + + -- Armor + local baseArmor, effectiveArmor, armorBonus, posBuff, negBuff = UnitArmor(RD_UNIT); + if effectiveArmor then + if armorBonus and armorBonus ~= 0 then + RelationshipsDashboardArmorRowValue:SetText(effectiveArmor .. " (+" .. armorBonus .. ")"); + else + RelationshipsDashboardArmorRowValue:SetText(tostring(effectiveArmor)); + end + elseif baseArmor then + RelationshipsDashboardArmorRowValue:SetText(tostring(baseArmor)); + else + RelationshipsDashboardArmorRowValue:SetText("--"); + end + + -- Movement Speed + -- GetUnitSpeed() does NOT exist in vanilla 1.12 / TurtleWoW / OctoWoW + -- Calculate movement speed from known speed-boosting buffs instead + local speedPct = 100; -- Base run speed = 7 yards/sec = 100% + local _, playerClass = UnitClass(RD_UNIT); + + -- Check for speed-increasing buffs by spell name + -- Ghost Wolf (Shaman): 40% bonus = 140% + -- Aspect of the Cheetah (Hunter): 30% bonus = 130% + -- Aspect of the Pack (Hunter): 30% bonus = 130% + -- Sprint (Rogue): 70% bonus = 170% + -- Dash (Druid cat): 70% bonus = 170% + -- Travel Form (Druid): 40% bonus = 140% + -- Cheeta (Druid travel): 40% bonus = 140% + -- Sprint (item/enchant): minor bonus + -- Swiftness potions: 50% bonus = 150% + -- Carrot on a Stick: 3% mounted bonus + -- Mithril Spurs: 4% mounted bonus + -- Riding enchant: 2% mounted bonus + -- Nifty Stopwatch: 5% bonus + + -- Check if mounted (mount speed varies by riding skill) + local isMounted = false; + for i = 1, 32 do + local texture, applications, spellName = UnitBuff(RD_UNIT, i); + if not texture then break; end + if spellName then + -- Mount detection + if strfind(spellName, "Mount") or strfind(spellName, "Steed") or + strfind(spellName, "Charger") or strfind(spellName, "Warhorse") or + strfind(spellName, "Kodo") or strfind(spellName, "Mechanostrider") or + strfind(spellName, "Ram") or strfind(spellName, "Tiger") or + strfind(spellName, "Wolf") or strfind(spellName, "Raptor") or + strfind(spellName, "Skeletal") or strfind(spellName, "Frostsaber") or + strfind(spellName, "Nightsaber") or strfind(spellName, "Horse") or + strfind(spellName, "Palomino") or strfind(spellName, "Pinto") or + strfind(spellName, "Stallion") or strfind(spellName, "Mare") then + isMounted = true; + speedPct = 160; -- Epic mount = 100% bonus (200% total = 160% of base run) + -- Check for standard vs epic mount: we can't easily distinguish + -- Default to showing 200% for epic mount speed + speedPct = 200; + break; + end + end + end + + if not isMounted then + -- Check for speed-boosting buffs + for i = 1, 32 do + local texture, applications, spellName = UnitBuff(RD_UNIT, i); + if not texture then break; end + if spellName then + -- Shaman: Ghost Wolf = 140% speed + if spellName == "Ghost Wolf" then speedPct = 140; break; end + -- Hunter: Aspect of the Cheetah/Pack = 130% speed + if spellName == "Aspect of the Cheetah" then speedPct = 130; break; end + if spellName == "Aspect of the Pack" then speedPct = 130; break; end + -- Rogue: Sprint = 170% speed + if spellName == "Sprint" then speedPct = 170; break; end + -- Druid: Travel Form / Swift Flight Form + if spellName == "Travel Form" then speedPct = 140; break; end + if spellName == "Dash" then speedPct = 170; break; end + if spellName == "Cat Form" then speedPct = 100; break; end + -- Swiftness Potion + if spellName == "Swiftness" then speedPct = 150; break; end + -- Nifty Stopwatch + if spellName == "Speed" then speedPct = 105; break; end + -- Any other speed buff (generic check) + if strfind(strlower(spellName), "sprint") then speedPct = 170; break; end + if strfind(strlower(spellName), "dash") then speedPct = 170; break; end + if strfind(strlower(spellName), "speed") then speedPct = 105; break; end + end + end + end + + RelationshipsDashboardMovementRowValue:SetText(string.format("%.0f%%", speedPct)); + + -- Durability + local avgDurability, repairCost, slotsWithDur = RelationshipsDashboard_CalculateDurabilityAndRepair(); + if RelationshipsDashboard_MerchantRepairCost > 0 then + -- At a repair merchant: show the exact cost the game charges. + repairCost = RelationshipsDashboard_MerchantRepairCost; + else + -- Away from a merchant: scale the raw estimate by the calibration factor + -- learned from the last real merchant repair cost so it matches reality. + repairCost = floor(repairCost * (RD_RepairCalibration or 1)); + end + local durText = RelationshipsDashboard_FormatPercent(avgDurability); + if avgDurability >= 75 then + RelationshipsDashboardDurabilityRowValue:SetTextColor(0.2, 1, 0.2); + elseif avgDurability >= 50 then + RelationshipsDashboardDurabilityRowValue:SetTextColor(1, 1, 0); + elseif avgDurability >= 25 then + RelationshipsDashboardDurabilityRowValue:SetTextColor(1, 0.5, 0); + else + RelationshipsDashboardDurabilityRowValue:SetTextColor(1, 0.2, 0.2); + end + RelationshipsDashboardDurabilityRowValue:SetText(durText); + + -- Repair Total + RelationshipsDashboardRepairCostRowValue:SetText(RelationshipsDashboard_FormatMoney(repairCost)); + + -- GearScore + local gearScore, avgILvl, equippedCount = RelationshipsDashboard_CalculateGearScore(); + if gearScore > 0 then + RelationshipsDashboardGearScoreRowValue:SetText(tostring(gearScore)); + else + RelationshipsDashboardGearScoreRowValue:SetText("--"); + end + if gearScore >= 3500 then + RelationshipsDashboardGearScoreRowValue:SetTextColor(1, 0.5, 0); + elseif gearScore >= 2000 then + RelationshipsDashboardGearScoreRowValue:SetTextColor(0.8, 0.2, 1); + elseif gearScore >= 1200 then + RelationshipsDashboardGearScoreRowValue:SetTextColor(0, 0.44, 0.87); + elseif gearScore >= 500 then + RelationshipsDashboardGearScoreRowValue:SetTextColor(0.12, 1, 0); + else + RelationshipsDashboardGearScoreRowValue:SetTextColor(1, 1, 1); + end + + -- Avg Item Level + if avgILvl > 0 then + RelationshipsDashboardAvgILvlRowValue:SetText(string.format("%.1f", avgILvl)); + else + RelationshipsDashboardAvgILvlRowValue:SetText("--"); + end + + -- ==== Section: Attributes ==== + -- UnitStat indices: 1=STR, 2=AGI, 3=STA, 4=INT, 5=SPI + local baseStr, effectiveStr = UnitStat(RD_UNIT, 1); + if effectiveStr then + local bonus = effectiveStr - baseStr; + if bonus ~= 0 then + RelationshipsDashboardStrengthRowValue:SetText(effectiveStr .. " (" .. baseStr .. " +" .. bonus .. ")"); + else + RelationshipsDashboardStrengthRowValue:SetText(tostring(effectiveStr)); + end + else + RelationshipsDashboardStrengthRowValue:SetText(tostring(baseStr)); + end + + local baseAgi, effectiveAgi = UnitStat(RD_UNIT, 2); + if effectiveAgi then + local bonus = effectiveAgi - baseAgi; + if bonus ~= 0 then + RelationshipsDashboardAgilityRowValue:SetText(effectiveAgi .. " (" .. baseAgi .. " +" .. bonus .. ")"); + else + RelationshipsDashboardAgilityRowValue:SetText(tostring(effectiveAgi)); + end + else + RelationshipsDashboardAgilityRowValue:SetText(tostring(baseAgi)); + end + + local baseSta, effectiveSta = UnitStat(RD_UNIT, 3); + if effectiveSta then + local bonus = effectiveSta - baseSta; + if bonus ~= 0 then + RelationshipsDashboardStaminaRowValue:SetText(effectiveSta .. " (" .. baseSta .. " +" .. bonus .. ")"); + else + RelationshipsDashboardStaminaRowValue:SetText(tostring(effectiveSta)); + end + else + RelationshipsDashboardStaminaRowValue:SetText(tostring(baseSta)); + end + + local baseInt, effectiveInt = UnitStat(RD_UNIT, 4); + if effectiveInt then + local bonus = effectiveInt - baseInt; + if bonus ~= 0 then + RelationshipsDashboardIntellectRowValue:SetText(effectiveInt .. " (" .. baseInt .. " +" .. bonus .. ")"); + else + RelationshipsDashboardIntellectRowValue:SetText(tostring(effectiveInt)); + end + else + RelationshipsDashboardIntellectRowValue:SetText(tostring(baseInt)); + end + + local baseSpi, effectiveSpi = UnitStat(RD_UNIT, 5); + if effectiveSpi then + local bonus = effectiveSpi - baseSpi; + if bonus ~= 0 then + RelationshipsDashboardSpiritRowValue:SetText(effectiveSpi .. " (" .. baseSpi .. " +" .. bonus .. ")"); + else + RelationshipsDashboardSpiritRowValue:SetText(tostring(effectiveSpi)); + end + else + RelationshipsDashboardSpiritRowValue:SetText(tostring(baseSpi)); + end + + -- ==== Section: Offense ==== + -- Damage (first in DejaStats Offense section) + local minDamage, maxDamage, minOff, maxOff, posBuffDmg, negBuffDmg = UnitDamage(RD_UNIT); + if minDamage and maxDamage then + local effectiveMin = minDamage + (posBuffDmg or 0) + (negBuffDmg or 0); + local effectiveMax = maxDamage + (posBuffDmg or 0) + (negBuffDmg or 0); + local dmgText = floor(effectiveMin) .. " - " .. floor(effectiveMax); + if minOff and maxOff and minOff > 0 then + local effectiveMinOff = minOff + (posBuffDmg or 0) + (negBuffDmg or 0); + local effectiveMaxOff = maxOff + (posBuffDmg or 0) + (negBuffDmg or 0); + dmgText = dmgText .. " / " .. floor(effectiveMinOff) .. " - " .. floor(effectiveMaxOff); + end + RelationshipsDashboardMeleeDmgRowValue:SetText(dmgText); + else + RelationshipsDashboardMeleeDmgRowValue:SetText("--"); + end + + -- Attack Power + local baseAP, posBuffAP, negBuffAP = UnitAttackPower(RD_UNIT); + if baseAP then + local totalAP = baseAP + (posBuffAP or 0) + (negBuffAP or 0); + local apBonus = (posBuffAP or 0) + (negBuffAP or 0); + if apBonus ~= 0 then + RelationshipsDashboardMeleeAPRowValue:SetText(totalAP .. " (+" .. apBonus .. ")"); + else + RelationshipsDashboardMeleeAPRowValue:SetText(tostring(totalAP)); + end + else + RelationshipsDashboardMeleeAPRowValue:SetText("--"); + end + + -- Attack Speed + local mainSpeed, offSpeed = UnitAttackSpeed(RD_UNIT); + if mainSpeed then + if offSpeed and offSpeed > 0 then + RelationshipsDashboardMeleeSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(mainSpeed) .. " / " .. RelationshipsDashboard_FormatDecimal(offSpeed)); + else + RelationshipsDashboardMeleeSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(mainSpeed)); + end + else + RelationshipsDashboardMeleeSpeedRowValue:SetText("--"); + end + + -- Weapon DPS + if mainSpeed and minDamage and maxDamage then + local effectiveMin = minDamage + (posBuffDmg or 0) + (negBuffDmg or 0); + local effectiveMax = maxDamage + (posBuffDmg or 0) + (negBuffDmg or 0); + local avgDmg = (effectiveMin + effectiveMax) / 2; + local weaponDPS = avgDmg / mainSpeed; + RelationshipsDashboardWeaponDPSRowValue:SetText(string.format("%.2f", weaponDPS)); + else + RelationshipsDashboardWeaponDPSRowValue:SetText("--"); + end + + -- Weapon Skill + RelationshipsDashboardWeaponSkillRowValue:SetText(RelationshipsDashboard_GetWeaponSkill()); + + -- Hit Chance + local meleeHit = RelationshipsDashboard_GetMeleeHitChance(); + RelationshipsDashboardMeleeHitRowValue:SetText(RelationshipsDashboard_FormatPercent(meleeHit)); + + -- Crit Chance + local meleeCrit = RelationshipsDashboard_GetMeleeCritChance(); + RelationshipsDashboardMeleeCritRowValue:SetText(RelationshipsDashboard_FormatPercent(meleeCrit)); + + -- ==== Section: Enhancements ==== + -- Ranged Attack Power + local baseRAP, posBuffRAP, negBuffRAP = UnitRangedAttackPower(RD_UNIT); + if baseRAP then + local totalRAP = baseRAP + (posBuffRAP or 0) + (negBuffRAP or 0); + RelationshipsDashboardRangedAPRowValue:SetText(tostring(totalRAP)); + else + RelationshipsDashboardRangedAPRowValue:SetText("--"); + end + + -- Ranged Crit + local rangedCrit = RelationshipsDashboard_GetRangedCritChance(); + RelationshipsDashboardRangedCritRowValue:SetText(RelationshipsDashboard_FormatPercent(rangedCrit)); + + -- Ranged Speed + -- UnitRangedDamage does NOT exist in vanilla 1.12 + -- Get ranged attack speed from the equipped ranged weapon's tooltip + local rangedSpeedVal = nil; + local rangedItemLink = GetInventoryItemLink(RD_UNIT, 18); + if rangedItemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + local ok, err = pcall(function() RelationshipsDashboardTooltip:SetInventoryItem(RD_UNIT, 18); end); + if ok then + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + -- Match weapon speed pattern: "Speed XX.XX" + local _, _, spd = strfind(text, "Speed (%d+%.%d+)"); + if spd then + rangedSpeedVal = tonumber(spd); + break; + end + end + end + end + end + end + if rangedSpeedVal then + RelationshipsDashboardRangedSpeedRowValue:SetText(RelationshipsDashboard_FormatDecimal(rangedSpeedVal)); + else + RelationshipsDashboardRangedSpeedRowValue:SetText("--"); + end + + -- Ranged Damage + local rangedItemLink = GetInventoryItemLink(RD_UNIT, 18); + if rangedItemLink and RelationshipsDashboardTooltip then + RelationshipsDashboard_ResetTooltip(); + RelationshipsDashboardTooltip_SetInventoryItemSafe(RD_UNIT, 18); + for i = 1, RelationshipsDashboardTooltip:NumLines() do + local textLine = getglobal("RelationshipsDashboardTooltipTextLeft" .. i); + if textLine then + local text = textLine:GetText(); + if text then + local _, _, dMin, dMax = strfind(text, "(%d+)%s*-%s*(%d+)%s+Damage"); + if dMin and dMax then + RelationshipsDashboardRangedDmgRowValue:SetText(dMin .. " - " .. dMax); + break; + end + end + end + end + else + RelationshipsDashboardRangedDmgRowValue:SetText("--"); + end + + -- Spell Power + local spellPower = RelationshipsDashboard_GetSpellPower(); + RelationshipsDashboardSpellPowerRowValue:SetText(tostring(spellPower)); + + -- Healing Power + local healingPower = RelationshipsDashboard_GetHealingPower(); + RelationshipsDashboardHealingPowerRowValue:SetText(tostring(healingPower)); + + -- Spell Hit + local spellHit = RelationshipsDashboard_GetSpellHitChance(); + RelationshipsDashboardSpellHitRowValue:SetText(RelationshipsDashboard_FormatPercent(spellHit)); + + -- Spell Crit + local spellCrit = RelationshipsDashboard_GetSpellCritChance(); + RelationshipsDashboardSpellCritRowValue:SetText(RelationshipsDashboard_FormatPercent(spellCrit)); + + -- Mana Regen + local manaRegen = RelationshipsDashboard_GetManaRegen(); + if manaRegen > 0 then + RelationshipsDashboardManaRegenRowValue:SetText(string.format("%.1f/s", manaRegen)); + else + RelationshipsDashboardManaRegenRowValue:SetText("--"); + end + + -- ==== Section: Defenses ==== + -- Defense + local defense = RelationshipsDashboard_GetDefense(); + RelationshipsDashboardDefenseRowValue:SetText(tostring(defense)); + + -- Dodge + if GetDodgeChance then + RelationshipsDashboardDodgeRowValue:SetText(RelationshipsDashboard_FormatPercent(GetDodgeChance())); + else + RelationshipsDashboardDodgeRowValue:SetText("--"); + end + + -- Parry + local parryChance = RelationshipsDashboard_GetParryChance(); + RelationshipsDashboardParryRowValue:SetText(RelationshipsDashboard_FormatPercent(parryChance)); + + -- Block + local blockChance = RelationshipsDashboard_GetBlockChance(); + RelationshipsDashboardBlockRowValue:SetText(RelationshipsDashboard_FormatPercent(blockChance)); + + -- Avoidance (Dodge + Parry + Block + Miss chance) + local missChance = 5; + local level = UnitLevel(RD_UNIT); + local defenseSkill = defense or (level * 5); + if defenseSkill > level * 5 then + missChance = 5 + (defenseSkill - level * 5) * 0.04; + end + local dodgeChance = (GetDodgeChance and GetDodgeChance()) or 0; + local avoidance = missChance + dodgeChance + parryChance + blockChance; + RelationshipsDashboardAvoidanceRowValue:SetText(RelationshipsDashboard_FormatPercent(avoidance)); + + -- ==== Section: Resistances ==== + -- Resistance indices: 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane + local resBase, resTotal, resBonus, resPos, resNeg; + + -- Arcane (index 6) + resBase, resTotal, resBonus, resPos, resNeg = UnitResistance(RD_UNIT, RESISTANCE_INDICES.arcane); + RelationshipsDashboardArcaneResRowValue:SetText(tostring(resTotal or 0)); + + -- Fire (index 2) + resBase, resTotal, resBonus, resPos, resNeg = UnitResistance(RD_UNIT, RESISTANCE_INDICES.fire); + RelationshipsDashboardFireResRowValue:SetText(tostring(resTotal or 0)); + + -- Nature (index 3) + resBase, resTotal, resBonus, resPos, resNeg = UnitResistance(RD_UNIT, RESISTANCE_INDICES.nature); + RelationshipsDashboardNatureResRowValue:SetText(tostring(resTotal or 0)); + + -- Frost (index 4) + resBase, resTotal, resBonus, resPos, resNeg = UnitResistance(RD_UNIT, RESISTANCE_INDICES.frost); + RelationshipsDashboardFrostResRowValue:SetText(tostring(resTotal or 0)); + + -- Shadow (index 5) + resBase, resTotal, resBonus, resPos, resNeg = UnitResistance(RD_UNIT, RESISTANCE_INDICES.shadow); + RelationshipsDashboardShadowResRowValue:SetText(tostring(resTotal or 0)); + + -- ==== Apply class-specific visibility (hide irrelevant stats) ==== + RelationshipsDashboard_ApplyClassVisibility(); +end + +------------------------------------------------------------------------------- +-- Show/Hide Logic +-- Toggle the stats frame visibility with the CharacterFrame +------------------------------------------------------------------------------- +local function RelationshipsDashboard_ToggleVisibility() + if CharacterFrame:IsVisible() then + RelationshipsDashboardFrame:Show(); + RelationshipsDashboard_UpdateStatsNow(); + + else + RelationshipsDashboardFrame:Hide(); + end +end + +------------------------------------------------------------------------------- +-- Hook into CharacterFrame show/hide +-- NOTE: HookScript() does NOT exist in vanilla 1.12 (added in TBC 2.1.0) +-- hooksecurefunc() does NOT exist either (added in TBC 2.0) +-- We must override the Show/Hide methods directly and call the originals. +-- Lua 5.0 uses unpack(arg) instead of ... for varargs in some contexts, +-- but function(...) works fine in Lua 5.0 as long as we forward args correctly. +------------------------------------------------------------------------------- +local RelationshipsDashboard_CharacterFrameHooked = false; + +local function RelationshipsDashboard_HookCharacterFrame() + if CharacterFrame and not RelationshipsDashboard_CharacterFrameHooked then + RelationshipsDashboard_CharacterFrameHooked = true; + + -- Override CharacterFrame.Show to also show our stats panel + local origShow = CharacterFrame.Show; + CharacterFrame.Show = function(...) + origShow(unpack(arg)); + RelationshipsDashboardFrame:Show(); + RelationshipsDashboard_UpdateStatsNow(); + + end; + + -- Override CharacterFrame.Hide to also hide our stats panel + local origHide = CharacterFrame.Hide; + CharacterFrame.Hide = function(...) + origHide(unpack(arg)); + RelationshipsDashboardFrame:Hide(); + end; + end +end + +------------------------------------------------------------------------------- +-- Hook InspectFrame so that inspecting another player also opens this +-- dashboard and shows THEIR stats (via unit "target"). When the inspect +-- window closes we revert RD_UNIT back to "player". +------------------------------------------------------------------------------- +local RelationshipsDashboard_InspectFrameHooked = false; + +------------------------------------------------------------------------------- +-- Force the scroll viewport mask frames to a very high frame level so they +-- always draw ON TOP of the stat-row sub-frames inside the ScrollChild. +-- Must be re-applied whenever we change RelationshipsDashboardFrame's parent +-- or frame level (e.g. when hooking InspectFrame), because vanilla 1.12 +-- cascades frame level changes down to child frames. +------------------------------------------------------------------------------- +function RelationshipsDashboard_BoostMaskLevels() + local base = RelationshipsDashboardFrame:GetFrameLevel() or 2; + local contentLevel = base + 10; + local target = base + 100; + + if RelationshipsDashboardScrollFrame then + RelationshipsDashboardScrollFrame:SetFrameLevel(contentLevel); + end + if RelationshipsDashboardContent then + RelationshipsDashboardContent:SetFrameLevel(contentLevel + 1); + end + RelationshipsDashboard_ForEachScrollElement(function(element) + element:SetFrameLevel(contentLevel + 2); + end); + + if RelationshipsDashboardMaskTop then + RelationshipsDashboardMaskTop:SetFrameLevel(target); + end + if RelationshipsDashboardMaskBottom then + RelationshipsDashboardMaskBottom:SetFrameLevel(target); + end +end + + +function RelationshipsDashboard_TryHookInspectFrame() + if RelationshipsDashboard_InspectFrameHooked then return; end + if not InspectFrame then return; end + RelationshipsDashboard_InspectFrameHooked = true; + + local origShow = InspectFrame.Show; + InspectFrame.Show = function(...) + origShow(unpack(arg)); + -- Switch to inspect target + RD_UNIT = "target"; + if UnitExists("target") and UnitIsPlayer("target") then + -- IMPORTANT: parent to UIParent, NOT InspectFrame. + -- InspectFrame has child sub-frames (PaperDoll, model) with high + -- framelevels that will draw above our BG texture if we are a + -- child of InspectFrame — that's what caused the "text bleeding + -- through / scrolling through the inspect UI" bug. + RelationshipsDashboardFrame:SetParent(UIParent); + RelationshipsDashboardFrame:ClearAllPoints(); + -- Match the self (CharacterFrame) dashboard position: sit just off + -- the frame's right edge with the same -30 overlap offset so the + -- inspect dashboard lines up exactly like the personal one. + RelationshipsDashboardFrame:SetPoint("TOPLEFT", InspectFrame, "TOPRIGHT", -30, -16); + RelationshipsDashboardFrame:SetFrameStrata("HIGH"); + RelationshipsDashboardFrame:SetFrameLevel(20); + RelationshipsDashboard_BoostMaskLevels(); + RelationshipsDashboardFrame:Show(); + RelationshipsDashboard_ApplyClassVisibility(); + RelationshipsDashboard_UpdateStats(); + end + end; + + local origHide = InspectFrame.Hide; + InspectFrame.Hide = function(...) + origHide(unpack(arg)); + -- Restore player context and re-parent to CharacterFrame with the + -- original overlapping anchor (CharacterFrame's right edge is empty + -- border, so overlap is safe there). + RD_UNIT = "player"; + RelationshipsDashboardFrame:Hide(); + RelationshipsDashboardFrame:SetParent(CharacterFrame); + RelationshipsDashboardFrame:ClearAllPoints(); + RelationshipsDashboardFrame:SetPoint("TOPLEFT", CharacterFrame, "TOPRIGHT", -30, -16); + RelationshipsDashboardFrame:SetFrameStrata("MEDIUM"); + RelationshipsDashboardFrame:SetFrameLevel(2); + RelationshipsDashboard_BoostMaskLevels(); + if CharacterFrame:IsVisible() then + RelationshipsDashboardFrame:Show(); + RelationshipsDashboard_ApplyClassVisibility(); + RelationshipsDashboard_UpdateStats(); + end + end; +end + +------------------------------------------------------------------------------- +-- OnLoad - Initialize the addon +------------------------------------------------------------------------------- +function RelationshipsDashboard_OnLoad() + -- Create the tooltip scanner (hidden, used for data extraction) + RelationshipsDashboardTooltip = CreateFrame("GameTooltip", "RelationshipsDashboardTooltip", nil, "GameTooltipTemplate"); + RelationshipsDashboardTooltip:SetOwner(WorldFrame, "ANCHOR_NONE"); + + -- Initialize section header text (DejaStats-style gold section headers) + RDHeaderGeneralText:SetText("General"); + RDHeaderAttributesText:SetText("Attributes"); + RDHeaderOffenseText:SetText("Offense"); + RDHeaderEnhancementsText:SetText("Enhancements"); + RDHeaderDefensesText:SetText("Defenses"); + RDHeaderResistancesText:SetText("Resistances"); + + -- Initialize stat row labels matching DejaStats layout + -- Section: General + RelationshipsDashboardHealthRowLabel:SetText("Health:"); + RelationshipsDashboardPowerRowLabel:SetText("Mana:"); + RelationshipsDashboardManaRowLabel:SetText("Mana:"); + RelationshipsDashboardArmorRowLabel:SetText("Armor:"); + RelationshipsDashboardMovementRowLabel:SetText("Movement Speed:"); + RelationshipsDashboardDurabilityRowLabel:SetText("Durability:"); + RelationshipsDashboardRepairCostRowLabel:SetText("Repair Total:"); + RelationshipsDashboardRepairCostRowValue:SetTextColor(1, 0.2, 0.2); + RelationshipsDashboardGearScoreRowLabel:SetText("GearScore:"); + RelationshipsDashboardAvgILvlRowLabel:SetText("Avg Item Level:"); + + -- Section: Attributes + RelationshipsDashboardStrengthRowLabel:SetText("Strength:"); + RelationshipsDashboardAgilityRowLabel:SetText("Agility:"); + RelationshipsDashboardStaminaRowLabel:SetText("Stamina:"); + RelationshipsDashboardIntellectRowLabel:SetText("Intellect:"); + RelationshipsDashboardSpiritRowLabel:SetText("Spirit:"); + + -- Section: Offense + RelationshipsDashboardMeleeDmgRowLabel:SetText("Damage:"); + RelationshipsDashboardMeleeAPRowLabel:SetText("Attack Power:"); + RelationshipsDashboardMeleeSpeedRowLabel:SetText("Attack Speed:"); + RelationshipsDashboardWeaponDPSRowLabel:SetText("Weapon DPS:"); + RelationshipsDashboardWeaponSkillRowLabel:SetText("Weapon Skill:"); + RelationshipsDashboardMeleeHitRowLabel:SetText("Hit Chance:"); + RelationshipsDashboardMeleeCritRowLabel:SetText("Crit Chance:"); + + -- Section: Enhancements + RelationshipsDashboardRangedAPRowLabel:SetText("Ranged AP:"); + RelationshipsDashboardRangedCritRowLabel:SetText("Ranged Crit:"); + RelationshipsDashboardRangedSpeedRowLabel:SetText("Ranged Speed:"); + RelationshipsDashboardRangedDmgRowLabel:SetText("Ranged Damage:"); + RelationshipsDashboardSpellPowerRowLabel:SetText("Spell Power:"); + RelationshipsDashboardHealingPowerRowLabel:SetText("Healing Power:"); + RelationshipsDashboardSpellHitRowLabel:SetText("Spell Hit:"); + RelationshipsDashboardSpellCritRowLabel:SetText("Spell Crit:"); + RelationshipsDashboardManaRegenRowLabel:SetText("Mana Regen:"); + + -- Section: Defenses + RelationshipsDashboardDefenseRowLabel:SetText("Defense:"); + RelationshipsDashboardDodgeRowLabel:SetText("Dodge:"); + RelationshipsDashboardParryRowLabel:SetText("Parry:"); + RelationshipsDashboardBlockRowLabel:SetText("Block:"); + RelationshipsDashboardAvoidanceRowLabel:SetText("Avoidance:"); + RelationshipsDashboardAvoidanceRowValue:SetTextColor(0.2, 1, 0.2); + + -- Section: Resistances (school-colored labels) + RelationshipsDashboardArcaneResRowLabel:SetText("Arcane:"); + RelationshipsDashboardFireResRowLabel:SetText("Fire:"); + RelationshipsDashboardNatureResRowLabel:SetText("Nature:"); + RelationshipsDashboardFrostResRowLabel:SetText("Frost:"); + RelationshipsDashboardShadowResRowLabel:SetText("Shadow:"); + + -- Resistance label color overrides (school colors matching DejaStats) + RelationshipsDashboardArcaneResRowLabel:SetTextColor(0.7, 0.2, 1); -- Arcane (purple) + RelationshipsDashboardFireResRowLabel:SetTextColor(1, 0.4, 0.1); -- Fire (red-orange) + RelationshipsDashboardNatureResRowLabel:SetTextColor(0.2, 0.9, 0.2); -- Nature (green) + RelationshipsDashboardFrostResRowLabel:SetTextColor(0.3, 0.6, 1); -- Frost (blue) + RelationshipsDashboardShadowResRowLabel:SetTextColor(0.6, 0.2, 0.8); -- Shadow (violet) + + -- Power label override: update based on class power type at load + -- This will be refined on each UpdateStats call + local powerType = UnitPowerType("player"); + if powerType == 1 then + RelationshipsDashboardPowerRowLabel:SetText("Rage:"); + elseif powerType == 2 then + RelationshipsDashboardPowerRowLabel:SetText("Focus:"); + elseif powerType == 3 then + RelationshipsDashboardPowerRowLabel:SetText("Energy:"); + else + RelationshipsDashboardPowerRowLabel:SetText("Mana:"); + end + + -- Register for events + local frame = RelationshipsDashboardFrame; + frame:RegisterEvent("UNIT_STATS"); + frame:RegisterEvent("UNIT_ATTACK_POWER"); + frame:RegisterEvent("UNIT_RANGED_ATTACK_POWER"); + frame:RegisterEvent("UNIT_ATTACK_SPEED"); + frame:RegisterEvent("UNIT_RESISTANCES"); + frame:RegisterEvent("UNIT_DEFENSE"); + frame:RegisterEvent("UNIT_DAMAGE"); + frame:RegisterEvent("UPDATE_INVENTORY_ALERTS"); + frame:RegisterEvent("UNIT_INVENTORY_CHANGED"); + frame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED"); + frame:RegisterEvent("UNIT_MODEL_CHANGED"); + frame:RegisterEvent("PLAYER_ENTERING_WORLD"); + frame:RegisterEvent("PLAYER_MONEY"); + frame:RegisterEvent("UPDATE_TOOLTIP"); + frame:RegisterEvent("MERCHANT_SHOW"); + frame:RegisterEvent("MERCHANT_UPDATE"); + frame:RegisterEvent("MERCHANT_CLOSED"); + frame:RegisterEvent("UNIT_DISPLAYPOWER"); + frame:RegisterEvent("UNIT_MANA"); + frame:RegisterEvent("UNIT_RAGE"); + frame:RegisterEvent("UNIT_ENERGY"); + frame:RegisterEvent("UNIT_FOCUS"); + frame:RegisterEvent("UNIT_HEALTH"); + frame:RegisterEvent("PLAYER_DEAD"); + frame:RegisterEvent("PLAYER_REGEN_DISABLED"); + frame:RegisterEvent("PLAYER_REGEN_ENABLED"); + frame:RegisterEvent("ZONE_CHANGED_NEW_AREA"); + frame:RegisterEvent("ZONE_CHANGED"); + frame:RegisterEvent("UNIT_MAXHEALTH"); + frame:RegisterEvent("UNIT_MAXMANA"); + frame:RegisterEvent("UNIT_MAXRAGE"); + frame:RegisterEvent("UNIT_MAXENERGY"); + frame:RegisterEvent("UNIT_MAXFOCUS"); + frame:RegisterEvent("ADDON_LOADED"); + frame:RegisterEvent("VARIABLES_LOADED"); + frame:RegisterEvent("PLAYER_TARGET_CHANGED"); + frame:RegisterEvent("PLAYER_UNGHOST"); -- Player is no longer a ghost (rez'd) + frame:RegisterEvent("PLAYER_ALIVE"); -- Fires on both corpse-run and SH rez + frame:RegisterEvent("CORPSE_IN_RANGE"); -- Near own corpse (retrieval possible) + frame:RegisterEvent("CORPSE_OUT_OF_RANGE"); + -- Explicitly flush the saved-durability table back to its SavedVariable + -- global on logout / /reload. WoW normally handles this automatically, + -- but this safety net protects against the reference being unhooked by + -- any future edit that reassigns RD_SavedDurability locally. + frame:RegisterEvent("DUEL_REQUESTED"); + frame:RegisterEvent("DUEL_FINISHED"); + frame:RegisterEvent("PLAYER_LOGOUT"); + + + + -- Hide the scrollbar and ALL its sub-elements for a flat UI + -- UIPanelScrollFrameTemplate (WoW 1.12) creates $parentScrollBar (a Slider) with: + -- $parentScrollUpButton (Button: NormalTexture, PushedTexture, DisabledTexture, HighlightTexture) + -- $parentScrollDownButton (Button: NormalTexture, PushedTexture, DisabledTexture, HighlightTexture) + -- $parentThumbTexture (Texture inheriting UIPanelScrollBarButton) + -- We hide AND set alpha=0 on every element to guarantee nothing is visible + local scrollBar = getglobal("RelationshipsDashboardScrollFrameScrollBar"); + if scrollBar then + scrollBar:Hide(); + scrollBar:SetAlpha(0); + + -- Hide and make transparent the up/down arrow buttons and all their texture states + local scrollUp = getglobal("RelationshipsDashboardScrollFrameScrollBarScrollUpButton"); + local scrollDown = getglobal("RelationshipsDashboardScrollFrameScrollBarScrollDownButton"); + if scrollUp then + scrollUp:Hide(); + scrollUp:SetAlpha(0); + -- Hide each texture state on the Up button + local tex; + tex = scrollUp:GetNormalTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollUp:GetPushedTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollUp:GetDisabledTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollUp:GetHighlightTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + end + if scrollDown then + scrollDown:Hide(); + scrollDown:SetAlpha(0); + -- Hide each texture state on the Down button + local tex; + tex = scrollDown:GetNormalTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollDown:GetPushedTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollDown:GetDisabledTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + tex = scrollDown:GetHighlightTexture(); if tex then tex:SetAlpha(0); tex:Hide(); end + end + + -- Hide and make transparent the thumb (the draggable block in the scrollbar) + -- In WoW 1.12, ThumbTexture is a single Texture (no Left/Right/Middle sub-textures) + local thumb = getglobal("RelationshipsDashboardScrollFrameScrollBarThumbTexture"); + if thumb then + thumb:SetAlpha(0); + thumb:Hide(); + end + end + + -- Enable mouse wheel scrolling on the scroll frame + -- The ScrollFrame doesn't natively handle mouse wheel, so we add it + local scrollFrame = RelationshipsDashboardScrollFrame; + scrollFrame:EnableMouseWheel(true); + scrollFrame:SetScript("OnMouseWheel", function() + -- In WoW 1.12 Lua 5.0, arg1 contains the scroll delta + -- Positive = scroll up, Negative = scroll down + local delta = arg1; + local scrollBar = getglobal("RelationshipsDashboardScrollFrameScrollBar"); + if scrollBar then + local minVal, maxVal = scrollBar:GetMinMaxValues(); + local current = scrollBar:GetValue(); + local step = 30; -- pixels per scroll step + if delta > 0 then + -- Scroll up + if current - step < minVal then + scrollBar:SetValue(minVal); + else + scrollBar:SetValue(current - step); + end + else + -- Scroll down + if current + step > maxVal then + scrollBar:SetValue(maxVal); + else + scrollBar:SetValue(current + step); + end + end + RelationshipsDashboard_UpdateViewportClip(); + end + end); + + frame:SetScript("OnUpdate", function() + -- Vanilla 1.12 passes elapsed via arg1 (Lua 5.0 has no `...` here). + local elapsed = arg1 or 0; + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateViewportClip(); + -- Flush any pending coalesced stat refresh (throttled). + RelationshipsDashboard_FlushPendingStats(elapsed); + end + -- Detect ghost-state transitions (spirit-release → ghost, or rez → + -- alive) as they happen, so the durability % and estimated repair + -- cost update the moment the player becomes a ghost instead of + -- waiting until they respawn / a later event. + local isGhostNow = (UnitIsGhost and UnitIsGhost("player")) and true or false; + if RD_GhostStateLast == nil then + RD_GhostStateLast = isGhostNow; + elseif isGhostNow ~= RD_GhostStateLast then + RD_GhostStateLast = isGhostNow; + if isGhostNow then + -- Player just released and became a ghost. The 10% death + -- durability penalty is normally applied on PLAYER_DEAD, but + -- guard against a missed application (e.g. PLAYER_DEAD arrived + -- before saved-durability baselines existed). + if not RD_DeathPenaltyApplied then + RelationshipsDashboard_ApplyDeathPenalty(); + RD_DeathPenaltyApplied = true; + end + RD_WasGhost = true; + else + RD_DeathPenaltyApplied = false; + end + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + end + end); + + + -- Ensure the scroll-viewport masks always draw above the stat row sub-frames + -- inside the ScrollChild. Vanilla ScrollFrame does NOT clip nested child + -- frames, so without these masks the row FontStrings render outside the + -- scroll area when scrolled. See RelationshipsDashboard.xml for details. + RelationshipsDashboard_BoostMaskLevels(); + + -- Hook into CharacterFrame + RelationshipsDashboard_HookCharacterFrame(); RelationshipsDashboard_TryHookInspectFrame(); + + -- Initialize saved variables + if not RelationshipsDashboardDB then + RelationshipsDashboardDB = {}; + end + + -- Initial update (will run when CharacterFrame opens) + -- The frame starts hidden, so we don't update immediately +end + +------------------------------------------------------------------------------- +-- OnEvent - Handle registered events +------------------------------------------------------------------------------- +function RelationshipsDashboard_OnEvent(event) + if event == "ADDON_LOADED" then + if arg1 == "Blizzard_InspectUI" then + RelationshipsDashboard_TryHookInspectFrame(); + elseif arg1 == "RelationshipsDashboard" then + -- Bind the saved durability table so it persists across /reload + if RelationshipsDashboardDB_SavedDurability == nil then + RelationshipsDashboardDB_SavedDurability = {}; + end + RD_SavedDurability = RelationshipsDashboardDB_SavedDurability; + -- Restore the learned repair calibration factor. + if RelationshipsDashboardDB and RelationshipsDashboardDB.repairCalibration + and RelationshipsDashboardDB.repairCalibrationVersion == RD_REPAIR_CALIBRATION_VERSION then + RD_RepairCalibration = RelationshipsDashboardDB.repairCalibration; + else + RD_RepairCalibration = 1.0; + end + end + return; + elseif event == "VARIABLES_LOADED" then + if RelationshipsDashboardDB_SavedDurability == nil then + RelationshipsDashboardDB_SavedDurability = {}; + end + RD_SavedDurability = RelationshipsDashboardDB_SavedDurability; + if RelationshipsDashboardDB and RelationshipsDashboardDB.repairCalibration + and RelationshipsDashboardDB.repairCalibrationVersion == RD_REPAIR_CALIBRATION_VERSION then + RD_RepairCalibration = RelationshipsDashboardDB.repairCalibration; + else + RD_RepairCalibration = 1.0; + end + return; + elseif event == "PLAYER_LOGOUT" then + -- Guarantee the saved-durability table (and calibration) survive + -- /reload and full logout, even if some other code path reassigned + -- RD_SavedDurability to a fresh table after ADDON_LOADED. + if RD_SavedDurability then + RelationshipsDashboardDB_SavedDurability = RD_SavedDurability; + end + if RelationshipsDashboardDB and RD_RepairCalibration then + RelationshipsDashboardDB.repairCalibration = RD_RepairCalibration; + RelationshipsDashboardDB.repairCalibrationVersion = RD_REPAIR_CALIBRATION_VERSION; + end + return; + + elseif event == "PLAYER_TARGET_CHANGED" then + -- The dashboard can stay open while the user clicks between different + -- targets in the world (or opens/closes inspect). Resync RD_UNIT against + -- reality every time target changes, so class-based row filtering always + -- matches the unit whose stats we are about to display. + if RelationshipsDashboardFrame:IsVisible() then + if InspectFrame and InspectFrame:IsVisible() + and UnitExists("target") and UnitIsPlayer("target") then + RD_UNIT = "target"; + else + RD_UNIT = "player"; + end + RelationshipsDashboard_ApplyClassVisibility(); + RelationshipsDashboard_UpdateStats(); + end + return; + end + if event == "PLAYER_ENTERING_WORLD" then + -- Delay initial setup slightly + RelationshipsDashboard_HookCharacterFrame(); RelationshipsDashboard_TryHookInspectFrame(); + -- Initialize mana tracking for druid form support + local powerType = UnitPowerType("player"); + if powerType == 0 then + RelationshipsDashboard_LastKnownMana = UnitMana("player"); + RelationshipsDashboard_LastKnownMaxMana = UnitManaMax("player"); + end + RD_LastMoney = GetMoney(); + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + if RD_InBattleground and not RD_BG_DurabilitySnapshot then + RelationshipsDashboard_SnapshotDurabilityForBG(); + end + -- Sync stored ghost state to reality on login + if UnitIsGhost and UnitIsGhost("player") then + RD_WasGhost = true; + end + elseif event == "UNIT_DISPLAYPOWER" then + -- Power type changed (e.g., druid shifted forms) + if arg1 and arg1 ~= "player" then return; end + local powerType = UnitPowerType("player"); + if powerType == 0 then + RelationshipsDashboard_LastKnownMana = UnitMana("player"); + RelationshipsDashboard_LastKnownMaxMana = UnitManaMax("player"); + end + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_ApplyClassVisibility(); + RelationshipsDashboard_UpdateStats(); + end + elseif event == "MERCHANT_SHOW" or event == "MERCHANT_UPDATE" then + RelationshipsDashboard_UpdateRepairFromMerchant(); + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + elseif event == "MERCHANT_CLOSED" then + RelationshipsDashboard_MerchantRepairCost = 0; + RD_AtRepairMerchant = false; + RD_LastMoney = GetMoney(); + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + elseif event == "PLAYER_DEAD" then + -- Refresh BG state before deciding to apply the death penalty. + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + if RD_InBattleground and not RD_BG_DurabilitySnapshot then + -- We entered a BG without seeing ZONE_CHANGED first (rare but + -- observed on OctoWoW). Snapshot now so the restore is a no-op. + RelationshipsDashboard_SnapshotDurabilityForBG(); + end + -- Player died: apply 10% durability loss (skipped inside BGs). + RelationshipsDashboard_ApplyDeathPenalty(); + if RD_InBattleground then + RelationshipsDashboard_RestoreDurabilityFromBGSnapshot(); + end + RD_DeathPenaltyApplied = true; + RD_WasGhost = true; + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + elseif event == "PLAYER_UNGHOST" or event == "PLAYER_ALIVE" then + -- Detect spirit-healer resurrection: player was a ghost, and after + -- becoming alive is NOT near their corpse (corpse-run always leaves + -- you at the corpse location — spirit rez teleports you to the + -- graveyard). Vanilla exposes HasCorpse via GetCorpseRecoveryDelay(): + -- returns > 0 only after a spirit rez. That's our signal. + local isAlive = not (UnitIsGhost and UnitIsGhost("player")) + and not (UnitIsDead and UnitIsDead("player")); + if RD_WasGhost and isAlive then + local corpseDelay = 0; + if GetCorpseRecoveryDelay then + corpseDelay = GetCorpseRecoveryDelay() or 0; + end + if corpseDelay > 0 then + RelationshipsDashboard_ApplySpiritHealerPenalty(); + end + RD_WasGhost = false; + end + -- Clear the death-penalty guard once the player is alive again so a + -- subsequent death re-applies the 10% loss. + if not (UnitIsGhost and UnitIsGhost("player")) then + RD_DeathPenaltyApplied = false; + RD_GhostStateLast = false; + end + -- Always refresh the dashboard on rez (corpse-run OR spirit-heal). + -- The 10% death penalty was already applied on PLAYER_DEAD but the + -- character panel is usually closed at that moment, so this is the + -- first chance the visible dashboard has to redraw with the loss. + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + elseif event == "PLAYER_REGEN_DISABLED" then + -- Entering combat: start tracking hits for durability estimation + RD_InCombat = true; + RD_CombatHitCount = 0; + RD_LastHealth = UnitHealth("player"); + elseif event == "PLAYER_REGEN_ENABLED" then + -- Leaving combat: stop tracking + RD_InCombat = false; + RD_CombatHitCount = 0; + elseif event == "DUEL_REQUESTED" then + -- A duel has been initiated between the player and someone else. + -- Duels never cause real durability loss, so treat the duration + -- like a battleground for our purposes. We capture equipped + -- durability snapshots so we can restore them exactly if the duel + -- somehow does mutate our tracker. + RD_InDuel = true; + elseif event == "DUEL_FINISHED" then + -- Duel is over (accepted-and-completed, declined, or cancelled). + -- Restore estimated durability to full for equipped items so any + -- combat-wear ticks that fired mid-duel are undone. + if RD_InDuel then + RelationshipsDashboard_ResetDurabilityToFull(); + end + RD_InDuel = false; + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + + elseif event == "ZONE_CHANGED_NEW_AREA" or event == "ZONE_CHANGED" then + -- Zone changed: update battleground status + local wasInBG = RD_InBattleground; + RD_InBattleground = RelationshipsDashboard_IsInBattleground(); + -- Entering a battleground: snapshot current durability so we can + -- restore it exactly on exit regardless of any events fired inside. + if RD_InBattleground and not wasInBG then + RelationshipsDashboard_SnapshotDurabilityForBG(); + end + -- When leaving a battleground, restore the pre-BG snapshot instead + -- of resetting to full (avoids over-repairing gear that was already + -- damaged before queueing). + if wasInBG and not RD_InBattleground then + if RD_BG_DurabilitySnapshot then + RelationshipsDashboard_RestoreDurabilityFromBGSnapshot(); + RD_BG_DurabilitySnapshot = nil; + else + RelationshipsDashboard_ResetDurabilityToFull(); + end + end + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + elseif event == "PLAYER_MONEY" then + -- Gold changed. If a repair merchant is open this is very likely a + -- repair (or a purchase). Check whether money actually decreased and + -- whether the drop is consistent with the last-known repair-all cost. + -- If so, we KNOW a repair just happened -> force a full reset even if + -- GetRepairAllCost hasn't yet updated to 0 (some server builds lag by + -- a frame). + local newMoney = GetMoney(); + local spent = 0; + if RD_LastMoney and newMoney and newMoney < RD_LastMoney then + spent = RD_LastMoney - newMoney; + end + if RD_AtRepairMerchant then + -- Detect "just repaired": money dropped by ~90%+ of the previously + -- reported repair-all cost. Covers Repair All AND individual + -- right-click repairs (which drop by a slot's share). + if spent > 0 and RD_PrevMerchantRepairCost > 0 + and spent >= math.floor(RD_PrevMerchantRepairCost * 0.9) + and spent <= RD_PrevMerchantRepairCost + 100 then + RelationshipsDashboard_MerchantRepairCost = 0; + RD_PrevMerchantRepairCost = 0; + RelationshipsDashboard_ResetDurabilityToFull(); + else + -- Otherwise just re-query cost normally; the function will + -- itself trigger a reset if cost dropped to 0. + RelationshipsDashboard_UpdateRepairFromMerchant(); + end + end + RD_LastMoney = newMoney; + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + else + -- All other stat-related events (UNIT_MANA, UNIT_HEALTH, etc.) + -- arg1 is the unitID for UNIT_* events — only update for "player" + if arg1 and arg1 ~= "player" then + return; + end + -- Track damage taken in combat for durability estimation + if event == "UNIT_HEALTH" and RD_InCombat then + local currentHealth = UnitHealth("player"); + if RD_LastHealth > 0 and currentHealth < RD_LastHealth then + local damageTaken = RD_LastHealth - currentHealth; + -- Apply combat wear (the function internally tracks hit count) + RelationshipsDashboard_ApplyCombatWear(damageTaken); + end + RD_LastHealth = currentHealth; + end + if RelationshipsDashboardFrame:IsVisible() then + RelationshipsDashboard_UpdateStats(); + end + end +end