diff --git a/MailboxCourier/MailboxCourier.lua b/MailboxCourier/MailboxCourier.lua new file mode 100644 index 0000000..59848d8 --- /dev/null +++ b/MailboxCourier/MailboxCourier.lua @@ -0,0 +1,543 @@ +local ADDON_NAME = "MailboxCourier" + +local DEFAULTS = { + recipient = "", + subject = "Auto mail", + interval = 1.2, + dryRun = false, + autoConfirmUnknownRecipient = true, + skipSoulbound = true, + skipQuest = true, + skipContainers = true, + blacklist = { + [6948] = true, -- Hearthstone + [7005] = true, -- Skinning Knife + }, +} + +local state = { + running = false, + mailboxOpen = false, + queue = {}, + batch = {}, + batchCount = 0, + sentItems = 0, + sentMails = 0, + nextAt = 0, + waitingForSuccess = false, + lastConfirmAt = 0, + lastError = "", +} + +local frame = CreateFrame("Frame") +local tooltip = CreateFrame("GameTooltip", "MailboxCourierTooltip", UIParent, "GameTooltipTemplate") +tooltip:SetOwner(UIParent, "ANCHOR_NONE") + +local function copyDefaults(src, dst) + dst = dst or {} + for key, value in pairs(src) do + if type(value) == "table" then + dst[key] = copyDefaults(value, dst[key]) + elseif dst[key] == nil then + dst[key] = value + end + end + return dst +end + +local function db() + MailboxCourierDB = copyDefaults(DEFAULTS, MailboxCourierDB or {}) + return MailboxCourierDB +end + +local function trim(text) + return tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") +end + +local function log(message) + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[" .. ADDON_NAME .. "]|r " .. tostring(message)) +end + +local function parseItemId(itemLink) + if not itemLink then + return nil + end + local itemId = itemLink:match("item:(%d+)") + return itemId and tonumber(itemId) or nil +end + +local function clearSendMailAttachments() + ClearCursor() + if ClearSendMail then + ClearSendMail() + return + end + for index = 1, 12 do + if HasSendMailItem and HasSendMailItem(index) then + ClickSendMailItemButton(index) + ClearCursor() + end + end +end + +local function tooltipHasText(bag, slot, needles) + tooltip:ClearLines() + tooltip:SetBagItem(bag, slot) + for i = 1, tooltip:NumLines() do + local line = _G["MailboxCourierTooltipTextLeft" .. i] + local text = line and line:GetText() + if text then + for _, needle in ipairs(needles) do + if needle and needle ~= "" and text:find(needle, 1, true) then + return true + end + end + end + end + return false +end + +local function isSoulbound(bag, slot) + return tooltipHasText(bag, slot, { + ITEM_SOULBOUND, + "Soulbound", + "灵魂绑定", + "靈魂綁定", + }) +end + +local function isQuestItem(bag, slot) + return tooltipHasText(bag, slot, { + ITEM_BIND_QUEST, + "Quest Item", + "任务物品", + "任務物品", + }) +end + +local function isContainerItem(itemId, itemLink) + local name, link, quality, itemLevel, reqLevel, itemType, itemSubType, stackCount, equipLoc = GetItemInfo(itemId or itemLink or "") + if equipLoc == "INVTYPE_BAG" then + return true + end + if itemType == ITEM_CLASS_CONTAINER or itemType == "Container" or itemType == "容器" or itemType == "容器" then + return true + end + return false +end + +local function getContainerItemInfoCompat(bag, slot) + local texture, count, locked, quality, readable, lootable, itemLink + if C_Container and C_Container.GetContainerItemInfo then + local info = C_Container.GetContainerItemInfo(bag, slot) + if not info then + return nil + end + texture = info.iconFileID + count = info.stackCount + locked = info.isLocked + quality = info.quality + readable = info.isReadable + lootable = info.hasLoot + itemLink = info.hyperlink + else + texture, count, locked, quality, readable, lootable, itemLink = GetContainerItemInfo(bag, slot) + if not itemLink and GetContainerItemLink then + itemLink = GetContainerItemLink(bag, slot) + end + end + if not itemLink then + return nil + end + return { + texture = texture, + count = count or 1, + locked = locked, + quality = quality, + readable = readable, + lootable = lootable, + itemLink = itemLink, + itemId = parseItemId(itemLink), + } +end + +local function getContainerNumSlotsCompat(bag) + if C_Container and C_Container.GetContainerNumSlots then + return C_Container.GetContainerNumSlots(bag) or 0 + end + return GetContainerNumSlots(bag) or 0 +end + +local function shouldSkipItem(bag, slot, info) + local cfg = db() + local itemId = info.itemId + + if info.locked then + return true, "locked" + end + if itemId and cfg.blacklist[itemId] then + return true, "blacklisted" + end + if cfg.skipSoulbound and isSoulbound(bag, slot) then + return true, "soulbound" + end + if cfg.skipQuest and isQuestItem(bag, slot) then + return true, "quest" + end + if cfg.skipContainers and isContainerItem(itemId, info.itemLink) then + return true, "container" + end + return false, nil +end + +local function scanBags() + local queue = {} + local skipped = {} + + for bag = 0, 4 do + for slot = 1, getContainerNumSlotsCompat(bag) do + local info = getContainerItemInfoCompat(bag, slot) + if info then + local skip, reason = shouldSkipItem(bag, slot, info) + if skip then + skipped[reason] = (skipped[reason] or 0) + 1 + else + table.insert(queue, { + bag = bag, + slot = slot, + itemId = info.itemId, + itemLink = info.itemLink, + count = info.count or 1, + }) + end + end + end + end + + return queue, skipped +end + +local function stop(reason) + state.running = false + state.waitingForSuccess = false + state.queue = {} + state.batch = {} + state.batchCount = 0 + state.nextAt = 0 + clearSendMailAttachments() + if reason and reason ~= "" then + log(reason) + end +end + +local function popNextUnlockedItem() + while #state.queue > 0 do + local item = table.remove(state.queue, 1) + local info = getContainerItemInfoCompat(item.bag, item.slot) + if info and not info.locked and parseItemId(info.itemLink) == item.itemId then + local skip = shouldSkipItem(item.bag, item.slot, info) + if not skip then + return item + end + end + end + return nil +end + +local function attachNextBatch() + clearSendMailAttachments() + state.batch = {} + state.batchCount = 0 + + for attachment = 1, 12 do + local item = popNextUnlockedItem() + if not item then + break + end + + ClearCursor() + PickupContainerItem(item.bag, item.slot) + if CursorHasItem and not CursorHasItem() then + log("无法拿起物品,跳过: " .. tostring(item.itemLink)) + else + ClickSendMailItemButton(attachment) + ClearCursor() + table.insert(state.batch, item) + state.batchCount = state.batchCount + 1 + end + end + + return state.batchCount > 0 +end + +local function sendCurrentBatch() + local cfg = db() + local recipient = trim(cfg.recipient) + if recipient == "" then + stop("没有收件人。先输入 /mc to 角色名") + return + end + + local subject = trim(cfg.subject) + if subject == "" then + subject = "Auto mail" + end + subject = subject .. " " .. (state.sentMails + 1) + + if cfg.dryRun then + state.sentItems = state.sentItems + state.batchCount + state.sentMails = state.sentMails + 1 + log("dry-run: 模拟发送第 " .. state.sentMails .. " 封,附件 " .. state.batchCount .. " 件") + clearSendMailAttachments() + state.nextAt = GetTime() + cfg.interval + return + end + + state.waitingForSuccess = true + state.nextAt = GetTime() + 8 + SendMail(recipient, subject, "") + log("已提交第 " .. (state.sentMails + 1) .. " 封,附件 " .. state.batchCount .. " 件") +end + +local function maybeConfirmMailPopup() + local cfg = db() + if not cfg.autoConfirmUnknownRecipient then + return false + end + if not state.running or not state.waitingForSuccess then + return false + end + if GetTime() - state.lastConfirmAt < 0.5 then + return false + end + + local recipient = trim(cfg.recipient) + for i = 1, 4 do + local popup = _G["StaticPopup" .. i] + if popup and popup:IsShown() then + local which = tostring(popup.which or "") + local textFrame = _G["StaticPopup" .. i .. "Text"] + local text = tostring(textFrame and textFrame:GetText() or "") + local lowerText = text:lower() + local looksLikeMailPopup = + which:find("MAIL", 1, true) + or lowerText:find("mail", 1, true) + or lowerText:find("recipient", 1, true) + or lowerText:find("send", 1, true) + or (recipient ~= "" and text:find(recipient, 1, true)) + + if looksLikeMailPopup then + local button = _G["StaticPopup" .. i .. "Button1"] + if button and (not button.IsEnabled or button:IsEnabled()) then + state.lastConfirmAt = GetTime() + button:Click() + log("Auto-confirmed mail warning popup.") + return true + end + end + end + end + + return false +end + +local function process() + if not state.running then + return + end + if state.waitingForSuccess then + maybeConfirmMailPopup() + end + if GetTime() < state.nextAt then + return + end + if state.waitingForSuccess then + stop("等待邮件发送成功超时,已停止。最后错误: " .. tostring(state.lastError)) + return + end + if not state.mailboxOpen then + stop("邮箱没有打开,已停止。") + return + end + + if attachNextBatch() then + sendCurrentBatch() + else + stop("邮寄完成,共发送 " .. state.sentMails .. " 封," .. state.sentItems .. " 件物品。") + end +end + +local function beginSend() + local cfg = db() + local recipient = trim(cfg.recipient) + if recipient == "" then + log("没有收件人。用法: /mc to 角色名") + return + end + if not state.mailboxOpen then + log("邮箱还没打开。请先和邮箱交互。") + return + end + + state.queue = {} + state.batch = {} + state.batchCount = 0 + state.sentItems = 0 + state.sentMails = 0 + state.lastError = "" + state.waitingForSuccess = false + + local skipped + state.queue, skipped = scanBags() + if #state.queue == 0 then + log("没有找到可邮寄物品。") + return + end + + state.running = true + state.nextAt = GetTime() + 0.2 + log("开始邮寄给 " .. recipient .. ",待发送 " .. #state.queue .. " 个背包格。") + + local parts = {} + for reason, count in pairs(skipped or {}) do + table.insert(parts, reason .. "=" .. count) + end + if #parts > 0 then + log("已跳过: " .. table.concat(parts, ", ")) + end +end + +local function showStatus() + local cfg = db() + log("收件人: " .. (trim(cfg.recipient) ~= "" and cfg.recipient or "未设置")) + log("主题: " .. cfg.subject .. ";间隔: " .. tostring(cfg.interval) .. " 秒;dry-run: " .. tostring(cfg.dryRun)) + log("命令: /mc to 角色名, /mc send, /mc stop, /mc dryrun on|off, /mc black add|del itemID") +end + +local function setRecipient(name) + name = trim(name) + if name == "" then + log("用法: /mc to 角色名") + return + end + db().recipient = name + log("收件人已设置为: " .. name) +end + +local function setSubject(subject) + subject = trim(subject) + if subject == "" then + log("用法: /mc subject 邮件主题") + return + end + db().subject = subject + log("邮件主题已设置为: " .. subject) +end + +local function setDryRun(value) + value = trim(value):lower() + if value == "on" or value == "1" or value == "true" then + db().dryRun = true + elseif value == "off" or value == "0" or value == "false" then + db().dryRun = false + else + log("用法: /mc dryrun on|off") + return + end + log("dry-run: " .. tostring(db().dryRun)) +end + +local function setAutoConfirm(value) + value = trim(value):lower() + if value == "on" or value == "1" or value == "true" then + db().autoConfirmUnknownRecipient = true + elseif value == "off" or value == "0" or value == "false" then + db().autoConfirmUnknownRecipient = false + else + log("Usage: /mc autoconfirm on|off") + return + end + log("auto-confirm unknown recipient: " .. tostring(db().autoConfirmUnknownRecipient)) +end + +local function updateBlacklist(rest) + local action, idText = trim(rest):match("^(%S+)%s+(%S+)$") + local itemId = tonumber(idText or "") + if not action or not itemId then + log("用法: /mc black add itemID 或 /mc black del itemID") + return + end + action = action:lower() + if action == "add" then + db().blacklist[itemId] = true + log("已加入黑名单 itemID: " .. itemId) + elseif action == "del" or action == "remove" then + db().blacklist[itemId] = nil + log("已移出黑名单 itemID: " .. itemId) + else + log("用法: /mc black add itemID 或 /mc black del itemID") + end +end + +SLASH_MAILBOXCOURIER1 = "/mc" +SLASH_MAILBOXCOURIER2 = "/mailboxcourier" +SlashCmdList["MAILBOXCOURIER"] = function(msg) + local command, rest = trim(msg):match("^(%S*)%s*(.-)$") + command = (command or ""):lower() + rest = rest or "" + + if command == "to" then + setRecipient(rest) + elseif command == "subject" then + setSubject(rest) + elseif command == "send" then + beginSend() + elseif command == "stop" then + stop("已手动停止。") + elseif command == "dryrun" then + setDryRun(rest) + elseif command == "autoconfirm" then + setAutoConfirm(rest) + elseif command == "black" or command == "blacklist" then + updateBlacklist(rest) + else + showStatus() + end +end + +frame:RegisterEvent("ADDON_LOADED") +frame:RegisterEvent("MAIL_SHOW") +frame:RegisterEvent("MAIL_CLOSED") +frame:RegisterEvent("MAIL_SEND_SUCCESS") +frame:RegisterEvent("UI_ERROR_MESSAGE") + +frame:SetScript("OnEvent", function(_, event, arg1, arg2) + if event == "ADDON_LOADED" and arg1 == ADDON_NAME then + db() + log("已加载。输入 /mc 查看命令。") + elseif event == "MAIL_SHOW" then + state.mailboxOpen = true + log("邮箱已打开。") + elseif event == "MAIL_CLOSED" then + state.mailboxOpen = false + if state.running then + stop("邮箱关闭,已停止。") + end + elseif event == "MAIL_SEND_SUCCESS" then + state.sentItems = state.sentItems + state.batchCount + state.sentMails = state.sentMails + 1 + state.waitingForSuccess = false + state.batch = {} + state.batchCount = 0 + clearSendMailAttachments() + state.nextAt = GetTime() + db().interval + log("第 " .. state.sentMails .. " 封发送成功,累计 " .. state.sentItems .. " 件。") + elseif event == "UI_ERROR_MESSAGE" then + state.lastError = tostring(arg2 or arg1 or "") + if state.running and state.lastError ~= "" then + log("错误: " .. state.lastError) + end + end +end) + +frame:SetScript("OnUpdate", process) diff --git a/MailboxCourier/MailboxCourier.toc b/MailboxCourier/MailboxCourier.toc new file mode 100644 index 0000000..3c7482d --- /dev/null +++ b/MailboxCourier/MailboxCourier.toc @@ -0,0 +1,8 @@ +## Interface: 30300 +## Title: MailboxCourier +## Notes: Open a mailbox, then batch mail bag items to a saved recipient. +## Author: Codex +## Version: 0.1.0 +## SavedVariablesPerCharacter: MailboxCourierDB + +MailboxCourier.lua diff --git a/MailboxCourier/README.md b/MailboxCourier/README.md new file mode 100644 index 0000000..e12c73c --- /dev/null +++ b/MailboxCourier/README.md @@ -0,0 +1,42 @@ +# MailboxCourier + +Wrath 3.3.5 addon for testing mailbox batch sending. + +## Install + +Copy the `MailboxCourier` folder into: + +```text +World of Warcraft/Interface/AddOns/ +``` + +Then reload UI or restart the game. + +## Use + +1. Open a mailbox. +2. Set recipient: + +```text +/mc to CharacterName +``` + +3. Send eligible bag items: + +```text +/mc send +``` + +Useful commands: + +```text +/mc +/mc stop +/mc subject Auto mail +/mc dryrun on +/mc dryrun off +/mc black add 6948 +/mc black del 6948 +``` + +The addon skips soulbound items, quest items, containers, locked items, and blacklisted item IDs. Hearthstone item ID `6948` and Skinning Knife item ID `7005` are blacklisted by default. diff --git a/auto_bot_move.py b/auto_bot_move.py index b442257..3e3a89d 100644 --- a/auto_bot_move.py +++ b/auto_bot_move.py @@ -256,6 +256,7 @@ class AutoBotMove: turn_error_key=None, turn_error_hold_sec=None, distance_interact_pause_sec=None, + mailbox_route_path=None, ): self.last_tab_time = 0 self.last_interaction_time = 0 # 记录上一次按互动键的时间 @@ -324,6 +325,17 @@ class AutoBotMove: self.logistics_manager = LogisticsManager(vendor_file) self.logistics_manager.bag_full_hearthstone = bool(layout.get("bag_full_hearthstone", False)) self.logistics_manager.hearthstone_key = str(layout.get("hearthstone_key", "b") or "b") + self.logistics_manager.enable_bag_full_mail = bool(layout.get("enable_bag_full_mail", False)) + self.logistics_manager.mailbox_route_file = str( + mailbox_route_path + or layout.get("mailbox_route_json", os.path.join("recorder", "mailbox.json")) + or os.path.join("recorder", "mailbox.json") + ) + self.logistics_manager.mailbox_interact_key = str(layout.get("mailbox_interact_key", "8") or "8") + self.logistics_manager.mail_recipient_key = str(layout.get("mail_recipient_key", "") or "") + self.logistics_manager.mail_send_key = str(layout.get("mail_send_key", "f8") or "f8") + self.logistics_manager.mailbox_open_wait_sec = float(layout.get("mailbox_open_wait_sec", 2.0)) + self.logistics_manager.mail_send_wait_sec = float(layout.get("mail_send_wait_sec", 60.0)) def _has_prepare_route(self) -> bool: return bool(self.prepare_route_waypoints) @@ -721,8 +733,19 @@ class AutoBotMove: self.patrol_controller.stop_all() self.is_moving = False self.patrol_controller.reset_stuck() - # 勾选"包满炉石回城":按炉石后触发停止回调 - if self.logistics_manager.bag_full_hearthstone: + bag_full_now = int(state.get('free_slots', 0) or 0) < 2 + if bag_full_now and self.logistics_manager.enable_bag_full_mail: + get_state_fn = (lambda: None if self._should_stop() else parse_game_state()) + self.logistics_manager.run_bag_full_mail_flow( + get_state_fn, + self.patrol_controller, + stop_check=self._should_stop, + ) + if callable(getattr(self, '_on_hearthstone_stop', None)): + self._on_hearthstone_stop() + return + # 勾选"包满炉石回城":只有真正包满时才炉石;耐久低仍走修理路线 + if bag_full_now and self.logistics_manager.bag_full_hearthstone: get_state_fn = (lambda: None if self._should_stop() else parse_game_state()) self.logistics_manager.use_hearthstone_and_stop(get_state=get_state_fn) if callable(getattr(self, '_on_hearthstone_stop', None)): diff --git a/game_state.py b/game_state.py index a1cde60..e3b9f89 100644 --- a/game_state.py +++ b/game_state.py @@ -31,6 +31,14 @@ _DEFAULTS = { # 炉石回城 "hearthstone_key": "b", "bag_full_hearthstone": False, + # 包满炉石后跑邮箱,并触发 MailboxCourier 插件宏 + "enable_bag_full_mail": False, + "mailbox_route_json": "recorder/mailbox.json", + "mailbox_interact_key": "8", + "mail_recipient_key": "", + "mail_send_key": "f8", + "mailbox_open_wait_sec": 2.0, + "mail_send_wait_sec": 60.0, } SCREENSHOT_DIR = 'screenshot' diff --git a/logistics_manager.py b/logistics_manager.py index 1ee6052..dbd7764 100644 --- a/logistics_manager.py +++ b/logistics_manager.py @@ -1,5 +1,6 @@ import json import math +import os import time from hardware_control import hw_ctrl @@ -20,6 +21,32 @@ class LogisticsManager: self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理 self.hearthstone_key = "b" # 炉石按键 self.hearthstone_cast_sec = 10.0 # 炉石施法等待秒数 + self.enable_bag_full_mail = False + self.mailbox_route_file = os.path.join("recorder", "mailbox.json") + self.mailbox_interact_key = "8" + self.mail_recipient_key = "" + self.mail_send_key = "f8" + self.mailbox_open_wait_sec = 2.0 + self.mail_send_wait_sec = 60.0 + + def _resolve_path(self, path): + if not path: + return "" + path = str(path) + if os.path.isabs(path): + return path + cwd_path = os.path.abspath(path) + if os.path.exists(cwd_path): + return cwd_path + return os.path.join(os.path.dirname(os.path.abspath(__file__)), path) + + def _sleep_with_stop(self, seconds, stop_check=None): + end_at = time.time() + max(0.0, float(seconds)) + while time.time() < end_at: + if callable(stop_check) and stop_check(): + return False + time.sleep(min(0.1, end_at - time.time())) + return True def check_logistics(self, state): """ @@ -109,6 +136,83 @@ class LogisticsManager: hw_ctrl.press("4") time.sleep(2) + def run_bag_full_mail_flow(self, get_state, patrol, stop_check=None): + """ + 包满邮寄第一版流程: + 炉石 -> 跑到邮箱路线终点 -> 交互打开邮箱 -> 按 MailboxCourier 宏键 -> 等待后停止。 + Python 不判断邮件是否发完,发送细节交给游戏内插件。 + """ + route_file = self._resolve_path(self.mailbox_route_file) + if not route_file or not os.path.exists(route_file): + print(f">>> [后勤-邮箱] 邮箱路线不存在,已停止: {route_file}") + self.is_returning = False + return False + + if callable(stop_check) and stop_check(): + self.is_returning = False + return False + + ok = self.use_hearthstone_and_stop(get_state=get_state) + if not ok: + print(">>> [后勤-邮箱] 炉石失败,未继续跑邮箱。") + self.is_returning = False + return False + + try: + with open(route_file, "r", encoding="utf-8") as f: + path = json.load(f) + except Exception as exc: + print(f">>> [后勤-邮箱] 邮箱路线读取失败: {exc}") + self.is_returning = False + return False + + if not path: + print(f">>> [后勤-邮箱] 邮箱路线为空,已停止: {route_file}") + self.is_returning = False + return False + + print(f">>> [后勤-邮箱] 开始跑邮箱路线: {route_file}") + old_enable_mount = getattr(patrol, "enable_mount", None) + if old_enable_mount is not None: + patrol.enable_mount = False + try: + ok = patrol.navigate_path(get_state, path, forward=True, arrival_threshold=VENDOR_ARRIVAL_THRESHOLD) + finally: + if old_enable_mount is not None: + patrol.enable_mount = old_enable_mount + if not ok: + print(">>> [后勤-邮箱] 邮箱路线未完成,已停止。") + self.is_returning = False + return False + + if callable(stop_check) and stop_check(): + self.is_returning = False + return False + + patrol.stop_all() + print(f">>> [后勤-邮箱] 到达邮箱附近,按交互键: {self.mailbox_interact_key}") + hw_ctrl.press(self.mailbox_interact_key) + if not self._sleep_with_stop(self.mailbox_open_wait_sec, stop_check=stop_check): + self.is_returning = False + return False + + if self.mail_recipient_key: + print(f">>> [后勤-邮箱] 触发 MailboxCourier 收件人宏: {self.mail_recipient_key}") + hw_ctrl.press(self.mail_recipient_key) + if not self._sleep_with_stop(0.5, stop_check=stop_check): + self.is_returning = False + return False + + print(f">>> [后勤-邮箱] 触发 MailboxCourier 发送宏: {self.mail_send_key}") + hw_ctrl.press(self.mail_send_key) + if not self._sleep_with_stop(self.mail_send_wait_sec, stop_check=stop_check): + self.is_returning = False + return False + + print(">>> [后勤-邮箱] 邮寄宏已触发,流程结束。") + self.is_returning = False + return True + def run_route1_round(self, get_state, patrol, route_file=None): """ 读取 route1.json 路径,先正向走完,执行交互(8、4),再反向走完,然后结束。 diff --git a/wow_multikey_gui.py b/wow_multikey_gui.py index 488087f..7f0e3fb 100644 --- a/wow_multikey_gui.py +++ b/wow_multikey_gui.py @@ -305,6 +305,7 @@ class GameLoopWorker(QThread): waypoints_path=None, prepare_route_path=None, vendor_path=None, + mailbox_route_path=None, record_filename=None, record_min_distance=None, attack_loop_path=None, @@ -342,6 +343,7 @@ class GameLoopWorker(QThread): self.waypoints_path = waypoints_path self.prepare_route_path = prepare_route_path self.vendor_path = vendor_path + self.mailbox_route_path = mailbox_route_path self.record_filename = record_filename or 'waypoints' self.record_min_distance = record_min_distance self.attack_loop_path = attack_loop_path or None @@ -422,6 +424,7 @@ class GameLoopWorker(QThread): turn_error_key=self.turn_error_key, turn_error_hold_sec=self.turn_error_hold_sec, distance_interact_pause_sec=self.distance_interact_pause_sec, + mailbox_route_path=self.mailbox_route_path, ) self.bot_move._on_hearthstone_stop = self.stop_signal.emit except ImportError as e: @@ -723,6 +726,8 @@ class WoWMultiKeyGUI(QMainWindow): self.waypoints_combo.setMinimumWidth(200) self.vendor_combo = QComboBox() self.vendor_combo.setMinimumWidth(200) + self.mailbox_route_combo = QComboBox() + self.mailbox_route_combo.setMinimumWidth(200) self.patrol_attack_loop_combo = QComboBox() self.patrol_attack_loop_combo.setMinimumWidth(200) self.prepare_route_combo = QComboBox() @@ -733,6 +738,7 @@ class WoWMultiKeyGUI(QMainWindow): patrol_layout.addRow("准备路线 JSON:", self.prepare_route_combo) patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo) patrol_layout.addRow("修理商 JSON:", self.vendor_combo) + patrol_layout.addRow("邮箱路线 JSON:", self.mailbox_route_combo) self.resurrection_route_a_combo = QComboBox() self.resurrection_route_a_combo.setMinimumWidth(200) self.resurrection_route_b_combo = QComboBox() @@ -1110,6 +1116,30 @@ class WoWMultiKeyGUI(QMainWindow): self.gs_hearthstone_key.setText("b") self.gs_bag_full_hearthstone = QCheckBox("包满时用炉石回城并停止") self.gs_bag_full_hearthstone.setChecked(False) + self.gs_enable_bag_full_mail = QCheckBox("包满炉石后跑邮箱并邮寄") + self.gs_enable_bag_full_mail.setChecked(False) + self.gs_mailbox_interact_key = QLineEdit() + self.gs_mailbox_interact_key.setPlaceholderText("如 8") + self.gs_mailbox_interact_key.setMaxLength(16) + self.gs_mailbox_interact_key.setText("8") + self.gs_mail_recipient_key = QLineEdit() + self.gs_mail_recipient_key.setPlaceholderText("如 f7") + self.gs_mail_recipient_key.setMaxLength(16) + self.gs_mail_recipient_key.setText("") + self.gs_mail_send_key = QLineEdit() + self.gs_mail_send_key.setPlaceholderText("如 f8") + self.gs_mail_send_key.setMaxLength(16) + self.gs_mail_send_key.setText("f8") + self.gs_mailbox_open_wait = QDoubleSpinBox() + self.gs_mailbox_open_wait.setRange(0.1, 30.0) + self.gs_mailbox_open_wait.setSingleStep(0.1) + self.gs_mailbox_open_wait.setValue(2.0) + self.gs_mailbox_open_wait.setSuffix(" 秒") + self.gs_mail_send_wait = QDoubleSpinBox() + self.gs_mail_send_wait.setRange(0.0, 600.0) + self.gs_mail_send_wait.setSingleStep(1.0) + self.gs_mail_send_wait.setValue(60.0) + self.gs_mail_send_wait.setSuffix(" 秒") self.gs_enable_mount = QCheckBox("启用上马") self.gs_enable_mount.setChecked(True) self.gs_mount_key = QLineEdit() @@ -1175,7 +1205,18 @@ class WoWMultiKeyGUI(QMainWindow): game_grid.addWidget(self.gs_use_hardware_input, 8, 0, 1, 2) game_grid.addWidget(QLabel("复活按键:"), 6, 2) game_grid.addWidget(self.gs_resurrect_key, 6, 3) - game_grid.addWidget(self.gs_bag_full_hearthstone, 9, 1) + game_grid.addWidget(self.gs_bag_full_hearthstone, 9, 0, 1, 2) + game_grid.addWidget(self.gs_enable_bag_full_mail, 9, 2, 1, 2) + game_grid.addWidget(QLabel("邮箱交互键:"), 10, 0) + game_grid.addWidget(self.gs_mailbox_interact_key, 10, 1) + game_grid.addWidget(QLabel("收信人按键:"), 10, 2) + game_grid.addWidget(self.gs_mail_recipient_key, 10, 3) + game_grid.addWidget(QLabel("邮寄宏按键:"), 11, 0) + game_grid.addWidget(self.gs_mail_send_key, 11, 1) + game_grid.addWidget(QLabel("邮箱打开等待:"), 11, 2) + game_grid.addWidget(self.gs_mailbox_open_wait, 11, 3) + game_grid.addWidget(QLabel("邮寄后等待:"), 12, 0) + game_grid.addWidget(self.gs_mail_send_wait, 12, 1) params_layout.addWidget(game_group) @@ -1211,10 +1252,17 @@ class WoWMultiKeyGUI(QMainWindow): self.gs_scan_height.setValue(cfg.get('scan_region_height', 15)) self.gs_offset_left.setValue(cfg.get('offset_left', 20)) self.gs_offset_top.setValue(cfg.get('offset_top', 45)) + self.gs_enable_mount.setChecked(bool(cfg.get('enable_mount', True))) self.gs_mount_key.setText(str(cfg.get('mount_key', 'x') or 'x')) self.gs_mount_hold.setValue(float(cfg.get('mount_hold_sec', 1.6))) self.gs_hearthstone_key.setText(str(cfg.get('hearthstone_key', 'b') or 'b')) self.gs_bag_full_hearthstone.setChecked(bool(cfg.get('bag_full_hearthstone', False))) + self.gs_enable_bag_full_mail.setChecked(bool(cfg.get('enable_bag_full_mail', False))) + self.gs_mailbox_interact_key.setText(str(cfg.get('mailbox_interact_key', '8') or '8')) + self.gs_mail_recipient_key.setText(str(cfg.get('mail_recipient_key', '') or '')) + self.gs_mail_send_key.setText(str(cfg.get('mail_send_key', 'f8') or 'f8')) + self.gs_mailbox_open_wait.setValue(float(cfg.get('mailbox_open_wait_sec', 2.0))) + self.gs_mail_send_wait.setValue(float(cfg.get('mail_send_wait_sec', 60.0))) self.gs_mount_retry.setValue(float(cfg.get('mount_retry_after_sec', 2.0))) self.gs_release_spirit_key.setText(str(cfg.get('release_spirit_key', '9') or '9')) self.gs_resurrect_key.setText(str(cfg.get('resurrect_key', '0') or '0')) @@ -1254,11 +1302,18 @@ class WoWMultiKeyGUI(QMainWindow): cfg['scan_region_height'] = self.gs_scan_height.value() cfg['offset_left'] = self.gs_offset_left.value() cfg['offset_top'] = self.gs_offset_top.value() + cfg['enable_mount'] = self.gs_enable_mount.isChecked() cfg['mount_key'] = (self.gs_mount_key.text().strip() or 'x') cfg['mount_hold_sec'] = float(self.gs_mount_hold.value()) cfg['mount_retry_after_sec'] = float(self.gs_mount_retry.value()) cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b') cfg['bag_full_hearthstone'] = self.gs_bag_full_hearthstone.isChecked() + cfg['enable_bag_full_mail'] = self.gs_enable_bag_full_mail.isChecked() + cfg['mailbox_interact_key'] = self.gs_mailbox_interact_key.text().strip() or '8' + cfg['mail_recipient_key'] = self.gs_mail_recipient_key.text().strip() + cfg['mail_send_key'] = self.gs_mail_send_key.text().strip() or 'f8' + cfg['mailbox_open_wait_sec'] = float(self.gs_mailbox_open_wait.value()) + cfg['mail_send_wait_sec'] = float(self.gs_mail_send_wait.value()) cfg['release_spirit_key'] = (self.gs_release_spirit_key.text().strip() or '9') cfg['resurrect_key'] = (self.gs_resurrect_key.text().strip() or '0') path = save_layout_config(cfg) @@ -1471,6 +1526,16 @@ class WoWMultiKeyGUI(QMainWindow): ] if hasattr(self, "repair_vendor_combo"): combos_with_default.append((self.repair_vendor_combo, 'vendor.json')) + if hasattr(self, "mailbox_route_combo"): + self.mailbox_route_combo.blockSignals(True) + self.mailbox_route_combo.clear() + self.mailbox_route_combo.addItem("-- 置空(不跑邮箱路线) --", "") + for name, path in items: + self.mailbox_route_combo.addItem(name, path) + idx = self.mailbox_route_combo.findData(os.path.join(get_recorder_dir(), 'mailbox.json')) + if idx >= 0: + self.mailbox_route_combo.setCurrentIndex(idx) + self.mailbox_route_combo.blockSignals(False) for combo, default_name in combos_with_default: combo.blockSignals(True) combo.clear() @@ -1635,6 +1700,7 @@ class WoWMultiKeyGUI(QMainWindow): waypoints_path = None prepare_route_path = None vendor_path = None + mailbox_route_path = None flight_json_path = None resurrection_route_a_path = None resurrection_route_b_path = None @@ -1642,6 +1708,7 @@ class WoWMultiKeyGUI(QMainWindow): prep = self.prepare_route_combo.currentData() or "" wp = self.waypoints_combo.currentData() or "" vp = self.vendor_combo.currentData() or "" + mp = self.mailbox_route_combo.currentData() or "" route_a = self.resurrection_route_a_combo.currentData() or "" route_b = self.resurrection_route_b_combo.currentData() or "" if not wp: @@ -1659,6 +1726,13 @@ class WoWMultiKeyGUI(QMainWindow): if prep and not os.path.exists(prep): QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}") return + if self.gs_enable_bag_full_mail.isChecked(): + if not mp: + QMessageBox.warning(self, "提示", "已启用包满邮寄,请选择邮箱路线 JSON 文件") + return + if not os.path.exists(mp): + QMessageBox.warning(self, "提示", f"邮箱路线文件不存在: {mp}") + return if route_a and not os.path.exists(route_a): QMessageBox.warning(self, "提示", f"复活路线 A 文件不存在: {route_a}") return @@ -1668,6 +1742,7 @@ class WoWMultiKeyGUI(QMainWindow): waypoints_path = wp prepare_route_path = prep or None vendor_path = vp + mailbox_route_path = mp or None resurrection_route_a_path = route_a or None resurrection_route_b_path = route_b or None attack_loop_path = None @@ -1753,6 +1828,7 @@ class WoWMultiKeyGUI(QMainWindow): self.game_worker = GameLoopWorker( mode, waypoints_path=waypoints_path, prepare_route_path=prepare_route_path, vendor_path=vendor_path, + mailbox_route_path=mailbox_route_path, attack_loop_path=attack_loop_path, skinning_wait_sec=skinning_wait_sec, food_key=food_key,