From 3591f232db14ede05bf5920b7e7bee7ac9a881a8 Mon Sep 17 00:00:00 2001 From: Wishmaster117 <140754794+Wishmaster117@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:18:45 +0200 Subject: [PATCH 1/4] MultiBot: improve bridge synchronization, strategy controls and persistent favorites ## Summary This PR consolidates the current MultiBot addon-side work for the gradual migration from legacy chat-driven bot control toward the MultiBot Bridge architecture. The main goals are to improve bridge synchronization, make strategy controls more reliable, and keep bot/favorite UI state consistent across connection changes and reloads. ## Main changes ### Bridge-backed bot state - Improves synchronization between the addon roster and bridge-provided bot state. - Keeps existing bot buttons updated when bridge roster information changes. - Uses bridge `classId` and level information to refresh: - class icon; - localized class tooltip; - bot level; - runtime button metadata. - Preserves the existing addon behavior for functionality that has not yet been migrated away from chat. ### Strategy controls - Improves strategy state synchronization and refresh behavior. - Keeps strategy UI state consistent after bridge commands and acknowledgements. - Validated with multiple classes and strategy operations, including: - loot; - gather; - tank assist; - cure. ### Favorites - Favorites stored in SavedVariables can now be represented in the Favorites roster even when the bot is offline. - Offline favorite buttons remain clickable and can still be used to connect the bot. - Favorite buttons are reused when the corresponding bot becomes available instead of requiring a UI reload. - Fixes the case where connecting several favorite bots successively could leave the second bot without its class bar until `/reload`. ### Favorite metadata synchronization - Bot details received from the bridge are persisted through the existing global bot store. - Normalizes bridge gender metadata to the format already expected by the addon store: - `Male` -> `[M]` - `Female` -> `[F]` - Reuses the existing `profile.bots` SavedVariables structure instead of introducing another persistence table. - Requests bot details when required to populate metadata for newly added favorites. - Adds a bounded/rearmable roster refresh after connecting an offline favorite so successive bot connections are detected without an addon reload. ## Compatibility Target environment: - World of Warcraft 3.3.5a - AzerothCore WotLK - mod-playerbots - mod-multibot-bridge No modern WoW Lua APIs are introduced. ## Validation Validated with: - addon reloads; - reconnects; - offline favorites; - successive connection of multiple favorite bots; - class icon/tooltip refresh; - persisted bot metadata; - strategy enable/disable operations; - multiple bots; - zero Lua errors during the final in-game tests. The latest Favorites synchronization runtime test confirmed: - offline favorite buttons: OK; - successive bot connections: OK; - second bot bar without `/reload`: OK; - class icon synchronization: OK; - class tooltip synchronization: OK; - Lua errors: none. ## Scope This PR intentionally does not attempt to remove every remaining chat-based Playerbots command. The migration is incremental: legacy chat mechanisms are kept where a validated bridge replacement is not available yet. --- Core/MultiBot.lua | 757 +++++++++++++++++++++++++++++++++-- Core/MultiBotComm.lua | 796 ++++++++++++++++++++++++++++++++++++- Core/MultiBotEngine.lua | 149 ++++--- UI/MultiBotUnitsRootUI.lua | 14 + 4 files changed, 1605 insertions(+), 111 deletions(-) diff --git a/Core/MultiBot.lua b/Core/MultiBot.lua index 85a5ef7..952600b 100644 --- a/Core/MultiBot.lua +++ b/Core/MultiBot.lua @@ -1331,6 +1331,493 @@ local function IsBridgeRosterBotActive(botName) return false end +-- HOTFIX FAVORITES METADATA + ROSTER SYNC V1 START +local FAVORITE_ROSTER_REFRESH_DELAYS = { 0, 0.8, 1.8, 3.2, 5.0, 7.5 } +local FAVORITE_ROSTER_REFRESH_TTL = 10.0 + +local function GetFavoriteRosterRefreshNow() + if type(GetTime) == "function" then + return GetTime() + end + + if type(time) == "function" then + return time() + end + + return 0 +end + +local function NormalizeFavoriteRosterRefreshName(name) + if type(name) ~= "string" then + return "" + end + + return string.lower(name) +end + +local function GetFavoriteRosterRefreshState() + if type(MultiBot._favoriteRosterRefresh) ~= "table" then + MultiBot._favoriteRosterRefresh = {} + end + + local state = MultiBot._favoriteRosterRefresh + state.generation = tonumber(state.generation or 0) or 0 + state.targets = type(state.targets) == "table" and state.targets or {} + return state +end + +local function PruneFavoriteRosterRefreshTargets(state, roster) + local visible = {} + + for _, entry in ipairs(type(roster) == "table" and roster or {}) do + if type(entry) == "table" and type(entry.name) == "string" and entry.name ~= "" then + visible[NormalizeFavoriteRosterRefreshName(entry.name)] = true + end + end + + local now = GetFavoriteRosterRefreshNow() + local unresolved = 0 + + for key, target in pairs(state.targets) do + if visible[key] or type(target) ~= "table" + or tonumber(target.expiresAt or 0) <= now then + state.targets[key] = nil + else + unresolved = unresolved + 1 + end + end + + return unresolved +end + +function MultiBot.ObserveFavoriteRosterRefresh(roster) + local state = GetFavoriteRosterRefreshState() + return PruneFavoriteRosterRefreshTargets(state, roster) +end + +function MultiBot.BeginFavoriteRosterRefresh(name) + if type(name) ~= "string" or name == "" then + return false + end + + if not (MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestRoster) == "function") then + return false + end + + local state = GetFavoriteRosterRefreshState() + local key = NormalizeFavoriteRosterRefreshName(name) + local now = GetFavoriteRosterRefreshNow() + + state.targets[key] = { + name = name, + expiresAt = now + FAVORITE_ROSTER_REFRESH_TTL, + } + + state.generation = state.generation + 1 + local generation = state.generation + + local function requestRoster(attempt) + if state.generation ~= generation then + return + end + + if PruneFavoriteRosterRefreshTargets( + state, + MultiBot.bridge and MultiBot.bridge.roster + ) == 0 then + return + end + + if MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestRoster) == "function" then + MultiBot.Comm.RequestRoster() + end + + if attempt >= #FAVORITE_ROSTER_REFRESH_DELAYS then + PruneFavoriteRosterRefreshTargets( + state, + MultiBot.bridge and MultiBot.bridge.roster + ) + end + end + + for index = 1, #FAVORITE_ROSTER_REFRESH_DELAYS do + local attempt = index + local delay = FAVORITE_ROSTER_REFRESH_DELAYS[index] + + if delay <= 0 then + requestRoster(attempt) + elseif type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(delay, function() + requestRoster(attempt) + end) + end + end + + return true +end + +local function UpdateBridgeUnitButton(button, className, level, name) + if not button then + return false + end + + local classCanon = (MultiBot.toClass and MultiBot.toClass(className or "UNKNOWN")) or "UNKNOWN" + if type(classCanon) ~= "string" or classCanon == "" then + classCanon = "UNKNOWN" + end + + local texture = "Interface\\Icons\\INV_Misc_QuestionMark" + if string.lower(classCanon) ~= "unknown" then + texture = "Interface\\AddOns\\MultiBot\\Icons\\class_" + .. string.lower(classCanon) .. ".blp" + end + + local displayClass = classCanon + if string.lower(classCanon) == "unknown" then + displayClass = "Unknown" + elseif MultiBot.GetClassDisplay then + displayClass = MultiBot.GetClassDisplay(classCanon) or classCanon + end + + local numericLevel = tonumber(level) + if numericLevel and numericLevel <= 0 then + numericLevel = nil + end + + local tooltip = MultiBot.toTip + and MultiBot.toTip(displayClass, numericLevel, name or button.name) + or (name or button.name) + + if button.setButton then + button.setButton(texture, tooltip) + elseif button.icon and button.icon.SetTexture then + button.icon:SetTexture( + MultiBot.SafeTexturePath and MultiBot.SafeTexturePath(texture) or texture + ) + end + + button.class = classCanon + if numericLevel then + button.level = numericLevel + end + + return true +end +-- HOTFIX FAVORITES METADATA + ROSTER SYNC V1 END + +-- HOTFIX ADDCLASS AUTO-GROUP ROSTER V1 START +local ADDCLASS_AUTO_GROUP_CLASS_IDS = { + warrior = 1, + paladin = 2, + hunter = 3, + rogue = 4, + priest = 5, + deathknight = 6, + dk = 6, + shaman = 7, + mage = 8, + warlock = 9, + druid = 11, +} + +local ADDCLASS_AUTO_GROUP_TIMEOUT = 12.0 +local ADDCLASS_AUTO_GROUP_MAX_PENDING = 8 +local ADDCLASS_AUTO_GROUP_MAX_ATTEMPTS = 3 +local ADDCLASS_AUTO_GROUP_RETRY_DELAY = 1.5 + +local function GetAddClassAutoGroupNow() + if type(GetTime) == "function" then + return GetTime() + end + + if type(time) == "function" then + return time() + end + + return 0 +end + +local function NormalizeAddClassAutoGroupName(name) + if type(name) ~= "string" then + return "" + end + + return string.lower(name) +end + +local function GetAddClassAutoGroupState() + if type(MultiBot._addClassAutoGroup) ~= "table" then + MultiBot._addClassAutoGroup = {} + end + + local state = MultiBot._addClassAutoGroup + state.sequence = tonumber(state.sequence or 0) or 0 + state.pending = type(state.pending) == "table" and state.pending or {} + state.claimed = type(state.claimed) == "table" and state.claimed or {} + return state +end + +local function ReleaseAddClassAutoGroupClaim(state, transaction) + if not state or not transaction or not transaction.candidateKey then + return + end + + if state.claimed[transaction.candidateKey] == transaction.id then + state.claimed[transaction.candidateKey] = nil + end +end + +local function CompleteAddClassAutoGroupTransaction(state, transaction) + if not transaction or transaction.completed then + return + end + + transaction.completed = true + transaction.inviteScheduled = false + ReleaseAddClassAutoGroupClaim(state, transaction) +end + +local function CompactAddClassAutoGroupState(state) + local now = GetAddClassAutoGroupNow() + local pending = {} + + for _, transaction in ipairs(state.pending) do + if transaction.completed or now >= transaction.expiresAt then + ReleaseAddClassAutoGroupClaim(state, transaction) + else + pending[#pending + 1] = transaction + end + end + + state.pending = pending +end + +local function BuildAddClassAutoGroupBaseline() + local names = {} + + if MultiBot.bridge and type(MultiBot.bridge.roster) == "table" then + for _, entry in ipairs(MultiBot.bridge.roster) do + if type(entry) == "table" and type(entry.name) == "string" and entry.name ~= "" then + names[NormalizeAddClassAutoGroupName(entry.name)] = true + end + end + end + + if MultiBot.index and type(MultiBot.index.players) == "table" then + for _, name in ipairs(MultiBot.index.players) do + if type(name) == "string" and name ~= "" then + names[NormalizeAddClassAutoGroupName(name)] = true + end + end + end + + local playerName = type(UnitName) == "function" and UnitName("player") or nil + if type(playerName) == "string" and playerName ~= "" then + names[NormalizeAddClassAutoGroupName(playerName)] = true + end + + return names +end + +local function ScheduleAddClassAutoGroupInvite(state, transaction) + if not state or not transaction or transaction.completed or transaction.inviteScheduled then + return false + end + + local botName = transaction.candidateName + if type(botName) ~= "string" or botName == "" then + return false + end + + if IsBridgeRosterBotActive(botName) then + CompleteAddClassAutoGroupTransaction(state, transaction) + return true + end + + if transaction.inviteAttempts >= ADDCLASS_AUTO_GROUP_MAX_ATTEMPTS then + return false + end + + transaction.inviteScheduled = true + + local delay = 0 + local inRaid = type(IsInRaid) == "function" and IsInRaid() + local partyCount = type(GetNumPartyMembers) == "function" and (GetNumPartyMembers() or 0) or 0 + + if not inRaid and partyCount >= 4 and type(ConvertToRaid) == "function" then + ConvertToRaid() + delay = 0.25 + end + + local function inviteCandidate() + transaction.inviteScheduled = false + + if transaction.completed then + return + end + + local now = GetAddClassAutoGroupNow() + if now >= transaction.expiresAt then + CompleteAddClassAutoGroupTransaction(state, transaction) + return + end + + if IsBridgeRosterBotActive(botName) then + CompleteAddClassAutoGroupTransaction(state, transaction) + return + end + + transaction.inviteAttempts = transaction.inviteAttempts + 1 + transaction.lastInviteAt = now + + if MultiBot.doSlash then + MultiBot.doSlash("/invite", botName) + elseif type(InviteUnit) == "function" then + InviteUnit(botName) + else + return + end + + if type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(ADDCLASS_AUTO_GROUP_RETRY_DELAY, function() + if transaction.completed then + return + end + + if IsBridgeRosterBotActive(botName) then + CompleteAddClassAutoGroupTransaction(state, transaction) + elseif transaction.inviteAttempts < ADDCLASS_AUTO_GROUP_MAX_ATTEMPTS + and GetAddClassAutoGroupNow() < transaction.expiresAt then + ScheduleAddClassAutoGroupInvite(state, transaction) + end + + CompactAddClassAutoGroupState(state) + end) + end + end + + if delay > 0 and type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(delay, inviteCandidate) + else + inviteCandidate() + end + + return true +end + +function MultiBot.BeginAddClassAutoGroup(classCmd) + if not (MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestRoster) == "function") then + return false + end + + local normalizedClass = type(classCmd) == "string" and string.lower(classCmd) or "" + local classId = ADDCLASS_AUTO_GROUP_CLASS_IDS[normalizedClass] + if not classId then + return false + end + + local state = GetAddClassAutoGroupState() + CompactAddClassAutoGroupState(state) + + while #state.pending >= ADDCLASS_AUTO_GROUP_MAX_PENDING do + local removed = table.remove(state.pending, 1) + ReleaseAddClassAutoGroupClaim(state, removed) + end + + state.sequence = state.sequence + 1 + local now = GetAddClassAutoGroupNow() + local transaction = { + id = state.sequence, + classId = classId, + baseline = BuildAddClassAutoGroupBaseline(), + createdAt = now, + expiresAt = now + ADDCLASS_AUTO_GROUP_TIMEOUT, + inviteAttempts = 0, + inviteScheduled = false, + completed = false, + } + + state.pending[#state.pending + 1] = transaction + + if type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(4.0, function() + if not transaction.completed and GetAddClassAutoGroupNow() < transaction.expiresAt + and MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestRoster) == "function" then + MultiBot.Comm.RequestRoster() + end + end) + + MultiBot.TimerAfter(8.0, function() + if not transaction.completed and GetAddClassAutoGroupNow() < transaction.expiresAt + and MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestRoster) == "function" then + MultiBot.Comm.RequestRoster() + end + end) + end + + return true +end + +function MultiBot.ProcessPendingAddClassRoster(roster) + if type(roster) ~= "table" then + return 0 + end + + local state = GetAddClassAutoGroupState() + CompactAddClassAutoGroupState(state) + + local scheduled = 0 + local now = GetAddClassAutoGroupNow() + + for _, transaction in ipairs(state.pending) do + if not transaction.completed then + if transaction.candidateName then + if IsBridgeRosterBotActive(transaction.candidateName) then + CompleteAddClassAutoGroupTransaction(state, transaction) + elseif not transaction.inviteScheduled + and transaction.inviteAttempts < ADDCLASS_AUTO_GROUP_MAX_ATTEMPTS + and (not transaction.lastInviteAt + or now - transaction.lastInviteAt >= ADDCLASS_AUTO_GROUP_RETRY_DELAY) then + if ScheduleAddClassAutoGroupInvite(state, transaction) then + scheduled = scheduled + 1 + end + end + else + for _, entry in ipairs(roster) do + local name = type(entry) == "table" and entry.name or nil + local classId = type(entry) == "table" and tonumber(entry.classId or 0) or 0 + local key = NormalizeAddClassAutoGroupName(name) + + if key ~= "" and classId == transaction.classId + and not transaction.baseline[key] + and not state.claimed[key] then + transaction.candidateName = name + transaction.candidateKey = key + state.claimed[key] = transaction.id + + if IsBridgeRosterBotActive(name) then + CompleteAddClassAutoGroupTransaction(state, transaction) + elseif ScheduleAddClassAutoGroupInvite(state, transaction) then + scheduled = scheduled + 1 + end + + break + end + end + end + end + end + + CompactAddClassAutoGroupState(state) + return scheduled +end +-- HOTFIX ADDCLASS AUTO-GROUP ROSTER V1 END + local function HideButtonUnitFrame(button) if not button or not button.parent or not button.parent.frames then return @@ -1369,6 +1856,12 @@ function MultiBot.BindUnitToggleHandlers(button, options) SendChatMessage(".playerbot bot add " .. unitButton.name, "SAY") unitButton.setEnable() + + if (unitButton._mbFavoritePlaceholder + or (MultiBot.IsFavorite and MultiBot.IsFavorite(unitButton.name))) + and MultiBot.BeginFavoriteRosterRefresh then + MultiBot.BeginFavoriteRosterRefresh(unitButton.name) + end end button._mbUnitToggleBound = true @@ -1380,6 +1873,10 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) return false end + if MultiBot.ObserveFavoriteRosterRefresh then + MultiBot.ObserveFavoriteRosterRefresh(roster) + end + if not (MultiBot.frames and MultiBot.frames["MultiBar"] and MultiBot.frames["MultiBar"].frames and MultiBot.frames["MultiBar"].frames["Units"] @@ -1439,6 +1936,7 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) local botClass = GetBridgeRosterClass(entry.classId) local button = MultiBot.addPlayer(botClass, entry.name) if button then + button._mbFavoritePlaceholder = nil local isActive = IsBridgeRosterBotActive(entry.name) button.class = botClass @@ -1449,6 +1947,7 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) button.hpPct = tonumber(entry.hpPct or 0) or 0 button.mpPct = tonumber(entry.mpPct or 0) or 0 button.bridge = entry + UpdateBridgeUnitButton(button, botClass, button.level, entry.name) if MultiBot.BindUnitToggleHandlers then MultiBot.BindUnitToggleHandlers(button, { requireEnabledStateOnRight = true }) @@ -1472,6 +1971,10 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) end end + if MultiBot.ProcessPendingAddClassRoster then + MultiBot.ProcessPendingAddClassRoster(roster) + end + if MultiBot.UpdateFavoritesIndex then MultiBot.UpdateFavoritesIndex() end @@ -1528,6 +2031,25 @@ function MultiBot.GetCachedBridgeDetail(name) return MultiBot.bridge.details[string.lower(name)] end +local function NormalizeBridgeDetailStoreGender(value) + local gender = tostring(value or "") + local normalized = string.lower(gender) + + if normalized == "male" or normalized == "m" or normalized == "[m]" then + return "[M]" + end + + if normalized == "female" or normalized == "f" or normalized == "[f]" then + return "[F]" + end + + if string.match(gender, "^%[[^%]]+%]$") then + return gender + end + + return "[?]" +end + local function BuildBridgeDetailStoreValue(detail) if type(detail) ~= "table" or type(detail.name) ~= "string" or detail.name == "" then return nil @@ -1558,7 +2080,7 @@ local function BuildBridgeDetailStoreValue(detail) local classDisplay = (MultiBot.GetClassDisplay and MultiBot.GetClassDisplay(classCanon)) or classCanon local race = tostring(detail.race or "Unknown") - local gender = tostring(detail.gender or "Unknown") + local gender = NormalizeBridgeDetailStoreGender(detail.gender) local talents = talent1 .. "/" .. talent2 .. "/" .. talent3 local level = tonumber(detail.level or 0) or 0 local score = tonumber(detail.score or 0) or 0 @@ -1576,13 +2098,37 @@ function MultiBot.ApplyBridgeBotDetail(detail) return false end + local storedValue = nil if MultiBot.SetGlobalBotEntry then - MultiBot.SetGlobalBotEntry(detail.name, value) + storedValue = MultiBot.SetGlobalBotEntry(detail.name, value) else if type(_G.MultiBotGlobalSave) ~= "table" then _G.MultiBotGlobalSave = {} end _G.MultiBotGlobalSave[detail.name] = value + storedValue = value + end + + if not storedValue then + return false + end + + local units = MultiBot.frames + and MultiBot.frames["MultiBar"] + and MultiBot.frames["MultiBar"].frames + and MultiBot.frames["MultiBar"].frames["Units"] + local button = units and units.buttons and units.buttons[detail.name] + local classCanon = (MultiBot.toClass + and MultiBot.toClass(detail.className or detail.class or "Unknown")) + or "Unknown" + + if button then + UpdateBridgeUnitButton(button, classCanon, detail.level, detail.name) + end + + if MultiBot.IsFavorite and MultiBot.IsFavorite(detail.name) + and MultiBot.UpdateFavoritesIndex then + MultiBot.UpdateFavoritesIndex() end if MultiBot.raidus and MultiBot.raidus.setRaidus and MultiBot.raidus.IsShown and MultiBot.raidus:IsShown() then @@ -1958,40 +2504,174 @@ function MultiBot.IsFavorite(name) return favorites and favorites[name] == true end +local FAVORITE_UNKNOWN_CLASS = "UNKNOWN" +local FAVORITE_UNKNOWN_TEXTURE = "Interface\\Icons\\INV_Misc_QuestionMark" + +local function NormalizeFavoriteClass(value) + if type(value) ~= "string" or value == "" then + return FAVORITE_UNKNOWN_CLASS + end + + local className = (MultiBot.toClass and MultiBot.toClass(value)) or value + if type(className) ~= "string" or className == "" + or string.lower(className) == "unknown" then + return FAVORITE_UNKNOWN_CLASS + end + + return className +end + +local function FindFavoriteClassInPlayersIndex(name) + local byClass = MultiBot.index + and MultiBot.index.classes + and MultiBot.index.classes.players + + if type(byClass) ~= "table" then + return nil + end + + for className, names in pairs(byClass) do + for index = 1, (names and #names or 0) do + if names[index] == name then + return NormalizeFavoriteClass(className) + end + end + end + + return nil +end + +local function GetFavoriteCachedMetadata(name) + local store = MultiBot.GetGlobalBotStore and MultiBot.GetGlobalBotStore() + local value = store and store[name] + + if type(value) ~= "string" then + return FAVORITE_UNKNOWN_CLASS, nil + end + + local classValue, levelValue = string.match( + value, + "^[^,]*,[^,]*,[^,]*,[^,]*,([^,]*),([^,]*)," + ) + + return NormalizeFavoriteClass(classValue), tonumber(levelValue) +end + +function MultiBot.ResolveFavoriteButtonMetadata(name, units) + local button = units and units.buttons and units.buttons[name] + if button and button.class then + local buttonClass = NormalizeFavoriteClass(button.class) + if buttonClass ~= FAVORITE_UNKNOWN_CLASS then + return buttonClass, tonumber(button.level) + end + end + + local indexedClass = FindFavoriteClassInPlayersIndex(name) + if indexedClass and indexedClass ~= FAVORITE_UNKNOWN_CLASS then + return indexedClass, button and tonumber(button.level) or nil + end + + return GetFavoriteCachedMetadata(name) +end + +function MultiBot.EnsureFavoriteButtons(favorites) + local units = MultiBot.frames + and MultiBot.frames["MultiBar"] + and MultiBot.frames["MultiBar"].frames + and MultiBot.frames["MultiBar"].frames["Units"] + + if not units or type(units.addButton) ~= "function" then + return 0 + end + + favorites = favorites or getFavoritesStore() + local created = 0 + + for name, isFavorite in pairs(favorites or {}) do + if isFavorite == true and type(name) == "string" and name ~= "" then + local button = units.buttons and units.buttons[name] + if button and button._mbFavoritePlaceholder and button.roster ~= "favorites" then + button._mbFavoritePlaceholder = nil + end + + local className, level = MultiBot.ResolveFavoriteButtonMetadata(name, units) + local texture = FAVORITE_UNKNOWN_TEXTURE + if className ~= FAVORITE_UNKNOWN_CLASS then + texture = "Interface\\AddOns\\MultiBot\\Icons\\class_" + .. string.lower(className) .. ".blp" + end + + local displayClass = className + if className == FAVORITE_UNKNOWN_CLASS then + displayClass = "Unknown" + elseif MultiBot.GetClassDisplay then + displayClass = MultiBot.GetClassDisplay(className) or className + end + + local tooltip = MultiBot.toTip + and MultiBot.toTip(displayClass, level, name) + or name + + if not button then + button = units.addButton(name, 0, 0, texture, tooltip) + button:Hide() + button._mbFavoritePlaceholder = true + button.roster = "favorites" + created = created + 1 + elseif button._mbFavoritePlaceholder and button.setButton then + button.setButton(texture, tooltip) + end + + if button then + button.name = name + if not button.class + or NormalizeFavoriteClass(button.class) == FAVORITE_UNKNOWN_CLASS then + button.class = className + end + if level and not button.level then + button.level = level + end + + if MultiBot.BindUnitToggleHandlers then + MultiBot.BindUnitToggleHandlers( + button, + { requireEnabledStateOnRight = true } + ) + end + + if button._mbFavoritePlaceholder and button.setDisable then + button.setDisable() + end + end + end + end + + return created +end + function MultiBot.UpdateFavoritesIndex() local favorites = getFavoritesStore() + local units = MultiBot.frames + and MultiBot.frames["MultiBar"] + and MultiBot.frames["MultiBar"].frames + and MultiBot.frames["MultiBar"].frames["Units"] MultiBot.index.favorites = {} MultiBot.index.classes.favorites = {} - for name, _ in pairs(favorites or {}) do - table.insert(MultiBot.index.favorites, name) - local cls = nil - -- 1) If the unit button already exists, use its class. - local units = nil - if MultiBot.frames and MultiBot.frames["MultiBar"] - and MultiBot.frames["MultiBar"].frames - and MultiBot.frames["MultiBar"].frames["Units"] - then - units = MultiBot.frames["MultiBar"].frames["Units"] - end - local buttons = units and units.buttons or nil - if buttons and buttons[name] and buttons[name].class then - cls = buttons[name].class - else - -- 2) Otherwise fallback to players class index. - local byClass = MultiBot.index and MultiBot.index.classes and MultiBot.index.classes.players - if byClass then - for c, arr in pairs(byClass) do - for i = 1, (arr and #arr or 0) do - if arr[i] == name then cls = c break end - end - if cls then break end - end - end + + for name, isFavorite in pairs(favorites or {}) do + if isFavorite == true and type(name) == "string" and name ~= "" then + table.insert(MultiBot.index.favorites, name) + + local className = MultiBot.ResolveFavoriteButtonMetadata(name, units) + MultiBot.index.classes.favorites[className] = + MultiBot.index.classes.favorites[className] or {} + table.insert(MultiBot.index.classes.favorites[className], name) end - cls = cls or "UNKNOWN" - MultiBot.index.classes.favorites[cls] = MultiBot.index.classes.favorites[cls] or {} - table.insert(MultiBot.index.classes.favorites[cls], name) + end + + if MultiBot.EnsureFavoriteButtons then + MultiBot.EnsureFavoriteButtons(favorites) end end @@ -2003,6 +2683,19 @@ function MultiBot.SetFavorite(name, isFav) if isFav then favorites[name] = true else favorites[name] = nil end + + if isFav then + local detail = MultiBot.GetCachedBridgeDetail + and MultiBot.GetCachedBridgeDetail(name) + + if detail and MultiBot.ApplyBridgeBotDetail then + MultiBot.ApplyBridgeBotDetail(detail) + elseif MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestBotDetail) == "function" then + MultiBot.Comm.RequestBotDetail(name) + end + end + MultiBot.UpdateFavoritesIndex() end @@ -2189,6 +2882,10 @@ MultiBot.AddClassToTarget = function(classCmd, gender) msg = msg .. " " .. gender print("[DBG] Message de sortie :" ,msg) end + if MultiBot.BeginAddClassAutoGroup then + MultiBot.BeginAddClassAutoGroup(classCmd) + end + SendChatMessage(msg, "SAY") if MultiBot.RequestBridgeRosterRefresh then diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index ad85096..e862e87 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -11,6 +11,20 @@ MultiBot.Comm = Comm Comm.prefix = "MBOT" Comm.version = "1" +local STATE_FRAMING_CAPABILITY = "STATE_FRAMING_V1" +local STRATEGY_MUTATION_CAPABILITY = "STRATEGY_MUTATION_V1" +local STATE_TIMEOUT_SECONDS = 5.0 +local STRATEGY_MUTATION_TIMEOUT_SECONDS = 5.0 +local STRATEGY_MUTATION_MAX_ACTIVE = 32 +local STRATEGY_MUTATION_MAX_CHANGES_LENGTH = 160 +local STRATEGY_MUTATION_MAX_OPERATIONS = 32 +local STRATEGY_MUTATION_MAX_STRATEGY_LENGTH = 96 +local STATE_MAX_ACTIVE = 32 +local STATE_MAX_BOTS = 128 +local STATE_MAX_STRATEGIES_PER_SCOPE = 256 +local STATE_MAX_STRATEGY_LENGTH = 192 +local STATE_MAX_TOTAL_BYTES = 32768 + local function safeNow() if type(GetTime) == "function" then return GetTime() @@ -53,6 +67,25 @@ local function splitOnce(value, separator) return string.sub(value, 1, startIndex - 1), string.sub(value, endIndex + 1) end +local function splitFields(value) + local fields = {} + value = type(value) == "string" and value or "" + local startIndex = 1 + + while true do + local separatorIndex = string.find(value, "~", startIndex, true) + if not separatorIndex then + fields[#fields + 1] = string.sub(value, startIndex) + break + end + + fields[#fields + 1] = string.sub(value, startIndex, separatorIndex - 1) + startIndex = separatorIndex + 1 + end + + return fields +end + local function urlDecodeField(value) if type(value) ~= "string" or value == "" then return "" @@ -70,6 +103,77 @@ local function urlEncodeField(value) end)) end +local function urlDecodeFieldStrict(value, maxLength, allowEmpty) + if type(value) ~= "string" then + return nil + end + + maxLength = tonumber(maxLength or 0) or 0 + if maxLength <= 0 or #value > (maxLength * 3) then + return nil + end + + local output = {} + local outputLength = 0 + local index = 1 + + while index <= #value do + local byteValue = string.byte(value, index) + if byteValue == 37 then + if index + 2 > #value then + return nil + end + + local hex = string.sub(value, index + 1, index + 2) + if not string.match(hex, "^%x%x$") then + return nil + end + + byteValue = tonumber(hex, 16) + index = index + 3 + else + index = index + 1 + end + + if not byteValue or byteValue < 32 or byteValue == 127 then + return nil + end + + outputLength = outputLength + 1 + if outputLength > maxLength then + return nil + end + + output[#output + 1] = string.char(byteValue) + end + + local decoded = table.concat(output) + if decoded == "" and not allowEmpty then + return nil + end + + return decoded +end + +local function parseBoundedInteger(value, minimum, maximum) + value = trim(value) + if value == "" or not string.match(value, "^%d+$") then + return nil + end + + local number = tonumber(value) + if not number or number < minimum or number > maximum or math.floor(number) ~= number then + return nil + end + + return number +end + +local function isValidStateToken(token) + token = trim(token) + return token ~= "" and #token <= 64 and string.match(token, "^[%w%-%_%.:]+$") ~= nil +end + local function getPlayerName() if type(UnitName) ~= "function" then return nil @@ -96,6 +200,15 @@ local function ensureBridgeState() state.lastError = state.lastError or nil state.roster = state.roster or {} state.states = state.states or {} + state.stateSeq = state.stateSeq or 0 + state.stateRequests = state.stateRequests or {} + state.stateActive = state.stateActive or {} + state.stateLatestByBot = state.stateLatestByBot or {} + state.stateGlobalLatestToken = state.stateGlobalLatestToken or nil + state.stateFramingCapable = state.stateFramingCapable or false + state.strategyMutationCapable = state.strategyMutationCapable or false + state.strategyMutationSeq = state.strategyMutationSeq or 0 + state.strategyMutationCommands = state.strategyMutationCommands or {} state.details = state.details or {} state.professions = state.professions or {} state.pvpStats = state.pvpStats or {} @@ -159,6 +272,100 @@ local function ensureBridgeState() return state end +local function countTableEntries(values) + local count = 0 + for _ in pairs(values or {}) do + count = count + 1 + end + return count +end + +local function stateTransactionKey(token, botName) + return tostring(token or "") .. "\031" .. string.lower(tostring(botName or "")) +end + +local function clearStateTransactionsForToken(state, token) + for key, transaction in pairs(state.stateActive or {}) do + if type(transaction) == "table" and transaction.token == token then + state.stateActive[key] = nil + end + end +end + +local function clearStateRequest(state, token) + local request = state.stateRequests and state.stateRequests[token] or nil + if type(request) == "table" then + if request.global then + if state.stateGlobalLatestToken == token then + state.stateGlobalLatestToken = nil + end + else + local botKey = string.lower(request.botName or "") + if state.stateLatestByBot[botKey] == token then + state.stateLatestByBot[botKey] = nil + end + end + end + + clearStateTransactionsForToken(state, token) + state.stateRequests[token] = nil +end + +local function scheduleStateTimeout(token) + if not (MultiBot and type(MultiBot.TimerAfter) == "function") then + return + end + + MultiBot.TimerAfter(STATE_TIMEOUT_SECONDS, function() + local state = ensureBridgeState() + if not state.stateRequests[token] then + return + end + + clearStateRequest(state, token) + state.lastError = "STATE_TIMEOUT~" .. token + end) +end + +local function beginStateRequest(state, botName, isGlobal) + if countTableEntries(state.stateRequests) >= STATE_MAX_ACTIVE then + return nil + end + + state.stateSeq = (tonumber(state.stateSeq) or 0) + 1 + local suffix = isGlobal and "states" or "state" + local token = tostring(math.floor(safeNow() * 1000)) .. "-" .. suffix .. "-" .. tostring(state.stateSeq) + + state.stateRequests[token] = { + token = token, + botName = botName or "", + global = isGlobal == true, + startedAt = safeNow(), + begun = false, + expectedBots = 0, + completedBots = 0, + completedBotKeys = {}, + } + + if isGlobal then + local previous = state.stateGlobalLatestToken + if previous and previous ~= token then + clearStateRequest(state, previous) + end + state.stateGlobalLatestToken = token + else + local botKey = string.lower(botName or "") + local previous = state.stateLatestByBot[botKey] + if previous and previous ~= token then + clearStateRequest(state, previous) + end + state.stateLatestByBot[botKey] = token + end + + scheduleStateTimeout(token) + return token +end + local function debugPrint(...) if MultiBot and MultiBot.dprint then MultiBot.dprint(...) @@ -239,16 +446,46 @@ function Comm.RequestRoster() end function Comm.RequestState(name) + local state = ensureBridgeState() name = trim(name) if name == "" then return false end - return Comm.Send("GET", "STATE~" .. name) + if not state.stateFramingCapable then + return Comm.Send("GET", "STATE~" .. name) + end + + local token = beginStateRequest(state, name, false) + if not token then + state.lastError = "STATE_TOO_MANY_REQUESTS" + return false + end + if not Comm.Send("GET", "STATE~" .. urlEncodeField(name) .. "~" .. token) then + clearStateRequest(state, token) + return false + end + + return token end function Comm.RequestStates() - return Comm.Send("GET", "STATES") + local state = ensureBridgeState() + if not state.stateFramingCapable then + return Comm.Send("GET", "STATES") + end + + local token = beginStateRequest(state, "", true) + if not token then + state.lastError = "STATE_TOO_MANY_REQUESTS" + return false + end + if not Comm.Send("GET", "STATES~" .. token) then + clearStateRequest(state, token) + return false + end + + return token end function Comm.RequestBotDetail(name) @@ -353,6 +590,151 @@ function Comm.RunCombatCommand(scope, target, command) return Comm.Send("RUN", "COMBAT~" .. scope .. "~" .. urlEncodeField(target) .. "~" .. token .. "~" .. urlEncodeField(command)) end +local function validateStrategyMutationChanges(changes) + changes = trim(changes or "") + if changes == "" or #changes > STRATEGY_MUTATION_MAX_CHANGES_LENGTH then + return nil + end + + local normalized = {} + local startIndex = 1 + + while true do + local separatorIndex = string.find(changes, ",", startIndex, true) + local operation + if separatorIndex then + operation = string.sub(changes, startIndex, separatorIndex - 1) + else + operation = string.sub(changes, startIndex) + end + + operation = trim(operation) + local prefix = string.sub(operation, 1, 1) + local strategy = string.lower(trim(string.sub(operation, 2))) + + if (prefix ~= "+" and prefix ~= "-") + or strategy == "" + or #strategy > STRATEGY_MUTATION_MAX_STRATEGY_LENGTH + or string.find(strategy, "[^%w%s%-%_']") then + return nil + end + + normalized[#normalized + 1] = prefix .. strategy + if #normalized > STRATEGY_MUTATION_MAX_OPERATIONS then + return nil + end + + if not separatorIndex then + break + end + startIndex = separatorIndex + 1 + end + + if #normalized == 0 then + return nil + end + + return table.concat(normalized, ",") +end + +local function finishStrategyMutationCommand(token, result) + local state = ensureBridgeState() + local pending = state.strategyMutationCommands[token] + if type(pending) ~= "table" then + return false + end + + state.strategyMutationCommands[token] = nil + result = type(result) == "table" and result or {} + result.token = token + result.scope = result.scope or pending.scope + result.target = result.target or pending.target + result.stateScope = result.stateScope or pending.stateScope + result.changes = result.changes or pending.changes + + if type(pending.callback) == "function" then + pending.callback(result) + end + + if MultiBot.OnStrategyMutationApplied then + MultiBot.OnStrategyMutationApplied(result) + end + + return true +end + +function Comm.RunStrategyCommand(scope, target, stateScope, changes, callback) + local state = ensureBridgeState() + + if not state.connected or not state.strategyMutationCapable then + return false + end + + scope = string.upper(trim(scope or "BOT")) + target = trim(target or "") + stateScope = string.upper(trim(stateScope or "")) + changes = validateStrategyMutationChanges(changes) + + if scope ~= "ALL" and scope ~= "GROUP" and scope ~= "PARTY" and scope ~= "RAID" and scope ~= "BOT" then + return false + end + if scope == "BOT" and target == "" then + return false + end + if scope ~= "BOT" and target ~= "" then + return false + end + if stateScope ~= "C" and stateScope ~= "N" then + return false + end + if not changes or countTableEntries(state.strategyMutationCommands) >= STRATEGY_MUTATION_MAX_ACTIVE then + return false + end + + state.strategyMutationSeq = (tonumber(state.strategyMutationSeq) or 0) + 1 + local token = tostring(math.floor(safeNow() * 1000)) .. "-strategy-" .. tostring(state.strategyMutationSeq) + state.strategyMutationCommands[token] = { + scope = scope, + target = target, + stateScope = stateScope, + changes = changes, + callback = type(callback) == "function" and callback or nil, + startedAt = safeNow(), + } + + local payload = "STRATEGY~" + .. scope .. "~" + .. urlEncodeField(target) .. "~" + .. token .. "~" + .. stateScope .. "~" + .. urlEncodeField(changes) + + if not Comm.Send("RUN", payload) then + state.strategyMutationCommands[token] = nil + return false + end + + if MultiBot and type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(STRATEGY_MUTATION_TIMEOUT_SECONDS, function() + local bridge = ensureBridgeState() + if not bridge.strategyMutationCommands[token] then + return + end + + bridge.lastError = "STRATEGY_TIMEOUT~" .. token + finishStrategyMutationCommand(token, { + status = "timeout", + matched = 0, + succeeded = 0, + failed = 0, + reason = "TIMEOUT", + }) + end) + end + + return token +end + function Comm.RunLootCommand(scope, target, command) local state = ensureBridgeState() @@ -1120,6 +1502,8 @@ function Comm.MarkDisconnected(reason) state.trainerCommands = {} state.formationCommands = {} state.formationQueryActive = nil + state.strategyMutationCapable = false + state.strategyMutationCommands = {} end local function parseBridgeDetailPayload(payload) @@ -1219,11 +1603,8 @@ function Comm.ApplyRosterPayload(payload) return roster end -function Comm.ApplyStatePayload(payload) +local function applyStateEntry(name, combat, normal) local state = ensureBridgeState() - local name, rest = splitOnce(payload or "", "~") - local combat, normal = splitOnce(rest or "", "~") - name = trim(name) if name == "" then return nil @@ -1246,6 +1627,12 @@ function Comm.ApplyStatePayload(payload) return entry end +function Comm.ApplyStatePayload(payload) + local name, rest = splitOnce(payload or "", "~") + local combat, normal = splitOnce(rest or "", "~") + return applyStateEntry(name, combat, normal) +end + function Comm.ApplyStatesPayload(payload) local applied = 0 @@ -1261,6 +1648,263 @@ function Comm.ApplyStatesPayload(payload) return applied end +local function getStateRequest(token) + local state = ensureBridgeState() + token = trim(token) + if not isValidStateToken(token) then + return nil + end + return state.stateRequests[token] +end + +local function abortStateRequest(token, reason) + local state = ensureBridgeState() + token = trim(token) + if token == "" then + return false + end + + clearStateRequest(state, token) + state.lastError = "STATE_ABORT~" .. token .. "~" .. tostring(reason or "UNKNOWN") + debugPrint("ADDON:RX", "STATE_ABORT", token, reason or "UNKNOWN") + return true +end + +function Comm.ApplyStateBeginPayload(payload) + local fields = splitFields(payload) + if #fields ~= 4 then + return false + end + + local token = trim(fields[1]) + local botName = urlDecodeFieldStrict(fields[2], 64, false) + local combatCount = parseBoundedInteger(fields[3], 0, STATE_MAX_STRATEGIES_PER_SCOPE) + local normalCount = parseBoundedInteger(fields[4], 0, STATE_MAX_STRATEGIES_PER_SCOPE) + local state = ensureBridgeState() + local request = getStateRequest(token) + + if not request or not botName or combatCount == nil or normalCount == nil then + return false + end + + local botKey = string.lower(botName) + if request.global then + if not request.begun then + return abortStateRequest(token, "GLOBAL_NOT_BEGUN") + end + if request.completedBotKeys[botKey] then + return abortStateRequest(token, "DUPLICATE_BOT") + end + elseif string.lower(request.botName or "") ~= botKey then + return abortStateRequest(token, "BOT_MISMATCH") + end + + local key = stateTransactionKey(token, botName) + if state.stateActive[key] then + return abortStateRequest(token, "DUPLICATE_BEGIN") + end + + if countTableEntries(state.stateActive) >= STATE_MAX_ACTIVE then + return abortStateRequest(token, "TOO_MANY_ACTIVE") + end + + state.stateActive[key] = { + token = token, + botName = botName, + botKey = botKey, + combatExpected = combatCount, + normalExpected = normalCount, + combatReceived = 0, + normalReceived = 0, + combat = {}, + normal = {}, + totalBytes = 0, + } + + debugPrint("ADDON:RX", "STATE_BEGIN", token, botName, combatCount, normalCount) + return true +end + +function Comm.ApplyStateItemPayload(payload) + local fields = splitFields(payload) + if #fields ~= 5 then + return false + end + + local token = trim(fields[1]) + local botName = urlDecodeFieldStrict(fields[2], 64, false) + local scope = string.upper(trim(fields[3])) + local index = parseBoundedInteger(fields[4], 1, STATE_MAX_STRATEGIES_PER_SCOPE) + local strategy = urlDecodeFieldStrict(fields[5], STATE_MAX_STRATEGY_LENGTH, false) + if not botName or not index or not strategy or (scope ~= "C" and scope ~= "N") then + return false + end + + local state = ensureBridgeState() + local transaction = state.stateActive[stateTransactionKey(token, botName)] + if not transaction then + return false + end + + local expected = scope == "C" and transaction.combatExpected or transaction.normalExpected + local items = scope == "C" and transaction.combat or transaction.normal + if index > expected then + return abortStateRequest(token, "INDEX_OUT_OF_RANGE") + end + + if items[index] ~= nil then + if items[index] == strategy then + return true + end + return abortStateRequest(token, "CONFLICTING_DUPLICATE") + end + + transaction.totalBytes = transaction.totalBytes + #strategy + if transaction.totalBytes > STATE_MAX_TOTAL_BYTES then + return abortStateRequest(token, "STATE_TOO_LARGE") + end + + items[index] = strategy + if scope == "C" then + transaction.combatReceived = transaction.combatReceived + 1 + else + transaction.normalReceived = transaction.normalReceived + 1 + end + + debugPrint("ADDON:RX", "STATE_ITEM", token, botName, scope, index, strategy) + return true +end + +function Comm.ApplyStateEndPayload(payload) + local fields = splitFields(payload) + if #fields ~= 4 then + return false + end + + local token = trim(fields[1]) + local botName = urlDecodeFieldStrict(fields[2], 64, false) + local combatCount = parseBoundedInteger(fields[3], 0, STATE_MAX_STRATEGIES_PER_SCOPE) + local normalCount = parseBoundedInteger(fields[4], 0, STATE_MAX_STRATEGIES_PER_SCOPE) + if not botName or combatCount == nil or normalCount == nil then + return false + end + + local state = ensureBridgeState() + local key = stateTransactionKey(token, botName) + local transaction = state.stateActive[key] + local request = getStateRequest(token) + if not transaction or not request then + return false + end + + if combatCount ~= transaction.combatExpected or normalCount ~= transaction.normalExpected or + transaction.combatReceived ~= combatCount or transaction.normalReceived ~= normalCount then + return abortStateRequest(token, "COUNT_MISMATCH") + end + + for index = 1, combatCount do + if transaction.combat[index] == nil then + return abortStateRequest(token, "MISSING_COMBAT_ITEM") + end + end + for index = 1, normalCount do + if transaction.normal[index] == nil then + return abortStateRequest(token, "MISSING_NORMAL_ITEM") + end + end + + if request.global then + if state.stateGlobalLatestToken ~= token then + return abortStateRequest(token, "STALE_GLOBAL") + end + elseif state.stateLatestByBot[transaction.botKey] ~= token then + return abortStateRequest(token, "STALE_BOT") + end + + local entry = applyStateEntry(transaction.botName, table.concat(transaction.combat, ", "), table.concat(transaction.normal, ", ")) + if not entry then + return abortStateRequest(token, "APPLY_FAILED") + end + + state.stateActive[key] = nil + if request.global then + if not request.completedBotKeys[transaction.botKey] then + request.completedBotKeys[transaction.botKey] = true + request.completedBots = request.completedBots + 1 + end + else + clearStateRequest(state, token) + end + + debugPrint("ADDON:RX", "STATE_END", token, botName, combatCount, normalCount) + return true +end + +function Comm.ApplyStateAbortPayload(payload) + local fields = splitFields(payload) + if #fields ~= 3 then + return false + end + + local token = trim(fields[1]) + local reason = urlDecodeFieldStrict(fields[3], 64, false) or "UNKNOWN" + if not getStateRequest(token) then + return false + end + + return abortStateRequest(token, reason) +end + +function Comm.ApplyStatesBeginPayload(payload) + local fields = splitFields(payload) + if #fields ~= 2 then + return false + end + + local token = trim(fields[1]) + local botCount = parseBoundedInteger(fields[2], 0, STATE_MAX_BOTS) + local request = getStateRequest(token) + if not request or not request.global or botCount == nil or request.begun then + return false + end + + request.begun = true + request.expectedBots = botCount + request.completedBots = 0 + request.completedBotKeys = {} + debugPrint("ADDON:RX", "STATES_BEGIN", token, botCount) + return true +end + +function Comm.ApplyStatesEndPayload(payload) + local fields = splitFields(payload) + if #fields ~= 2 then + return false + end + + local token = trim(fields[1]) + local sentCount = parseBoundedInteger(fields[2], 0, STATE_MAX_BOTS) + local state = ensureBridgeState() + local request = getStateRequest(token) + if not request or not request.global or not request.begun or sentCount == nil then + return false + end + + for _, transaction in pairs(state.stateActive) do + if type(transaction) == "table" and transaction.token == token then + return abortStateRequest(token, "INCOMPLETE_TRANSACTION") + end + end + + if sentCount ~= request.expectedBots or request.completedBots ~= request.expectedBots then + return abortStateRequest(token, "GLOBAL_COUNT_MISMATCH") + end + + clearStateRequest(state, token) + debugPrint("ADDON:RX", "STATES_END", token, sentCount) + return true +end + function Comm.ApplyBotDetailPayload(payload) local state = ensureBridgeState() local detail = parseBridgeDetailPayload(payload) @@ -2487,6 +3131,21 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) return true end + if opcode == "CAPS" then + state.stateFramingCapable = false + state.strategyMutationCapable = false + for capability in string.gmatch(payload or "", "([^,]+)") do + capability = trim(capability) + if capability == STATE_FRAMING_CAPABILITY then + state.stateFramingCapable = true + elseif capability == STRATEGY_MUTATION_CAPABILITY then + state.strategyMutationCapable = true + end + end + debugPrint("ADDON:RX", "CAPS", payload or "") + return true + end + if opcode == "ROSTER" then state.connected = true state.lastError = nil @@ -2494,6 +3153,47 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) return true end + if opcode == "STATE_BEGIN" then + state.connected = true + state.lastError = nil + Comm.ApplyStateBeginPayload(payload) + return true + end + + if opcode == "STATE_ITEM" then + state.connected = true + state.lastError = nil + Comm.ApplyStateItemPayload(payload) + return true + end + + if opcode == "STATE_END" then + state.connected = true + state.lastError = nil + Comm.ApplyStateEndPayload(payload) + return true + end + + if opcode == "STATE_ABORT" then + state.connected = true + Comm.ApplyStateAbortPayload(payload) + return true + end + + if opcode == "STATES_BEGIN" then + state.connected = true + state.lastError = nil + Comm.ApplyStatesBeginPayload(payload) + return true + end + + if opcode == "STATES_END" then + state.connected = true + state.lastError = nil + Comm.ApplyStatesEndPayload(payload) + return true + end + if opcode == "STATE" then state.connected = true state.lastError = nil @@ -3384,6 +4084,66 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) return true end + if opcode == "STRATEGY_ACK" then + local fields = splitFields(payload or "") + if #fields ~= 8 then + state.lastError = "STRATEGY_ACK_BAD_FIELD_COUNT" + return true + end + + local scope = string.upper(trim(fields[1])) + local target = urlDecodeFieldStrict(fields[2], 64, true) + local token = trim(fields[3]) + local stateScope = string.upper(trim(fields[4])) + local matched = parseBoundedInteger(fields[5], 0, 128) + local succeeded = parseBoundedInteger(fields[6], 0, 128) + local failed = parseBoundedInteger(fields[7], 0, 128) + local reason = urlDecodeFieldStrict(fields[8], 64, false) + + local pending = state.strategyMutationCommands[token] + if (scope ~= "ALL" and scope ~= "GROUP" and scope ~= "PARTY" and scope ~= "RAID" and scope ~= "BOT") + or target == nil + or not isValidStateToken(token) + or (stateScope ~= "C" and stateScope ~= "N") + or matched == nil + or succeeded == nil + or failed == nil + or reason == nil + or succeeded + failed > matched + or type(pending) ~= "table" + or pending.scope ~= scope + or string.lower(pending.target or "") ~= string.lower(target) + or pending.stateScope ~= stateScope then + state.lastError = "STRATEGY_ACK_INVALID" + return true + end + + state.connected = true + state.lastError = nil + debugPrint("ADDON:RX", "STRATEGY_ACK", payload or "") + + local status = "failed" + if matched == 0 then + status = "no_match" + elseif succeeded == matched and failed == 0 then + status = "ok" + elseif succeeded > 0 then + status = "partial" + end + + finishStrategyMutationCommand(token, { + status = status, + scope = scope, + target = target, + stateScope = stateScope, + matched = matched, + succeeded = succeeded, + failed = failed, + reason = reason, + }) + return true + end + if opcode == "RTI_ACK" then state.connected = true state.lastError = nil @@ -3476,6 +4236,23 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) if opcode == "ERR" then state.lastError = payload debugPrint("ADDON:RX", "ERR", payload or "") + + local fields = splitFields(payload or "") + if #fields == 4 then + local requestType = string.upper(trim(urlDecodeField(fields[2]))) + local token = trim(fields[3]) + local reason = trim(urlDecodeField(fields[4])) + if requestType == "STRATEGY" and state.strategyMutationCommands[token] then + finishStrategyMutationCommand(token, { + status = "error", + matched = 0, + succeeded = 0, + failed = 0, + reason = reason ~= "" and reason or "PROTOCOL_ERROR", + }) + end + end + return true end @@ -3498,6 +4275,13 @@ end function Comm.OnPlayerEnteringWorld() local state = ensureBridgeState() state.states = {} + state.stateRequests = {} + state.stateActive = {} + state.stateLatestByBot = {} + state.stateGlobalLatestToken = nil + state.stateFramingCapable = false + state.strategyMutationCapable = false + state.strategyMutationCommands = {} state.details = {} state.stats = {} state.pvpStats = {} diff --git a/Core/MultiBotEngine.lua b/Core/MultiBotEngine.lua index 6244efb..c4f53cd 100644 --- a/Core/MultiBotEngine.lua +++ b/Core/MultiBotEngine.lua @@ -474,10 +474,62 @@ MultiBot.SpellToMacro = function(pName, pSpell, pTexture) PickupMacro(tMacro) end +local function _mbParseStrategyMutation(action) + if(type(action) ~= "string") then return nil end + + local scope, changes = string.match(action, "^%s*(%a+)%s+(.+)%s*$") + scope = scope and string.lower(scope) or nil + if(scope ~= "co" and scope ~= "nc") then return nil end + + changes = string.gsub(changes or "", ",%?%s*$", "") + changes = string.gsub(changes, "^%s+", "") + changes = string.gsub(changes, "%s+$", "") + if(not string.match(changes, "^[+-]")) then return nil end + + return scope, changes +end + +local function _mbCanUseBridgeStrategyMutation() + return MultiBot.bridge + and MultiBot.bridge.connected == true + and MultiBot.bridge.strategyMutationCapable == true + and MultiBot.Comm + and type(MultiBot.Comm.RunStrategyCommand) == "function" +end + +local function _mbRunBridgeStrategyMutation(action, commandScope, target) + if(not _mbCanUseBridgeStrategyMutation()) then return false end + + local mutationScope, changes = _mbParseStrategyMutation(action) + if(not mutationScope) then return false end + + commandScope = string.upper(commandScope or "BOT") + target = target or "" + local stateScope = mutationScope == "nc" and "N" or "C" + + local token = MultiBot.Comm.RunStrategyCommand(commandScope, target, stateScope, changes, function(result) + if(type(result) ~= "table" or (tonumber(result.matched) or 0) <= 0) then return end + + if(commandScope == "BOT") then + if(MultiBot.Comm and MultiBot.Comm.RequestState) then + MultiBot.Comm.RequestState(target) + end + elseif(MultiBot.Comm and MultiBot.Comm.RequestStates) then + MultiBot.Comm.RequestStates() + end + end) + + return token ~= false and token ~= nil +end + MultiBot.ActionToTarget = function(pAction, oTarget) local tName = MultiBot.IF(oTarget == nil, UnitName("target"), oTarget) if(tName ~= nil and tName ~= "Unknown Entity") then + if(_mbRunBridgeStrategyMutation(pAction, "BOT", tName)) then + return true + end + SendChatMessage(pAction, "WHISPER", nil, tName) return true end @@ -490,16 +542,21 @@ MultiBot.ActionToTargetOrGroup = function(pAction) local tName = UnitName("target") if(tName ~= nil and tName ~= "Unknown Entity") then - SendChatMessage(pAction, "WHISPER", nil, tName) - return true + return MultiBot.ActionToTarget(pAction, tName) end if(GetNumRaidMembers() > 5) then + if(_mbRunBridgeStrategyMutation(pAction, "RAID", "")) then + return true + end SendChatMessage(pAction, "RAID") return true end if(GetNumPartyMembers() > 0) then + if(_mbRunBridgeStrategyMutation(pAction, "PARTY", "")) then + return true + end SendChatMessage(pAction, "PARTY") return true end @@ -510,11 +567,17 @@ end MultiBot.ActionToGroup = function(pAction) if(GetNumRaidMembers() > 5) then + if(_mbRunBridgeStrategyMutation(pAction, "RAID", "")) then + return true + end SendChatMessage(pAction, "RAID") return true end if(GetNumPartyMembers() > 0) then + if(_mbRunBridgeStrategyMutation(pAction, "PARTY", "")) then + return true + end SendChatMessage(pAction, "PARTY") return true end @@ -856,85 +919,21 @@ local function _mbGetStrategyUnitButton(target) return units.buttons[target] end -local function _mbGetStrategyMutationScope(action) - if(type(action) ~= "string") then return nil end - local scope = string.match(string.lower(action), "^%s*(%a+)%s+[+-]") - if(scope == "co" or scope == "nc") then return scope end - return nil -end - -local function _mbStripStrategyQuerySuffix(action) - if(type(action) ~= "string") then return action end - return (string.gsub(action, ",%?%s*$", "")) -end - -local function _mbGetBridgeStateTimestamp(target) - local bridge = MultiBot.bridge - local states = bridge and bridge.states - local entry = states and states[string.lower(target or "")] - return entry and tonumber(entry.lastUpdateAt) or 0 -end - -local function _mbScheduleStrategyStateRefresh(target, scope) - if(type(target) ~= "string" or target == "") then return end - if(not (MultiBot.Comm and MultiBot.Comm.RequestState)) then return end - scope = (scope == "nc") and "nc" or "co" - - MultiBot._strategySyncSequence = MultiBot._strategySyncSequence or {} - local sequence = (MultiBot._strategySyncSequence[target] or 0) + 1 - MultiBot._strategySyncSequence[target] = sequence - local previousUpdateAt = _mbGetBridgeStateTimestamp(target) - - local function isCurrent() - return MultiBot._strategySyncSequence - and MultiBot._strategySyncSequence[target] == sequence - end - - local function requestState() - if(not isCurrent()) then return end - MultiBot.Comm.RequestState(target) - end - - local function legacyFallback() - if(not isCurrent()) then return end - if(_mbGetBridgeStateTimestamp(target) > previousUpdateAt) then return end - - local unitButton = _mbGetStrategyUnitButton(target) - if(not unitButton) then return end - unitButton.waitFor = string.upper(scope) - SendChatMessage(scope .. " ?", "WHISPER", nil, target) - end - - if(type(MultiBot.TimerAfter) == "function") then - MultiBot.TimerAfter(0.45, requestState) - MultiBot.TimerAfter(1.00, requestState) - MultiBot.TimerAfter(1.80, legacyFallback) - else - requestState() - end -end - MultiBot.OnOffActionToTarget = function(pButton, pOn, pOff, pTarget) - local action = pButton.state and pOff or pOn - local scope = _mbGetStrategyMutationScope(action) - local bridgeSync = scope ~= nil - and MultiBot.bridge - and MultiBot.bridge.connected == true - and MultiBot.Comm - and MultiBot.Comm.RequestState + local wasEnabled = pButton.state == true + local action = wasEnabled and pOff or pOn + local mutationScope = _mbParseStrategyMutation(action) - if(bridgeSync) then - if(MultiBot.ActionToTarget(_mbStripStrategyQuerySuffix(action), pTarget)) then - _mbScheduleStrategyStateRefresh(pTarget, scope) - end - -- L'état visuel est reconstruit depuis l'état réel du bot. - return false + if(mutationScope and _mbRunBridgeStrategyMutation(action, "BOT", pTarget)) then + -- Conserve la sémantique historique du retour pour tous les appelants co/nc. + -- L'état visuel définitif reste reconstruit depuis l'ACK puis STATE. + return not wasEnabled end local unitButton = _mbGetStrategyUnitButton(pTarget) - if(scope and unitButton) then unitButton.waitFor = string.upper(scope) end + if(mutationScope and unitButton) then unitButton.waitFor = string.upper(mutationScope) end - if(pButton.state) then + if(wasEnabled) then MultiBot.ActionToTarget(pOff, pTarget) pButton.setDisable() return false diff --git a/UI/MultiBotUnitsRootUI.lua b/UI/MultiBotUnitsRootUI.lua index 1b0e1ac..2456e69 100644 --- a/UI/MultiBotUnitsRootUI.lua +++ b/UI/MultiBotUnitsRootUI.lua @@ -299,6 +299,14 @@ local function refreshUnitsDisplay(unitsButton, requestedRoster, requestedFilter unitsButton.filter = requestedFilter end + if unitsButton.roster == "favorites" then + if MultiBot.UpdateFavoritesIndex then + MultiBot.UpdateFavoritesIndex() + elseif MultiBot.EnsureFavoriteButtons then + MultiBot.EnsureFavoriteButtons() + end + end + if requestedRoster == "players" or unitsButton.roster == "players" then if MultiBot.bridge and MultiBot.bridge.connected then if MultiBot.bridge.roster and #MultiBot.bridge.roster > 0 then @@ -1010,6 +1018,12 @@ function MultiBot.InitializeUnitsRootUI(tMultiBar) MultiBot.BuildRTIControlUI(controlFrame) end + if MultiBot.UpdateFavoritesIndex then + MultiBot.UpdateFavoritesIndex() + elseif MultiBot.EnsureFavoriteButtons then + MultiBot.EnsureFavoriteButtons() + end + if MultiBot.bridge and MultiBot.bridge.roster and #MultiBot.bridge.roster > 0 then if MultiBot.SyncBridgeRosterToPlayers then MultiBot.SyncBridgeRosterToPlayers(MultiBot.bridge.roster) From 0f7622383b25269c2637f2ce910a8b9f92a2649d Mon Sep 17 00:00:00 2001 From: Wishmaster117 <140754794+Wishmaster117@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:27:23 +0200 Subject: [PATCH 2/4] Fix stale global state ordering in addon synchronization --- Core/MultiBotComm.lua | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index e862e87..b0ef2e2 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -204,6 +204,7 @@ local function ensureBridgeState() state.stateRequests = state.stateRequests or {} state.stateActive = state.stateActive or {} state.stateLatestByBot = state.stateLatestByBot or {} + state.stateLatestOrderByBot = state.stateLatestOrderByBot or {} state.stateGlobalLatestToken = state.stateGlobalLatestToken or nil state.stateFramingCapable = state.stateFramingCapable or false state.strategyMutationCapable = state.strategyMutationCapable or false @@ -340,6 +341,7 @@ local function beginStateRequest(state, botName, isGlobal) token = token, botName = botName or "", global = isGlobal == true, + order = state.stateSeq, startedAt = safeNow(), begun = false, expectedBots = 0, @@ -466,6 +468,16 @@ function Comm.RequestState(name) return false end + local request = state.stateRequests[token] + if type(request) == "table" then + local botKey = string.lower(name) + local requestOrder = tonumber(request.order) or 0 + local latestOrder = tonumber(state.stateLatestOrderByBot[botKey]) or 0 + if requestOrder > latestOrder then + state.stateLatestOrderByBot[botKey] = requestOrder + end + end + return token end @@ -1821,6 +1833,25 @@ function Comm.ApplyStateEndPayload(payload) return abortStateRequest(token, "STALE_BOT") end + local requestOrder = tonumber(request.order) or 0 + local latestOrder = tonumber(state.stateLatestOrderByBot[transaction.botKey]) or 0 + if requestOrder < latestOrder then + state.stateActive[key] = nil + if request.global then + if not request.completedBotKeys[transaction.botKey] then + request.completedBotKeys[transaction.botKey] = true + request.completedBots = request.completedBots + 1 + end + else + clearStateRequest(state, token) + end + + debugPrint("ADDON:RX", "STATE_END_STALE", token, botName, requestOrder, latestOrder) + return true + end + + state.stateLatestOrderByBot[transaction.botKey] = requestOrder + local entry = applyStateEntry(transaction.botName, table.concat(transaction.combat, ", "), table.concat(transaction.normal, ", ")) if not entry then return abortStateRequest(token, "APPLY_FAILED") @@ -4278,6 +4309,7 @@ function Comm.OnPlayerEnteringWorld() state.stateRequests = {} state.stateActive = {} state.stateLatestByBot = {} + state.stateLatestOrderByBot = {} state.stateGlobalLatestToken = nil state.stateFramingCapable = false state.strategyMutationCapable = false From 197f72dc23c0a4bc87d9f7d642dae2fa3b5ba056 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:16:01 +0200 Subject: [PATCH 3/4] Hotfix --- .luacheckrc | 2 +- Core/MultiBot.lua | 70 ++++++++++++++++++++++++----------------- Core/MultiBotComm.lua | 21 +++++++++++++ Core/MultiBotEngine.lua | 3 +- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index ca5fb32..d283814 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -33,7 +33,7 @@ globals = { "sendInventoryItemCommand", "LE_ITEM_CLASS_QUESTITEM", "ITEMS", "LOADING", "QUEST_LOG", "INSPECT", "SPELLBOOK", "MB_TAB_TITLE_DEFAULT", "ensureHiddenTooltip", "IsInGuild", "GetGuildInfo", "GetGuildRosterShowOffline", "SetGuildRosterShowOffline", "PLAYER","Ambiguate", "ChatFrame_AddMessageEventFilter", "ChatTypeInfo", "x", "y", "CLASS_ICON_TCOORDS", "GetLootSlotLink", "GetLootSlotInfo", "GetLootMethod", "GetMasterLootCandidate", "LOCALIZED_CLASS_NAMES_MALE", "LOCALIZED_CLASS_NAMES_FEMALE", "date", "LootSlotIsCoin", "LootSlotIsItem", "classColor", "GetNumLootItems", "GetItemQualityColor", "GiveMasterLoot", "GetLootThreshold", - "QuestFrameRewardPanel", "QuestFrame", "GetFactionInfoByID", "PanelTemplates_SetTab", "PanelTemplates_SetNumTabs", "ABANDON_QUEST", "GetCoinTextureString" + "QuestFrameRewardPanel", "QuestFrame", "GetFactionInfoByID", "PanelTemplates_SetTab", "PanelTemplates_SetNumTabs", "ABANDON_QUEST", "GetCoinTextureString", "InviteUnit" } diff --git a/Core/MultiBot.lua b/Core/MultiBot.lua index 952600b..aaa3661 100644 --- a/Core/MultiBot.lua +++ b/Core/MultiBot.lua @@ -1332,7 +1332,7 @@ local function IsBridgeRosterBotActive(botName) end -- HOTFIX FAVORITES METADATA + ROSTER SYNC V1 START -local FAVORITE_ROSTER_REFRESH_DELAYS = { 0, 0.8, 1.8, 3.2, 5.0, 7.5 } +local FAVORITE_ROSTER_REFRESH_DELAYS = { 0, 0.8, 1.8, 3.2, 5.0, 7.5, 9.5 } local FAVORITE_ROSTER_REFRESH_TTL = 10.0 local function GetFavoriteRosterRefreshNow() @@ -1361,26 +1361,19 @@ local function GetFavoriteRosterRefreshState() end local state = MultiBot._favoriteRosterRefresh - state.generation = tonumber(state.generation or 0) or 0 + state.sequence = tonumber(state.sequence or 0) or 0 state.targets = type(state.targets) == "table" and state.targets or {} return state end -local function PruneFavoriteRosterRefreshTargets(state, roster) - local visible = {} - - for _, entry in ipairs(type(roster) == "table" and roster or {}) do - if type(entry) == "table" and type(entry.name) == "string" and entry.name ~= "" then - visible[NormalizeFavoriteRosterRefreshName(entry.name)] = true - end - end - +local function PruneFavoriteRosterRefreshTargets(state) local now = GetFavoriteRosterRefreshNow() local unresolved = 0 for key, target in pairs(state.targets) do - if visible[key] or type(target) ~= "table" - or tonumber(target.expiresAt or 0) <= now then + if type(target) ~= "table" + or tonumber(target.expiresAt or 0) <= now + or IsBridgeRosterBotActive(target.name) then state.targets[key] = nil else unresolved = unresolved + 1 @@ -1390,9 +1383,9 @@ local function PruneFavoriteRosterRefreshTargets(state, roster) return unresolved end -function MultiBot.ObserveFavoriteRosterRefresh(roster) +function MultiBot.ObserveFavoriteRosterRefresh(_) local state = GetFavoriteRosterRefreshState() - return PruneFavoriteRosterRefreshTargets(state, roster) + return PruneFavoriteRosterRefreshTargets(state) end function MultiBot.BeginFavoriteRosterRefresh(name) @@ -1409,23 +1402,23 @@ function MultiBot.BeginFavoriteRosterRefresh(name) local key = NormalizeFavoriteRosterRefreshName(name) local now = GetFavoriteRosterRefreshNow() + state.sequence = state.sequence + 1 + local generation = state.sequence + state.targets[key] = { name = name, expiresAt = now + FAVORITE_ROSTER_REFRESH_TTL, + generation = generation, } - state.generation = state.generation + 1 - local generation = state.generation - local function requestRoster(attempt) - if state.generation ~= generation then + local target = state.targets[key] + if type(target) ~= "table" or target.generation ~= generation then return end - if PruneFavoriteRosterRefreshTargets( - state, - MultiBot.bridge and MultiBot.bridge.roster - ) == 0 then + if tonumber(target.expiresAt or 0) <= GetFavoriteRosterRefreshNow() then + state.targets[key] = nil return end @@ -1435,10 +1428,7 @@ function MultiBot.BeginFavoriteRosterRefresh(name) end if attempt >= #FAVORITE_ROSTER_REFRESH_DELAYS then - PruneFavoriteRosterRefreshTargets( - state, - MultiBot.bridge and MultiBot.bridge.roster - ) + PruneFavoriteRosterRefreshTargets(state) end end @@ -1468,6 +1458,13 @@ local function UpdateBridgeUnitButton(button, className, level, name) classCanon = "UNKNOWN" end + if string.lower(classCanon) == "unknown" + and type(button.class) == "string" + and button.class ~= "" + and string.lower(button.class) ~= "unknown" then + classCanon = button.class + end + local texture = "Interface\\Icons\\INV_Misc_QuestionMark" if string.lower(classCanon) ~= "unknown" then texture = "Interface\\AddOns\\MultiBot\\Icons\\class_" @@ -1889,6 +1886,13 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) local buttons = units.buttons or {} local frames = units.frames or {} local visibleNames = {} + local previousActive = {} + + for _, activeName in ipairs(MultiBot.index.actives or {}) do + if type(activeName) == "string" and activeName ~= "" then + previousActive[string.lower(activeName)] = true + end + end local playerName = nil if type(UnitName) == "function" then @@ -1962,6 +1966,16 @@ function MultiBot.SyncBridgeRosterToPlayers(roster) if button.setEnable then button.setEnable() end + + local activeKey = string.lower(entry.name) + if previousActive[activeKey] ~= true + and MultiBot.bridge and MultiBot.bridge.connected + and MultiBot.Comm and type(MultiBot.Comm.RequestState) == "function" then + local stateRequest = MultiBot.Comm.RequestState(entry.name) + if stateRequest then + button.waitFor = "BRIDGE_STATE" + end + end else if button.setDisable then button.setDisable() @@ -2043,7 +2057,7 @@ local function NormalizeBridgeDetailStoreGender(value) return "[F]" end - if string.match(gender, "^%[[^%]]+%]$") then + if string.match(gender, "^%[[^%],]+%]$") then return gender end diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index b0ef2e2..d32d70c 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -1515,7 +1515,28 @@ function Comm.MarkDisconnected(reason) state.formationCommands = {} state.formationQueryActive = nil state.strategyMutationCapable = false + state.stateFramingCapable = false + + local pendingTokens = {} + for token in pairs(state.strategyMutationCommands or {}) do + pendingTokens[#pendingTokens + 1] = token + end + for _, token in ipairs(pendingTokens) do + finishStrategyMutationCommand(token, { + status = "error", + matched = 0, + succeeded = 0, + failed = 0, + reason = "DISCONNECTED", + }) + end state.strategyMutationCommands = {} + + state.stateRequests = {} + state.stateActive = {} + state.stateLatestByBot = {} + state.stateLatestOrderByBot = {} + state.stateGlobalLatestToken = nil end local function parseBridgeDetailPayload(payload) diff --git a/Core/MultiBotEngine.lua b/Core/MultiBotEngine.lua index c4f53cd..f70c2b5 100644 --- a/Core/MultiBotEngine.lua +++ b/Core/MultiBotEngine.lua @@ -508,7 +508,8 @@ local function _mbRunBridgeStrategyMutation(action, commandScope, target) local stateScope = mutationScope == "nc" and "N" or "C" local token = MultiBot.Comm.RunStrategyCommand(commandScope, target, stateScope, changes, function(result) - if(type(result) ~= "table" or (tonumber(result.matched) or 0) <= 0) then return end + if(type(result) ~= "table") then return end + if(not MultiBot.bridge or MultiBot.bridge.connected ~= true) then return end if(commandScope == "BOT") then if(MultiBot.Comm and MultiBot.Comm.RequestState) then From e67f2da8999ca80260639338292cd74b24148850 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:34:45 +0200 Subject: [PATCH 4/4] Hotfix 2 --- Core/MultiBot.lua | 2 +- Core/MultiBotComm.lua | 34 +++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Core/MultiBot.lua b/Core/MultiBot.lua index aaa3661..552aa20 100644 --- a/Core/MultiBot.lua +++ b/Core/MultiBot.lua @@ -2112,7 +2112,7 @@ function MultiBot.ApplyBridgeBotDetail(detail) return false end - local storedValue = nil + local storedValue if MultiBot.SetGlobalBotEntry then storedValue = MultiBot.SetGlobalBotEntry(detail.name, value) else diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index d32d70c..71a002c 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -14,6 +14,7 @@ Comm.version = "1" local STATE_FRAMING_CAPABILITY = "STATE_FRAMING_V1" local STRATEGY_MUTATION_CAPABILITY = "STRATEGY_MUTATION_V1" local STATE_TIMEOUT_SECONDS = 5.0 +local STATES_TIMEOUT_SECONDS = 15.0 local STRATEGY_MUTATION_TIMEOUT_SECONDS = 5.0 local STRATEGY_MUTATION_MAX_ACTIVE = 32 local STRATEGY_MUTATION_MAX_CHANGES_LENGTH = 160 @@ -312,12 +313,13 @@ local function clearStateRequest(state, token) state.stateRequests[token] = nil end -local function scheduleStateTimeout(token) +local function scheduleStateTimeout(token, isGlobal) if not (MultiBot and type(MultiBot.TimerAfter) == "function") then return end - MultiBot.TimerAfter(STATE_TIMEOUT_SECONDS, function() + local timeoutSeconds = isGlobal and STATES_TIMEOUT_SECONDS or STATE_TIMEOUT_SECONDS + MultiBot.TimerAfter(timeoutSeconds, function() local state = ensureBridgeState() if not state.stateRequests[token] then return @@ -364,7 +366,7 @@ local function beginStateRequest(state, botName, isGlobal) state.stateLatestByBot[botKey] = token end - scheduleStateTimeout(token) + scheduleStateTimeout(token, isGlobal) return token end @@ -4291,17 +4293,23 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) local fields = splitFields(payload or "") if #fields == 4 then - local requestType = string.upper(trim(urlDecodeField(fields[2]))) + local requestType = urlDecodeFieldStrict(fields[2], 32, false) local token = trim(fields[3]) - local reason = trim(urlDecodeField(fields[4])) - if requestType == "STRATEGY" and state.strategyMutationCommands[token] then - finishStrategyMutationCommand(token, { - status = "error", - matched = 0, - succeeded = 0, - failed = 0, - reason = reason ~= "" and reason or "PROTOCOL_ERROR", - }) + local reason = urlDecodeFieldStrict(fields[4], 64, false) + + requestType = requestType and string.upper(trim(requestType)) or nil + if requestType and isValidStateToken(token) and reason then + if requestType == "STRATEGY" and state.strategyMutationCommands[token] then + finishStrategyMutationCommand(token, { + status = "error", + matched = 0, + succeeded = 0, + failed = 0, + reason = reason, + }) + elseif (requestType == "STATE" or requestType == "STATES") and state.stateRequests[token] then + clearStateRequest(state, token) + end end end