diff --git a/docs/AVA2-CHANNEL.md b/docs/AVA2-CHANNEL.md new file mode 100644 index 00000000..c8d37a43 --- /dev/null +++ b/docs/AVA2-CHANNEL.md @@ -0,0 +1,54 @@ +# AVA2 换线适配进度(v1) + +2026-10-15。只改了 metin2-client,服务端不需要改动。参考了 AVA2 的 `ENABLE_CHANNEL_CHANGE`(`Locale_inc.h`)。AVA2 客户端只接收 `HEADER_GC_CHANNEL`(121)来显示当前频道,真正的换线由服务端完成;AVA2 服务端源码不在本机,所以这里没有照搬它的做法。 + +## 与 AVA2 的差异 + +| 项目 | AVA2 | 本实现 | +| --- | --- | --- | +| 换线方式 | 服务端把角色传送到目标频道的核心 | 客户端依次走原有流程:`/logout` → 登录界面 → 用保存的账号登录目标频道 → 自动选择同一角色进入游戏 | +| 服务端改动 | 需要(频道到端口的对应关系,或 DB/P2P 协议) | 不需要;本服务端的核心之间不知道其他频道的监听端口 | +| 频道列表 | 服务端提供 | 客户端 `serverInfo.REGION_DICT`,与登录界面的频道列表相同 | +| 等待时间 | 传送 | 原 `/logout` 倒计时(3 秒,战斗中 10 秒)加一次登录和读图 | + +## 流程 + +1. 游戏内点“换线”,窗口列出当前服务器的频道。当前频道用 `【】` 标出且不能点。当前频道按 `MainStream` 的连接地址在 `serverInfo` 中查找;找不到时(例如测试端口 13040)显示“未知”,服务器取 `channel.inf` 中上次选择的服务器。 +2. 选择频道后发送原 `/logout`。服务端倒计时,结束后关闭连接;`OnRemoteDisconnect` 调用 `SetLoginPhase`。 +3. `introLogin.LoginWindow.Open` 之后,脚本把连接信息设为目标频道(认证服务器取 `REGION_AUTH_SERVER_DICT`),写入 `channel.inf` 和 `net.SetServerInfo`,打开 `stream.isAutoSelect` 并恢复原来的角色槽位,然后调用原 `LoginWindow.Connect`,账号密码取自 `MainStream.id/pwd`。 +4. `introSelect.SelectCharacterWindow.Open` 中原有的 `isAutoSelect` 分支选择同一槽位并 `StartGame`。 +5. 进入游戏后关闭 `isAutoSelect`,聊天框提示“已换到 CHx”。 + +换线进行中所有频道按钮都不能点:再次发送 `/logout` 会取消服务端倒计时。 + +## 失败处理 + +- 20 秒内服务端没有断开连接(例如刚战斗过,`IsHack` 拒绝退出),取消换线并提示。 +- 登录失败或连接失败时,取消换线、关闭 `isAutoSelect`,并显示原登录失败提示,之后可以手动登录。 +- 没有保存的账号密码时(例如自动登录信息不完整)不发送 `/logout`。 + +## 客户端 + +- 模块 `mt_channel`(`src/platform/ScriptLib/ChannelScript.inc`),在 `PythonBoot.cpp` 中安装: + - 入口是小地图旁“显示”下方的“换线”按钮。 + - 挂接 `game.GameWindow.Open/Close/OnUpdate` 和 `introLogin.LoginWindow.Open/OnUpdate/OnLoginFailure/OnConnectFailure`。 + +## 证据 + +- `tests/wiki/test_channel.py`(CTest:`port.channel`)共 6 项通过,覆盖: + - 当前频道定位(`serverInfo` 匹配、`channel.inf` 回退); + - 完整换线:只发一次 `/logout`,登录窗口连接到目标频道和认证服务器,`isAutoSelect`、槽位、`channel.inf`、`SetServerInfo`,进入游戏后复位; + - 退出超时、登录失败、同频道和无账号时拒绝; + - 服务端 `do_cmd` 的 10 秒倒计时小于超时,且重复 `/logout` 会取消。 +- 2026-10-15 联机测试(203 正式频道,macOS 原生客户端,账号 admin,角色 `[SA]Admin`): + - CH1(13000)→ CH2(13010)→ CH1,每次约 20 秒(倒计时 3 秒、登录约 2 秒、读图约 6 秒)。 + - 换线后角色在同一位置(64600, 79800),连接端口分别为 13010 和 13000,选人界面由原 `isAutoSelect` 自动进入。 + - 截图确认窗口布局、当前频道标记和换线中状态正常。 + +## 限制和未完成 + +- 换线经过登录界面,比服务端传送慢,期间会看到登录和读图画面。 +- 只支持 `serverInfo` 里有的频道;测试核心 channel5(13040)不在列表中,从它出发时当前频道显示“未知”。 +- 副本等地图没有做客户端限制:退出后重新登录由服务端按原规则决定角色位置。 +- macOS 桌面客户端没有中文字体,窗口文字显示为 `?`。 +- Android 真机测试未做。 diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index 038db3f3..87fdd09f 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -122,3 +122,5 @@ 2026-10-15 批量拾取(计划外追加,参考 AVA2 do_pickup_all):服务端 mt_pickup 对范围内自己的物品按分类逐件调用原 PickupItem,客户端在拾取键(Z)上接入并提供分类设置窗口;macOS 客户端已在 203 测试核心联机验收,未部署 203 正式环境、未做 Android 真机测试和触屏按钮。见 AVA2-PICKUP.md。 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。 diff --git a/src/platform/ScriptLib/ChannelScript.inc b/src/platform/ScriptLib/ChannelScript.inc new file mode 100644 index 00000000..8eb6cd90 --- /dev/null +++ b/src/platform/ScriptLib/ChannelScript.inc @@ -0,0 +1,240 @@ +// PORT: AVA2-style channel switch (ENABLE_CHANNEL_CHANGE), client-only. Instead of a server-side warp it runs +// the original paths in order: /logout (the server's 3 s / 10 s countdown, which also refuses while fighting), +// the disconnect brings back introLogin.LoginWindow, which then connects to the chosen channel of serverInfo +// with the account kept by networkModule.MainStream, and stream.isAutoSelect makes introSelect start the same +// character. See docs/AVA2-CHANNEL.md. +static const char c_szChannelScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import game +import grp +import introLogin +import net +import ui +import weakref +from mt_biolog import Action, Text, L +from mt_wiki import B + +try: + import serverInfo +except ImportError: + serverInfo=None + +g_window=None +g_entry=None +CHANNEL_FILE='channel.inf' # the file introLogin's server board reads +LOGOUT_TIMEOUT=20. # countdown is 3 s, 10 s while fighting; nothing back means the server refused +LOGIN_TIMEOUT=30. + + +def Locate(addr,port): + # (region, server, channel) of serverInfo whose game address is addr:port; channel 0 when none matches. + regions=getattr(serverInfo,'REGION_DICT',{}) + for region in sorted(regions): + for server in sorted(regions[region]): + for channel,data in sorted(regions[region][server].get('channel',{}).items()): + if data.get('ip')==addr and data.get('tcp_port')==port:return region,server,channel + try: + f=open(CHANNEL_FILE,'r');words=f.read().split();f.close() + server=int(words[0]);region=int(words[2]) if len(words)>2 else 0 + if server in regions.get(region,{}):return region,server,0 + except (IOError,OSError,ValueError,IndexError):pass + for region in sorted(regions): + for server in sorted(regions[region]):return region,server,0 + return None + + +def Channels(place): + if not place:return [] + region,server,current=place + data=serverInfo.REGION_DICT[region][server] + return [(channel,info) for channel,info in sorted(data.get('channel',{}).items())] + + +class Switcher(object): + # stage: None, 'logout' (waiting for the server to close the session), 'login' (connecting to the target). + def __init__(self,send,now,listener=None): + self.send=send;self.now=now;self.listener=listener + self.stage=None;self.deadline=0;self.target=None;self.account=None;self.error=None;self.stream=None + + def Busy(self):return self.stage is not None + + def Start(self,stream,place,channel): + if self.Busy():return False + region,server,current=place + info=serverInfo.REGION_DICT[region][server]['channel'].get(channel) + if not info or channel==current:self.error='channel';self.Notify('error');return False + if not getattr(stream,'id','') or not getattr(stream,'pwd',''):self.error='account';self.Notify('error');return False + auth=getattr(serverInfo,'REGION_AUTH_SERVER_DICT',{}).get(region,{}).get(server) + account=(auth['ip'],auth['port']) if auth else (stream.account_addr,stream.account_port) + self.target=dict(region=region,server=server,channel=channel,ip=info['ip'],port=info['tcp_port'], + account=account,label='%s, %s'%(serverInfo.REGION_DICT[region][server].get('name',''),info.get('name',''))) + self.account=(stream.id,stream.pwd,stream.GetCharacterSlot());self.stream=stream + self.error=None;self.stage='logout';self.deadline=self.now()+LOGOUT_TIMEOUT + self.send('/logout');self.Notify('start') + return True + + def Tick(self): + if self.stage and self.now()>=self.deadline: + self.error='timeout' if self.stage=='logout' else 'login';self.Clear();self.Notify('error') + + def Clear(self): + if self.stream:self.stream.isAutoSelect=0 # never leave the original auto-select on for a manual login + self.stage=None;self.target=None;self.account=None;self.stream=None + + def Notify(self,kind): + if self.listener:self.listener(kind) + + def OnLoginWindow(self,window): + # LoginWindow.Open has just reset the connect info from loginInfo.xml; point it at the target channel. + if self.stage!='logout':return False + t=self.target;user,pwd,slot=self.account;stream=window.stream + stream.SetConnectInfo(t['ip'],t['port'],t['account'][0],t['account'][1]) + stream.SetCharacterSlot(slot);stream.isAutoSelect=1 + net.SetServerInfo(t['label'].strip()) + try: + f=open(CHANNEL_FILE,'w');f.write('%d %d %d'%(t['server'],t['channel'],t['region']));f.close() + except (IOError,OSError):pass + self.stage='login';self.deadline=self.now()+LOGIN_TIMEOUT + window.Connect(user,pwd) + return True + + def OnLoginFailure(self,error): + if self.stage=='login':self.error='login';self.Clear();self.Notify('error') + + def OnGame(self,stream): + if self.stage=='login': + channel=self.target['channel'];self.Clear();self.Notify('done') + return channel + return 0 + + +ERRORS={'channel':'无法换到该频道','account':'没有保存的登录信息,请重新登录后再换线', + 'timeout':'服务器没有退出(战斗中或不允许退出),换线已取消','login':'登录目标频道失败'} + + +class ChannelWindow(ui.Window): + def __init__(self,switcher,stream): + ui.Window.__init__(self);self.SetWindowName('ChannelWindow');self.AddFlag('float');self.AddFlag('movable') + self.switcher=switcher;self.stream=stream;self.place=None;self.channelButtons=[] + self.SetSize(332,260) + self.heading=Text(self,18,18,'换线',0xffffd68b) + self.closeButton=Action(self,256,6,60,'关闭',self.Hide) + self.current=Text(self,18,52,'',0xffc3b7a1) + self.status=Text(self,18,232,'',0xffc3b7a1) + self.Hide() + + def Buttons(self):return [self.closeButton]+self.channelButtons + + def Build(self): + for b in self.channelButtons:b.Hide();b.event=None + self.place=Locate(self.stream.addr,self.stream.port) + channels=Channels(self.place) + self.channelButtons=[Action(self,18+(i%2)*152,82+(i//2)*52,144,'',lambda channel=channel:self.Switch(channel)) + for i,(channel,info) in enumerate(channels)] + self.SetSize(332,max(160,82+(len(channels)+1)//2*52+40));self.status.SetPosition(18,self.GetHeight()-28) + + def Open(self):self.Build();self.Refresh();self.SetCenterPosition();self.Show();self.SetTop() + + def Switch(self,channel): + if self.place:self.switcher.Start(self.stream,self.place,channel) + self.Refresh() + + def Refresh(self): + current=self.place[2] if self.place else 0 + channels=Channels(self.place);busy=self.switcher.Busy() + name=dict(channels).get(current,{}).get('name') + self.current.SetText(L('当前频道:'+(name if name else '未知'))) + for button,(channel,info) in zip(self.channelButtons,channels): + button.SetText(L(B(u'【%s】'%info.get('name','') if channel==current else info.get('name','')))) + button.SetEnabled(not busy and channel!=current) + self.status.SetText(L(self.StatusText())) + + def StatusText(self): + s=self.switcher + if s.stage=='logout':return '正在退出,稍后自动登录 %s'%s.target['label'].split(', ')[-1] + if s.stage=='login':return '正在登录 %s'%s.target['label'].split(', ')[-1] + if s.error:return '!'+ERRORS[s.error] + if not self.place:return '!没有可用的频道列表' + return '退出后自动登录所选频道,并进入同一角色' + + 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.Hide() + for b in self.Buttons():b.event=None + + +class Entry(Action): + def __init__(self,window): + Action.__init__(self,None,0,0,64,'换线',Toggle) + self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('ChannelEntry');self.OnUpdate() + + def OnUpdate(self): + x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+304) + + +def OnSwitch(kind): + if kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[g_switcher.error])) + if g_window:g_window.Refresh() + + +g_switcher=Switcher(net.SendChatPacket,app.GetTime,OnSwitch) + + +def Toggle(): + if g_window: + if g_window.IsShow():g_window.Hide() + else:g_window.Open() + + +_open=game.GameWindow.Open +_close=game.GameWindow.Close +_update=game.GameWindow.OnUpdate +_login_open=introLogin.LoginWindow.Open +_login_update=introLogin.LoginWindow.OnUpdate +_login_failure=introLogin.LoginWindow.OnLoginFailure +_connect_failure=introLogin.LoginWindow.OnConnectFailure + +def Open(self): + global g_window,g_entry + _open(self) + channel=g_switcher.OnGame(self.stream) + g_window=ChannelWindow(g_switcher,self.stream);g_entry=Entry(self) + if channel: + name=dict(Channels(Locate(self.stream.addr,self.stream.port))).get(channel,{}).get('name','') + chat.AppendChat(chat.CHAT_TYPE_INFO,L('已换到 %s'%name)) + +def Close(self): + global g_window,g_entry + if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None + if g_window:g_window.Destroy();g_window=None + _close(self) + +def OnUpdate(self): + _update(self) + if g_switcher.stage=='logout':g_switcher.Tick() + +def LoginOpen(self): + _login_open(self);g_switcher.OnLoginWindow(self) + +def LoginUpdate(self): + _login_update(self);g_switcher.Tick() + +def LoginFailure(self,error): + g_switcher.OnLoginFailure(error);_login_failure(self,error) + +def ConnectFailure(self): + g_switcher.OnLoginFailure('connect');_connect_failure(self) + +game.GameWindow.Open=Open +game.GameWindow.Close=Close +game.GameWindow.OnUpdate=OnUpdate +introLogin.LoginWindow.Open=LoginOpen +introLogin.LoginWindow.OnUpdate=LoginUpdate +introLogin.LoginWindow.OnLoginFailure=LoginFailure +introLogin.LoginWindow.OnConnectFailure=ConnectFailure +)PY"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 1b2d9581..e9f216c1 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -1002,6 +1002,7 @@ namespace PythonBoot #include "SwitchbotScript.inc" #include "PickupScript.inc" #include "DisplayScript.inc" +#include "ChannelScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -1078,6 +1079,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_switchbot", c_szSwitchbotScript); install_script_module("mt_pickup", c_szPickupScript); install_script_module("mt_display", c_szDisplayScript); + install_script_module("mt_channel", c_szChannelScript); 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 e11cdeee..3f56489a 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -322,6 +322,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_pickup.py) add_test(NAME port.display 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) 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_channel.py b/tests/wiki/test_channel.py new file mode 100644 index 00000000..1edcaf68 --- /dev/null +++ b/tests/wiki/test_channel.py @@ -0,0 +1,142 @@ +import os +import sys +import tempfile +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 Stream(object): + def __init__(self): + self.id='admin';self.pwd='secret';self.addr='10.0.0.1';self.port=13000;self.account_addr='10.0.0.1';self.account_port=11000 + self.slot=2;self.isAutoSelect=0 + def SetConnectInfo(self,addr,port,account_addr=0,account_port=0): + self.addr=addr;self.port=port;self.account_addr=account_addr;self.account_port=account_port + def SetCharacterSlot(self,slot):self.slot=slot + def GetCharacterSlot(self):return self.slot + + +class Login(object): + # introLogin.LoginWindow.Open resets the connect info and the slot from loginInfo.xml. + def __init__(self,stream,log):self.stream=stream;self.log=log + def Open(self):self.stream.SetConnectInfo('',0,'',0);self.stream.SetCharacterSlot(0);self.stream.isAutoSelect=0 + def OnUpdate(self):pass + def OnLoginFailure(self,error):self.log.append(('failure',error)) + def OnConnectFailure(self):self.log.append(('connect',)) + def Connect(self,id,pwd):self.log.append(('connect_to',self.stream.addr,self.stream.port,id,pwd)) + + +class ChannelTests(unittest.TestCase): + def setUp(self): + self.time=1.;self.sent=[];self.chat=[];self.log=[];self.serverinfo=[] + self.cwd=os.getcwd();self.tmp=tempfile.TemporaryDirectory();os.chdir(self.tmp.name) + mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','introLogin','net','serverInfo','ui','mt_biolog','mt_wiki')} + mods['app'].GetTime=lambda:self.time + mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m) + mods['net'].SendChatPacket=self.sent.append;mods['net'].SetServerInfo=self.serverinfo.append + mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,OnUpdate=lambda s:None)) + mods['introLogin'].LoginWindow=Login + si=mods['serverInfo'] + si.REGION_DICT={0:{1:{'name':'Metin2','channel':{ + 1:{'name':'CH1','ip':'10.0.0.1','tcp_port':13000},2:{'name':'CH2','ip':'10.0.0.1','tcp_port':13010}, + 3:{'name':'CH3','ip':'10.0.0.1','tcp_port':13020}}}, + 2:{'name':'Test','channel':{1:{'name':'CH1','ip':'127.0.0.1','tcp_port':13000}}}}} + si.REGION_AUTH_SERVER_DICT={0:{1:{'ip':'10.0.0.1','port':11000},2:{'ip':'127.0.0.1','port':11000}}} + grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None + mods['ui'].Window=Widget + mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t if isinstance(t,str) else t.decode('utf-8') + mods['mt_wiki'].B=lambda t,limit=0:t + self.previous={n:sys.modules.get(n) for n in mods} + sys.modules.update(mods) + self.ns={'__name__':'mt_channel'};exec(script('ChannelScript.inc'),self.ns) + self.stream=Stream();self.game=sys.modules['game'];self.gw=self.game.GameWindow() + self.gw.stream=self.stream;self.gw.interface=types.SimpleNamespace(wndMiniMap=Widget()) + + def tearDown(self): + os.chdir(self.cwd);self.tmp.cleanup() + for name,value in self.previous.items(): + if value is None:sys.modules.pop(name,None) + else:sys.modules[name]=value + + def test_locate(self): + Locate=self.ns['Locate'] + self.assertEqual(Locate('10.0.0.1',13010),(0,1,2)) + self.assertEqual(Locate('127.0.0.1',13000),(0,2,1)) + self.assertEqual(Locate('10.0.0.1',13040),(0,1,0)) # unknown port: first server, current unknown + Path('channel.inf').write_text('2 1 0') + self.assertEqual(Locate('10.0.0.1',13040),(0,2,0)) # the server last picked on the login board + Path('channel.inf').write_text('9 1 0');self.assertEqual(Locate('x',1),(0,1,0)) + + def test_full_switch(self): + game=self.game;gw=self.gw + game.GameWindow.Open(gw);w=self.ns['g_window'];self.assertEqual(self.ns['g_entry'].pos,(4,304)) + w.Open();self.assertEqual(len(w.channelButtons),3);self.assertIn('CH1',w.current.text) + self.assertFalse(w.channelButtons[0].enabled);self.assertIn('【',w.channelButtons[0].text) + w.channelButtons[1].event();self.assertEqual(self.sent,['/logout']) + self.assertTrue(all(not b.enabled for b in w.channelButtons));self.assertIn('正在退出',w.status.text) + w.channelButtons[2].event();self.assertEqual(self.sent,['/logout']) # busy: a second /logout would cancel it + game.GameWindow.Close(gw) # server closed the session -> SetLoginPhase + login=sys.modules['introLogin'].LoginWindow(self.stream,self.log);login.Open() + self.assertEqual(self.log,[('connect_to','10.0.0.1',13010,'admin','secret')]) + self.assertEqual((self.stream.account_addr,self.stream.account_port,self.stream.slot,self.stream.isAutoSelect),('10.0.0.1',11000,2,1)) + self.assertEqual(self.serverinfo,['Metin2, CH2']);self.assertEqual(Path('channel.inf').read_text(),'1 2 0') + game.GameWindow.Open(gw) # introSelect auto-selected slot 2 and the game started + self.assertEqual(self.stream.isAutoSelect,0);self.assertIsNone(self.ns['g_switcher'].stage) + self.assertIn('已换到 CH2',self.chat[-1]) + w=self.ns['g_window'];w.Open();self.assertIn('CH2',w.current.text);self.assertTrue(w.channelButtons[0].enabled) + login.Open();self.assertEqual(len(self.log),1) # a later login window is left alone + + def test_logout_refused_times_out(self): + self.game.GameWindow.Open(self.gw);w=self.ns['g_window'];w.Open();w.channelButtons[2].event() + self.time+=19;self.game.GameWindow.OnUpdate(self.gw);self.assertEqual(self.ns['g_switcher'].stage,'logout') + self.time+=1;self.game.GameWindow.OnUpdate(self.gw);self.assertIsNone(self.ns['g_switcher'].stage) + self.assertIn('换线已取消',self.chat[-1]);self.assertTrue(w.channelButtons[2].enabled) + + def test_login_failure_clears_auto_select(self): + self.game.GameWindow.Open(self.gw);w=self.ns['g_window'];w.Open();w.channelButtons[1].event() + login=sys.modules['introLogin'].LoginWindow(self.stream,self.log);login.Open() + self.assertEqual(self.stream.isAutoSelect,1) + login.OnLoginFailure('ALREADY');self.assertEqual(self.stream.isAutoSelect,0) + self.assertEqual(self.log[-1],('failure','ALREADY'));self.assertIn('登录目标频道失败',self.chat[-1]) + self.assertIsNone(self.ns['g_switcher'].stage) + + def test_refuses_without_account_or_same_channel(self): + s=self.ns['g_switcher'];place=(0,1,1) + self.assertFalse(s.Start(self.stream,place,1));self.assertEqual(s.error,'channel') + self.assertFalse(s.Start(self.stream,place,7));self.assertEqual(s.error,'channel') + self.stream.pwd='';self.assertFalse(s.Start(self.stream,place,2));self.assertEqual(s.error,'account') + self.assertEqual(self.sent,[]) + + def test_server_logout_countdown_fits_timeout(self): + server=ROOT.parent/'metin2-server/src/server/game/src/cmd_general.cpp' + if not server.exists():self.skipTest('server checkout not installed') + text=server.read_text(encoding='latin1');body=text.split('ACMD(do_cmd)',1)[1].split('\n}\n',1)[0] + self.assertIn('info->left_second = 10;',body);self.assertLess(10+2,self.ns['LOGOUT_TIMEOUT']) + self.assertIn('event_cancel(&ch->m_pkTimedEvent)',body) # why the switcher never sends /logout twice + + +if __name__=='__main__':unittest.main()