Add batch pickup on the pickup key for the server mt_pickup command

Embedded module mt_pickup wraps GameWindow.PickUpItem (Z / `): with batch
pickup on it sends /mt_pickup 1 MASK (0.5 s gap, one in flight, presses
swallowed while waiting); after 3 s without a reply it notes once in chat
and falls back to the original single pickup. Settings window (on/off and
8 categories, persisted in mt_pickup.cfg) behind a "拾取" button under the
switchbot entry by the minimap.

Live tested against 203 (channel-5 test core, port 13040) with the macOS
native client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-10-15 20:45:37 +09:00
co-authored by Claude Opus 5.5
parent 514eae6745
commit e22d7ba6fd
6 changed files with 374 additions and 1 deletions
+194
View File
@@ -0,0 +1,194 @@
// PORT: AVA2-style batch pickup (do_pickup_all), driven by the server's /mt_pickup command. With batch
// pickup on, the pickup key (GameWindow.PickUpItem, Z / `) asks the server to pick up every own item in
// range of the chosen categories; each item still goes through the original pickup path. Against a server
// without the command it falls back to the original single pickup. Protocol: metin2-server docs/FEATURE-PICKUP.md.
static const char c_szPickupScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import net
import ui
import weakref
from mt_biolog import Action, Text, L
from mt_wiki import B
g_window=None
g_entry=None
CONFIG='mt_pickup.cfg'
ALL=255
CATEGORIES=((1,u'金币'),(2,u'武器'),(4,u'防具/首饰'),(8,u'技能书'),(16,u'灵石'),(32,u'材料'),(64,u'时装'),(128,u'其他'))
SEND_GAP=.5
TIMEOUT=3.
ERRORS={'rate':'操作过快','invalid':'参数无效','busy':'当前状态不能拾取'}
def Number(text,low=0,high=4294967295):
if not text.isdigit() or len(text)>10:raise ValueError(text)
value=int(text)
if not low<=value<=high:raise ValueError(text)
return value
class Picker(object):
# Protocol state only. send(text) posts a chat command, now() is seconds.
def __init__(self,send,now,listener=None):
self.send=send;self.now=now;self.listener=listener
self.enabled=True;self.mask=ALL;self.waiting=None;self.nextSend=0
self.unsupported=False;self.result=None;self.error=None
def Press(self):
# True when the press was handled here; False means use the original single pickup.
self.Tick()
if not self.enabled or self.unsupported:return False
now=self.now()
if self.waiting is None and now>=self.nextSend:
self.send('/mt_pickup 1 %d'%self.mask);self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP
return True
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
def Notify(self,kind):
if self.listener:self.listener(kind)
def Receive(self,line):
if not line.startswith('MTPU1 '):return False
words=line.split()
try:
if words[1]=='done' and len(words)==5:
found,picked,failed=[Number(w,0,1000) for w in words[2:]]
self.result=dict(found=found,picked=picked,failed=failed);self.error=None;kind='done'
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
else:return True
except ValueError:return True
self.waiting=None;self.unsupported=False;self.Notify(kind)
return True
def Load(self,text):
try:
enabled,mask=[Number(w) for w in text.split()]
if enabled in (0,1) and 1<=mask<=ALL:self.enabled=enabled==1;self.mask=mask
except ValueError:pass
def Dump(self):return '%d %d'%(int(self.enabled),self.mask)
def Toggle(self,bit):
mask=self.mask^bit
if mask:self.mask=mask # at least one category stays on
def LoadConfig(picker):
try:
f=open(CONFIG,'r');text=f.read();f.close();picker.Load(text)
except (IOError,OSError):pass
def SaveConfig(picker):
try:
f=open(CONFIG,'w');f.write(picker.Dump());f.close()
except (IOError,OSError):pass
class PickupWindow(ui.Window):
def __init__(self,picker):
ui.Window.__init__(self);self.SetWindowName('PickupWindow');self.AddFlag('float');self.AddFlag('movable')
self.picker=picker;self.SetSize(332,346)
self.heading=Text(self,18,18,'批量拾取',0xffffd68b)
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
self.enableButton=Action(self,18,52,296,'',self.ToggleEnabled)
self.categoryButtons=[Action(self,18+(i%2)*152,108+(i//2)*52,144,'',lambda bit=bit:self.ToggleCategory(bit))
for i,(bit,name) in enumerate(CATEGORIES)]
self.status=Text(self,18,322,'',0xffc3b7a1)
self.Refresh();self.Hide()
def Buttons(self):return [self.closeButton,self.enableButton]+self.categoryButtons
def Open(self):self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
def ToggleEnabled(self):
self.picker.enabled=not self.picker.enabled;self.picker.unsupported=False;SaveConfig(self.picker);self.Refresh()
def ToggleCategory(self,bit):self.picker.Toggle(bit);SaveConfig(self.picker);self.Refresh()
def Refresh(self):
p=self.picker
self.enableButton.SetText(L('拾取键(Z):批量拾取 已开启' if p.enabled else '拾取键(Z):批量拾取 已关闭(单个拾取)'))
for button,(bit,name) in zip(self.categoryButtons,CATEGORIES):
button.SetText(L(B(u'【%s】'%name if p.mask&bit else name)))
button.SetEnabled(p.enabled)
self.status.SetText(L(self.StatusText()))
def StatusText(self):
p=self.picker
if p.unsupported:return '!服务器尚未启用批量拾取,已改用单个拾取'
if p.error:return '!'+ERRORS[p.error]
if p.result:
r=p.result;text='上次:附近 %d 件,拾取 %d 件'%(r['found'],r['picked'])
return text+(',%d 件未能拾取'%r['failed'] if r['failed'] else '')
return '只拾取自己的物品,范围与单个拾取相同'
def OnRender(self):
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
def OnUpdate(self):self.picker.Tick()
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide()
for b in self.Buttons():b.event=None
class Entry(Action):
def __init__(self,window):
Action.__init__(self,None,0,0,64,'拾取',Toggle)
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('PickupEntry');self.OnUpdate()
def OnUpdate(self):
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+200)
def OnPicker(kind):
if kind=='unsupported':chat.AppendChat(chat.CHAT_TYPE_INFO,L('服务器尚未启用批量拾取,已改用单个拾取'))
if g_window:g_window.Refresh()
g_picker=Picker(net.SendChatPacket,app.GetTime,OnPicker)
LoadConfig(g_picker)
def Toggle():
if g_window:
if g_window.IsShow():g_window.Hide()
else:g_window.Open()
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
_pick=game.GameWindow.PickUpItem
def Open(self):
global g_window,g_entry
_open(self);g_picker.waiting=None;g_window=PickupWindow(g_picker);g_entry=Entry(self)
def Close(self):
global g_window,g_entry
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
if g_window:g_window.Destroy();g_window=None
_close(self)
def Command(self,line):
if g_picker.Receive(line):return 1
return _command(self,line)
def PickUpItem(self):
if not g_picker.Press():_pick(self)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
game.GameWindow.PickUpItem=PickUpItem
)PY";
+2
View File
@@ -1000,6 +1000,7 @@ namespace PythonBoot
#include "CalendarScript.inc"
#include "WikiEntryScript.inc"
#include "SwitchbotScript.inc"
#include "PickupScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -1074,6 +1075,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
install_script_module("mt_calendar", c_szCalendarScript);
install_script_module("mt_wiki_entries", c_szWikiEntryScript);
install_script_module("mt_switchbot", c_szSwitchbotScript);
install_script_module("mt_pickup", c_szPickupScript);
if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
+2
View File
@@ -318,6 +318,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_service.py)
add_test(NAME port.switchbot
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_switchbot.py)
add_test(NAME port.pickup
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_pickup.py)
endif()
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)