diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index 7ac676d8..b94dc0a9 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -116,3 +116,5 @@ 2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。 2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。 + +2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,未部署 203、未联机验收。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。 diff --git a/docs/AVA2-SWITCHBOT.md b/docs/AVA2-SWITCHBOT.md new file mode 100644 index 00000000..9d00d1e3 --- /dev/null +++ b/docs/AVA2-SWITCHBOT.md @@ -0,0 +1,54 @@ +# AVA2 自动洗练适配进度(v1,不含属性锁定) + +2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考。服务端规则和协议见 `metin2-server/docs/FEATURE-SWITCHBOT.md`。 + +## 与 AVA2 的差异 + +| 项目 | AVA2 | 本实现 | +| --- | --- | --- | +| 窗口 | 新增 SWITCHBOT 物品窗口,5 个物品槽加 1 个道具槽 | 不新增窗口类型,物品留在背包,按物品 ID 绑定;每个角色同一时间只洗 1 件 | +| 洗练执行 | 自己实现消耗和 ChangeAttribute,绕过手动检查和 ItemLog | 每次都调用原 `UseItem`,检查、概率、日志、扣道具都和手动洗练一样;71151/76023 仍只能洗等级上限 ≤40 的武器和衣服 | +| 速度 | 0.1 秒一次 | 0.25 秒一次(约 4 次/秒) | +| 物品被移动 | 拦截移动、交易、出售等路径 | 每次洗练前重新检查:物品不在背包、已装备、易主时停止(item_gone);交易、商店、仓库、合成界面打开时停止(busy) | +| 换线 / 下线 | 通过 P2P 转移,继续洗 | 直接停止,不离线洗练 | + +匹配规则和 AVA2 相同:最多 2 套方案,每套 1–5 条“属性 ≥ 阈值”;任意一套全部达标就停止,只看前 5 条普通属性。 + +## 客户端 + +- 模块 `mt_switchbot`(`src/platform/ScriptLib/SwitchbotScript.inc`),在 `PythonBoot.cpp` 中安装。入口是小地图旁“百科”按钮下方的“洗练”按钮。 +- 物品:扫描背包里至少有一条属性的武器和防具,用 `<` `>` 切换。洗练道具:扫描子类型为 USE_CHANGE_ATTRIBUTE 的道具,显示数量。次数上限可选 10、50、100、200、500、1000。 +- 可选属性和最大值由服务端 `attrs` 下发,客户端不自己推算。添加属性时分页点选;阈值用 -/+ 调整,步长为最大值的 1/5,默认取最大值。 +- 运行时锁定所有设置,实时显示次数、剩余道具、停止原因和当前 5 条属性。停止时在聊天框提示;达标时如果窗口已关闭,会自动打开。 +- 命令间隔至少 0.3 秒,同一时间只有一个请求在等待回复。4 秒没有回复就显示“服务器尚未启用自动洗练”。`attrs` 只接受完整、连续的回复,不完整的整批丢弃。 +- 文字都用 GBK 可编码的字符(不用 ◀/▶/⚠)。 + +## 证据 + +- `tests/wiki/test_switchbot.py`(CTest:`port.switchbot`)共 6 项通过,覆盖: + - 请求节流和合并; + - 不完整的 attrs 回复被拒绝; + - start 命令的拼装和校验; + - state/error 解析和超时; + - 和服务端的原因码、错误码、上限一致; + - 窗口流程:扫描 → 读取属性 → 选属性 → 开始 → 达标提示。 +- 脚本能在 Python 2.7 下编译。`tests/wiki/test_service.py` 仍然通过。 +- 2026-10-15 联机测试(203 额外开的测试核心 channel5/first,端口 13040,使用隔离编译的 game;macOS 原生客户端,账号 admin,物品为背包 33 格的剑 279,道具 71084): + - 上限 10 次:洗满 10 次后停止(limit),约 4 次/秒。 + - 两套方案、阈值 1:第 18 次达标(matched)。 + - 已经达标的物品:0 次就停止,不扣道具。 + - 洗练中把物品从 33 格移到 90 格:继续洗,窗口跟到 90 格;点停止后显示 stopped。 + - 洗练中退出客户端:服务端立即停止。 + - log.log 中 CHANGE_ATTRIBUTE 共 72 行,和总次数一致。 + - 截图里窗口布局正常。仓库自带的 `Client` 是英文 cp1252 locale,所以中文显示为 `????`;需要 zh locale 客户端才能看到中文。 +- 联机测试发现并修复了两个窗口问题,`test_switchbot.py` 已加回归用例: + - 洗练中移动物品后,选中项会跳到别的物品:现在按服务端报告的格子跟随。 + - 空闲时移动物品后选中项丢失:现在退而按相同 vnum 匹配。 +- 服务端 `tests/features/test_switchbot.py` 和 `test_queries.py` 通过。203 的隔离目录 `/root/metin2-features-dev` 完整编译通过。 + +## 未完成 + +- Android 真机测试;busy、no_scroll 只有单元测试,没有联机验证。 +- macOS 构建需要 `-DMT_40250_SOURCE=<含 EterPack/EterPack.cpp 的源码目录>` 来生成 pack 密钥,否则 system.py 读不出来(RunMain Error)。本机没有 40250 源码,这次用的是 AVA2 的 `TEST/3. Source BIN/Client`,两者密钥相同。 +- 服务端还没有部署到 203 正式环境。 +- 属性锁定(阶段 6 后半):需要单独的存储设计。 diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 8ac6423d..24680a4c 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -999,6 +999,7 @@ namespace PythonBoot #include "DungeonScript.inc" #include "CalendarScript.inc" #include "WikiEntryScript.inc" +#include "SwitchbotScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -1072,6 +1073,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_dungeons", c_szDungeonScript); install_script_module("mt_calendar", c_szCalendarScript); install_script_module("mt_wiki_entries", c_szWikiEntryScript); + install_script_module("mt_switchbot", c_szSwitchbotScript); if (g_touch_input) install_script_module("mt_mobile_hud", c_szMobileHUDScript); } diff --git a/src/platform/ScriptLib/SwitchbotScript.inc b/src/platform/ScriptLib/SwitchbotScript.inc new file mode 100644 index 00000000..acf1cf5f --- /dev/null +++ b/src/platform/ScriptLib/SwitchbotScript.inc @@ -0,0 +1,371 @@ +// PORT: AVA2-style automatic attribute change (uiswitchbot.py), driven by the server's /mt_switchbot +// command. Every roll runs on the server through the original change-attribute scroll path; this window +// only picks the item, scroll and targets and shows progress. Protocol: metin2-server docs/FEATURE-SWITCHBOT.md. +static const char c_szSwitchbotScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import game +import grp +import item +import net +import player +import ui +import uiToolTip +import wndMgr +import weakref +from mt_biolog import Action, Text, L +from mt_wiki import B, U + +g_window=None +g_entry=None +USE_CHANGE_ATTRIBUTE=18 # CItemData::EUseSubTypes; the server re-checks the scroll subtype. +LIMITS=(10,50,100,200,500,1000) +MAX_TARGETS=5 +SEND_GAP=.3 +TIMEOUT=4. +REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达到次数上限','no_scroll':'洗练道具已用完', + 'item_gone':'物品已移动、装备或交易,已停止','rejected':'服务器拒绝了本次洗练','busy':'交易/商店/仓库/合成界面打开,已停止', + 'stopped':'已手动停止','idle':'未运行'} +ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练', + 'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练', + 'unsupported':'服务器尚未启用自动洗练'} + + +def Number(text,low=0,high=4294967295): + if not text.isdigit() or len(text)>10:raise ValueError(text) + value=int(text) + if not low<=value<=high:raise ValueError(text) + return value + + +class Bot(object): + # Protocol state only; the window renders it. send(text) posts a chat command, now() is seconds. + def __init__(self,send,now,listener=None): + self.send=send;self.now=now;self.listener=listener + self.queue=[];self.nextSend=0;self.waiting=None + self.allowed={};self.loading=None;self.state=None;self.error=None;self.unsupported=False + + def Enqueue(self,verb,text): + if verb=='attrs' and self.waiting and self.waiting[2]==text:return + self.queue=[q for q in self.queue if not (verb=='attrs' and q[0]=='attrs')] + if len(self.queue)<4:self.queue.append((verb,text)) + self.Tick() + + def RequestAttrs(self,cell): + self.Enqueue('attrs','/mt_switchbot 1 attrs %d'%cell) + + def Start(self,cell,scroll,limit,alternatives): + groups=[a for a in alternatives if a] + if not groups or len(groups)>2 or not 1<=limit<=1000:return 'invalid' + allowed=dict(self.allowed.get(cell,{}).get('rows',())) + parts=['/mt_switchbot 1 start %d %d %d'%(cell,scroll,limit)] + for targets in groups: + if len(targets)>MAX_TARGETS or len(set(a for a,v in targets))!=len(targets):return 'invalid' + parts.append(str(len(targets))) + for apply,value in targets: + if apply not in allowed or not 1<=value<=allowed[apply]:return 'target' + parts.append('%d %d'%(apply,value)) + self.error=None;self.Enqueue('start',' '.join(parts));return None + + def Stop(self):self.Enqueue('stop','/mt_switchbot 1 stop') + def Status(self):self.Enqueue('status','/mt_switchbot 1 status') + def Running(self):return bool(self.state and self.state['active']) + + def Tick(self): + now=self.now() + if self.waiting and now>=self.waiting[1]: + self.waiting=None;self.queue=[];self.loading=None;self.unsupported=True;self.error='unsupported';self.Notify('error') + if self.queue and not self.waiting and now>=self.nextSend: + verb,text=self.queue.pop(0);self.send(text);self.nextSend=now+SEND_GAP + self.waiting=(verb,now+TIMEOUT,text) + + def Answered(self,verb): + if self.waiting and (self.waiting[0]==verb or verb in ('state','error')):self.waiting=None + self.unsupported=False + + def Notify(self,kind): + if self.listener:self.listener(kind) + + def Receive(self,line): + if not line.startswith('MTSB1 '):return False + words=line.split() + try:self.Parse(words[1:]) + except (ValueError,IndexError,KeyError):self.loading=None + return True + + def Parse(self,words): + kind=words[0] + if kind=='attrs': + self.loading=dict(cell=Number(words[1]),vnum=Number(words[2]),count=Number(words[3],0,255),rows=[]) + elif kind=='attr': + row=self.loading;index=Number(words[1]) + if not row or index!=len(row['rows']) or index>=row['count']:raise ValueError('order') + row['rows'].append((Number(words[2],0,255),Number(words[3],1,2147483647))) + elif kind=='attrs_end': + row=self.loading;self.loading=None + if not row or Number(words[1])!=row['cell'] or Number(words[2])!=row['count'] or len(row['rows'])!=row['count']: + raise ValueError('partial') + self.allowed[row['cell']]=row;self.Answered('attrs');self.Notify('attrs') + elif kind=='state': + if len(words)!=9 or words[8] not in REASONS:raise ValueError('state') + self.state=dict(active=Number(words[1],0,1)==1,cell=Number(words[2]),vnum=Number(words[3]),rolls=Number(words[4]), + limit=Number(words[5]),scroll=Number(words[6]),scrolls=Number(words[7]),reason=words[8]) + self.Answered('state');self.Notify('state') + elif kind=='error': + if len(words)!=3 or words[2] not in ERRORS:raise ValueError('error') + if words[1]=='attrs':self.loading=None + self.error=words[2];self.Answered('error');self.Notify('error') + + +def ItemName(vnum): + item.SelectItem(vnum);return item.GetItemName() + + +def AffectText(apply,value): + affect=getattr(uiToolTip.ItemToolTip,'AFFECT_DICT',{}).get(apply) + try: + if affect:return affect(value) + except (TypeError,ValueError):pass + return L(B(u'属性#%d +%d'%(apply,value))) + + +def Step(maximum):return max(1,maximum//5) + + +class SwitchbotWindow(ui.Window): + def __init__(self): + ui.Window.__init__(self);self.SetWindowName('SwitchbotWindow');self.AddFlag('float');self.AddFlag('movable') + self.bot=Bot(net.SendChatPacket,app.GetTime,self.OnBot) + self.items=[];self.itemIndex=0;self.scrolls=[];self.scrollIndex=0;self.limitIndex=2 + self.alternatives=[[],[]];self.alt=0;self.picking=False;self.pickPage=0;self.dimensions=None;self.nextScan=0 + self.heading=Text(self,18,18,'自动洗练',0xffffd68b) + self.closeButton=Action(self,0,6,60,'关闭',self.Hide) + self.itemPrev=Action(self,18,58,48,'<',lambda:self.Cycle('item',-1));self.itemNext=Action(self,0,58,48,'>',lambda:self.Cycle('item',1)) + self.itemText=Text(self,80,82,'') + self.scrollPrev=Action(self,18,110,48,'<',lambda:self.Cycle('scroll',-1));self.scrollNext=Action(self,0,110,48,'>',lambda:self.Cycle('scroll',1)) + self.scrollText=Text(self,80,134,'') + self.limitButton=Action(self,0,110,110,'',self.NextLimit) + self.altButtons=[Action(self,18+i*104,162,96,'方案一' if i==0 else '方案二',lambda i=i:self.SelectAlt(i)) for i in range(2)] + self.addButton=Action(self,0,162,110,'添加属性',self.TogglePick) + self.rows=[] + for i in range(MAX_TARGETS): + y=214+i*46 + self.rows.append(dict(text=Text(self,24,y+22,''),minus=Action(self,0,y,44,'-',lambda i=i:self.Adjust(i,-1)), + plus=Action(self,0,y,44,'+',lambda i=i:self.Adjust(i,1)),remove=Action(self,0,y,44,'×',lambda i=i:self.Remove(i)), + pick=Action(self,18,y,100,'',lambda i=i:self.Pick(i)))) + self.pickPrev=Action(self,18,0,80,'上一页',lambda:self.PickPage(-1));self.pickNext=Action(self,0,0,80,'下一页',lambda:self.PickPage(1)) + self.startButton=Action(self,18,0,120,'开始洗练',self.Start);self.stopButton=Action(self,146,0,120,'停止',self.bot.Stop) + self.status=Text(self,18,0,'',0xffc3b7a1);self.current=Text(self,18,0,'',0xffefdfc3) + self.Reflow();self.Hide() + + def Buttons(self): + rows=[b for r in self.rows for b in (r['minus'],r['plus'],r['remove'],r['pick'])] + return [self.closeButton,self.itemPrev,self.itemNext,self.scrollPrev,self.scrollNext,self.limitButton,self.addButton, + self.pickPrev,self.pickNext,self.startButton,self.stopButton]+self.altButtons+rows + + def Open(self): + self.Reflow();self.Scan();self.SetCenterPosition();self.Show();self.SetTop();self.bot.Status();self.RequestAttrs() + + def Scan(self): + items=[];scrolls={} + for cell in range(player.INVENTORY_PAGE_SIZE*player.INVENTORY_PAGE_COUNT): + vnum=player.GetItemIndex(player.INVENTORY,cell) + if not vnum:continue + item.SelectItem(vnum);kind=item.GetItemType() + if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE: + scrolls[vnum]=scrolls.get(vnum,0)+player.GetItemCount(player.INVENTORY,cell) + elif kind in (item.ITEM_TYPE_WEAPON,item.ITEM_TYPE_ARMOR) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)): + items.append((cell,vnum)) + selected=self.Selected();scroll=self.Scroll();state=self.bot.state + if state and state['active'] and (state['cell'],state['vnum']) in items: + # The server binds the bot to the item ID and reports its current cell; follow it after a move. + if selected and selected[0] in self.bot.allowed and selected[1]==state['vnum']: + self.bot.allowed[state['cell']]=dict(self.bot.allowed[selected[0]],cell=state['cell']) + selected=(state['cell'],state['vnum']) + self.items=items;self.scrolls=sorted(scrolls.items()) + self.itemIndex=next((i for i,v in enumerate(items) if v==selected), + next((i for i,v in enumerate(items) if selected and v[1]==selected[1]),min(self.itemIndex,max(0,len(items)-1)))) + self.scrollIndex=next((i for i,v in enumerate(self.scrolls) if v[0]==scroll),min(self.scrollIndex,max(0,len(self.scrolls)-1))) + self.nextScan=app.GetTime()+1 + if self.Selected()!=selected:self.alternatives=[[],[]];self.RequestAttrs() + + def Selected(self):return self.items[self.itemIndex] if self.items else None + def Scroll(self):return self.scrolls[self.scrollIndex][0] if self.scrolls else 0 + + def RequestAttrs(self): + selected=self.Selected() + if selected and (self.bot.allowed.get(selected[0],{}).get('vnum')!=selected[1]):self.bot.RequestAttrs(selected[0]) + + def Allowed(self): + selected=self.Selected() + data=self.bot.allowed.get(selected[0]) if selected else None + return data['rows'] if data and data['vnum']==selected[1] else None + + def Cycle(self,kind,step): + if self.bot.Running():return + if kind=='item' and self.items: + self.itemIndex=(self.itemIndex+step)%len(self.items);self.alternatives=[[],[]];self.picking=False;self.RequestAttrs() + elif kind=='scroll' and self.scrolls:self.scrollIndex=(self.scrollIndex+step)%len(self.scrolls) + self.Refresh() + + def NextLimit(self): + if not self.bot.Running():self.limitIndex=(self.limitIndex+1)%len(LIMITS);self.Refresh() + + def SelectAlt(self,index):self.alt=index;self.picking=False;self.Refresh() + + def TogglePick(self): + if self.bot.Running() or self.Allowed() is None:return + self.picking=not self.picking;self.pickPage=0;self.Refresh() + + def Candidates(self): + used=set(a for a,v in self.alternatives[self.alt]) + return [r for r in self.Allowed() or () if r[0] not in used] + + def PickPage(self,step):self.pickPage+=step;self.Refresh() + + def Pick(self,index): + rows=self.Candidates()[self.pickPage*MAX_TARGETS:] + if index1:b.Show() + else:b.Hide() + self.pickPrev.SetEnabled(self.pickPage>0);self.pickNext.SetEnabled(self.pickPage+1=self.nextScan:self.Scan();self.Refresh() + + 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();self.bot.listener=None + 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('SwitchbotEntry');self.OnUpdate() + + def OnUpdate(self): + x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+148) + + +def Toggle(): + if g_window: + if g_window.IsShow():g_window.Hide() + else:g_window.Open() + + +_open=game.GameWindow.Open +_close=game.GameWindow.Close +_command=game.GameWindow.BINARY_ServerCommand_Run + +def Open(self): + global g_window,g_entry + _open(self);g_window=SwitchbotWindow();g_entry=Entry(self) + +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 Command(self,line): + if line.startswith('MTSB1 '): + if g_window:g_window.bot.Receive(line) + return 1 + return _command(self,line) + +game.GameWindow.Open=Open +game.GameWindow.Close=Close +game.GameWindow.BINARY_ServerCommand_Run=Command +)PY"; diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index b791fadd..785e4b2d 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -316,6 +316,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_timers.py) add_test(NAME port.feature_service COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_service.py) + add_test(NAME port.switchbot + COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_switchbot.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_switchbot.py b/tests/wiki/test_switchbot.py new file mode 100644 index 00000000..7775c6f4 --- /dev/null +++ b/tests/wiki/test_switchbot.py @@ -0,0 +1,154 @@ +import re +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 GetLocalPosition(self):return self.pos + 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.label=Widget();self.text=text;self.shown=True + def SetEnabled(self,enabled):self.enabled=enabled + + +class SwitchbotTests(unittest.TestCase): + def setUp(self): + self.time=1.;self.sent=[];self.chat=[];self.events=[] + self.inventory={10:(11209,1,[(3,2),(15,1)]),20:(71084,7,[]),21:(71084,3,[]),30:(27001,5,[])} + protos={11209:(2,0),71084:(3,18),27001:(3,0)} + mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','item','net','player','ui','uiToolTip','wndMgr','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['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,BINARY_ServerCommand_Run=lambda s,line:7)) + grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None + it=mods['item'];it.ITEM_TYPE_WEAPON=1;it.ITEM_TYPE_ARMOR=2;it.ITEM_TYPE_USE=3;self.selected=None + it.SelectItem=lambda v:setattr(self,'selected',v);it.GetItemType=lambda:protos[self.selected][0];it.GetItemSubType=lambda:protos[self.selected][1] + it.GetItemName=lambda:'N%d'%self.selected + pl=mods['player'];pl.INVENTORY=1;pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5 + pl.GetItemIndex=lambda w,c:self.inventory.get(c,(0,))[0];pl.GetItemCount=lambda w,c:self.inventory[c][1] + pl.GetItemAttribute=lambda w,c,i:(self.inventory[c][2]+[(0,0)]*5)[i] + mods['ui'].Window=Widget + mods['uiToolTip'].ItemToolTip=type('ItemToolTip',(),dict(AFFECT_DICT={3:lambda v:'STR+%d'%v})) + mods['wndMgr'].GetScreenWidth=lambda:800;mods['wndMgr'].GetScreenHeight=lambda:600 + 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'].U=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_switchbot'};exec(script('SwitchbotScript.inc'),self.ns) + self.bot=self.ns['Bot'](self.sent.append,lambda:self.time,self.events.append) + + 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 advance(self,seconds=.4):self.time+=seconds;self.bot.Tick() + + def load(self,bot,cell=10): + for line in ('MTSB1 attrs %d 11209 3'%cell,'MTSB1 attr 0 3 12','MTSB1 attr 1 63 6','MTSB1 attr 2 71 30','MTSB1 attrs_end %d 3'%cell):bot.Receive(line) + + def test_commands_are_paced_and_attrs_requests_collapse(self): + self.bot.RequestAttrs(10);self.bot.RequestAttrs(11);self.bot.RequestAttrs(12);self.bot.Status() + self.assertEqual(self.sent,['/mt_switchbot 1 attrs 10']) + self.load(self.bot);self.advance() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 attrs 12') + self.bot.Receive('MTSB1 error attrs no_attr');self.advance() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 status') + self.assertEqual(len(self.sent),3) + + def test_atomic_attr_list_and_rejected_partials(self): + self.load(self.bot) + self.assertEqual(self.bot.allowed[10]['rows'],[(3,12),(63,6),(71,30)]) + for bad in (['MTSB1 attrs 11 1 2','MTSB1 attr 0 3 12','MTSB1 attrs_end 11 2'], + ['MTSB1 attrs 11 1 1','MTSB1 attr 1 3 12','MTSB1 attrs_end 11 1'], + ['MTSB1 attrs 11 1 1','MTSB1 attr 0 3 -1','MTSB1 attrs_end 11 1'], + ['MTSB1 attrs_end 11 0']): + for line in bad:self.assertTrue(self.bot.Receive(line)) + self.assertNotIn(11,self.bot.allowed) + self.assertFalse(self.bot.Receive('MTF1 cap 1 1 127 0')) + + def test_start_validates_against_server_list_and_builds_command(self): + self.load(self.bot) + self.assertEqual(self.bot.Start(10,71084,100,[[(3,13)],[]]),'target') + self.assertEqual(self.bot.Start(10,71084,100,[[(1,1)],[]]),'target') + self.assertEqual(self.bot.Start(10,71084,100,[[(3,1),(3,2)],[]]),'invalid') + self.assertEqual(self.bot.Start(10,71084,1001,[[(3,1)],[]]),'invalid') + self.assertEqual(self.bot.Start(10,71084,100,[[],[]]),'invalid') + self.assertIsNone(self.bot.Start(10,71084,100,[[(3,8),(63,6)],[(71,30)]])) + self.advance() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 start 10 71084 100 2 3 8 63 6 1 71 30') + self.bot.Receive('MTSB1 error start running') + self.assertIsNone(self.bot.Start(10,71084,5,[[],[(71,1)]]));self.advance() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 start 10 71084 5 1 71 1') # empty first alternative skipped + + def test_state_errors_and_timeout(self): + self.bot.Receive('MTSB1 state 1 10 11209 3 100 71084 4 running') + self.assertTrue(self.bot.Running());self.assertEqual(self.bot.state['scrolls'],4) + self.bot.Receive('MTSB1 state 0 10 11209 4 100 71084 3 matched') + self.assertFalse(self.bot.Running());self.assertEqual(self.bot.state['reason'],'matched') + self.bot.Receive('MTSB1 state 0 10 11209 4 100 71084 3 exploded') + self.assertEqual(self.bot.state['reason'],'matched') # unknown reason ignored + self.bot.Receive('MTSB1 error start busy');self.assertEqual(self.bot.error,'busy') + self.bot.Status();self.advance(4.1);self.advance() + self.assertEqual(self.bot.error,'unsupported');self.assertTrue(self.bot.unsupported) + + def test_server_constants_match(self): + server=ROOT.parent/'metin2-server/src/server/game/src/port_switchbot.h' + if not server.exists():self.skipTest('server checkout not installed') + text=server.read_text(encoding='latin1') + reasons=set(re.findall(r'return "([a-z_]+)"',text))|{'running','stopped','idle'} + errors=set(re.findall(r'error [a-z%]+ ([a-z_]+)"',text))|{'invalid','rate'} + self.assertTrue(reasons-{'invalid','running','item','equipped','no_attr','scroll','target','busy'}<=set(self.ns['REASONS'])) + self.assertTrue(errors<=set(self.ns['ERRORS'])) + self.assertIn('kPortSwitchLimit=1000',text);self.assertEqual(max(self.ns['LIMITS']),1000) + self.assertEqual(self.ns['MAX_TARGETS'],5) + + def test_window_scan_pick_start_and_notify(self): + game=sys.modules['game'];window_type=game.GameWindow + gw=window_type();gw.interface=types.SimpleNamespace(wndMiniMap=Widget()) + game.GameWindow.Open(gw);w=self.ns['g_window'];w.Open() + self.assertEqual(w.items,[(10,11209)]);self.assertEqual(w.scrolls,[(71084,10)]) + self.assertEqual(self.sent,['/mt_switchbot 1 attrs 10']) # the status request waits for this reply + self.load(w.bot);self.time+=.5;w.OnUpdate() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 status') + self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTSB1 state 0 0 0 0 0 0 0 idle'),1) + self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7) + w.TogglePick();w.Pick(0) + self.assertEqual(w.alternatives[0],[[3,12]]) + w.Adjust(0,-1);self.assertEqual(w.alternatives[0][0][1],10);w.Adjust(0,1);w.Adjust(0,1);self.assertEqual(w.alternatives[0][0][1],12) + w.Start();self.time+=.5;w.OnUpdate() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 start 10 71084 100 1 3 12') + w.bot.Receive('MTSB1 state 1 10 11209 1 100 71084 9 running');self.assertFalse(w.startButton.enabled) + self.inventory[40]=self.inventory.pop(10);sent=len(self.sent) # moved while running: follow the server's cell + w.bot.Receive('MTSB1 state 1 40 11209 2 100 71084 8 running');w.Scan() + self.assertEqual(w.Selected(),(40,11209));self.assertEqual(w.alternatives[0],[[3,12]]) + self.assertEqual(w.Allowed(),[(3,12),(63,6),(71,30)]);self.time+=.5;w.OnUpdate();self.assertEqual(len(self.sent),sent) + w.bot.Receive('MTSB1 state 0 40 11209 2 100 71084 8 stopped') + self.inventory[5]=self.inventory.pop(40);w.Scan();self.assertEqual(w.Selected(),(5,11209)) # moved while idle: same vnum + self.inventory[10]=self.inventory.pop(5);w.Scan() + w.Hide();w.bot.Receive('MTSB1 state 0 10 11209 2 100 71084 8 matched') + self.assertTrue(w.IsShow());self.assertIn('已获得目标属性',self.chat[-1]) + game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']) + + +if __name__=='__main__':unittest.main()