local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local EA = HMGT.EncounterAlerts if not EA then return end local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true) local LR = {} EA.LuraRunes = LR local MEDIA_DIR = "Interface\\AddOns\\HailMaryGuildTools\\Modules\\EncounterAlerts\\Media\\LuraRunes\\" local FALLBACK_TEXTURE = "Interface\\AddOns\\HailMaryGuildTools\\Media\\HailMaryIcon.png" local ROLE_ICON_TEXTURE = "Interface\\LFGFrame\\UI-LFG-ICON-ROLES" local CLEAR_BUTTON_TEXTURE = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" local RUNE_ORDER = { "circle", "cross", "diamond", "t", "triangle" } local RUNE_DATA = { circle = { label = "Circle", chatToken = "Rune_Circle", texture = MEDIA_DIR .. "Rune_Circle.tga", aliases = { "kreis", "round", "rund" }, }, cross = { label = "Cross", chatToken = "Rune_X", texture = MEDIA_DIR .. "Rune_X.tga", aliases = { "x", "kreuz" }, }, diamond = { label = "Diamond", chatToken = "Rune_Diamond", texture = MEDIA_DIR .. "Rune_Diamond.tga", aliases = { "diamant", "rhombus" }, }, t = { label = "T", chatToken = "Rune_T", texture = MEDIA_DIR .. "Rune_T.tga", aliases = { "tee" }, }, triangle = { label = "Triangle", chatToken = "Rune_Triangle", texture = MEDIA_DIR .. "Rune_Triangle.tga", aliases = { "dreieck" }, }, } local RUNE_ALIASES = {} for key, data in pairs(RUNE_DATA) do RUNE_ALIASES[key] = key if data.chatToken then RUNE_ALIASES[string.lower(data.chatToken)] = key end for _, alias in ipairs(data.aliases or {}) do RUNE_ALIASES[alias] = key end end local DEFAULT_TEST_ASSIGNMENTS = { "circle", "cross", "diamond", "t", "triangle", } local LURA_NAME_TOKENS = { "l'ura", "lura" } local LURA_CONTEXT_MAP_IDS = {} local LURA_ENCOUNTER_IDS = {} local LURA_NPC_IDS = {} local LURA_SCAN_UNITS = { "boss1", "boss2", "boss3", "boss4", "boss5", "target", "focus" } local function Debug(level, fmt, ...) if HMGT and HMGT.DebugScoped then HMGT:DebugScoped(level or "info", "EncounterAlerts", fmt, ...) end end local function NotifyOptionsChanged() if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then AceConfigRegistry:NotifyChange(ADDON_NAME) end end local function ClampNumber(value, minimum, maximum, fallback) local number = tonumber(value) if not number then number = fallback end number = tonumber(number) or 0 if number < minimum then return minimum end if number > maximum then return maximum end return number end local function NormalizeRuneKey(value) local key = tostring(value or ""):lower() return RUNE_ALIASES[key] or "" end local function ParseRuneRaidChatMessage(message) local okText, text = pcall(tostring, message) if not okText or type(text) ~= "string" then return nil, nil end local okMatch, slotText, token = pcall(string.match, text, "^HMGT:Rune([1-5]):([%w_%-]+)$") if not okMatch or not slotText or not token then return nil, nil end local key = NormalizeRuneKey(token) if key == "" then return nil, nil end return tonumber(slotText), key end local function NormalizeActionBarOrientation(value) if tostring(value or "") == "vertical" then return "vertical" end return "horizontal" end local function TextLooksLikeLura(text) local value = tostring(text or ""):lower() if value == "" then return false end for _, token in ipairs(LURA_NAME_TOKENS) do if string.find(value, token, 1, true) then return true end end if string.find((value:gsub("%W", "")), "lura", 1, true) then return true end return false end local function GetNPCIdFromGUID(guid) local _, _, _, _, _, npcId = strsplit("-", tostring(guid or "")) return tonumber(npcId) end local function UnitLooksLikeLura(unitId) if not unitId or not UnitExists(unitId) then return false end local npcId = GetNPCIdFromGUID(UnitGUID(unitId)) if npcId and LURA_NPC_IDS[npcId] then return true end return TextLooksLikeLura(UnitName(unitId)) end local function NormalizeColor(color, fallback) color = type(color) == "table" and color or {} fallback = type(fallback) == "table" and fallback or {} return { r = ClampNumber(color.r, 0, 1, fallback.r or 1), g = ClampNumber(color.g, 0, 1, fallback.g or 0.82), b = ClampNumber(color.b, 0, 1, fallback.b or 0.1), a = ClampNumber(color.a, 0, 1, fallback.a or 0.9), } end local function ApplyIconBorder(frame, settings, backgroundColor) if not frame or type(frame.SetBackdrop) ~= "function" then return end local border = settings and settings.border or {} if border.enabled ~= true then frame:SetBackdrop(nil) return end local width = math.max(1, tonumber(border.width) or 2) frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = width, insets = { left = width, right = width, top = width, bottom = width }, }) local bg = backgroundColor or { r = 0, g = 0, b = 0, a = 0.35 } local color = border.color or {} frame:SetBackdropColor(bg.r or 0, bg.g or 0, bg.b or 0, bg.a or 0.35) frame:SetBackdropBorderColor(color.r or 1, color.g or 0.82, color.b or 0.1, color.a or 0.9) end local function SetTankIconTexture(texture) if not texture then return end if type(texture.SetAtlas) == "function" then local ok = pcall(texture.SetAtlas, texture, "roleicon-tiny-tank", true) if ok then return end end texture:SetTexture(ROLE_ICON_TEXTURE) if type(GetTexCoordsForRoleSmallCircle) == "function" then local left, right, top, bottom = GetTexCoordsForRoleSmallCircle("TANK") if left and right and top and bottom then texture:SetTexCoord(left, right, top, bottom) return end elseif type(GetTexCoordsForRole) == "function" then local left, right, top, bottom = GetTexCoordsForRole("TANK") if left and right and top and bottom then texture:SetTexCoord(left, right, top, bottom) return end end texture:SetTexCoord(0, 0.26171875, 0.26171875, 0.5234375) end local function HasAssignments(slots) if type(slots) ~= "table" then return false end for slot = 1, 5 do if NormalizeRuneKey(slots[slot]) ~= "" then return true end end return false end local function UnitCanSendSequence(unitId) if not unitId or not UnitExists(unitId) then return false end if UnitIsGroupLeader and UnitIsGroupLeader(unitId) then return true end if UnitIsGroupAssistant and UnitIsGroupAssistant(unitId) then return true end return false end function LR:IsSequenceSenderAllowed(playerName) if playerName and playerName ~= "" then local unitId = HMGT and HMGT.GetUnitForPlayer and HMGT:GetUnitForPlayer(playerName) or nil return UnitCanSendSequence(unitId) end if not IsInGroup() and not IsInRaid() then return true end return UnitCanSendSequence("player") end function LR:CanBroadcastSequence() return self:IsSequenceSenderAllowed(nil) end function LR:IsTestMode() return self.testMode == true end function LR:CanUseRuneInput() return self:IsTestMode() or self:CanBroadcastSequence() end function LR:IsLuraEncounter(encounterId, encounterName) local id = tonumber(encounterId) or 0 if LURA_ENCOUNTER_IDS[id] then return true end return TextLooksLikeLura(encounterName) end function LR:HasLuraUnit() for _, unitId in ipairs(LURA_SCAN_UNITS) do if UnitLooksLikeLura(unitId) then return true end end return false end function LR:IsInLuraContext() if self.luraEncounterActive == true then return true end local inInstance, instanceType = IsInInstance() if inInstance ~= true or instanceType ~= "raid" then return false end local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") or nil if mapId and LURA_CONTEXT_MAP_IDS[mapId] then return true end local mapInfo = mapId and C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(mapId) or nil if mapInfo and TextLooksLikeLura(mapInfo.name) then return true end if TextLooksLikeLura(GetSubZoneText and GetSubZoneText() or nil) or TextLooksLikeLura(GetMinimapZoneText and GetMinimapZoneText() or nil) or TextLooksLikeLura(GetRealZoneText and GetRealZoneText() or nil) then return true end return self:HasLuraUnit() end function LR:ShouldAutoShowActionBar() local settings = self:GetSettings() return settings.actionBar.autoShow == true and self:CanBroadcastSequence() and self:IsInLuraContext() end function LR:RefreshContext(reason) local contextActive = self:IsInLuraContext() local canUse = self:CanUseRuneInput() local autoShow = self:ShouldAutoShowActionBar() if contextActive ~= self.lastContextActive or canUse ~= self.lastCanUse or autoShow ~= self.lastAutoShow then Debug( "verbose", "Lura context reason=%s active=%s autoShow=%s canUse=%s", tostring(reason or "refresh"), tostring(contextActive), tostring(autoShow), tostring(canUse) ) self.lastContextActive = contextActive self.lastCanUse = canUse self.lastAutoShow = autoShow end self:RefreshActionBar() end function LR:OnEncounterStart(encounterId, encounterName) self.luraEncounterActive = self:IsLuraEncounter(encounterId, encounterName) if self.luraEncounterActive then Debug("info", "Lura encounter context started encounter=%s", tostring(encounterId or "?")) end self:RefreshContext("encounter_start") end function LR:OnEncounterEnd(encounterId) if self.luraEncounterActive then Debug("info", "Lura encounter context ended encounter=%s", tostring(encounterId or "?")) end self.luraEncounterActive = false self:RefreshContext("encounter_end") end function LR:LogNotLeader(context) Debug("info", "Lura rune %s blocked: only raid leader or raid assist can send", tostring(context or "sequence")) end local function SplitAssignments(payload) local slots = {} local text = tostring(payload or "") local index = 1 for token in string.gmatch(text .. ",", "([^,]*),") do if index > 5 then break end slots[index] = NormalizeRuneKey(token) index = index + 1 end for slot = 1, 5 do slots[slot] = NormalizeRuneKey(slots[slot]) end return slots end function LR:GetSettings() local settings = EA:GetLuraRunesSettings() settings.enabled = settings.enabled == true settings.unlocked = settings.unlocked == true settings.posX = math.floor(ClampNumber(settings.posX, -1200, 1200, 0) + 0.5) settings.posY = math.floor(ClampNumber(settings.posY, -900, 900, -120) + 0.5) settings.iconSize = math.floor(ClampNumber(settings.iconSize, 28, 80, 44) + 0.5) settings.backgroundAlpha = ClampNumber(settings.backgroundAlpha, 0, 0.8, 0.14) settings.showLabels = settings.showLabels ~= false settings.actionBar = type(settings.actionBar) == "table" and settings.actionBar or {} settings.actionBar.shown = settings.actionBar.shown == true settings.actionBar.autoShow = settings.actionBar.autoShow ~= false settings.actionBar.unlocked = settings.actionBar.unlocked == true settings.actionBar.posX = math.floor(ClampNumber(settings.actionBar.posX, -1200, 1200, 0) + 0.5) settings.actionBar.posY = math.floor(ClampNumber(settings.actionBar.posY, -900, 900, -300) + 0.5) settings.actionBar.iconSize = math.floor(ClampNumber(settings.actionBar.iconSize, 28, 80, 42) + 0.5) settings.actionBar.iconSpacing = math.floor(ClampNumber(settings.actionBar.iconSpacing, 0, 80, 8) + 0.5) settings.actionBar.orientation = NormalizeActionBarOrientation(settings.actionBar.orientation) settings.actionBar.border = type(settings.actionBar.border) == "table" and settings.actionBar.border or {} settings.actionBar.border.enabled = settings.actionBar.border.enabled == true settings.actionBar.border.width = math.floor(ClampNumber(settings.actionBar.border.width, 1, 12, 2) + 0.5) settings.actionBar.border.color = NormalizeColor(settings.actionBar.border.color, { r = 1, g = 0.82, b = 0.1, a = 0.9 }) settings.slots = type(settings.slots) == "table" and settings.slots or {} for slot = 1, 5 do settings.slots[slot] = NormalizeRuneKey(settings.slots[slot]) end return settings end function LR:GetRuneLabel(key) local normalized = NormalizeRuneKey(key) local data = RUNE_DATA[normalized] if data then return data.label end return L["OPT_EA_LURA_RUNE_EMPTY"] or "Empty" end function LR:GetRuneTexture(key) local normalized = NormalizeRuneKey(key) local data = RUNE_DATA[normalized] return (data and data.texture) or nil end function LR:GetRuneChatToken(key) local normalized = NormalizeRuneKey(key) local data = RUNE_DATA[normalized] return (data and data.chatToken) or normalized end function LR:GetAssignmentsSummary() local settings = self:GetSettings() local parts = {} for slot = 1, 5 do parts[#parts + 1] = string.format("%d=%s", slot, self:GetRuneLabel(settings.slots[slot])) end return table.concat(parts, ", ") end function LR:SerializeAssignments() local settings = self:GetSettings() local parts = {} for slot = 1, 5 do parts[slot] = NormalizeRuneKey(settings.slots[slot]) end return table.concat(parts, ",") end function LR:SavePosition() local frame = self.frame if not frame then return end local frameCenterX, frameCenterY = frame:GetCenter() local parentCenterX, parentCenterY = UIParent:GetCenter() if not frameCenterX or not frameCenterY or not parentCenterX or not parentCenterY then return end local settings = self:GetSettings() settings.posX = math.floor(frameCenterX - parentCenterX + 0.5) settings.posY = math.floor(frameCenterY - parentCenterY + 0.5) end function LR:SaveActionBarPosition() local frame = self.actionBarFrame if not frame then return end local frameCenterX, frameCenterY = frame:GetCenter() local parentCenterX, parentCenterY = UIParent:GetCenter() if not frameCenterX or not frameCenterY or not parentCenterX or not parentCenterY then return end local settings = self:GetSettings() settings.actionBar.posX = math.floor(frameCenterX - parentCenterX + 0.5) settings.actionBar.posY = math.floor(frameCenterY - parentCenterY + 0.5) end function LR:ApplyFrameStyle() local frame = self.frame if not frame then return end local settings = self:GetSettings() frame:SetScale(1) frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", settings.posX or 0, settings.posY or -120) frame:EnableMouse(settings.unlocked == true) if type(frame.SetBackdrop) == "function" then local backgroundAlpha = settings.backgroundAlpha or 0.14 if settings.unlocked == true then frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 }, }) frame:SetBackdropColor(0, 0, 0, backgroundAlpha) frame:SetBackdropBorderColor(0, 0, 0, 1) elseif backgroundAlpha > 0 then frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", }) frame:SetBackdropColor(0, 0, 0, backgroundAlpha) else frame:SetBackdrop(nil) end end if frame.dragHint then frame.dragHint:SetShown(settings.unlocked == true) end local iconSize = settings.iconSize or 44 local iconSpacing = 8 local slotSize = iconSize local radiusX = math.max(116, (iconSize + iconSpacing) * 2.35) local radiusY = math.max(104, (iconSize + iconSpacing) * 2.15) frame:SetSize(math.max(340, radiusX * 2.25 + slotSize), math.max(300, radiusY * 2.1 + slotSize)) local positions = { [1] = { -radiusX * 0.56, -radiusY * 0.70 }, [2] = { -radiusX, radiusY * 0.08 }, [3] = { 0, radiusY }, [4] = { radiusX, radiusY * 0.08 }, [5] = { radiusX * 0.56, -radiusY * 0.70 }, } for slot = 1, 5 do local slotFrame = frame.slots and frame.slots[slot] if slotFrame then slotFrame:ClearAllPoints() slotFrame:SetPoint("CENTER", frame, "CENTER", positions[slot][1], positions[slot][2]) slotFrame:SetSize(slotSize, slotSize) slotFrame.icon:SetSize(iconSize, iconSize) if type(slotFrame.SetBackdrop) == "function" then slotFrame:SetBackdrop(nil) end slotFrame.label:SetShown(false) end end if frame.tank then frame.tank:ClearAllPoints() frame.tank:SetPoint("CENTER", frame, "CENTER", 0, -radiusY * 0.98) frame.tank:SetSize(math.max(28, iconSize * 0.72), math.max(28, iconSize * 0.72)) frame.tank.icon:SetAllPoints(frame.tank) end end function LR:UpdateSlot(slot) local frame = self.frame local slotFrame = frame and frame.slots and frame.slots[slot] if not slotFrame then return end local settings = self:GetSettings() local key = NormalizeRuneKey(settings.slots[slot]) local texture = self:GetRuneTexture(key) if texture then slotFrame.icon:SetTexture(texture) slotFrame.icon:SetVertexColor(1, 1, 1, 1) else slotFrame.icon:SetTexture(FALLBACK_TEXTURE) slotFrame.icon:SetVertexColor(0.18, 0.18, 0.18, 0.45) end slotFrame.label:SetText("") end function LR:UpdateFrame() if not self.frame then return end self:ApplyFrameStyle() for slot = 1, 5 do self:UpdateSlot(slot) end end function LR:ApplyActionBarStyle() local frame = self.actionBarFrame if not frame then return end local settings = self:GetSettings() local actionBar = settings.actionBar local iconSpacing = actionBar.iconSpacing or 8 local borderWidth = actionBar.border and actionBar.border.enabled and (actionBar.border.width or 2) or 0 frame:SetScale(1) frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", actionBar.posX or 0, actionBar.posY or -300) frame:EnableMouse(actionBar.unlocked == true) if type(frame.SetBackdrop) == "function" then if actionBar.unlocked == true then frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 }, }) frame:SetBackdropColor(0, 0, 0, 0.28) frame:SetBackdropBorderColor(0, 0, 0, 1) else frame:SetBackdrop(nil) end end if frame.dragHint then frame.dragHint:SetShown(actionBar.unlocked == true) end local buttons = frame.buttons or {} local iconSize = actionBar.iconSize or 42 local buttonSize = iconSize + (borderWidth * 2) local spacing = iconSpacing local padding = 8 local count = math.max(1, #buttons) local orientation = NormalizeActionBarOrientation(actionBar.orientation) if orientation == "vertical" then frame:SetSize(buttonSize + padding, (count * buttonSize) + ((count - 1) * spacing) + padding) else frame:SetSize((count * buttonSize) + ((count - 1) * spacing) + padding, buttonSize + padding) end local totalLength = (count * buttonSize) + ((count - 1) * spacing) local start = -(totalLength - buttonSize) / 2 for index, button in ipairs(buttons) do button:SetSize(buttonSize, buttonSize) if button.icon then button.icon:SetSize(iconSize, iconSize) end ApplyIconBorder(button, actionBar, button.isClear and { r = 0.18, g = 0, b = 0, a = 0.48 } or nil) button:ClearAllPoints() if orientation == "vertical" then button:SetPoint("CENTER", frame, "CENTER", 0, -start - ((index - 1) * (buttonSize + spacing))) else button:SetPoint("CENTER", frame, "CENTER", start + ((index - 1) * (buttonSize + spacing)), 0) end end local canUse = self:CanUseRuneInput() for _, button in ipairs(buttons) do if type(button.SetEnabled) == "function" then button:SetEnabled(canUse) end button:SetAlpha(canUse and 1 or 0.45) end end function LR:ShouldShowActionBar() local settings = self:GetSettings() return EA.runtimeEnabled == true and settings.enabled == true and (settings.actionBar.shown == true or self:IsTestMode() or self:ShouldAutoShowActionBar()) end function LR:RefreshActionBar() local frame = self.actionBarFrame if not frame and not self:ShouldShowActionBar() then return end frame = self:EnsureActionBar() self:ApplyActionBarStyle() if self:ShouldShowActionBar() then frame:Show() else frame:Hide() end end function LR:EnsureActionBar() if self.actionBarFrame then return self.actionBarFrame end local frame = CreateFrame("Frame", "HMGT_LuraRuneActionBar", UIParent, "BackdropTemplate") frame:SetSize(316, 58) frame:SetFrameStrata("FULLSCREEN_DIALOG") frame:SetFrameLevel(205) frame:SetClampedToScreen(true) frame:SetMovable(true) frame:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function(selfFrame) if LR:GetSettings().actionBar.unlocked == true then selfFrame:StartMoving() end end) frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() LR:SaveActionBarPosition() LR:ApplyActionBarStyle() end) frame:Hide() local dragHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") dragHint:SetPoint("TOP", frame, "BOTTOM", 0, -2) dragHint:SetText(L["OPT_EA_LURA_DRAG_HINT"] or "Drag to move") frame.dragHint = dragHint frame.buttons = {} local size = 42 for _, key in ipairs(RUNE_ORDER) do local data = RUNE_DATA[key] or {} local button = CreateFrame("Button", nil, frame, "BackdropTemplate") button:SetSize(size, size) button.icon = button:CreateTexture(nil, "ARTWORK") button.icon:SetPoint("CENTER") button.icon:SetSize(size - 8, size - 8) button.icon:SetTexture(data.texture or FALLBACK_TEXTURE) button.icon:SetVertexColor(1, 1, 1, 1) button:SetScript("OnClick", function() LR:AppendRuneToSequence(key) LR:ApplyActionBarStyle() end) frame.buttons[#frame.buttons + 1] = button end local clearButton = CreateFrame("Button", nil, frame, "BackdropTemplate") clearButton:SetSize(size, size) clearButton.isClear = true clearButton.icon = clearButton:CreateTexture(nil, "ARTWORK") clearButton.icon:SetPoint("CENTER") clearButton.icon:SetSize(size - 8, size - 8) clearButton.icon:SetTexture(CLEAR_BUTTON_TEXTURE) clearButton.icon:SetVertexColor(1, 1, 1, 1) clearButton:SetScript("OnClick", function() LR:ClearAssignments(false) LR:ApplyActionBarStyle() end) frame.clearButton = clearButton frame.buttons[#frame.buttons + 1] = clearButton self.actionBarFrame = frame self:ApplyActionBarStyle() return frame end function LR:EnsureFrame() if self.frame then return self.frame end local frame = CreateFrame("Frame", "HMGT_LuraRunesFrame", UIParent, "BackdropTemplate") frame:SetSize(340, 300) frame:SetFrameStrata("FULLSCREEN_DIALOG") frame:SetFrameLevel(190) frame:SetClampedToScreen(true) frame:SetMovable(true) frame:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function(selfFrame) if LR:GetSettings().unlocked == true then selfFrame:StartMoving() end end) frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() LR:SavePosition() LR:ApplyFrameStyle() end) frame:Hide() local dragHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") dragHint:SetPoint("TOP", frame, "TOP", 0, -4) dragHint:SetText(L["OPT_EA_LURA_DRAG_HINT"] or "Drag to move") frame.dragHint = dragHint local boss = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") boss:SetPoint("CENTER", frame, "CENTER", 0, 0) boss:SetText(L["OPT_EA_LURA_BOSS"] or "Boss") frame.boss = boss local tank = CreateFrame("Frame", nil, frame) tank:SetSize(32, 32) tank.icon = tank:CreateTexture(nil, "ARTWORK") tank.icon:SetAllPoints(tank) SetTankIconTexture(tank.icon) frame.tank = tank frame.slots = {} for slot = 1, 5 do local slotFrame = CreateFrame("Frame", nil, frame, "BackdropTemplate") slotFrame.icon = slotFrame:CreateTexture(nil, "ARTWORK") slotFrame.icon:SetPoint("CENTER", slotFrame, "CENTER", 0, 0) slotFrame.icon:SetTexture(FALLBACK_TEXTURE) slotFrame.number = slotFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") slotFrame.number:SetPoint("TOPLEFT", slotFrame, "TOPLEFT", 5, -3) slotFrame.number:SetText(tostring(slot)) slotFrame.label = slotFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") slotFrame.label:SetPoint("TOP", slotFrame.icon, "BOTTOM", 0, -2) slotFrame.label:SetWidth(90) slotFrame.label:SetJustifyH("CENTER") slotFrame.label:SetText("") slotFrame.label:Hide() frame.slots[slot] = slotFrame end self.frame = frame self:UpdateFrame() return frame end function LR:ShouldShow() local settings = self:GetSettings() return EA.runtimeEnabled == true and settings.enabled == true and (settings.unlocked == true or HasAssignments(settings.slots)) end function LR:Refresh() local frame = self:EnsureFrame() self:UpdateFrame() if self:ShouldShow() then frame:Show() else frame:Hide() end self:RefreshActionBar() end function LR:Show() local settings = self:GetSettings() EA:GetSettings().enabled = true EA.runtimeEnabled = true settings.enabled = true self:EnsureFrame() self:UpdateFrame() self.frame:Show() NotifyOptionsChanged() end function LR:Hide() self.testMode = false if self.frame then self.frame:Hide() end if self.actionBarFrame then self.actionBarFrame:Hide() end end function LR:ApplyAssignments(slots, source) local settings = self:GetSettings() for slot = 1, 5 do settings.slots[slot] = NormalizeRuneKey(slots and slots[slot]) end self:Refresh() NotifyOptionsChanged() Debug("info", "Lura runes updated source=%s %s", tostring(source or "local"), self:GetAssignmentsSummary()) end function LR:ApplyTestPattern() self.testMode = true Debug("info", "Lura local test mode enabled") self:ApplyAssignments(DEFAULT_TEST_ASSIGNMENTS, "test") end function LR:ClearAssignments(broadcast) self:ApplyAssignments({}, "clear") if broadcast then self:BroadcastAssignments() end end function LR:GetNextEmptySequenceSlot() local settings = self:GetSettings() for slot = 1, 5 do if NormalizeRuneKey(settings.slots[slot]) == "" then return slot end end return nil end function LR:LogSequenceProgress(slot, key) local label = self:GetRuneLabel(key) if slot >= 5 then Debug("info", "Lura rune sequence complete %s", self:GetAssignmentsSummary()) else Debug("info", "Lura rune %s saved as slot %d; waiting for next rune", tostring(label or "?"), slot) end end function LR:SendRuneRaidChat(slot, key) if self:IsTestMode() or not self:CanBroadcastSequence() then return false end if not IsInRaid() then Debug("verbose", "Lura raid chat skipped: player is not in raid") return false end local slotIndex = tonumber(slot) or 0 if slotIndex < 1 or slotIndex > 5 then return false end local token = self:GetRuneChatToken(key) if not token or token == "" then return false end local message = string.format("HMGT:Rune%d:%s", slotIndex, token) local ok = false if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then ok = pcall(C_ChatInfo.SendChatMessage, message, "RAID") elseif type(SendChatMessage) == "function" then ok = pcall(SendChatMessage, message, "RAID") end if ok then Debug("info", "Lura raid chat sent %s", message) return true end Debug("error", "Lura raid chat failed %s", message) return false end function LR:SendRuneRaidChatSequence() local settings = self:GetSettings() local sent = 0 for slot = 1, 5 do local key = NormalizeRuneKey(settings.slots[slot]) if key ~= "" and self:SendRuneRaidChat(slot, key) then sent = sent + 1 end end return sent end function LR:AppendRuneToSequence(key) local runeKey = NormalizeRuneKey(key) if runeKey == "" then Debug("info", "Lura rune input ignored: unknown rune. Valid runes: circle, x, diamond, t, triangle") return false end if not self:CanUseRuneInput() then self:LogNotLeader("input") return false end local settings = self:GetSettings() local slot = self:GetNextEmptySequenceSlot() if not slot then for index = 1, 5 do settings.slots[index] = "" end slot = 1 end EA:GetSettings().enabled = true EA.runtimeEnabled = true settings.enabled = true settings.slots[slot] = runeKey self:Refresh() NotifyOptionsChanged() self:LogSequenceProgress(slot, runeKey) Debug("info", "Lura rune input slot=%d rune=%s %s", slot, tostring(runeKey), self:GetAssignmentsSummary()) self:SendRuneRaidChat(slot, runeKey) if slot >= 5 and self:CanBroadcastSequence() then self:BroadcastAssignments(false) elseif slot >= 5 then Debug("info", "Lura local test sequence complete; not sending to raid") end return true end function LR:BroadcastAssignments(sendRaidChat) if not self:CanBroadcastSequence() then self:LogNotLeader("sequence send") return false end local prefix = HMGT.MSG_LURA_RUNES or "LUR" local payload = self:SerializeAssignments() if sendRaidChat ~= false then self:SendRuneRaidChatSequence() end HMGT:SendGroupMessage(string.format("%s|%s", prefix, payload), "ALERT") Debug("info", "Lura rune sequence sent %s", self:GetAssignmentsSummary()) return true end function LR:HandleComm(senderName, payload) local settings = self:GetSettings() if EA.runtimeEnabled ~= true or settings.enabled ~= true then return end if not self:IsSequenceSenderAllowed(senderName) then Debug("info", "Lura rune sequence ignored from non-leader/non-assist sender=%s", tostring(senderName or "?")) return end self:ApplyAssignments(SplitAssignments(payload), senderName) end function LR:HandleRaidChatMessage(message, senderName, event) local slot, key = ParseRuneRaidChatMessage(message) if not slot or not key then return false end local settings = self:GetSettings() if EA.runtimeEnabled ~= true or settings.enabled ~= true then Debug("verbose", "Lura raid chat ignored while disabled event=%s sender=%s", tostring(event or "?"), tostring(senderName or "?")) return false end if not self:IsSequenceSenderAllowed(senderName) then Debug("info", "Lura raid chat ignored from non-leader/non-assist sender=%s", tostring(senderName or "?")) return false end settings.slots[slot] = key self:Refresh() NotifyOptionsChanged() Debug("info", "Lura raid chat applied sender=%s slot=%d rune=%s", tostring(senderName or "?"), slot, tostring(key)) return true end function LR:HandleSlashCommand(input) local rest = tostring(input or ""):match("^lura%s*(.*)$") or "" rest = rest:gsub("^%s+", ""):gsub("%s+$", "") if rest == "" or rest == "show" then self:Show() return end if rest == "hide" then self:Hide() return end if rest == "unlock" then local settings = self:GetSettings() settings.unlocked = not settings.unlocked self:Show() self:Refresh() return end if rest == "bar" or rest == "buttons" or rest == "actionbar" then local settings = self:GetSettings() EA:GetSettings().enabled = true EA.runtimeEnabled = true settings.enabled = true settings.actionBar.shown = not settings.actionBar.shown self:Refresh() NotifyOptionsChanged() return end if rest == "bar unlock" or rest == "buttons unlock" or rest == "actionbar unlock" then local settings = self:GetSettings() EA:GetSettings().enabled = true EA.runtimeEnabled = true settings.enabled = true settings.actionBar.shown = true settings.actionBar.unlocked = not settings.actionBar.unlocked self:Refresh() NotifyOptionsChanged() return end if rest == "test" then self:Show() self:ApplyTestPattern() return end if rest == "send" or rest == "broadcast" then self:BroadcastAssignments() return end if rest == "clear" then self:ClearAssignments(true) return end if rest == "reset" or rest == "new" then self.testMode = false self:ClearAssignments(false) return end local slots = {} local index = 1 for token in rest:gmatch("[^,%s]+") do if index > 5 then break end slots[index] = NormalizeRuneKey(token) index = index + 1 end if index == 2 then self:AppendRuneToSequence(slots[1]) return end if index > 1 then self:Show() self:ApplyAssignments(slots, "slash") self:BroadcastAssignments() else Debug("info", "Lura slash usage: /hmgt lura test | reset | clear | unlock | send | circle x diamond t triangle") end end