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:
co-authored by
Claude Opus 5.5
parent
13a462b6fc
commit
ffe097ec57
@@ -0,0 +1,64 @@
|
||||
# AVA2 世界 Boss 追踪进度(v1)
|
||||
|
||||
2026-10-15。参考 AVA2 `uitrack` 的世界 Boss 页,只读。服务端见 metin2-server `docs/FEATURE-BOSS-TRACK.md`。没有移植 AVA2 的传送按钮(它会改变游戏进程);副本页已经由 `mt_dungeons` 实现。
|
||||
|
||||
## 与 AVA2 的差异
|
||||
|
||||
| 项目 | AVA2 | 本实现 |
|
||||
| --- | --- | --- |
|
||||
| 数据来源 | 专用封包 | 原有 `/mt_features` 查询,种类 `bosses`(能力位 256) |
|
||||
| 跨核心 | 不明(AVA2 服务端源码不在本机) | 每个核心用原 `HEADER_GG_RELAY` 广播自己的 Boss 刷新点,旧核心会忽略 |
|
||||
| 传送 | 有 | 无 |
|
||||
| 提醒 | 有 | 有:刷新后在聊天框提示,以服务器快照为准 |
|
||||
|
||||
## 窗口
|
||||
|
||||
- 入口:小地图左侧按钮列中的“Boss”(换线按钮下方)。
|
||||
- 标题“世界Boss”,有“刷新”“上一页/下一页”按钮,每页 8 行。
|
||||
- 每行显示:
|
||||
- `[CHn]` 或 `[共享]`(频道 99)、Boss 名、地图名;
|
||||
- 状态(存活 a/m、刷新倒计时、即将刷新、不再刷新)、坐标(地图内坐标 /100,`ANYWHERE` 显示“随机位置”)、刷新周期;
|
||||
- “提醒:开/关”按钮,只能对未存活、有计时的 Boss 打开。
|
||||
- 窗口打开时每 30 秒刷新一次。倒计时按服务器时间和本地经过时间计算。
|
||||
- 名称:
|
||||
- Boss 名取自 `nonplayer.GetMonsterName`;
|
||||
- 地图名用服务端地图编号 → 地图目录(`MAPS`,取自服务端 `map/index`)在 `localeInfo.MINIMAP_ZONE_NAME_DICT` 中查找;
|
||||
- 找不到时显示“地图N”。
|
||||
|
||||
## 提醒
|
||||
|
||||
- 提醒的键是 (vnum, 地图, 频道, x, y)。
|
||||
- 窗口关闭时,提醒过的 Boss 计时到期 2 秒后,客户端会主动查询一次。
|
||||
- 只有新快照显示它存活时,聊天框才提示“X 已在 Y 刷新”,然后清除这条提醒;只到计时不提示。
|
||||
- 服务器不再列出的 Boss 会被移出提醒。
|
||||
- 离开游戏(换线、换角色)时,清空快照和提醒。
|
||||
|
||||
## 客户端
|
||||
|
||||
- 模块 `mt_boss`(`src/platform/ScriptLib/BossScript.inc`),在 `PythonBoot.cpp` 中安装,挂接 `game.GameWindow.Open/Close`。
|
||||
- `FeatureServiceScript.inc`:
|
||||
- 新增 `bosses` 种类和 `boss` 行解析(13 项,X/Y 有符号);
|
||||
- 校验 vnum/地图/周期非 0、频道 ≤ 255、alive ≤ max ≤ 255;
|
||||
- 一次最多 128 行。
|
||||
|
||||
## 证据
|
||||
|
||||
- `tests/wiki/test_boss.py`(CTest:`port.boss`)共 5 项通过,覆盖:
|
||||
- 入口位置、各状态文字、坐标、分页;
|
||||
- 只在确认刷新后提示;
|
||||
- 未启用和空列表的提示;
|
||||
- 关闭时复位;
|
||||
- 地图表。
|
||||
- `tests/wiki/test_service.py` 新增 `boss` 行和旧服务端(cap 255)返回 unsupported 的测试。
|
||||
- 2026-10-15 联机测试(203,macOS 原生客户端,账号 admin):
|
||||
- 临时开了两个 channel5 核心:13040 放地图 1/21/41/68,13041 放地图 72/5。
|
||||
- 从任一核心进入游戏,窗口都列出两个核心的 5 个 Boss(海盗 5001 ×2、2307、1192、5161),倒计时一致。
|
||||
- 截图确认布局正常。测试后 channel5 已恢复原配置。
|
||||
|
||||
## 限制和未完成
|
||||
|
||||
- 只列出有定时刷新的 Boss 刷新点(regen 文件里 `time > 0`);由任务或事件刷出的 Boss 不在列表中。
|
||||
- 共享频道(game99)的 Boss 要等 game99 换上新服务端才会出现。
|
||||
- 副本内地图(如 5、72)在 `MINIMAP_ZONE_NAME_DICT` 中没有名称,显示“地图N”。
|
||||
- macOS 桌面客户端没有中文字体,窗口文字显示为 `?`。
|
||||
- Android 真机测试未做。
|
||||
@@ -124,3 +124,4 @@
|
||||
2026-10-15 显示开关(计划外追加,参考 AVA2 ENABLE_GRAPHIC_ON_OFF / HIDE_OBJECTS):仅客户端,5 档特效等级(挂载特效 Active/Deactive)、树木和云开关、名字和伤害数字快捷开关;macOS 客户端已在 203 测试核心联机验收,未做 Android 真机测试。不移植摆摊/宠物/坐骑显示范围。见 AVA2-DISPLAY.md。
|
||||
|
||||
2026-10-15 换线(计划外追加,参考 AVA2 ENABLE_CHANNEL_CHANGE):仅客户端,按原流程 /logout → 登录目标频道(serverInfo 频道表 + 保存的账号)→ isAutoSelect 进入同一角色,不做服务端传送;macOS 客户端已在 203 正式频道 CH1↔CH2 联机验收,未做 Android 真机测试。见 AVA2-CHANNEL.md。
|
||||
2026-10-15 世界 Boss 追踪(AVA2 uitrack 世界 Boss 页,只读,不含传送):服务端各核心用原 HEADER_GG_RELAY(名字 #mt_boss)共享定时 Boss 刷新点,旧核心忽略;客户端 mt_boss 窗口 + 刷新提醒,查询种类 bosses(能力位 256);已在 203 两个 channel5 测试核心上做跨核心联机验收,正式核心尚未部署。见 AVA2-BOSS.md。
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// PORT: AVA2 uitrack world-boss tab, read-only. The server's boss regens (own channel plus the shared core) come
|
||||
// from the MTF1 'bosses' query; the original regen timers stay authoritative. AVA2's teleport button is left out.
|
||||
// See docs/AVA2-BOSS.md.
|
||||
static const char c_szBossScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import chat
|
||||
import game
|
||||
import grp
|
||||
import nonplayer
|
||||
import ui
|
||||
import weakref
|
||||
import wndMgr
|
||||
import mt_feature_service
|
||||
from mt_biolog import Action, Text, L
|
||||
|
||||
try:
|
||||
import localeInfo
|
||||
except ImportError:
|
||||
localeInfo=None
|
||||
|
||||
g_window=None
|
||||
g_entry=None
|
||||
g_tick=None
|
||||
PAGE=8
|
||||
REFRESH=30.
|
||||
SHARED_CHANNEL=99
|
||||
# Server map index -> map directory (share/locale/<locale>/map/index), for localeInfo.MINIMAP_ZONE_NAME_DICT.
|
||||
MAPS={1:'metin2_map_a1',3:'metin2_map_a3',4:'metin2_map_guild_01',5:'metin2_map_monkey_dungeon_11',
|
||||
6:'metin2_guild_village_01',21:'metin2_map_b1',23:'metin2_map_b3',24:'metin2_map_guild_02',
|
||||
25:'metin2_map_monkey_dungeon_12',26:'metin2_guild_village_02',41:'metin2_map_c1',43:'metin2_map_c3',
|
||||
44:'metin2_map_guild_03',45:'metin2_map_monkey_dungeon_13',46:'metin2_guild_village_03',61:'map_n_snowm_01',
|
||||
62:'metin2_map_n_flame_01',63:'metin2_map_n_desert_01',64:'map_n_threeway',65:'metin2_map_milgyo',
|
||||
66:'metin2_map_deviltower1',67:'metin2_map_trent',68:'metin2_map_trent02',69:'season1/metin2_map_WL_01',
|
||||
70:'season1/metin2_map_nusluck01',71:'metin2_map_spiderdungeon_02',72:'metin2_map_skipia_dungeon_01',
|
||||
73:'metin2_map_skipia_dungeon_02',81:'metin2_map_wedding_01',100:'metin2_map_fielddungeon',
|
||||
101:'metin2_map_resources_zon',103:'metin2_map_t1',104:'metin2_map_spiderdungeon',105:'metin2_map_t2',
|
||||
107:'metin2_map_monkey_dungeon',108:'metin2_map_monkey_dungeon2',109:'metin2_map_monkey_dungeon3',
|
||||
110:'metin2_map_t3',111:'metin2_map_t4',112:'metin2_map_duel',113:'metin2_map_oxevent',
|
||||
114:'metin2_map_sungzi',118:'metin2_map_sungzi_flame_hill_01',119:'metin2_map_sungzi_flame_hill_02',
|
||||
120:'metin2_map_sungzi_flame_hill_03',121:'metin2_map_sungzi_snow',122:'metin2_map_sungzi_snow_pass01',
|
||||
123:'metin2_map_sungzi_snow_pass02',124:'metin2_map_sungzi_snow_pass03',125:'metin2_map_sungzi_desert_01',
|
||||
126:'metin2_map_sungzi_desert_hill_01',127:'metin2_map_sungzi_desert_hill_02',
|
||||
128:'metin2_map_sungzi_desert_hill_03',181:'metin2_map_empirewar01',182:'metin2_map_empirewar02',
|
||||
183:'metin2_map_empirewar03',200:'gm_guild_build',208:'metin2_map_skipia_dungeon_boss',
|
||||
216:'metin2_map_devilcatacomb',217:'metin2_map_spiderdungeon_03',301:'Metin2_map_CapeDragonHead',
|
||||
302:'metin2_map_dawnmistwood',303:'metin2_map_BayBlackSand',304:'metin2_map_Mt_Thunder',
|
||||
351:'metin2_map_n_flame_dungeon_01'}
|
||||
|
||||
|
||||
def Duration(seconds):
|
||||
seconds=max(0,int(seconds))
|
||||
return '%02d:%02d:%02d'%(seconds//3600,(seconds//60)%60,seconds%60)
|
||||
|
||||
|
||||
def MapName(index):
|
||||
names=getattr(localeInfo,'MINIMAP_ZONE_NAME_DICT',{})
|
||||
name=names.get(MAPS.get(index,''))
|
||||
return name if name else L('地图%d'%index)
|
||||
|
||||
|
||||
def BossName(vnum):
|
||||
try:name=nonplayer.GetMonsterName(vnum)
|
||||
except Exception:name=''
|
||||
return name if name else L('Boss %d'%vnum)
|
||||
|
||||
|
||||
def Key(row):return (row['vnum'],row['map'],row['channel'],row['x'],row['y'])
|
||||
|
||||
|
||||
def Rows(data):
|
||||
rows=[]
|
||||
for v in data['rows']:
|
||||
vnum,map_index,x,y,channel,alive,most,next_time,period=v
|
||||
rows.append(dict(vnum=vnum,map=map_index,x=x,y=y,channel=channel,alive=alive,max=most,next=next_time,period=period))
|
||||
return rows
|
||||
|
||||
|
||||
class Tracker(object):
|
||||
# Server snapshot plus the per-boss spawn reminders; a reminder only fires on a fresh snapshot.
|
||||
def __init__(self,now,notify):
|
||||
self.now=now;self.notify=notify;self.rows=None;self.serverTime=0;self.received=0;self.error=None
|
||||
self.reminders=set()
|
||||
|
||||
def Apply(self,data,error):
|
||||
if error:self.error=error;self.rows=None;return
|
||||
self.error=None;self.serverTime=data['time'];self.received=self.now();rows=Rows(data)
|
||||
alive=set(Key(r) for r in rows if r['alive'])
|
||||
for key in sorted(self.reminders&alive):self.notify(key);self.reminders.discard(key)
|
||||
self.reminders&=set(Key(r) for r in rows) # a boss the server no longer lists cannot be announced
|
||||
self.rows=rows
|
||||
|
||||
def Now(self):return self.serverTime+max(0,self.now()-self.received)
|
||||
|
||||
def Left(self,row):return max(0,row['next']-self.Now()) if row['next'] else None
|
||||
|
||||
def Toggle(self,row):
|
||||
key=Key(row)
|
||||
if key in self.reminders:self.reminders.discard(key)
|
||||
elif not row['alive']:self.reminders.add(key)
|
||||
|
||||
def Due(self):
|
||||
# A reminded boss whose regen timer has passed: ask the server whether it really spawned.
|
||||
if not self.rows:return False
|
||||
return any(Key(r) in self.reminders and r['next'] and self.Now()>=r['next']+2 for r in self.rows)
|
||||
|
||||
|
||||
class BossWindow(ui.Window):
|
||||
def __init__(self,tracker):
|
||||
ui.Window.__init__(self);self.SetWindowName('BossTrackWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.tracker=tracker;self.pending=False;self.generation=0;self.nextQuery=0;self.page=0;self.dimensions=None
|
||||
self.heading=Text(self,18,18,'世界Boss',0xffffd68b)
|
||||
self.closeButton=Action(self,0,6,60,'关闭',self.Hide)
|
||||
self.refreshButton=Action(self,18,48,80,'刷新',self.Query)
|
||||
self.prevButton=Action(self,110,48,72,'上一页',lambda:self.Turn(-1))
|
||||
self.nextButton=Action(self,194,48,72,'下一页',lambda:self.Turn(1))
|
||||
self.status=Text(self,280,54,'',0xffc3b7a1)
|
||||
self.lines=[Text(self,18,0,'',0xffefdfc3) for i in range(PAGE)]
|
||||
self.details=[Text(self,30,0,'',0xffb2aa9a) for i in range(PAGE)]
|
||||
self.remindButtons=[Action(self,0,0,92,'',lambda i=i:self.Remind(i)) for i in range(PAGE)]
|
||||
self.note=Text(self,18,0,'提醒:Boss 刷新后在聊天框提示;以服务器刷新为准。',0xffb2aa9a)
|
||||
self.Reflow();self.Hide()
|
||||
|
||||
def Buttons(self):return [self.closeButton,self.refreshButton,self.prevButton,self.nextButton]+self.remindButtons
|
||||
|
||||
def Open(self):
|
||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
||||
|
||||
def Query(self):
|
||||
if self.pending:return
|
||||
self.pending=True;self.generation+=1;generation=self.generation
|
||||
self.refreshButton.SetEnabled(False);self.status.SetText(L('正在同步…'))
|
||||
def received(data,error):
|
||||
if generation!=self.generation:return
|
||||
self.pending=False;self.refreshButton.SetEnabled(True);self.nextQuery=app.GetTime()+REFRESH
|
||||
self.tracker.Apply(data,error);self.Render()
|
||||
if mt_feature_service.g_service:mt_feature_service.g_service.Request('bosses',0,0,received)
|
||||
else:received(None,'unsupported')
|
||||
|
||||
def Turn(self,step):
|
||||
self.page=max(0,min(self.Pages()-1,self.page+step));self.Render()
|
||||
|
||||
def Pages(self):
|
||||
rows=self.tracker.rows or []
|
||||
return max(1,(len(rows)+PAGE-1)//PAGE)
|
||||
|
||||
def Remind(self,i):
|
||||
rows=self.tracker.rows or [];index=self.page*PAGE+i
|
||||
if index<len(rows):self.tracker.Toggle(rows[index]);self.Render()
|
||||
|
||||
def Render(self):
|
||||
t=self.tracker;rows=t.rows or [];self.page=min(self.page,self.Pages()-1)
|
||||
if t.error:self.status.SetText(L('服务端未启用 Boss 追踪' if t.error=='unsupported' else '未同步,请稍后刷新'))
|
||||
elif t.rows is None:self.status.SetText(L('正在同步…'))
|
||||
elif not rows:self.status.SetText(L('本频道没有定时刷新的 Boss'))
|
||||
else:self.status.SetText(L('第 %d/%d 页,共 %d 个'%(self.page+1,self.Pages(),len(rows))))
|
||||
self.prevButton.SetEnabled(self.page>0);self.nextButton.SetEnabled(self.page+1<self.Pages())
|
||||
for i in range(PAGE):
|
||||
index=self.page*PAGE+i;line=self.lines[i];detail=self.details[i];button=self.remindButtons[i]
|
||||
if index>=len(rows):line.Hide();detail.Hide();button.Hide();continue
|
||||
r=rows[index];left=t.Left(r)
|
||||
channel='共享' if r['channel']==SHARED_CHANNEL else 'CH%d'%r['channel']
|
||||
line.SetText(L('[%s] '%channel)+BossName(r['vnum'])+L(' ')+MapName(r['map']))
|
||||
if r['alive']:state='存活 %d/%d'%(r['alive'],r['max'])
|
||||
elif left is None:state='不再刷新'
|
||||
elif left:state='刷新倒计时 '+Duration(left)
|
||||
else:state='即将刷新'
|
||||
where='随机位置' if not r['x'] and not r['y'] else '坐标 (%d, %d)'%(r['x']//100,r['y']//100)
|
||||
detail.SetText(L('%s · %s · 周期 %s'%(state,where,Duration(r['period']))))
|
||||
button.SetText(L('提醒:'+('开' if Key(r) in t.reminders else '关')))
|
||||
button.SetEnabled(bool(r['alive']==0 and r['next']) or Key(r) in t.reminders)
|
||||
line.Show();detail.Show();button.Show()
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
if dims==self.dimensions:return
|
||||
self.dimensions=dims;width=min(620,max(460,dims[0]-16));height=min(560,max(360,dims[1]-16));self.SetSize(width,height)
|
||||
self.closeButton.SetPosition(width-76,6)
|
||||
spacing=max(36,min(56,(height-140)//PAGE))
|
||||
for i in range(PAGE):
|
||||
y=88+i*spacing
|
||||
self.lines[i].SetPosition(18,y);self.details[i].SetPosition(30,y+18);self.remindButtons[i].SetPosition(width-110,y)
|
||||
self.note.SetPosition(18,height-22);self.SetCenterPosition()
|
||||
|
||||
def OnUpdate(self):
|
||||
self.Reflow();self.Render()
|
||||
|
||||
def OnRender(self):
|
||||
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
|
||||
|
||||
def OnPressEscapeKey(self):self.Hide();return True
|
||||
|
||||
def Destroy(self):
|
||||
self.generation+=1;self.Hide()
|
||||
for b in self.Buttons():b.event=None
|
||||
|
||||
|
||||
class ReminderTick(ui.Window):
|
||||
# Keeps the snapshot fresh while the window is open, and checks due reminders while it is closed.
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.AddFlag('not_pick');self.SetSize(0,0);self.Show()
|
||||
def OnUpdate(self):
|
||||
w=g_window
|
||||
if not w or w.pending or app.GetTime()<w.nextQuery:return
|
||||
if w.IsShow() or g_tracker.Due():w.Query()
|
||||
|
||||
|
||||
class Entry(Action):
|
||||
def __init__(self,window):
|
||||
Action.__init__(self,None,0,0,64,'Boss',Toggle)
|
||||
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('BossEntry');self.OnUpdate()
|
||||
|
||||
def OnUpdate(self):
|
||||
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+356)
|
||||
|
||||
|
||||
def Announce(key):
|
||||
vnum,map_index,channel,x,y=key
|
||||
chat.AppendChat(chat.CHAT_TYPE_INFO,BossName(vnum)+L(' 已在 ')+MapName(map_index)+L(' 刷新'))
|
||||
|
||||
|
||||
g_tracker=Tracker(app.GetTime,Announce)
|
||||
|
||||
|
||||
def Toggle():
|
||||
if g_window:
|
||||
if g_window.IsShow():g_window.Hide()
|
||||
else:g_window.Open()
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
|
||||
def Open(self):
|
||||
global g_window,g_entry,g_tick
|
||||
_open(self);g_window=BossWindow(g_tracker);g_entry=Entry(self);g_tick=ReminderTick()
|
||||
|
||||
def Close(self):
|
||||
global g_window,g_entry,g_tick
|
||||
if g_tick:g_tick.Hide();g_tick=None
|
||||
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
g_tracker.rows=None;g_tracker.reminders=set() # another channel/character lists other bosses
|
||||
_close(self)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
)PY";
|
||||
@@ -6,7 +6,8 @@ import net
|
||||
import ui
|
||||
from mt_wiki_data import Number
|
||||
|
||||
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32}
|
||||
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32,'bosses':256}
|
||||
WHOLE=('stats','dungeons','bosses') # one reply with every row; no subject or page
|
||||
g_service=None
|
||||
g_tick=None
|
||||
_serial=0
|
||||
@@ -27,7 +28,7 @@ class Service(object):
|
||||
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):
|
||||
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))
|
||||
@@ -53,7 +54,7 @@ class Service(object):
|
||||
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'])
|
||||
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
|
||||
|
||||
@@ -80,15 +81,15 @@ class Service(object):
|
||||
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)
|
||||
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') and 'result' in p:
|
||||
elif op in ('row','stat','event','boss') 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'])
|
||||
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 ('stats','dungeons','calendar'):
|
||||
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)
|
||||
@@ -97,12 +98,17 @@ class Service(object):
|
||||
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 ('stats','dungeons') else min(8,result['total']-p['offset'])
|
||||
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'])
|
||||
|
||||
@@ -1003,6 +1003,7 @@ namespace PythonBoot
|
||||
#include "PickupScript.inc"
|
||||
#include "DisplayScript.inc"
|
||||
#include "ChannelScript.inc"
|
||||
#include "BossScript.inc"
|
||||
|
||||
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
||||
{
|
||||
@@ -1080,6 +1081,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
||||
install_script_module("mt_pickup", c_szPickupScript);
|
||||
install_script_module("mt_display", c_szDisplayScript);
|
||||
install_script_module("mt_channel", c_szChannelScript);
|
||||
install_script_module("mt_boss", c_szBossScript);
|
||||
if (g_touch_input)
|
||||
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
||||
}
|
||||
|
||||
@@ -324,6 +324,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_display.py)
|
||||
add_test(NAME port.channel
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_channel.py)
|
||||
add_test(NAME port.boss
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
|
||||
endif()
|
||||
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
|
||||
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
|
||||
|
||||
@@ -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()
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user