Files
RelationshipsDashboard/RelationshipsDashboard.lua
2026-07-08 18:04:07 +01:00

4286 lines
197 KiB
Lua

-------------------------------------------------------------------------------
-- 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 _, _, val1b = strfind(text, "%+(%d+) Spell Damage and Healing");
if val1b then spellPower = spellPower + tonumber(val1b); end
local _, _, val1c = strfind(text, "%+(%d+) damage and healing");
if val1c then spellPower = spellPower + tonumber(val1c); end
local _, _, val1d = strfind(text, "%+(%d+) to Healing Spells");
if val1d then spellPower = spellPower + tonumber(val1d); 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 _, _, val3b = strfind(text, "damage done by magical spells and effects by up to (%d+)");
if val3b then spellPower = spellPower + tonumber(val3b); end
local _, _, val3c = strfind(text, "Damage Bonus: (%d+)");
if val3c then spellPower = spellPower + tonumber(val3c); 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 _, _, val1b = strfind(text, "%+(%d+) to Healing Spells");
if val1b then healingPower = healingPower + tonumber(val1b); end
local _, _, val1c = strfind(text, "%+(%d+) to Healing");
if val1c then healingPower = healingPower + tonumber(val1c); end
local _, _, val2 = strfind(text, "%+(%d+) Healing");
if val2 then healingPower = healingPower + tonumber(val2); end
local _, _, val2b = strfind(text, "%+(%d+) Spell Damage and Healing");
if val2b then healingPower = healingPower + tonumber(val2b); end
local _, _, val2c = strfind(text, "%+(%d+) Spell Power");
if val2c then healingPower = healingPower + tonumber(val2c); end
local _, _, val3 = strfind(text, "Increases healing done by spells and effects by up to (%d+)");
if val3 then healingPower = healingPower + tonumber(val3); end
local _, _, val3b = strfind(text, "healing done by spells and effects by up to (%d+)");
if val3b then healingPower = healingPower + tonumber(val3b); 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 <Stat>" (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 <Stat> 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, world buffs, 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+) Spell Damage and Healing"); if v then est.spellDmg = est.spellDmg + v; est.healing = est.healing + v; end
_,_,v = strfind(text, "%+(%d+) damage and healing"); 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
_,_,v = strfind(text, "%+(%d+) to Healing Spells"); if v then est.healing = est.healing + v; end
_,_,v = strfind(text, "%+(%d+) to Healing"); if v then est.healing = est.healing + v; end
_,_,v = strfind(text, "[Ii]ncreases healing done by spells and effects by up to (%d+)"); if v then est.healing = est.healing + 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, "[Ii]ncreases damage done by magical spells and effects by up to (%d+)"); if v then est.spellDmg = est.spellDmg + v; end
_,_,v = strfind(text, "damage done by magical spells and effects by up to (%d+)"); if v then est.spellDmg = est.spellDmg + v; end
-- Crit % (Rallying Cry, Moonkin Aura, world buffs, etc.)
_,_,v = strfind(text, "critical strike with spells by (%d+)%%");
if v then est.critSpell = est.critSpell + v; end
_,_,v = strfind(text, "[Ii]ncreases spell critical chance by (%d+)%%");
if v then est.critSpell = est.critSpell + v; end
_,_,v = strfind(text, "[Ii]ncreases critical strike chance by (%d+)%%");
if v then est.critMelee = est.critMelee + v; 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+) to Healing Spells"); if v then est.healing = est.healing + v; end
_,_,v = strfind(text, "%+(%d+) to 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 Damage and Healing"); if v then est.spellDmg = est.spellDmg + v; est.healing = est.healing + v; end
_,_,v = strfind(text, "%+(%d+) damage and healing"); if v then est.spellDmg = est.spellDmg + v; est.healing = est.healing + 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
_,_,v = strfind(text, "Damage Bonus: (%d+)"); 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