diff --git a/docs/AVA2-BOSS.md b/docs/AVA2-BOSS.md new file mode 100644 index 00000000..3b0f3d5d --- /dev/null +++ b/docs/AVA2-BOSS.md @@ -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 真机测试未做。 diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index 87fdd09f..b30269a7 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -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。 diff --git a/src/platform/ScriptLib/BossScript.inc b/src/platform/ScriptLib/BossScript.inc new file mode 100644 index 00000000..cf340090 --- /dev/null +++ b/src/platform/ScriptLib/BossScript.inc @@ -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//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 index0);self.nextButton.SetEnabled(self.page+1=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()=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=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']) diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index e9f216c1..5db9a9f7 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -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); } diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 3f56489a..449e289e 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -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) diff --git a/tests/wiki/test_boss.py b/tests/wiki/test_boss.py new file mode 100644 index 00000000..1a49d839 --- /dev/null +++ b/tests/wiki/test_boss.py @@ -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() diff --git a/tests/wiki/test_service.py b/tests/wiki/test_service.py index b15fee92..01a3ca34 100644 --- a/tests/wiki/test_service.py +++ b/tests/wiki/test_service.py @@ -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']