Add feature queries, dungeon information and event calendar UI

This commit is contained in:
shen
2026-10-15 02:28:01 -07:00
parent 9d9993b007
commit 4e2d39b612
17 changed files with 1248 additions and 11 deletions
@@ -0,0 +1,157 @@
// 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}
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 ('stats','dungeons') 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 ('stats','dungeons'):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 ('stats','dungeons') 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') and 'result' in p:
result=p['result'];rows=result['rows']
limit=result['total'] if p['kind'] in ('stats','dungeons') 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 ('stats','dungeons','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=='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 ('stats','dungeons') 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";