diff --git a/BossTimelines/Beloren.lua b/BossTimelines/Beloren.lua new file mode 100644 index 0000000..1c4624f --- /dev/null +++ b/BossTimelines/Beloren.lua @@ -0,0 +1,37 @@ +local _, NSI = ... -- Internal namespace + +-------------------------------------------------------------------------------- +-- BELO'REN (3182) +-- Phoenix fight with ~110s cycles, intermissions at Death Drop +-------------------------------------------------------------------------------- + +local abilities = { + {name = "Voidlight Convergence", spellID = 1242515, category = "damage", phase = 1, times = {1, 82, 192, 302, 412}, duration = 4}, + {name = "Light Dive", spellID = 1241291, category = "movement", phase = 1, times = {20, 101, 211, 321, 431}, duration = 3}, + {name = "Void Edict", spellID = 1261218, category = "soak", phase = 1, times = {21, 107, 137, 217, 247, 327, 357, 437, 467, 487}, duration = 3}, + {name = "Light Edict", spellID = 1261217, category = "soak", phase = 1, times = {26, 102, 132, 152, 212, 242, 262, 322, 352, 372, 432, 462, 482}, duration = 3}, + {name = "Holy Burn", spellID = 1244348, category = "damage", phase = 1, times = {27, 108, 126, 148, 218, 236, 258, 328, 346, 368, 438, 456, 478}, duration = 3}, + {name = "Death Drop", spellID = 1246709, category = "intermission", phase = 1, times = {40, 46, 150, 156, 260, 266, 370, 376}, duration = 3}, + {name = "Incubation of Flames", spellID = 1242792, category = "intermission", phase = 1, times = {47, 157, 267, 377}, duration = 8}, + {name = "Voidlight Edict", spellID = 1241640, category = "soak", phase = 1, times = {72, 112, 142, 222, 252, 332, 362}, duration = 3}, + {name = "Light Quill", spellID = 1241992, category = "damage", phase = 1, times = {122, 232, 342, 452, 472}, duration = 3}, + {name = "Guardian's Edict", spellID = 1260826, category = "soak", phase = 1, times = {128, 237, 347, 458}, duration = 4}, + {name = "Radiant Echoes", spellID = 1242981, category = "damage", phase = 1, times = {158, 268, 378, 442, 488}, duration = 4}, +} + +local phases = { + [1] = {start = 0}, +} + +NSI.BossTimelines[3182] = { + Mythic = { + duration = 540, + phases = phases, + abilities = abilities, + }, + Heroic = { + duration = 540, + phases = phases, + abilities = abilities, + }, +} diff --git a/BossTimelines/BossTimelines.lua b/BossTimelines/BossTimelines.lua new file mode 100644 index 0000000..afac9a3 --- /dev/null +++ b/BossTimelines/BossTimelines.lua @@ -0,0 +1,298 @@ +local _, NSI = ... -- Internal namespace + +--[[ + Boss Timeline Data + + Structure (nested by difficulty): + NSI.BossTimelines[encounterID] = { + Mythic = { ... }, -- Mythic difficulty timeline + Heroic = { ... }, -- Heroic difficulty timeline + } + + Each difficulty contains: + { + duration = number, -- Total fight duration in seconds + phases = { + [phaseNum] = { + start = number, -- Default phase start time in seconds + name = string, -- (optional) Phase display name + color = {r, g, b}, -- (optional) RGB color for phase (0-1 range) + }, + }, + abilities = { + { + name = string, -- Ability name + spellID = number, -- WoW spell ID for icon lookup + category = string, -- Comma-separated keywords (see below) + phase = number, -- Phase number (1, 2, 3, etc.) + times = {number, ...}, -- Array of cast times (seconds from phase start) + duration = number, -- Ability duration in seconds (0 if instant) + }, + }, + } + + Category Keywords (comma-separated, e.g. "raid damage, debuffs"): + - raid damage / damage: Raid-wide damage requiring healing cooldowns + - tankbuster / tank: Tank-specific mechanics (busters, swaps) + - frontal: Frontal cone attacks (often combined with tankbuster) + - movement: Positioning/movement mechanics + - soak / group soak: Soak mechanics requiring assignments + - debuffs: Debuff application mechanics + - healing absorb: Healing absorption effects + - knock: Knockback mechanics + - event: Special event or intermission + - intermission: Phase transition abilities +]] + +-- Initialize the BossTimelines table +NSI.BossTimelines = NSI.BossTimelines or {} + +-- Category colors for timeline display +-- Maps category keywords to colors (supports compound categories like "raid damage, debuffs") +NSI.BossTimelineColors = { + -- Damage categories (Red) + damage = {0.9, 0.3, 0.3}, + ["raid damage"] = {0.9, 0.3, 0.3}, + + -- Tank categories (Blue) + tank = {0.3, 0.5, 0.9}, + tankbuster = {0.3, 0.5, 0.9}, + frontal = {0.3, 0.5, 0.9}, + + -- Movement categories (Yellow/Orange) + movement = {0.9, 0.7, 0.2}, + knock = {0.9, 0.7, 0.2}, + + -- Soak categories (Green) + soak = {0.5, 0.9, 0.5}, + ["group soak"] = {0.5, 0.9, 0.5}, + + -- Debuff/Healing categories (Pink/Magenta) + debuffs = {0.9, 0.5, 0.7}, + ["healing absorb"] = {0.9, 0.5, 0.7}, + + -- Event/Intermission categories (Purple) + intermission = {0.7, 0.4, 0.9}, + event = {0.7, 0.4, 0.9}, +} + +-- Category sort priority (lower = higher priority) +NSI.BossTimelineCategoryOrder = { + -- Damage first + damage = 1, + ["raid damage"] = 1, + -- Then soak + soak = 2, + ["group soak"] = 2, + -- Then tank + tank = 3, + tankbuster = 3, + frontal = 3, + -- Then debuffs + debuffs = 4, + ["healing absorb"] = 4, + -- Then movement + movement = 5, + knock = 5, + -- Then events/intermission + event = 6, + intermission = 6, +} + +-- Parse a compound category string and return color and sort order +-- e.g., "raid damage, debuffs" -> color for "raid damage", order 1 +function NSI:ParseCategoryForDisplay(categoryStr) + if not categoryStr or categoryStr == "" then + return {0.7, 0.7, 0.7}, 99, "unknown" -- default gray + end + + local color = nil + local order = 99 + local primaryCategory = "unknown" + + -- Split by comma and check each keyword + for keyword in categoryStr:gmatch("([^,]+)") do + keyword = strtrim(keyword):lower() + + -- Check for color match (use first match found) + if not color and self.BossTimelineColors[keyword] then + color = self.BossTimelineColors[keyword] + primaryCategory = keyword + end + + -- Check for sort order (use lowest/highest priority found) + local keywordOrder = self.BossTimelineCategoryOrder[keyword] + if keywordOrder and keywordOrder < order then + order = keywordOrder + if not color then + primaryCategory = keyword + end + end + end + + return color or {0.7, 0.7, 0.7}, order, primaryCategory +end + +-- Encounter name lookup +NSI.BossTimelineNames = { + [3176] = "Imperator Averzian", + [3177] = "Vorasius", + [3178] = "Vaelgor & Ezzorak", + [3179] = "Fallen King Salhadaar", + [3180] = "Lightblinded Vanguard", + [3181] = "Crown of the Cosmos", + [3182] = "Belo'ren", + [3183] = "Midnight Falls", + [3306] = "Chimaerus", +} + +-------------------------------------------------------------------------------- +-- HELPER FUNCTIONS +-------------------------------------------------------------------------------- + +-- Difficulty ID to name mapping +NSI.DifficultyNames = { + [15] = "Heroic", + [16] = "Mythic", +} + +-- Get current difficulty name, defaults to "Mythic" if unknown +function NSI:GetCurrentDifficultyName() + local _, _, difficultyID = GetInstanceInfo() + return self.DifficultyNames[difficultyID] or "Mythic" +end + +-- Get the timeline data for a specific encounter and difficulty +-- Falls back to Mythic > Heroic > Normal if requested difficulty not available +function NSI:GetBossTimeline(encounterID, difficulty) + local bossData = self.BossTimelines[encounterID] + if not bossData then return nil end + + -- If difficulty specified, try that first + if difficulty and bossData[difficulty] then + return bossData[difficulty], difficulty + end + + -- Auto-detect current difficulty + local currentDiff = self:GetCurrentDifficultyName() + if bossData[currentDiff] then + return bossData[currentDiff], currentDiff + end + + -- Fallback chain: Mythic > Heroic + if bossData.Mythic then return bossData.Mythic, "Mythic" end + if bossData.Heroic then return bossData.Heroic, "Heroic" end + + return nil +end + +-- Get user-adjusted phase start time, or default if not set +function NSI:GetPhaseStart(encounterID, phaseNum, difficulty) + -- Phase 1 always starts at 0 + if phaseNum == 1 then return 0 end + + local timeline = self:GetBossTimeline(encounterID, difficulty) + if not timeline or not timeline.phases or not timeline.phases[phaseNum] then return 0 end + + -- Check for user adjustment + if NSRT.PhaseTimings and NSRT.PhaseTimings[encounterID] and NSRT.PhaseTimings[encounterID][phaseNum] then + return NSRT.PhaseTimings[encounterID][phaseNum] + end + + return timeline.phases[phaseNum].start +end + +-- Set user-adjusted phase start time +function NSI:SetPhaseStart(encounterID, phaseNum, time) + -- Cannot adjust phase 1 + if phaseNum == 1 then return end + + if not NSRT.PhaseTimings then + NSRT.PhaseTimings = {} + end + if not NSRT.PhaseTimings[encounterID] then + NSRT.PhaseTimings[encounterID] = {} + end + + NSRT.PhaseTimings[encounterID][phaseNum] = time +end + +-- Reset phase timing to default +function NSI:ResetPhaseStart(encounterID, phaseNum) + if NSRT.PhaseTimings and NSRT.PhaseTimings[encounterID] then + NSRT.PhaseTimings[encounterID][phaseNum] = nil + end +end + +-- Get all abilities for an encounter with absolute times +function NSI:GetBossTimelineAbilities(encounterID, difficulty) + local timeline, actualDifficulty = self:GetBossTimeline(encounterID, difficulty) + if not timeline then return nil end + + -- Pre-calculate all phase start times for filtering + local phaseStarts = {} + local maxPhase = 0 + for phaseNum, _ in pairs(timeline.phases) do + phaseStarts[phaseNum] = self:GetPhaseStart(encounterID, phaseNum, actualDifficulty) + if phaseNum > maxPhase then + maxPhase = phaseNum + end + end + + local result = {} + + for i, ability in ipairs(timeline.abilities) do + local phaseStart = self:GetPhaseStart(encounterID, ability.phase, actualDifficulty) + local absoluteTimes = {} + + -- Get the start time of the next phase (if it exists) + -- Abilities from this phase should not extend past the next phase start + local nextPhaseStart = nil + if ability.phase < maxPhase then + nextPhaseStart = phaseStarts[ability.phase + 1] + end + + for _, time in ipairs(ability.times) do + local absoluteTime = phaseStart + time + -- Filter out abilities that occur after the next phase has started + if not nextPhaseStart or absoluteTime < nextPhaseStart then + table.insert(absoluteTimes, absoluteTime) + end + end + + -- Only add ability if it has any visible times + if #absoluteTimes > 0 then + -- Parse compound category for color and sort order + local color, sortOrder, primaryCategory = self:ParseCategoryForDisplay(ability.category) + + table.insert(result, { + name = ability.name, + spellID = ability.spellID, + category = ability.category, -- Keep original for tooltip display + primaryCategory = primaryCategory, -- Parsed primary category + sortOrder = sortOrder, -- For sorting in timeline + phase = ability.phase, + times = absoluteTimes, + duration = ability.duration, + color = color, + }) + end + end + + -- Build phases with adjusted times + local phases = {} + for phaseNum, phaseData in pairs(timeline.phases) do + phases[phaseNum] = { + name = phaseData.name, -- May be nil + start = self:GetPhaseStart(encounterID, phaseNum, actualDifficulty), + color = phaseData.color, -- May be nil + } + end + + return result, timeline.duration, phases, actualDifficulty +end + +-- Get encounter name from ID +function NSI:GetEncounterName(encounterID) + return self.BossTimelineNames[encounterID] or ("Encounter " .. encounterID) +end diff --git a/BossTimelines/BossTimelines.xml b/BossTimelines/BossTimelines.xml new file mode 100644 index 0000000..1a7e59d --- /dev/null +++ b/BossTimelines/BossTimelines.xml @@ -0,0 +1,13 @@ + + +