diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index b94dc0a9..4567edc3 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -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。 diff --git a/docs/AVA2-PICKUP.md b/docs/AVA2-PICKUP.md new file mode 100644 index 00000000..978b9848 --- /dev/null +++ b/docs/AVA2-PICKUP.md @@ -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`)没有改动。 diff --git a/src/platform/ScriptLib/PickupScript.inc b/src/platform/ScriptLib/PickupScript.inc new file mode 100644 index 00000000..7ae7edc4 --- /dev/null +++ b/src/platform/ScriptLib/PickupScript.inc @@ -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"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 24680a4c..6fe52709 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -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); } diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 785e4b2d..de364b4c 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -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) diff --git a/tests/wiki/test_pickup.py b/tests/wiki/test_pickup.py new file mode 100644 index 00000000..2c12e529 --- /dev/null +++ b/tests/wiki/test_pickup.py @@ -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()