From 49898645787c1185a5f672f9c8cb45f421a1ebf4 Mon Sep 17 00:00:00 2001 From: Alex Dcnh <140754794+Wishmaster117@users.noreply.github.com> Date: Wed, 13 May 2026 20:38:10 +0200 Subject: [PATCH] Expose trainer spell listing and learning endpoints --- Core/MultiBotComm.lua | 286 +++++++++++++ Core/MultiBotEvery.lua | 13 +- Locales/MultiBotAceLocale-deDE.lua | 20 + Locales/MultiBotAceLocale-enGB.lua | 20 + Locales/MultiBotAceLocale-enUS.lua | 20 + Locales/MultiBotAceLocale-esES.lua | 20 + Locales/MultiBotAceLocale-frFR.lua | 20 + Locales/MultiBotAceLocale-koKR.lua | 20 + Locales/MultiBotAceLocale-ruRU.lua | 20 + Locales/MultiBotAceLocale-zhCN.lua | 20 + MultiBot.toc | 1 + TODO.md | 5 + UI/MultiBotTrainerUI.lua | 499 ++++++++++++++++++++++ docs/multibot_bridge_chatless_roadmap.md | 22 +- docs/multibot_missing_commands_roadmap.md | 21 +- 15 files changed, 1002 insertions(+), 5 deletions(-) create mode 100644 UI/MultiBotTrainerUI.lua diff --git a/Core/MultiBotComm.lua b/Core/MultiBotComm.lua index 7894b8e..de73ada 100644 --- a/Core/MultiBotComm.lua +++ b/Core/MultiBotComm.lua @@ -141,6 +141,10 @@ local function ensureBridgeState() state.outfitSeq = state.outfitSeq or 0 state.outfitActive = state.outfitActive or nil state.outfitCommands = state.outfitCommands or {} + state.trainerSeq = state.trainerSeq or 0 + state.trainerActive = state.trainerActive or nil + state.trainerCommands = state.trainerCommands or {} + state.trainerSpells = state.trainerSpells or {} state.glyphs = state.glyphs or {} state.glyphSeq = state.glyphSeq or 0 state.glyphActive = state.glyphActive or nil @@ -446,6 +450,74 @@ function Comm.RunOutfitCommand(name, commandSuffix, persist) return true end +function Comm.RequestTrainer(name) + local state = ensureBridgeState() + name = trim(name) + if name == "" or not state.connected then + return false + end + + state.trainerSeq = (tonumber(state.trainerSeq) or 0) + 1 + local token = tostring(math.floor(safeNow() * 1000)) .. "-trainer-" .. tostring(state.trainerSeq) + state.trainerActive = { + botName = name, + botNameKey = string.lower(name), + token = token, + trainerEntry = 0, + trainerName = "", + startedAt = safeNow(), + spells = {}, + } + + if not Comm.Send("GET", "TRAINER~" .. name .. "~" .. token) then + state.trainerActive = nil + return false + end + + return true +end + +function Comm.RunTrainerLearn(name, trainerEntry, spellId) + local state = ensureBridgeState() + name = trim(name) + trainerEntry = tonumber(trainerEntry or 0) or 0 + + local spellToken = trim(spellId) + if spellToken == "" and tonumber(spellId or 0) then + spellToken = tostring(tonumber(spellId or 0) or 0) + end + if string.upper(spellToken) ~= "ALL" then + local numericSpellId = tonumber(spellToken or "0") or 0 + if numericSpellId <= 0 then + return false + end + spellToken = tostring(numericSpellId) + else + spellToken = "ALL" + end + + if name == "" or trainerEntry <= 0 or not state.connected then + return false + end + + state.trainerSeq = (tonumber(state.trainerSeq) or 0) + 1 + local token = tostring(math.floor(safeNow() * 1000)) .. "-trainer-learn-" .. tostring(state.trainerSeq) + state.trainerCommands[token] = { + botName = name, + botNameKey = string.lower(name), + trainerEntry = trainerEntry, + spellId = spellToken, + startedAt = safeNow(), + } + + if not Comm.Send("RUN", "TRAINER_LEARN~" .. name .. "~" .. token .. "~" .. trainerEntry .. "~" .. spellToken) then + state.trainerCommands[token] = nil + return false + end + + return true +end + function Comm.RequestGlyphs(name) local state = ensureBridgeState() if not state.connected and not state.bootstrapPending then @@ -818,6 +890,8 @@ function Comm.MarkDisconnected(reason) state.professionRecipeCrafts = {} state.outfitActive = nil state.outfitCommands = {} + state.trainerActive = nil + state.trainerCommands = {} end local function parseBridgeDetailPayload(payload) @@ -1595,6 +1669,182 @@ function Comm.ApplyOutfitCommandPayload(payload) return true end +local function getActiveTrainerRequest(botName, token) + local active = ensureBridgeState().trainerActive + if not active then return nil end + botName = trim(botName) + token = trim(token) + if token ~= active.token then return nil end + if botName ~= "" and string.lower(botName) ~= active.botNameKey then return nil end + return active +end + +local function clearActiveTrainerRequest(botName, token) + local state = ensureBridgeState() + if getActiveTrainerRequest(botName, token) then + state.trainerActive = nil + end +end + +function Comm.ApplyTrainerBeginPayload(payload) + local botName, rest = splitOnce(payload or "", "~") + local token, rest2 = splitOnce(rest or "", "~") + local trainerEntry, trainerName = splitOnce(rest2 or "", "~") + + botName = trim(urlDecodeField(botName)) + token = trim(token) + trainerEntry = tonumber(trainerEntry or "0") or 0 + trainerName = trim(urlDecodeField(trainerName)) + + local active = getActiveTrainerRequest(botName, token) + if botName == "" or not active then + return false + end + + active.botName = botName + active.botNameKey = string.lower(botName) + active.trainerEntry = trainerEntry + active.trainerName = trainerName + active.spells = {} + + if MultiBot.TrainerUI and MultiBot.TrainerUI.HandleBridgeBegin then + MultiBot.TrainerUI:HandleBridgeBegin(botName, token, trainerEntry, trainerName) + end + + debugPrint("ADDON:RX", "TRAINER_BEGIN", botName, trainerEntry) + return true +end + +function Comm.ApplyTrainerItemPayload(payload) + local botName, rest = splitOnce(payload or "", "~") + local token, rest2 = splitOnce(rest or "", "~") + local trainerEntry, rest3 = splitOnce(rest2 or "", "~") + local spellId, rest4 = splitOnce(rest3 or "", "~") + local cost, canAfford = splitOnce(rest4 or "", "~") + + botName = trim(urlDecodeField(botName)) + token = trim(token) + trainerEntry = tonumber(trainerEntry or "0") or 0 + spellId = tonumber(spellId or "0") or 0 + cost = tonumber(cost or "0") or 0 + canAfford = tostring(canAfford or "0") == "1" + + local active = getActiveTrainerRequest(botName, token) + if botName == "" or not active or spellId <= 0 then + return false + end + + local entry = { + spellId = spellId, + cost = cost, + canAfford = canAfford, + trainerEntry = trainerEntry, + } + active.spells[#active.spells + 1] = entry + + if MultiBot.TrainerUI and MultiBot.TrainerUI.HandleBridgeLine then + MultiBot.TrainerUI:HandleBridgeLine(botName, token, entry) + end + + debugPrint("ADDON:RX", "TRAINER_ITEM", botName, spellId, cost, canAfford and 1 or 0) + return true +end + +function Comm.ApplyTrainerErrorPayload(payload) + local botName, rest = splitOnce(payload or "", "~") + local token, rest2 = splitOnce(rest or "", "~") + local trainerEntry, reason = splitOnce(rest2 or "", "~") + + botName = trim(urlDecodeField(botName)) + token = trim(token) + trainerEntry = tonumber(trainerEntry or "0") or 0 + reason = trim(urlDecodeField(reason)) + + local active = getActiveTrainerRequest(botName, token) + if botName == "" or not active then + return false + end + + active.error = reason + active.trainerEntry = trainerEntry + + if MultiBot.TrainerUI and MultiBot.TrainerUI.HandleBridgeError then + MultiBot.TrainerUI:HandleBridgeError(botName, token, reason, trainerEntry) + end + + debugPrint("ADDON:RX", "TRAINER_ERROR", botName, reason) + return true +end + +function Comm.ApplyTrainerEndPayload(payload) + local botName, rest = splitOnce(payload or "", "~") + local token, rest2 = splitOnce(rest or "", "~") + local trainerEntry, trainerName = splitOnce(rest2 or "", "~") + + botName = trim(urlDecodeField(botName)) + token = trim(token) + trainerEntry = tonumber(trainerEntry or "0") or 0 + trainerName = trim(urlDecodeField(trainerName)) + + local active = getActiveTrainerRequest(botName, token) + if botName == "" or not active then + return false + end + + local state = ensureBridgeState() + state.trainerSpells[string.lower(botName)] = active.spells or {} + + if MultiBot.TrainerUI and MultiBot.TrainerUI.HandleBridgeEnd then + MultiBot.TrainerUI:HandleBridgeEnd(botName, token, trainerEntry, trainerName, active.spells or {}, active.error) + end + + clearActiveTrainerRequest(botName, token) + debugPrint("ADDON:RX", "TRAINER_END", botName) + return true +end + +function Comm.ApplyTrainerLearnPayload(payload) + local botName, rest = splitOnce(payload or "", "~") + local token, rest2 = splitOnce(rest or "", "~") + local trainerEntry, rest3 = splitOnce(rest2 or "", "~") + local spellId, rest4 = splitOnce(rest3 or "", "~") + local result, rest5 = splitOnce(rest4 or "", "~") + local reason, rest6 = splitOnce(rest5 or "", "~") + local learnedCount, spent = splitOnce(rest6 or "", "~") + + botName = trim(urlDecodeField(botName)) + token = trim(token) + trainerEntry = tonumber(trainerEntry or "0") or 0 + spellId = trim(urlDecodeField(spellId)) + result = trim(result) + reason = trim(urlDecodeField(reason)) + learnedCount = tonumber(learnedCount or "0") or 0 + spent = tonumber(spent or "0") or 0 + + local state = ensureBridgeState() + local command = state.trainerCommands and state.trainerCommands[token] or nil + if not command then + return false + end + + command.botName = botName ~= "" and botName or command.botName + command.botNameKey = string.lower(command.botName or "") + command.trainerEntry = trainerEntry + command.spellId = spellId + command.result = result + command.reason = reason + command.learnedCount = learnedCount + command.spent = spent + + if MultiBot.TrainerUI and MultiBot.TrainerUI.HandleBridgeLearnResult then + MultiBot.TrainerUI:HandleBridgeLearnResult(command.botName, token, trainerEntry, spellId, result, reason, learnedCount, spent) + end + + state.trainerCommands[token] = nil + debugPrint("ADDON:RX", "TRAINER_LEARN", command.botName, spellId, result, reason) + return true +end + function Comm.ApplyProfessionRecipeCraftPayload(payload) local botName, rest = splitOnce(payload or "", "~") local token, rest2 = splitOnce(rest or "", "~") @@ -2103,6 +2353,36 @@ function Comm.HandleAddonMessage(prefix, message, distribution, sender) return Comm.ApplyOutfitCommandPayload(payload) end + if opcode == "TRAINER_BEGIN" then + state.connected = true + state.lastError = nil + return Comm.ApplyTrainerBeginPayload(payload) + end + + if opcode == "TRAINER_ITEM" then + state.connected = true + state.lastError = nil + return Comm.ApplyTrainerItemPayload(payload) + end + + if opcode == "TRAINER_ERROR" then + state.connected = true + state.lastError = nil + return Comm.ApplyTrainerErrorPayload(payload) + end + + if opcode == "TRAINER_END" then + state.connected = true + state.lastError = nil + return Comm.ApplyTrainerEndPayload(payload) + end + + if opcode == "TRAINER_LEARN" then + state.connected = true + state.lastError = nil + return Comm.ApplyTrainerLearnPayload(payload) + end + if opcode == "GLYPHS_BEGIN" then state.connected = true state.lastError = nil @@ -2944,7 +3224,13 @@ function Comm.OnPlayerEnteringWorld() state.professionRecipeCrafts = {} state.outfitActive = nil state.outfitCommands = {} + state.trainerActive = nil + state.trainerCommands = {} + state.trainerSpells = {} Comm.MarkDisconnected(nil) + state.trainerActive = nil + state.trainerCommands = {} + state.trainerSpells = {} state.bootstrapPending = true state.bootstrapDeadline = safeNow() + 4.0 diff --git a/Core/MultiBotEvery.lua b/Core/MultiBotEvery.lua index 6c4c515..61ab50b 100644 --- a/Core/MultiBotEvery.lua +++ b/Core/MultiBotEvery.lua @@ -230,16 +230,23 @@ MultiBot.addEvery = function(pFrame, pCombat, pNormal) end end + pFrame.addButton("Trainer", 394, 0, "spell_holy_magicalsentry", MultiBot.L("tips.every.trainer", "Trainer")).setDisable() + .doLeft = function(pButton) + if(MultiBot.OpenBotTrainer) then + MultiBot.OpenBotTrainer(pButton.getName(), pButton) + end + end + local botName = pFrame.getName and pFrame.getName() or nil if MultiBot.BuildBotRTIUI and botName and botName ~= "" then - MultiBot.BuildBotRTIUI(pFrame, botName, 394, 0) + MultiBot.BuildBotRTIUI(pFrame, botName, 424, 0) end - local combatFrame = pFrame.addFrame("CombatCommands", 424, 29, nil, 58, 114) + local combatFrame = pFrame.addFrame("CombatCommands", 454, 29, nil, 58, 114) combatFrame:Hide() combatFrame._mbDropdownManaged = true - pFrame.addButton("Combat", 424, 0, "Ability_Warrior_BattleShout", MultiBot.L("tips.every.combat")) + pFrame.addButton("Combat", 454, 0, "Ability_Warrior_BattleShout", MultiBot.L("tips.every.combat")) .doLeft = function() MultiBot.ShowHideSwitch(combatFrame) end diff --git a/Locales/MultiBotAceLocale-deDE.lua b/Locales/MultiBotAceLocale-deDE.lua index 6b7da67..f82bb61 100644 --- a/Locales/MultiBotAceLocale-deDE.lua +++ b/Locales/MultiBotAceLocale-deDE.lua @@ -1006,6 +1006,26 @@ local deDEValues = { ["info.outfits.replace"] = "Ersetzen", ["info.outfits.update"] = "Aktualisieren", ["info.outfits.reset"] = "Zurücksetzen", + ["tips.every.trainer"] = "Lehrer|cffffffff\nZeigt, was dieser Bot beim ausgewählten Lehrer lernen kann.|r\n\n|cffff0000Linksklick zum Öffnen|r\n|cff999999(Ausgeführt von: Bot)|r", + ["info.trainer.window_title"] = "Lehrer", + ["info.trainer.refresh"] = "Aktualisieren", + ["info.trainer.learn"] = "Lernen", + ["info.trainer.learn_all"] = "Alles lernen", + ["info.trainer.loading"] = "Lehrerzauber werden geladen ...", + ["info.trainer.learning"] = "Lernen ...", + ["info.trainer.no_spells"] = "Keine erlernbaren Zauber.", + ["info.trainer.loaded"] = "Lehrerzauber geladen.", + ["info.trainer.bridge_required"] = "Für Lehreraktionen wird die Bridge benötigt.", + ["info.trainer.learned"] = "%d Zauber gelernt.", + ["info.trainer.failed"] = "Lehreraktion fehlgeschlagen.", + ["info.trainer.reason.NO_BOT"] = "Bot nicht gefunden.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Wähle zuerst einen Lehrer aus.", + ["info.trainer.reason.TRAINER_CHANGED"] = "Der ausgewählte Lehrer hat sich geändert.", + ["info.trainer.reason.NO_TRAINER"] = "Lehrerdaten sind nicht verfügbar.", + ["info.trainer.reason.INVALID_TRAINER"] = "Dieser Lehrer kann diesem Bot nichts beibringen.", + ["info.trainer.reason.NO_SPELL"] = "Zauber nicht gefunden.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "Dieser Zauber ist nicht mehr verfügbar.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "Der Bot hat nicht genug verfügbares Geld.", ["tips.outfits.equip"] = "Linksklick: Ausrüsten\nRechtsklick: Ersetzen", } diff --git a/Locales/MultiBotAceLocale-enGB.lua b/Locales/MultiBotAceLocale-enGB.lua index 84fa412..68d2532 100644 --- a/Locales/MultiBotAceLocale-enGB.lua +++ b/Locales/MultiBotAceLocale-enGB.lua @@ -1009,6 +1009,26 @@ local enGBValues = { ["info.outfits.replace"] = "Replace", ["info.outfits.update"] = "Update", ["info.outfits.reset"] = "Reset", + ["tips.every.trainer"] = "Trainer|cffffffff\nShows what this bot can learn from your selected trainer.|r\n\n|cffff0000Left-click to open|r\n|cff999999(Executed by: Bot)|r", + ["info.trainer.window_title"] = "Trainer", + ["info.trainer.refresh"] = "Refresh", + ["info.trainer.learn"] = "Learn", + ["info.trainer.learn_all"] = "Learn all", + ["info.trainer.loading"] = "Loading trainer spells...", + ["info.trainer.learning"] = "Learning...", + ["info.trainer.no_spells"] = "No learnable spells.", + ["info.trainer.loaded"] = "Trainer spells loaded.", + ["info.trainer.bridge_required"] = "Bridge support is required for trainer actions.", + ["info.trainer.learned"] = "Learned %d spell(s).", + ["info.trainer.failed"] = "Trainer action failed.", + ["info.trainer.reason.NO_BOT"] = "Bot not found.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Select a trainer first.", + ["info.trainer.reason.TRAINER_CHANGED"] = "The selected trainer changed.", + ["info.trainer.reason.NO_TRAINER"] = "Trainer data is unavailable.", + ["info.trainer.reason.INVALID_TRAINER"] = "This trainer cannot teach this bot.", + ["info.trainer.reason.NO_SPELL"] = "Spell not found.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "This spell is no longer available.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "The bot does not have enough available money.", ["tips.outfits.equip"] = "Left click: Equip\nRight click: Replace", } diff --git a/Locales/MultiBotAceLocale-enUS.lua b/Locales/MultiBotAceLocale-enUS.lua index ee5541a..7b28c58 100644 --- a/Locales/MultiBotAceLocale-enUS.lua +++ b/Locales/MultiBotAceLocale-enUS.lua @@ -1009,6 +1009,26 @@ local enUSValues = { ["info.outfits.replace"] = "Replace", ["info.outfits.update"] = "Update", ["info.outfits.reset"] = "Reset", + ["tips.every.trainer"] = "Trainer|cffffffff\nShows what this bot can learn from your selected trainer.|r\n\n|cffff0000Left-click to open|r\n|cff999999(Executed by: Bot)|r", + ["info.trainer.window_title"] = "Trainer", + ["info.trainer.refresh"] = "Refresh", + ["info.trainer.learn"] = "Learn", + ["info.trainer.learn_all"] = "Learn all", + ["info.trainer.loading"] = "Loading trainer spells...", + ["info.trainer.learning"] = "Learning...", + ["info.trainer.no_spells"] = "No learnable spells.", + ["info.trainer.loaded"] = "Trainer spells loaded.", + ["info.trainer.bridge_required"] = "Bridge support is required for trainer actions.", + ["info.trainer.learned"] = "Learned %d spell(s).", + ["info.trainer.failed"] = "Trainer action failed.", + ["info.trainer.reason.NO_BOT"] = "Bot not found.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Select a trainer first.", + ["info.trainer.reason.TRAINER_CHANGED"] = "The selected trainer changed.", + ["info.trainer.reason.NO_TRAINER"] = "Trainer data is unavailable.", + ["info.trainer.reason.INVALID_TRAINER"] = "This trainer cannot teach this bot.", + ["info.trainer.reason.NO_SPELL"] = "Spell not found.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "This spell is no longer available.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "The bot does not have enough available money.", ["tips.outfits.equip"] = "Left click: Equip\nRight click: Replace", } diff --git a/Locales/MultiBotAceLocale-esES.lua b/Locales/MultiBotAceLocale-esES.lua index f879b93..b2d2731 100644 --- a/Locales/MultiBotAceLocale-esES.lua +++ b/Locales/MultiBotAceLocale-esES.lua @@ -1007,6 +1007,26 @@ local esESValues = { ["info.outfits.replace"] = "Reemplazar", ["info.outfits.update"] = "Actualizar", ["info.outfits.reset"] = "Restablecer", + ["tips.every.trainer"] = "Entrenador|cffffffff\nMuestra lo que este bot puede aprender del entrenador seleccionado.|r\n\n|cffff0000Clic izquierdo para abrir|r\n|cff999999(Ejecutado por: Bot)|r", + ["info.trainer.window_title"] = "Entrenador", + ["info.trainer.refresh"] = "Actualizar", + ["info.trainer.learn"] = "Aprender", + ["info.trainer.learn_all"] = "Aprender todo", + ["info.trainer.loading"] = "Cargando hechizos del entrenador...", + ["info.trainer.learning"] = "Aprendiendo...", + ["info.trainer.no_spells"] = "No hay hechizos para aprender.", + ["info.trainer.loaded"] = "Hechizos del entrenador cargados.", + ["info.trainer.bridge_required"] = "Se requiere el bridge para las acciones de entrenador.", + ["info.trainer.learned"] = "%d hechizo(s) aprendido(s).", + ["info.trainer.failed"] = "La acción del entrenador falló.", + ["info.trainer.reason.NO_BOT"] = "Bot no encontrado.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Selecciona primero un entrenador.", + ["info.trainer.reason.TRAINER_CHANGED"] = "El entrenador seleccionado cambió.", + ["info.trainer.reason.NO_TRAINER"] = "Los datos del entrenador no están disponibles.", + ["info.trainer.reason.INVALID_TRAINER"] = "Este entrenador no puede enseñar a este bot.", + ["info.trainer.reason.NO_SPELL"] = "Hechizo no encontrado.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "Este hechizo ya no está disponible.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "El bot no tiene suficiente dinero disponible.", ["tips.outfits.equip"] = "Clic izquierdo: Equipar\nClic derecho: Reemplazar", } diff --git a/Locales/MultiBotAceLocale-frFR.lua b/Locales/MultiBotAceLocale-frFR.lua index 19ca5a5..dddaf49 100644 --- a/Locales/MultiBotAceLocale-frFR.lua +++ b/Locales/MultiBotAceLocale-frFR.lua @@ -1006,6 +1006,26 @@ local frFRValues = { ["info.outfits.replace"] = "Remplacer", ["info.outfits.update"] = "Mettre à jour", ["info.outfits.reset"] = "Réinitialiser", + ["tips.every.trainer"] = "Entraîneur|cffffffff\nAffiche ce que ce bot peut apprendre auprès de l'entraîneur sélectionné.|r\n\n|cffff0000Clic gauche pour ouvrir|r\n|cff999999(Ordre d'exécution : Bot)|r", + ["info.trainer.window_title"] = "Entraîneur", + ["info.trainer.refresh"] = "Rafraîchir", + ["info.trainer.learn"] = "Apprendre", + ["info.trainer.learn_all"] = "Tout apprendre", + ["info.trainer.loading"] = "Chargement des sorts du maître...", + ["info.trainer.learning"] = "Apprentissage...", + ["info.trainer.no_spells"] = "Aucun sort à apprendre.", + ["info.trainer.loaded"] = "Sorts du maître chargés.", + ["info.trainer.bridge_required"] = "Le bridge est requis pour les actions d'entraîneur.", + ["info.trainer.learned"] = "%d sort(s) appris.", + ["info.trainer.failed"] = "Action d'entraîneur échouée.", + ["info.trainer.reason.NO_BOT"] = "Bot introuvable.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Sélectionnez d'abord un entraîneur.", + ["info.trainer.reason.TRAINER_CHANGED"] = "L'entraîneur sélectionné a changé.", + ["info.trainer.reason.NO_TRAINER"] = "Données d'entraîneur indisponibles.", + ["info.trainer.reason.INVALID_TRAINER"] = "Cet entraîneur ne peut rien apprendre à ce bot.", + ["info.trainer.reason.NO_SPELL"] = "Sort introuvable.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "Ce sort n'est plus disponible.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "Le bot n'a pas assez d'argent disponible.", ["tips.outfits.equip"] = "Clic gauche : Équiper\nClic droit : Remplacer", } diff --git a/Locales/MultiBotAceLocale-koKR.lua b/Locales/MultiBotAceLocale-koKR.lua index f7d3a3f..1280d42 100644 --- a/Locales/MultiBotAceLocale-koKR.lua +++ b/Locales/MultiBotAceLocale-koKR.lua @@ -998,6 +998,26 @@ local koKRValues = { ["info.outfits.replace"] = "바꾸기", ["info.outfits.update"] = "업데이트", ["info.outfits.reset"] = "초기화", + ["tips.every.trainer"] = "전문 기술자|cffffffff\n선택한 trainer에게서 이 봇이 배울 수 있는 항목을 표시합니다.|r\n\n|cffff0000왼쪽 클릭으로 열기|r\n|cff999999(실행: 봇)|r", + ["info.trainer.window_title"] = "Trainer", + ["info.trainer.refresh"] = "새로 고침", + ["info.trainer.learn"] = "배우기", + ["info.trainer.learn_all"] = "모두 배우기", + ["info.trainer.loading"] = "trainer 주문을 불러오는 중...", + ["info.trainer.learning"] = "배우는 중...", + ["info.trainer.no_spells"] = "배울 수 있는 주문이 없습니다.", + ["info.trainer.loaded"] = "trainer 주문을 불러왔습니다.", + ["info.trainer.bridge_required"] = "trainer 동작에는 bridge 지원이 필요합니다.", + ["info.trainer.learned"] = "%d개의 주문을 배웠습니다.", + ["info.trainer.failed"] = "trainer 동작이 실패했습니다.", + ["info.trainer.reason.NO_BOT"] = "봇을 찾을 수 없습니다.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "먼저 trainer를 선택하세요.", + ["info.trainer.reason.TRAINER_CHANGED"] = "선택한 trainer가 변경되었습니다.", + ["info.trainer.reason.NO_TRAINER"] = "trainer 데이터를 사용할 수 없습니다.", + ["info.trainer.reason.INVALID_TRAINER"] = "이 trainer는 이 봇을 가르칠 수 없습니다.", + ["info.trainer.reason.NO_SPELL"] = "주문을 찾을 수 없습니다.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "이 주문은 더 이상 사용할 수 없습니다.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "봇의 사용 가능한 돈이 부족합니다.", ["tips.outfits.equip"] = "왼쪽 클릭: 장착\n오른쪽 클릭: 바꾸기", } diff --git a/Locales/MultiBotAceLocale-ruRU.lua b/Locales/MultiBotAceLocale-ruRU.lua index bb1f789..729efb0 100644 --- a/Locales/MultiBotAceLocale-ruRU.lua +++ b/Locales/MultiBotAceLocale-ruRU.lua @@ -1007,6 +1007,26 @@ local ruRUValues = { ["info.outfits.replace"] = "Заменить", ["info.outfits.update"] = "Обновить", ["info.outfits.reset"] = "Сбросить", + ["tips.every.trainer"] = "Тренер|cffffffff\nПоказывает, чему этот бот может научиться у выбранного тренера.|r\n\n|cffff0000Левый клик — открыть|r\n|cff999999(Выполняет: Бот)|r", + ["info.trainer.window_title"] = "Тренер", + ["info.trainer.refresh"] = "Обновить", + ["info.trainer.learn"] = "Выучить", + ["info.trainer.learn_all"] = "Выучить всё", + ["info.trainer.loading"] = "Загрузка заклинаний тренера...", + ["info.trainer.learning"] = "Обучение...", + ["info.trainer.no_spells"] = "Нет доступных заклинаний.", + ["info.trainer.loaded"] = "Заклинания тренера загружены.", + ["info.trainer.bridge_required"] = "Для действий тренера требуется bridge.", + ["info.trainer.learned"] = "Выучено заклинаний: %d.", + ["info.trainer.failed"] = "Действие тренера не удалось.", + ["info.trainer.reason.NO_BOT"] = "Бот не найден.", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "Сначала выберите тренера.", + ["info.trainer.reason.TRAINER_CHANGED"] = "Выбранный тренер изменился.", + ["info.trainer.reason.NO_TRAINER"] = "Данные тренера недоступны.", + ["info.trainer.reason.INVALID_TRAINER"] = "Этот тренер не может обучать этого бота.", + ["info.trainer.reason.NO_SPELL"] = "Заклинание не найдено.", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "Это заклинание больше недоступно.", + ["info.trainer.reason.TOO_EXPENSIVE"] = "У бота недостаточно доступных денег.", ["tips.outfits.equip"] = "ЛКМ: Надеть\nПКМ: Заменить", } diff --git a/Locales/MultiBotAceLocale-zhCN.lua b/Locales/MultiBotAceLocale-zhCN.lua index 09f701b..d53d8c1 100644 --- a/Locales/MultiBotAceLocale-zhCN.lua +++ b/Locales/MultiBotAceLocale-zhCN.lua @@ -1007,6 +1007,26 @@ local zhCNValues = { ["info.outfits.replace"] = "替换", ["info.outfits.update"] = "更新", ["info.outfits.reset"] = "重置", + ["tips.every.trainer"] = "训练师|cffffffff\n显示该机器人可从当前选中的训练师学习的内容。|r\n\n|cffff0000左键打开|r\n|cff999999(执行者:机器人)|r", + ["info.trainer.window_title"] = "训练师", + ["info.trainer.refresh"] = "刷新", + ["info.trainer.learn"] = "学习", + ["info.trainer.learn_all"] = "全部学习", + ["info.trainer.loading"] = "正在加载训练师法术……", + ["info.trainer.learning"] = "正在学习……", + ["info.trainer.no_spells"] = "没有可学习的法术。", + ["info.trainer.loaded"] = "训练师法术已加载。", + ["info.trainer.bridge_required"] = "训练师操作需要 bridge 支持。", + ["info.trainer.learned"] = "已学习 %d 个法术。", + ["info.trainer.failed"] = "训练师操作失败。", + ["info.trainer.reason.NO_BOT"] = "找不到机器人。", + ["info.trainer.reason.NO_TRAINER_TARGET"] = "请先选中一个训练师。", + ["info.trainer.reason.TRAINER_CHANGED"] = "选中的训练师已改变。", + ["info.trainer.reason.NO_TRAINER"] = "训练师数据不可用。", + ["info.trainer.reason.INVALID_TRAINER"] = "该训练师无法教这个机器人。", + ["info.trainer.reason.NO_SPELL"] = "找不到法术。", + ["info.trainer.reason.NO_MATCHING_SPELL"] = "该法术已不可用。", + ["info.trainer.reason.TOO_EXPENSIVE"] = "机器人的可用金币不足。", ["tips.outfits.equip"] = "左键:装备\n右键:替换", } diff --git a/MultiBot.toc b/MultiBot.toc index 10e2ea2..9f733c1 100644 --- a/MultiBot.toc +++ b/MultiBot.toc @@ -67,6 +67,7 @@ UI\MultiBotSpellBookFrame.lua UI\MultiBotCharacterInfoFrame.lua UI\MultiBotRewardFrame.lua UI\MultiBotOutfitUI.lua +UI\MultiBotTrainerUI.lua UI\MultiBotInventoryFrame.lua UI\MultiBotInventoryItem.lua UI\MultiBotBankFrame.lua diff --git a/TODO.md b/TODO.md index 8ed3657..c4302f8 100644 --- a/TODO.md +++ b/TODO.md @@ -109,6 +109,11 @@ * Banque bot bridge-first avec consultation, dépôt et retrait. * Banque de guilde bot bridge-first avec consultation, dépôt et retrait protégé par les droits de guilde. * Layout des frames banque bot et BDG uniformisé avec fond interne sombre. +* Trainer bridge-first : + * bouton `Trainer` ajouté dans l'EveryBar après `Outfits` ; + * frame harmonisée avec les frames de quêtes ; + * consultation des sorts apprenables depuis le trainer sélectionné ; + * apprentissage d'un sort ou de tous les sorts via bridge. * Achat vendeur bridge-first depuis les composants manquants de recette métier. * Profession recipes bridge-first. * Craft de recettes métier via bridge `RUN~CRAFT_RECIPE`. diff --git a/UI/MultiBotTrainerUI.lua b/UI/MultiBotTrainerUI.lua new file mode 100644 index 0000000..9ad77c3 --- /dev/null +++ b/UI/MultiBotTrainerUI.lua @@ -0,0 +1,499 @@ +if not MultiBot then + return +end + +local AceGUI = LibStub and LibStub("AceGUI-3.0", true) + +local TRAINER_WINDOW_WIDTH = 420 +local TRAINER_WINDOW_HEIGHT = 420 +local TRAINER_PAGE_SIZE = 10 +local TRAINER_ROW_HEIGHT = 30 +local TRAINER_REFRESH_DELAY = 0.45 + +local TrainerUI = MultiBot.TrainerUI or {} +MultiBot.TrainerUI = TrainerUI + +local function L(key, fallback) + if MultiBot and type(MultiBot.L) == "function" then + return MultiBot.L(key, fallback) + end + + return fallback or key +end + +local function setButtonEnabled(button, enabled) + if not button then + return + end + + if enabled then + if button.Enable then button:Enable() end + if button.SetAlpha then button:SetAlpha(1) end + else + if button.Disable then button:Disable() end + if button.SetAlpha then button:SetAlpha(0.45) end + end +end + +local function addSimpleBackdrop(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 formatMoney(copper) + copper = tonumber(copper or 0) or 0 + if GetCoinTextureString then + return GetCoinTextureString(copper) + end + + local gold = math.floor(copper / 10000) + local silver = math.floor((copper % 10000) / 100) + local copperOnly = copper % 100 + return tostring(gold) .. "g " .. tostring(silver) .. "s " .. tostring(copperOnly) .. "c" +end + +local function getReasonText(reason) + reason = tostring(reason or "") + if reason == "" or reason == "OK" then + return "" + end + + return L("info.trainer.reason." .. reason, reason) +end + +local function getWindowTitle(botName) + local title = L("info.trainer.window_title", "Trainer") + if type(botName) == "string" and botName ~= "" then + return title .. " - " .. botName + end + + return title +end + +local function safeDelay(delaySeconds, callback) + if type(callback) ~= "function" then + return + end + + if MultiBot and type(MultiBot.TimerAfter) == "function" then + MultiBot.TimerAfter(delaySeconds or 0, callback) + return + end + + callback() +end + +local function sameBotName(left, right) + return string.lower(tostring(left or "")) == string.lower(tostring(right or "")) +end + +function TrainerUI:EnsureWindow() + if self.frame then + return self.frame + end + + local frame + local content + + if AceGUI then + local window = AceGUI:Create("Window") + window:SetTitle(getWindowTitle(self.botName)) + window:SetWidth(TRAINER_WINDOW_WIDTH) + window:SetHeight(TRAINER_WINDOW_HEIGHT) + window:EnableResize(false) + window:SetLayout("Fill") + frame = window.frame + content = window.content + frame._mbAceWindow = window + + local strataLevel = MultiBot.GetGlobalStrataLevel and MultiBot.GetGlobalStrataLevel() + if strataLevel then + frame:SetFrameStrata(strataLevel) + end + + if MultiBot.SetAceWindowCloseToHide then MultiBot.SetAceWindowCloseToHide(window) end + if MultiBot.RegisterAceWindowEscapeClose then MultiBot.RegisterAceWindowEscapeClose(window, "BotTrainer") end + if MultiBot.BindAceWindowPosition then MultiBot.BindAceWindowPosition(window, "bot_trainer_popup") end + else + frame = CreateFrame("Frame", "MultiBotTrainerFrame", UIParent) + frame:SetSize(TRAINER_WINDOW_WIDTH, TRAINER_WINDOW_HEIGHT) + frame:SetPoint("CENTER", UIParent, "CENTER", -120, 20) + frame:SetFrameStrata("DIALOG") + frame:EnableMouse(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", frame.StartMoving) + frame:SetScript("OnDragStop", frame.StopMovingOrSizing) + addSimpleBackdrop(frame, 0.96) + + frame.close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + frame.close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -4) + + frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + frame.title:SetPoint("TOP", 0, -7) + + content = CreateFrame("Frame", nil, frame) + content:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -34) + content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 12) + end + + frame:Hide() + frame.content = content or frame + addSimpleBackdrop(frame.content, 0.90) + + frame.status = frame.content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + frame.status:SetPoint("TOPLEFT", frame.content, "TOPLEFT", 10, -12) + frame.status:SetPoint("TOPRIGHT", frame.content, "TOPRIGHT", -10, -12) + frame.status:SetJustifyH("LEFT") + frame.status:SetText("") + + frame.rows = {} + for index = 1, TRAINER_PAGE_SIZE do + local row = CreateFrame("Frame", nil, frame.content) + row:SetPoint("TOPLEFT", frame.content, "TOPLEFT", 10, -38 - ((index - 1) * TRAINER_ROW_HEIGHT)) + row:SetPoint("TOPRIGHT", frame.content, "TOPRIGHT", -10, -38 - ((index - 1) * TRAINER_ROW_HEIGHT)) + row:SetHeight(TRAINER_ROW_HEIGHT) + + row.icon = CreateFrame("Button", nil, row) + row.icon:SetSize(22, 22) + row.icon:SetPoint("LEFT", row, "LEFT", 0, 0) + row.icon.texture = row.icon:CreateTexture(nil, "ARTWORK") + row.icon.texture:SetAllPoints(row.icon) + + row.name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0) + row.name:SetWidth(160) + row.name:SetJustifyH("LEFT") + + row.cost = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + row.cost:SetPoint("LEFT", row.name, "RIGHT", 8, 0) + row.cost:SetWidth(78) + row.cost:SetJustifyH("RIGHT") + + row.learn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + row.learn:SetSize(86, 22) + row.learn:SetPoint("RIGHT", row, "RIGHT", 0, 0) + row.learn:SetText(L("info.trainer.learn", "Learn")) + row.learn:SetScript("OnClick", function(button) + if button.spellId then + TrainerUI:LearnSpell(button.spellId) + end + end) + + row.icon:SetScript("OnEnter", function(button) + if button.spellId and GameTooltip then + GameTooltip:SetOwner(button, "ANCHOR_RIGHT") + GameTooltip:SetHyperlink("spell:" .. tostring(button.spellId)) + GameTooltip:Show() + end + end) + row.icon:SetScript("OnLeave", function() + if GameTooltip then + GameTooltip:Hide() + end + end) + + frame.rows[index] = row + end + + frame.prev = CreateFrame("Button", nil, frame.content, "UIPanelButtonTemplate") + frame.prev:SetSize(32, 22) + frame.prev:SetPoint("BOTTOMLEFT", frame.content, "BOTTOMLEFT", 10, 12) + frame.prev:SetText("<") + frame.prev:SetScript("OnClick", function() + TrainerUI.page = math.max(1, (TrainerUI.page or 1) - 1) + TrainerUI:Render() + end) + + frame.pageText = frame.content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + frame.pageText:SetPoint("LEFT", frame.prev, "RIGHT", 8, 0) + frame.pageText:SetWidth(60) + frame.pageText:SetJustifyH("CENTER") + + frame.next = CreateFrame("Button", nil, frame.content, "UIPanelButtonTemplate") + frame.next:SetSize(32, 22) + frame.next:SetPoint("LEFT", frame.pageText, "RIGHT", 8, 0) + frame.next:SetText(">") + frame.next:SetScript("OnClick", function() + local maxPage = TrainerUI:GetMaxPage() + TrainerUI.page = math.min(maxPage, (TrainerUI.page or 1) + 1) + TrainerUI:Render() + end) + + frame.refresh = CreateFrame("Button", nil, frame.content, "UIPanelButtonTemplate") + frame.refresh:SetSize(90, 22) + frame.refresh:SetPoint("BOTTOMRIGHT", frame.content, "BOTTOMRIGHT", -112, 12) + frame.refresh:SetText(L("info.trainer.refresh", "Refresh")) + frame.refresh:SetScript("OnClick", function() + TrainerUI:RequestList() + end) + + frame.learnAll = CreateFrame("Button", nil, frame.content, "UIPanelButtonTemplate") + frame.learnAll:SetSize(100, 22) + frame.learnAll:SetPoint("BOTTOMRIGHT", frame.content, "BOTTOMRIGHT", -10, 12) + frame.learnAll:SetText(L("info.trainer.learn_all", "Learn all")) + frame.learnAll:SetScript("OnClick", function() + TrainerUI:LearnAll() + end) + + frame:HookScript("OnHide", function() + if TrainerUI.sourceButton and TrainerUI.sourceButton.setDisable then + TrainerUI.sourceButton.setDisable() + end + end) + + self.frame = frame + self.entries = self.entries or {} + self.page = self.page or 1 + return frame +end + +function TrainerUI:SetStatus(message) + self:EnsureWindow() + self.frame.status:SetText(message or "") +end + +function TrainerUI:GetMaxPage() + local count = #(self.entries or {}) + local maxPage = math.ceil(count / TRAINER_PAGE_SIZE) + if maxPage < 1 then + maxPage = 1 + end + return maxPage +end + +function TrainerUI:Render() + local frame = self:EnsureWindow() + local entries = self.entries or {} + local maxPage = self:GetMaxPage() + self.page = math.min(math.max(1, self.page or 1), maxPage) + + local startIndex = ((self.page - 1) * TRAINER_PAGE_SIZE) + 1 + for rowIndex = 1, TRAINER_PAGE_SIZE do + local row = frame.rows[rowIndex] + local entry = entries[startIndex + rowIndex - 1] + + if entry then + local spellName, spellRank, icon = GetSpellInfo(entry.spellId) + row.icon.spellId = entry.spellId + row.icon.texture:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark") + row.name:SetText((spellName or ("spell:" .. tostring(entry.spellId))) .. (spellRank and spellRank ~= "" and (" (" .. spellRank .. ")") or "")) + row.cost:SetText(formatMoney(entry.cost)) + row.learn.spellId = entry.spellId + row.learn:SetText(L("info.trainer.learn", "Learn")) + setButtonEnabled(row.learn, not self.pending and entry.canAfford) + row:Show() + else + row.icon.spellId = nil + row.learn.spellId = nil + row:Hide() + end + end + + frame.pageText:SetText(tostring(self.page) .. "/" .. tostring(maxPage)) + setButtonEnabled(frame.prev, self.page > 1) + setButtonEnabled(frame.next, self.page < maxPage) + setButtonEnabled(frame.refresh, not self.pending) + setButtonEnabled(frame.learnAll, not self.pending and #entries > 0) +end + +function TrainerUI:RequestList() + if not self.botName or self.botName == "" then + return false + end + + if not MultiBot.Comm or not MultiBot.Comm.RequestTrainer then + self:SetStatus(L("info.trainer.bridge_required", "Bridge support is required for trainer actions.")) + return false + end + + self.pending = nil + self.entries = {} + self.page = 1 + self.error = nil + self.trainerEntry = 0 + self.trainerName = "" + self:SetStatus(L("info.trainer.loading", "Loading trainer spells...")) + self:Render() + + if not MultiBot.Comm.RequestTrainer(self.botName) then + self:SetStatus(L("info.trainer.bridge_required", "Bridge support is required for trainer actions.")) + self:Render() + return false + end + + return true +end + +function TrainerUI:LearnSpell(spellId) + if self.pending or not self.trainerEntry or self.trainerEntry <= 0 then + return false + end + + if not MultiBot.Comm or not MultiBot.Comm.RunTrainerLearn then + return false + end + + self.pending = spellId + self:SetStatus(L("info.trainer.learning", "Learning...")) + self:Render() + if not MultiBot.Comm.RunTrainerLearn(self.botName, self.trainerEntry, spellId) then + self.pending = nil + self:SetStatus(L("info.trainer.failed", "Trainer action failed.")) + self:Render() + return false + end + + return true +end + +function TrainerUI:LearnAll() + if self.pending or not self.trainerEntry or self.trainerEntry <= 0 then + return false + end + + if not MultiBot.Comm or not MultiBot.Comm.RunTrainerLearn then + return false + end + + self.pending = "ALL" + self:SetStatus(L("info.trainer.learning", "Learning...")) + self:Render() + if not MultiBot.Comm.RunTrainerLearn(self.botName, self.trainerEntry, "ALL") then + self.pending = nil + self:SetStatus(L("info.trainer.failed", "Trainer action failed.")) + self:Render() + return false + end + + return true +end + +function TrainerUI:HandleBridgeBegin(botName, token, trainerEntry, trainerName) + if not sameBotName(botName, self.botName) then + return + end + + self.requestToken = token + self.trainerEntry = tonumber(trainerEntry or 0) or 0 + self.trainerName = trainerName or "" + self.entries = {} + self.error = nil + self.page = 1 + self:SetStatus(self.trainerName ~= "" and self.trainerName or L("info.trainer.loading", "Loading trainer spells...")) + self:Render() +end + +function TrainerUI:HandleBridgeLine(botName, token, entry) + if not sameBotName(botName, self.botName) or (self.requestToken and token ~= self.requestToken) then + return + end + + self.entries = self.entries or {} + self.entries[#self.entries + 1] = entry + self:Render() +end + +function TrainerUI:HandleBridgeError(botName, token, reason, trainerEntry) + if not sameBotName(botName, self.botName) or (self.requestToken and token ~= self.requestToken) then + return + end + + self.trainerEntry = tonumber(trainerEntry or self.trainerEntry or 0) or 0 + self.error = reason + self:SetStatus(getReasonText(reason)) + self:Render() +end + +function TrainerUI:HandleBridgeEnd(botName, token, trainerEntry, trainerName, entries, errorReason) + if not sameBotName(botName, self.botName) or (self.requestToken and token ~= self.requestToken) then + return + end + + self.trainerEntry = tonumber(trainerEntry or self.trainerEntry or 0) or 0 + self.trainerName = (type(trainerName) == "string" and trainerName ~= "") and trainerName or self.trainerName + self.entries = entries or self.entries or {} + self.error = errorReason + self.pending = nil + + if self.error and self.error ~= "" then + self:SetStatus(getReasonText(self.error)) + elseif #(self.entries or {}) == 0 then + self:SetStatus(L("info.trainer.no_spells", "No learnable spells.")) + else + self:SetStatus(self.trainerName ~= "" and self.trainerName or L("info.trainer.loaded", "Trainer spells loaded.")) + end + + self:Render() +end + +function TrainerUI:HandleBridgeLearnResult(botName, token, trainerEntry, spellId, result, reason, learnedCount, spent) + if not sameBotName(botName, self.botName) then + return + end + + self.pending = nil + self.trainerEntry = tonumber(trainerEntry or self.trainerEntry or 0) or 0 + + if result == "OK" then + self:SetStatus(string.format(L("info.trainer.learned", "Learned %d spell(s)."), tonumber(learnedCount or 0) or 0)) + safeDelay(TRAINER_REFRESH_DELAY, function() + if TrainerUI.frame and TrainerUI.frame:IsShown() and sameBotName(TrainerUI.botName, botName) then + TrainerUI:RequestList() + end + end) + else + local detail = getReasonText(reason) + if detail == "" then + detail = L("info.trainer.failed", "Trainer action failed.") + end + self:SetStatus(detail) + self:Render() + end +end + +function MultiBot.OpenBotTrainer(botName, sourceButton) + botName = tostring(botName or "") + if botName == "" then + return false + end + + if TrainerUI.sourceButton and TrainerUI.sourceButton ~= sourceButton and TrainerUI.sourceButton.setDisable then + TrainerUI.sourceButton.setDisable() + end + + TrainerUI.botName = botName + TrainerUI.sourceButton = sourceButton + TrainerUI:EnsureWindow() + if TrainerUI.frame._mbAceWindow then + TrainerUI.frame._mbAceWindow:SetTitle(getWindowTitle(botName)) + TrainerUI.frame._mbAceWindow:Show() + else + TrainerUI.frame.title:SetText(getWindowTitle(botName)) + TrainerUI.frame:Show() + end + + if sourceButton and sourceButton.setEnable then + sourceButton.setEnable() + end + + return TrainerUI:RequestList() +end diff --git a/docs/multibot_bridge_chatless_roadmap.md b/docs/multibot_bridge_chatless_roadmap.md index 0b11252..15834cb 100644 --- a/docs/multibot_bridge_chatless_roadmap.md +++ b/docs/multibot_bridge_chatless_roadmap.md @@ -1,6 +1,6 @@ # MultiBot / Bridge — roadmap chatless -Dernière mise à jour : 2026-05-12 +Dernière mise à jour : 2026-05-13 ## Objectif exact @@ -154,6 +154,17 @@ Le but n’est pas de supprimer ces commandes. Le but est de ne plus les lancer - [x] Messages d'erreur structurés pour banquier introuvable, droits BDG, vendeur introuvable, objet non vendu et monnaie spéciale - [x] Bouton `Acheter` dans la frame recettes pour acheter les composants manquants disponibles chez un vendeur proche +### Trainer + +- [x] Endpoint côté bridge : `GET~TRAINER~~` +- [x] Réponses en paquets courts : `TRAINER_BEGIN`, `TRAINER_ITEM`, `TRAINER_ERROR`, `TRAINER_END` +- [x] Endpoint côté bridge : `RUN~TRAINER_LEARN~~~~` +- [x] Réponse d'action : `TRAINER_LEARN~~~~~OK|ERR~~~` +- [x] Validation du trainer sélectionné côté joueur pour éviter d'apprendre depuis un PNJ différent après ouverture +- [x] Coût calculé côté bridge avec réduction réputation et budget playerbots `free money for spells` +- [x] Bouton `Trainer` ajouté dans l'EveryBar après `Outfits` +- [x] Nouvelle frame trainer harmonisée avec les frames de quêtes, avec bouton `Apprendre` par sort et `Tout apprendre` + ### PVP Stats - [x] Endpoint côté bridge : `GET~PVP_STATS~` @@ -282,6 +293,7 @@ Avec beaucoup de bots, éviter les réponses globales trop grosses. - `GET~BOT_REPUTATIONS` répond en paquets `BOT_REPUTATIONS_BEGIN` / `BOT_REPUTATION_ITEM` / `BOT_REPUTATIONS_END` ; - `GET~BOT_EMBLEMS` répond en paquets `BOT_EMBLEMS_BEGIN` / `BOT_EMBLEM_ITEM` / `BOT_EMBLEMS_MONEY` / `BOT_EMBLEMS_END` ; - `GET~PROFESSION_RECIPES` répond en paquets `PROFESSION_RECIPES_BEGIN` / `PROFESSION_RECIPES_ITEM` / `PROFESSION_RECIPES_END` ; +- `GET~TRAINER` répond en paquets `TRAINER_BEGIN` / `TRAINER_ITEM` / `TRAINER_ERROR` / `TRAINER_END` ; - `GET~BANK` répond en paquets `BANK_BEGIN` / `BANK_ITEM` / `BANK_ERROR` / `BANK_END` ; - `GET~GBANK` répond en paquets `GBANK_BEGIN` / `GBANK_RIGHTS` / `GBANK_ITEM` / `GBANK_ERROR` / `GBANK_END` ; - un paquet global vide peut seulement servir de réponse vide si aucun bot n’est disponible. @@ -332,6 +344,12 @@ Les extensions inventaire banque, banque de guilde et achat vendeur sont mainten Les actions dépôt banque, retrait banque, dépôt banque de guilde, retrait banque de guilde et achat vendeur passent par `RUN~ITEM_ACTION`. Le retrait BDG est exposé dans l'UI avec un bouton grisé quand le bot n'a plus de droits de retrait disponibles. +### Trainer + +La commande `trainer` est sortie du parsing chat automatique pour l'UI. Le bouton `Trainer` de l'EveryBar ouvre une frame alimentée par `GET~TRAINER`, basée sur le trainer actuellement sélectionné par le joueur. + +L'apprentissage passe par `RUN~TRAINER_LEARN`, soit pour un `spellId` précis, soit pour `ALL`. Le bridge revalide le trainer sélectionné, vérifie que le trainer peut enseigner au bot, applique le coût exact et renvoie une raison structurée en cas d'échec. + ### Audit chatless final Les refresh automatiques UI les plus sensibles ne retombent plus en chat legacy par défaut. Le switch `MultiBot.allowLegacyChatFallback` permet de réactiver temporairement les anciens fallbacks pour diagnostic, mais la configuration propre reste `false`. @@ -399,6 +417,8 @@ Conclusion : ne pas migrer par réflexe. À traiter seulement si une fenêtre UI | Banque bot bridge | Fait | | Banque de guilde bot bridge | Fait | | Achat vendeur bridge | Fait | +| Trainer bridge | Fait | +| Trainer learn par sort / all | Fait | | PVP Stats bridge | Fait | | Stats simples bridge | Fait | | Quêtes bridge | Fait | diff --git a/docs/multibot_missing_commands_roadmap.md b/docs/multibot_missing_commands_roadmap.md index 65ffdc6..7ff2d48 100644 --- a/docs/multibot_missing_commands_roadmap.md +++ b/docs/multibot_missing_commands_roadmap.md @@ -9,7 +9,7 @@ Ce document suit les commandes `mod-playerbots` encore intéressantes à intégr - les commandes serveur/admin à ne pas intégrer dans l'addon ; - les priorités d'intégration bridge-first/chatless. -Le principe reste le même que pour Inventory, Spellbook, Glyphs, Talents, Stats, Quests, Outfits, RTI, Pull Control, Combat Strategies, Disperse, Loot Rules, Character Info, Reputations, Monnaies, Profession Recipes, Craft, Loot Master, Bot Bank, Guild Bank et Vendor Buy : +Le principe reste le même que pour Inventory, Spellbook, Glyphs, Talents, Stats, Quests, Outfits, RTI, Pull Control, Combat Strategies, Disperse, Loot Rules, Character Info, Reputations, Monnaies, Profession Recipes, Craft, Loot Master, Bot Bank, Guild Bank, Vendor Buy et Trainer : **éviter le spam chat automatique**, utiliser le bridge quand c'est possible, et conserver les commandes manuelles utiles comme `who`, `co ?`, `nc ?`, `ss ?`. --- @@ -35,6 +35,7 @@ Le principe reste le même que pour Inventory, Spellbook, Glyphs, Talents, Stats | Profession recipes | Fait | Nouvelle frame recettes par métier via `GET~PROFESSION_RECIPES`, composants, compte craftable, recettes à résultat direct ou indirect. | | Profession recipe craft | Fait | Bouton `Créer` via `RUN~CRAFT_RECIPE`, support recettes classiques et résultats aléatoires/indirects, erreurs détaillées. | | Bot bank / Guild bank / Vendor buy | Fait/Partiel | `GET~BANK`, `GET~GBANK` et `RUN~ITEM_ACTION` pour banque bot, banque de guilde bot avec retrait protégé, achat vendeur et actions inventaire avancées validées. | +| Trainer / Trainer learn | Fait | Frame EveryBar via `GET~TRAINER`, boutons `Apprendre` et `Tout apprendre` via `RUN~TRAINER_LEARN`, sans parsing chat automatique. | | RTI / Target Icons | Fait | UI complète + bridge `RUN~RTI`, scopes `ALL`, `GROUP`, `BOT`. | | Pull Control | Fait | Mini-frame MainBar + bridge `RUN~COMBAT`, séquences de commandes, scopes et presets. | | Combat Strategies | Fait | Toggles individuels dans les EveryBars + mini-frame Party/Raid, via `RUN~COMBAT`. | @@ -259,6 +260,24 @@ Fonctionnalités terminées : - consultation de la banque de guilde du bot sans exiger que le joueur soit dans la même guilde ; - harmonisation visuelle des frames banque bot et BDG avec fond interne sombre. +### Trainer / apprentissage + +Les commandes trainer sont maintenant exposées en bridge-first pour les bots individuels. + +Fonctionnalités terminées : + +- bouton `Trainer` ajouté dans l'EveryBar de chaque bot, après `Outfits` ; +- frame trainer harmonisée avec le style des frames quêtes ; +- endpoint bridge `GET~TRAINER~~` ; +- payloads structurés `TRAINER_BEGIN`, `TRAINER_ITEM`, `TRAINER_ERROR`, `TRAINER_END` ; +- affichage des sorts que le bot peut apprendre depuis le trainer sélectionné par le joueur ; +- affichage du coût et désactivation du bouton si le bot n'a pas assez d'argent disponible selon le budget playerbots ; +- endpoint bridge `RUN~TRAINER_LEARN~~~~` ; +- bouton `Apprendre` par sort ; +- bouton `Tout apprendre` ; +- revalidation côté bridge du trainer sélectionné pour éviter d'apprendre depuis un autre PNJ si la cible change ; +- retour structuré `TRAINER_LEARN` avec résultat, raison, nombre appris et coût dépensé. + À garder pour plus tard : - affichage de l'argent de guilde ;