- add MeshPort peer-to-peer overlay networking SDK, C++ integration, and Python bindings - add item actions quick use/split/delete/lock overlay window (mt_item_actions) - add shop name range and NPC name visibility settings to system display options - support battlepass ticket activation and month validation in mt_battlepass - support boss tracking teleport destination button in mt_bosses - support asynchronous account registration cancellation and pending login cancellation - update AVA2 item conversion for attr lock scrolls and battlepass tickets - update .gitignore for local build outputs and caches
164 lines
8.1 KiB
C++
164 lines
8.1 KiB
C++
// PORT read-only feature protocol. Requests are serialized, bounded and tied to this game session.
|
|
static const char c_szFeatureServiceScript[] = R"PY(
|
|
import app
|
|
import game
|
|
import net
|
|
import ui
|
|
from mt_wiki_data import Number
|
|
|
|
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32,'bosses':256,'travel':512}
|
|
WHOLE=('stats','dungeons','bosses') # one reply with every row; no subject or page
|
|
g_service=None
|
|
g_tick=None
|
|
_serial=0
|
|
|
|
|
|
def Signed(text):
|
|
value=int(text)
|
|
if len(text)>20 or not -(1<<63)<=value<(1<<63):raise ValueError('invalid stat value')
|
|
return value
|
|
|
|
|
|
class Service(object):
|
|
def __init__(self):
|
|
self.queue=[];self.pending=None;self.mask=None;self.nextSend=app.GetTime()+0.5
|
|
self.deadline=0;self.closed=False
|
|
|
|
def Request(self,kind,subject,offset,callback):
|
|
global _serial
|
|
if kind not in BITS or not 0<=subject<=4294967295 or not 0<=offset<=4096 or len(self.queue)>=8:
|
|
callback(None,'invalid');return 0
|
|
if (kind in WHOLE and (subject or offset)) or (kind=='calendar' and subject!=1):
|
|
callback(None,'invalid');return 0
|
|
_serial=(_serial%2147483647)+1
|
|
self.queue.append(dict(id=_serial,kind=kind,subject=subject,offset=offset,callback=callback,retries=0))
|
|
return _serial
|
|
|
|
def Tick(self):
|
|
global _serial
|
|
now=app.GetTime()
|
|
if self.closed:return
|
|
if self.pending and now>self.deadline:
|
|
if self.pending['kind']=='cap':
|
|
self.mask=0;self.pending=None
|
|
else:self.Finish(None,'timeout')
|
|
self.nextSend=now+0.3
|
|
if self.pending or not self.queue or now<self.nextSend:return
|
|
if self.mask is None:
|
|
_serial=(_serial%2147483647)+1
|
|
self.pending=dict(id=_serial,kind='cap',subject=0,offset=0,callback=None,retries=0)
|
|
net.SendChatPacket('/mt_features 1 %d cap'%_serial)
|
|
else:
|
|
self.pending=self.queue.pop(0)
|
|
p=self.pending
|
|
if not self.mask&BITS[p['kind']]:
|
|
self.Finish(None,'unsupported');return
|
|
text='/mt_features 1 %d %s'%(p['id'],p['kind'])
|
|
if p['kind'] not in WHOLE:text+=' %d %d'%(p['subject'],p['offset'])
|
|
net.SendChatPacket(text)
|
|
self.deadline=now+4.0;self.nextSend=now+0.3
|
|
|
|
def Finish(self,result,error=None):
|
|
pending=self.pending;self.pending=None
|
|
if pending and pending.get('callback') and not self.closed:pending['callback'](result,error)
|
|
|
|
def Receive(self,line):
|
|
if not line.startswith('MTF1 '):return False
|
|
p=self.pending
|
|
try:
|
|
w=line.split()
|
|
if len(w)<3 or len(line)>512:return True
|
|
request=Number(w[2],2147483647)
|
|
if not p or p['id']!=request:return True
|
|
op=w[1]
|
|
if op=='cap' and len(w)==6 and p['kind']=='cap':
|
|
if Number(w[3])!=1:raise ValueError('unsupported protocol')
|
|
self.mask=Number(w[4],4294967295);Number(w[5]);self.pending=None
|
|
elif op=='error' and len(w)==4:
|
|
if w[3]=='rate' and p['kind']!='cap' and p['retries']<2:
|
|
p['retries']+=1;self.queue.insert(0,p);self.pending=None;self.nextSend=app.GetTime()+0.4
|
|
else:self.Finish(None,w[3])
|
|
elif op=='begin' and len(w)==9 and p['kind']!='cap':
|
|
if 'result' in p or w[3]!=p['kind'] or Number(w[4])!=p['subject'] or Number(w[5])!=p['offset']:
|
|
raise ValueError('mismatched response')
|
|
total=Number(w[6],128 if p['kind'] in WHOLE else 256 if p['kind']=='calendar' else 4096)
|
|
if total<p['offset']:raise ValueError('invalid page')
|
|
p['result']=dict(kind=w[3],subject=p['subject'],offset=p['offset'],total=total,mode=Number(w[7],11),
|
|
time=Number(w[8]),rows=[])
|
|
elif op in ('row','stat','event','boss') and 'result' in p:
|
|
result=p['result'];rows=result['rows']
|
|
limit=result['total'] if p['kind'] in WHOLE else min(8,result['total']-p['offset'])
|
|
if len(rows)>=limit or Number(w[3])!=p['offset']+len(rows):raise ValueError('invalid row order')
|
|
if op=='row' and len(w)==12 and p['kind'] not in WHOLE+('calendar',):
|
|
values=[Number(v) for v in w[4:]]
|
|
if values[2] not in (0,1,2,3,4,5,10,11,20,21) or not values[0]:raise ValueError('invalid row')
|
|
rows.append(values)
|
|
elif op=='event' and len(w)==11 and p['kind']=='calendar':
|
|
values=[Number(v) for v in w[4:]]
|
|
if not values[0] or values[1] not in (1,2) or values[2]>=values[3] or values[4]>255 or values[5]>3 or values[6]>1:raise ValueError('invalid calendar event')
|
|
if any(r[0]==values[0] for r in rows):raise ValueError('duplicate event')
|
|
rows.append(values)
|
|
elif op=='boss' and len(w)==13 and p['kind']=='bosses':
|
|
# VNUM MAP X Y CHANNEL ALIVE MAX NEXT PERIOD; X/Y are map-local and may be negative.
|
|
values=[Number(w[4]),Number(w[5]),Signed(w[6]),Signed(w[7])]+[Number(v) for v in w[8:]]
|
|
if not values[0] or not values[1] or values[4]>255 or values[6]>255 or values[5]>values[6] or not values[8]:raise ValueError('invalid boss')
|
|
rows.append(values)
|
|
elif op=='stat' and len(w)==6 and p['kind'] in ('stats','dungeons'):
|
|
if not w[4].replace('_','').isalpha() or len(w[4])>32 or any(r[0]==w[4] for r in rows):raise ValueError('invalid stat')
|
|
rows.append((w[4],Signed(w[5])))
|
|
else:raise ValueError('invalid row format')
|
|
elif op=='end' and len(w)==4 and 'result' in p:
|
|
result=p['result'];expected=result['total'] if p['kind'] in WHOLE else min(8,result['total']-p['offset'])
|
|
if Number(w[3])!=expected or len(result['rows'])!=expected:raise ValueError('incomplete reply')
|
|
if p['kind']=='dungeons':
|
|
values=dict(result['rows'])
|
|
required=('lair_end','lair_join_end','lair_phase','lair_ticket','lair_tickets','lair_boss','lair_owned','lair_alive','lair_npc','lair_map')
|
|
if any(k not in values or values[k]<0 for k in required):raise ValueError('incomplete dungeon snapshot')
|
|
if values['lair_phase']>2 or not values['lair_ticket'] or values['lair_join_end']>values['lair_end']:raise ValueError('invalid dungeon state')
|
|
weekly=('weekly_end','weekly_hunt_end','weekly_active','weekly_npc','weekly_map')
|
|
if any(k in values for k in weekly) and any(k not in values or values[k]<0 for k in weekly):raise ValueError('incomplete weekly state')
|
|
if values.get('weekly_active',0)>1:raise ValueError('invalid weekly state')
|
|
self.Finish(result)
|
|
else:raise ValueError('unknown response')
|
|
except (ValueError,TypeError,IndexError,OverflowError):
|
|
if p and self.pending is p:
|
|
if p['kind']=='cap':self.mask=0;self.pending=None
|
|
else:self.Finish(None,'bad_reply')
|
|
return True
|
|
|
|
def Close(self):
|
|
self.closed=True;self.pending=None;self.queue=[]
|
|
|
|
|
|
class TickWindow(ui.Window):
|
|
def __init__(self):
|
|
ui.Window.__init__(self);self.AddFlag('not_pick');self.SetSize(0,0);self.Show()
|
|
def OnUpdate(self):
|
|
if g_service:g_service.Tick()
|
|
|
|
|
|
_open=game.GameWindow.Open
|
|
_close=game.GameWindow.Close
|
|
_command=game.GameWindow.BINARY_ServerCommand_Run
|
|
|
|
def Open(self):
|
|
global g_service,g_tick
|
|
_open(self);g_service=Service();g_tick=TickWindow()
|
|
|
|
def Close(self):
|
|
global g_service,g_tick
|
|
if g_service:g_service.Close();g_service=None
|
|
if g_tick:g_tick.Hide();g_tick=None
|
|
_close(self)
|
|
|
|
def Command(self,line):
|
|
if line.startswith('MTF1 '):
|
|
if g_service:g_service.Receive(line)
|
|
return 1
|
|
return _command(self,line)
|
|
|
|
game.GameWindow.Open=Open
|
|
game.GameWindow.Close=Close
|
|
game.GameWindow.BINARY_ServerCommand_Run=Command
|
|
)PY";
|