Files
shenleiandClaude Opus 5.5 b8363693f1 Add batch gift box opening window (mt_chest)
Ctrl + right click on an ITEM_GIFTBOX opens a count window (1/10/50/all,
max 200) that sends /mt_chest and sums the MTCH1 gains into chat. Without
Ctrl or against a server without the command the original use runs.
Exports item.ITEM_TYPE_GIFTBOX. Verified live on the 203 channel5 core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:31:03 +09:00

134 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import sys
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
NAMES={50100:'Box',27001:'Potion',110000:'Soul',30000:'Ore'}
TYPES={50100:23,27001:3,30000:5}
class ChestTests(unittest.TestCase):
def setUp(self):
self.time=1.;self.sent=[];self.chat=[];self.used=[];self.ctrl=False;self.bag={}
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','item','net','player','ui','uiInventory','mt_biolog','mt_wiki')}
app=mods['app'];app.GetTime=lambda:self.time;app.DIK_LCONTROL=29;app.DIK_RCONTROL=157
app.IsPressed=lambda key:self.ctrl and key==29
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['net'].SendChatPacket=self.sent.append
selected=[0];it=mods['item']
it.ITEM_TYPE_GIFTBOX=23;it.SelectItem=lambda v:selected.__setitem__(0,v)
it.GetItemName=lambda:NAMES.get(selected[0],'?');it.GetItemType=lambda:TYPES.get(selected[0],0)
pl=mods['player'];pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5
pl.GetItemIndex=lambda c:self.bag.get(c,(0,0))[0]
pl.GetItemCountByVnum=lambda v:sum(n for vnum,n in self.bag.values() if vnum==v)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
BINARY_ServerCommand_Run=lambda s,line:7))
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(UseItemSlot=lambda s,slot:self.used.append(slot),
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+45))
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
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.ns={'__name__':'mt_chest'};exec(script('ChestScript.inc'),self.ns)
self.opener=self.ns['Opener'](self.sent.append,lambda:self.time)
def tearDown(self):
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_open_sends_once_and_collects_gains_until_done(self):
self.assertTrue(self.opener.Open(50,7));self.assertFalse(self.opener.Open(50,7))
self.assertEqual(self.sent,['/mt_chest 1 open 50 7'])
self.time+=1;self.assertFalse(self.opener.Open(50,7)) # still waiting for the reply
for line in ('MTCH1 got 27001 7','MTCH1 got 110000 2','MTCH1 done 50100 7 7 done 700'):
self.assertTrue(self.opener.Receive(line))
r=self.opener.result
self.assertEqual((r['vnum'],r['asked'],r['opened'],r['reason'],r['gold']),(50100,7,7,'done',700))
self.assertEqual(r['gains'],[(27001,7),(110000,2)]);self.assertEqual(self.opener.gains,[])
self.assertTrue(self.opener.Open(50,500));self.assertEqual(self.sent[-1],'/mt_chest 1 open 50 200') # capped
self.opener.Receive('MTCH1 done 50100 200 0 rejected -50');self.assertEqual(self.opener.result['gold'],-50)
self.time+=.1;self.assertFalse(self.opener.Open(50,1)) # inside the send gap
def test_errors_malformed_and_foreign_lines(self):
self.opener.Open(1,1);self.opener.Receive('MTCH1 error kind')
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.waiting)
for bad in ('MTCH1 done 1 1 1 done','MTCH1 done 1 1 1 boom 0','MTCH1 got x 1','MTCH1 got 1 0','MTCH1 error boom','MTCH1 done a 1 1 done 0'):
self.assertTrue(self.opener.Receive(bad))
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.result)
self.assertFalse(self.opener.Receive('MTPU1 done 1 1 0'))
def test_timeout_marks_unsupported(self):
events=[];self.opener.listener=events.append
self.opener.Open(1,1);self.time+=3.1;self.assertFalse(self.opener.Open(1,1))
self.assertTrue(self.opener.unsupported);self.assertEqual(events,['unsupported'])
self.opener.Receive('MTCH1 done 50100 1 1 done 0');self.assertFalse(self.opener.unsupported) # a late reply re-enables
def test_summary_lines(self):
lines=self.ns['Summary'](dict(vnum=50100,opened=12,reason='full',gold=1200,gains=[(27001,12)]+[(30000,1)]*5))
self.assertEqual(lines[0],'批量开启 Box:开启 12 个,金币 +1200(背包空位不足,已停止)')
self.assertEqual(lines[1],'获得:Potion ×12,Ore ×1,Ore ×1,Ore ×1,Ore ×1');self.assertEqual(lines[2],'获得:Ore ×1')
self.assertEqual(self.ns['Summary'](dict(vnum=50100,opened=1,reason='done',gold=0,gains=[])),['批量开启 Box:开启 1 个'])
def test_ctrl_right_click_opens_window_and_hooks(self):
game=sys.modules['game'];inv=sys.modules['uiInventory'].InventoryWindow();gw=game.GameWindow()
self.bag={50:(50100,1),51:(50100,1),52:(27001,3),90:(50100,1)}
game.GameWindow.Open(gw);w=self.ns['g_window']
inv.UseItemSlot(5);self.assertEqual(self.used,[5]);self.assertFalse(w.IsShow()) # no Ctrl: original use
self.ctrl=True;inv.UseItemSlot(7);self.assertEqual(self.used,[5,7]) # Ctrl on a potion: original use
inv.UseItemSlot(5);self.assertEqual(self.used,[5,7]);self.assertTrue(w.IsShow())
self.assertEqual((w.cell,w.vnum),(50,50100));self.assertIn('3',w.owned.text);self.assertEqual(w.openButton.text,'开启 1 个')
w.allButton.event();self.assertEqual(w.openButton.text,'开启 3 个')
del self.bag[50];w.openButton.event();self.assertEqual(self.sent,['/mt_chest 1 open 51 2']) # chosen cell gone: next box, 2 left
self.assertFalse(w.openButton.enabled)
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 got 27001 2'),1)
self.bag={52:(27001,6)}
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 done 50100 2 2 done 200'),1)
self.assertEqual(self.chat,['批量开启 Box:开启 2 个,金币 +200','获得:Potion ×2'])
self.assertIn('2/2',w.status.text);self.assertEqual(w.openButton.text,'没有宝箱')
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
self.bag={60:(50100,1)};self.time+=1;w.openButton.event();self.time+=3.1;w.OnUpdate()
self.assertTrue(self.ns['g_opener'].unsupported);self.assertIn('单个开启',self.chat[-1])
inv.UseItemSlot(15);self.assertEqual(self.used,[5,7,15]) # unsupported server: Ctrl falls back to normal use
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window'])
def test_server_constants_match(self):
server=ROOT.parent/'metin2-server/src/server/game/src/port_chest.h'
if not server.exists():self.skipTest('server checkout not installed')
text=server.read_text(encoding='latin1')
self.assertIn('kPortChestMax=200',text);self.assertEqual(self.ns['MAX'],200)
self.assertIn('kPortChestGapMs=500',text);self.assertEqual(self.ns['SEND_GAP'],.5)
self.assertTrue(set(re.findall(r'return "([a-z]+)";',text))<=set(self.ns['ERRORS']))
self.assertTrue(set(re.findall(r'reason="([a-z]+)"',text))<=set(self.ns['REASONS']))
if __name__=='__main__':unittest.main()