diff --git a/.luacheckrc b/.luacheckrc index 6565a19..d59c1c8 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -516,6 +516,7 @@ stds.wow = { "GetMouseFocus", "GetNormalizedRealmName", "GetNumLanguages", + "GetNumSubgroupMembers", "GetPlayerInfoByGUID", "GetRealmName", "GetSpellBaseCooldown", @@ -829,6 +830,7 @@ stds.wow = { "RUNES", "RUNIC_POWER", "SAVE", + "SETTINGS", "SOUL_SHARDS", "SOUND", "SOUNDKIT", diff --git a/Core/Auras.lua b/Core/Auras.lua index a8ab4e1..b62eb5e 100644 --- a/Core/Auras.lua +++ b/Core/Auras.lua @@ -25,6 +25,27 @@ function SIPPYCUP.Auras.DebugEnabledAuras() end end +---SkipDuplicatePrismUnitAura determines if a prism-type aura update should be ignored +---@param profileOptionData SIPPYCUPProfileOption The option profile data. +---@return boolean skip True if this aura update should be skipped due to duplicate UNIT_AURA events. +local function SkipDuplicatePrismUnitAura(profileOptionData) + local skip = false; + + -- Prism duplicate handling + if profileOptionData.isPrism then + if profileOptionData.instantUpdate then + -- Second UNIT_AURA in pair: skip + skip = true; + profileOptionData.instantUpdate = false; -- reset for next usage + else + -- First UNIT_AURA: allow and mark + profileOptionData.instantUpdate = true; + end + end + + return skip; +end + ---QueueAuraAction enqueues a popup action for aura changes. ---@param profileOptionData table The option profile data. ---@param auraInfo table? The aura information, or nil if removed. @@ -32,14 +53,26 @@ end ---@param source string The source description of the action. ---@return nil local function QueueAuraAction(profileOptionData, auraInfo, reason, source) + local optionData = SIPPYCUP.Options.ByAuraID[profileOptionData.aura]; + -- Consumables that actually consume bag space/items need a bag check + local needsBagCheck = (profileOptionData.type == 0 and not profileOptionData.usesCharges); + local shouldIncrement = needsBagCheck and reason == SIPPYCUP.Popups.Reason.ADDITION; + local data = { active = auraInfo ~= nil, auraID = profileOptionData.aura, auraInfo = auraInfo, - optionData = SIPPYCUP.Options.ByAuraID[profileOptionData.aura], + optionData = optionData, profileOptionData = profileOptionData, reason = reason, - } + needsBagCheck = needsBagCheck, + auraGeneration = shouldIncrement and (SIPPYCUP.Bags.auraGeneration + 1) or 0, + }; + + if shouldIncrement then + SIPPYCUP.Bags.auraGeneration = SIPPYCUP.Bags.auraGeneration + 1; + end + SIPPYCUP.Popups.QueuePopupAction(data, source); end @@ -54,7 +87,6 @@ end ---@return nil local function ParseAura(updateInfo) local GetAuraDataByAuraInstanceID = C_UnitAuras.GetAuraDataByAuraInstanceID; - local auraAdded = false; -- isFullUpdate true means nil values, invalidate the state. if updateInfo.isFullUpdate then @@ -72,13 +104,16 @@ local function ParseAura(updateInfo) for _, auraInfo in ipairs(updateInfo.addedAuras) do local profileOptionData = SIPPYCUP.Database.FindMatchingProfile(auraInfo.spellId); if profileOptionData and profileOptionData.enable then - auraAdded = true; - profileOptionData.currentInstanceID = auraInfo.auraInstanceID; - SIPPYCUP.Database.instanceToProfile[auraInfo.auraInstanceID] = profileOptionData; + local skip = SkipDuplicatePrismUnitAura(profileOptionData); - SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData); + if not skip then + profileOptionData.currentInstanceID = auraInfo.auraInstanceID; + SIPPYCUP.Database.instanceToProfile[auraInfo.auraInstanceID] = profileOptionData; - QueueAuraAction(profileOptionData, auraInfo, SIPPYCUP.Popups.Reason.ADDITION, "ParseAura - addition"); + SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData); + + QueueAuraAction(profileOptionData, auraInfo, SIPPYCUP.Popups.Reason.ADDITION, "ParseAura - addition"); + end end end end @@ -91,16 +126,17 @@ local function ParseAura(updateInfo) if profileOptionData and profileOptionData.enable then local auraInfo = GetAuraDataByAuraInstanceID("player", auraInstanceID); if auraInfo then - auraAdded = true; - -- This is not necessary, but a safety update just in case. - profileOptionData.currentInstanceID = auraInfo.auraInstanceID; - SIPPYCUP.Database.instanceToProfile[auraInfo.auraInstanceID] = profileOptionData; + local skip = SkipDuplicatePrismUnitAura(profileOptionData); - -- On aura update, we remove all pre-expiration timers as that's obvious no longer relevant. - SIPPYCUP.Auras.CancelPreExpirationTimer(nil, profileOptionData.aura, auraInstanceID); - SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData); + if not skip then + profileOptionData.currentInstanceID = auraInfo.auraInstanceID; + SIPPYCUP.Database.instanceToProfile[auraInfo.auraInstanceID] = profileOptionData; - QueueAuraAction(profileOptionData, auraInfo, SIPPYCUP.Popups.Reason.ADDITION, "ParseAura - updated"); + SIPPYCUP.Auras.CancelPreExpirationTimer(nil, profileOptionData.aura, auraInstanceID); + SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData); + + QueueAuraAction(profileOptionData, auraInfo, SIPPYCUP.Popups.Reason.ADDITION, "ParseAura - updated"); + end end end end @@ -122,11 +158,6 @@ local function ParseAura(updateInfo) end end end - - -- If no auras were added, there was no bag update so we mark bag as synchronized. - if not auraAdded then - SIPPYCUP.Items.HandleBagUpdate(); - end end SIPPYCUP.Auras.auraQueue = {}; @@ -148,7 +179,7 @@ local function flushAuraQueue() -- Deduplication arrays local seenAdd, seenUpdate, seenRemoval = {}, {}, {}; - local isFullUpdate = false + local isFullUpdate = false; -- 1) harvest everything into seenAdd / seenUpdate / seenRemoval for _, updateInfo in ipairs(queue) do @@ -296,7 +327,7 @@ function SIPPYCUP.Auras.Convert(source, data) end -- buffer it instead of parsing immediately - SIPPYCUP.Auras.auraQueue[#SIPPYCUP.Auras.auraQueue + 1] = updateInfo + SIPPYCUP.Auras.auraQueue[#SIPPYCUP.Auras.auraQueue + 1] = updateInfo; -- flush on the next frame (which will run the batched UNIT_AURAs) if not SIPPYCUP.Auras.auraQueueScheduled then @@ -321,7 +352,7 @@ function SIPPYCUP.Auras.CheckAllActiveOptions() -- auraToProfile holds only enabled options (whether they are active or not). for _, profileOptionData in pairs(auraToProfile) do - local currentInstanceID = profileOptionData.currentInstanceID + local currentInstanceID = profileOptionData.currentInstanceID; -- True if it's active (or was), false if it's not been active. local canBeActive = (currentInstanceID or 0) ~= 0; local auraInfo = GetPlayerAuraBySpellID(profileOptionData.aura); @@ -511,7 +542,7 @@ function SIPPYCUP.Auras.CheckPreExpirationForAllActiveOptions(minSeconds) end ---CheckPreExpirationForSingleOption sets up pre-expiration warnings for aura-based options. ----@param profileOptionData table Profile data for the option. +---@param profileOptionData SIPPYCUPProfileOption Profile data for the option. ---@param minSeconds number? Time window to check ahead, defaults to 180. ---@return boolean preExpireFired True if a pre-expiration popup was fired. function SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData, minSeconds) @@ -523,7 +554,6 @@ function SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData, min end minSeconds = minSeconds or 180.0; - local preOffset = SIPPYCUP.global.PreExpirationLeadTimer * 60; local auraID = profileOptionData.aura; local auraInfo = C_UnitAuras.GetPlayerAuraBySpellID(auraID); @@ -550,6 +580,17 @@ function SIPPYCUP.Auras.CheckPreExpirationForSingleOption(profileOptionData, min local now = GetTime(); local remaining = auraInfo.expirationTime - now; local duration = auraInfo.duration; + local preOffset; + + if profileOptionData.isPrism then + if profileOptionData.usesCharges then -- reflecting prism + preOffset = SIPPYCUP.global.ReflectingPrismPreExpirationLeadTimer * 60; + else -- projection prism + preOffset = SIPPYCUP.global.ProjectionPrismPreExpirationLeadTimer * 60; + end + else -- Global + preOffset = SIPPYCUP.global.PreExpirationLeadTimer * 60; + end -- If the option only lasts for less than user set, we need to change it. if duration <= preOffset then diff --git a/Core/Bags.lua b/Core/Bags.lua new file mode 100644 index 0000000..5ae4702 --- /dev/null +++ b/Core/Bags.lua @@ -0,0 +1,22 @@ +-- Copyright The Sippy Cup Authors +-- SPDX-License-Identifier: Apache-2.0 + +SIPPYCUP.Bags = {}; + +SIPPYCUP.Bags.auraGeneration = 0; +SIPPYCUP.Bags.bagGeneration = 0; + +function SIPPYCUP.Bags.BagUpdateDelayed() + SIPPYCUP.Bags.lastBagUpdate = GetTime(); + SIPPYCUP.Bags.bagGeneration = SIPPYCUP.Bags.bagGeneration + 1; + + SIPPYCUP_OUTPUT.Debug("Bag generation:", SIPPYCUP.Bags.bagGeneration); + SIPPYCUP.Bags.ClearBagQueue(); +end + +---HandleBagUpdate marks bag data as synchronized and processes deferred popups. +-- Fires on BAG_UPDATE_DELAYED, which batches all bag changes after UNIT_AURA. +function SIPPYCUP.Bags.ClearBagQueue() + -- Flush deferred popups that were blocked by bag desync + SIPPYCUP.Popups.HandleDeferredActions("bag", SIPPYCUP.Items.bagSyncedGeneration); +end diff --git a/Core/Core.xml b/Core/Core.xml index a546b17..8548abd 100644 --- a/Core/Core.xml +++ b/Core/Core.xml @@ -7,6 +7,7 @@ + diff --git a/Core/Database.lua b/Core/Database.lua index 62333eb..4a6fb13 100644 --- a/Core/Database.lua +++ b/Core/Database.lua @@ -78,6 +78,8 @@ end ---@field PopupPosition string Position of the popup ("TOP", "BOTTOM", etc.). ---@field PreExpirationChecks boolean Whether to perform checks shortly before aura expiration. ---@field PreExpirationLeadTimer number Time (in minutes) before a pre-expiration reminder should fire. +---@field ProjectionPrismPreExpirationLeadTimer number Time (in minutes) before a projection prism pre-expiration reminder should fire. +---@field ReflectingPrismPreExpirationLeadTimer number Time (in minutes) before a reflecting prism pre-expiration reminder should fire. ---@field UseToyCooldown boolean Whether to use toy cooldowns for popups instead. ---@field WelcomeMessage boolean Whether to display a welcome message on login. @@ -96,6 +98,7 @@ SIPPYCUP.Database.defaults = { global = { AlertSound = true, AlertSoundID = "fx_ship_bell_chime_02", + DebugOutput = false, FlashTaskbar = true, Flyway = { CurrentBuild = 0, @@ -111,13 +114,15 @@ SIPPYCUP.Database.defaults = { PopupPosition = "TOP", PreExpirationChecks = true, PreExpirationLeadTimer = 1, + ProjectionPrismPreExpirationLeadTimer = 5, + ReflectingPrismPreExpirationLeadTimer = 3, UseToyCooldown = true, WelcomeMessage = true, }, profiles = { Default = {}, -- will hold all options per profile }, -} +}; local defaults = SIPPYCUP.Database.defaults; @@ -128,7 +133,12 @@ local defaults = SIPPYCUP.Database.defaults; ---@field currentInstanceID number? Aura instance ID currently being tracked (if any). ---@field currentStacks number Current number of detected stacks. ---@field aura number The associated aura ID for this option. +---@field castAura number The associated cast aura ID, if none is set then use aura ID. ---@field untrackableByAura boolean Whether this option can be tracked via its aura or not. +---@field type string Whether this option is a consumable (0) or toy (1). +---@field isPrism boolean Whether this option is considered a prism. +---@field instantUpdate boolean Whether the instant UNIT_AURA update has already happened right after the addition (prisms). +---@field usesCharges boolean Whether this option uses charges (generally reflecting prism). ---Populate the default option's table keyed by aura ID, with all known entries from SIPPYCUP.Options.Data. ---This defines initial tracking settings for each option by its aura ID key. @@ -138,7 +148,12 @@ local function PopulateDefaultOptions() for i = 1, #optionsData do local option = optionsData[i]; local spellID = option.auraID; + local castSpellID = option.castAuraID; local untrackableByAura = option.itemTrackable or option.spellTrackable; + local type = option.type; + local isPrism = (option.category == "PRISM") or false; + local instantUpdate = not isPrism; + local usesCharges = option.charges; if spellID then -- Use auraID as the key, not profileKey @@ -148,7 +163,12 @@ local function PopulateDefaultOptions() currentInstanceID = nil, currentStacks = 0, aura = spellID, + castAura = castSpellID, untrackableByAura = untrackableByAura, + type = type, + isPrism = isPrism, + instantUpdate = instantUpdate, + usesCharges = usesCharges, }; end end @@ -162,6 +182,9 @@ SIPPYCUP.Database.auraToProfile = {}; -- auraID --> profile data SIPPYCUP.Database.instanceToProfile = {}; -- instanceID --> profile data ---@type table SIPPYCUP.Database.untrackableByAuraProfile = {}; -- itemID --> profile data (only if no aura) +---@type table +SIPPYCUP.Database.castAuraToProfile = {}; -- castAuraID (if different) / auraID --> profile data + ---RebuildAuraMap rebuilds internal lookup tables for aura and instance-based option tracking. ---@return nil @@ -171,11 +194,14 @@ function SIPPYCUP.Database.RebuildAuraMap() wipe(db.auraToProfile); wipe(db.instanceToProfile); wipe(db.untrackableByAuraProfile); + wipe(db.castAuraToProfile); for _, profileOptionData in pairs(SIPPYCUP.Profile) do if profileOptionData.enable and profileOptionData.aura then local auraID = profileOptionData.aura; db.auraToProfile[auraID] = profileOptionData; + local castAuraID = profileOptionData.castAura; + db.castAuraToProfile[castAuraID] = profileOptionData; -- Update instance ID if aura is currently active local auraInfo; diff --git a/Core/Items.lua b/Core/Items.lua index 9d5e7e5..a51134a 100644 --- a/Core/Items.lua +++ b/Core/Items.lua @@ -279,14 +279,3 @@ function SIPPYCUP.Items.CheckNoAuraSingleOption(profileOptionData, spellID, minS return preExpireFired; end - -SIPPYCUP.Items.bagUpdateUnhandled = false; - ----HandleBagUpdate Marks bag data as synced and processes deferred popups. --- Fires on BAG_UPDATE_DELAYED, which batches all bag changes after UNIT_AURA. -function SIPPYCUP.Items.HandleBagUpdate() - SIPPYCUP.Items.bagUpdateUnhandled = false; - - -- Now that bag data is synced, process deferred actions using accurate data. - SIPPYCUP.Popups.HandleDeferredActions("bag"); -end diff --git a/Core/Options.lua b/Core/Options.lua index 5764724..e2aee5f 100644 --- a/Core/Options.lua +++ b/Core/Options.lua @@ -17,7 +17,8 @@ SIPPYCUP.Options.Type = { ---@class SIPPYCUPOption: table ---@field type string Whether this option is a consumable (0) or toy (1). ---@field auraID number The option's aura ID. ----@field itemID number The option's item ID. +---@field castAuraID number The option's cast aura ID, if none is set then use auraID. +---@field itemID number|table The option's item ID(s) ---@field loc string The option's localization key (auto-gen). ---@field category string The option's category (e.g., potion, food). ---@field profile table The option's associated DB profile (auto-gen). @@ -30,16 +31,24 @@ SIPPYCUP.Options.Type = { ---@field spellTrackable boolean Whether the option can only be tracked through the spell itself (cooldowns, etc.). ---@field delayedAura boolean Whether the option is applied after a delay (e.g. food buff), on false a buff is applied instantly. ---@field cooldownMismatch boolean Whether the option has a mismatch in cooldowns (cd longer than buff lasts), on false there is no mismatch. - +---@field buildAdded string The Sippy Cup and WoW build this option was added (for the new feature indicator). +---@field requiresGroup boolean Whether the option requires a group to be sued (e.g. prisms). +---@field charges boolean Whether the option uses charges. ---NewOption creates a new object with the specified parameters. ---@param params SIPPYCUPOption A table containing parameters for the new option. ---@return SIPPYCUPOption local function NewOption(params) + local itemIDs = params.itemID; + if type(itemIDs) == "number" then + itemIDs = { itemIDs }; + end + return { type = params.type or SIPPYCUP.Options.Type.CONSUMABLE, auraID = params.auraID, - itemID = params.itemID, + castAuraID = params.castAuraID or params.auraID, + itemID = itemIDs; -- always store as a table internally loc = params.loc, category = params.category, profile = params.profile, @@ -53,6 +62,8 @@ local function NewOption(params) delayedAura = params.delayedAura or false, cooldownMismatch = params.cooldownMismatch or false, buildAdded = params.buildAdded or nil, + requiresGroup = params.requiresGroup or false, + charges = params.charges or false, }; end @@ -85,14 +96,14 @@ SIPPYCUP.Options.Data = { NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 185394, itemID = 124640, category = "EFFECT", preExpiration = true }, -- INKY_BLACK_POTION NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 1218300, itemID = 235703, category = "SIZE", stacks = true, maxStacks = 10, preExpiration = true }, -- NOGGENFOGGER_SELECT_DOWN NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 1218297, itemID = 235704, category = "SIZE", stacks = true, maxStacks = 10, preExpiration = true }, -- NOGGENFOGGER_SELECT_UP - NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 374957, itemID = 193029, category = "PRISM", preExpiration = true }, -- PROJECTION_PRISM + NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 374959, itemID = {193031, 193030, 193029}, category = "PRISM", preExpiration = true, requiresGroup = true, buildAdded = "0.7.0|120001" }, -- PROJECTION_PRISM NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 368038, itemID = 190739, category = "EFFECT", preExpiration = true }, -- PROVIS_WAX NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 244015, itemID = 151256, category = "HANDHELD", preExpiration = true }, -- PURPLE_DANCE_STICK NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 53805, itemID = 40195, category = "SIZE", stacks = true, maxStacks = 10 }, -- PYGMY_OIL NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 393979, itemID = 201428, category = "EFFECT" }, -- QUICKSILVER_SANDS NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 1213974, itemID = 234287, category = "EFFECT", preExpiration = true }, -- RADIANT_FOCUS NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 1214287, itemID = 234527, category = "HANDHELD", preExpiration = true }, -- SACREDITES_LEDGER - NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 163219, itemID = 112384, category = "PRISM", preExpiration = true }, -- REFLECTING_PRISM + NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 163267, castAuraID = 163219, itemID = 112384, category = "PRISM", preExpiration = true, requiresGroup = true, charges = true, buildAdded = "0.7.0|120001" }, -- REFLECTING_PRISM NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 279742, itemID = 163695, category = "EFFECT" }, -- SCROLL_OF_INNER_TRUTH NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 1222834, itemID = 237332, category = "PLACEMENT", spellTrackable = true }, -- SINGLE_USE_GRILL NewOption{ type = SIPPYCUP.Options.Type.CONSUMABLE, auraID = 58479, itemID = 43480, category = "SIZE", preExpiration = true, delayedAura = true }, -- SMALL_FEAST @@ -216,21 +227,33 @@ function SIPPYCUP.Options.Setup() -- Build lookups for _, option in ipairs(data) do - remaining[option.itemID] = true; + for _, id in ipairs(option.itemID) do + SIPPYCUP.Options.ByItemID[id] = option; + remaining[id] = true; + end SIPPYCUP.Options.ByAuraID[option.auraID] = option; - SIPPYCUP.Options.ByItemID[option.itemID] = option; end -- Fast prune: remove invalid items before async loads for i = #data, 1, -1 do local option = data[i]; + local valid = false; - if C_Item.GetItemInfoInstant(option.itemID) == nil then - SIPPYCUP.Options.ByAuraID[option.auraID] = nil; - SIPPYCUP.Options.ByItemID[option.itemID] = nil; + for _, id in ipairs(option.itemID) do + if C_Item.GetItemInfoInstant(id) ~= nil then + valid = true; + break; + end + end + if not valid then + -- Remove all itemIDs from lookups + for _, id in ipairs(option.itemID) do + SIPPYCUP.Options.ByItemID[id] = nil; + remaining[id] = nil; + end + SIPPYCUP.Options.ByAuraID[option.auraID] = nil; table.remove(data, i); - remaining[option.itemID] = nil; end end @@ -256,27 +279,40 @@ function SIPPYCUP.Options.Setup() -- Async load for all valid items for _, option in ipairs(data) do - local item = Item:CreateFromItemID(option.itemID); + -- pick first valid itemID number to feed into Item:CreateFromItemID + local firstID; + for _, id in ipairs(option.itemID) do + if type(id) == "number" then + firstID = id; + break; + end + end - item:ContinueOnItemLoad(function() - -- Skip if removed by earlier pruning - if not remaining[option.itemID] then return end; + if firstID then + local item = Item:CreateFromItemID(firstID); - local name = item:GetItemName(); + item:ContinueOnItemLoad(function() + if not remaining[firstID] then return; end; - option.name = name; - option.loc = NormalizeLocName(name); + local name = item:GetItemName(); + option.name = name; + option.loc = NormalizeLocName(name); - option.profile = string.gsub(string.lower(option.loc), "_(%a)", function(c) - return c:upper(); - end); + option.profile = string.gsub(string.lower(option.loc), "_(%a)", function(c) + return c:upper(); + end); - option.icon = item:GetItemIcon(); - SIPPYCUP.Options.ByName[name] = option; + option.icon = item:GetItemIcon(); + SIPPYCUP.Options.ByName[name] = option; - remaining[option.itemID] = nil; - Finalize(); - end); + -- mark all itemIDs as loaded + for _, id in ipairs(option.itemID) do + remaining[id] = nil; + end + + Finalize(); + end); + end end end @@ -291,39 +327,26 @@ function SIPPYCUP.Options.RefreshStackSizes(checkAll, reset, preExpireOnly) -- Helper to check cooldown startTime for item or spell trackable local function GetCooldownStartTime(option) - local trackBySpell = false; - local trackByItem = false; - - if option.type == SIPPYCUP.Options.Type.CONSUMABLE then - trackBySpell = option.spellTrackable; - trackByItem = option.itemTrackable; - elseif option.type == SIPPYCUP.Options.Type.TOY then - -- Always track by item if itemTrackable - if option.itemTrackable then - trackByItem = true; - end + local trackBySpell = option.spellTrackable or false; + local trackByItem = option.itemTrackable or false; - if option.spellTrackable then - if SIPPYCUP.global.UseToyCooldown then - trackByItem = true; - else - trackBySpell = true; + if trackByItem then + for _, id in ipairs(option.itemID) do + local startTime = C_Item.GetItemCooldown(id); + if startTime and startTime > 0 then + return startTime; end end end - if trackByItem then - local startTime = C_Item.GetItemCooldown(option.itemID); - if startTime and startTime > 0 then - return startTime; - end - elseif trackBySpell then + if trackBySpell then local spellCooldown = C_Spell.GetSpellCooldown(option.auraID); local startTime = spellCooldown and spellCooldown.startTime; if startTime and startTime > 0 then return startTime; end end + return nil; end diff --git a/Core/Utils.lua b/Core/Utils.lua index a52600c..788950e 100644 --- a/Core/Utils.lua +++ b/Core/Utils.lua @@ -125,32 +125,41 @@ function SIPPYCUP_OUTPUT.Write(output, command) Print(formattedOutput); end +local function formatValue(val, isTop) + if type(val) == "table" then + local isArray = (#val > 0); + if isArray then + local items = {}; + for _, v in ipairs(val) do + table.insert(items, formatValue(v)); + end + return "{" .. table.concat(items, ",") .. "}"; + else + local items = {}; + for k, v in pairs(val) do + table.insert(items, tostring(k) .. ": " .. formatValue(v)); + end + if isTop then + return table.concat(items, ", "); + else + return "{" .. table.concat(items, ", ") .. "}"; + end + end + else + return tostring(val); + end +end + ---Debug prints formatted output, only works when IS_DEV_BUILD is true. ---Accepts any number of arguments and joins them with space. ---@param ... any Values to print (strings, numbers, tables, etc.) function SIPPYCUP_OUTPUT.Debug(...) - if not SIPPYCUP.IS_DEV_BUILD then - return; - end + if not SIPPYCUP.IS_DEV_BUILD or not SIPPYCUP.Database.GetSetting("global", "DebugOutput", nil) then return; end - local args = { ... }; + local args = {...}; local outputLines = {}; - for _, arg in ipairs(args) do - if type(arg) == "table" then - local isArray = (#arg > 0); - if isArray then - for _, v in ipairs(arg) do - table.insert(outputLines, tostring(v)); - end - else - for k, v in pairs(arg) do - table.insert(outputLines, tostring(k) .. ": " .. tostring(v)); - end - end - else - table.insert(outputLines, tostring(arg)); - end + table.insert(outputLines, formatValue(arg, true)); end local finalOutput = table.concat(outputLines, " "); diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 40f12f2..22ad7f0 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -12,6 +12,7 @@ L = { --/ Popup dialog /-- POPUP_ON_COOLDOWN_TEXT = "On Cooldown", POPUP_IN_FLIGHT_TEXT = "Disabled to prevent dismount during flight.", + POPUP_NOT_IN_PARTY_TEXT = "Disabled as item requires a party.", POPUP_FOOD_BUFF_TEXT = "Disappears once food buff is applied. Do not move!", POPUP_NOT_IN_INVENTORY_TEXT = "Not in Inventory", POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT = "Not Enough (%d Missing)", @@ -103,6 +104,8 @@ L = { -- Prism OPTIONS_TAB_PRISM_TITLE = "Prism", OPTIONS_TAB_PRISM_INSTRUCTION = "These options control all reminders for prism consumables/toys that change your appearance.", + OPTIONS_TAB_PRISM_TIMER = "%s - Timer", + OPTIONS_TAB_PRISM_TIMER_TEXT = "Set the desired time, in minutes, before the prism pre-expiration reminder popup should be shown (default: %d minutes).|n|n|cnWARNING_FONT_COLOR:If an option does not support the chosen time, it will default to %d minutes.|r", -- Size OPTIONS_TAB_SIZE_TITLE = "Size", diff --git a/Locales/esES.lua b/Locales/esES.lua index 4d6c7fb..c0e83c9 100644 --- a/Locales/esES.lua +++ b/Locales/esES.lua @@ -13,6 +13,7 @@ L = { --/ Popup dialog /-- POPUP_ON_COOLDOWN_TEXT = "En tiempo de espera", POPUP_IN_FLIGHT_TEXT = "Desactivado para evitar el desmontaje durante el vuelo.", + POPUP_NOT_IN_PARTY_TEXT = "Disabled as item requires a party.", -- (NEW) POPUP_FOOD_BUFF_TEXT = "Desaparece una vez que se aplica el beneficio de comida. ¡No te muevas!", POPUP_NOT_IN_INVENTORY_TEXT = "No hay en el inventario", POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT = "No es suficiente (%d Faltante)", @@ -104,6 +105,8 @@ L = { -- Prism OPTIONS_TAB_PRISM_TITLE = "Prismas", OPTIONS_TAB_PRISM_INSTRUCTION = "Estas opciones controlan todos los recordatorios para los consumibles/juguetes prismáticos que cambian tu apariencia.", + OPTIONS_TAB_PRISM_TIMER = "%s - Timer", -- (NEW) + OPTIONS_TAB_PRISM_TIMER_TEXT = "Set the desired time, in minutes, before the prism pre-expiration reminder popup should be shown (default: %d minutes).|n|n|cnWARNING_FONT_COLOR:If an option does not support the chosen time, it will default to %d minutes.|r", -- (NEW) -- Size OPTIONS_TAB_SIZE_TITLE = "Tamaño", diff --git a/Locales/frFR.lua b/Locales/frFR.lua index 6d59ceb..4433050 100644 --- a/Locales/frFR.lua +++ b/Locales/frFR.lua @@ -13,6 +13,7 @@ L = { --/ Popup dialog /-- POPUP_ON_COOLDOWN_TEXT = "En recharge", POPUP_IN_FLIGHT_TEXT = "Désactivé pour ne pas tomber de la monture durant le vol.", + POPUP_NOT_IN_PARTY_TEXT = "Désactivé : l'objet nécessite d'être en groupe.", POPUP_FOOD_BUFF_TEXT = "Disparaît une fois que le buff de nourriture est appliqué. Ne bougez pas !", POPUP_NOT_IN_INVENTORY_TEXT = "Pas dans l'inventaire", POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT = "Insuffisant (%d manquant)", @@ -41,14 +42,14 @@ L = { OPTIONS_GENERAL_POPUPS_HEADER = "Fenêtre de rappel", OPTIONS_GENERAL_POPUPS_POSITION_NAME = "Position", - OPTIONS_GENERAL_POPUPS_POSITION_DESC = "Sélectionne où vous souhaitez que les fenêtres de rappel s'affichent sur votre écran.", - OPTIONS_GENERAL_POPUPS_POSITION_TOP = "Top (Default)", -- (NEW) - OPTIONS_GENERAL_POPUPS_POSITION_CENTER = "Center", -- (NEW) - OPTIONS_GENERAL_POPUPS_POSITION_BOTTOM = "Bottom", -- (NEW) + OPTIONS_GENERAL_POPUPS_POSITION_DESC = "Sélectionne où vous souhaitez que les notifications de rappel s'affichent sur votre écran.", + OPTIONS_GENERAL_POPUPS_POSITION_TOP = "Haut(Default)", + OPTIONS_GENERAL_POPUPS_POSITION_CENTER = "Centre", + OPTIONS_GENERAL_POPUPS_POSITION_BOTTOM = "Bas", OPTIONS_GENERAL_POPUPS_PRE_EXPIRATION_CHECKS_ENABLE = "Rappel pré-expiration", OPTIONS_GENERAL_POPUPS_PRE_EXPIRATION_CHECKS_ENABLE_DESC = "Active l'affichage des rappels pré-expiration juste avant que le consommable/jouet expire.|n|n|cnWARNING_FONT_COLOR:Tous les objets ne supportent pas cette option; voir l'infobulle quand activé.|r", OPTIONS_GENERAL_POPUPS_PRE_EXPIRATION_LEAD_TIMER = "Minuteur du Rappel pré-expiration", - OPTIONS_GENERAL_POPUPS_PRE_EXPIRATION_LEAD_TIMER_TEXT = "Définit, en minutes, le délai souhaité avant l'affichage d'une notification de Rappel pré-expiration (par défaut: 1 minute).|n|n|cnWARNING_FONT_COLOR:Si une option ne prend pas en charge le délai choisi, la valeur sera par défaut de 1 minute, ou 15 secondes si 1 minute n'est pas disponible.|r", + OPTIONS_GENERAL_POPUPS_PRE_EXPIRATION_LEAD_TIMER_TEXT = "Définit, en minutes, le délai souhaité avant l'affichage d'une notification de Rappel pré-expiration (par défaut: 1 minute).|n|n|cnWARNING_FONT_COLOR:Si une option ne prend pas en charge le délai choisi, la valeur par défaut sera de 1 minute, ou 15 secondes si 1 minute n'est pas disponible.|r", OPTIONS_GENERAL_POPUPS_INSUFFICIENT_REMINDER_ENABLE = "Rappel insuffisant", OPTIONS_GENERAL_POPUPS_INSUFFICIENT_REMINDER_ENABLE_DESC = "Active l'affichage d'un rappel lorsque la quantité de consommables est insuffisante pour le prochain rafraîchissement.", OPTIONS_GENERAL_POPUPS_TRACK_TOY_ITEM_CD_ENABLE = "Utiliser la recharge du jouet", @@ -106,6 +107,8 @@ L = { -- Prism OPTIONS_TAB_PRISM_TITLE = "Prisme", OPTIONS_TAB_PRISM_INSTRUCTION = "Ces options contrôlent tous les rappels pour les prismes consommables/jouets qui altèrent votre apparence.", + OPTIONS_TAB_PRISM_TIMER = "%s - Minuteur", + OPTIONS_TAB_PRISM_TIMER_TEXT = "Définit, en minutes, le délai souhaité avant l'affichage d'une notification de Rappel de pré-expiration du prisme (défaut : %d minutes).|n|n|cnWARNING_FONT_COLOR:Si une option ne prend pas en charge le délai choisi, la valeur par défaut sera de %d minutes.|r", -- Size OPTIONS_TAB_SIZE_TITLE = "Taille", diff --git a/Locales/ruRU.lua b/Locales/ruRU.lua index 42c5e3f..65366ec 100644 --- a/Locales/ruRU.lua +++ b/Locales/ruRU.lua @@ -13,6 +13,7 @@ L = { --/ Popup dialog /-- POPUP_ON_COOLDOWN_TEXT = "currently on cooldown.", -- (NEW) POPUP_IN_FLIGHT_TEXT = "Disabled to prevent dismount during flight.", -- (NEW) + POPUP_NOT_IN_PARTY_TEXT = "Disabled as item requires a party.", -- (NEW) POPUP_FOOD_BUFF_TEXT = "Disappears once food buff is applied. Do not move!", -- (NEW) POPUP_NOT_IN_INVENTORY_TEXT = "не найден в вашем инвентаре.", -- (TEXT CHANGE IN ENGLISH) POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT = "недостаточно в вашем инвентаре.|n(%d missing)", -- (NEW) (Ending) @@ -107,6 +108,8 @@ L = { -- Prism OPTIONS_TAB_PRISM_TITLE = "Prism", -- (NEW) OPTIONS_TAB_PRISM_INSTRUCTION = "These options control all reminders for prism consumables/toys to alter your appearance.", -- (NEW) + OPTIONS_TAB_PRISM_TIMER = "%s - Timer", -- (NEW) + OPTIONS_TAB_PRISM_TIMER_TEXT = "Set the desired time, in minutes, before the prism pre-expiration reminder popup should be shown (default: %d minutes).|n|n|cnWARNING_FONT_COLOR:If an option does not support the chosen time, it will default to %d minutes.|r", -- (NEW) -- Size OPTIONS_TAB_SIZE_TITLE = "Размер", diff --git a/Modules/LinkDialog/LinkDialog.lua b/Modules/LinkDialog/LinkDialog.lua index ae4e101..a12f7cf 100644 --- a/Modules/LinkDialog/LinkDialog.lua +++ b/Modules/LinkDialog/LinkDialog.lua @@ -54,7 +54,7 @@ StaticPopupDialogs["SIPPYCUP_LINK_DIALOG"] = { whileDead = true, hideOnEscape = true, preferredIndex = 3, -} +}; ---CreateExternalLinkDialog displays a dialog with an external link. ---@param url string The URL to be displayed in the dialog. diff --git a/Modules/Popups/Popups.lua b/Modules/Popups/Popups.lua index f3c41f6..16b234c 100644 --- a/Modules/Popups/Popups.lua +++ b/Modules/Popups/Popups.lua @@ -177,6 +177,11 @@ local function CreatePopup(templateType) local itemID = data and data.optionData and data.optionData.itemID; if not itemID then return; end + -- Normalize to single ID if a table + if type(itemID) == "table" then + itemID = itemID[1]; -- use the first ID for tooltip + end + local item = Item:CreateFromItemID(itemID); item:ContinueOnItemLoad(function() @@ -197,35 +202,51 @@ local function CreatePopup(templateType) local tooltipText; local currentPopup = self:GetParent(); local popupData = currentPopup and currentPopup.popupData; + local optionData = popupData.optionData; if isFlying then popupData.isFlying = true; self:Disable(); end + if optionData.requiresGroup then + if popupData.isNotinGroupButRequired and UnitInParty("player") and GetNumSubgroupMembers() > 0 then + popupData.isNotinGroupButRequired = false; + self:Enable(); + elseif not UnitInParty("player") or GetNumSubgroupMembers() == 0 then + popupData.isNotinGroupButRequired = true; + self:Disable() + end + end + if not self:IsEnabled() then if isFlying then tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_IN_FLIGHT_TEXT .. "|r"; + elseif popupData.isNotinGroupButRequired then + tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_NOT_IN_PARTY_TEXT .. "|r"; elseif popupData and popupData.optionData and popupData.optionData.delayedAura then tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_FOOD_BUFF_TEXT .. "|r"; else tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_ON_COOLDOWN_TEXT .. "|r"; end else - if popupData then - local optionData = popupData.optionData; - if optionData.type == SIPPYCUP.Options.Type.CONSUMABLE then - local profileOptionData = popupData.profileOptionData; - - local itemID = optionData.itemID; - local itemCount = C_Item.GetItemCount(itemID); - local maxCount = itemCount + profileOptionData.currentStacks; - - if itemCount == 0 then - tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_NOT_IN_INVENTORY_TEXT .. "|r"; - elseif maxCount < profileOptionData.desiredStacks then - tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT:format(profileOptionData.desiredStacks - maxCount); + if optionData.type == SIPPYCUP.Options.Type.CONSUMABLE then + local profileOptionData = popupData.profileOptionData; + + local itemCount = 0; + if type(optionData.itemID) == "table" then + for _, id in ipairs(optionData.itemID) do + itemCount = itemCount + C_Item.GetItemCount(id) end + else + itemCount = C_Item.GetItemCount(optionData.itemID) + end + local maxCount = itemCount + profileOptionData.currentStacks; + + if itemCount == 0 then + tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_NOT_IN_INVENTORY_TEXT .. "|r"; + elseif maxCount < profileOptionData.desiredStacks then + tooltipText = "|cnWARNING_FONT_COLOR:" .. L.POPUP_NOT_ENOUGH_IN_INVENTORY_TEXT:format(profileOptionData.desiredStacks - maxCount); end end end @@ -245,6 +266,11 @@ local function CreatePopup(templateType) popupData.isFlying = false; self:Enable(); end + + local optionData = popupData.optionData; + if optionData.requiresGroup and popupData.isNotinGroupButRequired and UnitInParty("player") and GetNumSubgroupMembers() > 0 then + self:Enable() + end GameTooltip:Hide(); end); @@ -253,8 +279,16 @@ local function CreatePopup(templateType) local currentPopup = self:GetParent(); local popupData = currentPopup and currentPopup.popupData; local optionData = popupData.optionData; - local itemID = optionData.itemID; - local itemCount = C_Item.GetItemCount(itemID); + + -- Sum counts if multiple item IDs + local itemCount = 0; + if type(optionData.itemID) == "table" then + for _, id in ipairs(optionData.itemID) do + itemCount = itemCount + C_Item.GetItemCount(id); + end + else + itemCount = C_Item.GetItemCount(optionData.itemID); + end -- If no more charges, don't disable as next charge doesn't exist to re-enable. if itemCount > 0 then @@ -368,7 +402,23 @@ local function UpdatePopupVisuals(popup, data) local optionData = data.optionData; local profileOptionData = data.profileOptionData; - local itemID = optionData.itemID; + -- Determine which itemID to use for tooltip/icon/cooldown + local itemID; + if type(optionData.itemID) == "table" then + itemID = nil; + for _, id in ipairs(optionData.itemID) do + if C_Item.GetItemCount(id) > 0 then + itemID = id; + break; + end + end + -- fallback to first ID if none have count > 0 + if not itemID then + itemID = optionData.itemID[1]; + end + else + itemID = optionData.itemID; + end local itemName, itemLink = C_Item.GetItemInfo(itemID); itemName = itemName or optionData.name; @@ -435,7 +485,16 @@ local function UpdatePopupVisuals(popup, data) popup.RefreshButton:Enable(); end elseif popup.templateType == "SIPPYCUP_MissingPopupTemplate" then - local itemCount = C_Item.GetItemCount(itemID) or 0; + -- Sum item counts if multiple IDs + local itemCount = 0; + if type(optionData.itemID) == "table" then + for _, id in ipairs(optionData.itemID) do + itemCount = itemCount + C_Item.GetItemCount(id); + end + else + itemCount = C_Item.GetItemCount(optionData.itemID); + end + local text = L.POPUP_INSUFFICIENT_NEXT_REFRESH_TEXT:format(itemCount, profileOptionData.desiredStacks); popup.Text:SetText(text or ""); popup.OkayButton:SetText(OKAY); @@ -625,14 +684,12 @@ function SIPPYCUP.Popups.Toggle(itemName, auraID, enabled) -- If item can only be tracked by the item cooldown (worst) if trackByItem then - SIPPYCUP_OUTPUT.Debug("Tracking through Item"); startTime = C_Item.GetItemCooldown(optionData.itemID); if startTime and startTime > 0 then active = true; end -- If item can be tracked through the spell cooldown (fine). elseif trackBySpell then - SIPPYCUP_OUTPUT.Debug("Tracking through Spell"); local spellCooldownInfo = C_Spell.GetSpellCooldown(optionData.auraID); if canaccessvalue == nil or canaccessvalue(spellCooldownInfo) then startTime = spellCooldownInfo and spellCooldownInfo.startTime; @@ -681,7 +738,7 @@ local pendingCalls = {}; ---@param caller string What function called the popup action. ---@return nil function SIPPYCUP.Popups.QueuePopupAction(data, caller) - SIPPYCUP_OUTPUT.Debug("QueuePopupAction"); + SIPPYCUP_OUTPUT.Debug("QueuePopupAction -", caller); -- Bail out entirely when in PvP Matches, we do not show popups. if SIPPYCUP.States.pvpMatch then @@ -703,11 +760,11 @@ function SIPPYCUP.Popups.QueuePopupAction(data, caller) if pendingCalls[key] then -- Update the existing entry with the latest arguments for this auraID+reason pendingCalls[key].args = args; - return + return; end -- first call for this auraID+reason: store args and schedule - pendingCalls[key] = { args = args } + pendingCalls[key] = { args = args }; C_Timer.After(DEBOUNCE_DELAY, function() local entry = pendingCalls[key]; pendingCalls[key] = nil; @@ -726,7 +783,7 @@ end ---@param caller string What function called the popup action. ---@return nil function SIPPYCUP.Popups.HandlePopupAction(data, caller) - SIPPYCUP_OUTPUT.Debug("HandlePopupAction"); + SIPPYCUP_OUTPUT.Debug("HandlePopupAction -", caller); local optionData = data.optionData or SIPPYCUP.Options.ByAuraID[data.auraID]; local profileOptionData = data.profileOptionData or SIPPYCUP.Profile[data.auraID]; @@ -753,6 +810,65 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) return; end + local optionType = optionData.type; + local isToy = optionType == SIPPYCUP.Options.Type.TOY; + local isConsumable = optionType == SIPPYCUP.Options.Type.CONSUMABLE; + + SIPPYCUP_OUTPUT.Debug("name:", optionData.name); + -- Check for a dirty bag state, if so then defer until it is no longer. + if isConsumable and data.needsBagCheck then + if SIPPYCUP.Bags.bagGeneration < data.auraGeneration then + SIPPYCUP_OUTPUT.Debug("Reached HandlePopupAction, but bag state is dirty"); + + deferredActions[#deferredActions + 1] = { + data = data, + caller = caller, + blockedBy = { + bag = true, + }, + }; + return; + end + SIPPYCUP_OUTPUT.Debug("Reached HandlePopupAction, bag state is fine so continue."); + end + + local itemIDs = type(optionData.itemID) == "table" and optionData.itemID or { optionData.itemID }; + local usableItemID; + local itemCount = 0; + local now = GetTime(); + + -- Track which itemIDs are depleted + local depleted = {}; + + for _, id in ipairs(itemIDs) do + local count = isToy and (PlayerHasToy(id) and 1 or 0) or C_Item.GetItemCount(id); + if count > 0 then + local startTime, duration = C_Container.GetItemCooldown(id); + if not startTime or startTime + duration <= now then + usableItemID = usableItemID or id; -- pick the first usable + itemCount = itemCount + count; -- sum counts across all items + end + else + depleted[id] = true; + end + end + + -- Only update the *current usable item* + if usableItemID then + profileOptionData.currentItemID = usableItemID; + else + -- No usable item available, popup can report zero count + profileOptionData.currentItemID = itemIDs[#itemIDs]; + SIPPYCUP_OUTPUT.Debug("No usable item, reporting last itemID:", profileOptionData.currentItemID); + end + + -- Always update last known count + profileOptionData.lastItemCount = itemCount; + + -- Unnecessary for now, keep it here in case it might become so. + -- local currentItemID = profileOptionData.currentItemID or optionData.itemID; + -- local lastCount = profileOptionData.lastItemCount or itemCount; + local reason = data.reason; local active = data.active; @@ -760,45 +876,31 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) local auraInfo = data.auraInfo; local currentInstanceID = profileOptionData.currentInstanceID; - -- Removal of a spell/aura count generally is not due to an item's action, mark bag as synchronized. - -- Pre-expiration also does not do any bag changes, so mark as synchronised in case. - -- Delayed (e.g. eating x seconds) UNIT_AURA calls, mark bag as synchronized (as it was removed earlier). - -- Toys UNIT_AURA calls, mark bag as synchronized (as no items are actually used). - if reason == SIPPYCUP.Popups.Reason.REMOVAL or reason == SIPPYCUP.Popups.Reason.PRE_EXPIRATION or optionData.delayedAura or optionData.type == SIPPYCUP.Options.Type.TOY then - SIPPYCUP.Items.HandleBagUpdate(); - end - -- We defer popups in three situations: - -- > Bag data is desynch'd from UNIT_AURA. - -- > We're in combat (experimental). + -- > Bag data is desynch'd from UNIT_AURA. (Already done). + -- > We're in combat. -- > We're in a loading screen. -- This should be handled before any other logic, as there's no point to calculate deferred logic. - if SIPPYCUP.Items.bagUpdateUnhandled or InCombatLockdown() or SIPPYCUP.InLoadingScreen or SIPPYCUP.States.pvpMatch then - local blockedBy; - if SIPPYCUP.Items.bagUpdateUnhandled then - blockedBy = "bag"; - elseif InCombatLockdown() or SIPPYCUP.States.pvpMatch then - blockedBy = "combat"; -- Won't ever happen anymore as UNIT_AURA does not run in combat, legacy code. - elseif SIPPYCUP.States.loadingScreen then - blockedBy = "loading"; - end - - local deferredData = { - active = active, - auraID = auraID, - auraInfo = auraInfo, - optionData = optionData, - profileOptionData = profileOptionData, - reason = reason, + if InCombatLockdown() then + deferredActions[#deferredActions + 1] = { + data = data, + caller = caller, + blockedBy = { + combat = true, + }, }; - + return; + elseif SIPPYCUP.InLoadingScreen then deferredActions[#deferredActions + 1] = { - data = deferredData, + data = data, caller = caller, - blockedBy = blockedBy, + blockedBy = { + loading = true, + }, }; return; end + -- At this point, we're certain that we're safe to execute further! -- Recover auraInfo if possible (perhaps triggered through combat or other means) @@ -831,20 +933,16 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) active = true; end - local auraInstanceID = auraInfo and auraInfo.auraInstanceID; - local trackBySpell = false; local trackByItem = false; - if optionData.type == SIPPYCUP.Options.Type.CONSUMABLE then + if isConsumable then trackBySpell = optionData.spellTrackable; trackByItem = optionData.itemTrackable; - elseif optionData.type == SIPPYCUP.Options.Type.TOY then - -- Always track by item if itemTrackable + elseif isToy then if optionData.itemTrackable then trackByItem = true; end - if optionData.spellTrackable then if SIPPYCUP.global.UseToyCooldown then trackByItem = true; @@ -855,19 +953,17 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) end -- Extra check because toys have longer cooldowns than option tend to, so don't fire if cd is still up. - if optionData.type == SIPPYCUP.Options.Type.TOY and reason == SIPPYCUP.Popups.Reason.REMOVAL then + if isToy and reason == SIPPYCUP.Popups.Reason.REMOVAL then local cooldownActive = false; - - -- If item can only be tracked by the item cooldown (worst) if trackByItem then - SIPPYCUP_OUTPUT.Debug("Tracking through Item"); - local startTime = C_Item.GetItemCooldown(optionData.itemID); - if startTime and startTime > 0 then - cooldownActive = true; + for _, id in ipairs(itemIDs) do + local startTime, duration = C_Container.GetItemCooldown(id); + if startTime and duration and duration > 0 and (startTime + duration - now > 0) then + cooldownActive = true; + break; + end end - -- If item can be tracked through the spell cooldown (fine). elseif trackBySpell then - SIPPYCUP_OUTPUT.Debug("Tracking through Spell"); local spellCooldownInfo = C_Spell.GetSpellCooldown(optionData.auraID); local startTime; if canaccessvalue == nil or canaccessvalue(spellCooldownInfo) then @@ -877,8 +973,6 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) cooldownActive = true; end end - - -- Cooldown is active when removal happened? We don't show anything. if cooldownActive then return; end @@ -886,34 +980,41 @@ function SIPPYCUP.Popups.HandlePopupAction(data, caller) SIPPYCUP_OUTPUT.Debug({ caller = caller, auraID = optionData.auraID, itemID = optionData.itemID, name = optionData.name }); + -- Remove depleted items from optionData.itemID, but always keep at least the last one + local newItemIDs = {}; + local lastIndex = #itemIDs; + for i, id in ipairs(itemIDs) do + if not depleted[id] or i == lastIndex then + newItemIDs[#newItemIDs + 1] = id; + end + end + optionData.itemID = newItemIDs; + + local auraInstanceID = auraInfo and auraInfo.auraInstanceID; -- First, let's grab the latest currentInstanceID (or have it be nil if none which is fine). profileOptionData.currentInstanceID = (auraInfo and auraInfo.auraInstanceID) or auraInstanceID; - -- If the option does not support stacks, we always desire just 1. - profileOptionData.desiredStacks = optionData.stacks and profileOptionData.desiredStacks or 1; - - local itemCount; - if optionData.type == SIPPYCUP.Options.Type.CONSUMABLE then + if isConsumable then profileOptionData.currentStacks = SIPPYCUP.Auras.CalculateCurrentStacks(auraInfo, auraID, reason, active); - itemCount = C_Item.GetItemCount(optionData.itemID); - elseif optionData.type == SIPPYCUP.Options.Type.TOY then + elseif isToy then if auraInfo then profileOptionData.currentStacks = SIPPYCUP.Auras.CalculateCurrentStacks(auraInfo, auraID, reason, active); else profileOptionData.currentStacks = active and 1 or 0; end - itemCount = PlayerHasToy(optionData.itemID) and 1 or 0; end local requiredStacks = profileOptionData.desiredStacks - profileOptionData.currentStacks; SIPPYCUP.Popups.HandleReminderPopup({ + active = active, + auraID = auraID, + auraInfo = auraInfo, optionData = optionData, profileOptionData = profileOptionData, - requiredStacks = requiredStacks, reason = reason, + requiredStacks = requiredStacks, itemCount = itemCount, - active = active, }); end @@ -945,18 +1046,16 @@ end ---DeferAllRefreshPopups defers all active and queued popups to be processed later. -- Typically called when popups cannot be shown due to bags, combat, or loading screen. ----@param reason number Why a deferred action is being handled (0 - bag, 1 - combat, 2 - loading) +---@param reasonKey "bag"|"combat"|"loading" The gate reason being deferred. ---@return nil function SIPPYCUP.Popups.DeferAllRefreshPopups(reasonKey) - local blockedBy; - if reasonKey == 0 or SIPPYCUP.Items.bagUpdateUnhandled then - blockedBy = "bag"; - elseif reasonKey == 1 or InCombatLockdown() or SIPPYCUP.States.pvpMatch then - blockedBy = "combat"; -- Only way combat is ever used, by deferring before combat or PvP matches. - elseif reasonKey == 2 or SIPPYCUP.States.loadingScreen then - blockedBy = "loading"; + if not reasonKey then + return; end + local blockedBy = {}; + blockedBy[reasonKey] = true; + local function MakeDeferredData(popup) local d = popup.popupData; return { @@ -993,9 +1092,24 @@ function SIPPYCUP.Popups.DeferAllRefreshPopups(reasonKey) end end ----HandleDeferredActions Processes and flushes all queued popup actions. --- Called after bag data is synced (BAG_UPDATE_DELAYED) to ensure accurate context. ----@param reason number Why a deferred action is being handled (0 - bag, 1 - combat, 2 - loading) +---ForEachActivePopup iterates over all currently shown popups and calls a handler function. +---@param handler fun(popup: Frame) Callback to execute on each active popup. +---@return nil +function SIPPYCUP.Popups.ForEachActivePopup(handler) + if not handler then return; end + + for _, popup in ipairs(activePopups) do + if popup and popup:IsShown() then + handler(popup); + end + end +end + +---HandleDeferredActions Resolves and processes deferred popup actions for a specific gate. +---Removes the given reasonKey blocker from deferred entries and executes those +---that are no longer blocked and whose bag generation requirement is satisfied. +---@param reasonKey "bag"|"combat"|"loading" The gate reason being cleared. +---@return nil function SIPPYCUP.Popups.HandleDeferredActions(reasonKey) if not deferredActions or #deferredActions == 0 then return; @@ -1004,14 +1118,30 @@ function SIPPYCUP.Popups.HandleDeferredActions(reasonKey) local i = 1; while i <= #deferredActions do local action = deferredActions[i]; - if action.blockedBy == reasonKey then - SIPPYCUP.Popups.QueuePopupAction( - action.data, - action.caller - ); - tremove(deferredActions, i); -- remove handled item, don't increment + + -- Remove the specified blocker from this action + if action.blockedBy and action.blockedBy[reasonKey] then + action.blockedBy[reasonKey] = nil; + end + + -- Check if any blockers remain + local stillBlocked = action.blockedBy and next(action.blockedBy) ~= nil; + + if not stillBlocked then + -- Final bag ordering safety check + local gen = action.data.auraGeneration or 0; + + if SIPPYCUP.Bags.bagGeneration >= gen then + SIPPYCUP.Popups.QueuePopupAction( + action.data, + action.caller + ); + tremove(deferredActions, i); + else + i = i + 1; + end else - i = i + 1; -- skip non-matching item + i = i + 1; end end end diff --git a/Modules/TRP/TRP.lua b/Modules/TRP/TRP.lua index 3b566bd..ddc14c5 100644 --- a/Modules/TRP/TRP.lua +++ b/Modules/TRP/TRP.lua @@ -26,7 +26,7 @@ local function onStart() SIPPYCUP_Addon:OpenSettings(8); end end, - } + }; end) end diff --git a/SippyCup.lua b/SippyCup.lua index b19dd76..1be9d31 100644 --- a/SippyCup.lua +++ b/SippyCup.lua @@ -93,6 +93,8 @@ function SIPPYCUP_Addon:OnPlayerLogin() -- Register game events related to aura and spell tracking for Sippy Cup. SIPPYCUP_Addon:RegisterUnitEvent("UNIT_AURA", "player"); SIPPYCUP_Addon:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player"); + SIPPYCUP_Addon:RegisterUnitEvent("UNIT_SPELLCAST_RETICLE_CLEAR", "player"); + SIPPYCUP_Addon:RegisterUnitEvent("UNIT_SPELLCAST_INTERRUPTED", "player"); SIPPYCUP.Config.TryCreateConfigFrame(); end @@ -142,7 +144,7 @@ end ---BAG_UPDATE_DELAYED Handles delayed bag updates and triggers item update processing. function SIPPYCUP_Addon:BAG_UPDATE_DELAYED() - SIPPYCUP.Items.HandleBagUpdate(); + SIPPYCUP.Bags.BagUpdateDelayed(); end function SIPPYCUP_Addon:ADDON_RESTRICTION_STATE_CHANGED(_, type, state) -- luacheck: no unused (type) @@ -189,7 +191,7 @@ function SIPPYCUP_Addon:PLAYER_REGEN_DISABLED() -- Combat is entered when regen is disabled. self:StopContinuousCheck(); - SIPPYCUP.Popups.DeferAllRefreshPopups(1); + SIPPYCUP.Popups.DeferAllRefreshPopups("combat"); end ---PLAYER_REGEN_ENABLED Restarts continuous checks and handles deferred combat actions after leaving combat. @@ -214,8 +216,7 @@ function SIPPYCUP_Addon:UNIT_AURA(_, unitTarget, updateInfo) -- luacheck: no unu if InCombatLockdown() or C_Secrets and C_Secrets.ShouldAurasBeSecret() or SIPPYCUP.States.pvpMatch then return; end - -- Bag data is not synched immediately when UNIT_AURA fires, signal desync to the addon. - SIPPYCUP.Items.bagUpdateUnhandled = true; + SIPPYCUP.Auras.Convert(SIPPYCUP.Auras.Sources.UNIT_AURA, updateInfo); end @@ -229,6 +230,34 @@ function SIPPYCUP_Addon:UNIT_SPELLCAST_SUCCEEDED(_, unitTarget, _, spellID) -- l SIPPYCUP.Items.CheckNoAuraSingleOption(nil, spellID); end +function SIPPYCUP_Addon:UNIT_SPELLCAST_RETICLE_CLEAR(_, unitTarget, _, spellID) -- luacheck: no unused (unitTarget) + if InCombatLockdown() or not canaccessvalue(spellID) then + return; + end + + if SIPPYCUP.Database.castAuraToProfile[spellID] then + SIPPYCUP.Popups.ForEachActivePopup(function(popup) + if popup.templateType == "SIPPYCUP_RefreshPopupTemplate" and popup.RefreshButton and not popup.RefreshButton:IsEnabled() then + popup.RefreshButton:Enable(); + end + end); + end +end + +function SIPPYCUP_Addon:UNIT_SPELLCAST_INTERRUPTED(_, unitTarget, _, spellID) -- luacheck: no unused (unitTarget) + if InCombatLockdown() or not canaccessvalue(spellID) then + return; + end + + if SIPPYCUP.Database.castAuraToProfile[spellID] then + SIPPYCUP.Popups.ForEachActivePopup(function(popup) + if popup.templateType == "SIPPYCUP_RefreshPopupTemplate" and popup.RefreshButton and not popup.RefreshButton:IsEnabled() then + popup.RefreshButton:Enable(); + end + end); + end +end + ---ZONE_CHANGED_NEW_AREA Handles zone changes and triggers loading screen end logic if needed. function SIPPYCUP_Addon:ZONE_CHANGED_NEW_AREA() SIPPYCUP.Callbacks:TriggerEvent(SIPPYCUP.Events.LOADING_SCREEN_ENDED); diff --git a/UI/Config.lua b/UI/Config.lua index 10a03ba..eb9d7cb 100644 --- a/UI/Config.lua +++ b/UI/Config.lua @@ -18,7 +18,7 @@ local defaultSounds = { { key = "fx_ship_bell_chime_02", fid = 1129274 }, { key = "fx_ship_bell_chime_03", fid = 1129275 }, { key = "raidwarning", fid = 567397 }, -} +}; -- Register default sounds for _, sound in ipairs(defaultSounds) do @@ -26,9 +26,9 @@ for _, sound in ipairs(defaultSounds) do end -- Build soundList with keys = values for quick lookup/use -local soundList = {} +local soundList = {}; for _, soundName in ipairs(SharedMedia:List("sound")) do - soundList[soundName] = soundName + soundList[soundName] = soundName; end function SIPPYCUP.Config.TryCreateConfigFrame() @@ -37,6 +37,26 @@ function SIPPYCUP.Config.TryCreateConfigFrame() end end +-- Utility to get the first valid itemID from number or table +local function GetFirstItemID(itemID) + if type(itemID) == "table" then + for _, id in ipairs(itemID) do + if id and id > 0 then + return id; + end + end + return nil; + else + return itemID; + end +end + +-- Example: toy checkbox disabled function +local function IsToyDisabled(toyID) + local firstID = GetFirstItemID(toyID); + return not firstID or not PlayerHasToy(firstID); +end + ---AddTab creates a new tab button under the given parent frame and adds it to the parent's Tabs list. ---It positions the new tab relative to existing tabs, sets up its scripts for show and click events, and registers it for ElvUI skinning. ---@param parent table Frame containing the Tabs table and the SetTab function. @@ -207,17 +227,20 @@ end ---AttachItemTooltip adds mouseover tooltips showing item info by itemID to one or multiple frames. ---It sets up OnEnter and OnLeave scripts to show and hide the item tooltip anchored as specified. ---@param frames table|table[] The single frame or list of frames to attach the tooltip to. ----@param itemID number The item ID to create the tooltip for. +---@param itemID number|number[] The item ID(s) to create the tooltip for. ---@param anchor string? Optional anchor point for tooltip, defaults to "ANCHOR_TOP". local function AttachItemTooltip(frames, itemID, anchor) if not itemID then return; end anchor = anchor or "ANCHOR_TOP"; + local firstID = GetFirstItemID(itemID); + if not firstID then return; end + local isList = type(frames) == "table" and not frames.GetObjectType; local firstFrame = isList and frames[1] or frames; if not firstFrame then return; end - local item = Item:CreateFromItemID(itemID); + local item = Item:CreateFromItemID(firstID); item:ContinueOnItemLoad(function() local itemLink = item:GetItemLink(); @@ -440,7 +463,7 @@ local function CreateInset(parent, insetData) ElvUI.RegisterSkinnableElement(infoInset, "inset"); -- Distance from bottom - local bottomOffset = 20 + local bottomOffset = 20; infoInset:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 20, bottomOffset); infoInset:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -20, bottomOffset); @@ -496,6 +519,21 @@ local function CreateInset(parent, insetData) bsky:SetScript("OnClick", function() SIPPYCUP.LinkDialog.CreateExternalLinkDialog("https://bsky.app/profile/dawnsong.me"); end); + + if SIPPYCUP.IS_DEV_BUILD then + local printCheckbox = CreateFrame("CheckButton", nil, infoInset, "SettingsCheckBoxTemplate"); + printCheckbox:SetPoint("TOPRIGHT", bsky, "TOPLEFT", -8, 0); + printCheckbox:SetSize(22, 22); + ElvUI.RegisterSkinnableElement(printCheckbox, "checkbox"); + + printCheckbox:SetChecked(SIPPYCUP.Database.GetSetting("global", "DebugOutput", nil)); + + AttachTooltip(printCheckbox, "Enable Debug Output", "Click this to enable the debug prints.|n|nIf you see this without knowing what debug is, you might've done something wrong!"); + + printCheckbox:SetScript("OnClick", function(self) + SIPPYCUP.Database.UpdateSetting("global", "DebugOutput", nil, self:GetChecked()) + end); + end end end @@ -860,6 +898,8 @@ local function CreateConfigSlider(elementContainer, data) local r, g, b = WHITE_FONT_COLOR:GetRGB(); + widget.TopText:SetText(data.label); + widget.MinText:SetText(minVal); widget.MinText:SetVertexColor(r, g, b); @@ -868,13 +908,14 @@ local function CreateConfigSlider(elementContainer, data) widget.RightText:SetVertexColor(r, g, b); + widget.TopText:Show(); widget.MinText:Show(); widget.MaxText:Show(); widget.RightText:Show(); widget:SetPoint("LEFT", 0, 0); widget:SetWidth(elementContainer:GetWidth() * 0.8); - widget:SetHeight(41); + widget:SetHeight(data.height or 41); if data.buildAdded and SIPPYCUP_BUILDINFO.CheckNewlyAdded(data.buildAdded) then local newPip = widget:CreateFontString(nil, "OVERLAY", "GameFontHighlight"); @@ -1387,7 +1428,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() func = function() SIPPYCUP.Popups.ResetIgnored(); end, - } + }, }; self.allWidgets[#self.allWidgets + 1] = CreateWidgetRowContainer(generalPanel, reminderCheckboxData); @@ -1414,7 +1455,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() SIPPYCUP.Database.UpdateSetting("global", "PopupPosition", nil, val); end, }, - } + }; self.allWidgets[#self.allWidgets + 1] = CreateWidgetRowContainer(generalPanel, positionWidgetData); @@ -1461,7 +1502,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() SIPPYCUP.Database.UpdateSetting("global", "FlashTaskbar", nil, val); end, }, - } + }; self.allWidgets[#self.allWidgets + 1] = CreateWidgetRowContainer(generalPanel, alertWidgetData); @@ -1488,7 +1529,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() end end, }, - } + }; self.allWidgets[#self.allWidgets + 1] = CreateWidgetRowContainer(generalPanel, integrationsWidgetData); @@ -1524,7 +1565,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() text = "Bluesky", tooltip = L.OPTIONS_GENERAL_BLUESKY_SHILL_DESC, }, - } + }; self.allWidgets[#self.allWidgets + 1] = CreateInset(generalPanel, insetData); @@ -1599,6 +1640,66 @@ function SIPPYCUP_ConfigMixin:OnLoad() end end + if categoryName == "PRISM" then + CreateCategoryHeader(categoryPanel, SETTINGS); + + local prismWidgetData = { + { + type = "slider", + label = L.OPTIONS_TAB_PRISM_TIMER:format(SIPPYCUP.Options.ByItemID[193031].name), + tooltip = L.OPTIONS_TAB_PRISM_TIMER_TEXT:format(5, 5), + buildAdded = "0.7.0|120001", + min = 1, + max = 7, + step = 1, + height = 35, + disabled = function() + return not SIPPYCUP.Database.GetSetting("global", "ProjectionPrismPreExpirationLeadTimer", nil); + end, + get = function() + return SIPPYCUP.Database.GetSetting("global", "ProjectionPrismPreExpirationLeadTimer", nil); + end, + set = function(val) + SIPPYCUP.Database.UpdateSetting("global", "ProjectionPrismPreExpirationLeadTimer", nil, val); + local reason = SIPPYCUP.Popups.Reason.PRE_EXPIRATION; + SIPPYCUP.Auras.CancelAllPreExpirationTimers(); + SIPPYCUP.Items.CancelAllItemTimers(reason); + SIPPYCUP.Popups.HideAllRefreshPopups(reason); + SIPPYCUP.Options.RefreshStackSizes(SIPPYCUP.MSP.IsEnabled() and SIPPYCUP.global.MSPStatusCheck, false, true); + end, + }, + { + type = "slider", + label = L.OPTIONS_TAB_PRISM_TIMER:format(SIPPYCUP.Options.ByItemID[112384].name), + tooltip = L.OPTIONS_TAB_PRISM_TIMER_TEXT:format(3, 3), + buildAdded = "0.7.0|120001", + min = 1, + max = 5, + step = 1, + height = 35, + disabled = function() + return not SIPPYCUP.Database.GetSetting("global", "ReflectingPrismPreExpirationLeadTimer", nil); + end, + get = function() + return SIPPYCUP.Database.GetSetting("global", "ReflectingPrismPreExpirationLeadTimer", nil); + end, + set = function(val) + SIPPYCUP.Database.UpdateSetting("global", "ReflectingPrismPreExpirationLeadTimer", nil, val); + local reason = SIPPYCUP.Popups.Reason.PRE_EXPIRATION; + SIPPYCUP.Auras.CancelAllPreExpirationTimers(); + SIPPYCUP.Items.CancelAllItemTimers(reason); + SIPPYCUP.Popups.HideAllRefreshPopups(reason); + SIPPYCUP.Options.RefreshStackSizes(SIPPYCUP.MSP.IsEnabled() and SIPPYCUP.global.MSPStatusCheck, false, true); + end, + }, + } + + local widgets = CreateWidgetRowContainer(categoryPanel, prismWidgetData, 2, 40, 20, true); + + self.profileWidgets[#self.profileWidgets + 1] = widgets; + self.allWidgets[#self.allWidgets + 1] = widgets; + end + if #categoryConsumablesData > 0 then CreateCategoryHeader(categoryPanel, BAG_FILTER_CONSUMABLES); @@ -1634,7 +1735,7 @@ function SIPPYCUP_ConfigMixin:OnLoad() stacks = optionData.stacks, buildAdded = optionData.buildAdded, disabled = function() - return not PlayerHasToy(toyID); + return IsToyDisabled(toyID); end, get = function() return SIPPYCUP.Database.GetSetting("profile", checkboxProfileKey, "enable");