Add bag full mailbox mailing flow

This commit is contained in:
王鹏
2026-05-07 08:33:10 +08:00
parent a9e162f00e
commit 2ee9f9d10d
7 changed files with 807 additions and 3 deletions

View File

@@ -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)

View File

@@ -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

42
MailboxCourier/README.md Normal file
View File

@@ -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.

View File

@@ -256,6 +256,7 @@ class AutoBotMove:
turn_error_key=None, turn_error_key=None,
turn_error_hold_sec=None, turn_error_hold_sec=None,
distance_interact_pause_sec=None, distance_interact_pause_sec=None,
mailbox_route_path=None,
): ):
self.last_tab_time = 0 self.last_tab_time = 0
self.last_interaction_time = 0 # 记录上一次按互动键的时间 self.last_interaction_time = 0 # 记录上一次按互动键的时间
@@ -324,6 +325,17 @@ class AutoBotMove:
self.logistics_manager = LogisticsManager(vendor_file) self.logistics_manager = LogisticsManager(vendor_file)
self.logistics_manager.bag_full_hearthstone = bool(layout.get("bag_full_hearthstone", False)) 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.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: def _has_prepare_route(self) -> bool:
return bool(self.prepare_route_waypoints) return bool(self.prepare_route_waypoints)
@@ -721,8 +733,19 @@ class AutoBotMove:
self.patrol_controller.stop_all() self.patrol_controller.stop_all()
self.is_moving = False self.is_moving = False
self.patrol_controller.reset_stuck() self.patrol_controller.reset_stuck()
# 勾选"包满炉石回城":按炉石后触发停止回调 bag_full_now = int(state.get('free_slots', 0) or 0) < 2
if self.logistics_manager.bag_full_hearthstone: 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()) 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) self.logistics_manager.use_hearthstone_and_stop(get_state=get_state_fn)
if callable(getattr(self, '_on_hearthstone_stop', None)): if callable(getattr(self, '_on_hearthstone_stop', None)):

View File

@@ -31,6 +31,14 @@ _DEFAULTS = {
# 炉石回城 # 炉石回城
"hearthstone_key": "b", "hearthstone_key": "b",
"bag_full_hearthstone": False, "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' SCREENSHOT_DIR = 'screenshot'

View File

@@ -1,5 +1,6 @@
import json import json
import math import math
import os
import time import time
from hardware_control import hw_ctrl from hardware_control import hw_ctrl
@@ -20,6 +21,32 @@ class LogisticsManager:
self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理 self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理
self.hearthstone_key = "b" # 炉石按键 self.hearthstone_key = "b" # 炉石按键
self.hearthstone_cast_sec = 10.0 # 炉石施法等待秒数 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): def check_logistics(self, state):
""" """
@@ -109,6 +136,83 @@ class LogisticsManager:
hw_ctrl.press("4") hw_ctrl.press("4")
time.sleep(2) 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): def run_route1_round(self, get_state, patrol, route_file=None):
""" """
读取 route1.json 路径先正向走完执行交互8、4再反向走完然后结束。 读取 route1.json 路径先正向走完执行交互8、4再反向走完然后结束。

View File

