Add client-only channel switch (AVA2 ENABLE_CHANNEL_CHANGE)

mt_channel lists the serverInfo channels of the current server and switches by
running the original paths: /logout, then the login window connects to the
chosen channel with the account kept by MainStream, and the original
stream.isAutoSelect starts the same character. No server change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-10-15 21:37:02 +09:00
co-authored by Claude Opus 5.5
parent aeb67f3c9c
commit 13a462b6fc
6 changed files with 442 additions and 0 deletions
+54
View File
@@ -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 真机测试未做。
+2
View File
@@ -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。
+240
View File
@@ -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";
+2
View File
@@ -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);
}
+2
View File
@@ -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)
+142
View File
@@ -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()