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>
128 lines
7.0 KiB
Python
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()
|