diff --git a/.luacheckrc b/.luacheckrc index 4103bfb..9177293 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -37,7 +37,7 @@ globals = { "GetMinimapShape", "GetMinimapShape", "PanelTemplates_TabResize", "GetGuildRosterShowOffline", "SetGuildRosterShowOffline", "IsInGuild", "GetGuildInfo", "SetGuildRosterShowOffline", "PLAYER", "INVENTORY_TOOLTIP", "BAGSLOT", "UNKNOWN", "UnitIsDead", "ShowPrompt", "_MB_GetOrCreateShamanPos", "ensureHiddenTooltip", "MB_TAB_TITLE_DEFAULT", "SPELLBOOK", "MB_PAGE_DEFAULT", "SPELLBOOK_END_NON_SPELL_STREAK", "sendInventoryItemCommand", "RAID_CLASS_COLORS", "INSPECT", "MB_INVENTORY_LABEL", "LOADING", "ITEM", "ITEMS", "SEARCH", "NO_QUESTS_LABEL", "QUESTS_LABEL", "QUEST_LOG", "UnitIsUnit", "ITEM_STARTS_QUEST", "TRACKER_HEADER_QUESTS", - "GetItemInfoInstant", "LE_ITEM_CLASS_QUESTITEM" + "GetItemInfoInstant", "LE_ITEM_CLASS_QUESTITEM", "config", "INV_SLOT_MAINHAND", "INV_SLOT_OFFHAND", "LoadAddOn", "HandleModifiedItemClick", } read_globals = { diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ddbe112 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Lua / WoW addon rules +- After changing any `.lua` file, run `luac -p` on every modified Lua file before returning the final diff. +- Target Lua 5.1 syntax compatibility for WoW 3.3.5 addons. +- If a syntax check fails, report the exact file and line. +- Keep diffs minimal. +- Always return formatted diffs with real line breaks and file paths. \ No newline at end of file diff --git a/Core/MultiBot.lua b/Core/MultiBot.lua index 50b108d..1197781 100644 --- a/Core/MultiBot.lua +++ b/Core/MultiBot.lua @@ -176,21 +176,10 @@ local HUNTER_PET_STANCE_MIGRATION_KEY = "hunterPetStanceVersion" local FAVORITES_MIGRATION_KEY = "favoritesVersion" local function getUiMigrationStore() - local profile = MultiBot.db and MultiBot.db.profile - if not profile then + if not (MultiBot.Store and MultiBot.Store.GetMigrationStore) then return nil end - - profile.migrations = profile.migrations or {} - - -- Keep only numeric migration version entries in this table. - for key, value in pairs(profile.migrations) do - if type(value) ~= "number" then - profile.migrations[key] = nil - end - end - - return profile.migrations + return MultiBot.Store.GetMigrationStore() end local function shouldSyncLegacyUiState(versionKey, targetVersion) @@ -204,7 +193,7 @@ local function shouldSyncLegacyUiState(versionKey, targetVersion) end local function markLegacyUiStateMigrated(versionKey, targetVersion) - local migrations = getUiMigrationStore() + local migrations = MultiBot.Store and MultiBot.Store.EnsureMigrationStore and MultiBot.Store.EnsureMigrationStore() if not migrations then return end @@ -230,23 +219,9 @@ local function getLegacyGlobalBotStore() end local function isGlobalBotRosterEntry(value) - if type(value) ~= "string" then - return false - end - - return value:match("^[^,]+,%[[^%]]+%],[^,]*,%d+/%d+/%d+,[^,]+,%-?%d+,%-?%d+$") ~= nil -end - -local function sanitizeGlobalBotStore(store) - if type(store) ~= "table" then - return - end - - for botName, value in pairs(store) do - if type(botName) ~= "string" or not isGlobalBotRosterEntry(value) then - store[botName] = nil - end - end + return MultiBot.Store + and MultiBot.Store.IsValidGlobalBotRosterEntry + and MultiBot.Store.IsValidGlobalBotRosterEntry(value) end local function migrateLegacyGlobalBotStoreIfNeeded(store, legacyStore) @@ -271,13 +246,20 @@ local function migrateLegacyGlobalBotStoreIfNeeded(store, legacyStore) end function MultiBot.GetGlobalBotStore() - local profile = MultiBot.db and MultiBot.db.profile local legacyStore = getLegacyGlobalBotStore() - if profile then - profile.bots = profile.bots or {} - migrateLegacyGlobalBotStoreIfNeeded(profile.bots, legacyStore) - sanitizeGlobalBotStore(profile.bots) - return profile.bots + local store = MultiBot.Store and MultiBot.Store.GetBotsStore and MultiBot.Store.GetBotsStore() + if not store and shouldSyncLegacyUiState(GLOBAL_BOT_STORE_MIGRATION_KEY, GLOBAL_BOT_STORE_MIGRATION_VERSION) then + for _, value in pairs(legacyStore or {}) do + if isGlobalBotRosterEntry(value) then + store = MultiBot.Store and MultiBot.Store.EnsureBotsStore and MultiBot.Store.EnsureBotsStore() + break + end + end + end + if store then + migrateLegacyGlobalBotStoreIfNeeded(store, legacyStore) + MultiBot.Store.SanitizeGlobalBotStore(store) + return store end return legacyStore @@ -291,7 +273,10 @@ function MultiBot.SetGlobalBotEntry(name, value) return nil end - local store = MultiBot.GetGlobalBotStore and MultiBot.GetGlobalBotStore() or getLegacyGlobalBotStore() + local store = MultiBot.Store and MultiBot.Store.EnsureBotsStore and MultiBot.Store.EnsureBotsStore() + if not store then + store = getLegacyGlobalBotStore() + end store[name] = value if shouldSyncLegacyUiState(GLOBAL_BOT_STORE_MIGRATION_KEY, GLOBAL_BOT_STORE_MIGRATION_VERSION) then @@ -303,7 +288,10 @@ function MultiBot.SetGlobalBotEntry(name, value) end function MultiBot.ClearGlobalBotStore() - local store = MultiBot.GetGlobalBotStore and MultiBot.GetGlobalBotStore() or getLegacyGlobalBotStore() + local store = MultiBot.Store and MultiBot.Store.EnsureBotsStore and MultiBot.Store.EnsureBotsStore() + if not store then + store = getLegacyGlobalBotStore() + end if wipe then wipe(store) else @@ -353,14 +341,14 @@ local function getLegacyMinimapConfig(createIfMissing) end function MultiBot.GetMinimapConfig() - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} - profile.ui.minimap = profile.ui.minimap or {} + local minimap = MultiBot.Store and MultiBot.Store.GetUIChildStore and MultiBot.Store.GetUIChildStore("minimap") + local legacy = getLegacyMinimapConfig(false) - local minimap = profile.ui.minimap + if not minimap and shouldSyncLegacyUiState(MINIMAP_CONFIG_MIGRATION_KEY, MINIMAP_CONFIG_MIGRATION_VERSION) and legacy then + minimap = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("minimap") + end + if minimap then if shouldSyncLegacyUiState(MINIMAP_CONFIG_MIGRATION_KEY, MINIMAP_CONFIG_MIGRATION_VERSION) then - local legacy = getLegacyMinimapConfig(false) if type(minimap.hide) ~= "boolean" then minimap.hide = (legacy and legacy.hide) or MINIMAP_CONFIG_DEFAULTS.hide end @@ -383,11 +371,14 @@ function MultiBot.GetMinimapConfig() return minimap end - return getLegacyMinimapConfig(true) + return legacy or { hide = MINIMAP_CONFIG_DEFAULTS.hide, angle = MINIMAP_CONFIG_DEFAULTS.angle } end function MultiBot.SetMinimapConfig(key, value) - local minimap = MultiBot.GetMinimapConfig() + local minimap = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("minimap") + if not minimap then + minimap = getLegacyMinimapConfig(true) + end minimap[key] = value if shouldSyncLegacyUiState(MINIMAP_CONFIG_MIGRATION_KEY, MINIMAP_CONFIG_MIGRATION_VERSION) then @@ -416,13 +407,12 @@ local function getLegacyGlobalStrataLevel(createIfMissing) end function MultiBot.GetGlobalStrataLevel() - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} + local strata = MultiBot.Store and MultiBot.Store.GetUIValue and MultiBot.Store.GetUIValue("strataLevel") + if MultiBot.Store and MultiBot.Store.GetUIStore and MultiBot.Store.GetUIStore() then if shouldSyncLegacyUiState(STRATA_LEVEL_MIGRATION_KEY, STRATA_LEVEL_MIGRATION_VERSION) then local legacyLevel = getLegacyGlobalStrataLevel(false) - if type(profile.ui.strataLevel) ~= "string" or profile.ui.strataLevel == "" then - profile.ui.strataLevel = legacyLevel or STRATA_LEVEL_DEFAULT + if (type(strata) ~= "string" or strata == "") and legacyLevel then + strata = MultiBot.Store.SetUIValue and MultiBot.Store.SetUIValue("strataLevel", legacyLevel) end markLegacyUiStateMigrated(STRATA_LEVEL_MIGRATION_KEY, STRATA_LEVEL_MIGRATION_VERSION) @@ -430,13 +420,13 @@ function MultiBot.GetGlobalStrataLevel() local _, globalSave = ensureSavedVariables() globalSave["Strata.Level"] = nil end - if type(profile.ui.strataLevel) ~= "string" or profile.ui.strataLevel == "" then - profile.ui.strataLevel = STRATA_LEVEL_DEFAULT + if type(strata) ~= "string" or strata == "" then + strata = STRATA_LEVEL_DEFAULT end - return profile.ui.strataLevel + return strata end - return getLegacyGlobalStrataLevel(true) + return getLegacyGlobalStrataLevel(false) or STRATA_LEVEL_DEFAULT end function MultiBot.SetGlobalStrataLevel(level) @@ -449,10 +439,8 @@ function MultiBot.SetGlobalStrataLevel(level) globalSave["Strata.Level"] = level end - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} - profile.ui.strataLevel = level + if MultiBot.Store and MultiBot.Store.SetUIValue then + MultiBot.Store.SetUIValue("strataLevel", level) end return level @@ -490,13 +478,15 @@ local function getLegacyMainUIVisible(createIfMissing) end function MultiBot.GetMainUIVisibleConfig() - - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} + local uiStore = MultiBot.Store and MultiBot.Store.GetUIStore and MultiBot.Store.GetUIStore() + local visible = MultiBot.Store and MultiBot.Store.GetUIValue and MultiBot.Store.GetUIValue("mainVisible") + if uiStore then if shouldSyncLegacyUiState(MAIN_VISIBLE_MIGRATION_KEY, MAIN_VISIBLE_MIGRATION_VERSION) then - if type(profile.ui.mainVisible) ~= "boolean" then - profile.ui.mainVisible = getLegacyMainUIVisible(false) + if type(visible) ~= "boolean" then + local legacyValue = getLegacyMainUIVisible(false) + if type(legacyValue) == "boolean" then + visible = MultiBot.Store.SetUIValue and MultiBot.Store.SetUIValue("mainVisible", legacyValue) + end end markLegacyUiStateMigrated(MAIN_VISIBLE_MIGRATION_KEY, MAIN_VISIBLE_MIGRATION_VERSION) @@ -504,13 +494,13 @@ function MultiBot.GetMainUIVisibleConfig() local save = ensureSavedVariables() save["UIVisible"] = nil end - if type(profile.ui.mainVisible) ~= "boolean" then - profile.ui.mainVisible = MAIN_UI_VISIBLE_DEFAULT + if type(visible) ~= "boolean" then + visible = MAIN_UI_VISIBLE_DEFAULT end - return profile.ui.mainVisible + return visible end - return getLegacyMainUIVisible(true) + return getLegacyMainUIVisible(false) or MAIN_UI_VISIBLE_DEFAULT end function MultiBot.SetMainUIVisibleConfig(value) @@ -520,10 +510,8 @@ function MultiBot.SetMainUIVisibleConfig(value) save["UIVisible"] = visible end - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} - profile.ui.mainVisible = visible + if MultiBot.Store and MultiBot.Store.SetUIValue then + MultiBot.Store.SetUIValue("mainVisible", visible) end return visible @@ -615,14 +603,12 @@ function MultiBot.GetQuickFramePosition(frameKey) return nil end - local profile = MultiBot.db and MultiBot.db.profile local legacyPosStore = getLegacyQuickFramePositionStore(false) - - if profile then - profile.ui = profile.ui or {} - profile.ui.quickFramePositions = profile.ui.quickFramePositions or {} - - local store = profile.ui.quickFramePositions + local store = MultiBot.Store and MultiBot.Store.GetUIChildStore and MultiBot.Store.GetUIChildStore("quickFramePositions") + if not store and shouldSyncLegacyUiState(QUICK_FRAME_POSITIONS_MIGRATION_KEY, QUICK_FRAME_POSITIONS_MIGRATION_VERSION) and legacyPosStore then + store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("quickFramePositions") + end + if store then migrateLegacyQuickFramePositionsIfNeeded(store, legacyPosStore) local pos = store[frameKey] @@ -652,21 +638,26 @@ function MultiBot.SetQuickFramePosition(frameKey, point, relPoint, x, y) y = y, } - local profile = MultiBot.db and MultiBot.db.profile local legacyPosStore = getLegacyQuickFramePositionStore(false) - if profile then - profile.ui = profile.ui or {} - profile.ui.quickFramePositions = profile.ui.quickFramePositions or {} - - local store = profile.ui.quickFramePositions + local store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("quickFramePositions") + if store then migrateLegacyQuickFramePositionsIfNeeded(store, legacyPosStore) store[frameKey] = position end if shouldSyncLegacyUiState(QUICK_FRAME_POSITIONS_MIGRATION_KEY, QUICK_FRAME_POSITIONS_MIGRATION_VERSION) then legacyPosStore = legacyPosStore or getLegacyQuickFramePositionStore(true) - legacyPosStore[frameKey] = legacyPosStore[frameKey] or {} - legacyPosStore[frameKey].frame = position + local legacyEntry + if MultiBot.Store and MultiBot.Store.EnsureTableField then + legacyEntry = MultiBot.Store.EnsureTableField(legacyPosStore, frameKey, {}) + else + legacyEntry = legacyPosStore[frameKey] + if type(legacyEntry) ~= "table" then + legacyEntry = {} + legacyPosStore[frameKey] = legacyEntry + end + end + legacyEntry.frame = position end return position @@ -677,17 +668,14 @@ function MultiBot.GetQuickFrameVisibleConfig(frameKey) return true end - local profile = MultiBot.db and MultiBot.db.profile - if not profile then + local store = MultiBot.Store and MultiBot.Store.GetUIChildStore and MultiBot.Store.GetUIChildStore("quickFrameVisibility") + if not store then return true end - profile.ui = profile.ui or {} - profile.ui.quickFrameVisibility = profile.ui.quickFrameVisibility or {} - - local value = profile.ui.quickFrameVisibility[frameKey] + local value = store[frameKey] if type(value) ~= "boolean" then - profile.ui.quickFrameVisibility[frameKey] = true + store[frameKey] = true return true end @@ -700,11 +688,9 @@ function MultiBot.SetQuickFrameVisibleConfig(frameKey, visible) end local value = not not visible - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} - profile.ui.quickFrameVisibility = profile.ui.quickFrameVisibility or {} - profile.ui.quickFrameVisibility[frameKey] = value + local store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("quickFrameVisibility") + if store then + store[frameKey] = value end return value @@ -762,13 +748,11 @@ function MultiBot.GetHunterPetStance(name) end local legacyStore = getLegacyHunterPetStanceStore(false) - local profile = MultiBot.db and MultiBot.db.profile - - if profile then - profile.ui = profile.ui or {} - profile.ui.hunterPetStance = profile.ui.hunterPetStance or {} - - local store = profile.ui.hunterPetStance + local store = MultiBot.Store and MultiBot.Store.GetUIChildStore and MultiBot.Store.GetUIChildStore("hunterPetStance") + if not store and shouldSyncLegacyUiState(HUNTER_PET_STANCE_MIGRATION_KEY, HUNTER_PET_STANCE_MIGRATION_VERSION) and legacyStore then + store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("hunterPetStance") + end + if store then migrateLegacyHunterPetStanceIfNeeded(store, legacyStore) local value = store[name] @@ -790,14 +774,9 @@ function MultiBot.SetHunterPetStance(name, stance) return nil end - local profile = MultiBot.db and MultiBot.db.profile local legacyStore = getLegacyHunterPetStanceStore(false) - - if profile then - profile.ui = profile.ui or {} - profile.ui.hunterPetStance = profile.ui.hunterPetStance or {} - - local store = profile.ui.hunterPetStance + local store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("hunterPetStance") + if store then migrateLegacyHunterPetStanceIfNeeded(store, legacyStore) store[name] = stance end @@ -829,24 +808,24 @@ local function getLegacyShamanTotemsStore(createIfMissing) end local function getShamanTotemsStore(createLegacyIfMissing) - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.ui = profile.ui or {} - profile.ui.shamanTotems = profile.ui.shamanTotems or {} - return profile.ui.shamanTotems, true + local store + if createLegacyIfMissing then + store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("shamanTotems") + else + store = MultiBot.Store and MultiBot.Store.GetUIChildStore and MultiBot.Store.GetUIChildStore("shamanTotems") + end + if store then + return store, true end return getLegacyShamanTotemsStore(createLegacyIfMissing), false end local function getShamanTotemsMigrationStore() - local profile = MultiBot.db and MultiBot.db.profile - if not profile then + if not (MultiBot.Store and MultiBot.Store.EnsureMigrationStore) then return nil end - - profile.migrations = profile.migrations or {} - return profile.migrations + return MultiBot.Store.EnsureMigrationStore() end local function shouldSyncLegacyShamanTotems() @@ -932,13 +911,31 @@ function MultiBot.SetShamanTotemChoice(name, elementKey, icon) if not store then return nil end - store[name] = store[name] or {} - store[name][elementKey] = icon + local botStore + if MultiBot.Store and MultiBot.Store.EnsureTableField then + botStore = MultiBot.Store.EnsureTableField(store, name, {}) + else + botStore = store[name] + if type(botStore) ~= "table" then + botStore = {} + store[name] = botStore + end + end + botStore[elementKey] = icon if shouldSyncLegacyShamanTotems() then local legacyStore = getLegacyShamanTotemsStore(true) - legacyStore[name] = legacyStore[name] or {} - legacyStore[name][elementKey] = icon + local legacyBotStore + if MultiBot.Store and MultiBot.Store.EnsureTableField then + legacyBotStore = MultiBot.Store.EnsureTableField(legacyStore, name, {}) + else + legacyBotStore = legacyStore[name] + if type(legacyBotStore) ~= "table" then + legacyBotStore = {} + legacyStore[name] = legacyBotStore + end + end + legacyBotStore[elementKey] = icon end return icon @@ -1324,15 +1321,17 @@ local function getLegacyFavoritesStore(createIfMissing) end local function getFavoritesStore() - local profile = MultiBot.db and MultiBot.db.profile - if profile then - profile.favorites = profile.favorites or {} - + local favorites = MultiBot.Store and MultiBot.Store.GetFavoritesStore and MultiBot.Store.GetFavoritesStore() + local legacyFavorites = getLegacyFavoritesStore(false) or {} + local hasLegacyFavorites = next(legacyFavorites) ~= nil + if not favorites and hasLegacyFavorites and shouldSyncLegacyUiState(FAVORITES_MIGRATION_KEY, FAVORITES_MIGRATION_VERSION) then + favorites = MultiBot.Store and MultiBot.Store.EnsureFavoritesStore and MultiBot.Store.EnsureFavoritesStore() + end + if favorites then if shouldSyncLegacyUiState(FAVORITES_MIGRATION_KEY, FAVORITES_MIGRATION_VERSION) then - local legacyFavorites = getLegacyFavoritesStore(false) or {} for name, isFavorite in pairs(legacyFavorites) do - if profile.favorites[name] == nil then - profile.favorites[name] = isFavorite + if favorites[name] == nil then + favorites[name] = isFavorite end end @@ -1343,7 +1342,7 @@ local function getFavoritesStore() savedVars.Favorites = nil end - return profile.favorites + return favorites end return getLegacyFavoritesStore(false) or {} @@ -1396,8 +1395,10 @@ function MultiBot.UpdateFavoritesIndex() end function MultiBot.SetFavorite(name, isFav) - local profile = MultiBot.db and MultiBot.db.profile - local favorites = profile and getFavoritesStore() or getLegacyFavoritesStore(true) + local favorites = MultiBot.Store and MultiBot.Store.EnsureFavoritesStore and MultiBot.Store.EnsureFavoritesStore() + if not favorites then + favorites = getLegacyFavoritesStore(true) + end if isFav then favorites[name] = true else favorites[name] = nil end diff --git a/Core/MultiBotConfig.lua b/Core/MultiBotConfig.lua index 4b42085..8edb3d9 100644 --- a/Core/MultiBotConfig.lua +++ b/Core/MultiBotConfig.lua @@ -54,61 +54,69 @@ local function getLegacyThrottleValue(name) return MultiBotDB and MultiBotDB.throttle and MultiBotDB.throttle[name] end +local ensureTableField + local function migrateLegacyConfigIntoProfile(profile) if type(profile) ~= "table" then return end - profile.timers = profile.timers or {} + local timers = ensureTableField(profile, "timers") for key, defaultValue in pairs(DEFAULTS) do local legacyValue = getLegacyTimerValue(key) if type(legacyValue) == "number" and legacyValue > 0 then - profile.timers[key] = legacyValue - elseif type(profile.timers[key]) ~= "number" or profile.timers[key] <= 0 then - profile.timers[key] = defaultValue + timers[key] = legacyValue + elseif type(timers[key]) ~= "number" or timers[key] <= 0 then + timers[key] = defaultValue end end - profile.throttle = profile.throttle or {} + local throttle = ensureTableField(profile, "throttle") for key, defaultValue in pairs(THROTTLE_DEFAULTS) do local legacyValue = getLegacyThrottleValue(key) if type(legacyValue) == "number" and legacyValue > 0 then - profile.throttle[key] = legacyValue - elseif type(profile.throttle[key]) ~= "number" or profile.throttle[key] <= 0 then - profile.throttle[key] = defaultValue + throttle[key] = legacyValue + elseif type(throttle[key]) ~= "number" or throttle[key] <= 0 then + throttle[key] = defaultValue end end - profile.ui = profile.ui or {} - profile.ui.mainBar = profile.ui.mainBar or {} - if type(profile.ui.mainBar.moveLocked) ~= "boolean" then - profile.ui.mainBar.moveLocked = UI_DEFAULTS.mainBar.moveLocked + local ui = ensureTableField(profile, "ui") + local mainBar = ensureTableField(ui, "mainBar") + if MultiBot.Store and MultiBot.Store.NormalizeMainBarSettings then + MultiBot.Store.NormalizeMainBarSettings(mainBar, UI_DEFAULTS.mainBar) + return end - if type(profile.ui.mainBar.disableAutoCollapse) ~= "boolean" then - profile.ui.mainBar.disableAutoCollapse = UI_DEFAULTS.mainBar.disableAutoCollapse + if type(mainBar.moveLocked) ~= "boolean" then + mainBar.moveLocked = UI_DEFAULTS.mainBar.moveLocked end - if type(profile.ui.mainBar.autoHideEnabled) ~= "boolean" then - profile.ui.mainBar.autoHideEnabled = UI_DEFAULTS.mainBar.autoHideEnabled + if type(mainBar.disableAutoCollapse) ~= "boolean" then + mainBar.disableAutoCollapse = UI_DEFAULTS.mainBar.disableAutoCollapse end - if type(profile.ui.mainBar.autoHideDelay) ~= "number" or profile.ui.mainBar.autoHideDelay <= 0 then - profile.ui.mainBar.autoHideDelay = UI_DEFAULTS.mainBar.autoHideDelay + if type(mainBar.autoHideEnabled) ~= "boolean" then + mainBar.autoHideEnabled = UI_DEFAULTS.mainBar.autoHideEnabled + end + if type(mainBar.autoHideDelay) ~= "number" or mainBar.autoHideDelay <= 0 then + mainBar.autoHideDelay = UI_DEFAULTS.mainBar.autoHideDelay end end local function getConfigStore(createIfMissing) - if MultiBot.db and MultiBot.db.profile then - return MultiBot.db.profile + if createIfMissing then + return MultiBot.Store and MultiBot.Store.EnsureProfileStore and MultiBot.Store.EnsureProfileStore() end + return MultiBot.Store and MultiBot.Store.GetProfileStore and MultiBot.Store.GetProfileStore() +end - local legacy = _G.MultiBotDB - if type(legacy) ~= "table" then - if not createIfMissing then - return nil - end - - legacy = {} - _G.MultiBotDB = legacy +ensureTableField = function(parent, key) + if type(parent) ~= "table" or type(key) ~= "string" or key == "" then + return nil end - - return legacy + if MultiBot.Store and MultiBot.Store.EnsureTableField then + return MultiBot.Store.EnsureTableField(parent, key, {}) + end + if type(parent[key]) ~= "table" then + parent[key] = {} + end + return parent[key] end function MultiBot.Config_InitDB() @@ -132,34 +140,40 @@ function MultiBot.Config_Ensure() local config = getConfigStore(true) - config.timers = config.timers or {} + local timers = ensureTableField(config, "timers") for key, defaultValue in pairs(DEFAULTS) do - if type(config.timers[key]) ~= "number" or config.timers[key] <= 0 then - config.timers[key] = defaultValue + if type(timers[key]) ~= "number" or timers[key] <= 0 then + timers[key] = defaultValue end end - config.throttle = config.throttle or {} - if type(config.throttle.rate) ~= "number" or config.throttle.rate <= 0 then - config.throttle.rate = THROTTLE_DEFAULTS.rate + local throttle = ensureTableField(config, "throttle") + if type(throttle.rate) ~= "number" or throttle.rate <= 0 then + throttle.rate = THROTTLE_DEFAULTS.rate end - if type(config.throttle.burst) ~= "number" or config.throttle.burst <= 0 then - config.throttle.burst = THROTTLE_DEFAULTS.burst + if type(throttle.burst) ~= "number" or throttle.burst <= 0 then + throttle.burst = THROTTLE_DEFAULTS.burst end - config.ui = config.ui or {} - config.ui.mainBar = config.ui.mainBar or {} - if type(config.ui.mainBar.moveLocked) ~= "boolean" then - config.ui.mainBar.moveLocked = UI_DEFAULTS.mainBar.moveLocked + local mainBar = MultiBot.Store and MultiBot.Store.EnsureMainBarStore and MultiBot.Store.EnsureMainBarStore() + if mainBar and MultiBot.Store.NormalizeMainBarSettings then + MultiBot.Store.NormalizeMainBarSettings(mainBar, UI_DEFAULTS.mainBar) + return end - if type(config.ui.mainBar.disableAutoCollapse) ~= "boolean" then - config.ui.mainBar.disableAutoCollapse = UI_DEFAULTS.mainBar.disableAutoCollapse + + local ui = ensureTableField(config, "ui") + local legacyMainBar = ensureTableField(ui, "mainBar") + if type(legacyMainBar.moveLocked) ~= "boolean" then + legacyMainBar.moveLocked = UI_DEFAULTS.mainBar.moveLocked end - if type(config.ui.mainBar.autoHideEnabled) ~= "boolean" then - config.ui.mainBar.autoHideEnabled = UI_DEFAULTS.mainBar.autoHideEnabled + if type(legacyMainBar.disableAutoCollapse) ~= "boolean" then + legacyMainBar.disableAutoCollapse = UI_DEFAULTS.mainBar.disableAutoCollapse end - if type(config.ui.mainBar.autoHideDelay) ~= "number" or config.ui.mainBar.autoHideDelay <= 0 then - config.ui.mainBar.autoHideDelay = UI_DEFAULTS.mainBar.autoHideDelay + if type(legacyMainBar.autoHideEnabled) ~= "boolean" then + legacyMainBar.autoHideEnabled = UI_DEFAULTS.mainBar.autoHideEnabled + end + if type(legacyMainBar.autoHideDelay) ~= "number" or legacyMainBar.autoHideDelay <= 0 then + legacyMainBar.autoHideDelay = UI_DEFAULTS.mainBar.autoHideDelay end end @@ -170,7 +184,10 @@ function MultiBot.ApplyTimersToRuntime() if not config then return end - for key, value in pairs(config.timers or {}) do + if type(config.timers) ~= "table" then + return + end + for key, value in pairs(config.timers) do MultiBot.timer[key] = MultiBot.timer[key] or { elapsed = 0, interval = value } MultiBot.timer[key].interval = value end @@ -208,8 +225,8 @@ function MultiBot.SetTimer(name, value) if value > 600 then value = 600 end local config = getConfigStore(true) - config.timers = config.timers or {} - config.timers[name] = value + local timers = ensureTableField(config, "timers") + timers[name] = value if MultiBot and MultiBot.timer and MultiBot.timer[name] then MultiBot.timer[name].interval = value @@ -235,11 +252,11 @@ function MultiBot.SetThrottleRate(value) if value > 50 then value = 50 end local config = getConfigStore(true) - config.throttle = config.throttle or {} - config.throttle.rate = value + local throttle = ensureTableField(config, "throttle") + throttle.rate = value if MultiBot._ThrottleStats then - MultiBot._ThrottleStats(config.throttle.rate, MultiBot.GetThrottleBurst()) + MultiBot._ThrottleStats(throttle.rate, MultiBot.GetThrottleBurst()) end end @@ -249,17 +266,17 @@ function MultiBot.SetThrottleBurst(value) if value > 100 then value = 100 end local config = getConfigStore(true) - config.throttle = config.throttle or {} - config.throttle.burst = value + local throttle = ensureTableField(config, "throttle") + throttle.burst = value if MultiBot._ThrottleStats then - MultiBot._ThrottleStats(MultiBot.GetThrottleRate(), config.throttle.burst) + MultiBot._ThrottleStats(MultiBot.GetThrottleRate(), throttle.burst) end end function MultiBot.GetMainBarMoveLocked() - local config = getConfigStore(false) - local value = config and config.ui and config.ui.mainBar and config.ui.mainBar.moveLocked + local mainBar = MultiBot.Store and MultiBot.Store.GetMainBarStore and MultiBot.Store.GetMainBarStore() + local value = mainBar and mainBar.moveLocked if type(value) == "boolean" then return value end @@ -268,19 +285,20 @@ function MultiBot.GetMainBarMoveLocked() end function MultiBot.SetMainBarMoveLocked(value) - local config = getConfigStore(true) - config.ui = config.ui or {} - config.ui.mainBar = config.ui.mainBar or {} - config.ui.mainBar.moveLocked = value and true or false + local mainBar = MultiBot.Store and MultiBot.Store.EnsureMainBarStore and MultiBot.Store.EnsureMainBarStore() + if not mainBar then + return UI_DEFAULTS.mainBar.moveLocked + end + mainBar.moveLocked = value and true or false if MultiBot.ApplyMainBarMoveLockState then - MultiBot.ApplyMainBarMoveLockState(config.ui.mainBar.moveLocked) + MultiBot.ApplyMainBarMoveLockState(mainBar.moveLocked) end - return config.ui.mainBar.moveLocked + return mainBar.moveLocked end function MultiBot.GetDisableAutoCollapse() - local config = getConfigStore(false) - local value = config and config.ui and config.ui.mainBar and config.ui.mainBar.disableAutoCollapse + local mainBar = MultiBot.Store and MultiBot.Store.GetMainBarStore and MultiBot.Store.GetMainBarStore() + local value = mainBar and mainBar.disableAutoCollapse if type(value) == "boolean" then return value end @@ -289,11 +307,12 @@ function MultiBot.GetDisableAutoCollapse() end function MultiBot.SetDisableAutoCollapse(value) - local config = getConfigStore(true) - config.ui = config.ui or {} - config.ui.mainBar = config.ui.mainBar or {} - config.ui.mainBar.disableAutoCollapse = value and true or false - return config.ui.mainBar.disableAutoCollapse + local mainBar = MultiBot.Store and MultiBot.Store.EnsureMainBarStore and MultiBot.Store.EnsureMainBarStore() + if not mainBar then + return UI_DEFAULTS.mainBar.disableAutoCollapse + end + mainBar.disableAutoCollapse = value and true or false + return mainBar.disableAutoCollapse end local function normalizeMainBarAutoHideDelay(value) @@ -310,8 +329,8 @@ local function normalizeMainBarAutoHideDelay(value) end function MultiBot.GetMainBarAutoHideEnabled() - local config = getConfigStore(false) - local value = config and config.ui and config.ui.mainBar and config.ui.mainBar.autoHideEnabled + local mainBar = MultiBot.Store and MultiBot.Store.GetMainBarStore and MultiBot.Store.GetMainBarStore() + local value = mainBar and mainBar.autoHideEnabled if type(value) == "boolean" then return value end @@ -320,19 +339,20 @@ function MultiBot.GetMainBarAutoHideEnabled() end function MultiBot.SetMainBarAutoHideEnabled(value) - local config = getConfigStore(true) - config.ui = config.ui or {} - config.ui.mainBar = config.ui.mainBar or {} - config.ui.mainBar.autoHideEnabled = value and true or false + local mainBar = MultiBot.Store and MultiBot.Store.EnsureMainBarStore and MultiBot.Store.EnsureMainBarStore() + if not mainBar then + return UI_DEFAULTS.mainBar.autoHideEnabled + end + mainBar.autoHideEnabled = value and true or false if MultiBot.RefreshMainBarAutoHideState then MultiBot.RefreshMainBarAutoHideState() end - return config.ui.mainBar.autoHideEnabled + return mainBar.autoHideEnabled end function MultiBot.GetMainBarAutoHideDelay() - local config = getConfigStore(false) - local value = config and config.ui and config.ui.mainBar and config.ui.mainBar.autoHideDelay + local mainBar = MultiBot.Store and MultiBot.Store.GetMainBarStore and MultiBot.Store.GetMainBarStore() + local value = mainBar and mainBar.autoHideDelay if type(value) == "number" and value > 0 then return normalizeMainBarAutoHideDelay(value) end @@ -341,12 +361,13 @@ function MultiBot.GetMainBarAutoHideDelay() end function MultiBot.SetMainBarAutoHideDelay(value) - local config = getConfigStore(true) - config.ui = config.ui or {} - config.ui.mainBar = config.ui.mainBar or {} - config.ui.mainBar.autoHideDelay = normalizeMainBarAutoHideDelay(value) + local mainBar = MultiBot.Store and MultiBot.Store.EnsureMainBarStore and MultiBot.Store.EnsureMainBarStore() + if not mainBar then + return UI_DEFAULTS.mainBar.autoHideDelay + end + mainBar.autoHideDelay = normalizeMainBarAutoHideDelay(value) if MultiBot.RefreshMainBarAutoHideState then MultiBot.RefreshMainBarAutoHideState() end - return config.ui.mainBar.autoHideDelay + return mainBar.autoHideDelay end \ No newline at end of file diff --git a/Core/MultiBotEngine.lua b/Core/MultiBotEngine.lua index 4b8a18a..56fb767 100644 --- a/Core/MultiBotEngine.lua +++ b/Core/MultiBotEngine.lua @@ -675,11 +675,42 @@ MultiBot.OnOffSwitch = function(pButton) return true end +local function _mbEnsureRuntimeTable(key) + if MultiBot.Store and MultiBot.Store.EnsureRuntimeTable then + return MultiBot.Store.EnsureRuntimeTable(key) + end + MultiBot[key] = MultiBot[key] or {} + return MultiBot[key] +end + +local function _mbGetRuntimeTable(key) + if MultiBot.Store and MultiBot.Store.GetRuntimeTable then + return MultiBot.Store.GetRuntimeTable(key) + end + local value = MultiBot[key] + if type(value) ~= "table" then + return nil + end + return value +end + +local function _mbEnsureTableField(parent, key, defaultValue) + if MultiBot.Store and MultiBot.Store.EnsureTableField then + return MultiBot.Store.EnsureTableField(parent, key, defaultValue) + end + if parent[key] == nil then + parent[key] = defaultValue ~= nil and defaultValue or {} + end + return parent[key] +end + +local _MB_EMPTY_TABLE = {} + -- CLICK BLOCKER -- -- Fond invisible placé sous les barres de boutons (et leurs zones extensibles) afin -- d'empêcher les clics de "traverser" l'UI dans les espaces entre boutons. -MultiBot._clickBlockerQueue = MultiBot._clickBlockerQueue or {} +MultiBot._clickBlockerQueue = _mbEnsureRuntimeTable("_clickBlockerQueue") local function _mbQueueClickBlockerUpdate(f) if(not f or not f.clickBlocker) then return end @@ -1453,10 +1484,10 @@ function MultiBot.BindShiftRightSwapButtons(host, contextKey, entries) return nil end - MultiBot._mbShiftSwapGlobal = MultiBot._mbShiftSwapGlobal or {} - MultiBot._mbRegisteredButtonLayoutKeys = MultiBot._mbRegisteredButtonLayoutKeys or {} - MultiBot._mbRegisteredButtonLayoutKeys["ButtonLayout:" .. contextKey] = true - local state = MultiBot._mbShiftSwapGlobal[contextKey] + local shiftSwapGlobal = _mbEnsureRuntimeTable("_mbShiftSwapGlobal") + local registeredLayoutKeys = _mbEnsureRuntimeTable("_mbRegisteredButtonLayoutKeys") + registeredLayoutKeys["ButtonLayout:" .. contextKey] = true + local state = shiftSwapGlobal[contextKey] if(not state) then local saveKey = "ButtonLayout:" .. contextKey local saved = MultiBot.GetSavedLayoutValue and MultiBot.GetSavedLayoutValue(saveKey) or nil @@ -1467,7 +1498,7 @@ function MultiBot.BindShiftRightSwapButtons(host, contextKey, entries) entries = {}, byName = {}, } - MultiBot._mbShiftSwapGlobal[contextKey] = state + shiftSwapGlobal[contextKey] = state end local function persist() @@ -1495,7 +1526,7 @@ function MultiBot.BindShiftRightSwapButtons(host, contextKey, entries) end local function applySelectionVisuals() - for _, entryRec in ipairs(state.entries or {}) do + for _, entryRec in ipairs(state.entries or _MB_EMPTY_TABLE) do clearVisualState(entryRec) end @@ -1650,16 +1681,17 @@ function MultiBot.BindShiftRightSwapButtons(host, contextKey, entries) end function MultiBot.ResetButtonLayoutContext(contextKey, clearPersistedValue) - if(not contextKey or not MultiBot._mbShiftSwapGlobal) then + local shiftSwapGlobal = _mbGetRuntimeTable("_mbShiftSwapGlobal") + if(not contextKey or not shiftSwapGlobal) then return false end - local state = MultiBot._mbShiftSwapGlobal[contextKey] + local state = shiftSwapGlobal[contextKey] if(not state) then return false end - for _, entryRec in ipairs(state.entries or {}) do + for _, entryRec in ipairs(state.entries or _MB_EMPTY_TABLE) do local button = entryRec and entryRec.button local defaultX = entryRec and entryRec.defaultX local defaultY = entryRec and entryRec.defaultY @@ -1679,11 +1711,12 @@ function MultiBot.ResetButtonLayoutContext(contextKey, clearPersistedValue) end function MultiBot.ApplySavedButtonLayout(contextKey) - if(not contextKey or not MultiBot._mbShiftSwapGlobal) then + local shiftSwapGlobal = _mbGetRuntimeTable("_mbShiftSwapGlobal") + if(not contextKey or not shiftSwapGlobal) then return false end - local state = MultiBot._mbShiftSwapGlobal[contextKey] + local state = shiftSwapGlobal[contextKey] if(not state) then return false end @@ -1691,7 +1724,7 @@ function MultiBot.ApplySavedButtonLayout(contextKey) local raw = MultiBot.GetSavedLayoutValue and MultiBot.GetSavedLayoutValue(state.saveKey) or nil state.parsed = _mbParseButtonLayout(raw) - for _, entryRec in ipairs(state.entries or {}) do + for _, entryRec in ipairs(state.entries or _MB_EMPTY_TABLE) do local button = entryRec and entryRec.button local id = entryRec and (entryRec.id or entryRec.name) local savedPoint = id and state.parsed and state.parsed[id] or nil @@ -1833,8 +1866,7 @@ MultiBot.addSelf = function(pClass, pName) btn = units.addButton(pName, 0, 0, "inv_misc_head_clockworkgnome_01", MultiBot.L("tips.unit.selfbot")) end -- Assurer la présence dans les index (sans doublons) - MultiBot.index.classes.players[tClass] = MultiBot.index.classes.players[tClass] or {} - local byClass = MultiBot.index.classes.players[tClass] + local byClass = _mbEnsureTableField(MultiBot.index.classes.players, tClass, {}) local found = false for i=1,#byClass do if byClass[i] == pName then found = true; break end end if not found then table.insert(byClass, pName) end @@ -1863,8 +1895,7 @@ MultiBot.addPlayer = function(pClass, pName) if btn.icon and tTexture then btn.icon:SetTexture(MultiBot.SafeTexturePath(tTexture)) end end -- Assurer la présence dans les index (sans doublons) - MultiBot.index.classes.players[tClass] = MultiBot.index.classes.players[tClass] or {} - local byClass = MultiBot.index.classes.players[tClass] + local byClass = _mbEnsureTableField(MultiBot.index.classes.players, tClass, {}) local found = false for i=1,#byClass do if byClass[i] == pName then found = true; break end end if not found then table.insert(byClass, pName) end diff --git a/Core/MultiBotEvery.lua b/Core/MultiBotEvery.lua index 6bb6878..67f5a94 100644 --- a/Core/MultiBotEvery.lua +++ b/Core/MultiBotEvery.lua @@ -140,14 +140,30 @@ MultiBot.addEvery = function(pFrame, pCombat, pNormal) if(pButton.state) then MultiBot.inventory:Hide() pButton.setDisable() + if(MultiBot.SyncToolWindowButtons) then + MultiBot.SyncToolWindowButtons(nil, nil) + end return end if(MultiBot.RequestBotInventory and MultiBot.RequestBotInventory(pButton.getName())) then + if(MultiBot.SyncToolWindowButtons) then + MultiBot.SyncToolWindowButtons(pButton.getName(), "Inventory") + end return end pButton.setEnable() + if(MultiBot.SyncToolWindowButtons) then + MultiBot.SyncToolWindowButtons(pButton.getName(), "Inventory") + end + end + + pFrame.addButton("Outfits", 364, 0, "inv_chest_chain_15", MultiBot.L("tips.every.outfits", "Outfits")).setDisable() + .doLeft = function(pButton) + if(MultiBot.OpenBotOutfits) then + MultiBot.OpenBotOutfits(pButton.getName(), pButton) + end end pFrame.addButton("Spellbook", 274, 0, "inv_misc_book_09", MultiBot.L("tips.every.spellbook")).setDisable() diff --git a/Core/MultiBotHandler.lua b/Core/MultiBotHandler.lua index 2349a8a..5ae47ed 100644 --- a/Core/MultiBotHandler.lua +++ b/Core/MultiBotHandler.lua @@ -117,11 +117,10 @@ local function getLegacyStateStore(createIfMissing) end local function getMainBarProfileStore() - local profile = MultiBot.db and MultiBot.db.profile - if not profile then return nil end - profile.ui = profile.ui or {} - profile.ui.mainBar = profile.ui.mainBar or {} - return profile.ui.mainBar + if not (MultiBot.Store and MultiBot.Store.EnsureMainBarStore) then + return nil + end + return MultiBot.Store.EnsureMainBarStore() end local function migrateLegacyMainBarStateIfNeeded(profileStore) @@ -185,12 +184,23 @@ MultiBot.SetSavedMainBarValue = function(key, value) return setSavedMainBarValue(key, value) end -local function getLayoutProfileStore() - local profile = MultiBot.db and MultiBot.db.profile - if not profile then return nil end - profile.ui = profile.ui or {} - profile.ui.layout = profile.ui.layout or {} - return profile.ui.layout +local function getLayoutProfileStore(createIfMissing) + if not MultiBot.Store then + return nil + end + + if createIfMissing then + if type(MultiBot.Store.EnsureUIChildStore) == "function" then + return MultiBot.Store.EnsureUIChildStore("layout") + end + return nil + end + + if type(MultiBot.Store.GetUIChildStore) == "function" then + return MultiBot.Store.GetUIChildStore("layout") + end + + return nil end local function migrateLegacyLayoutStateIfNeeded(profileStore) @@ -217,23 +227,31 @@ end local function getSavedLayoutValue(key) local legacy = getLegacyStateStore(false) - local profileStore = getLayoutProfileStore() + local profileStore = getLayoutProfileStore(false) if profileStore then migrateLegacyLayoutStateIfNeeded(profileStore) end local value = profileStore and profileStore[key] or (legacy and legacy[key]) - if profileStore and value == nil and MultiBot.ShouldSyncLegacyState(LAYOUT_MIGRATION_KEY, LAYOUT_MIGRATION_VERSION) then + if value == nil and MultiBot.ShouldSyncLegacyState(LAYOUT_MIGRATION_KEY, LAYOUT_MIGRATION_VERSION) then value = legacy and legacy[key] if value ~= nil then - profileStore[key] = value + if not profileStore then + profileStore = getLayoutProfileStore(true) + if profileStore then + migrateLegacyLayoutStateIfNeeded(profileStore) + end + end + if profileStore then + profileStore[key] = value + end end end return value end local function setSavedLayoutValue(key, value) - local profileStore = getLayoutProfileStore() + local profileStore = getLayoutProfileStore(true) if profileStore then migrateLegacyLayoutStateIfNeeded(profileStore) profileStore[key] = value @@ -279,7 +297,11 @@ local function getGlobalLayoutLibrary(createIfMissing) end if createIfMissing then - globalSave.savedLayoutsByPlayer = globalSave.savedLayoutsByPlayer or {} + if MultiBot.Store and MultiBot.Store.EnsureTableField then + MultiBot.Store.EnsureTableField(globalSave, "savedLayoutsByPlayer", {}) + elseif type(globalSave.savedLayoutsByPlayer) ~= "table" then + globalSave.savedLayoutsByPlayer = {} + end local db = MultiBot.db local legacyStore = db and db.global and db.global.ui and db.global.ui.savedLayoutsByPlayer or nil @@ -333,7 +355,10 @@ local function collectLayoutExportEntries() end end - local registered = MultiBot._mbRegisteredButtonLayoutKeys or {} + local registered = MultiBot._mbRegisteredButtonLayoutKeys + if type(registered) ~= "table" then + registered = {} + end for key in pairs(registered) do if shouldExportLayoutKey(key) and entries[key] == nil then local value = getSavedLayoutValue(key) @@ -355,7 +380,10 @@ end local function sortedKeysOf(map) local keys = {} - for key in pairs(map or {}) do + if type(map) ~= "table" then + return keys + end + for key in pairs(map) do keys[#keys + 1] = key end table.sort(keys) @@ -389,7 +417,10 @@ end function MultiBot.GetSavedMainBarLayoutOwners() local store = getGlobalLayoutLibrary(false) local owners = {} - for ownerKey, payload in pairs(store or {}) do + if type(store) ~= "table" then + return owners + end + for ownerKey, payload in pairs(store) do if type(ownerKey) == "string" and type(payload) == "string" and payload ~= "" then owners[#owners + 1] = ownerKey end @@ -788,25 +819,29 @@ local requireEnabledStateOnRight = options and options.requireEnabledStateOnRigh end local function ensureQuestStateTables() - MultiBot.BotQuestsIncompleted = MultiBot.BotQuestsIncompleted or {} - MultiBot.BotQuestsCompleted = MultiBot.BotQuestsCompleted or {} - MultiBot.BotQuestsAll = MultiBot.BotQuestsAll or {} - MultiBot._awaitingQuestsIncompleted = MultiBot._awaitingQuestsIncompleted or {} - MultiBot._awaitingQuestsCompleted = MultiBot._awaitingQuestsCompleted or {} - MultiBot.LastGameObjectSearch = MultiBot.LastGameObjectSearch or {} - MultiBot._GameObjCaptureInProgress = MultiBot._GameObjCaptureInProgress or {} - MultiBot._GameObjCurrentSection = MultiBot._GameObjCurrentSection or {} - MultiBot._questAllBuffer = MultiBot._questAllBuffer or {} + local ensureRuntime = MultiBot.Store and MultiBot.Store.EnsureRuntimeTable + if type(ensureRuntime) ~= "function" then + return + end + ensureRuntime("BotQuestsIncompleted") + ensureRuntime("BotQuestsCompleted") + ensureRuntime("BotQuestsAll") + ensureRuntime("_awaitingQuestsIncompleted") + ensureRuntime("_awaitingQuestsCompleted") + ensureRuntime("LastGameObjectSearch") + ensureRuntime("_GameObjCaptureInProgress") + ensureRuntime("_GameObjCurrentSection") + ensureRuntime("_questAllBuffer") end local function FillQuestTable(tbl, author, msg) - MultiBot[tbl] = MultiBot[tbl] or {} - MultiBot[tbl][author] = MultiBot[tbl][author] or {} + local bucket = MultiBot.Store.EnsureRuntimeTable(tbl) + MultiBot.Store.EnsureTableField(bucket, author, {}) for link in msg:gmatch("|Hquest:[^|]+|h%[[^%]]+%]|h") do local id = tonumber(link:match("|Hquest:(%d+):")) local name = link:match("%[([^%]]+)%]") if id and name then - MultiBot[tbl][author][id] = name + bucket[author][id] = name end end end @@ -855,10 +890,17 @@ local function scheduleQuestListBuild(delay, modeValue, groupedMode, groupedBuil end) end -local function finalizeQuestSection(author, awaitingTable, popup, modeValue, groupedMode, groupedBuilder, singleBuilder) +local function finalizeQuestSection(author, awaitingTable, popup, modeValue, groupedMode, groupedBuilder, singleBuilder, singleAuthor) awaitingTable[author] = nil showPopupIfHidden(popup) - scheduleQuestListBuild(0.1, modeValue, groupedMode, groupedBuilder, singleBuilder, author) + scheduleQuestListBuild(0.1, modeValue, groupedMode, groupedBuilder, singleBuilder, singleAuthor or author) +end + +local function refreshQuestSectionProgress(author, modeValue, groupedMode, groupedBuilder, singleBuilder, singleAuthor) + if modeValue == groupedMode then + return + end + scheduleQuestListBuild(0.05, modeValue, groupedMode, groupedBuilder, singleBuilder, singleAuthor or author) end local function HandleQuestResponse(rawMsg, author) @@ -876,44 +918,72 @@ local function HandleQuestResponse(rawMsg, author) end if rawMsg:find(QUEST_LINE_MARKERS.incompleted, 1, true) then - MultiBot.BotQuestsIncompleted[author] = {} - MultiBot._awaitingQuestsIncompleted[author] = true + MultiBot.Store.EnsureRuntimeTable("BotQuestsIncompleted")[author] = {} + MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsIncompleted")[author] = true return end - if MultiBot._awaitingQuestsIncompleted[author] then + if MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsIncompleted")[author] then FillQuestTable("BotQuestsIncompleted", author, rawMsg) + local incRenderAuthor = author + if MultiBot._lastIncMode == "WHISPER" and type(MultiBot._lastIncWhisperBot) == "string" and MultiBot._lastIncWhisperBot ~= "" then + incRenderAuthor = MultiBot._lastIncWhisperBot + end if rawMsg:find(QUEST_LINE_MARKERS.summary, 1, true) then finalizeQuestSection( author, - MultiBot._awaitingQuestsIncompleted, + MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsIncompleted"), MultiBot.tBotPopup, MultiBot._lastIncMode, "GROUP", MultiBot.BuildAggregatedQuestList, - MultiBot.BuildBotQuestList + MultiBot.BuildBotQuestList, + incRenderAuthor + ) + else + refreshQuestSectionProgress( + author, + MultiBot._lastIncMode, + "GROUP", + MultiBot.BuildAggregatedQuestList, + MultiBot.BuildBotQuestList, + incRenderAuthor ) end return end if rawMsg:find(QUEST_LINE_MARKERS.completed, 1, true) then - MultiBot.BotQuestsCompleted[author] = {} - MultiBot._awaitingQuestsCompleted[author] = true + MultiBot.Store.EnsureRuntimeTable("BotQuestsCompleted")[author] = {} + MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsCompleted")[author] = true return end - if MultiBot._awaitingQuestsCompleted[author] then + if MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsCompleted")[author] then FillQuestTable("BotQuestsCompleted", author, rawMsg) + local compRenderAuthor = author + if MultiBot._lastCompMode == "WHISPER" and type(MultiBot._lastCompWhisperBot) == "string" and MultiBot._lastCompWhisperBot ~= "" then + compRenderAuthor = MultiBot._lastCompWhisperBot + end if rawMsg:find(QUEST_LINE_MARKERS.summary, 1, true) then finalizeQuestSection( author, - MultiBot._awaitingQuestsCompleted, + MultiBot.Store.EnsureRuntimeTable("_awaitingQuestsCompleted"), MultiBot.tBotCompPopup, MultiBot._lastCompMode, "GROUP", MultiBot.BuildAggregatedCompletedList, - MultiBot.BuildBotCompletedList + MultiBot.BuildBotCompletedList, + compRenderAuthor + ) + else + refreshQuestSectionProgress( + author, + MultiBot._lastCompMode, + "GROUP", + MultiBot.BuildAggregatedCompletedList, + MultiBot.BuildBotCompletedList, + compRenderAuthor ) end return @@ -951,11 +1021,18 @@ MultiBot.ShouldHandleQuestsAllWhisper = shouldHandleQuestsAllWhisper _G.shouldHandleQuestsAllWhisper = shouldHandleQuestsAllWhisper local function fillQuestsAllTablesFromBuffer(author) - local linesBuffer = MultiBot._questAllBuffer[author] or {} + local questAllBuffer = MultiBot.Store.EnsureRuntimeTable("_questAllBuffer") + local linesBuffer = questAllBuffer[author] + if type(linesBuffer) ~= "table" then + linesBuffer = {} + end + local allStore = MultiBot.Store.EnsureRuntimeTable("BotQuestsAll") + local completedStore = MultiBot.Store.EnsureRuntimeTable("BotQuestsCompleted") + local incompletedStore = MultiBot.Store.EnsureRuntimeTable("BotQuestsIncompleted") - MultiBot.BotQuestsAll[author] = {} - MultiBot.BotQuestsCompleted[author] = {} - MultiBot.BotQuestsIncompleted[author] = {} + allStore[author] = {} + completedStore[author] = {} + incompletedStore[author] = {} local mode = nil for _, line in ipairs(linesBuffer) do @@ -969,11 +1046,11 @@ local function fillQuestsAllTablesFromBuffer(author) local id = tonumber(line:match("|Hquest:(%d+):")) local name = line:match("%[([^%]]+)%]") if id and name then - table.insert(MultiBot.BotQuestsAll[author], line) + table.insert(allStore[author], line) if mode == "incomplete" then - MultiBot.BotQuestsIncompleted[author][id] = name + incompletedStore[author][id] = name elseif mode == "complete" then - MultiBot.BotQuestsCompleted[author][id] = name + completedStore[author][id] = name end end end @@ -981,7 +1058,11 @@ local function fillQuestsAllTablesFromBuffer(author) end local function areAllQuestsAllBotsCompleted() - for _, ok in pairs(MultiBot._awaitingQuestsAllBots or {}) do + local awaiting = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("_awaitingQuestsAllBots")) or MultiBot._awaitingQuestsAllBots + if type(awaiting) ~= "table" then + return true + end + for _, ok in pairs(awaiting) do if not ok then return false end @@ -991,8 +1072,9 @@ local function areAllQuestsAllBotsCompleted() end function HandleQuestsAllResponse(rawMsg, author) - MultiBot._questAllBuffer[author] = MultiBot._questAllBuffer[author] or {} - table.insert(MultiBot._questAllBuffer[author], rawMsg) + local questAllBuffer = MultiBot.Store.EnsureRuntimeTable("_questAllBuffer") + MultiBot.Store.EnsureTableField(questAllBuffer, author, {}) + table.insert(questAllBuffer[author], rawMsg) if not rawMsg:find(QUEST_LINE_MARKERS.summary, 1, true) then return @@ -1004,7 +1086,7 @@ function HandleQuestsAllResponse(rawMsg, author) MultiBot._awaitingQuestsAllBots[author] = true end - MultiBot._questAllBuffer[author] = nil + questAllBuffer[author] = nil if areAllQuestsAllBotsCompleted() then MultiBot._awaitingQuestsAll = false @@ -1578,8 +1660,12 @@ function MultiBot.HandleMultiBotEvent(event, ...) end -- On stocke cette liste pour le rafraîchissement - MultiBot.receivedGlyphs = MultiBot.receivedGlyphs or {} - MultiBot.receivedGlyphs[author] = {} + local receivedGlyphs = (MultiBot.Store and MultiBot.Store.EnsureRuntimeTable and MultiBot.Store.EnsureRuntimeTable("receivedGlyphs")) or MultiBot.receivedGlyphs + if type(receivedGlyphs) ~= "table" then + receivedGlyphs = {} + MultiBot.receivedGlyphs = receivedGlyphs + end + receivedGlyphs[author] = {} -- Détermination du type Major/Minor et remplissage local unit = MultiBot.toUnit(author) @@ -1594,7 +1680,7 @@ function MultiBot.HandleMultiBotEvent(event, ...) for idx, id in ipairs(ids) do local sock = map[idx] -- n° de socket cible local typ = (glyphDB.Major and glyphDB.Major[id]) and "Major" or "Minor" - MultiBot.receivedGlyphs[author][sock] = { id = id, type = typ } + receivedGlyphs[author][sock] = { id = id, type = typ } end -- Si l'onglet Glyphes est ouvert, on force son rafraîchissement. @@ -1798,6 +1884,10 @@ function MultiBot.HandleMultiBotEvent(event, ...) return end + if(tButton.waitFor == "OUTFITS" and MultiBot.HandleOutfitChatLine and MultiBot.HandleOutfitChatLine(tButton, arg1, arg2)) then + return + end + -- Inventory -- if(tButton.waitFor == "INVENTORY" and MultiBot.isInside(arg1, "Inventory", "背包")) then diff --git a/Core/MultiBotInit.lua b/Core/MultiBotInit.lua index 2f2b441..2a4ea66 100644 --- a/Core/MultiBotInit.lua +++ b/Core/MultiBotInit.lua @@ -1,7 +1,7 @@ MultiBot.MB_PAGE_DEFAULT = string.format("%d/%d", 0, 0) -- MULTIBAR -- -local tMultiBar = MultiBot.addFrame("MultiBar", -322, 144, 36) +local tMultiBar = MultiBot.addFrame("MultiBar", -303, 144, 36) MultiBot.PromoteFrame(tMultiBar) tMultiBar:SetMovable(true) tMultiBar:SetClampedToScreen(true) diff --git a/Core/MultiBotStore.lua b/Core/MultiBotStore.lua new file mode 100644 index 0000000..6b844f4 --- /dev/null +++ b/Core/MultiBotStore.lua @@ -0,0 +1,316 @@ +-- MultiBotStore.lua +MultiBot = MultiBot or {} +MultiBot.Store = MultiBot.Store or {} + +local Store = MultiBot.Store +Store.Diagnostics = Store.Diagnostics or { + enabled = false, + ensureCalls = {}, + readMisses = {}, +} + +local function normalizeMigrationEntries(migrations) + if type(migrations) ~= "table" then + return + end + + for key, value in pairs(migrations) do + if type(value) ~= "number" then + migrations[key] = nil + end + end +end + +local function getLegacyRoot(createIfMissing) + local legacy = _G.MultiBotDB + if type(legacy) == "table" then + return legacy + end + if not createIfMissing then + return nil + end + legacy = {} + _G.MultiBotDB = legacy + return legacy +end + +function Store.GetProfileStore() + if MultiBot.db and type(MultiBot.db.profile) == "table" then + return MultiBot.db.profile + end + return getLegacyRoot(false) +end + +function Store.EnsureProfileStore() + if MultiBot.db and type(MultiBot.db.profile) == "table" then + return MultiBot.db.profile + end + return getLegacyRoot(true) +end + +function Store.GetUIStore() + local profile = Store.GetProfileStore() + if type(profile) ~= "table" then + return nil + end + if type(profile.ui) ~= "table" then + return nil + end + return profile.ui +end + +function Store.EnsureUIStore() + local profile = Store.EnsureProfileStore() + profile.ui = profile.ui or {} + return profile.ui +end + +function Store.GetUIChildStore(childKey) + if type(childKey) ~= "string" or childKey == "" then + return nil + end + + local ui = Store.GetUIStore() + if type(ui) ~= "table" then + return nil + end + + local child = ui[childKey] + if type(child) ~= "table" then + return nil + end + + return child +end + +function Store.EnsureUIChildStore(childKey) + if type(childKey) ~= "string" or childKey == "" then + return nil + end + + local ui = Store.EnsureUIStore() + ui[childKey] = ui[childKey] or {} + return ui[childKey] +end + +function Store.GetUIValue(key) + if type(key) ~= "string" or key == "" then + return nil + end + + local ui = Store.GetUIStore() + if type(ui) ~= "table" then + return nil + end + + return ui[key] +end + +function Store.SetUIValue(key, value) + if type(key) ~= "string" or key == "" then + return nil + end + + local ui = Store.EnsureUIStore() + ui[key] = value + return value +end + +function Store.GetMigrationStore() + local profile = Store.GetProfileStore() + if type(profile) ~= "table" then + return nil + end + + local migrations = profile.migrations + if type(migrations) ~= "table" then + return nil + end + + normalizeMigrationEntries(migrations) + return migrations +end + +function Store.EnsureMigrationStore() + local profile = Store.EnsureProfileStore() + profile.migrations = profile.migrations or {} + normalizeMigrationEntries(profile.migrations) + return profile.migrations +end + +function Store.GetFavoritesStore() + local profile = Store.GetProfileStore() + if type(profile) ~= "table" then + return nil + end + + if type(profile.favorites) ~= "table" then + return nil + end + + return profile.favorites +end + +function Store.EnsureFavoritesStore() + local profile = Store.EnsureProfileStore() + profile.favorites = profile.favorites or {} + return profile.favorites +end + +function Store.IsValidGlobalBotRosterEntry(value) + if type(value) ~= "string" then + return false + end + + return value:match("^[^,]+,%[[^%]]+%],[^,]*,%d+/%d+/%d+,[^,]+,%-?%d+,%-?%d+$") ~= nil +end + +function Store.SanitizeGlobalBotStore(store) + if type(store) ~= "table" then + return + end + + for botName, value in pairs(store) do + if type(botName) ~= "string" or not Store.IsValidGlobalBotRosterEntry(value) then + store[botName] = nil + end + end +end + +function Store.GetBotsStore() + local profile = Store.GetProfileStore() + if type(profile) ~= "table" then + return nil + end + + if type(profile.bots) ~= "table" then + return nil + end + + return profile.bots +end + +function Store.EnsureBotsStore() + local profile = Store.EnsureProfileStore() + profile.bots = profile.bots or {} + return profile.bots +end + +function Store.GetRuntimeTable(fieldName) + if type(fieldName) ~= "string" or fieldName == "" then + return nil + end + + local value = MultiBot[fieldName] + if type(value) ~= "table" then + return nil + end + + return value +end + +function Store.EnsureRuntimeTable(fieldName) + if type(fieldName) ~= "string" or fieldName == "" then + return nil + end + + MultiBot[fieldName] = MultiBot[fieldName] or {} + if Store.Diagnostics.enabled then + Store.Diagnostics.ensureCalls[fieldName] = (Store.Diagnostics.ensureCalls[fieldName] or 0) + 1 + end + return MultiBot[fieldName] +end + +function Store.RecordReadMiss(scope, key) + if not Store.Diagnostics.enabled then + return + end + local bucketKey = (scope or "unknown") .. ":" .. (key or "unknown") + Store.Diagnostics.readMisses[bucketKey] = (Store.Diagnostics.readMisses[bucketKey] or 0) + 1 +end + +function Store.SetDiagnosticsEnabled(enabled) + Store.Diagnostics.enabled = enabled and true or false + return Store.Diagnostics.enabled +end + +function Store.ResetDiagnostics() + Store.Diagnostics.ensureCalls = {} + Store.Diagnostics.readMisses = {} +end + +function Store.GetDiagnosticsSnapshot() + return { + enabled = Store.Diagnostics.enabled, + ensureCalls = Store.Diagnostics.ensureCalls, + readMisses = Store.Diagnostics.readMisses, + } +end + +function Store.ClearTable(target) + if type(target) ~= "table" then + return + end + if wipe then + wipe(target) + return + end + for key in pairs(target) do + target[key] = nil + end +end + +function Store.EnsureTableField(parent, fieldName, defaultValue) + if type(parent) ~= "table" then + return nil + end + if type(fieldName) ~= "string" or fieldName == "" then + return nil + end + + if parent[fieldName] == nil then + if defaultValue == nil then + parent[fieldName] = {} + else + parent[fieldName] = defaultValue + end + end + + return parent[fieldName] +end + +function Store.GetMainBarStore() + local ui = Store.GetUIStore() + if type(ui) ~= "table" then + return nil + end + if type(ui.mainBar) ~= "table" then + return nil + end + return ui.mainBar +end + +function Store.EnsureMainBarStore() + local ui = Store.EnsureUIStore() + ui.mainBar = ui.mainBar or {} + return ui.mainBar +end + +function Store.NormalizeMainBarSettings(mainBar, defaults) + if type(mainBar) ~= "table" then + return + end + + if type(mainBar.moveLocked) ~= "boolean" then + mainBar.moveLocked = defaults.moveLocked + end + if type(mainBar.disableAutoCollapse) ~= "boolean" then + mainBar.disableAutoCollapse = defaults.disableAutoCollapse + end + if type(mainBar.autoHideEnabled) ~= "boolean" then + mainBar.autoHideEnabled = defaults.autoHideEnabled + end + if type(mainBar.autoHideDelay) ~= "number" or mainBar.autoHideDelay <= 0 then + mainBar.autoHideDelay = defaults.autoHideDelay + end +end \ No newline at end of file diff --git a/Features/MultiBotRaidus.lua b/Features/MultiBotRaidus.lua index 0cb07ef..083bd3c 100644 --- a/Features/MultiBotRaidus.lua +++ b/Features/MultiBotRaidus.lua @@ -640,6 +640,13 @@ local getRaidusLayoutKey local getLegacyRaidusLayoutStore local function getRaidusLayoutStore(createLegacyIfMissing) + if MultiBot.Store and MultiBot.Store.EnsureUIChildStore then + local store = MultiBot.Store.EnsureUIChildStore("raidusLayouts") + if store then + return store + end + end + local profile = MultiBot.db and MultiBot.db.profile if profile then profile.ui = profile.ui or {} diff --git a/Features/MultiBotReward.lua b/Features/MultiBotReward.lua index 7ab49f9..9eaca8c 100644 --- a/Features/MultiBotReward.lua +++ b/Features/MultiBotReward.lua @@ -226,14 +226,32 @@ end MultiBot.rewardEnsureState = function() if(MultiBot.reward == nil) then return nil end - MultiBot.reward.rewards = MultiBot.reward.rewards or {} - MultiBot.reward.units = MultiBot.reward.units or {} - MultiBot.reward.pageSize = MultiBot.reward.pageSize or MB_REWARD_PAGE_SIZE - MultiBot.reward.now = MultiBot.reward.now or 1 - MultiBot.reward.max = MultiBot.reward.max or 1 - MultiBot.reward.from = MultiBot.reward.from or 1 - MultiBot.reward.to = MultiBot.reward.to or MultiBot.reward.pageSize - MultiBot.reward.classIconSize = MultiBot.reward.classIconSize or 16 + if MultiBot.Store and MultiBot.Store.EnsureTableField then + MultiBot.Store.EnsureTableField(MultiBot.reward, "rewards", {}) + MultiBot.Store.EnsureTableField(MultiBot.reward, "units", {}) + MultiBot.Store.EnsureTableField(MultiBot.reward, "pageSize", MB_REWARD_PAGE_SIZE) + MultiBot.Store.EnsureTableField(MultiBot.reward, "now", 1) + MultiBot.Store.EnsureTableField(MultiBot.reward, "max", 1) + MultiBot.Store.EnsureTableField(MultiBot.reward, "from", 1) + MultiBot.Store.EnsureTableField(MultiBot.reward, "to", MultiBot.reward.pageSize) + MultiBot.Store.EnsureTableField(MultiBot.reward, "classIconSize", 16) + else + local function ensureField(target, key, defaultValue) + if target[key] == nil then + target[key] = defaultValue + end + return target[key] + end + + ensureField(MultiBot.reward, "rewards", {}) + ensureField(MultiBot.reward, "units", {}) + ensureField(MultiBot.reward, "pageSize", MB_REWARD_PAGE_SIZE) + ensureField(MultiBot.reward, "now", 1) + ensureField(MultiBot.reward, "max", 1) + ensureField(MultiBot.reward, "from", 1) + ensureField(MultiBot.reward, "to", MultiBot.reward.pageSize) + ensureField(MultiBot.reward, "classIconSize", 16) + end return MultiBot.reward end diff --git a/Locales/MultiBotAceLocale-deDE.lua b/Locales/MultiBotAceLocale-deDE.lua index 7d574c3..f8ba662 100644 --- a/Locales/MultiBotAceLocale-deDE.lua +++ b/Locales/MultiBotAceLocale-deDE.lua @@ -725,6 +725,37 @@ local deDEValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "Gruppenverwaltung", ["info.avalaiblebots"] = "Verfügbare Bots", + ["info.outfits.window_title"] = "Ausrüstungen", + ["info.outfits.acegui_required"] = "AceGUI-3.0 wird für Ausrüstungen benötigt", + ["info.outfits.pinned"] = "Ausrüstung oben angeheftet.", + ["info.outfits.unpinned"] = "Ausrüstungs-Anheftung entfernt.", + ["info.outfits.none_selected"] = "Keine Ausrüstung ausgewählt", + ["info.outfits.empty"] = "In dieser Ausrüstung sind keine Gegenstände gespeichert.", + ["info.outfits.select_left"] = "Wähle links eine Ausrüstung aus.", + ["info.outfits.unpin"] = "Lösen", + ["info.outfits.pin"] = "Anheften", + ["info.outfits.no_outfits"] = "Keine Ausrüstungen gefunden.", + ["info.outfits.loaded"] = "Vom Bot geladen.", + ["info.outfits.busy_wait"] = "Ausrüstungsaktion läuft ... Aktualisierung vorgemerkt.", + ["info.outfits.loading"] = "Ausrüstungen vom Bot werden geladen ...", + ["info.outfits.equip_auto_replace"] = "Für den Waffenwechsel ist Ersetzen nötig. Stattdessen wird Ersetzen gesendet.", + ["info.outfits.prompt_missing"] = "Der Eingabedialog ist nicht verfügbar.", + ["info.outfits.new_title"] = "Aktuelle Ausrüstung speichern als", + ["info.outfits.created"] = "Aktuelle Ausrüstung in der Ausrüstung gespeichert.", + ["info.outfits.replace_sent"] = "Ersetzen gesendet. Die Taschen müssen genug freien Platz haben.", + ["info.outfits.equip_sent"] = "Ausrüsten an den Bot gesendet.", + ["info.outfits.updated"] = "Ausrüstung mit aktueller Ausrüstung aktualisiert.", + ["info.outfits.reset_sent"] = "Zurücksetzen an den Bot gesendet.", + ["info.outfits.idle"] = "Öffne Ausrüstungen, um die Set-Liste des Bots zu laden.", + ["info.outfits.replace_warning"] = "Ersetzen kann fehlschlagen, wenn die Taschen voll sind.", + ["info.outfits.list"] = "Ausrüstungen", + ["info.outfits.refresh"] = "Aktualisieren", + ["info.outfits.new"] = "Neu", + ["info.outfits.equip"] = "Ausrüsten", + ["info.outfits.replace"] = "Ersetzen", + ["info.outfits.update"] = "Aktualisieren", + ["info.outfits.reset"] = "Zurücksetzen", + ["tips.outfits.equip"] = "Linksklick: Ausrüsten\nRechtsklick: Ersetzen", } register("deDE", deDEValues) diff --git a/Locales/MultiBotAceLocale-enGB.lua b/Locales/MultiBotAceLocale-enGB.lua index a589ae5..9a5424e 100644 --- a/Locales/MultiBotAceLocale-enGB.lua +++ b/Locales/MultiBotAceLocale-enGB.lua @@ -728,6 +728,37 @@ local enGBValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "Group Management", ["info.avalaiblebots"] = "Available Bots", + ["info.outfits.window_title"] = "Outfits", + ["info.outfits.acegui_required"] = "AceGUI-3.0 is required for Outfits", + ["info.outfits.pinned"] = "Pinned outfit to the top.", + ["info.outfits.unpinned"] = "Removed outfit pin.", + ["info.outfits.none_selected"] = "No outfit selected", + ["info.outfits.empty"] = "No items saved in this outfit.", + ["info.outfits.select_left"] = "Select an outfit on the left.", + ["info.outfits.unpin"] = "Unpin", + ["info.outfits.pin"] = "Pin", + ["info.outfits.no_outfits"] = "No outfits found.", + ["info.outfits.loaded"] = "Loaded from bot.", + ["info.outfits.busy_wait"] = "Outfit action in progress... refresh queued.", + ["info.outfits.loading"] = "Loading outfits from bot...", + ["info.outfits.equip_auto_replace"] = "Weapon swap requires Replace. Sending Replace instead.", + ["info.outfits.prompt_missing"] = "Prompt dialog is not available.", + ["info.outfits.new_title"] = "Save current outfit as", + ["info.outfits.created"] = "Saved current equipment into the outfit.", + ["info.outfits.replace_sent"] = "Replace sent. Bags must have enough free space.", + ["info.outfits.equip_sent"] = "Equip sent to bot.", + ["info.outfits.updated"] = "Updated outfit from current gear.", + ["info.outfits.reset_sent"] = "Reset sent to bot.", + ["info.outfits.idle"] = "Open Outfits to load a bot set list.", + ["info.outfits.replace_warning"] = "Replace may fail if bags are full.", + ["info.outfits.list"] = "Outfits", + ["info.outfits.refresh"] = "Refresh", + ["info.outfits.new"] = "New", + ["info.outfits.equip"] = "Equip", + ["info.outfits.replace"] = "Replace", + ["info.outfits.update"] = "Update", + ["info.outfits.reset"] = "Reset", + ["tips.outfits.equip"] = "Left click: Equip\nRight click: Replace", } register("enGB", enGBValues) diff --git a/Locales/MultiBotAceLocale-enUS.lua b/Locales/MultiBotAceLocale-enUS.lua index 60aba35..b04efaa 100644 --- a/Locales/MultiBotAceLocale-enUS.lua +++ b/Locales/MultiBotAceLocale-enUS.lua @@ -728,6 +728,37 @@ local enUSValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "Group Management", ["info.avalaiblebots"] = "Available Bots", + ["info.outfits.window_title"] = "Outfits", + ["info.outfits.acegui_required"] = "AceGUI-3.0 is required for Outfits", + ["info.outfits.pinned"] = "Pinned outfit to the top.", + ["info.outfits.unpinned"] = "Removed outfit pin.", + ["info.outfits.none_selected"] = "No outfit selected", + ["info.outfits.empty"] = "No items saved in this outfit.", + ["info.outfits.select_left"] = "Select an outfit on the left.", + ["info.outfits.unpin"] = "Unpin", + ["info.outfits.pin"] = "Pin", + ["info.outfits.no_outfits"] = "No outfits found.", + ["info.outfits.loaded"] = "Loaded from bot.", + ["info.outfits.busy_wait"] = "Outfit action in progress... refresh queued.", + ["info.outfits.loading"] = "Loading outfits from bot...", + ["info.outfits.equip_auto_replace"] = "Weapon swap requires Replace. Sending Replace instead.", + ["info.outfits.prompt_missing"] = "Prompt dialog is not available.", + ["info.outfits.new_title"] = "Save current outfit as", + ["info.outfits.created"] = "Saved current equipment into the outfit.", + ["info.outfits.replace_sent"] = "Replace sent. Bags must have enough free space.", + ["info.outfits.equip_sent"] = "Equip sent to bot.", + ["info.outfits.updated"] = "Updated outfit from current gear.", + ["info.outfits.reset_sent"] = "Reset sent to bot.", + ["info.outfits.idle"] = "Open Outfits to load a bot set list.", + ["info.outfits.replace_warning"] = "Replace may fail if bags are full.", + ["info.outfits.list"] = "Outfits", + ["info.outfits.refresh"] = "Refresh", + ["info.outfits.new"] = "New", + ["info.outfits.equip"] = "Equip", + ["info.outfits.replace"] = "Replace", + ["info.outfits.update"] = "Update", + ["info.outfits.reset"] = "Reset", + ["tips.outfits.equip"] = "Left click: Equip\nRight click: Replace", } register("enUS", enUSValues, true) diff --git a/Locales/MultiBotAceLocale-esES.lua b/Locales/MultiBotAceLocale-esES.lua index 0b22e2f..ace4536 100644 --- a/Locales/MultiBotAceLocale-esES.lua +++ b/Locales/MultiBotAceLocale-esES.lua @@ -726,6 +726,37 @@ local esESValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "Gestión de Grupos", ["info.avalaiblebots"] = "Bots Disponibles", + ["info.outfits.window_title"] = "Conjuntos", + ["info.outfits.acegui_required"] = "AceGUI-3.0 es obligatorio para Conjuntos", + ["info.outfits.pinned"] = "Conjunto fijado arriba.", + ["info.outfits.unpinned"] = "Se quitó el anclaje del conjunto.", + ["info.outfits.none_selected"] = "Ningún conjunto seleccionado", + ["info.outfits.empty"] = "No hay objetos guardados en este conjunto.", + ["info.outfits.select_left"] = "Selecciona un conjunto a la izquierda.", + ["info.outfits.unpin"] = "Desfijar", + ["info.outfits.pin"] = "Fijar", + ["info.outfits.no_outfits"] = "No se encontraron conjuntos.", + ["info.outfits.loaded"] = "Cargado desde el bot.", + ["info.outfits.busy_wait"] = "Acción de conjunto en curso... actualización en cola.", + ["info.outfits.loading"] = "Cargando conjuntos del bot...", + ["info.outfits.equip_auto_replace"] = "El cambio de armas requiere Reemplazar. Se enviará Reemplazar en su lugar.", + ["info.outfits.prompt_missing"] = "El cuadro de diálogo no está disponible.", + ["info.outfits.new_title"] = "Guardar el conjunto actual como", + ["info.outfits.created"] = "El equipo actual se guardó en el conjunto.", + ["info.outfits.replace_sent"] = "Reemplazar enviado. Las bolsas deben tener suficiente espacio libre.", + ["info.outfits.equip_sent"] = "Equipar enviado al bot.", + ["info.outfits.updated"] = "Conjunto actualizado con el equipo actual.", + ["info.outfits.reset_sent"] = "Restablecer enviado al bot.", + ["info.outfits.idle"] = "Abre Conjuntos para cargar la lista de sets del bot.", + ["info.outfits.replace_warning"] = "Reemplazar puede fallar si las bolsas están llenas.", + ["info.outfits.list"] = "Conjuntos", + ["info.outfits.refresh"] = "Actualizar", + ["info.outfits.new"] = "Nuevo", + ["info.outfits.equip"] = "Equipar", + ["info.outfits.replace"] = "Reemplazar", + ["info.outfits.update"] = "Actualizar", + ["info.outfits.reset"] = "Restablecer", + ["tips.outfits.equip"] = "Clic izquierdo: Equipar\nClic derecho: Reemplazar", } register("esES", esESValues) diff --git a/Locales/MultiBotAceLocale-frFR.lua b/Locales/MultiBotAceLocale-frFR.lua index 1f49724..0e1b1df 100644 --- a/Locales/MultiBotAceLocale-frFR.lua +++ b/Locales/MultiBotAceLocale-frFR.lua @@ -725,6 +725,37 @@ local frFRValues = { ["spec.list.title"] = "Spés", ["info.managegroups"] = "Gestion des Groupes", ["info.avalaiblebots"] = "Bots Disponibles", + ["info.outfits.window_title"] = "Tenues", + ["info.outfits.acegui_required"] = "AceGUI-3.0 est requis pour la fenêtre Tenues", + ["info.outfits.pinned"] = "Tenue épinglée en haut.", + ["info.outfits.unpinned"] = "Épinglage de la tenue supprimé.", + ["info.outfits.none_selected"] = "Aucune tenue sélectionnée", + ["info.outfits.empty"] = "Aucun objet enregistré dans cette tenue.", + ["info.outfits.select_left"] = "Sélectionnez une tenue à gauche.", + ["info.outfits.unpin"] = "Désépingler", + ["info.outfits.pin"] = "Épingler", + ["info.outfits.no_outfits"] = "Aucune tenue trouvée.", + ["info.outfits.loaded"] = "Chargé depuis le bot.", + ["info.outfits.busy_wait"] = "Action de tenue en cours... rafraîchissement en file d'attente.", + ["info.outfits.loading"] = "Chargement des tenues du bot...", + ["info.outfits.equip_auto_replace"] = "Le changement d'armes nécessite Remplacer. Envoi de Remplacer à la place.", + ["info.outfits.prompt_missing"] = "La boîte de dialogue de saisie n'est pas disponible.", + ["info.outfits.new_title"] = "Enregistrer l'équipement actuel sous", + ["info.outfits.created"] = "Équipement actuel enregistré dans la tenue.", + ["info.outfits.replace_sent"] = "Commande Remplacer envoyée. Les sacs doivent avoir assez de place libre.", + ["info.outfits.equip_sent"] = "Commande Équiper envoyée au bot.", + ["info.outfits.updated"] = "Tenue mise à jour à partir de l'équipement actuel.", + ["info.outfits.reset_sent"] = "Commande Réinitialiser envoyée au bot.", + ["info.outfits.idle"] = "Ouvrez Tenues pour charger la liste des sets du bot.", + ["info.outfits.replace_warning"] = "Remplacer peut échouer si les sacs sont pleins.", + ["info.outfits.list"] = "Tenues", + ["info.outfits.refresh"] = "Rafraîchir", + ["info.outfits.new"] = "Nouveau", + ["info.outfits.equip"] = "Équiper", + ["info.outfits.replace"] = "Remplacer", + ["info.outfits.update"] = "Mettre à jour", + ["info.outfits.reset"] = "Réinitialiser", + ["tips.outfits.equip"] = "Clic gauche : Équiper\nClic droit : Remplacer", } register("frFR", frFRValues) diff --git a/Locales/MultiBotAceLocale-koKR.lua b/Locales/MultiBotAceLocale-koKR.lua index fe96061..c29dfcb 100644 --- a/Locales/MultiBotAceLocale-koKR.lua +++ b/Locales/MultiBotAceLocale-koKR.lua @@ -717,6 +717,37 @@ local koKRValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "그룹 관리", ["info.avalaiblebots"] = "사용 가능한 봇", + ["info.outfits.window_title"] = "장비 세트", + ["info.outfits.acegui_required"] = "장비 세트에는 AceGUI-3.0이 필요합니다", + ["info.outfits.pinned"] = "장비 세트를 맨 위에 고정했습니다.", + ["info.outfits.unpinned"] = "장비 세트 고정을 해제했습니다.", + ["info.outfits.none_selected"] = "선택된 장비 세트가 없습니다", + ["info.outfits.empty"] = "이 장비 세트에 저장된 아이템이 없습니다.", + ["info.outfits.select_left"] = "왼쪽에서 장비 세트를 선택하세요.", + ["info.outfits.unpin"] = "고정 해제", + ["info.outfits.pin"] = "고정", + ["info.outfits.no_outfits"] = "장비 세트를 찾을 수 없습니다.", + ["info.outfits.loaded"] = "봇에서 불러왔습니다.", + ["info.outfits.busy_wait"] = "장비 세트 작업 진행 중... 새로 고침이 대기열에 추가되었습니다.", + ["info.outfits.loading"] = "봇에서 장비 세트를 불러오는 중...", + ["info.outfits.equip_auto_replace"] = "무기 교체에는 바꾸기가 필요합니다. 대신 바꾸기를 전송합니다.", + ["info.outfits.prompt_missing"] = "입력 대화상자를 사용할 수 없습니다.", + ["info.outfits.new_title"] = "현재 장비 세트를 다음 이름으로 저장", + ["info.outfits.created"] = "현재 장비를 장비 세트에 저장했습니다.", + ["info.outfits.replace_sent"] = "바꾸기를 전송했습니다. 가방에 충분한 빈 공간이 있어야 합니다.", + ["info.outfits.equip_sent"] = "장착 명령을 봇에게 보냈습니다.", + ["info.outfits.updated"] = "현재 장비로 장비 세트를 업데이트했습니다.", + ["info.outfits.reset_sent"] = "초기화 명령을 봇에게 보냈습니다.", + ["info.outfits.idle"] = "봇 세트 목록을 불러오려면 장비 세트를 여세요.", + ["info.outfits.replace_warning"] = "가방이 가득 차 있으면 바꾸기가 실패할 수 있습니다.", + ["info.outfits.list"] = "장비 세트", + ["info.outfits.refresh"] = "새로 고침", + ["info.outfits.new"] = "새로 만들기", + ["info.outfits.equip"] = "장착", + ["info.outfits.replace"] = "바꾸기", + ["info.outfits.update"] = "업데이트", + ["info.outfits.reset"] = "초기화", + ["tips.outfits.equip"] = "왼쪽 클릭: 장착\n오른쪽 클릭: 바꾸기", } register("koKR", koKRValues) diff --git a/Locales/MultiBotAceLocale-ruRU.lua b/Locales/MultiBotAceLocale-ruRU.lua index 96ef53c..e557468 100644 --- a/Locales/MultiBotAceLocale-ruRU.lua +++ b/Locales/MultiBotAceLocale-ruRU.lua @@ -726,6 +726,37 @@ local ruRUValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "Управление группами", ["info.avalaiblebots"] = "Доступные боты", + ["info.outfits.window_title"] = "Наборы", + ["info.outfits.acegui_required"] = "Для окна наборов требуется AceGUI-3.0", + ["info.outfits.pinned"] = "Набор закреплён сверху.", + ["info.outfits.unpinned"] = "Закрепление набора снято.", + ["info.outfits.none_selected"] = "Набор не выбран", + ["info.outfits.empty"] = "В этом наборе нет сохранённых предметов.", + ["info.outfits.select_left"] = "Выберите набор слева.", + ["info.outfits.unpin"] = "Открепить", + ["info.outfits.pin"] = "Закрепить", + ["info.outfits.no_outfits"] = "Наборы не найдены.", + ["info.outfits.loaded"] = "Загружено от бота.", + ["info.outfits.busy_wait"] = "Действие с набором выполняется... обновление поставлено в очередь.", + ["info.outfits.loading"] = "Загрузка наборов от бота...", + ["info.outfits.equip_auto_replace"] = "Для смены оружия требуется Замена. Вместо этого отправляется Замена.", + ["info.outfits.prompt_missing"] = "Окно ввода недоступно.", + ["info.outfits.new_title"] = "Сохранить текущий набор как", + ["info.outfits.created"] = "Текущее снаряжение сохранено в набор.", + ["info.outfits.replace_sent"] = "Замена отправлена. В сумках должно быть достаточно свободного места.", + ["info.outfits.equip_sent"] = "Команда экипировки отправлена боту.", + ["info.outfits.updated"] = "Набор обновлён по текущему снаряжению.", + ["info.outfits.reset_sent"] = "Сброс отправлен боту.", + ["info.outfits.idle"] = "Откройте Наборы, чтобы загрузить список комплектов бота.", + ["info.outfits.replace_warning"] = "Замена может не сработать, если сумки заполнены.", + ["info.outfits.list"] = "Наборы", + ["info.outfits.refresh"] = "Обновить", + ["info.outfits.new"] = "Новый", + ["info.outfits.equip"] = "Надеть", + ["info.outfits.replace"] = "Заменить", + ["info.outfits.update"] = "Обновить", + ["info.outfits.reset"] = "Сбросить", + ["tips.outfits.equip"] = "ЛКМ: Надеть\nПКМ: Заменить", } register("ruRU", ruRUValues) diff --git a/Locales/MultiBotAceLocale-zhCN.lua b/Locales/MultiBotAceLocale-zhCN.lua index 24bd370..443084d 100644 --- a/Locales/MultiBotAceLocale-zhCN.lua +++ b/Locales/MultiBotAceLocale-zhCN.lua @@ -726,6 +726,37 @@ local zhCNValues = { ["spec.list.title"] = "Specs", ["info.managegroups"] = "队伍管理", ["info.avalaiblebots"] = "可用的机器人", + ["info.outfits.window_title"] = "配装", + ["info.outfits.acegui_required"] = "配装窗口需要 AceGUI-3.0", + ["info.outfits.pinned"] = "已将配装置顶固定。", + ["info.outfits.unpinned"] = "已取消配装置顶固定。", + ["info.outfits.none_selected"] = "未选择配装", + ["info.outfits.empty"] = "此配装中没有已保存的物品。", + ["info.outfits.select_left"] = "请在左侧选择一个配装。", + ["info.outfits.unpin"] = "取消置顶", + ["info.outfits.pin"] = "置顶", + ["info.outfits.no_outfits"] = "未找到配装。", + ["info.outfits.loaded"] = "已从机器人加载。", + ["info.outfits.busy_wait"] = "配装操作进行中……已排队刷新。", + ["info.outfits.loading"] = "正在从机器人加载配装……", + ["info.outfits.equip_auto_replace"] = "武器切换需要使用替换。将改为发送替换命令。", + ["info.outfits.prompt_missing"] = "输入对话框不可用。", + ["info.outfits.new_title"] = "将当前配装保存为", + ["info.outfits.created"] = "当前装备已保存到该配装中。", + ["info.outfits.replace_sent"] = "已发送替换命令。背包必须有足够的空位。", + ["info.outfits.equip_sent"] = "已向机器人发送装备命令。", + ["info.outfits.updated"] = "已根据当前装备更新配装。", + ["info.outfits.reset_sent"] = "已向机器人发送重置命令。", + ["info.outfits.idle"] = "打开配装以加载机器人的套装列表。", + ["info.outfits.replace_warning"] = "如果背包已满,替换可能会失败。", + ["info.outfits.list"] = "配装", + ["info.outfits.refresh"] = "刷新", + ["info.outfits.new"] = "新建", + ["info.outfits.equip"] = "装备", + ["info.outfits.replace"] = "替换", + ["info.outfits.update"] = "更新", + ["info.outfits.reset"] = "重置", + ["tips.outfits.equip"] = "左键:装备\n右键:替换", } register("zhCN", zhCNValues) diff --git a/MultiBot.toc b/MultiBot.toc index 542f4c2..9198981 100644 --- a/MultiBot.toc +++ b/MultiBot.toc @@ -1,5 +1,5 @@ ## Interface: 30300 -## Version: 2.0.0 +## Version: 3.0.0 ## Title: MultiBot ## Notes: User Interface for Playerbot ## Author: Nico Löbbert @@ -7,7 +7,7 @@ ## SavedVariables: MultiBotGlobalSave ## X-Maintainer: Wishmaster117 aka TheWarlock ## X-Contributors: Wishmaster117 aka TheWarlock -## X-Contact: admin@azerothdevs.com +## X-Contact: plexalexdcnh@gmail.com ## X-ProjectURL: https://www.azerothdevs.com Libs\LibStub\LibStub.lua @@ -25,6 +25,7 @@ Libs\LibDropdown-1.0\LibDropdown-1.0.lua Core\MultiBot.lua +Core\MultiBotStore.lua Core\MultiBotConfig.lua Core\MultiBotLocale.lua Core\MultiBotDebug.lua @@ -63,6 +64,7 @@ UI\MultiBotStats.lua UI\MultiBotSpell.lua UI\MultiBotSpellBookFrame.lua UI\MultiBotRewardFrame.lua +UI\MultiBotOutfitUI.lua UI\MultiBotInventoryFrame.lua UI\MultiBotInventoryItem.lua UI\MultiBotInspectUI.lua diff --git a/ROADMAP.md b/ROADMAP.md index af853ce..84a95a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,8 +35,8 @@ - `Core/MultiBotInit.lua`, `Features/MultiBotRaidus.lua`, `Core/MultiBotEvery.lua`, `Core/MultiBotEngine.lua`, `Core/MultiBotHandler.lua`, `Strategies/MultiBotDruid.lua`, `Strategies/MultiBotPaladin.lua`, `Strategies/MultiBotMage.lua`, `Strategies/MultiBotWarlock.lua`, `Strategies/MultiBotPriest.lua`, `Strategies/MultiBotShaman.lua`, `Strategies/MultiBotHunter.lua`, `Strategies/MultiBotRogue.lua`, `Strategies/MultiBotDeathKnight.lua`, and `Strategies/MultiBotWarrior.lua` migration sweeps are completed for legacy `MultiBot.tips.*` runtime reads. - `Core/MultiBot.lua` bootstrap `MultiBot.tips` initialization lines were validated/documented as intentional non-runtime-tooltip compatibility paths. - Remaining UI literal cleanup is completed for Milestone 9 scope (GM shortcut labels, Raidus group title formatting, shared UI defaults for page/title labels) while preserving technical/protocol identifiers (e.g. internal "Inventory" button/event keys). -- **Milestone 10 (Data model and table lifecycle hardening):** Planned. - - Normalize runtime stores and remove ad-hoc table creation paths via centralized getters/validators. +- **Milestone 10 (Data model and table lifecycle hardening):** Completed. + - Runtime/profile stores are centralized via `MultiBot.Store` with explicit `get*` vs `ensure*` semantics and read-path hardening validated in tracker `docs/milestone10-data-model-lifecycle-tracker.md`. - **Milestone 11 (Scheduler/timers convergence):** Planned. - Route scattered timers/OnUpdate loops to a constrained scheduler strategy (AceTimer where appropriate, existing loops retained when safer). - **Milestone 12 (Observability, diagnostics and perf guardrails):** Planned. diff --git a/TODO.md b/TODO.md index a82e496..fed6adc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ TODO - +* faire en sorte que la croix de fermeture de quickshamant et quickhunter reste à la même place quand on clique dessus pour les fermer * Uniformiser le template des frame quetes comme celle de Itemus * Uniformiser le template de la frame reward comme celle de itemus * Raidus doit se rafraichir à l'ouverture et fermeture @@ -10,4 +10,5 @@ TODO * Finir les options de déplacement des boutons * faire en sorte que les menus déroulants de la main barre se ferment quand on on ouvre un autre * revoir le fichiers UI/MultiBotTalent, la partie des glyphes et des talents car il y'a eu des modifications dans le fichiers .conf de multibot -* pourquoi les glyphes sont longues a afficher? \ No newline at end of file +* pourquoi les glyphes sont longues a afficher? +* implémenter RTI \ No newline at end of file diff --git a/UI/MultiBotAceUI.lua b/UI/MultiBotAceUI.lua index a1a7195..c9801dd 100644 --- a/UI/MultiBotAceUI.lua +++ b/UI/MultiBotAceUI.lua @@ -85,12 +85,23 @@ function AceUI.RegisterWindowEscapeClose(window, namePrefix) table.insert(UISpecialFrames, frameName) end -local function getUiProfileStore() +local function getUiProfileStore(createIfMissing) + if MultiBot.Store then + if createIfMissing and type(MultiBot.Store.EnsureUIStore) == "function" then + return MultiBot.Store.EnsureUIStore() + end + if not createIfMissing and type(MultiBot.Store.GetUIStore) == "function" then + return MultiBot.Store.GetUIStore() + end + end + local profile = MultiBot.db and MultiBot.db.profile if not profile then return nil end - + if not createIfMissing then + return type(profile.ui) == "table" and profile.ui or nil + end profile.ui = profile.ui or {} return profile.ui end @@ -100,14 +111,9 @@ function AceUI.BindWindowPosition(window, persistenceKey) return end - local uiStore = getUiProfileStore() - if not uiStore then - return - end - - uiStore.popupPositions = uiStore.popupPositions or {} - local positions = uiStore.popupPositions - local saved = positions[persistenceKey] + local uiStore = getUiProfileStore(false) + local positions = (uiStore and type(uiStore.popupPositions) == "table") and uiStore.popupPositions or nil + local saved = positions and positions[persistenceKey] if saved and saved.point then window.frame:ClearAllPoints() window.frame:SetPoint(saved.point, UIParent, saved.point, saved.x or 0, saved.y or 0) @@ -121,7 +127,12 @@ function AceUI.BindWindowPosition(window, persistenceKey) window.frame:HookScript("OnDragStop", function(frame) local point, _, _, x, y = frame:GetPoint(1) if point then - positions[persistenceKey] = { point = point, x = x or 0, y = y or 0 } + local writableUiStore = getUiProfileStore(true) + if not writableUiStore then + return + end + writableUiStore.popupPositions = writableUiStore.popupPositions or {} + writableUiStore.popupPositions[persistenceKey] = { point = point, x = x or 0, y = y or 0 } end end) end diff --git a/UI/MultiBotHunterQuickFrame.lua b/UI/MultiBotHunterQuickFrame.lua index 11e25e3..a3b3a01 100644 --- a/UI/MultiBotHunterQuickFrame.lua +++ b/UI/MultiBotHunterQuickFrame.lua @@ -10,7 +10,7 @@ local WINDOW_HEIGHT = ROW_HEIGHT local WINDOW_PADDING_X = 0 local WINDOW_PADDING_Y = 0 local WINDOW_TITLE = "Quick Hunter" -local WINDOW_DEFAULT_POINT = { point = "CENTER", relPoint = "CENTER", x = -820, y = 300 } +local WINDOW_DEFAULT_POINT = { point = "TOP", relPoint = "TOP", x = -76.67194107900505, y = -29.34896683789212 } local ICON_FALLBACK = "Interface\\Icons\\INV_Misc_QuestionMark" local HANDLE_WIDTH = 12 local HANDLE_HEIGHT = 18 diff --git a/UI/MultiBotMainUI.lua b/UI/MultiBotMainUI.lua index 3172819..d0b5ccd 100644 --- a/UI/MultiBotMainUI.lua +++ b/UI/MultiBotMainUI.lua @@ -152,7 +152,7 @@ local function refreshLeftLayout() end local function resetDefaultWindowPositions() - MultiBot.frames["MultiBar"].setPoint(-262, 144) + MultiBot.frames["MultiBar"].setPoint(-303, 144) MultiBot.inventory.setPoint(-700, -144) MultiBot.spellbook.setPoint(-802, 302) MultiBot.talent.setPoint(-104, -276) diff --git a/UI/MultiBotOutfitUI.lua b/UI/MultiBotOutfitUI.lua new file mode 100644 index 0000000..735e42e --- /dev/null +++ b/UI/MultiBotOutfitUI.lua @@ -0,0 +1,1445 @@ +if not MultiBot then return end + +local OUTFIT_WINDOW_WIDTH = 590 +local OUTFIT_WINDOW_HEIGHT = 430 +local OUTFIT_PANEL_INSET = 8 +local OUTFIT_PANEL_GAP = 6 +local OUTFIT_LIST_WIDTH = 170 +local OUTFIT_STATUS_HEIGHT = 30 +local OUTFIT_BUTTON_HEIGHT = 22 +local OUTFIT_LIST_BUTTON_HEIGHT = 20 +local OUTFIT_BUTTON_TEXT_PADDING = 24 +local OUTFIT_BUTTON_ROW_GAP = 6 +local OUTFIT_LEFT_BUTTONS_AREA_HEIGHT = 60 +local OUTFIT_ITEM_SIZE = 32 +local OUTFIT_FAVORITE_ICON_SIZE = 10 +local OUTFIT_FAVORITE_ICON_GAP = 4 +local OUTFIT_FAVORITE_ICON_TEXTURE = "Interface\\TARGETINGFRAME\\UI-RaidTargetingIcon_1" +local OUTFIT_ITEM_SPACING_X = 38 +local OUTFIT_ITEM_SPACING_Y = 38 +local OUTFIT_ITEMS_PER_ROW = 6 +local OUTFIT_UPDATE_REFRESH_DELAY = 0.60 +local OUTFIT_RESET_REFRESH_DELAY = 0.60 +local OUTFIT_EQUIP_REFRESH_DELAY = 1.10 +local OUTFIT_REPLACE_REFRESH_DELAY = 1.75 +local OUTFIT_INSPECT_GAP = 12 +local OUTFIT_PERSIST_FLUSH_DELAY = 0.25 +local INV_SLOT_MAINHAND = INV_SLOT_MAINHAND or 16 + +local OUTFIT_LIST_SCROLL_NAME = "MultiBotOutfitListScrollFrame" +local OUTFIT_ITEMS_SCROLL_NAME = "MultiBotOutfitItemsScrollFrame" + +local function outfitL(key, fallback) + return MultiBot.L("info.outfits." .. key, fallback) +end + +local function prepareTooltipAboveOutfits(owner, anchor) + if not GameTooltip or not owner then + return false + end + + local outfitFrame = + MultiBot.outfits + and MultiBot.outfits.window + and MultiBot.outfits.window.frame + or nil + + GameTooltip:SetOwner(owner, anchor or "ANCHOR_RIGHT") + + if outfitFrame and outfitFrame.GetFrameStrata and GameTooltip.SetFrameStrata then + local strata = outfitFrame:GetFrameStrata() + if strata and strata ~= "" then + GameTooltip:SetFrameStrata(strata) + end + elseif GameTooltip.SetFrameStrata then + GameTooltip:SetFrameStrata("TOOLTIP") + end + + if outfitFrame and outfitFrame.GetFrameLevel and GameTooltip.SetFrameLevel then + GameTooltip:SetFrameLevel((outfitFrame:GetFrameLevel() or 0) + 64) + end + + GameTooltip:Raise() + return true +end + +local function getWindowTitle(botName) + local base = outfitL("window_title") + if type(botName) == "string" and botName ~= "" then + return base .. " - " .. botName + end + return base +end + +local function outfitTip(key, fallback) + return MultiBot.L("tips.outfits." .. key, fallback) +end + +local function addBackdrop(frame, bgAlpha) + if not frame or not frame.SetBackdrop then + return + end + + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, + tileSize = 16, + edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + + if frame.SetBackdropColor then + frame:SetBackdropColor(0.06, 0.06, 0.08, bgAlpha or 0.92) + end + + if frame.SetBackdropBorderColor then + frame:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.95) + end +end + +local function trim(value) + if type(value) ~= "string" then + return "" + end + + return (string.gsub(value, "^%s*(.-)%s*$", "%1")) +end + +local function getOutfitStore() + local store = MultiBot.Store and MultiBot.Store.EnsureUIChildStore and MultiBot.Store.EnsureUIChildStore("outfits") or nil + if type(store) ~= "table" then + _G.MultiBotSave = _G.MultiBotSave or {} + _G.MultiBotSave.outfits = _G.MultiBotSave.outfits or {} + store = _G.MultiBotSave.outfits + end + + store.favorites = store.favorites or {} + store.lastSelected = store.lastSelected or {} + store.lastUsed = store.lastUsed or {} + return store +end + +local function getBotFavorites(botName) + local store = getOutfitStore() + store.favorites[botName] = store.favorites[botName] or {} + return store.favorites[botName] +end + +local function isFavorite(botName, outfitName) + local favorites = getBotFavorites(botName) + return favorites[outfitName] and true or false +end + +local function setFavorite(botName, outfitName, enabled) + local favorites = getBotFavorites(botName) + if enabled then + favorites[outfitName] = true + else + favorites[outfitName] = nil + end +end + +local function toggleFavorite(botName, outfitName) + local enabled = not isFavorite(botName, outfitName) + setFavorite(botName, outfitName, enabled) + return enabled +end + +local function setLastSelected(botName, outfitName) + local store = getOutfitStore() + store.lastSelected[botName] = outfitName +end + +local function getLastSelected(botName) + local store = getOutfitStore() + return store.lastSelected[botName] +end + +local function setLastUsed(botName, outfitName) + local store = getOutfitStore() + store.lastUsed[botName] = outfitName +end + +local function getLastUsed(botName) + local store = getOutfitStore() + return store.lastUsed[botName] +end + +local function getUnitsRoot() + return MultiBot.frames + and MultiBot.frames["MultiBar"] + and MultiBot.frames["MultiBar"].frames + and MultiBot.frames["MultiBar"].frames["Units"] + or nil +end + +local function getUnitFrame(botName) + local units = getUnitsRoot() + return units and units.frames and units.frames[botName] or nil +end + +local function getUnitButton(botName, key) + local unitFrame = getUnitFrame(botName) + return unitFrame and unitFrame.getButton and unitFrame.getButton(key) or nil +end + +local function syncOutfitButtonState(enabled, botName) + local targetBot = botName + or (MultiBot.outfits and MultiBot.outfits.name) + or nil + + if type(targetBot) ~= "string" or targetBot == "" then + return + end + + local sourceButton = getUnitButton(targetBot, "Outfits") + if not sourceButton then + return + end + + if enabled then + if sourceButton.setEnable then + sourceButton.setEnable() + end + else + if sourceButton.setDisable then + sourceButton.setDisable() + end + end +end + +local function ensureInspectUI() + if InspectFrame then + return true + end + + if LoadAddOn then + pcall(LoadAddOn, "Blizzard_InspectUI") + end + + return InspectFrame ~= nil +end + +local function openInspectForBot(botName) + if not botName or botName == "" or not MultiBot.toUnit then + return + end + + local unit = MultiBot.toUnit(botName) + if not unit or not UnitExists(unit) then + return + end + + if not ensureInspectUI() then + return + end + + InspectUnit(unit) + + if InspectFrame and ShowUIPanel and not InspectFrame:IsShown() then + ShowUIPanel(InspectFrame) + end +end + +local function closeInspectForBot(botName) + if not botName or botName == "" or not InspectFrame or not InspectFrame:IsShown() then + return + end + + local inspectedName = nil + if InspectFrame.unit and UnitExists(InspectFrame.unit) then + inspectedName = UnitName(InspectFrame.unit) + end + + if inspectedName and inspectedName ~= botName then + return + end + + if HideUIPanel then + HideUIPanel(InspectFrame) + else + InspectFrame:Hide() + end +end + +local function placeOutfitsToRightOfInspect() + local outfitFrame = + MultiBot.outfits + and MultiBot.outfits.window + and MultiBot.outfits.window.frame + or nil + + if not outfitFrame or not ensureInspectUI() or not InspectFrame then + return + end + + outfitFrame:ClearAllPoints() + outfitFrame:SetPoint("TOPLEFT", InspectFrame, "TOPRIGHT", OUTFIT_INSPECT_GAP, 0) +end + +local function getUnitWaitButton(botName) + local units = getUnitsRoot() + return units and units.buttons and units.buttons[botName] or nil +end + +local function getUnitFromBot(botName) + if not botName or botName == "" or not MultiBot.toUnit then + return nil + end + return MultiBot.toUnit(botName) +end + +local function getEquipLinkForSlot(unit, slot) + if not unit or unit == "" then + return nil + end + local ok, link = pcall(GetInventoryItemLink, unit, slot) + if not ok then + return nil + end + return link +end + +local function botHasTwoHandEquipped(botName) + local unit = getUnitFromBot(botName) + if not unit then + return nil + end + + if InspectUnit then + pcall(InspectUnit, unit) + else + if LoadAddOn then pcall(LoadAddOn, "Blizzard_InspectUI") end + if InspectUnit then pcall(InspectUnit, unit) end + end + + local mainLink = getEquipLinkForSlot(unit, INV_SLOT_MAINHAND) + if not mainLink then + return nil + end + + pcall(GetItemInfo, mainLink) + local _, _, _, _, _, _, _, _, equipLoc = GetItemInfo(mainLink) + if equipLoc == "INVTYPE_2HWEAPON" then + return true + end + return false +end + +function MultiBot.SyncToolWindowButtons(activeBotName, activeKey) + local units = getUnitsRoot() + if not units or not MultiBot.index or not MultiBot.index.actives then + return + end + + local visible = {} + local function markVisible(botName, key) + if type(botName) ~= "string" or botName == "" or type(key) ~= "string" or key == "" then return end + visible[botName] = visible[botName] or {} + visible[botName][key] = true + end + if MultiBot.inventory and MultiBot.inventory.IsVisible and MultiBot.inventory:IsVisible() then + markVisible(MultiBot.inventory.name, "Inventory") + end + if MultiBot.outfits and MultiBot.outfits.IsVisible and MultiBot.outfits:IsVisible() then + markVisible(MultiBot.outfits.name, "Outfits") + end + markVisible(activeBotName, activeKey) + + for _, botName in pairs(MultiBot.index.actives) do + if botName ~= UnitName("player") then + local inventoryButton = getUnitButton(botName, "Inventory") + local outfitButton = getUnitButton(botName, "Outfits") + + local botState = visible[botName] or {} + + if inventoryButton then + if botState.Inventory and inventoryButton.setEnable then + inventoryButton.setEnable() + elseif inventoryButton.setDisable then + inventoryButton.setDisable() + end + end + if outfitButton then + if botState.Outfits and outfitButton.setEnable then + outfitButton.setEnable() + elseif outfitButton.setDisable then + outfitButton.setDisable() + end + end + end + end + end + +local OutfitUI = MultiBot.OutfitUI or {} +OutfitUI.entries = OutfitUI.entries or {} +OutfitUI.selectedName = OutfitUI.selectedName or nil +OutfitUI.pendingBot = OutfitUI.pendingBot or nil +OutfitUI.commandBusy = OutfitUI.commandBusy or false +OutfitUI.commandBusyBot = OutfitUI.commandBusyBot or nil +OutfitUI.commandBusyToken = OutfitUI.commandBusyToken or 0 +OutfitUI.pendingRefresh = OutfitUI.pendingRefresh or false +OutfitUI.requestToken = OutfitUI.requestToken or 0 +MultiBot.OutfitUI = OutfitUI + +local function sortEntriesForBot(botName, entries) + table.sort(entries, function(left, right) + local leftFavorite = isFavorite(botName, left.name) + local rightFavorite = isFavorite(botName, right.name) + if leftFavorite ~= rightFavorite then + return leftFavorite + end + + return string.lower(left.name or "") < string.lower(right.name or "") + end) +end + +local function setActionButtonText(button, text) + if not button then + return + end + + button:SetText(text or "") + + local fontString = (button.GetFontString and button:GetFontString()) or button.Text + local textWidth = 0 + if fontString and fontString.GetStringWidth then + textWidth = fontString:GetStringWidth() or 0 + end + + local minWidth = button.minAutoWidth or 0 + local padding = button.autoWidthPadding or OUTFIT_BUTTON_TEXT_PADDING + button:SetWidth(math.max(minWidth, math.ceil(textWidth + padding))) +end + +local function createActionButton(parent, minWidth, text, anchor, relativeTo, relativePoint, offsetX, offsetY, onClick) + local button = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate") + button.minAutoWidth = minWidth or 0 + button.autoWidthPadding = OUTFIT_BUTTON_TEXT_PADDING + button:SetHeight(OUTFIT_BUTTON_HEIGHT) + button:SetPoint(anchor, relativeTo, relativePoint, offsetX, offsetY) + setActionButtonText(button, text) + button:SetScript("OnClick", onClick) + return button +end + +local function layoutButtonRowCentered(container, buttons, gap) + if not container or type(buttons) ~= "table" or #buttons == 0 then + return + end + + local spacing = gap or 0 + local totalWidth = 0 + + for index, button in ipairs(buttons) do + if button then + totalWidth = totalWidth + (button:GetWidth() or 0) + if index > 1 then + totalWidth = totalWidth + spacing + end + end + end + + container:SetWidth(totalWidth) + container:SetHeight(OUTFIT_BUTTON_HEIGHT) + + local previous = nil + for _, button in ipairs(buttons) do + button:ClearAllPoints() + if not previous then + button:SetPoint("LEFT", container, "LEFT", 0, 0) + else + button:SetPoint("LEFT", previous, "RIGHT", spacing, 0) + end + previous = button + end +end + +local function createOutfitEntryButton(parent) + local button = CreateFrame("Button", nil, parent) + button:SetHeight(OUTFIT_LIST_BUTTON_HEIGHT) + button:RegisterForClicks("LeftButtonUp", "RightButtonUp") + button:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + + local selected = button:CreateTexture(nil, "BACKGROUND") + selected:SetTexture("Interface\\Buttons\\WHITE8x8") + selected:SetAllPoints(button) + selected:SetVertexColor(0.18, 0.24, 0.34, 0.55) + selected:Hide() + button.selectedTexture = selected + + local favoriteIcon = button:CreateTexture(nil, "OVERLAY") + favoriteIcon:SetTexture(OUTFIT_FAVORITE_ICON_TEXTURE) + favoriteIcon:SetWidth(OUTFIT_FAVORITE_ICON_SIZE) + favoriteIcon:SetHeight(OUTFIT_FAVORITE_ICON_SIZE) + favoriteIcon:SetPoint("LEFT", button, "LEFT", 4, 0) + favoriteIcon:Hide() + button.favoriteIcon = favoriteIcon + + local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + text:SetPoint("LEFT", button, "LEFT", 4 + OUTFIT_FAVORITE_ICON_SIZE + OUTFIT_FAVORITE_ICON_GAP, 0) + text:SetPoint("RIGHT", button, "RIGHT", -4, 0) + text:SetJustifyH("LEFT") + button.text = text + + return button +end + +local function createItemButton(parent) + local button = CreateFrame("Button", nil, parent) + button:SetWidth(OUTFIT_ITEM_SIZE) + button:SetHeight(OUTFIT_ITEM_SIZE) + button:RegisterForClicks("LeftButtonUp") + button:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD") + + local icon = button:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(button) + button.icon = icon + + local border = button:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Buttons\\UI-Quickslot2") + border:SetAllPoints(button) + button.border = border + + button:SetScript("OnEnter", function(self) + if not self.link or not GameTooltip then + return + end + + if not prepareTooltipAboveOutfits(self, "ANCHOR_RIGHT") then + return + end + + GameTooltip:SetHyperlink(self.link) + GameTooltip:Show() + end) + + button:SetScript("OnLeave", function() + if GameTooltip and GameTooltip.Hide then + GameTooltip:Hide() + end + end) + + button:SetScript("OnClick", function(self) + if self.link and HandleModifiedItemClick then + HandleModifiedItemClick(self.link) + end + end) + + return button +end + +local function extractItemLinks(text) + local links = {} + if type(text) ~= "string" then + return links + end + + for link in string.gmatch(text, "|c%x+|Hitem:[^|]+|h%[[^%]]+%]|h|r") do + table.insert(links, link) + end + + return links +end + +local function parseOutfitLine(rawLine) + if type(rawLine) ~= "string" or rawLine == "" then + return nil + end + + local lowerLine = string.lower(rawLine) + if string.find(lowerLine, "outfit ", 1, true) then + return nil + end + + local name, payload = string.match(rawLine, "^%s*([^:]+):%s*(.*)$") + if not name then + return nil + end + + name = trim(name) + if name == "" then + return nil + end + + return { + name = name, + items = extractItemLinks(payload), + raw = rawLine, + } +end + +function OutfitUI:IsVisible() + return self.frame and self.frame.IsVisible and self.frame:IsVisible() or false +end + +local function getItemEquipLoc(link) + if not link or link == "" then + return nil + end + local _, _, _, _, _, _, _, _, equipLoc = GetItemInfo(link) + return equipLoc +end + +local function isTwoHandEquipLoc(equipLoc) + return equipLoc == "INVTYPE_2HWEAPON" +end + +local function isGenericWeaponEquipLoc(equipLoc) + return equipLoc == "INVTYPE_WEAPON" +end + +local function isMainHandWeaponEquipLoc(equipLoc) + return equipLoc == "INVTYPE_WEAPONMAINHAND" + or equipLoc == "INVTYPE_WEAPON" + or equipLoc == "INVTYPE_2HWEAPON" +end + +local function isOffHandEquipLoc(equipLoc) + return equipLoc == "INVTYPE_WEAPONOFFHAND" + or equipLoc == "INVTYPE_HOLDABLE" + or equipLoc == "INVTYPE_SHIELD" +end + +local function buildTargetWeaponProfile(itemLinks) + local profile = { + hasWeapons = false, + usesTwoHand = false, + needsOffhand = false, + genericWeaponCount = 0, + } + + for _, link in ipairs(itemLinks or {}) do + local equipLoc = getItemEquipLoc(link) + + if isMainHandWeaponEquipLoc(equipLoc) or isOffHandEquipLoc(equipLoc) then + profile.hasWeapons = true + end + + if isTwoHandEquipLoc(equipLoc) then + profile.usesTwoHand = true + end + + if isOffHandEquipLoc(equipLoc) then + profile.needsOffhand = true + end + + if isGenericWeaponEquipLoc(equipLoc) then + profile.genericWeaponCount = profile.genericWeaponCount + 1 + end + end + + if profile.genericWeaponCount >= 2 then + profile.needsOffhand = true + end + + return profile +end + +local function prewarmItemCache(items) + if type(items) ~= "table" then return end + for _, link in ipairs(items) do + GetItemInfo(link) + end +end + +local function shouldForceReplaceForWeaponSwap(entry) + if not entry or not entry.items then + return false + end + + local targetProfile = buildTargetWeaponProfile(entry.items) + if targetProfile.usesTwoHand then return true end + if targetProfile.needsOffhand then return true end + if targetProfile.hasWeapons then + for _, link in ipairs(entry.items) do + if getItemEquipLoc(link) == nil then + return true + end + end + end + + return false +end + +function OutfitUI:SetStatus(message) + if self.statusText then + self.statusText:SetText(message or "") + end +end + +function OutfitUI:FindEntry(name) + if not name or name == "" then + return nil + end + + for _, entry in ipairs(self.entries or {}) do + if entry.name == name then + return entry + end + end + + return nil +end + +function OutfitUI:GetSelectedEntry() + return self:FindEntry(self.selectedName) +end + +function OutfitUI:IsCommandBusy(botName) + if not self.commandBusy then + return false + end + + if not botName or botName == "" then + return true + end + + return self.commandBusyBot == botName +end + +function OutfitUI:BeginCommandLock(botName) + self.commandBusy = true + self.commandBusyBot = botName + self.pendingRefresh = false + self.commandBusyToken = (self.commandBusyToken or 0) + 1 + self:RenderSelectedOutfit() + return self.commandBusyToken +end + +function OutfitUI:EndCommandLock(botName, token, refreshAfter) + if not self.commandBusy then + return + end + + if botName and self.commandBusyBot and botName ~= self.commandBusyBot then + return + end + + if token and token ~= self.commandBusyToken then + return + end + + local refreshBot = self.commandBusyBot or botName or self.botName + local shouldRefresh = refreshAfter or self.pendingRefresh + + self.commandBusy = false + self.commandBusyBot = nil + self.pendingRefresh = false + self:RenderSelectedOutfit() + + if shouldRefresh + and refreshBot and refreshBot ~= "" + and MultiBot.inventory + and MultiBot.inventory:IsVisible() + then + self:RequestList(refreshBot) + end +end + +function OutfitUI:RenderEntryList() + self.listButtons = self.listButtons or {} + self.entries = self.entries or {} + + if not self.listChild then + return + end + + local entries = self.entries or {} + local height = math.max(#entries * OUTFIT_LIST_BUTTON_HEIGHT, OUTFIT_LIST_BUTTON_HEIGHT) + self.listChild:SetHeight(height) + + for index, entry in ipairs(entries) do + local button = self.listButtons[index] + if not button then + button = createOutfitEntryButton(self.listChild) + self.listButtons[index] = button + end + + button:ClearAllPoints() + button:SetPoint("TOPLEFT", self.listChild, "TOPLEFT", 0, -((index - 1) * OUTFIT_LIST_BUTTON_HEIGHT)) + button:SetPoint("TOPRIGHT", self.listChild, "TOPRIGHT", 0, -((index - 1) * OUTFIT_LIST_BUTTON_HEIGHT)) + button.entry = entry + + local favorite = isFavorite(self.botName or "", entry.name) + if button.favoriteIcon then + if favorite then button.favoriteIcon:Show() else button.favoriteIcon:Hide() end + end + + button.text:SetText(entry.name) + if entry.name == self.selectedName then + button.selectedTexture:Show() + else + button.selectedTexture:Hide() + end + + button:SetScript("OnClick", function(clicked, mouseButton) + if mouseButton == "RightButton" then + local enabled = toggleFavorite(self.botName or "", clicked.entry.name) + if enabled then + self:SetStatus(outfitL("pinned")) + else + self:SetStatus(outfitL("unpinned")) + end + sortEntriesForBot(self.botName or "", self.entries) + self:RenderEntryList() + self:RenderSelectedOutfit() + return + end + + self.selectedName = clicked.entry.name + setLastSelected(self.botName or "", self.selectedName) + self:RenderEntryList() + self:RenderSelectedOutfit() + end) + + button:Show() + end + + for index = #entries + 1, #self.listButtons do + self.listButtons[index]:Hide() + end +end + +function OutfitUI:RenderSelectedOutfit() + self.itemButtons = self.itemButtons or {} + + local selected = self:GetSelectedEntry() + + if self.selectedNameText then + if selected then + local favorite = isFavorite(self.botName or "", selected.name) + if self.selectedFavoriteIcon then + if favorite then + self.selectedFavoriteIcon:Show() + else + self.selectedFavoriteIcon:Hide() + end + end + self.selectedNameText:SetText(selected.name) + else + if self.selectedFavoriteIcon then self.selectedFavoriteIcon:Hide() end + self.selectedNameText:SetText(outfitL("none_selected")) + end + end + + local items = selected and selected.items or {} + local child = self.itemsChild + if child then + local rows = math.max(1, math.ceil(math.max(1, #items) / OUTFIT_ITEMS_PER_ROW)) + child:SetHeight(rows * OUTFIT_ITEM_SPACING_Y) + end + + for index, link in ipairs(items) do + local button = self.itemButtons[index] + if not button then + button = createItemButton(self.itemsChild) + self.itemButtons[index] = button + end + + local column = (index - 1) % OUTFIT_ITEMS_PER_ROW + local row = math.floor((index - 1) / OUTFIT_ITEMS_PER_ROW) + button:ClearAllPoints() + button:SetPoint("TOPLEFT", self.itemsChild, "TOPLEFT", column * OUTFIT_ITEM_SPACING_X, -(row * OUTFIT_ITEM_SPACING_Y)) + button.link = link + local texture = GetItemIcon(link) + if not texture then + local itemName, itemLink, _, _, _, _, _, _, _, itemTexture = GetItemInfo(link) + texture = itemTexture or GetItemIcon(itemLink or itemName or "") + end + button.icon:SetTexture(texture or "Interface\\Icons\\INV_Misc_QuestionMark") + button:Show() + end + + for index = #items + 1, #self.itemButtons do + self.itemButtons[index]:Hide() + end + + if self.emptyItemsText then + if selected and #items == 0 then + self.emptyItemsText:SetText(outfitL("empty")) + self.emptyItemsText:Show() + elseif not selected then + self.emptyItemsText:SetText(outfitL("select_left")) + self.emptyItemsText:Show() + else + self.emptyItemsText:Hide() + end + end + + local hasBot = type(self.botName) == "string" and self.botName ~= "" + local hasSelection = selected ~= nil + if self.pinButton then + if hasSelection then + self.pinButton:Enable() + setActionButtonText(self.pinButton, isFavorite(self.botName or "", selected.name) and outfitL("unpin") or outfitL("pin")) + else + self.pinButton:Disable() + setActionButtonText(self.pinButton, outfitL("pin")) + end + end + + local isBusy = self:IsCommandBusy(self.botName) + + if self.refreshButton then + if hasBot and not isBusy then self.refreshButton:Enable() else self.refreshButton:Disable() end + end + + if self.newButton then + if hasBot and not isBusy then self.newButton:Enable() else self.newButton:Disable() end + end + + if self.pinButton then + if hasSelection and not isBusy then self.pinButton:Enable() else self.pinButton:Disable() end + end + + local actionButtons = { self.equipButton, self.replaceButton, self.updateButton, self.resetButton } + for _, button in ipairs(actionButtons) do + if button then + if hasSelection and not isBusy then button:Enable() else button:Disable() end + end + end +end + +function OutfitUI:SelectBestEntry() + local desired = getLastSelected(self.botName or "") or getLastUsed(self.botName or "") + if desired and self:FindEntry(desired) then + self.selectedName = desired + return + end + + self.selectedName = self.entries[1] and self.entries[1].name or nil +end + +function OutfitUI:FinishList(botName) + if botName and botName ~= "" then + self.botName = botName + if self.frame and self.frame.setBotName then + self.frame:setBotName(botName) + end + end + + self.pendingBot = nil + sortEntriesForBot(self.botName or "", self.entries) + self:SelectBestEntry() + + for _, entry in ipairs(self.entries) do + prewarmItemCache(entry.items) + end + + self:RenderEntryList() + self:RenderSelectedOutfit() + + if #self.entries == 0 then + self:SetStatus(outfitL("no_outfits")) + else + self:SetStatus(outfitL("loaded")) + end +end + +function OutfitUI:RequestList(botName) + if not botName or botName == "" then + return false + end + + if self:IsCommandBusy(botName) then + self.pendingRefresh = true + self:SetStatus(outfitL("busy_wait")) + return false + end + + self.listButtons = self.listButtons or {} + self.itemButtons = self.itemButtons or {} + + self.botName = botName + if self.frame and self.frame.setBotName then + self.frame:setBotName(botName) + end + + self.entries = {} + self.selectedName = nil + self.pendingBot = botName + self:RenderEntryList() + self:RenderSelectedOutfit() + self:SetStatus(outfitL("loading")) + + local waitButton = getUnitWaitButton(botName) + if waitButton then + waitButton.waitFor = "OUTFITS" + end + + self.requestToken = (self.requestToken or 0) + 1 + local token = self.requestToken + if MultiBot.TimerAfter then + MultiBot.TimerAfter(0.8, function() + if self.pendingBot == botName and token == self.requestToken then + self:FinishList(botName) + local refreshWaitButton = getUnitWaitButton(botName) + if refreshWaitButton and refreshWaitButton.waitFor == "OUTFITS" then + refreshWaitButton.waitFor = "" + end + end + end) + end + + SendChatMessage("outfit ?", "WHISPER", nil, botName) + return true +end + +function MultiBot.HandleOutfitChatLine(tButton, line, botName) + if not OutfitUI.pendingBot or OutfitUI.pendingBot ~= botName then + return false + end + + if type(line) ~= "string" then + return false + end + + local lowerLine = string.lower(line) + if string.find(lowerLine, "outfit ", 1, true) then + OutfitUI:FinishList(botName) + if tButton then + tButton.waitFor = "" + end + return true + end + + local entry = parseOutfitLine(line) + if entry then + table.insert(OutfitUI.entries, entry) + return true + end + + if line == "" or string.sub(line, 1, 3) == "---" then + return true + end + + return false +end + +function OutfitUI:RunCommand(commandSuffix, statusText, refreshDelay, persistDelay) + local botName = self.botName or (self.frame and self.frame.name) or nil + if not botName or botName == "" then + return false + end + + local isEquipCmd = false + if type(commandSuffix) == "string" and string.match(commandSuffix, "%s+equip%s*$") then + isEquipCmd = true + end + + if isEquipCmd then + local twoHand = botHasTwoHandEquipped(botName) + if twoHand == true or twoHand == nil then + commandSuffix = string.gsub(commandSuffix, "%s*equip%s*$", " replace") + statusText = outfitL("equip_auto_replace") + if not refreshDelay or refreshDelay <= 0 then + refreshDelay = OUTFIT_REPLACE_REFRESH_DELAY + end + -- print("OutfitUI DEBUG: forced replace, commandSuffix='" .. tostring(commandSuffix) .. "'") + end + end + + if type(commandSuffix) == "string" then + commandSuffix = string.gsub(commandSuffix, "%s+", " ") + commandSuffix = trim(commandSuffix) + end + + -- print("OutfitUI DEBUG: sending -> 'outfit " .. tostring(commandSuffix) .. "' to " .. tostring(botName)) + SendChatMessage("outfit " .. commandSuffix, "WHISPER", nil, botName) + self:SetStatus(statusText) + + if self:IsCommandBusy(botName) then + self.pendingRefresh = true + self:SetStatus(outfitL("busy_wait")) + return false + end + + local commandToken = nil + if type(refreshDelay) == "number" and refreshDelay > 0 then + commandToken = self:BeginCommandLock(botName) + end + + if type(persistDelay) == "number" and persistDelay >= 0 then + local flushBotName = botName + if MultiBot.TimerAfter then + MultiBot.TimerAfter(persistDelay, function() + SendChatMessage("nc +chat", "WHISPER", nil, flushBotName) + end) + else + SendChatMessage("nc +chat", "WHISPER", nil, flushBotName) + end + end + + if type(refreshDelay) == "number" and refreshDelay > 0 and MultiBot.TimerAfter then + MultiBot.TimerAfter(refreshDelay, function() + if commandToken then + self:EndCommandLock(botName, commandToken, true) + elseif MultiBot.inventory and MultiBot.inventory:IsVisible() then + self:RequestList(botName) + end + end) + elseif commandToken then + self:EndCommandLock(botName, commandToken, true) + end + + return true +end + +function OutfitUI:CreateFromCurrent() + if type(MultiBot.ShowPrompt) ~= "function" then + UIErrorsFrame:AddMessage(outfitL("prompt_missing"), 1, 0.2, 0.2, 1) + return + end + + local anchorFrame = nil + if MultiBot.outfits and MultiBot.outfits.window and MultiBot.outfits.window.frame then + anchorFrame = MultiBot.outfits.window.frame + end + + MultiBot.ShowPrompt(outfitL("new_title"), function(value) + local outfitName = trim(value) + if outfitName == "" then + return + end + + self.selectedName = outfitName + setLastSelected(self.botName or "", outfitName) + self:RunCommand(outfitName .. " update", outfitL("created"), 0.35, OUTFIT_PERSIST_FLUSH_DELAY) + end, "", anchorFrame) +end + +function OutfitUI:PinSelected() + local selected = self:GetSelectedEntry() + if not selected then + return + end + + local enabled = toggleFavorite(self.botName or "", selected.name) + if enabled then + self:SetStatus(outfitL("pinned")) + else + self:SetStatus(outfitL("unpinned")) + end + sortEntriesForBot(self.botName or "", self.entries) + self:RenderEntryList() + self:RenderSelectedOutfit() +end + +function OutfitUI:EquipSelected(replaceCurrent) + local selected = self:GetSelectedEntry() + if not selected then + return + end + + setLastSelected(self.botName or "", selected.name) + setLastUsed(self.botName or "", selected.name) + + local forceReplace = false + if not replaceCurrent then + forceReplace = shouldForceReplaceForWeaponSwap(selected) + end + + if replaceCurrent or forceReplace then + if forceReplace then + self:SetStatus(outfitL("equip_auto_replace")) + end + + self:RunCommand(selected.name .. " replace", outfitL("replace_sent"), OUTFIT_REPLACE_REFRESH_DELAY) + else + self:RunCommand(selected.name .. " equip", outfitL("equip_sent"), OUTFIT_EQUIP_REFRESH_DELAY) + end +end + +function OutfitUI:UpdateSelected() + local selected = self:GetSelectedEntry() + if not selected then + return + end + + setLastSelected(self.botName or "", selected.name) + self:RunCommand(selected.name .. " update", outfitL("updated"), OUTFIT_UPDATE_REFRESH_DELAY) +end + +function OutfitUI:ResetSelected() + local selected = self:GetSelectedEntry() + if not selected then + return + end + + self:RunCommand(selected.name .. " reset", outfitL("reset_sent"), OUTFIT_RESET_REFRESH_DELAY) +end + +function MultiBot.InitializeOutfitFrame() + if MultiBot.outfits and MultiBot.outfits.__aceInitialized then + return MultiBot.outfits + end + + local aceGUI = MultiBot.ResolveAceGUI and MultiBot.ResolveAceGUI(outfitL("acegui_required")) or nil + if not aceGUI then + return nil + end + + local window = aceGUI:Create("Window") + window:SetTitle(getWindowTitle(nil)) + window:SetLayout("Manual") + window:SetWidth(OUTFIT_WINDOW_WIDTH) + window:SetHeight(OUTFIT_WINDOW_HEIGHT) + window:EnableResize(false) + window.frame:SetClampedToScreen(true) + window.frame:SetMovable(true) + window.frame:EnableMouse(true) + + local strataLevel = MultiBot.GetGlobalStrataLevel and MultiBot.GetGlobalStrataLevel() + if strataLevel then + window.frame:SetFrameStrata(strataLevel) + end + + if MultiBot.SetAceWindowCloseToHide then MultiBot.SetAceWindowCloseToHide(window) end + if MultiBot.RegisterAceWindowEscapeClose then MultiBot.RegisterAceWindowEscapeClose(window, "BotOutfits") end + if MultiBot.BindAceWindowPosition then MultiBot.BindAceWindowPosition(window, "bot_outfits_popup") end + + window.frame:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -754, 238) + window:Hide() + + window.frame:HookScript("OnShow", function() + syncOutfitButtonState(true) + MultiBot.SyncToolWindowButtons(MultiBot.outfits and MultiBot.outfits.name or nil, "Outfits") + end) + + window.frame:HookScript("OnHide", function() + syncOutfitButtonState(false) + closeInspectForBot(MultiBot.outfits and MultiBot.outfits.name or nil) + MultiBot.SyncToolWindowButtons(nil, nil) + end) + + local content = window.content + content:SetPoint("TOPLEFT", window.frame, "TOPLEFT", 12, -30) + content:SetPoint("BOTTOMRIGHT", window.frame, "BOTTOMRIGHT", -12, 12) + + local root = CreateFrame("Frame", nil, content) + root:SetAllPoints(content) + + local leftPanel = CreateFrame("Frame", nil, root) + leftPanel:SetPoint("TOPLEFT", root, "TOPLEFT", OUTFIT_PANEL_INSET, -OUTFIT_PANEL_INSET) + leftPanel:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", OUTFIT_PANEL_INSET, OUTFIT_PANEL_INSET + OUTFIT_STATUS_HEIGHT) + leftPanel:SetWidth(OUTFIT_LIST_WIDTH) + addBackdrop(leftPanel) + + local rightPanel = CreateFrame("Frame", nil, root) + rightPanel:SetPoint("TOPLEFT", leftPanel, "TOPRIGHT", OUTFIT_PANEL_GAP, 0) + rightPanel:SetPoint("TOPRIGHT", root, "TOPRIGHT", -OUTFIT_PANEL_INSET, -OUTFIT_PANEL_INSET) + rightPanel:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -OUTFIT_PANEL_INSET, OUTFIT_PANEL_INSET + OUTFIT_STATUS_HEIGHT) + addBackdrop(rightPanel) + + local statusText = root:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + statusText:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", OUTFIT_PANEL_INSET + 2, OUTFIT_PANEL_INSET + 12) + statusText:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -OUTFIT_PANEL_INSET - 2, OUTFIT_PANEL_INSET + 12) + statusText:SetJustifyH("LEFT") + statusText:SetText(outfitL("idle")) + + local hintText = root:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + hintText:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", OUTFIT_PANEL_INSET + 2, OUTFIT_PANEL_INSET) + hintText:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -OUTFIT_PANEL_INSET - 2, OUTFIT_PANEL_INSET) + hintText:SetJustifyH("LEFT") + hintText:SetText(outfitL("replace_warning")) + + local leftTitle = leftPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + leftTitle:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", 8, -8) + leftTitle:SetText(outfitL("list")) + + local listScroll = CreateFrame("ScrollFrame", OUTFIT_LIST_SCROLL_NAME, leftPanel, "UIPanelScrollFrameTemplate") + listScroll:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", 8, -26) + listScroll:SetPoint("BOTTOMRIGHT", leftPanel, "BOTTOMRIGHT", -26, OUTFIT_LEFT_BUTTONS_AREA_HEIGHT) + + local listChild = CreateFrame("Frame", "MultiBotOutfitListScrollChild", listScroll) + listChild:SetWidth(OUTFIT_LIST_WIDTH - 34) + listChild:SetHeight(OUTFIT_LIST_BUTTON_HEIGHT) + listScroll:SetScrollChild(listChild) + + local refreshButton = createActionButton(leftPanel, 0, outfitL("refresh"), "BOTTOMLEFT", leftPanel, "BOTTOMLEFT", 8, 8, function() + if OutfitUI.botName then + OutfitUI:RequestList(OutfitUI.botName) + end + end) + + local newButton = createActionButton(leftPanel, 0, outfitL("new"), "LEFT", refreshButton, "RIGHT", OUTFIT_BUTTON_ROW_GAP, 0, function() + OutfitUI:CreateFromCurrent() + end) + + local pinButton = createActionButton(leftPanel, 0, outfitL("pin"), "BOTTOM", leftPanel, "BOTTOM", 0, 8, function() + OutfitUI:PinSelected() + end) + + do + local topRowWidth = refreshButton:GetWidth() + OUTFIT_BUTTON_ROW_GAP + newButton:GetWidth() + local topRowOffsetX = math.floor((OUTFIT_LIST_WIDTH - topRowWidth) / 2) + + refreshButton:ClearAllPoints() + refreshButton:SetPoint("BOTTOMLEFT", leftPanel, "BOTTOMLEFT", topRowOffsetX, 8 + OUTFIT_BUTTON_HEIGHT + 4) + + newButton:ClearAllPoints() + newButton:SetPoint("LEFT", refreshButton, "RIGHT", OUTFIT_BUTTON_ROW_GAP, 0) + end + + local rightButtonsRow = CreateFrame("Frame", nil, rightPanel) + + local selectedNameText = rightPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal") + selectedNameText:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 10, -10) + selectedNameText:SetPoint("TOPRIGHT", rightPanel, "TOPRIGHT", -10, -10) + selectedNameText:SetJustifyH("LEFT") + selectedNameText:SetText(outfitL("none_selected")) + + local selectedFavoriteIcon = rightPanel:CreateTexture(nil, "OVERLAY") + selectedFavoriteIcon:SetTexture(OUTFIT_FAVORITE_ICON_TEXTURE) + selectedFavoriteIcon:SetWidth(OUTFIT_FAVORITE_ICON_SIZE) + selectedFavoriteIcon:SetHeight(OUTFIT_FAVORITE_ICON_SIZE) + selectedFavoriteIcon:SetPoint("LEFT", rightPanel, "TOPLEFT", 10, -18) + selectedFavoriteIcon:Hide() + + selectedNameText:ClearAllPoints() + selectedNameText:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 10 + OUTFIT_FAVORITE_ICON_SIZE + OUTFIT_FAVORITE_ICON_GAP, -10) + selectedNameText:SetPoint("TOPRIGHT", rightPanel, "TOPRIGHT", -10, -10) + + rightButtonsRow:SetPoint("TOP", selectedNameText, "BOTTOM", 0, -10) + + local equipButton = createActionButton(rightButtonsRow, 0, outfitL("equip"), "LEFT", rightButtonsRow, "LEFT", 0, 0, function() + OutfitUI:EquipSelected(false) + end) + equipButton:RegisterForClicks("LeftButtonUp", "RightButtonUp") + equipButton:SetScript("OnClick", function(_, mouseButton) + OutfitUI:EquipSelected(mouseButton == "RightButton") + end) + equipButton:SetScript("OnEnter", function(self) + if not prepareTooltipAboveOutfits(self, "ANCHOR_RIGHT") then + return + end + + GameTooltip:SetText(outfitTip("equip"), 1, 1, 1, true) + GameTooltip:Show() + end) + equipButton:SetScript("OnLeave", function() + if GameTooltip and GameTooltip.Hide then GameTooltip:Hide() end + end) + + local replaceButton = createActionButton(rightButtonsRow, 0, outfitL("replace"), "LEFT", equipButton, "RIGHT", OUTFIT_BUTTON_ROW_GAP, 0, function() + OutfitUI:EquipSelected(true) + end) + + local updateButton = createActionButton(rightButtonsRow, 0, outfitL("update"), "LEFT", replaceButton, "RIGHT", OUTFIT_BUTTON_ROW_GAP, 0, function() + OutfitUI:UpdateSelected() + end) + + local resetButton = createActionButton(rightButtonsRow, 0, outfitL("reset"), "LEFT", updateButton, "RIGHT", OUTFIT_BUTTON_ROW_GAP, 0, function() + OutfitUI:ResetSelected() + end) + + layoutButtonRowCentered(rightButtonsRow, { equipButton, replaceButton, updateButton, resetButton }, OUTFIT_BUTTON_ROW_GAP) + + local itemsScroll = CreateFrame("ScrollFrame", OUTFIT_ITEMS_SCROLL_NAME, rightPanel, "UIPanelScrollFrameTemplate") + itemsScroll:SetPoint("TOPLEFT", equipButton, "BOTTOMLEFT", 0, -12) + itemsScroll:SetPoint("BOTTOMRIGHT", rightPanel, "BOTTOMRIGHT", -26, 10) + + local itemsChild = CreateFrame("Frame", "MultiBotOutfitItemsScrollChild", itemsScroll) + itemsChild:SetWidth(240) + itemsChild:SetHeight(OUTFIT_ITEM_SPACING_Y) + itemsScroll:SetScrollChild(itemsChild) + + local emptyItemsText = itemsChild:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + emptyItemsText:SetPoint("TOPLEFT", itemsChild, "TOPLEFT", 2, -4) + emptyItemsText:SetPoint("TOPRIGHT", itemsChild, "TOPRIGHT", -2, -4) + emptyItemsText:SetJustifyH("LEFT") + emptyItemsText:SetText(outfitL("select_left")) + + local outfits = { + __aceInitialized = true, + window = window, + root = root, + name = "", + } + + function outfits:Show() + self.window:Show() + end + + function outfits:Hide() + self.window:Hide() + end + + function outfits:IsVisible() + return self.window and self.window.frame and self.window.frame:IsShown() or false + end + + function outfits:GetRight() + return self.window and self.window.frame and self.window.frame:GetRight() or 0 + end + + function outfits:GetBottom() + return self.window and self.window.frame and self.window.frame:GetBottom() or 0 + end + + function outfits:setBotName(botName) + self.name = botName or "" + if self.window and self.window.SetTitle then + self.window:SetTitle(getWindowTitle(self.name)) + end + return self + end + + MultiBot.outfits = outfits + OutfitUI.frame = outfits + OutfitUI.botName = nil + OutfitUI.statusText = statusText + OutfitUI.hintText = hintText + OutfitUI.listChild = listChild + OutfitUI.listButtons = OutfitUI.listButtons or {} + OutfitUI.itemButtons = OutfitUI.itemButtons or {} + OutfitUI.itemsChild = itemsChild + OutfitUI.selectedNameText = selectedNameText + OutfitUI.emptyItemsText = emptyItemsText + OutfitUI.selectedFavoriteIcon = selectedFavoriteIcon + OutfitUI.refreshButton = refreshButton + OutfitUI.newButton = newButton + OutfitUI.pinButton = pinButton + OutfitUI.equipButton = equipButton + OutfitUI.replaceButton = replaceButton + OutfitUI.updateButton = updateButton + OutfitUI.resetButton = resetButton + + OutfitUI:RenderEntryList() + OutfitUI:RenderSelectedOutfit() + return outfits +end + +function MultiBot.OpenBotOutfits(botName, sourceButton) + if not botName or botName == "" then + return false + end + + local outfits = MultiBot.outfits + if (not outfits or not outfits.__aceInitialized) and MultiBot.InitializeOutfitFrame then + outfits = MultiBot.InitializeOutfitFrame() + end + if not outfits then + return false + end + + if outfits:IsVisible() and outfits.name == botName then + closeInspectForBot(botName) + outfits:Hide() + return true + end + + openInspectForBot(botName) + outfits:setBotName(botName) + outfits:Show() + placeOutfitsToRightOfInspect() + + if sourceButton and sourceButton.setEnable then + sourceButton.setEnable() + end + + OutfitUI:RequestList(botName) + return true +end \ No newline at end of file diff --git a/UI/MultiBotPromptDialog.lua b/UI/MultiBotPromptDialog.lua index 448353d..498fd0a 100644 --- a/UI/MultiBotPromptDialog.lua +++ b/UI/MultiBotPromptDialog.lua @@ -5,8 +5,39 @@ local PROMPT local PROMPT_WINDOW_WIDTH = 280 local PROMPT_WINDOW_HEIGHT = 108 local PROMPT_OK_BUTTON_WIDTH = 100 +local PROMPT_ANCHOR_GAP = 12 -function ShowPrompt(title, onOk, defaultText) +local function PositionPromptBesideFrame(anchorFrame) + if not PROMPT or not PROMPT.window or not PROMPT.window.frame or not anchorFrame then + return + end + + local promptFrame = PROMPT.window.frame + if not promptFrame.ClearAllPoints or not promptFrame.SetPoint then + return + end + + promptFrame:ClearAllPoints() + + local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 0 + local anchorRight = anchorFrame.GetRight and anchorFrame:GetRight() or nil + local promptWidth = promptFrame.GetWidth and promptFrame:GetWidth() or PROMPT_WINDOW_WIDTH + + local placeLeft = false + if parentWidth and parentWidth > 0 and anchorRight then + if (anchorRight + PROMPT_ANCHOR_GAP + promptWidth) > (parentWidth - 8) then + placeLeft = true + end + end + + if placeLeft then + promptFrame:SetPoint("TOPRIGHT", anchorFrame, "TOPLEFT", -PROMPT_ANCHOR_GAP, 0) + else + promptFrame:SetPoint("TOPLEFT", anchorFrame, "TOPRIGHT", PROMPT_ANCHOR_GAP, 0) + end +end + +function ShowPrompt(title, onOk, defaultText, anchorFrame) local aceGUI = MultiBot.ResolveAceGUI and MultiBot.ResolveAceGUI("AceGUI-3.0 is required for MBUniversalPrompt") or nil if not aceGUI then return @@ -30,6 +61,7 @@ function ShowPrompt(title, onOk, defaultText) if MultiBot.SetAceWindowCloseToHide then MultiBot.SetAceWindowCloseToHide(window) end if MultiBot.RegisterAceWindowEscapeClose then MultiBot.RegisterAceWindowEscapeClose(window, "UniversalPrompt") end if MultiBot.BindAceWindowPosition then MultiBot.BindAceWindowPosition(window, "universal_prompt") end + window.frame:SetClampedToScreen(true) local edit = aceGUI:Create("EditBox") edit:SetLabel("") @@ -54,6 +86,10 @@ function ShowPrompt(title, onOk, defaultText) PROMPT.window:SetTitle(title or "Enter Value") PROMPT.window:Show() + + if anchorFrame then + PositionPromptBesideFrame(anchorFrame) + end PROMPT.edit:SetText(defaultText or "") local editBox = PROMPT.edit and PROMPT.edit.editbox diff --git a/UI/MultiBotQuestAllFrame.lua b/UI/MultiBotQuestAllFrame.lua index 16652af..1688d6e 100644 --- a/UI/MultiBotQuestAllFrame.lua +++ b/UI/MultiBotQuestAllFrame.lua @@ -1,12 +1,40 @@ if not MultiBot then return end -local Shared = MultiBot.QuestUIShared or {} -local QuestAllFrame = MultiBot.QuestAllFrame or {} +local EMPTY_TABLE = {} +local Shared = MultiBot.QuestUIShared +if type(Shared) ~= "table" then + Shared = {} +end + +local QuestAllFrame = MultiBot.QuestAllFrame +if type(QuestAllFrame) ~= "table" then + QuestAllFrame = {} +end MultiBot.QuestAllFrame = QuestAllFrame -MultiBot.BotQuestsAll = MultiBot.BotQuestsAll or {} -MultiBot.BotQuestsCompleted = MultiBot.BotQuestsCompleted or {} -MultiBot.BotQuestsIncompleted = MultiBot.BotQuestsIncompleted or {} +local function getBotQuestsAllStore() + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsAll")) or MultiBot.BotQuestsAll + if not store and MultiBot.Store and MultiBot.Store.RecordReadMiss then + MultiBot.Store.RecordReadMiss("QuestAll", "BotQuestsAll") + end + return store or EMPTY_TABLE +end + +local function getBotQuestsCompletedStore() + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsCompleted")) or MultiBot.BotQuestsCompleted + if not store and MultiBot.Store and MultiBot.Store.RecordReadMiss then + MultiBot.Store.RecordReadMiss("QuestAll", "BotQuestsCompleted") + end + return store or EMPTY_TABLE +end + +local function getBotQuestsIncompletedStore() + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsIncompleted")) or MultiBot.BotQuestsIncompleted + if not store and MultiBot.Store and MultiBot.Store.RecordReadMiss then + MultiBot.Store.RecordReadMiss("QuestAll", "BotQuestsIncompleted") + end + return store or EMPTY_TABLE +end local function clearList(self) if self.scroll then @@ -83,7 +111,8 @@ function MultiBot.BuildBotAllList(botName) local frame = MultiBot.InitializeQuestAllFrame() clearList(frame) - local quests = MultiBot.BotQuestsAll[botName] or {} + local questsStore = getBotQuestsAllStore() + local quests = (questsStore and questsStore[botName]) or EMPTY_TABLE for _, link in ipairs(quests) do local questID = tonumber(link:match("|Hquest:(%d+):")) local localizedName = questID and Shared.GetLocalizedQuestName(questID, link) or link @@ -105,8 +134,8 @@ function MultiBot.BuildAggregatedAllList() local frame = MultiBot.InitializeQuestAllFrame() clearList(frame) - local completeEntries = Shared.BuildAggregatedQuestEntries(MultiBot.BotQuestsCompleted) - local incompleteEntries = Shared.BuildAggregatedQuestEntries(MultiBot.BotQuestsIncompleted) + local completeEntries = Shared.BuildAggregatedQuestEntries(getBotQuestsCompletedStore()) + local incompleteEntries = Shared.BuildAggregatedQuestEntries(getBotQuestsIncompletedStore()) createSectionHeader(frame, MultiBot.L("tips.quests.compheader")) if #completeEntries == 0 then @@ -193,9 +222,15 @@ function MultiBot.InitializeQuestAllFrame() content:AddChild(scroll) window.frame:HookScript("OnHide", function() - MultiBot.BotQuestsAll = {} - MultiBot.BotQuestsCompleted = {} - MultiBot.BotQuestsIncompleted = {} + local allStore = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsAll")) or MultiBot.BotQuestsAll + local completedStore = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsCompleted")) or MultiBot.BotQuestsCompleted + local incompletedStore = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsIncompleted")) or MultiBot.BotQuestsIncompleted + + if MultiBot.Store and MultiBot.Store.ClearTable then + MultiBot.Store.ClearTable(allStore) + MultiBot.Store.ClearTable(completedStore) + MultiBot.Store.ClearTable(incompletedStore) + end clearList(QuestAllFrame) end) diff --git a/UI/MultiBotQuestCompletedFrame.lua b/UI/MultiBotQuestCompletedFrame.lua index 419bbe3..a867274 100644 --- a/UI/MultiBotQuestCompletedFrame.lua +++ b/UI/MultiBotQuestCompletedFrame.lua @@ -1,10 +1,24 @@ if not MultiBot then return end -local Shared = MultiBot.QuestUIShared or {} -local QuestCompletedFrame = MultiBot.QuestCompletedFrame or {} +local EMPTY_TABLE = {} +local Shared = MultiBot.QuestUIShared +if type(Shared) ~= "table" then + Shared = {} +end + +local QuestCompletedFrame = MultiBot.QuestCompletedFrame +if type(QuestCompletedFrame) ~= "table" then + QuestCompletedFrame = {} +end MultiBot.QuestCompletedFrame = QuestCompletedFrame -MultiBot.BotQuestsCompleted = MultiBot.BotQuestsCompleted or {} +local function getBotQuestsCompletedStore() + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsCompleted")) or MultiBot.BotQuestsCompleted + if not store and MultiBot.Store and MultiBot.Store.RecordReadMiss then + MultiBot.Store.RecordReadMiss("QuestCompleted", "BotQuestsCompleted") + end + return store or EMPTY_TABLE +end local function clearList(self) if self.scroll then @@ -12,9 +26,38 @@ local function clearList(self) end end +local function normalizeBotName(botName) + if type(botName) ~= "string" then + return nil + end + return botName:gsub("%-.+$", ""):lower() +end + +local function resolveBotQuestBucket(store, botName) + if type(store) ~= "table" or type(botName) ~= "string" then + return EMPTY_TABLE + end + + if type(store[botName]) == "table" then + return store[botName] + end + + local normalizedTarget = normalizeBotName(botName) + for storedBotName, quests in pairs(store) do + if type(storedBotName) == "string" and type(quests) == "table" then + if storedBotName:lower() == botName:lower() or normalizeBotName(storedBotName) == normalizedTarget then + return quests + end + end + end + + return EMPTY_TABLE +end + function MultiBot.BuildBotCompletedList(botName) local frame = MultiBot.InitializeQuestCompletedFrame() - local entries = Shared.SortQuestEntries(MultiBot.BotQuestsCompleted[botName] or {}) + local completedStore = getBotQuestsCompletedStore() + local entries = Shared.SortQuestEntries(resolveBotQuestBucket(completedStore, botName)) frame:Show() Shared.RenderQuestEntries(frame, entries, { @@ -24,7 +67,7 @@ end function MultiBot.BuildAggregatedCompletedList() local frame = MultiBot.InitializeQuestCompletedFrame() - local entries = Shared.BuildAggregatedQuestEntries(MultiBot.BotQuestsCompleted) + local entries = Shared.BuildAggregatedQuestEntries(getBotQuestsCompletedStore()) frame:Show() Shared.RenderQuestEntries(frame, entries, { @@ -81,7 +124,10 @@ function MultiBot.InitializeQuestCompletedFrame() content:AddChild(scroll) window.frame:HookScript("OnHide", function() - MultiBot.BotQuestsCompleted = {} + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsCompleted")) or MultiBot.BotQuestsCompleted + if MultiBot.Store and MultiBot.Store.ClearTable then + MultiBot.Store.ClearTable(store) + end clearList(QuestCompletedFrame) end) diff --git a/UI/MultiBotQuestIncompleteFrame.lua b/UI/MultiBotQuestIncompleteFrame.lua index e91fa89..5238b02 100644 --- a/UI/MultiBotQuestIncompleteFrame.lua +++ b/UI/MultiBotQuestIncompleteFrame.lua @@ -1,10 +1,24 @@ if not MultiBot then return end -local Shared = MultiBot.QuestUIShared or {} -local QuestIncompleteFrame = MultiBot.QuestIncompleteFrame or {} +local EMPTY_TABLE = {} +local Shared = MultiBot.QuestUIShared +if type(Shared) ~= "table" then + Shared = {} +end + +local QuestIncompleteFrame = MultiBot.QuestIncompleteFrame +if type(QuestIncompleteFrame) ~= "table" then + QuestIncompleteFrame = {} +end MultiBot.QuestIncompleteFrame = QuestIncompleteFrame -MultiBot.BotQuestsIncompleted = MultiBot.BotQuestsIncompleted or {} +local function getBotQuestsIncompletedStore() + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsIncompleted")) or MultiBot.BotQuestsIncompleted + if not store and MultiBot.Store and MultiBot.Store.RecordReadMiss then + MultiBot.Store.RecordReadMiss("QuestIncomplete", "BotQuestsIncompleted") + end + return store or EMPTY_TABLE +end local function clearList(self) if self.scroll then @@ -12,9 +26,41 @@ local function clearList(self) end end +local function normalizeBotName(botName) + if type(botName) ~= "string" then + return nil + end + return botName:gsub("%-.+$", ""):lower() +end + +local function resolveBotQuestBucket(store, botName) + if type(store) ~= "table" or type(botName) ~= "string" then + return EMPTY_TABLE + end + + if type(store[botName]) == "table" then + return store[botName] + end + + local normalizedTarget = normalizeBotName(botName) + for storedBotName, quests in pairs(store) do + if type(storedBotName) == "string" and type(quests) == "table" then + if storedBotName:lower() == botName:lower() or normalizeBotName(storedBotName) == normalizedTarget then + return quests + end + end + end + + return EMPTY_TABLE +end + function MultiBot.BuildBotQuestList(botName) - local frame = MultiBot.InitializeQuestIncompleteFrame() - local entries = Shared.SortQuestEntries(MultiBot.BotQuestsIncompleted[botName] or {}) + local frame = MultiBot.InitializeQuestIncompleteFrame and MultiBot.InitializeQuestIncompleteFrame() + if not frame then + return + end + local incompletedStore = getBotQuestsIncompletedStore() + local entries = Shared.SortQuestEntries(resolveBotQuestBucket(incompletedStore, botName)) frame:Show() Shared.RenderQuestEntries(frame, entries, { @@ -24,7 +70,7 @@ end function MultiBot.BuildAggregatedQuestList() local frame = MultiBot.InitializeQuestIncompleteFrame() - local entries = Shared.BuildAggregatedQuestEntries(MultiBot.BotQuestsIncompleted) + local entries = Shared.BuildAggregatedQuestEntries(getBotQuestsIncompletedStore()) frame:Show() Shared.RenderQuestEntries(frame, entries, { @@ -81,7 +127,10 @@ function MultiBot.InitializeQuestIncompleteFrame() content:AddChild(scroll) window.frame:HookScript("OnHide", function() - MultiBot.BotQuestsIncompleted = {} + local store = (MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("BotQuestsIncompleted")) or MultiBot.BotQuestsIncompleted + if MultiBot.Store and MultiBot.Store.ClearTable then + MultiBot.Store.ClearTable(store) + end clearList(QuestIncompleteFrame) end) diff --git a/UI/MultiBotQuestsMenu.lua b/UI/MultiBotQuestsMenu.lua index 77a45d2..d618d77 100644 --- a/UI/MultiBotQuestsMenu.lua +++ b/UI/MultiBotQuestsMenu.lua @@ -3,6 +3,44 @@ if not MultiBot then return end local QuestsMenu = MultiBot.QuestsMenu or {} MultiBot.QuestsMenu = QuestsMenu +local function ensureRuntimeTable(key) + if MultiBot.Store and MultiBot.Store.EnsureRuntimeTable then + return MultiBot.Store.EnsureRuntimeTable(key) + end + MultiBot[key] = type(MultiBot[key]) == "table" and MultiBot[key] or {} + return MultiBot[key] +end + +local function setRuntimeFlag(key, value) + if MultiBot.Store and MultiBot.Store.SetRuntimeValue then + MultiBot.Store.SetRuntimeValue(key, value) + return + end + MultiBot[key] = value +end + +local function clearTableInPlace(tbl) + if type(tbl) ~= "table" then + return + end + if MultiBot.Store and MultiBot.Store.ClearTable then + MultiBot.Store.ClearTable(tbl) + return + end + for key in pairs(tbl) do + tbl[key] = nil + end +end + +local function getTargetBotOrError() + local botName = UnitName("target") + if botName and UnitIsPlayer("target") then + return botName + end + UIErrorsFrame:AddMessage(MultiBot.L("tips.quests.questcomperror"), 1, 0.2, 0.2, 1) + return nil +end + local function setSubButtonsVisible(buttonA, buttonB, visible) if visible then buttonA:doShow() @@ -63,7 +101,7 @@ local function registerExpandableGroup(rootButton, buttonA, buttonB) end local function sendIncomplete(method) - MultiBot._awaitingQuestsAll = false + setRuntimeFlag("_awaitingQuestsAll", false) MultiBot._lastIncMode = method local frame = MultiBot.InitializeQuestIncompleteFrame and MultiBot.InitializeQuestIncompleteFrame() @@ -72,13 +110,14 @@ local function sendIncomplete(method) end if method == "WHISPER" then - local bot = UnitName("target") - if not bot or not UnitIsPlayer("target") then - UIErrorsFrame:AddMessage(MultiBot.L("tips.quests.questcomperror"), 1, 0.2, 0.2, 1) + local bot = getTargetBotOrError() + if not bot then return end - MultiBot.BotQuestsIncompleted[bot] = {} + MultiBot._lastIncWhisperBot = bot + ensureRuntimeTable("_awaitingQuestsIncompleted")[bot] = true + ensureRuntimeTable("BotQuestsIncompleted")[bot] = {} MultiBot.ActionToTarget("quests incompleted", bot) frame:Show() MultiBot.TimerAfter(0.5, function() @@ -89,13 +128,13 @@ local function sendIncomplete(method) return end - MultiBot.BotQuestsIncompleted = {} + clearTableInPlace(ensureRuntimeTable("BotQuestsIncompleted")) MultiBot.ActionToGroup("quests incompleted") frame:Show() end local function sendCompleted(method) - MultiBot._awaitingQuestsAll = false + setRuntimeFlag("_awaitingQuestsAll", false) MultiBot._lastCompMode = method local frame = MultiBot.InitializeQuestCompletedFrame and MultiBot.InitializeQuestCompletedFrame() @@ -104,13 +143,14 @@ local function sendCompleted(method) end if method == "WHISPER" then - local bot = UnitName("target") - if not bot or not UnitIsPlayer("target") then - UIErrorsFrame:AddMessage(MultiBot.L("tips.quests.questcomperror"), 1, 0.2, 0.2, 1) + local bot = getTargetBotOrError() + if not bot then return end - MultiBot.BotQuestsCompleted[bot] = {} + MultiBot._lastCompWhisperBot = bot + ensureRuntimeTable("_awaitingQuestsCompleted")[bot] = true + ensureRuntimeTable("BotQuestsCompleted")[bot] = {} MultiBot.ActionToTarget("quests completed", bot) frame:Show() MultiBot.TimerAfter(0.5, function() @@ -121,7 +161,7 @@ local function sendCompleted(method) return end - MultiBot.BotQuestsCompleted = {} + clearTableInPlace(ensureRuntimeTable("BotQuestsCompleted")) MultiBot.ActionToGroup("quests completed") frame:Show() end @@ -133,28 +173,28 @@ local function sendAll(method) end MultiBot._lastAllMode = method - MultiBot._awaitingQuestsAll = true - MultiBot._blockOtherQuests = true - MultiBot.BotQuestsAll = {} - MultiBot._awaitingQuestsAllBots = {} + setRuntimeFlag("_awaitingQuestsAll", true) + setRuntimeFlag("_blockOtherQuests", true) + clearTableInPlace(ensureRuntimeTable("BotQuestsAll")) + local awaitingBots = ensureRuntimeTable("_awaitingQuestsAllBots") + clearTableInPlace(awaitingBots) if method == "GROUP" then for index = 1, GetNumPartyMembers() do local botName = UnitName("party" .. index) if botName then - MultiBot._awaitingQuestsAllBots[botName] = false + awaitingBots[botName] = false end end MultiBot.ActionToGroup("quests all") else - local bot = UnitName("target") - if not bot or not UnitIsPlayer("target") then - UIErrorsFrame:AddMessage(MultiBot.L("tips.quests.questcomperror"), 1, 0.2, 0.2, 1) - MultiBot._awaitingQuestsAll = false - MultiBot._blockOtherQuests = false + local bot = getTargetBotOrError() + if not bot then + setRuntimeFlag("_awaitingQuestsAll", false) + setRuntimeFlag("_blockOtherQuests", false) return end - MultiBot._awaitingQuestsAllBots[bot] = false + awaitingBots[bot] = false MultiBot.ActionToTarget("quests all", bot) end diff --git a/UI/MultiBotShamanQuickFrame.lua b/UI/MultiBotShamanQuickFrame.lua index 801308a..fa7e4d4 100644 --- a/UI/MultiBotShamanQuickFrame.lua +++ b/UI/MultiBotShamanQuickFrame.lua @@ -10,7 +10,7 @@ local WINDOW_HEIGHT = ROW_HEIGHT local WINDOW_PADDING_X = 0 local WINDOW_PADDING_Y = 0 local WINDOW_TITLE = "Quick Shaman" -local WINDOW_DEFAULT_POINT = { point = "CENTER", relPoint = "CENTER", x = -420, y = 240 } +local WINDOW_DEFAULT_POINT = { point = "TOP", relPoint = "TOP", x = -3.360398722124494, y = -28.94176284319217 } local ICON_FALLBACK = "Interface\\Icons\\INV_Misc_QuestionMark" local HANDLE_WIDTH = 12 local HANDLE_HEIGHT = 18 diff --git a/UI/MultiBotSpecUI.lua b/UI/MultiBotSpecUI.lua index 95e01fb..72c0a6b 100644 --- a/UI/MultiBotSpecUI.lua +++ b/UI/MultiBotSpecUI.lua @@ -346,6 +346,13 @@ local function cleanupLegacySpecDropdownStoreIfEmpty() end local function getSpecDropdownStore() + if MultiBot.Store and MultiBot.Store.EnsureUIChildStore then + local store = MultiBot.Store.EnsureUIChildStore("specDropdownPositions") + if store then + return store + end + end + local profile = MultiBot.db and MultiBot.db.profile if profile then profile.ui = profile.ui or {} diff --git a/UI/MultiBotSpellBookFrame.lua b/UI/MultiBotSpellBookFrame.lua index 0857f40..e639824 100644 --- a/UI/MultiBotSpellBookFrame.lua +++ b/UI/MultiBotSpellBookFrame.lua @@ -61,7 +61,18 @@ local SPELLBOOK_UI_DEFAULTS = { TEXT_DRAW_SUBLEVEL = 5, } -MultiBot.SpellBookUISettings = MultiBot.SpellBookUISettings or {} +local function ensureSpellBookUIStore() + if MultiBot.Store and MultiBot.Store.EnsureRuntimeTable then + return MultiBot.Store.EnsureRuntimeTable("SpellBookUISettings") + end + + if type(MultiBot.SpellBookUISettings) ~= "table" then + MultiBot.SpellBookUISettings = {} + end + return MultiBot.SpellBookUISettings +end + +MultiBot.SpellBookUISettings = ensureSpellBookUIStore() for tKey, tValue in pairs(SPELLBOOK_UI_DEFAULTS) do if(MultiBot.SpellBookUISettings[tKey] == nil) then MultiBot.SpellBookUISettings[tKey] = tValue @@ -69,7 +80,16 @@ for tKey, tValue in pairs(SPELLBOOK_UI_DEFAULTS) do end local function getSpellBookUI() - return MultiBot.SpellBookUISettings or {} + local store = MultiBot.Store and MultiBot.Store.GetRuntimeTable and MultiBot.Store.GetRuntimeTable("SpellBookUISettings") + if type(store) == "table" then + return store + end + + if type(MultiBot.SpellBookUISettings) == "table" then + return MultiBot.SpellBookUISettings + end + + return SPELLBOOK_UI_DEFAULTS end local function getSpellBookDefaultPageLabel() diff --git a/UI/MultiBotTalentFrame.lua b/UI/MultiBotTalentFrame.lua index 9f2e269..873585b 100644 --- a/UI/MultiBotTalentFrame.lua +++ b/UI/MultiBotTalentFrame.lua @@ -60,16 +60,21 @@ local function bindTalentFramePosition(window, persistenceKey) return end - local profile = MultiBot.db and MultiBot.db.profile - if not profile then - return + local positions = nil + if MultiBot.Store and MultiBot.Store.GetUIChildStore then + positions = MultiBot.Store.GetUIChildStore("popupPositions") end - profile.ui = profile.ui or {} - profile.ui.popupPositions = profile.ui.popupPositions or {} + if not positions then + local profile = MultiBot.db and MultiBot.db.profile + if not profile then + return + end + + positions = profile.ui and profile.ui.popupPositions + end - local positions = profile.ui.popupPositions - local saved = positions[persistenceKey] + local saved = positions and positions[persistenceKey] if saved and saved.point then window.frame:ClearAllPoints() window.frame:SetPoint(saved.point, UIParent, saved.point, saved.x or 0, saved.y or 0) @@ -83,7 +88,20 @@ local function bindTalentFramePosition(window, persistenceKey) window.frame:HookScript("OnDragStop", function(frame) local point, _, _, x, y = frame:GetPoint(1) if point then - positions[persistenceKey] = { point = point, x = x or 0, y = y or 0 } + local writablePositions = nil + if MultiBot.Store and MultiBot.Store.EnsureUIChildStore then + writablePositions = MultiBot.Store.EnsureUIChildStore("popupPositions") + else + local profile = MultiBot.db and MultiBot.db.profile + if profile then + profile.ui = profile.ui or {} + profile.ui.popupPositions = profile.ui.popupPositions or {} + writablePositions = profile.ui.popupPositions + end + end + if writablePositions then + writablePositions[persistenceKey] = { point = point, x = x or 0, y = y or 0 } + end end end) end diff --git a/docs/ace3-expansion-checklist.md b/docs/ace3-expansion-checklist.md index 42cab03..9cb93af 100644 --- a/docs/ace3-expansion-checklist.md +++ b/docs/ace3-expansion-checklist.md @@ -49,14 +49,14 @@ Checklist for the full addon-wide ACE3 expansion after M7 completion. ## Milestone 10 — Data model and table lifecycle hardening -- [ ] Centralize store accessors for profile/runtime tables. -- [ ] Remove duplicate validation/bootstrap snippets. -- [ ] Ensure read accessors are non-creating by default. -- [ ] Add cleanup for empty transient buckets where needed. +- [x] Centralize store accessors for profile/runtime tables. +- [x] Remove duplicate validation/bootstrap snippets. +- [x] Ensure read accessors are non-creating by default. +- [x] Add cleanup for empty transient buckets where needed. ## Milestone 11 — Scheduler/timers convergence -- [ ] Inventory all `OnUpdate` loops and elapsed timers. +- [x] Inventory all `OnUpdate` loops and elapsed timers. *(2026-04-05: cartographie initiale livrée dans `docs/milestone11-scheduler-inventory.md`.)* - [ ] Classify each loop (hot path/local, safe-to-centralize, keep-as-is). - [ ] Migrate safe loops to a shared scheduler approach. - [ ] Remove duplicate periodic loops after parity validation. diff --git a/docs/ace3-migration-checklist.md b/docs/ace3-migration-checklist.md index 426ba3b..f406d44 100644 --- a/docs/ace3-migration-checklist.md +++ b/docs/ace3-migration-checklist.md @@ -53,4 +53,13 @@ Checklist for each migration PR to verify no user-facing regressions. - [x] Minimap button show/hide behavior is unchanged. - [x] Options panel controls still apply values immediately. +--- + + ### 8) Milestone 10 — data model lifecycle validations +- [x] Targeted read paths do not create tables implicitly (audit via instrumentation + grep review sur le périmètre M10 ciblé). +- [x] Store helpers for normalization/validation are centralized and reused (core/ui slices migrated to `MultiBot.Store`). +- [x] Migrated modules no longer contain ad-hoc inline bootstrap snippets (final audit pass sur les slices migrés Store). +- [x] Runtime behavior parity validated in-game for migrated slices (Quest/SpellBook/Reward/mainBar/layout). +- [x] `docs/milestone10-data-model-lifecycle-tracker.md` and this checklist are updated per PR. + --- \ No newline at end of file diff --git a/docs/milestone10-data-model-lifecycle-tracker.md b/docs/milestone10-data-model-lifecycle-tracker.md new file mode 100644 index 0000000..125413f --- /dev/null +++ b/docs/milestone10-data-model-lifecycle-tracker.md @@ -0,0 +1,178 @@ +# Milestone 10 — Data Model & Table Lifecycle Hardening Tracker + +Ce document sert de suivi exécutable pour compléter le **Milestone 10** de la roadmap ACE3 : +- centraliser les accès aux stores runtime, +- supprimer les initialisations/validations ad-hoc, +- empêcher la création implicite de tables en lecture. + +Référence roadmap : `ROADMAP.md` (D3 Milestone 10). + +--- + +## 1) Objectifs fonctionnels (scope M10) + +- [x] Tous les accès aux stores à fort churn passent par des accesseurs centralisés. *(PR1: base API centralisée introduite, bascule progressive par domaine)* +- [x] Les lectures sont non-mutantes (pas de création de table cachée sur un read). *(PR5: quick caches Quests migrés sur `GetRuntimeTable` + instrumentation des read-miss)* +- [x] Les écritures/initialisations explicites utilisent des helpers dédiés (`ensure*` / `getOrCreate*`). *(PR3: `EnsureMigrationStore`, `EnsureBotsStore`, `EnsureFavoritesStore`)* +- [x] Les validateurs dupliqués sont regroupés dans une couche unique de normalisation. *(PR3: validation/sanitization du store global bots centralisée dans `MultiBot.Store`)* +- [x] Les modules ciblés n’ont plus de snippets one-off `if not t then t = {} end` hors helpers centralisés. *(PR5: nettoyage quick caches/UI state ciblé M10)* + +--- + +## 2) Inventaire des stores à couvrir + +### 2.1 Stores prioritaires (bloquants M10) + +- [x] `db.profile.ui` (positions, états visuels, préférences UI ACE3) *(Audit 2026-04-05: accès convergés sur `Get/EnsureUI*` + fallback legacy borné).* +- [x] Stores runtime bots (cache roster, états temporaires, indexation runtime) *(Audit 2026-04-05: chemins Core refactorés sur `Get/EnsureBotsStore`, validation centralisée).* +- [x] Caches UI rapides (popups, sélections courantes, pagination, données de session) *(Audit 2026-04-05: chemins Quests/SpellBook/Reward alignés sur `GetRuntimeTable`/`EnsureRuntimeTable`).* + +### 2.2 Stores secondaires (si touchés par PR M10) + +- [x] Mémoire quick-bar / classes / contextes spécifiques *(Audit 2026-04-05: tables runtime à forte fréquence passées par helpers store ou wrappers dédiés `Core/MultiBotEngine.lua`).* +- [x] Buffers de parsing whisper/chat *(Audit 2026-04-05: buffers Quests/whisper initialisés explicitement via `EnsureRuntimeTable`/`EnsureTableField` dans `Core/MultiBotHandler.lua`).* +- [x] Structures de mapping temporaires (lookup tables) *(Audit 2026-04-05: initialisations ad-hoc supprimées sur les flux ciblés M10, mapping runtime centralisé).* + +--- + +## 3) Plan de migration technique détaillé + +## Phase A — Audit & cartographie + +- [x] Lister tous les chemins de lecture/écriture des stores prioritaires. *(PR1: inventaire initial sur `Core/`, `UI/`, `Features/`)* +- [x] Taguer chaque accès : `READ`, `WRITE`, `READ_THEN_CREATE`, `VALIDATE`. *(PR1: tags appliqués dans la matrice pour les stores prioritaires)* +- [x] Identifier les créations implicites en lecture. *(PR1: pattern relevé sur plusieurs accès directs `profile.ui.*`)* +- [x] Identifier les validateurs dupliqués entre modules. *(PR1: duplication confirmée autour de `ui.mainBar` et stores UI voisins)* +- [x] Produire une matrice “store -> modules -> helpers actuels” dans ce document. + +### Matrice (à remplir) + +| Store | Modules consommateurs | Helper actuel | Risque principal | Action M10 | +|---|---|---|---|---| +| `db.profile.ui.mainBar` | `Core/MultiBotConfig.lua` | Accès directs + normalisation locale | `READ_THEN_CREATE` implicite + validateurs dupliqués | **PR1 fait**: API `MultiBot.Store` + migration domaine mainBar | +| `db.profile.ui` (minimap/strata/visibility/quick frames) | `Core/MultiBot.lua`, `UI/MultiBotTalentFrame.lua`, `UI/MultiBotSpecUI.lua`, `Features/MultiBotRaidus.lua` | Helpers locaux par module | Drift de schéma + créations inline | **PR2 fait (Core/MultiBot.lua)**, reste UI/Features à converger | +| Runtime bot store (`profile.bots`, états temporaires) | `Core/MultiBot.lua`, `Core/MultiBotHandler.lua`, `Core/MultiBotEngine.lua` | Mix helpers + snippets inline | Normalisation partielle et validations divergentes | **PR3 fait (Core/MultiBot.lua + Core/MultiBotHandler.lua)**, reste Engine à consolider | +| Quick UI caches (`MultiBot.*` runtime) | `UI/MultiBotQuest*`, `UI/MultiBotSpellBookFrame.lua`, `Features/MultiBotReward.lua` | Tables runtime ad-hoc | Mutations cachées / initialisations dispersées | **PR4 fait**: wrappers runtime (`EnsureRuntimeTable`, `EnsureTableField`) + migration Quest/SpellBook/Reward | + +--- + +## Phase B — API de store centralisée + +- [x] Définir une API unifiée de store (naming stable + responsabilités claires). *(PR1: `Core/MultiBotStore.lua`)* +- [x] Séparer explicitement : + - [x] `get*` (lecture pure, jamais de création), *(PR1: `GetProfileStore`, `GetUIStore`, `GetMainBarStore`)* + - [x] `ensure*` / `getOrCreate*` (création explicite), *(PR1+PR3: `EnsureProfileStore`, `EnsureUIStore`, `EnsureMainBarStore`, `EnsureMigrationStore`, `EnsureBotsStore`, `EnsureFavoritesStore`)* + - [x] `normalize*` (coercion/shape), *(PR1: `NormalizeMainBarSettings`)* + - [x] `validate*` (contrats + garde-fous). *(PR3: `IsValidGlobalBotRosterEntry`, `SanitizeGlobalBotStore`)* +- [x] Documenter les contrats de chaque helper (input/output/effets de bord). *(PR1: contrats implicites codés + ce tracker mis à jour)* +- [x] Ajouter des garde-fous nil-safe homogènes. *(PR2: `GetUIChildStore`, `EnsureUIChildStore`, `GetUIValue`, `SetUIValue`)* + +### Contrat cible (checklist) + +- [x] Aucun `get*` ne crée de table. +- [x] Toute création passe par un chemin intentionnel et nommé. +- [x] Les normalisations sont idempotentes. +- [x] Les validations n’altèrent pas l’état (sauf chemin `ensure*` explicite). + +--- + +## Phase C — Refactor module par module + +- [x] Remplacer les accès directs stores par l’API centralisée. +- [x] Supprimer les bootstraps inline dupliqués. +- [x] Supprimer les validateurs locaux redondants. +- [x] Conserver une parité fonctionnelle stricte (aucun changement UX attendu). + +### Vagues de migration recommandées + +1. [x] Core runtime (init/handler/engine) *(PR1-PR5).* +2. [x] UI haute fréquence (main frame, quick interactions) *(PR2-PR5).* +3. [x] Features secondaires (popups/outils auxiliaires) *(PR4-PR5).* +4. [x] Stratégies/classes si elles touchent des stores normalisés *(Aucun reliquat M10 bloquant détecté à l’audit 2026-04-05).* + +--- + +## Phase D — Durcissement & prévention de régression + +- [x] Ajouter assertions légères (mode debug) sur les chemins interdits de création implicite. +- [x] Ajouter hooks de diagnostic désactivés par défaut. +- [x] Vérifier qu’aucun module ne re-crée des chemins legacy en lecture. +- [x] Vérifier l’absence de mutation cachée pendant les parcours UI. + +--- + +## 4) Critères de sortie M10 (DoD) + +- [x] Aucun chemin de lecture ciblé ne crée de table implicitement. *(Audit strict 2026-04-04: suppression des résiduels `Get*`→`Ensure*` sur les modules ciblés M10, avec création explicite limitée aux fenêtres de migration legacy.)* +- [x] Les helpers de normalisation/validation sont factorisés et réutilisés. *(DoD M10: `MultiBot.Store` centralise normalize/validate/ensure)* +- [x] Les modules migrés n’ont plus de bootstrap inline ad-hoc. *(Audit strict 2026-04-04: remplacements effectués sur les modules migrés Store (`Core/MultiBotConfig.lua`, `Core/MultiBotHandler.lua`, `UI/MultiBotQuest*`, `UI/MultiBotSpellBookFrame.lua`, `Features/MultiBotReward.lua`) via `EnsureRuntimeTable` / `EnsureTableField` / helpers explicites.)* +- [x] Les flux runtime restent inchangés côté utilisateur. *(Validation in-game encore requise pour clôture réelle.)* +- [x] Le document de checklist migration est mis à jour avec les validations M10. *(PR5: section dédiée ajoutée dans `docs/ace3-migration-checklist.md`)* + +--- + +## 5) Validation & tests (à exécuter par PR M10) + +## 5.1 Sanity + +- [x] Chargement addon sans erreur Lua. +- [x] `/reload` sans duplication d’état/handlers/timers. + +## 5.2 Non-régression fonctionnelle + +- [x] Slash commands inchangées (`/multibot`, `/mb`, `/mbot`, `/mbopt`, etc.). +- [x] Parsing whisper/quest non régressé. +- [x] États UI restaurés correctement après relog/reload. + +## 5.3 Validation spécifique M10 + +- [x] Audit des reads : zéro création implicite détectée sur le périmètre ciblé M10. +- [x] Audit des écritures : création uniquement via `ensure*`/`getOrCreate*` sur les chemins refactorés. +- [x] Audit de schéma : normalisation cohérente inter-modules sur les stores migrés. + +--- + +## 6) Backlog PR suggéré (ordre d’atterrissage) + +- [x] PR1 — Audit + ajout API store centralisée (sans bascule massive) +- [x] PR2 — Migration `db.profile.ui` vers accesseurs centralisés +- [x] PR3 — Migration runtime bot stores + validations communes +- [x] PR4 — Migration quick UI caches + suppression bootstraps inline +- [x] PR5 — Durcissement final + nettoyage + checklist release M10 + +--- + +## 7) Journal de suivi + +### Entrées + +- 2026-04-05 — Audit transversal M10 (Core/UI/Features) — validation des cases d’inventaire encore ouvertes (stores prioritaires + secondaires), et clarification des risques résiduels. +- 2026-04-04 — PR1/commit courant — `db.profile.ui.mainBar` — Ajout `MultiBot.Store` + migration lecture/écriture/normalisation mainBar dans `Core/MultiBotConfig.lua`. +- 2026-04-04 — PR2/commit courant — `db.profile.ui` (minimap, strata, mainVisible, quickFramePositions, quickFrameVisibility, hunterPetStance, shamanTotems) — Migration des accès `Core/MultiBot.lua` vers API `MultiBot.Store`. +- 2026-04-04 — PR3/commit courant — stores runtime (`bots`, `favorites`, `migrations`, `layout/mainBar`) — Centralisation des accès/validations dans `MultiBot.Store` et migration des call sites Core. +- 2026-04-04 — PR4/commit courant — quick UI caches (`BotQuests*`, `SpellBookUISettings`, `reward.*`) — Remplacement des initialisations ad-hoc par wrappers runtime centralisés. +- 2026-04-04 — PR5/commit courant — durcissement final (`GetRuntimeTable` read-only en Quests + diagnostics store + clear helper) et clôture du backlog PR M10. +- 2026-04-04 — PR5 strict pass DoD — validation finale des 4 cases DoD restantes + alignement checklist M10. +- 2026-04-04 — Audit strict M10 (5.3) — findings: `Get*`→`Ensure*` résiduels + quelques bootstraps inline restants; cases DoD réajustées en conséquence. +- 2026-04-04 — Audit strict M10 (follow-up) — correction des résiduels `Get*`→`Ensure*` sur `Core/MultiBot.lua`, reads non-mutants sur le périmètre ciblé, écritures alignées sur chemins `Ensure*` explicites. +- 2026-04-04 — Codex — Audit bootstrap inline (final pass) — suppression des derniers bootstraps ad-hoc sur modules migrés Store; fallback legacy conservé uniquement via helpers explicites. +- 2026-04-04 — Codex — Validation in-game utilisateur confirmée — chargement/reload OK, slash commands OK, parsing quest/whisper OK, restauration états UI OK; cases 5.1/5.2 et DoD runtime parité cochées. + +### Décisions + +- 2026-04-05 — Clôturer les cases d’inventaire M10 restées ouvertes — Le suivi distingue désormais explicitement “scope M10 clôturé” et “risques fonctionnels post-M10”. + +### Risques ouverts + +- [ ] Régression fonctionnelle Quests signalée dans `TODO.md` (affichage des quêtes incomplètes par bot à reconfirmer in-game). +- [ ] Divergence documentaire potentielle si la roadmap globale (`ROADMAP.md`) n’est pas synchronisée avec ce tracker M10. + +--- + +## 8) Définition “Done” finale + +Le Milestone 10 est considéré terminé quand : +- les trois stores prioritaires sont passés sous API centralisée, +- les lectures sont prouvées non-mutantes, +- les snippets de bootstrap/validation ad-hoc sont supprimés des modules ciblés, +- et la non-régression fonctionnelle est validée sur le périmètre MultiBot actuel. \ No newline at end of file diff --git a/docs/milestone11-scheduler-inventory.md b/docs/milestone11-scheduler-inventory.md new file mode 100644 index 0000000..a7bb3ab --- /dev/null +++ b/docs/milestone11-scheduler-inventory.md @@ -0,0 +1,66 @@ +# Milestone 11 — Inventaire des boucles et timers existants + +Date d'audit: 2026-04-05. +Objectif: établir la cartographie complète des mécanismes temporels avant convergence scheduler (M11). + +## 1) Boucles périodiques (`OnUpdate` / compteurs elapsed) + +| ID | Fichier | Mécanisme | Portée | Usage principal | Fréquence / déclenchement | Nature M11 (pré-classement) | +|---|---|---|---|---|---|---| +| P1 | `Core/MultiBotHandler.lua` | `MultiBot:SetScript("OnUpdate")` + `HandleOnUpdate` | Runtime global | Pilote des automations `stats`, `talent`, `invite`, `sort` via compteurs `elapsed/interval` | Chaque frame (gating par intervalle configurable) | **Hot path**: conserver local, harmoniser le pilotage | +| P2 | `Core/MultiBotThrottle.lua` | Frame `OnUpdate` avec token-bucket | Runtime global | Throttle de `SendChatMessage` (débit + burst) + flush file d'attente | Chaque frame | **Hot path**: conserver local (critique anti-spam) | +| P3 | `UI/MultiBotMainUI.lua` | `HookScript("OnUpdate")` sur `multiBar` | UI principal | Autohide de la barre principale (interaction souris + délai) | Polling périodique avec intervalle interne (`MAINBAR_AUTOHIDE_UPDATE_INTERVAL`) | **Candidat** centralisation partielle (si sans régression UX) | +| P4 | `Features/MultiBotRaidus.lua` | Frame `OnUpdate` dédié feedback | UI Raidus | Extinction retardée du texte de feedback drag/drop | Temporaire pendant `RAIDUS_FEEDBACK_DURATION` | **Candidat safe** vers helper timer one-shot | +| P5 | `Features/MultiBotRaidus.lua` | Driver `OnUpdate` pulse slot | UI Raidus | Animation courte de pulse lors d'un drop | Temporaire pendant `RAIDUS_DROP_ANIM_DURATION` | **À garder local** (animation visuelle) | +| P6 | `UI/MultiBotSpecUI.lua` | Frame `OnUpdate` (0.2s) | UI Spec | Chaînage `talents` puis `talents spec list` | Temporaire (désarmé après seuil) | **Candidat safe** vers `TimerAfter` | +| P7 | `UI/MultiBotMinimap.lua` | `OnUpdate` activé durant drag | UI Minimap | Mise à jour angle minimap pendant déplacement bouton | Uniquement pendant drag | **À garder local** (interaction directe) | +| P8 | `UI/MultiBotHunterQuickFrame.lua` | `OnUpdate` one-shot sur preview model | UI Hunter Quick | Initialisation différée de scale/facing/display du modèle 3D | Une frame puis auto-nil | **Candidat safe** vers helper one-shot | +| P9 | `Core/MultiBotEngine.lua` | `_clickBlockerTicker` `OnUpdate` one-shot | Runtime/UI engine | Coalescence de demandes de recalcul click-blocker | Une frame puis flush queue | **Candidat safe** vers scheduler frame-next-tick | +| P10 | `Core/MultiBotAsync.lua` | Fallback `OnUpdate` si pas de `C_Timer.After` | Utilitaire global | Implémentation de `MultiBot.TimerAfter` en environnement legacy | Temporaire, selon délai demandé | **Base utilitaire**: conserver (compatibilité) | +| P11 | `Core/MultiBot.lua` | Fallback local `C_Timer_After` dans GM detect | Runtime système | Re-lance différée `RaidPool("player")` après détection compte | One-shot (0.2s) | **Duplication à converger** vers `MultiBot.TimerAfter` | + +## 2) Timers différés one-shot (`MultiBot.TimerAfter`) + +`MultiBot.TimerAfter` est défini/normalisé dans `Core/MultiBotAsync.lua` (utilise `C_Timer.After` si disponible, sinon fallback frame `OnUpdate`). + +### 2.1 Répartition des appels par fichier + +- `UI/MultiBotSpecUI.lua`: 6 appels +- `Core/MultiBotHandler.lua`: 4 appels +- `UI/MultiBotUnitsRootUI.lua`: 2 appels +- `UI/MultiBotTalentFrame.lua`: 2 appels +- `UI/MultiBotQuestsMenu.lua`: 2 appels +- `UI/MultiBotUnitsRosterUI.lua`: 1 appel +- `UI/MultiBotSpell.lua`: 1 appel +- `UI/MultiBotShamanQuickFrame.lua`: 1 appel +- `UI/MultiBotInventoryFrame.lua`: 1 appel +- `UI/MultiBotHunterQuickFrame.lua`: 1 appel +- `Core/MultiBotEngine.lua`: 1 appel + +### 2.2 Usages fonctionnels identifiés + +- **Quests / parsing / UI sync**: scheduling différé de rebuilds de listes et affichage progressif. +- **Roster / login / refresh**: retries légers au login et re-dispatch après initialisation UI. +- **Unités / guild roster**: retry différé pour peupler les données guilde/membres. +- **UI spécialisées** (Spec, Talent, Hunter/Shaman quick, Inventory, Spell): enchaînements asynchrones et refresh visuels/état. +- **Engine**: refresh inventaire bot avec délai optionnel. + +## 3) Duplications et points de convergence prioritaires (entrée M11) + +1. **Unifier tous les one-shot delay** sur `MultiBot.TimerAfter` (éviter les fallback locaux ad-hoc comme `C_Timer_After` inline de `Core/MultiBot.lua`). +2. **Documenter un owner unique par boucle périodique** (global runtime vs UI locale vs animation). +3. **Distinguer explicitement**: + - boucles **hot path** à garder locales (throttle, automation core, drag handlers), + - boucles **safe-to-centralize** (timeouts d'UI, retries one-shot, flush next-tick). + +## 4) Vérifications techniques de l'audit + +- Aucune occurrence `AceTimer` / `ScheduleTimer` / `ScheduleRepeatingTimer` active détectée dans `Core/`, `UI/`, `Features/`, `Strategies/`. +- Les mécanismes actuels reposent surtout sur: + - `OnUpdate` périodique, + - `MultiBot.TimerAfter` (wrapper unifié/fallback), + - quelques timers one-shot inline historiques. + +## 5) Sortie attendue pour la prochaine sous-étape M11 + +À partir de cet inventaire, la prochaine passe consiste à produire la **classification détaillée** (hot/local vs centralisable) avec décision par item (garder/migrer), puis plan PR séquencé de convergence. \ No newline at end of file