@@ -305,6 +305,7 @@ class GameLoopWorker(QThread):
waypoints_path=None, waypoints_path=None,
prepare_route_path=None, prepare_route_path=None,
vendor_path=None, vendor_path=None,
mailbox_route_path=None,
record_filename=None, record_filename=None,
record_min_distance=None, record_min_distance=None,
attack_loop_path=None, attack_loop_path=None,
@@ -342,6 +343,7 @@ class GameLoopWorker(QThread):
self.waypoints_path = waypoints_path self.waypoints_path = waypoints_path
self.prepare_route_path = prepare_route_path self.prepare_route_path = prepare_route_path
self.vendor_path = vendor_path self.vendor_path = vendor_path
self.mailbox_route_path = mailbox_route_path
self.record_filename = record_filename or 'waypoints' self.record_filename = record_filename or 'waypoints'
self.record_min_distance = record_min_distance self.record_min_distance = record_min_distance
self.attack_loop_path = attack_loop_path or None 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_key=self.turn_error_key,
turn_error_hold_sec=self.turn_error_hold_sec, turn_error_hold_sec=self.turn_error_hold_sec,
distance_interact_pause_sec=self.distance_interact_pause_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 self.bot_move._on_hearthstone_stop = self.stop_signal.emit
except ImportError as e: except ImportError as e:
@@ -723,6 +726,8 @@ class WoWMultiKeyGUI(QMainWindow):
self.waypoints_combo.setMinimumWidth(200) self.waypoints_combo.setMinimumWidth(200)
self.vendor_combo = QComboBox() self.vendor_combo = QComboBox()
self.vendor_combo.setMinimumWidth(200) 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 = QComboBox()
self.patrol_attack_loop_combo.setMinimumWidth(200) self.patrol_attack_loop_combo.setMinimumWidth(200)
self.prepare_route_combo = QComboBox() 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.prepare_route_combo)
patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo) patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo)
patrol_layout.addRow("修理商 JSON:", self.vendor_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 = QComboBox()
self.resurrection_route_a_combo.setMinimumWidth(200) self.resurrection_route_a_combo.setMinimumWidth(200)
self.resurrection_route_b_combo = QComboBox() self.resurrection_route_b_combo = QComboBox()
@@ -1110,6 +1116,30 @@ class WoWMultiKeyGUI(QMainWindow):
self.gs_hearthstone_key.setText("b") self.gs_hearthstone_key.setText("b")
self.gs_bag_full_hearthstone = QCheckBox("包满时用炉石回城并停止") self.gs_bag_full_hearthstone = QCheckBox("包满时用炉石回城并停止")
self.gs_bag_full_hearthstone.setChecked(False) 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 = QCheckBox("启用上马")
self.gs_enable_mount.setChecked(True) self.gs_enable_mount.setChecked(True)
self.gs_mount_key = QLineEdit() 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(self.gs_use_hardware_input, 8, 0, 1, 2)
game_grid.addWidget(QLabel("复活按键:"), 6, 2) game_grid.addWidget(QLabel("复活按键:"), 6, 2)
game_grid.addWidget(self.gs_resurrect_key, 6, 3) 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) 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_scan_height.setValue(cfg.get('scan_region_height', 15))
self.gs_offset_left.setValue(cfg.get('offset_left', 20)) self.gs_offset_left.setValue(cfg.get('offset_left', 20))
self.gs_offset_top.setValue(cfg.get('offset_top', 45)) 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_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_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_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_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_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_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')) 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['scan_region_height'] = self.gs_scan_height.value()
cfg['offset_left'] = self.gs_offset_left.value() cfg['offset_left'] = self.gs_offset_left.value()
cfg['offset_top'] = self.gs_offset_top.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_key'] = (self.gs_mount_key.text().strip() or 'x')
cfg['mount_hold_sec'] = float(self.gs_mount_hold.value()) cfg['mount_hold_sec'] = float(self.gs_mount_hold.value())
cfg['mount_retry_after_sec'] = float(self.gs_mount_retry.value()) cfg['mount_retry_after_sec'] = float(self.gs_mount_retry.value())
cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b') cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b')
cfg['bag_full_hearthstone'] = self.gs_bag_full_hearthstone.isChecked() 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['release_spirit_key'] = (self.gs_release_spirit_key.text().strip() or '9')
cfg['resurrect_key'] = (self.gs_resurrect_key.text().strip() or '0') cfg['resurrect_key'] = (self.gs_resurrect_key.text().strip() or '0')
path = save_layout_config(cfg) path = save_layout_config(cfg)
@@ -1471,6 +1526,16 @@ class WoWMultiKeyGUI(QMainWindow):
] ]
if hasattr(self, "repair_vendor_combo"): if hasattr(self, "repair_vendor_combo"):
combos_with_default.append((self.repair_vendor_combo, 'vendor.json')) 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: for combo, default_name in combos_with_default:
combo.blockSignals(True) combo.blockSignals(True)
combo.clear() combo.clear()
@@ -1635,6 +1700,7 @@ class WoWMultiKeyGUI(QMainWindow):
waypoints_path = None waypoints_path = None
prepare_route_path = None prepare_route_path = None
vendor_path = None vendor_path = None
mailbox_route_path = None
flight_json_path = None flight_json_path = None
resurrection_route_a_path = None resurrection_route_a_path = None
resurrection_route_b_path = None resurrection_route_b_path = None
@@ -1642,6 +1708,7 @@ class WoWMultiKeyGUI(QMainWindow):
prep = self.prepare_route_combo.currentData() or "" prep = self.prepare_route_combo.currentData() or ""
wp = self.waypoints_combo.currentData() or "" wp = self.waypoints_combo.currentData() or ""
vp = self.vendor_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_a = self.resurrection_route_a_combo.currentData() or ""
route_b = self.resurrection_route_b_combo.currentData() or "" route_b = self.resurrection_route_b_combo.currentData() or ""
if not wp: if not wp:
@@ -1659,6 +1726,13 @@ class WoWMultiKeyGUI(QMainWindow):
if prep and not os.path.exists(prep): if prep and not os.path.exists(prep):
QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}") QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}")
return 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): if route_a and not os.path.exists(route_a):
QMessageBox.warning(self, "提示", f"复活路线 A 文件不存在: {route_a}") QMessageBox.warning(self, "提示", f"复活路线 A 文件不存在: {route_a}")
return return
@@ -1668,6 +1742,7 @@ class WoWMultiKeyGUI(QMainWindow):
waypoints_path = wp waypoints_path = wp
prepare_route_path = prep or None prepare_route_path = prep or None
vendor_path = vp vendor_path = vp
mailbox_route_path = mp or None
resurrection_route_a_path = route_a or None resurrection_route_a_path = route_a or None
resurrection_route_b_path = route_b or None resurrection_route_b_path = route_b or None
attack_loop_path = None attack_loop_path = None
@@ -1753,6 +1828,7 @@ class WoWMultiKeyGUI(QMainWindow):
self.game_worker = GameLoopWorker( self.game_worker = GameLoopWorker(
mode, waypoints_path=waypoints_path, prepare_route_path=prepare_route_path, vendor_path=vendor_path, 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, attack_loop_path=attack_loop_path,
skinning_wait_sec=skinning_wait_sec, skinning_wait_sec=skinning_wait_sec,
food_key=food_key, food_key=food_key,