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
+3 -1
View File
@@ -117,4 +117,6 @@
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。
2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,未部署 203、未联机验收。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
2026-10-15 批量拾取(计划外追加,参考 AVA2 do_pickup_all):服务端 mt_pickup 对范围内自己的物品按分类逐件调用原 PickupItem,客户端在拾取键(Z)上接入并提供分类设置窗口;macOS 客户端已在 203 测试核心联机验收,未部署 203 正式环境、未做 Android 真机测试和触屏按钮。见 AVA2-PICKUP.md。
+46
View File
@@ -0,0 +1,46 @@
# AVA2 批量拾取适配进度(v1)
2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考(`do_pickup_all`、`ENABLE_AUTOMATIC_PICK_UP_SYSTEM`)。服务端规则和协议见 `metin2-server/docs/FEATURE-PICKUP.md`。
## 与 AVA2 的差异
| 项目 | AVA2 | 本实现 |
| --- | --- | --- |
| 触发 | 新包头 `HEADER_CG_ITEMS_PICKUP`,以及付费状态 70002(AFFECT_AUTO_PICK_UP)下的自动拾取 | 聊天命令 `/mt_pickup`,不新增包头;只在按拾取键时触发,不做自动拾取和付费状态 |
| 范围 | 自己的物品 | 相同:只拾取自己的或无主的物品;队伍分配的物品不批量拾取 |
| 拾取过程 | 自己实现 | 每件物品都调用原 `PickupItem`,距离、堆叠、背包空间、GET 日志、任务触发都不变 |
| 分类 | CheckItemCanGet 模式掩码 | 8 个分类位:金币、武器、防具/首饰、技能书、灵石、材料、时装、其他 |
## 客户端
- 模块 `mt_pickup`(`src/platform/ScriptLib/PickupScript.inc`),在 `PythonBoot.cpp` 中安装。替换了 `GameWindow.PickUpItem`(Z 键和 ` 键)。
- 开启时,按拾取键会发送 `/mt_pickup 1 掩码`。等待回复时,以及两次发送间隔不足 0.5 秒时,按键会被吞掉(防止按住 Z 连发)。关闭时走原来的单个拾取。
- 3 秒没有回复:在聊天框提示一次“服务器尚未启用批量拾取”,之后改用单个拾取;再收到回复会自动恢复。
- 设置窗口:入口是小地图旁“洗练”下方的“拾取”按钮。窗口内可开关批量拾取、选择 8 个分类(`【】` 表示已选,至少保留一个),并显示上次结果(附近 N 件、拾取 N 件、失败 N 件)。设置保存在客户端目录的 `mt_pickup.cfg`,格式为“开关 掩码”。
- 单件物品的提示仍由服务端发送;失败提示(例如背包已满)只发一次。
## 证据
- `tests/wiki/test_pickup.py`(CTest:`port.pickup`)共 7 项通过,覆盖:
- 发送节流和等待回复;
- done/error 解析,畸形行和其他前缀;
- 超时后改用单个拾取,迟到的回复会恢复批量拾取;
- 关闭开关、分类掩码、至少保留一个分类;
- 配置文件读写和损坏的配置;
- GameWindow 钩子:入口位置、拾取键、命令拦截、窗口开关;
- 和服务端的分类位、错误码、0.5 秒间隔一致。
- `tests/wiki/test_switchbot.py` 仍然通过。
- 2026-10-15 联机测试(203 测试核心 channel5/first,端口 13040,使用隔离编译的 game;macOS 原生客户端,账号 admin)。先用 `/item` 生成并逐件丢下武器 19、铠甲 11209、材料 30003、灵石 28030、技能书 50300 和 1000 金币:
- 掩码 2(武器):`done 1 1 0`,只拾回武器。
- 掩码 48(灵石+材料):`done 2 2 0`。
- 掩码 255:`done 3 3 0`,拾回铠甲、技能书和金币(金币数恢复)。
- 地上已没有物品:`done 0 0 0`;紧接着再发一次:`error rate`。
- log.log 有 5 行 GET,每件物品一行,和 DROP 一一对应。原路径拾取金币不写 GET 日志。
- 测试核心 syserr 没有新错误。丢金币时出现的 LOCALE_ERROR 来自原有的丢金币提示。
## 未完成
- Android 真机测试和触屏拾取按钮(`touch_controller.h` 的按钮发送 DIK 键码,需要再加布局)。
- 背包已满时的联机测试(服务端桩测试已覆盖)。
- 服务端还没有部署到 203 正式环境。
- 自动狩猎自带的客户端拾取(`PICKUP_TEXTS`)没有改动。
+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)
+127
View File
@@ -0,0 +1,127 @@
import os
import re
import sys
import tempfile
import types
import unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
class Widget(object):
def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0)
def __getattr__(self,name):return lambda *a,**k:None
def Show(self):self.shown=True
def Hide(self):self.shown=False
def IsShow(self):return self.shown
def SetText(self,text):self.text=text
def SetPosition(self,x,y):self.pos=(x,y)
def GetGlobalPosition(self):return self.pos
def SetSize(self,w,h):self.size=(w,h)
def GetWidth(self):return self.size[0]
def GetHeight(self):return self.size[1]
class Action(Widget):
def __init__(self,parent,x,y,width,text,event):
Widget.__init__(self);self.pos=(x,y);self.event=event;self.enabled=True;self.text=text;self.shown=True
def SetEnabled(self,enabled):self.enabled=enabled
class PickupTests(unittest.TestCase):
def setUp(self):
self.time=1.;self.sent=[];self.chat=[];self.events=[];self.single=[]
self.cwd=os.getcwd();self.tmp=tempfile.TemporaryDirectory();os.chdir(self.tmp.name)
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','net','ui','mt_biolog','mt_wiki')}
mods['app'].GetTime=lambda:self.time
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['net'].SendChatPacket=self.sent.append
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
BINARY_ServerCommand_Run=lambda s,line:7,PickUpItem=lambda s:self.single.append(1)))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
mods['ui'].Window=Widget
mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t if isinstance(t,str) else t.decode('utf-8')
mods['mt_wiki'].B=lambda t,limit=0:t
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.load()
def load(self):
self.ns={'__name__':'mt_pickup'};exec(script('PickupScript.inc'),self.ns)
self.picker=self.ns['Picker'](self.sent.append,lambda:self.time,self.events.append)
def tearDown(self):
os.chdir(self.cwd);self.tmp.cleanup()
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_press_sends_once_per_gap_and_waits_for_reply(self):
self.assertTrue(self.picker.Press());self.assertTrue(self.picker.Press())
self.assertEqual(self.sent,['/mt_pickup 1 255'])
self.time+=.6;self.picker.Press();self.assertEqual(len(self.sent),1) # still waiting for the reply
self.assertTrue(self.picker.Receive('MTPU1 done 5 4 1'))
self.assertEqual(self.picker.result,dict(found=5,picked=4,failed=1))
self.picker.Press();self.assertEqual(len(self.sent),2)
self.picker.Receive('MTPU1 done 0 0 0');self.time+=.1;self.picker.Press()
self.assertEqual(len(self.sent),2) # inside the 0.5 s gap: swallowed (key repeat)
def test_errors_malformed_and_foreign_lines(self):
self.picker.Press();self.picker.Receive('MTPU1 error busy')
self.assertEqual(self.picker.error,'busy');self.assertIsNone(self.picker.waiting)
for bad in ('MTPU1 done 1 2','MTPU1 done a 1 1','MTPU1 error boom','MTPU1 done 1 1 1 1'):
self.assertTrue(self.picker.Receive(bad))
self.assertEqual(self.picker.error,'busy')
self.assertFalse(self.picker.Receive('MTSB1 state 0 0 0 0 0 0 0 idle'))
def test_timeout_falls_back_to_single_pickup(self):
self.picker.Press();self.time+=3.1
self.assertFalse(self.picker.Press());self.assertTrue(self.picker.unsupported)
self.assertEqual(self.events,['unsupported']);self.assertEqual(len(self.sent),1)
self.picker.Receive('MTPU1 done 0 0 0');self.assertFalse(self.picker.unsupported) # a late reply re-enables
def test_disabled_and_category_mask(self):
self.picker.enabled=False;self.assertFalse(self.picker.Press());self.assertEqual(self.sent,[])
self.picker.enabled=True;self.picker.mask=1
self.picker.Toggle(1);self.assertEqual(self.picker.mask,1) # the last category stays on
self.picker.Toggle(32);self.picker.Press();self.assertEqual(self.sent,['/mt_pickup 1 33'])
def test_config_round_trip_and_bad_files(self):
self.picker.enabled=False;self.picker.mask=6;self.ns['SaveConfig'](self.picker)
other=self.ns['Picker'](None,None);self.ns['LoadConfig'](other)
self.assertEqual((other.enabled,other.mask),(False,6))
for text in ('1 0','1 256','2 5','x 5','1','1 -3'):
p=self.ns['Picker'](None,None);p.Load(text);self.assertEqual((p.enabled,p.mask),(True,255))
def test_game_window_hooks(self):
game=sys.modules['game'];gw=game.GameWindow();gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
game.GameWindow.Open(gw);w=self.ns['g_window'];picker=self.ns['g_picker']
self.assertEqual(self.ns['g_entry'].pos,(4,200))
game.GameWindow.PickUpItem(gw);self.assertEqual(self.sent,['/mt_pickup 1 255']);self.assertEqual(self.single,[])
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTPU1 done 3 3 0'),1)
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
self.assertIn('拾取 3 件',w.status.text if hasattr(w.status,'text') else '')
w.Open();w.ToggleCategory(128);self.assertNotIn('【',w.categoryButtons[7].text);self.assertIn('【',w.categoryButtons[0].text)
w.ToggleEnabled();self.assertFalse(picker.enabled);self.assertFalse(w.categoryButtons[0].enabled)
game.GameWindow.PickUpItem(gw);self.assertEqual(self.single,[1])
self.assertEqual(Path('mt_pickup.cfg').read_text(),'0 127')
self.time+=1;w.ToggleEnabled();game.GameWindow.PickUpItem(gw);self.time+=3.1;w.OnUpdate()
self.assertTrue(picker.unsupported);self.assertIn('单个拾取',self.chat[-1])
game.GameWindow.PickUpItem(gw);self.assertEqual(self.single,[1,1])
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry'])
def test_server_constants_match(self):
server=ROOT.parent/'metin2-server/src/server/game/src/port_pickup.h'
if not server.exists():self.skipTest('server checkout not installed')
text=server.read_text(encoding='latin1')
bits=dict((name,int(v)) for name,v in re.findall(r'PORT_PICKUP_([A-Z]+)=(\d+)',text))
self.assertEqual(bits.pop('ALL'),self.ns['ALL'])
self.assertEqual(sorted(bits.values()),[bit for bit,name in self.ns['CATEGORIES']])
self.assertTrue(set(re.findall(r'MTPU1 error ([a-z_]+)"',text))<=set(self.ns['ERRORS']))
self.assertIn('kPortPickupGapMs=500',text);self.assertEqual(self.ns['SEND_GAP'],.5)
if __name__=='__main__':unittest.main()