Add world boss window (AVA2 uitrack world-boss tab, read-only)

mt_boss lists the server's timed boss regens from the MTF1 "bosses"
query: alive/max, countdown, map-local position and period, paged by 8,
with per-boss spawn reminders that only fire on a fresh alive snapshot.
Teleport is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-10-15 22:36:04 +09:00
co-authored by Claude Opus 5.5
parent 13a462b6fc
commit ffe097ec57
8 changed files with 468 additions and 8 deletions
+117
View File
@@ -0,0 +1,117 @@
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
class Service(object):
def __init__(self):self.requests=[]
def Request(self,kind,subject,offset,callback):self.requests.append((kind,subject,offset,callback));return len(self.requests)
def Reply(self,rows,time=1700000000,error=None):
callback=self.requests[-1][3]
callback(None if error else dict(kind='bosses',subject=0,offset=0,total=len(rows),mode=0,time=time,rows=rows),error)
TRENT=[2307,68,51200,-300,1,0,1,1700000100,9000]
SKIPIA=[1192,72,0,0,1,1,1,1700028800,28800]
DESERT=[2191,999,10000,20000,99,0,1,0,1800]
class BossTests(unittest.TestCase):
def setUp(self):
self.time=100.;self.chat=[]
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','nonplayer','ui','wndMgr','localeInfo','mt_feature_service','mt_biolog')}
mods['app'].GetTime=lambda:self.time
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
mods['nonplayer'].GetMonsterName=lambda vnum:{2307:'TreeKing',1192:'Skipia',2191:'Turtle'}.get(vnum,'')
mods['ui'].Window=Widget
mods['wndMgr'].GetScreenWidth=lambda:1280;mods['wndMgr'].GetScreenHeight=lambda:800
mods['localeInfo'].MINIMAP_ZONE_NAME_DICT={'metin2_map_trent02':'RedWood'}
self.service=Service();mods['mt_feature_service'].g_service=self.service
mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_boss'};exec(script('BossScript.inc'),self.ns)
self.game=sys.modules['game'];self.gw=self.game.GameWindow()
self.gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
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 open(self):
self.game.GameWindow.Open(self.gw);w=self.ns['g_window'];w.Open();return w
def test_window_lists_server_rows(self):
w=self.open();self.assertEqual(self.ns['g_entry'].pos,(4,356))
self.assertEqual(self.service.requests[-1][:3],('bosses',0,0));self.assertFalse(w.refreshButton.enabled)
self.service.Reply([TRENT,SKIPIA,DESERT]);self.assertTrue(w.refreshButton.enabled)
self.assertEqual(w.lines[0].text,'[CH1] TreeKing RedWood')
self.assertEqual(w.details[0].text,'刷新倒计时 00:01:40 · 坐标 (512, -3) · 周期 02:30:00')
self.assertIn('存活 1/1',w.details[1].text);self.assertIn('随机位置',w.details[1].text)
self.assertEqual(w.lines[2].text,'[共享] Turtle 地图999');self.assertIn('不再刷新',w.details[2].text)
self.assertFalse(w.remindButtons[1].enabled) # alive
self.assertFalse(w.remindButtons[2].enabled) # no timer
self.assertFalse(w.lines[3].shown);self.assertIn('共 3 个',w.status.text)
self.time+=40;w.Render();self.assertIn('00:01:00',w.details[0].text) # counts down from the server time
def test_pages(self):
rows=[[2000+i,68,0,0,1,0,1,1700000100,600] for i in range(11)]
w=self.open();self.service.Reply(rows)
self.assertFalse(w.prevButton.enabled);self.assertTrue(w.nextButton.enabled)
w.nextButton.event();self.assertTrue(w.lines[2].shown);self.assertFalse(w.lines[3].shown);self.assertIn('第 2/2 页',w.status.text)
self.assertFalse(w.nextButton.enabled)
def test_reminder_fires_only_on_confirmed_spawn(self):
w=self.open();self.service.Reply([TRENT,SKIPIA]);w.remindButtons[0].event()
self.assertIn('开',w.remindButtons[0].text);w.Hide()
tick=self.ns['g_tick'];count=len(self.service.requests)
self.time+=50;tick.OnUpdate();self.assertEqual(len(self.service.requests),count) # not due yet
self.time+=60;tick.OnUpdate();self.assertEqual(len(self.service.requests),count+1)
self.service.Reply([TRENT,SKIPIA],time=1700000110);self.assertEqual(self.chat,[]) # timer passed, not spawned
self.assertIn(('2307',),[(str(k[0]),) for k in self.ns['g_tracker'].reminders])
spawned=list(TRENT);spawned[5]=1;spawned[7]=1700009110
self.time+=40;tick.OnUpdate();self.service.Reply([spawned,SKIPIA],time=1700000150)
self.assertEqual(self.chat,['TreeKing 已在 RedWood 刷新']);self.assertEqual(self.ns['g_tracker'].reminders,set())
def test_errors_and_close(self):
w=self.open();self.service.Reply([],error='unsupported');self.assertIn('未启用',w.status.text)
w.Query();self.service.Reply([]);self.assertIn('没有定时刷新',w.status.text)
w.Query();self.service.Reply([TRENT]);w.remindButtons[0].event()
self.game.GameWindow.Close(self.gw)
self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry'])
self.assertEqual(self.ns['g_tracker'].reminders,set());self.assertIsNone(self.ns['g_tracker'].rows)
def test_maps_match_server_index(self):
maps=self.ns['MAPS']
self.assertEqual((maps[68],maps[72],maps[302]),('metin2_map_trent02','metin2_map_skipia_dungeon_01','metin2_map_dawnmistwood'))
if __name__=='__main__':unittest.main()
+21
View File
@@ -110,6 +110,27 @@ class ServiceTests(unittest.TestCase):
self.assertEqual(self.replies[-1][1],expected)
if expected is None:self.assertEqual(self.replies[-1][0]['rows'][0][1],1)
def test_boss_rows_signed_positions_and_unsupported_on_old_cap(self):
self.s.mask=511
rid=self.request('bosses',0)
self.assertEqual(self.sent[-1],'/mt_features 1 %d bosses'%rid)
self.s.Receive('MTF1 begin %d bosses 0 0 2 0 1700000000'%rid)
self.s.Receive('MTF1 boss %d 0 2307 68 51200 -300 1 1 1 1700000900 9000'%rid)
self.s.Receive('MTF1 boss %d 1 2191 125 0 0 99 0 1 0 1800'%rid)
self.s.Receive('MTF1 end %d 2'%rid)
self.assertIsNone(self.replies[-1][1])
self.assertEqual(self.replies[-1][0]['rows'],[[2307,68,51200,-300,1,1,1,1700000900,9000],[2191,125,0,0,99,0,1,0,1800]])
for bad in ('2307 68 0 0 1 2 1 0 9000','0 68 0 0 1 0 1 0 9000','2307 68 0 0 1 0 1 0 0','2307 68 0 0 256 0 1 0 9000','2307 68 0 0 1 0 1 0'):
rid=self.request('bosses',0)
self.s.Receive('MTF1 begin %d bosses 0 0 1 0 1700000000'%rid)
self.s.Receive('MTF1 boss %d 0 %s'%(rid,bad));self.s.Receive('MTF1 end %d 1'%rid)
self.assertEqual(self.replies[-1],(None,'bad_reply'),bad)
self.assertEqual(self.s.Request('bosses',1,0,lambda d,e:self.replies.append((d,e))),0)
self.assertEqual(self.replies[-1],(None,'invalid'))
self.s.mask=255 # a server deployed before boss tracking
self.s.Request('bosses',0,0,lambda d,e:self.replies.append((d,e)));self.time+=1;self.s.Tick()
self.assertEqual(self.replies[-1],(None,'unsupported'))
def test_future_capability_bits_do_not_disable_known_features(self):
self.s.Request('stats',0,0,lambda d,e:None)
self.time+=1;self.s.Tick();rid=self.s.pending['id']