local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} local LSM = LibStub("LibSharedMedia-3.0", true) local AceGUI = LibStub("AceGUI-3.0", true) local RT = HMGT:NewModule("RaidTimeline") HMGT.RaidTimeline = RT RT.runtimeEnabled = false RT.eventFrame = nil RT.ticker = nil RT.alertFrame = nil RT.activeEncounterId = nil RT.activeEncounterStartTime = nil RT.activeSchedule = nil RT.firedEntries = nil RT.currentAlert = nil RT.receivedAssignments = nil RT.timelineEditor = nil RT.timelineEditorState = nil RT.activeEncounterDifficulty = nil RT.previewAlertActive = false RT.activeBossAbilityDispatches = nil RT.activeBossAbilityCounts = nil RT.testActive = false RT.testEncounterId = nil RT.testDuration = nil RT.dispatchedEntries = nil local encounterInstanceInfoCache = {} local encounterDirtyFlags = {} local encounterNormalizedFlags = {} local bossAbilityDefinitionsCache = {} local ENCOUNTER_DIFFICULTY_DEFAULTS = { lfr = true, normal = true, heroic = true, mythic = true, } local function Clamp(value, minValue, maxValue, fallback) local num = tonumber(value) if not num then num = tonumber(fallback) or minValue end if num < minValue then num = minValue end if num > maxValue then num = maxValue end return num end local function NormalizeName(name) return HMGT.NormalizePlayerName and HMGT:NormalizePlayerName(name) or name end local function GetOwnName() return NormalizeName(UnitName("player")) end local function GetSpellName(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end if C_Spell and type(C_Spell.GetSpellName) == "function" then local name = C_Spell.GetSpellName(sid) if type(name) == "string" and name ~= "" then return name end end if type(GetSpellInfo) == "function" then local name = GetSpellInfo(sid) if type(name) == "string" and name ~= "" then return name end end return nil end local function StableHash(...) local hash = 0 for argIndex = 1, select("#", ...) do local value = tostring(select(argIndex, ...) or "") for i = 1, #value do hash = (hash * 33 + string.byte(value, i)) % 2147483647 end end return hash end local function GetTimelineMessagePrefix() return HMGT.MSG_RAID_TIMELINE or "RTL" end local function GetTimelineTestMessagePrefix() return HMGT.MSG_RAID_TIMELINE_TEST or "RTS" end local function NormalizeEncounterDifficulties(difficulties) local source = type(difficulties) == "table" and difficulties or {} return { lfr = source.lfr ~= false, normal = source.normal ~= false, heroic = source.heroic ~= false, mythic = source.mythic ~= false, } end local function GetDifficultyKey(difficultyId) local id = tonumber(difficultyId) if id == 17 then return "lfr" elseif id == 14 then return "normal" elseif id == 15 then return "heroic" elseif id == 16 then return "mythic" end return nil end local TEXT_REMINDER_ITEM_ID = 134332 local function GetTextReminderIcon() if C_Item and type(C_Item.GetItemIconByID) == "function" then local icon = C_Item.GetItemIconByID(TEXT_REMINDER_ITEM_ID) if icon and icon ~= 0 and icon ~= "" then return icon end end if type(GetItemIcon) == "function" then local icon = GetItemIcon(TEXT_REMINDER_ITEM_ID) if icon and icon ~= 0 and icon ~= "" then return icon end end return 136243 end local function GetPreferredTestDifficultyId(encounter) local difficulties = encounter and encounter.difficulties or {} if difficulties.normal ~= false then return 14 elseif difficulties.heroic ~= false then return 15 elseif difficulties.mythic ~= false then return 16 elseif difficulties.lfr ~= false then return 17 end return 14 end local function NormalizeEntryType(entryType, spellId) local kind = tostring(entryType or (((tonumber(spellId) or 0) > 0) and "spell" or "text")) if kind == "text" then return "text" elseif kind == "bossAbility" then return "bossAbility" end return "spell" end local function NormalizeLegacyEntry(triggerType, actionType, entryType, spellId) local legacyType = NormalizeEntryType(entryType, spellId) local trigger = tostring(triggerType or "") local action = tostring(actionType or "") if trigger == "" or action == "" then if legacyType == "spell" then trigger = "time" action = "raidCooldown" elseif legacyType == "text" then trigger = "time" action = "text" elseif legacyType == "bossAbility" then trigger = "bossAbility" action = ((tonumber(spellId) or 0) > 0) and "raidCooldown" or "text" end end if trigger ~= "bossAbility" then trigger = "time" end if action ~= "text" then action = "raidCooldown" end return trigger, action end local function GetLegacyEntryTypeFromAxes(triggerType, actionType) if tostring(triggerType or "") == "bossAbility" then return "bossAbility" end if tostring(actionType or "") == "text" then return "text" end return "spell" end local function TrimText(value) return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "") end local function NormalizeBossAbilityBarName(value) local text = TrimText(value) if text == "" then return "" end text = text:gsub("%s*%(%d+%)%s*$", "") text = text:gsub("%s*%([Cc]ount%)%s*$", "") return TrimText(text) end local function NormalizeCastCountSelector(value) local text = string.lower(TrimText(value)) if text == "" then return "1" end if text == "all" or text == "odd" or text == "even" then return text end local numeric = tonumber(text) if numeric and numeric >= 1 then return tostring(math.max(1, math.floor(numeric + 0.5))) end return "1" end local function FormatCastCountSelector(value) local selector = NormalizeCastCountSelector(value) if selector == "all" then return L["OPT_RT_CAST_ALL"] or "All" end if selector == "odd" then return L["OPT_RT_CAST_ODD"] or "Odd" end if selector == "even" then return L["OPT_RT_CAST_EVEN"] or "Even" end return selector end local function DoesCastCountSelectorMatch(selector, currentCount) local normalizedSelector = NormalizeCastCountSelector(selector) local observedCount = math.max(1, tonumber(currentCount) or 1) if normalizedSelector == "all" then return true end if normalizedSelector == "odd" then return (observedCount % 2) == 1 end if normalizedSelector == "even" then return (observedCount % 2) == 0 end return observedCount == math.max(1, tonumber(normalizedSelector) or 1) end local function ParseTimelineClock(value) local text = TrimText(value) if text == "" then return nil end local minutes, seconds = text:match("^(%d+)%s*:%s*(%d%d)$") if minutes and seconds then local mm = tonumber(minutes) local ss = tonumber(seconds) if mm and ss and ss >= 0 and ss <= 59 then return (mm * 60) + ss end end local numeric = tonumber(text) if numeric and numeric >= 0 then return numeric end return nil end local function NormalizeInstanceName(name) local text = TrimText(name) if text == "" then text = L["OPT_RT_RAID_DEFAULT"] or "Encounter" end return text end local function EnsureEncounterJournalReady() if type(EncounterJournal_LoadUI) == "function" then pcall(EncounterJournal_LoadUI) return true end if C_AddOns and type(C_AddOns.LoadAddOn) == "function" then pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal") return type(EJ_GetEncounterInfo) == "function" end if type(LoadAddOn) == "function" then pcall(LoadAddOn, "Blizzard_EncounterJournal") return type(EJ_GetEncounterInfo) == "function" end return false end function RT:NormalizeCastCountSelector(value) return NormalizeCastCountSelector(value) end function RT:FormatCastCountSelector(value) return FormatCastCountSelector(value) end function RT:DoesCastCountSelectorMatch(selector, currentCount) return DoesCastCountSelectorMatch(selector, currentCount) end local function ResolveEncounterInstanceByJournalScan(encounterId) EnsureEncounterJournalReady() if type(EJ_GetNumTiers) ~= "function" or type(EJ_SelectTier) ~= "function" or type(EJ_GetInstanceByIndex) ~= "function" or type(EJ_GetEncounterInfoByIndex) ~= "function" then return nil, nil end local targetEncounterId = tonumber(encounterId) if not targetEncounterId or targetEncounterId <= 0 then return nil, nil end local numTiers = tonumber(EJ_GetNumTiers()) or 0 for tierIndex = 1, numTiers do pcall(EJ_SelectTier, tierIndex) for instanceIndex = 1, 200 do local instanceId, instanceName = EJ_GetInstanceByIndex(instanceIndex, true) instanceId = tonumber(instanceId) if not instanceId or instanceId <= 0 then break end for encounterIndex = 1, 200 do local _, _, journalEncounterId, _, _, _, dungeonEncounterId = EJ_GetEncounterInfoByIndex(encounterIndex, instanceId) journalEncounterId = tonumber(journalEncounterId) dungeonEncounterId = tonumber(dungeonEncounterId) if (not journalEncounterId or journalEncounterId <= 0) and (not dungeonEncounterId or dungeonEncounterId <= 0) then break end if journalEncounterId == targetEncounterId or dungeonEncounterId == targetEncounterId then return instanceId, NormalizeInstanceName(instanceName) end end end end return nil, nil end function RT:GetEncounterInstanceInfo(encounterId) local encounter = tonumber(encounterId) if not encounter or encounter <= 0 then return nil, NormalizeInstanceName(nil) end local cached = encounterInstanceInfoCache[encounter] if cached then return cached.journalInstanceId, cached.instanceName end EnsureEncounterJournalReady() local journalInstanceId if type(EJ_GetEncounterInfo) == "function" then local ok = pcall(function() journalInstanceId = select(6, EJ_GetEncounterInfo(encounter)) end) if not ok then journalInstanceId = nil end end journalInstanceId = tonumber(journalInstanceId) or 0 local instanceName if journalInstanceId > 0 and type(EJ_GetInstanceInfo) == "function" then local ok = pcall(function() instanceName = EJ_GetInstanceInfo(journalInstanceId) end) if not ok then instanceName = nil end end if (journalInstanceId <= 0 or not instanceName or instanceName == "") then local fallbackInstanceId, fallbackInstanceName = ResolveEncounterInstanceByJournalScan(encounter) if fallbackInstanceId and fallbackInstanceId > 0 then journalInstanceId = fallbackInstanceId end if fallbackInstanceName and fallbackInstanceName ~= "" then instanceName = fallbackInstanceName end end local resolvedId = (journalInstanceId > 0 and journalInstanceId or nil) local resolvedName = NormalizeInstanceName(instanceName) encounterInstanceInfoCache[encounter] = { journalInstanceId = resolvedId, instanceName = resolvedName, } return resolvedId, resolvedName end local function GetBossAbilityDataRoot() local root = HMGT.RaidTimelineBossAbilityData if type(root) ~= "table" then return nil end if type(root.raids) ~= "table" then root.raids = {} end return root end local function NormalizeBossAbilityDifficulties(difficulties) local source = type(difficulties) == "table" and difficulties or {} return { lfr = source.lfr == true, normal = source.normal == true, heroic = source.heroic == true, mythic = source.mythic == true, } end local function GetBossAbilityDifficultySuffix(definition) local difficulties = NormalizeBossAbilityDifficulties(definition and definition.difficulties) local enabled = {} if difficulties.lfr then enabled[#enabled + 1] = "LFR" end if difficulties.normal then enabled[#enabled + 1] = "N" end if difficulties.heroic then enabled[#enabled + 1] = "HC" end if difficulties.mythic then enabled[#enabled + 1] = "M" end if #enabled == 0 or #enabled == 4 then return "" end return string.format(" [%s]", table.concat(enabled, "/")) end function RT:GetBossAbilityDefinitions(encounterId) local targetEncounterId = tonumber(encounterId) local cached = bossAbilityDefinitionsCache[targetEncounterId or 0] if cached then return cached end local root = GetBossAbilityDataRoot() local definitions = {} if not root or not targetEncounterId or targetEncounterId <= 0 then return definitions end for _, raid in ipairs(root.raids or {}) do local bosses = type(raid) == "table" and raid.bosses or nil local boss = bosses and bosses[targetEncounterId] if type(boss) == "table" and type(boss.abilities) == "table" then for _, ability in ipairs(boss.abilities) do if type(ability) == "table" then local key = TrimText(ability.key or "") if key ~= "" then definitions[#definitions + 1] = { key = key, name = TrimText(ability.name or ""), spellId = tonumber(ability.spellId) or 0, icon = ability.icon, difficulties = ability.difficulties, triggers = ability.triggers, encounterId = targetEncounterId, raidName = TrimText(raid.name or ""), bossName = TrimText(boss.name or ""), } end end end end end table.sort(definitions, function(a, b) local aName = a.name ~= "" and a.name or tostring(a.key or "") local bName = b.name ~= "" and b.name or tostring(b.key or "") return aName < bName end) bossAbilityDefinitionsCache[targetEncounterId] = definitions return definitions end function RT:MarkEncounterDirty(encounterId) local id = tonumber(encounterId) if id and id > 0 then encounterDirtyFlags[id] = true encounterNormalizedFlags[id] = nil end end function RT:GetBossAbilityDefinition(encounterId, abilityKey) local key = TrimText(abilityKey or "") if key == "" then return nil end for _, definition in ipairs(self:GetBossAbilityDefinitions(encounterId)) do if tostring(definition.key or "") == key then return definition end end return nil end function RT:GetBossAbilityDisplayText(definition) if type(definition) ~= "table" then return L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability" end local text = TrimText(definition.name or "") local spellId = tonumber(definition.spellId) or 0 if text == "" then if spellId > 0 then text = GetSpellName(spellId) or ("Spell " .. tostring(spellId)) else text = tostring(definition.key or (L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability")) end end text = text .. GetBossAbilityDifficultySuffix(definition) local icon = definition.icon if (not icon or icon == "") and spellId > 0 and HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then icon = HMGT_SpellData.GetSpellIcon(spellId) end if (not icon or icon == "") and spellId > 0 and C_Spell and type(C_Spell.GetSpellTexture) == "function" then icon = C_Spell.GetSpellTexture(spellId) end if icon and icon ~= "" then return string.format("|T%s:16:16:0:0|t %s", tostring(icon), text) end return text end function RT:EncodeCommText(value) local text = tostring(value or "") text = text:gsub("%%", "%%25") text = text:gsub("|", "%%7C") text = text:gsub("\n", "%%0A") text = text:gsub("\r", "") return text end function RT:DecodeCommText(value) local text = tostring(value or "") text = text:gsub("%%0A", "\n") text = text:gsub("%%7C", "|") text = text:gsub("%%25", "%%") return text end function RT:GetSettings() local profile = HMGT.db and HMGT.db.profile if not profile then return nil end profile.raidTimeline = profile.raidTimeline or {} local settings = profile.raidTimeline if settings.enabled == nil then settings.enabled = false end settings.leadTime = math.floor(Clamp(settings.leadTime, 1, 15, 5) + 0.5) settings.assignmentLeadTime = math.floor(Clamp(settings.assignmentLeadTime, 0, 60, settings.leadTime or 5) + 0.5) settings.unlocked = settings.unlocked == true settings.alertPosX = Clamp(settings.alertPosX, -2000, 2000, 0) settings.alertPosY = Clamp(settings.alertPosY, -2000, 2000, 180) settings.alertFont = tostring(settings.alertFont or "Friz Quadrata TT") settings.alertFontSize = math.floor(Clamp(settings.alertFontSize, 10, 72, 30) + 0.5) settings.alertFontOutline = tostring(settings.alertFontOutline or "OUTLINE") settings.alertColor = type(settings.alertColor) == "table" and settings.alertColor or { r = 1, g = 0.82, b = 0.15, a = 1 } settings.alertColor.r = tonumber(settings.alertColor.r or settings.alertColor[1]) or 1 settings.alertColor.g = tonumber(settings.alertColor.g or settings.alertColor[2]) or 0.82 settings.alertColor.b = tonumber(settings.alertColor.b or settings.alertColor[3]) or 0.15 settings.alertColor.a = tonumber(settings.alertColor.a or settings.alertColor[4]) or 1 settings.encounters = type(settings.encounters) == "table" and settings.encounters or {} return settings end function RT:GetEncounterIds() local ids = {} local encounters = (self:GetSettings() or {}).encounters or {} for encounterId in pairs(encounters) do local id = tonumber(encounterId) if id and id > 0 then ids[#ids + 1] = id end end table.sort(ids) return ids end function RT:GetEncounter(encounterId) local id = tonumber(encounterId) if not id or id <= 0 then return nil end local settings = self:GetSettings() local encounter = settings and settings.encounters and settings.encounters[id] if type(encounter) ~= "table" then return nil end if encounterDirtyFlags[id] ~= true and encounterNormalizedFlags[id] == true then return encounter end local journalInstanceId, instanceName = self:GetEncounterInstanceInfo(id) encounter.name = tostring(encounter.name or "") encounter.journalInstanceId = journalInstanceId or tonumber(encounter.journalInstanceId) or 0 encounter.instanceName = instanceName encounter.difficulties = NormalizeEncounterDifficulties(encounter.difficulties) encounter.entries = type(encounter.entries) == "table" and encounter.entries or {} for _, entry in ipairs(encounter.entries) do entry.time = math.max(0, math.floor((ParseTimelineClock(entry.time) or tonumber(entry.time) or 0) + 0.5)) entry.spellId = math.max(0, tonumber(entry.spellId) or 0) entry.playerName = TrimText(entry.playerName or "") entry.entryType = NormalizeEntryType(entry.entryType, entry.spellId) entry.triggerType, entry.actionType = NormalizeLegacyEntry(entry.triggerType, entry.actionType, entry.entryType, entry.spellId) entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType) entry.bossAbilityId = TrimText(entry.bossAbilityId or "") entry.bossAbilityBarName = NormalizeBossAbilityBarName(entry.bossAbilityBarName or "") entry.castCount = NormalizeCastCountSelector(entry.castCount) entry.targetSpec = TrimText(entry.targetSpec or ((entry.actionType == "text") and entry.playerName or "")) entry.alertText = tostring(entry.alertText or "") if entry.actionType == "text" then entry.spellId = 0 end if entry.triggerType ~= "bossAbility" then entry.bossAbilityId = "" entry.bossAbilityBarName = "" entry.castCount = "1" else entry.time = 0 end end table.sort(encounter.entries, function(a, b) local aTime = tonumber(a and a.time) or 0 local bTime = tonumber(b and b.time) or 0 if aTime ~= bTime then return aTime < bTime end local aSpell = tonumber(a and a.spellId) or 0 local bSpell = tonumber(b and b.spellId) or 0 if aSpell ~= bSpell then return aSpell < bSpell end return tostring(a and a.playerName or "") < tostring(b and b.playerName or "") end) encounterDirtyFlags[id] = nil encounterNormalizedFlags[id] = true return encounter end function RT:EnsureEncounter(encounterId) local id = tonumber(encounterId) if not id or id <= 0 then return nil end local settings = self:GetSettings() settings.encounters[id] = settings.encounters[id] or { name = "", journalInstanceId = 0, instanceName = NormalizeInstanceName(nil), difficulties = NormalizeEncounterDifficulties(ENCOUNTER_DIFFICULTY_DEFAULTS), entries = {}, } self:MarkEncounterDirty(id) return self:GetEncounter(id) end function RT:AddEncounter(encounterId, encounterName) local id = tonumber(encounterId) if not id or id <= 0 then return false end local encounter = self:EnsureEncounter(id) encounter.name = tostring(encounterName or encounter.name or "") local journalInstanceId, instanceName = self:GetEncounterInstanceInfo(id) encounter.journalInstanceId = journalInstanceId or 0 encounter.instanceName = instanceName self:MarkEncounterDirty(id) return true end function RT:IsEncounterEnabledForDifficulty(encounter, difficultyId) if type(encounter) ~= "table" then return false end local key = GetDifficultyKey(difficultyId) if not key then return true end local difficulties = NormalizeEncounterDifficulties(encounter.difficulties) encounter.difficulties = difficulties return difficulties[key] == true end function RT:RemoveEncounter(encounterId) local id = tonumber(encounterId) local settings = self:GetSettings() if not id or not settings or not settings.encounters[id] then return false end settings.encounters[id] = nil if self.activeEncounterId == id then self:StopEncounter() end return true end function RT:AddEntry(encounterId, timeSec, spellId, playerName, alertText, entryType, targetSpec) local encounter = self:EnsureEncounter(encounterId) local atTime = ParseTimelineClock(timeSec) local sid = math.max(0, tonumber(spellId) or 0) local text = tostring(alertText or "") local kind = NormalizeEntryType(entryType, sid) local triggerType, actionType = NormalizeLegacyEntry(nil, nil, kind, sid) local fixedPlayer = TrimText(playerName or "") if kind == "text" then sid = 0 end local targets = TrimText(targetSpec or ((kind == "text") and fixedPlayer or "")) local trimmedText = TrimText(text) if not encounter or not atTime or atTime < 0 then return false end if kind == "spell" and sid <= 0 then return false end if kind == "text" and (trimmedText == "" or targets == "") then return false end encounter.entries[#encounter.entries + 1] = { time = math.floor(atTime + 0.5), spellId = sid, playerName = fixedPlayer, entryType = GetLegacyEntryTypeFromAxes(triggerType, actionType), triggerType = triggerType, actionType = actionType, targetSpec = targets, alertText = text, } self:MarkEncounterDirty(encounterId) self:GetEncounter(encounterId) self:Refresh() return true end function RT:AddDetailedEntry(encounterId, entryData) local data = type(entryData) == "table" and entryData or {} local encounter = self:EnsureEncounter(encounterId) if not encounter then return false end local triggerType, actionType = NormalizeLegacyEntry(data.triggerType, data.actionType, data.entryType, data.spellId) local timeSec = ParseTimelineClock(data.time) local spellId = math.max(0, tonumber(data.spellId) or 0) local playerName = TrimText(data.playerName or "") local alertText = tostring(data.alertText or "") local targetSpec = TrimText(data.targetSpec or "") local bossAbilityId = TrimText(data.bossAbilityId or "") local bossAbilityBarName = NormalizeBossAbilityBarName(data.bossAbilityBarName or "") local castCount = NormalizeCastCountSelector(data.castCount) if triggerType == "time" and actionType == "raidCooldown" then if timeSec == nil or timeSec < 0 or spellId <= 0 then return false end elseif triggerType == "time" and actionType == "text" then if timeSec == nil or timeSec < 0 or TrimText(alertText) == "" then return false end spellId = 0 bossAbilityId = "" castCount = "1" elseif triggerType == "bossAbility" and actionType == "text" then if bossAbilityBarName == "" or TrimText(alertText) == "" then return false end spellId = 0 timeSec = 0 elseif triggerType == "bossAbility" and actionType == "raidCooldown" then if bossAbilityBarName == "" or spellId <= 0 then return false end timeSec = 0 else return false end encounter.entries[#encounter.entries + 1] = { time = math.floor((timeSec or 0) + 0.5), spellId = spellId, playerName = playerName, entryType = GetLegacyEntryTypeFromAxes(triggerType, actionType), triggerType = triggerType, actionType = actionType, targetSpec = targetSpec, alertText = alertText, bossAbilityId = bossAbilityId, bossAbilityBarName = bossAbilityBarName, castCount = castCount, } self:MarkEncounterDirty(encounterId) self:GetEncounter(encounterId) self:Refresh() return true end function RT:RemoveEntry(encounterId, row) local encounter = self:GetEncounter(encounterId) local index = tonumber(row) if not encounter or not index or not encounter.entries[index] then return false end table.remove(encounter.entries, index) self:MarkEncounterDirty(encounterId) self:Refresh() return true end function RT:SetEntryField(encounterId, row, field, value) local encounter = self:GetEncounter(encounterId) local index = tonumber(row) if not encounter or not index or not encounter.entries[index] then return false end local entry = encounter.entries[index] if field == "time" then local timeValue = ParseTimelineClock(value) if not timeValue or timeValue < 0 then return false end entry.time = math.floor(timeValue + 0.5) elseif field == "spellId" then entry.spellId = math.max(0, tonumber(value) or 0) elseif field == "playerName" then entry.playerName = TrimText(value or "") elseif field == "triggerType" then local triggerType = tostring(value or "") entry.triggerType = (triggerType == "bossAbility") and "bossAbility" or "time" if entry.triggerType ~= "bossAbility" then entry.bossAbilityId = "" entry.bossAbilityBarName = "" entry.castCount = "1" else entry.time = 0 end entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType) elseif field == "actionType" then local actionType = tostring(value or "") entry.actionType = (actionType == "text") and "text" or "raidCooldown" if entry.actionType == "text" then entry.spellId = 0 if TrimText(entry.targetSpec or "") == "" then entry.targetSpec = TrimText(entry.playerName or "") end end entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType) elseif field == "entryType" then entry.entryType = NormalizeEntryType(value, entry.spellId) entry.triggerType, entry.actionType = NormalizeLegacyEntry(entry.triggerType, entry.actionType, entry.entryType, entry.spellId) entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType) elseif field == "targetSpec" then entry.targetSpec = TrimText(value or "") elseif field == "alertText" then entry.alertText = tostring(value or "") elseif field == "bossAbilityId" then entry.bossAbilityId = TrimText(value or "") elseif field == "bossAbilityBarName" then entry.bossAbilityBarName = NormalizeBossAbilityBarName(value or "") elseif field == "castCount" then entry.castCount = NormalizeCastCountSelector(value) else return false end self:MarkEncounterDirty(encounterId) self:GetEncounter(encounterId) self:Refresh() return true end function RT:GetSpellEntry(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end return HMGT_SpellData and HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] or nil end function RT:GetRaidCooldownSpellValues() local values = { [0] = L["OPT_RT_NO_SPELL"] or "No spell", } local seen = { [0] = true } local spells = HMGT_SpellData and HMGT_SpellData.RaidCooldowns or {} for _, entry in ipairs(spells) do local spellId = tonumber(entry and entry.spellId) if spellId and spellId > 0 and not seen[spellId] then seen[spellId] = true local spellName = GetSpellName(spellId) or tostring(entry.name or ("Spell " .. spellId)) local icon = nil if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then icon = HMGT_SpellData.GetSpellIcon(spellId) end if (not icon or icon == "") and C_Spell and type(C_Spell.GetSpellTexture) == "function" then icon = C_Spell.GetSpellTexture(spellId) end if icon and icon ~= "" then values[spellId] = string.format("|T%s:16:16:0:0|t %s (%d)", tostring(icon), spellName, spellId) else values[spellId] = string.format("%s (%d)", spellName, spellId) end end end return values end function RT:GetBossAbilityValues(encounterId) local values = { [""] = L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability", } local targetEncounterId = tonumber(encounterId) if not targetEncounterId or targetEncounterId <= 0 then return values end for _, definition in ipairs(self:GetBossAbilityDefinitions(targetEncounterId)) do values[tostring(definition.key)] = self:GetBossAbilityDisplayText(definition) end return values end function RT:GetEntryType(entry) return NormalizeEntryType(entry and entry.entryType, entry and entry.spellId) end function RT:GetTriggerType(entry) local triggerType, _ = NormalizeLegacyEntry( entry and entry.triggerType, entry and entry.actionType, entry and entry.entryType, entry and entry.spellId ) return triggerType end function RT:GetActionType(entry) local _, actionType = NormalizeLegacyEntry(entry and entry.triggerType, entry and entry.actionType, entry and entry.entryType, entry and entry.spellId) return actionType end function RT:GetTriggerTypeValues() return { time = L["OPT_RT_TRIGGER_TIME"] or "Time", bossAbility = L["OPT_RT_TRIGGER_BOSS_ABILITY"] or "Boss ability", } end function RT:GetActionTypeValues() return { text = L["OPT_RT_ACTION_TEXT"] or "Text", raidCooldown = L["OPT_RT_ACTION_RAID_COOLDOWN"] or "Raid Cooldown", } end function RT:GetEntryTypeValues() return { spell = L["OPT_RT_TYPE_SPELL"] or "Spell", text = L["OPT_RT_TYPE_TEXT"] or "Text", bossAbility = L["OPT_RT_TYPE_BOSS_ABILITY"] or "Boss ability", } end function RT:GetEntryAlertText(entry) local text = TrimText(entry and entry.alertText or "") if text == "" then return nil end return text end function RT:GetEntryDisplayText(entry) local triggerType = self:GetTriggerType(entry) local actionType = self:GetActionType(entry) local alertText = self:GetEntryAlertText(entry) if actionType == "text" and triggerType ~= "bossAbility" then return alertText or (L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected") end if triggerType == "bossAbility" then local encounterId = tonumber(entry and entry.encounterId) or tonumber(self.activeEncounterId) or 0 local definition = self:GetBossAbilityDefinition(encounterId, entry and entry.bossAbilityId) local bossAbilityText = TrimText(entry and entry.bossAbilityBarName or "") if bossAbilityText == "" then bossAbilityText = self:GetBossAbilityDisplayText(definition) end local castCount = self:FormatCastCountSelector(entry and entry.castCount) if actionType == "text" and alertText then return string.format("%s (%s %s)", alertText, L["OPT_RT_CAST"] or "Cast", castCount) elseif actionType == "raidCooldown" then local spellId = tonumber(entry and entry.spellId) or 0 local spellName = spellId > 0 and (GetSpellName(spellId) or ("Spell " .. tostring(spellId))) or (L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected") return string.format("%s -> %s (%s %s)", bossAbilityText, spellName, L["OPT_RT_CAST"] or "Cast", castCount) end return string.format("%s (%s %s)", bossAbilityText, L["OPT_RT_CAST"] or "Cast", castCount) end if alertText then return alertText end local spellId = tonumber(entry and entry.spellId) or 0 if spellId > 0 then return GetSpellName(spellId) or ("Spell " .. tostring(spellId)) end return L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected" end function RT:GetEntryIcon(entry) local triggerType = self:GetTriggerType(entry) local actionType = self:GetActionType(entry) local spellId = tonumber(entry and entry.spellId) or 0 if actionType == "raidCooldown" and spellId > 0 then if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then local icon = HMGT_SpellData.GetSpellIcon(spellId) if icon and icon ~= "" then return icon end end if C_Spell and type(C_Spell.GetSpellTexture) == "function" then local icon = C_Spell.GetSpellTexture(spellId) if icon and icon ~= "" then return icon end end end if triggerType == "bossAbility" then local encounterId = tonumber(entry and entry.encounterId) or tonumber(self.activeEncounterId) or 0 local definition = self:GetBossAbilityDefinition(encounterId, entry and entry.bossAbilityId) local icon = definition and definition.icon local numericAbilityId = tonumber(definition and definition.spellId) or 0 if (not icon or icon == "") and numericAbilityId > 0 and C_Spell and type(C_Spell.GetSpellTexture) == "function" then icon = C_Spell.GetSpellTexture(numericAbilityId) end if icon and icon ~= "" then return icon end end if actionType == "text" then return GetTextReminderIcon() end return 136243 end local function FormatTimelineClock(value) local total = math.max(0, math.floor((tonumber(value) or 0) + 0.5)) local minutes = math.floor(total / 60) local seconds = total % 60 return string.format("%02d:%02d", minutes, seconds) end function RT:FormatTimelineClock(value) return FormatTimelineClock(value) end function RT:ParseTimelineClock(value) return ParseTimelineClock(value) end function RT:GetTimelineEditorState() self.timelineEditorState = self.timelineEditorState or { encounterId = nil, viewportStart = 0, viewportDuration = 120, selectedIndex = nil, } local state = self.timelineEditorState state.viewportStart = Clamp(state.viewportStart, 0, 540, 0) state.viewportDuration = Clamp(state.viewportDuration, 30, 300, 120) return state end function RT:CloseTimelineEditor() if self.timelineEditor and self.timelineEditor.frame then self.timelineEditor.frame:Release() end self.timelineEditor = nil end function RT:GetTimelineCanvasTime(canvas, cursorX) if not canvas then return 0 end local width = math.max(1, canvas:GetWidth() or 1) local left = canvas:GetLeft() or 0 local state = self:GetTimelineEditorState() local ratio = (tonumber(cursorX) or left) - left ratio = ratio / width ratio = Clamp(ratio, 0, 1, 0) return math.floor((state.viewportStart + (ratio * state.viewportDuration)) + 0.5) end function RT:SelectTimelineEntry(index) local state = self:GetTimelineEditorState() state.selectedIndex = tonumber(index) self:RefreshTimelineEditor() end function RT:DeleteSelectedTimelineEntry() local state = self:GetTimelineEditorState() if not state.encounterId or not state.selectedIndex then return end if self:RemoveEntry(state.encounterId, state.selectedIndex) then state.selectedIndex = nil self:RefreshTimelineEditor() end end function RT:CreateTimelineEntryAtCursor(cursorX) local state = self:GetTimelineEditorState() if not self:IsLocalEditor() or not state.encounterId then return end local values = self:GetRaidCooldownSpellValues() local firstSpellId = nil for spellId in pairs(values) do if tonumber(spellId) and tonumber(spellId) > 0 and (not firstSpellId or spellId < firstSpellId) then firstSpellId = spellId end end if not firstSpellId then return end local timeSec = self:GetTimelineCanvasTime(self.timelineEditor and self.timelineEditor.canvas, cursorX) if self:AddEntry(state.encounterId, timeSec, firstSpellId, "", "") then local encounter = self:GetEncounter(state.encounterId) state.selectedIndex = encounter and #encounter.entries or nil self:RefreshTimelineEditor() end end function RT:HandleTimelineMarkerDrag(index, cursorX) local state = self:GetTimelineEditorState() if not self:IsLocalEditor() or not state.encounterId or not index then return end local encounter = self:GetEncounter(state.encounterId) local entry = encounter and encounter.entries and encounter.entries[index] if not entry then return end local newTime = self:GetTimelineCanvasTime(self.timelineEditor and self.timelineEditor.canvas, cursorX) entry.time = math.max(0, newTime) self:GetEncounter(state.encounterId) state.selectedIndex = index self:RefreshTimelineEditor() end function RT:RefreshTimelineEditorDetails() local editor = self.timelineEditor if not editor then return end local state = self:GetTimelineEditorState() local encounter = state.encounterId and self:GetEncounter(state.encounterId) or nil local entry = encounter and encounter.entries and encounter.entries[state.selectedIndex or 0] or nil local selected = entry ~= nil local triggerType = entry and self:GetTriggerType(entry) or "time" local actionType = entry and self:GetActionType(entry) or "raidCooldown" local editable = selected and self:IsLocalEditor() if editor.selectionHeader then local encounterLabel = encounter and (encounter.name ~= "" and encounter.name or (L["OPT_RT_ENCOUNTER"] or "Encounter")) or (L["OPT_RT_ENCOUNTER"] or "Encounter") local suffix = "" if entry then local displayEntry = entry if triggerType == "bossAbility" then displayEntry = {} for key, value in pairs(entry) do displayEntry[key] = value end displayEntry.encounterId = state.encounterId suffix = string.format(" - %s", self:GetEntryDisplayText(displayEntry)) else suffix = string.format(" - %s @ %s", self:GetEntryDisplayText(entry), FormatTimelineClock(entry.time)) end end local headerText = string.format("%s (%s)%s", encounterLabel, tostring(state.encounterId or "-"), suffix) editor.selectionHeader:SetText(headerText) if editor.selectionHeader.label then local stringHeight = editor.selectionHeader.label.GetStringHeight and editor.selectionHeader.label:GetStringHeight() or 0 editor.selectionHeader:SetHeight(math.max(42, math.ceil((tonumber(stringHeight) or 0) + 8))) end end if editor.timeValue then editor.timeValue:SetDisabled(not editable or triggerType == "bossAbility") editor.timeValue:SetText(entry and FormatTimelineClock(entry.time or 0) or "") if editor.timeValue.frame then editor.timeValue.frame:SetShown(triggerType ~= "bossAbility") end end if editor.triggerTypeValue then editor.triggerTypeValue:SetDisabled(not editable) editor.triggerTypeValue:SetValue(triggerType) end if editor.actionTypeValue then editor.actionTypeValue:SetDisabled(not editable) editor.actionTypeValue:SetValue(actionType) end if editor.spellValue then editor.spellValue:SetDisabled(not (editable and actionType == "raidCooldown")) editor.spellValue:SetValue(entry and math.max(0, tonumber(entry.spellId) or 0) or 0) if editor.spellValue.frame then editor.spellValue.frame:SetShown(actionType == "raidCooldown") end end if editor.castCountValue then editor.castCountValue:SetDisabled(not (editable and triggerType == "bossAbility")) editor.castCountValue:SetText(entry and self:FormatCastCountSelector(entry.castCount) or "1") if editor.castCountValue.frame then editor.castCountValue.frame:SetShown(triggerType == "bossAbility") end end if editor.bossAbilityBarNameValue then editor.bossAbilityBarNameValue:SetDisabled(not (editable and triggerType == "bossAbility")) editor.bossAbilityBarNameValue:SetText((triggerType == "bossAbility" and entry) and tostring(entry.bossAbilityBarName or "") or "") if editor.bossAbilityBarNameValue.frame then editor.bossAbilityBarNameValue.frame:SetShown(triggerType == "bossAbility") end end if editor.textValue then editor.textValue:SetDisabled(not editable or actionType ~= "text") if editor.textValue.SetLabel then editor.textValue:SetLabel(L["OPT_RT_ENTRY_TEXT"] or "Custom text") end editor.textValue:SetText((actionType == "text" and entry) and tostring(entry.alertText or "") or "") if editor.textValue.frame then editor.textValue.frame:SetShown(actionType == "text") end end if editor.targetValue then editor.targetValue:SetDisabled(not editable) if editor.targetValue.SetLabel then editor.targetValue:SetLabel((actionType == "text") and (L["OPT_RT_ENTRY_TARGETS"] or "Targets") or (L["OPT_RT_ENTRY_PLAYER"] or "Fixed player")) end editor.targetValue:SetText(entry and tostring((actionType == "text") and (entry.targetSpec or "") or (entry.playerName or "")) or "") end if editor.deleteButton then editor.deleteButton:SetDisabled(not editable) end end function RT:RefreshTimelineEditorCanvas() local editor = self.timelineEditor if not editor or not editor.canvas then return end local canvas = editor.canvas local state = self:GetTimelineEditorState() local encounter = state.encounterId and self:GetEncounter(state.encounterId) or nil local entries = encounter and encounter.entries or {} local width = math.max(1, canvas:GetWidth() or 1) local duration = math.max(30, tonumber(state.viewportDuration) or 120) local startSec = Clamp(state.viewportStart, 0, math.max(0, 600 - duration), 0) state.viewportStart = startSec if editor.viewportSlider then editor.viewportSlider:SetSliderValues(0, math.max(0, 600 - duration), 1) editor.viewportSlider:SetValue(startSec) end if editor.zoomSlider then editor.zoomSlider:SetValue(duration) end if editor.viewportLabel then editor.viewportLabel:SetText(string.format("%s - %s", FormatTimelineClock(startSec), FormatTimelineClock(startSec + duration))) end editor.markers = editor.markers or {} for _, marker in ipairs(editor.markers) do marker:Hide() end local tickSpacing = 30 local tickIndex = 1 for tick = startSec, startSec + duration, tickSpacing do local ratio = (tick - startSec) / duration local x = math.floor(ratio * (width - 20)) + 10 local line = editor.tickLines[tickIndex] local label = editor.tickLabels[tickIndex] if line and label then line:ClearAllPoints() line:SetPoint("BOTTOMLEFT", canvas, "BOTTOMLEFT", x, 26) line:SetSize(1, 28) line:Show() label:ClearAllPoints() label:SetPoint("TOP", line, "BOTTOM", 0, -2) label:SetText(FormatTimelineClock(tick)) label:Show() end tickIndex = tickIndex + 1 end for i = tickIndex, #editor.tickLines do editor.tickLines[i]:Hide() editor.tickLabels[i]:Hide() end local visibleCount = 0 for index, entry in ipairs(entries) do local timeSec = tonumber(entry.time) or 0 if self:GetTriggerType(entry) ~= "bossAbility" and timeSec >= startSec and timeSec <= startSec + duration then visibleCount = visibleCount + 1 local marker = editor.markers[visibleCount] if not marker then marker = CreateFrame("Button", nil, canvas, "BackdropTemplate") marker:SetSize(30, 30) marker:SetMovable(false) marker:RegisterForDrag("LeftButton") marker.icon = marker:CreateTexture(nil, "ARTWORK") marker.icon:SetAllPoints(marker) marker.stem = marker:CreateTexture(nil, "BORDER") marker.stem:SetColorTexture(1, 0.82, 0.15, 0.9) marker.stem:SetWidth(2) marker.border = marker:CreateTexture(nil, "OVERLAY") marker.border:SetTexture("Interface\\Buttons\\UI-Debuff-Overlays") marker.border:SetTexCoord(.296875, .5703125, 0, .515625) marker.border:SetAllPoints(marker) marker.label = marker:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") marker.label:SetPoint("TOP", marker, "BOTTOM", 0, -2) marker.label:SetTextColor(1, 0.82, 0.15) marker:SetScript("OnClick", function(button) RT:SelectTimelineEntry(button.entryIndex) end) marker:SetScript("OnEnter", function(button) local entryData = button.entryData if not entryData then return end GameTooltip:SetOwner(button, "ANCHOR_RIGHT") local alertText = RT:GetEntryAlertText(entryData) local spellId = tonumber(entryData.spellId) or 0 if alertText then GameTooltip:SetText(alertText) if spellId > 0 then GameTooltip:AddLine(GetSpellName(spellId) or ("Spell " .. tostring(spellId)), 1, 0.82, 0.15) end elseif spellId > 0 and type(GameTooltip.SetSpellByID) == "function" then GameTooltip:SetSpellByID(spellId) else GameTooltip:SetText(RT:GetEntryDisplayText(entryData)) end if RT:GetActionType(entryData) == "text" then local targets = TrimText(entryData.targetSpec or "") if targets ~= "" then GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_ENTRY_TARGETS"] or "Targets", targets), 0.85, 0.85, 0.85) end elseif RT:GetTriggerType(entryData) == "bossAbility" then local barName = TrimText(entryData.bossAbilityBarName or "") if barName == "" then local definition = RT:GetBossAbilityDefinition(RT:GetTimelineEditorState().encounterId, entryData.bossAbilityId) barName = RT:GetBossAbilityDisplayText(definition) end GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name", barName ~= "" and barName or (L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability")), 0.85, 0.85, 0.85) GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_CAST_COUNT"] or "Cast count", RT:FormatCastCountSelector(entryData.castCount)), 0.85, 0.85, 0.85) elseif entryData.playerName and entryData.playerName ~= "" then GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_ENTRY_PLAYER"] or "Fixed player", tostring(entryData.playerName)), 0.85, 0.85, 0.85) end HMGT:SafeShowTooltip(GameTooltip) end) marker:SetScript("OnLeave", function() GameTooltip:Hide() end) marker:SetScript("OnDragStart", function(button) if not RT:IsLocalEditor() then return end button.dragging = true end) marker:SetScript("OnDragStop", function(button) button.dragging = nil end) marker:SetScript("OnUpdate", function(button) if not button.dragging then return end local cursorX = GetCursorPosition() local scale = UIParent:GetEffectiveScale() or 1 RT:HandleTimelineMarkerDrag(button.entryIndex, cursorX / scale) end) editor.markers[visibleCount] = marker end marker.entryIndex = index marker.entryData = entry local ratio = (timeSec - startSec) / duration local x = math.floor(ratio * (width - 20)) + 10 marker:ClearAllPoints() marker:SetPoint("BOTTOM", canvas, "BOTTOMLEFT", x, 84) marker.stem:ClearAllPoints() marker.stem:SetPoint("TOP", marker, "CENTER", 0, 0) marker.stem:SetPoint("BOTTOM", canvas, "BOTTOMLEFT", x, 47) marker.icon:SetTexture(self:GetEntryIcon(entry)) marker.label:SetText(FormatTimelineClock(timeSec)) if state.selectedIndex == index then marker:SetBackdrop({ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12, }) marker:SetBackdropBorderColor(0.25, 1, 0.6, 1) else marker:SetBackdrop(nil) end marker:Show() end end for i = visibleCount + 1, #editor.markers do editor.markers[i]:Hide() end if editor.emptyText then editor.emptyText:SetShown(visibleCount == 0) end end function RT:RefreshTimelineEditor() if not self.timelineEditor then return end self:RefreshTimelineEditorCanvas() self:RefreshTimelineEditorDetails() end function RT:EnsureTimelineEditor() if self.timelineEditor or not AceGUI then return self.timelineEditor end local frame = AceGUI:Create("Frame") frame:SetTitle(L["OPT_RT_TIMELINE_EDITOR"] or "Raid Timeline Editor") frame:SetStatusText("") frame:SetLayout("Fill") frame:SetWidth(1080) frame:SetHeight(580) frame:EnableResize(true) frame:SetCallback("OnClose", function(widget) RT.timelineEditor = nil if widget and widget.Release then widget:Release() end end) local root = AceGUI:Create("SimpleGroup") root:SetFullWidth(true) root:SetFullHeight(true) root:SetLayout("Flow") frame:AddChild(root) local left = AceGUI:Create("SimpleGroup") left:SetWidth(760) left:SetFullHeight(true) left:SetLayout("List") root:AddChild(left) local canvasGroup = AceGUI:Create("SimpleGroup") canvasGroup:SetFullWidth(true) canvasGroup:SetHeight(300) canvasGroup:SetLayout("Fill") left:AddChild(canvasGroup) local viewportLabel = canvasGroup.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") viewportLabel:SetPoint("TOPLEFT", canvasGroup.content, "TOPLEFT", 10, -8) local canvasHolder = CreateFrame("Frame", nil, canvasGroup.content, "BackdropTemplate") canvasHolder:SetPoint("TOPLEFT", canvasGroup.content, "TOPLEFT", 8, -30) canvasHolder:SetPoint("TOPRIGHT", canvasGroup.content, "TOPRIGHT", -8, -30) canvasHolder:SetHeight(250) canvasHolder:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12, insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) canvasHolder:SetBackdropBorderColor(1, 0.82, 0.15, 0.55) canvasHolder:EnableMouse(true) canvasHolder:EnableMouseWheel(true) local axis = canvasHolder:CreateTexture(nil, "BACKGROUND") axis:SetColorTexture(1, 0.82, 0.15, 0.9) axis:SetPoint("BOTTOMLEFT", canvasHolder, "BOTTOMLEFT", 10, 44) axis:SetPoint("BOTTOMRIGHT", canvasHolder, "BOTTOMRIGHT", -10, 44) axis:SetHeight(3) local emptyText = canvasHolder:CreateFontString(nil, "OVERLAY", "GameFontDisable") emptyText:SetPoint("CENTER", canvasHolder, "CENTER", 0, 20) emptyText:SetText(L["OPT_RT_TIMELINE_EMPTY_WINDOW"] or "No cooldowns in the current window.") local tickLines, tickLabels = {}, {} for i = 1, 12 do local line = canvasHolder:CreateTexture(nil, "BORDER") line:SetColorTexture(1, 1, 1, 0.2) line:Hide() tickLines[i] = line local label = canvasHolder:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") label:SetTextColor(1, 0.82, 0.15) label:Hide() tickLabels[i] = label end canvasHolder:SetScript("OnMouseDown", function(_, button) if button ~= "LeftButton" then return end local cursorX = GetCursorPosition() local scale = UIParent:GetEffectiveScale() or 1 RT:CreateTimelineEntryAtCursor(cursorX / scale) end) canvasHolder:SetScript("OnMouseWheel", function(_, delta) local state = RT:GetTimelineEditorState() if IsControlKeyDown() then state.viewportDuration = Clamp(state.viewportDuration - (delta * 15), 30, 300, state.viewportDuration) else state.viewportStart = Clamp(state.viewportStart - (delta * 15), 0, math.max(0, 600 - state.viewportDuration), state.viewportStart) end RT:RefreshTimelineEditor() end) local viewportSlider = AceGUI:Create("Slider") viewportSlider:SetLabel(L["OPT_RT_TIMELINE_SCROLL"] or "Scroll timeline") viewportSlider:SetSliderValues(0, 480, 1) viewportSlider:SetValue(0) viewportSlider:SetWidth(360) viewportSlider:SetCallback("OnValueChanged", function(_, _, value) RT:GetTimelineEditorState().viewportStart = math.floor((tonumber(value) or 0) + 0.5) RT:RefreshTimelineEditor() end) left:AddChild(viewportSlider) local zoomSlider = AceGUI:Create("Slider") zoomSlider:SetLabel(L["OPT_RT_TIMELINE_ZOOM"] or "Zoom") zoomSlider:SetSliderValues(30, 300, 15) zoomSlider:SetValue(120) zoomSlider:SetWidth(300) zoomSlider:SetCallback("OnValueChanged", function(_, _, value) RT:GetTimelineEditorState().viewportDuration = math.floor((tonumber(value) or 120) + 0.5) RT:RefreshTimelineEditor() end) left:AddChild(zoomSlider) local hint = AceGUI:Create("Label") hint:SetFullWidth(true) hint:SetText(L["OPT_RT_TIMELINE_HINT"] or "Click the bar to add a cooldown. Drag markers horizontally to change the time. Mousewheel scrolls, Ctrl+Mousewheel zooms.") left:AddChild(hint) local right = AceGUI:Create("InlineGroup") right:SetTitle(L["OPT_RT_ASSIGNMENT_EDITOR"] or "Assignment") right:SetWidth(280) right:SetFullHeight(true) right:SetLayout("Fill") root:AddChild(right) local rightScroll = AceGUI:Create("ScrollFrame") rightScroll:SetLayout("List") right:AddChild(rightScroll) local selectionHeader = AceGUI:Create("Label") selectionHeader:SetFullWidth(true) selectionHeader:SetHeight(42) selectionHeader:SetText(L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected") if selectionHeader.label then selectionHeader.label:SetJustifyH("LEFT") selectionHeader.label:SetJustifyV("TOP") end rightScroll:AddChild(selectionHeader) local timeValue = AceGUI:Create("EditBox") timeValue:SetLabel(L["OPT_RT_ENTRY_TIME"] or "Time") timeValue:SetFullWidth(true) timeValue:SetCallback("OnEnterPressed", function(widget, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "time", value) RT:RefreshTimelineEditor() end widget:ClearFocus() end) rightScroll:AddChild(timeValue) local triggerTypeValue = AceGUI:Create("Dropdown") triggerTypeValue:SetLabel(L["OPT_RT_TRIGGER"] or "Trigger") triggerTypeValue:SetFullWidth(true) triggerTypeValue:SetList(RT:GetTriggerTypeValues()) triggerTypeValue:SetCallback("OnValueChanged", function(_, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "triggerType", value) RT:RefreshTimelineEditor() end end) rightScroll:AddChild(triggerTypeValue) local actionTypeValue = AceGUI:Create("Dropdown") actionTypeValue:SetLabel(L["OPT_RT_ACTION"] or "Action") actionTypeValue:SetFullWidth(true) actionTypeValue:SetList(RT:GetActionTypeValues()) actionTypeValue:SetCallback("OnValueChanged", function(_, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "actionType", value) RT:RefreshTimelineEditor() end end) rightScroll:AddChild(actionTypeValue) local spellValue = AceGUI:Create("Dropdown") spellValue:SetLabel(L["OPT_RT_ENTRY_SPELL"] or "Spell") spellValue:SetFullWidth(true) spellValue:SetList(RT:GetRaidCooldownSpellValues()) spellValue:SetCallback("OnValueChanged", function(_, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "spellId", value) RT:RefreshTimelineEditor() end end) rightScroll:AddChild(spellValue) local castCountValue = AceGUI:Create("EditBox") castCountValue:SetLabel(string.format( "%s (1 / %s / %s / %s)", L["OPT_RT_CAST_COUNT"] or "Cast count", L["OPT_RT_CAST_ALL"] or "All", L["OPT_RT_CAST_ODD"] or "Odd", L["OPT_RT_CAST_EVEN"] or "Even" )) castCountValue:SetFullWidth(true) castCountValue:SetCallback("OnEnterPressed", function(widget, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "castCount", value) RT:RefreshTimelineEditor() end widget:ClearFocus() end) rightScroll:AddChild(castCountValue) local bossAbilityBarNameValue = AceGUI:Create("EditBox") bossAbilityBarNameValue:SetLabel(L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name") bossAbilityBarNameValue:SetFullWidth(true) bossAbilityBarNameValue:SetCallback("OnEnterPressed", function(widget, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "bossAbilityBarName", value) RT:RefreshTimelineEditor() end widget:ClearFocus() end) rightScroll:AddChild(bossAbilityBarNameValue) local textValue = AceGUI:Create("EditBox") textValue:SetLabel(L["OPT_RT_ENTRY_TEXT"] or "Custom text") textValue:SetFullWidth(true) textValue:SetCallback("OnEnterPressed", function(widget, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then RT:SetEntryField(state.encounterId, state.selectedIndex, "alertText", value) RT:RefreshTimelineEditor() end widget:ClearFocus() end) rightScroll:AddChild(textValue) local targetValue = AceGUI:Create("EditBox") targetValue:SetLabel(L["OPT_RT_ENTRY_PLAYER"] or "Fixed player") targetValue:SetFullWidth(true) targetValue:SetCallback("OnEnterPressed", function(widget, _, value) local state = RT:GetTimelineEditorState() if state.encounterId and state.selectedIndex then local encounter = RT:GetEncounter(state.encounterId) local entry = encounter and encounter.entries and encounter.entries[state.selectedIndex] or nil local field = (entry and RT:GetActionType(entry) == "text") and "targetSpec" or "playerName" RT:SetEntryField(state.encounterId, state.selectedIndex, field, value) RT:RefreshTimelineEditor() end widget:ClearFocus() end) rightScroll:AddChild(targetValue) local deleteButton = AceGUI:Create("Button") deleteButton:SetText(REMOVE or "Remove") deleteButton:SetFullWidth(true) deleteButton:SetCallback("OnClick", function() RT:DeleteSelectedTimelineEntry() end) rightScroll:AddChild(deleteButton) self.timelineEditor = { frame = frame, canvas = canvasHolder, viewportLabel = viewportLabel, viewportSlider = viewportSlider, zoomSlider = zoomSlider, selectionHeader = selectionHeader, timeValue = timeValue, triggerTypeValue = triggerTypeValue, actionTypeValue = actionTypeValue, spellValue = spellValue, castCountValue = castCountValue, bossAbilityBarNameValue = bossAbilityBarNameValue, textValue = textValue, targetValue = targetValue, deleteButton = deleteButton, tickLines = tickLines, tickLabels = tickLabels, emptyText = emptyText, markers = {}, } return self.timelineEditor end function RT:OpenTimelineEditor(encounterId) if not AceGUI then return end local encounter = self:GetEncounter(encounterId) if not encounter then return end local state = self:GetTimelineEditorState() state.encounterId = tonumber(encounterId) state.selectedIndex = nil state.viewportStart = Clamp(state.viewportStart, 0, 540, 0) local editor = self:EnsureTimelineEditor() if not editor then return end editor.frame:SetTitle(string.format("%s: %s (%d)", L["OPT_RT_TIMELINE_EDITOR"] or "Raid Timeline Editor", encounter.name ~= "" and encounter.name or (L["OPT_RT_ENCOUNTER"] or "Encounter"), tonumber(encounterId) or 0)) self:RefreshTimelineEditor() end function RT:GetAlertFontPath() local settings = self:GetSettings() if LSM and settings and type(LSM.Fetch) == "function" then local fontPath = LSM:Fetch("font", settings.alertFont, true) if fontPath and fontPath ~= "" then return fontPath end end return STANDARD_TEXT_FONT end function RT:SaveAlertPosition() local frame = self.alertFrame local settings = self:GetSettings() if not frame or not settings 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 settings.alertPosX = math.floor(frameCenterX - parentCenterX + 0.5) settings.alertPosY = math.floor(frameCenterY - parentCenterY + 0.5) end function RT:ApplyAlertStyle() local frame = self.alertFrame local settings = self:GetSettings() if not frame or not settings then return end frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", settings.alertPosX or 0, settings.alertPosY or 180) frame:EnableMouse(settings.unlocked == true) if frame.handle then frame.handle:SetShown(settings.unlocked == true) end if frame.text then frame.text:SetFont(self:GetAlertFontPath(), settings.alertFontSize or 30, settings.alertFontOutline or "OUTLINE") frame.text:SetTextColor(settings.alertColor.r, settings.alertColor.g, settings.alertColor.b, settings.alertColor.a) end end function RT:EnsureAlertFrame() if self.alertFrame then return self.alertFrame end local frame = CreateFrame("Frame", "HMGT_RaidTimelineAlertFrame", UIParent, "BackdropTemplate") frame:SetSize(640, 120) frame:SetFrameStrata("FULLSCREEN_DIALOG") frame:SetFrameLevel(200) frame:SetClampedToScreen(true) frame:SetMovable(true) frame:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function(selfFrame) if RT:GetSettings().unlocked == true then selfFrame:StartMoving() end end) frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() RT:SaveAlertPosition() RT:ApplyAlertStyle() end) frame:Hide() local handle = frame:CreateTexture(nil, "BACKGROUND") handle:SetAllPoints(frame) handle:SetColorTexture(0, 0, 0, 0.35) frame.handle = handle local text = frame:CreateFontString(nil, "OVERLAY") text:SetPoint("CENTER", frame, "CENTER", 0, 0) text:SetPoint("LEFT", frame, "LEFT", 18, 0) text:SetPoint("RIGHT", frame, "RIGHT", -18, 0) text:SetJustifyH("CENTER") text:SetJustifyV("MIDDLE") frame.text = text self.alertFrame = frame self:ApplyAlertStyle() if frame.text then frame.text:SetText(L["OPT_RT_ALERT_PREVIEW"] or "Tranquility in 5") end return frame end function RT:ShowPreview() local frame = self:EnsureAlertFrame() if not frame then return end self.previewAlertActive = true frame:Show() if frame.text then frame.text:SetText(L["OPT_RT_ALERT_PREVIEW"] or "Tranquility in 5") end end function RT:HideAlert() self.previewAlertActive = false self.currentAlert = nil if not self.alertFrame then return end if self:GetSettings().unlocked == true then self:ApplyAlertStyle() self.alertFrame:Show() if self.alertFrame.text then self.alertFrame.text:SetText(L["OPT_RT_ALERT_UNLOCKED_HINT"] or "Raid Timeline Alert\nDrag to move") end else self.alertFrame:Hide() end end function RT:ShowCountdownAlert(spellId, secondsRemaining, alertText) local frame = self:EnsureAlertFrame() if not frame then return end self.currentAlert = { spellId = math.max(0, tonumber(spellId) or 0), alertText = tostring(alertText or ""), endTime = GetTime() + math.max(0, tonumber(secondsRemaining) or 0), hideTime = GetTime() + math.max(0, tonumber(secondsRemaining) or 0) + 1, } self:UpdateCurrentAlert(true) end local function GetAlertSpellLabel(spellId) local sid = tonumber(spellId) or 0 if sid <= 0 then return nil end local icon = RT:GetEntryIcon({ spellId = sid, actionType = "raidCooldown", triggerType = "time" }) local spellName = GetSpellName(sid) or ("Spell " .. tostring(sid)) if icon and icon ~= "" then return string.format("|T%s:22:22:0:0:64:64:5:59:5:59|t %s", tostring(icon), spellName) end return spellName end local function GetAlertTextLabel(alertText) local text = TrimText(alertText) if text == "" then return nil end return text end function RT:UpdateCurrentAlert(forceShow) local alert = self.currentAlert if not alert then return end local frame = self:EnsureAlertFrame() local now = GetTime() local remaining = (tonumber(alert.endTime) or 0) - now local displayValue = math.ceil(math.max(0, remaining)) local label = nil if (tonumber(alert.spellId) or 0) > 0 then label = GetAlertSpellLabel(alert.spellId) end if not label or label == "" then label = GetAlertTextLabel(alert.alertText) or self:GetEntryDisplayText({ spellId = alert.spellId, alertText = alert.alertText, actionType = "text", triggerType = "time" }) end if frame and frame.text then if remaining <= 0.05 then frame.text:SetText(label) else frame.text:SetText(string.format(L["OPT_RT_ALERT_TEMPLATE"] or "%s in %ds", label, displayValue)) end end if frame and (forceShow or not frame:IsShown()) then frame:Show() end if now >= (tonumber(alert.hideTime) or 0) then self:HideAlert() end end function RT:GetRosterEntries() local entries = {} local seen = {} local function addUnit(unitId, rosterIndex, subgroup) if not unitId or not UnitExists(unitId) or not UnitIsPlayer(unitId) then return end local name = NormalizeName(UnitName(unitId)) if not name or seen[name] then return end seen[name] = true entries[#entries + 1] = { name = name, index = tonumber(rosterIndex) or (#entries + 1), subgroup = tonumber(subgroup) or 1, unitId = unitId, } end if IsInRaid() then for i = 1, GetNumGroupMembers() do local _, _, subgroup = GetRaidRosterInfo(i) addUnit("raid" .. i, i, subgroup) end else addUnit("player", 1, 1) if IsInGroup() then for i = 1, GetNumSubgroupMembers() do addUnit("party" .. i, i + 1, 1) end end end table.sort(entries, function(a, b) return (tonumber(a.index) or 0) < (tonumber(b.index) or 0) end) return entries end function RT:GetRosterPlayers() local players = {} for _, entry in ipairs(self:GetRosterEntries()) do players[#players + 1] = entry.name end return players end function RT:GetResolvedTargetPlayers(targetSpec) local spec = tostring(targetSpec or "") spec = spec:gsub("^%s+", ""):gsub("%s+$", "") if spec == "" then return {} end local roster = self:GetRosterEntries() local selected = {} local seen = {} local function addName(name) local normalized = NormalizeName(name) if normalized and normalized ~= "" and not seen[normalized] and HMGT:IsPlayerInCurrentGroup(normalized) then seen[normalized] = true selected[#selected + 1] = normalized end end local function addEntry(entry) if entry and entry.name then addName(entry.name) end end local function addNumericTarget(startValue, endValue) local fromValue = math.min(tonumber(startValue) or 0, tonumber(endValue) or 0) local toValue = math.max(tonumber(startValue) or 0, tonumber(endValue) or 0) for _, entry in ipairs(roster) do local rosterValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index) if rosterValue and rosterValue >= fromValue and rosterValue <= toValue then addEntry(entry) end end end for token in string.gmatch(spec, "([^,]+)") do local trimmed = tostring(token or ""):gsub("^%s+", ""):gsub("%s+$", "") local lower = trimmed:lower() if trimmed ~= "" then if lower == "all" then for _, entry in ipairs(roster) do addEntry(entry) end elseif lower == "groupodd" then for _, entry in ipairs(roster) do local groupValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index) if ((groupValue or 0) % 2) == 1 then addEntry(entry) end end elseif lower == "groupeven" then for _, entry in ipairs(roster) do local groupValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index) if ((groupValue or 0) % 2) == 0 and (groupValue or 0) > 0 then addEntry(entry) end end elseif lower == "odd" then for _, entry in ipairs(roster) do if ((tonumber(entry.index) or 0) % 2) == 1 then addEntry(entry) end end elseif lower == "even" then for _, entry in ipairs(roster) do if ((tonumber(entry.index) or 0) % 2) == 0 then addEntry(entry) end end else local groupNumber = trimmed:match("^[Gg]roup(%d+)$") if groupNumber then addNumericTarget(groupNumber, groupNumber) else local rangeStart, rangeEnd = trimmed:match("^(%d+)%s*%-%s*(%d+)$") if rangeStart and rangeEnd then addNumericTarget(rangeStart, rangeEnd) else local num = tonumber(trimmed) if num then addNumericTarget(num, num) else addName(trimmed) end end end end end end table.sort(selected, function(a, b) local aIndex, bIndex = 999, 999 for _, entry in ipairs(roster) do if entry.name == a then aIndex = tonumber(entry.index) or 999 end if entry.name == b then bIndex = tonumber(entry.index) or 999 end end if aIndex ~= bIndex then return aIndex < bIndex end return tostring(a) < tostring(b) end) return selected end function RT:GetTextTargetPlayers(targetSpec) return self:GetResolvedTargetPlayers(targetSpec) end function RT:IsSpellReadyForPlayer(playerName, spellId) local name = NormalizeName(playerName) local sid = tonumber(spellId) if not name or not sid or sid <= 0 then return false end if not HMGT:IsPlayerInCurrentGroup(name) then return false end if not HMGT:IsTrackedSpellKnownForPlayer(name, sid) then return false end local spellEntry = self:GetSpellEntry(sid) local remaining, _, currentCharges, maxCharges = HMGT:GetCooldownInfo(name, sid) if spellEntry and HMGT:IsAvailabilitySpell(spellEntry) then return (tonumber(maxCharges) or 0) > 0 and (tonumber(currentCharges) or 0) >= (tonumber(maxCharges) or 0) end if (tonumber(maxCharges) or 0) > 0 then return (tonumber(currentCharges) or 0) > 0 end return (tonumber(remaining) or 0) <= 0 end function RT:GetReadyCandidates(spellId) local candidates = {} for _, playerName in ipairs(self:GetRosterPlayers()) do if self:IsSpellReadyForPlayer(playerName, spellId) then candidates[#candidates + 1] = playerName end end table.sort(candidates) return candidates end function RT:IsLocalEditor() return HMGT.IsRaidTimelineEditor and HMGT:IsRaidTimelineEditor() or true end function RT:IsAuthorizedSender(playerName) return HMGT.IsRaidTimelineEditor and HMGT:IsRaidTimelineEditor(playerName) or false end function RT:GetBossAbilityDispatchKey(entryIndex, castCount) return string.format("%s:%s", tostring(entryIndex or 0), tostring(math.max(1, tonumber(castCount) or 1))) end function RT:LogBossAbilityProbe(entryIndex, entry, stateKey, fmt, ...) self._bossAbilityProbeStates = self._bossAbilityProbeStates or {} local encounterId = tonumber(self.activeEncounterId) or 0 local slot = tonumber(entryIndex) or 0 local cacheKey = string.format("%d:%d", encounterId, slot) if self._bossAbilityProbeStates[cacheKey] == stateKey then return end self._bossAbilityProbeStates[cacheKey] = stateKey HMGT:DebugScoped("verbose", "RaidTimeline", fmt, ...) end function RT:ProbeBossAbilityEntry(entryIndex, entry) -- Bossmod-specific matching lives in separate integration files. end function RT:GetNextBossAbilityCount(bossAbilityId, explicitCount) local numericCount = tonumber(explicitCount) if numericCount and numericCount > 0 then return math.floor(numericCount + 0.5) end self.activeBossAbilityCounts = self.activeBossAbilityCounts or {} local key = tostring(bossAbilityId or "") self.activeBossAbilityCounts[key] = math.max(0, tonumber(self.activeBossAbilityCounts[key]) or 0) + 1 return self.activeBossAbilityCounts[key] end function RT:GetWhisperTargetForPlayer(playerName) local unitId = HMGT.GetUnitForPlayer and HMGT:GetUnitForPlayer(playerName) or nil if unitId and UnitExists(unitId) then local fullName = GetUnitName(unitId, true) if fullName and fullName ~= "" then return fullName end end return playerName end function RT:SelectAssignedPlayer(encounterId, entry, entryIndex) local spellId = tonumber(entry and entry.spellId) or 0 local desired = NormalizeName(entry and entry.playerName) local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "") if spellId <= 0 then return nil end local candidates = self:GetReadyCandidates(spellId) local targetPlayers = self:GetResolvedTargetPlayers(targetSpec) local isSingleExactTarget = desired and desired ~= "" and targetSpec ~= "" and not targetSpec:find(",") and #targetPlayers == 1 and targetPlayers[1] == desired if #candidates == 0 then HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=0 selected=nil", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(desired or ""), tostring(targetSpec) ) return nil end if isSingleExactTarget then for i = 1, #candidates do if candidates[i] == desired then HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-exact", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(desired), tostring(targetSpec), table.concat(candidates, ", "), tostring(candidates[i]) ) return candidates[i] end end for i = 1, #candidates do if candidates[i] > desired then HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-fallback-next", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(desired), tostring(targetSpec), table.concat(candidates, ", "), tostring(candidates[i]) ) return candidates[i] end end HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-fallback-wrap", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(desired), tostring(targetSpec), table.concat(candidates, ", "), tostring(candidates[1]) ) return candidates[1] end if #targetPlayers > 0 then local allowed = {} local filtered = {} for _, playerName in ipairs(targetPlayers) do allowed[playerName] = true end for _, playerName in ipairs(candidates) do if allowed[playerName] then filtered[#filtered + 1] = playerName end end if #filtered > 0 then local hash = StableHash(encounterId, spellId, targetSpec, tonumber(entry.time) or 0, tonumber(entryIndex) or 0) local selected = filtered[(hash % #filtered) + 1] HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s filtered=%s hash=%s selected=%s mode=target-filter", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(targetSpec), table.concat(candidates, ", "), table.concat(filtered, ", "), tostring(hash), tostring(selected) ) return selected end HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s filtered=0 selected=nil mode=target-filter-empty", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(targetSpec), table.concat(candidates, ", ") ) return nil end local hash = StableHash(encounterId, spellId, tonumber(entry.time) or 0, tonumber(entryIndex) or 0) local selected = candidates[(hash % #candidates) + 1] HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s hash=%s selected=%s mode=hash", tostring(encounterId), tostring(entryIndex), tostring(spellId), tostring(targetSpec), table.concat(candidates, ", "), tostring(hash), tostring(selected) ) return selected end function RT:GetAssignedPlayersForEntry(encounterId, entry, entryIndex) local actionType = self:GetActionType(entry) if actionType == "text" then local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "") if targetSpec == "" then return self:GetRosterPlayers() end return self:GetResolvedTargetPlayers(targetSpec) elseif self:GetTriggerType(entry) == "bossAbility" and actionType == "raidCooldown" then local selected = self:SelectAssignedPlayer(encounterId, entry, entryIndex) return selected and { selected } or {} elseif self:GetTriggerType(entry) == "bossAbility" then local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "") if targetSpec == "" then return self:GetRosterPlayers() end return self:GetResolvedTargetPlayers(targetSpec) end local selected = self:SelectAssignedPlayer(encounterId, entry, entryIndex) return selected and { selected } or {} end function RT:StoreAssignment(encounterId, timeSec, spellId, leadTime, alertText) local encounterKey = tonumber(encounterId) local timeValue = tonumber(timeSec) local spellValue = math.max(0, tonumber(spellId) or 0) local leadValue = tonumber(leadTime) local textValue = tostring(alertText or "") if not encounterKey or not timeValue or not leadValue then return end self.receivedAssignments = self.receivedAssignments or {} self.receivedAssignments[encounterKey] = self.receivedAssignments[encounterKey] or {} local assignments = self.receivedAssignments[encounterKey] local key = string.format("%d:%d:%s", timeValue, spellValue, self:EncodeCommText(textValue)) assignments[key] = { time = timeValue, spellId = spellValue, leadTime = leadValue, alertText = textValue, } end function RT:DispatchEntryAssignments(encounterId, entry, entryIndex, relativeTime) local leadTime = tonumber(self:GetSettings().leadTime) or 5 local dispatchTime = math.max(0, tonumber(relativeTime) or tonumber(entry and entry.time) or 0) local assignedPlayers = self:GetAssignedPlayersForEntry(encounterId, entry, entryIndex) local alertText = self:GetEntryAlertText(entry) or "" for _, assignedPlayer in ipairs(assignedPlayers) do HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline assign encounter=%s slot=%s time=%s trigger=%s action=%s spellId=%s text=%s player=%s target=%s", tostring(encounterId), tostring(entryIndex), tostring(dispatchTime), tostring(self:GetTriggerType(entry)), tostring(self:GetActionType(entry)), tostring(entry.spellId), tostring(alertText), tostring(assignedPlayer), tostring(entry.targetSpec or entry.playerName or "") ) if NormalizeName(assignedPlayer) == GetOwnName() then self:StoreAssignment(encounterId, dispatchTime, entry.spellId, leadTime, alertText) else local target = self:GetWhisperTargetForPlayer(assignedPlayer) if target and target ~= "" then HMGT:SendDirectMessage( string.format("%s|%d|%d|%d|%d|%s", GetTimelineMessagePrefix(), tonumber(encounterId) or 0, dispatchTime, math.max(0, tonumber(entry.spellId) or 0), leadTime, self:EncodeCommText(alertText)), target, "ALERT" ) end end end end function RT:DispatchAssignmentsForEncounter(encounterId) if not self:IsLocalEditor() then return end local schedule = self:GetEncounter(encounterId) if not schedule or type(schedule.entries) ~= "table" then return end for index, entry in ipairs(schedule.entries) do if self:GetTriggerType(entry) == "bossAbility" then self:DispatchEntryAssignments(encounterId, entry, index, entry.time) end end end function RT:TryDispatchBossAbilityEntry(entryIndex, entry, castCount, secondsUntilCast) if not self:IsLocalEditor() or not self.activeEncounterId or self:GetTriggerType(entry) ~= "bossAbility" then return end local currentCount = math.max(1, tonumber(castCount) or 1) local desiredSelector = self:NormalizeCastCountSelector(entry and entry.castCount) if not self:DoesCastCountSelectorMatch(desiredSelector, currentCount) then return end local warningLeadTime = tonumber(self:GetSettings().leadTime) or 5 local secondsRemaining = math.max(0, tonumber(secondsUntilCast) or 0) if secondsRemaining > warningLeadTime then self:LogBossAbilityProbe( entryIndex, entry, string.format("armed:%s:%d", tostring(desiredSelector), tonumber(currentCount) or 1), "RaidTimeline boss ability armed encounter=%s slot=%s selector=%s cast=%s remaining=%.1f lead=%.1f", tostring(self.activeEncounterId), tostring(entryIndex), tostring(self:FormatCastCountSelector(desiredSelector)), tostring(currentCount), tonumber(secondsRemaining) or 0, tonumber(warningLeadTime) or 0 ) return end self.activeBossAbilityDispatches = self.activeBossAbilityDispatches or {} local dispatchKey = self:GetBossAbilityDispatchKey(entryIndex, currentCount) if self.activeBossAbilityDispatches[dispatchKey] then return end self.activeBossAbilityDispatches[dispatchKey] = true local elapsed = math.max(0, GetTime() - (tonumber(self.activeEncounterStartTime) or GetTime())) local dispatchTime = elapsed + secondsRemaining HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline boss ability match encounter=%s slot=%s ability=%s selector=%s cast=%s triggerIn=%.1f lead=%.1f", tostring(self.activeEncounterId), tostring(entryIndex), tostring(entry.bossAbilityId), tostring(self:FormatCastCountSelector(desiredSelector)), tostring(currentCount), tonumber(secondsRemaining) or 0, tonumber(warningLeadTime) or 0 ) self:DispatchEntryAssignments(self.activeEncounterId, entry, entryIndex, dispatchTime) end function RT:HandleAssignmentComm(senderName, encounterId, timeSec, spellId, leadTime, alertText) if not self:IsAuthorizedSender(senderName) then HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline ignored assignment sender=%s encounter=%s time=%s spellId=%s", tostring(senderName), tostring(encounterId), tostring(timeSec), tostring(spellId) ) return end local decodedText = self:DecodeCommText(alertText) HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline received assignment sender=%s encounter=%s time=%s spellId=%s lead=%s text=%s", tostring(senderName), tostring(encounterId), tostring(timeSec), tostring(spellId), tostring(leadTime), tostring(decodedText) ) self:StoreAssignment(encounterId, timeSec, spellId, leadTime, decodedText) end function RT:HandleTestStartComm(senderName, encounterId, difficultyId, serverStartTime, duration) if not self:IsAuthorizedSender(senderName) then HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline ignored test sender=%s encounter=%s", tostring(senderName), tostring(encounterId) ) return end local id = tonumber(encounterId) if not id or id <= 0 then return end self.testActive = true self.testEncounterId = id self.testDuration = math.max(0, tonumber(duration) or 0) self:StartEncounter(id, difficultyId) if tonumber(self.activeEncounterId) ~= id then self.testActive = false self.testEncounterId = nil self.testDuration = nil return end local serverNow = tonumber(GetServerTime and GetServerTime() or 0) or 0 local startedAt = tonumber(serverStartTime) or serverNow local elapsed = math.max(0, serverNow - startedAt) self.activeEncounterStartTime = GetTime() - elapsed HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline remote test start sender=%s encounter=%s difficulty=%s elapsed=%.1f duration=%s", tostring(senderName), tostring(id), tostring(GetDifficultyKey(difficultyId) or difficultyId), tonumber(elapsed) or 0, tostring(self.testDuration) ) end function RT:StartRuntime() self.runtimeEnabled = true if not self.eventFrame then self.eventFrame = CreateFrame("Frame") self.eventFrame:SetScript("OnEvent", function(_, event, ...) RT:HandleEvent(event, ...) end) end self.eventFrame:RegisterEvent("ENCOUNTER_START") self.eventFrame:RegisterEvent("ENCOUNTER_END") self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") self.eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE") if not self.ticker then self.ticker = C_Timer.NewTicker(0.1, function() RT:HandleDueEntries() end) end self:EnsureAlertFrame() self:ApplyAlertStyle() self:HandleDueEntries() end function RT:StopRuntime() self.runtimeEnabled = false if self.eventFrame then self.eventFrame:UnregisterAllEvents() end if self.ticker then self.ticker:Cancel() self.ticker = nil end self.activeEncounterId = nil self.activeEncounterStartTime = nil self.activeSchedule = nil self.firedEntries = nil self.currentAlert = nil self.activeBossAbilityDispatches = nil self.activeBossAbilityCounts = nil self.dispatchedEntries = nil self.testActive = false self.testEncounterId = nil self.testDuration = nil if self.alertFrame then self.alertFrame:Hide() end self:CloseTimelineEditor() end function RT:OnInitialize() HMGT.RaidTimeline = self end function RT:OnEnable() self:StartRuntime() end function RT:OnDisable() self:StopRuntime() end function RT:StartEncounter(encounterId, difficultyId) self.activeEncounterId = tonumber(encounterId) self.activeEncounterDifficulty = GetDifficultyKey(difficultyId) self.activeEncounterStartTime = GetTime() self.activeSchedule = self:GetEncounter(encounterId) if self.activeSchedule and not self:IsEncounterEnabledForDifficulty(self.activeSchedule, difficultyId) then HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline encounter skipped encounter=%s difficulty=%s", tostring(self.activeEncounterId), tostring(difficultyId) ) self.activeSchedule = nil self.activeEncounterId = nil self.activeEncounterDifficulty = nil self.activeEncounterStartTime = nil self.firedEntries = nil return end self.firedEntries = {} self.dispatchedEntries = {} self.currentAlert = nil self.activeBossAbilityDispatches = {} self.activeBossAbilityCounts = {} self._bossAbilityProbeStates = {} self.receivedAssignments = self.receivedAssignments or {} self.receivedAssignments[self.activeEncounterId] = {} HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline encounter start encounter=%s difficulty=%s leader=%s entries=%s", tostring(self.activeEncounterId), tostring(self.activeEncounterDifficulty or difficultyId), tostring(self:IsLocalEditor()), tostring(self.activeSchedule and #self.activeSchedule.entries or 0) ) end function RT:StopEncounter() self.activeEncounterId = nil self.activeEncounterDifficulty = nil self.activeEncounterStartTime = nil self.activeSchedule = nil self.firedEntries = nil self.dispatchedEntries = nil self.activeBossAbilityDispatches = nil self.activeBossAbilityCounts = nil self._bossAbilityProbeStates = nil self.testActive = false self.testEncounterId = nil self.testDuration = nil self:HideAlert() end function RT:GetEncounterTestDuration(encounterId) local schedule = self:GetEncounter(encounterId) local maxTime = 0 if schedule and type(schedule.entries) == "table" then for _, entry in ipairs(schedule.entries) do if self:GetTriggerType(entry) ~= "bossAbility" then maxTime = math.max(maxTime, tonumber(entry.time) or 0) end end end local leadTime = tonumber(self:GetSettings().leadTime) or 5 return math.max(15, maxTime + leadTime + 3) end function RT:IsTestRunning(encounterId) local id = tonumber(encounterId) return self.testActive == true and id ~= nil and tonumber(self.testEncounterId) == id end function RT:StartEncounterTest(encounterId) local id = tonumber(encounterId) if not id or id <= 0 then return false end local schedule = self:GetEncounter(id) if not schedule then return false end if self.activeEncounterId then self:StopEncounter() end local difficultyId = GetPreferredTestDifficultyId(schedule) self.testActive = true self.testEncounterId = id self.testDuration = self:GetEncounterTestDuration(id) HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline test start encounter=%s difficulty=%s duration=%s", tostring(id), tostring(GetDifficultyKey(difficultyId) or difficultyId), tostring(self.testDuration) ) self:StartEncounter(id, difficultyId) if tonumber(self.activeEncounterId) ~= id then self.testActive = false self.testEncounterId = nil self.testDuration = nil return false end if IsInGroup() or IsInRaid() then HMGT:SendGroupMessage( string.format( "%s|%d|%d|%d|%d", GetTimelineTestMessagePrefix(), id, tonumber(difficultyId) or 0, tonumber(GetServerTime and GetServerTime() or 0) or 0, tonumber(self.testDuration) or 0 ), "ALERT" ) end self:Refresh() return true end function RT:StopEncounterTest() if self.testActive then HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline test stop encounter=%s", tostring(self.testEncounterId or self.activeEncounterId) ) end self:StopEncounter() end function RT:HandleDueEntries() if not self.runtimeEnabled or (self:GetSettings().enabled ~= true and not self.testActive) then self:HideAlert() return end if self.previewAlertActive == true then self:ApplyAlertStyle() return end if not self.activeEncounterId or not self.activeEncounterStartTime then self:UpdateCurrentAlert() return end local assignments = self.receivedAssignments and self.receivedAssignments[self.activeEncounterId] if type(assignments) ~= "table" then self:UpdateCurrentAlert() return end local elapsed = GetTime() - self.activeEncounterStartTime if self:IsLocalEditor() and self.activeSchedule and type(self.activeSchedule.entries) == "table" then self.dispatchedEntries = self.dispatchedEntries or {} local assignmentLeadTime = tonumber(self:GetSettings().assignmentLeadTime) or tonumber(self:GetSettings().leadTime) or 5 for index, entry in ipairs(self.activeSchedule.entries) do if self:GetTriggerType(entry) == "bossAbility" then self:ProbeBossAbilityEntry(index, entry) elseif self.dispatchedEntries[index] ~= true then local assignAt = math.max(0, (tonumber(entry.time) or 0) - assignmentLeadTime) if elapsed >= assignAt then self.dispatchedEntries[index] = true HMGT:DebugScoped( "verbose", "RaidTimeline", "RaidTimeline assignment due encounter=%s slot=%s time=%s assignAt=%.1f elapsed=%.1f", tostring(self.activeEncounterId), tostring(index), tostring(entry.time), tonumber(assignAt) or 0, tonumber(elapsed) or 0 ) self:DispatchEntryAssignments(self.activeEncounterId, entry, index, entry.time) end end end end for key, assignment in pairs(assignments) do if self.firedEntries[key] ~= true then local warnAt = math.max(0, (tonumber(assignment.time) or 0) - (tonumber(assignment.leadTime) or 5)) if elapsed >= warnAt then self.firedEntries[key] = true HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline firing encounter=%s key=%s time=%s spellId=%s elapsed=%.1f", tostring(self.activeEncounterId), tostring(key), tostring(assignment.time), tostring(assignment.spellId), tonumber(elapsed) or 0 ) local countdown = math.max(0, (tonumber(assignment.time) or 0) - elapsed) self:ShowCountdownAlert(assignment.spellId, countdown, assignment.alertText) end end end self:UpdateCurrentAlert() if self.testActive then local duration = tonumber(self.testDuration) or 0 if duration > 0 and elapsed >= duration and not self.currentAlert then HMGT:DebugScoped( "info", "RaidTimeline", "RaidTimeline test complete encounter=%s elapsed=%.1f", tostring(self.activeEncounterId), tonumber(elapsed) or 0 ) self:StopEncounter() end end end function RT:Refresh() self:ApplyAlertStyle() self:HandleDueEntries() self:RefreshTimelineEditor() end function RT:HandleEvent(event, ...) if event == "ENCOUNTER_START" then local encounterId, _, difficultyId = ... self:StartEncounter(encounterId, difficultyId) elseif event == "ENCOUNTER_END" or event == "PLAYER_ENTERING_WORLD" then self:StopEncounter() elseif event == "GROUP_ROSTER_UPDATE" then self:HandleDueEntries() end end