Files
shenleiandClaude Opus 5.5 e22d7ba6fd 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>
2026-10-15 20:45:37 +09:00

128 lines
7.0 KiB
Python

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