From a22c064587948828ff2505fb1f08e48909fb75eb Mon Sep 17 00:00:00 2001 From: shenlei Date: Thu, 15 Oct 2026 23:06:00 +0900 Subject: [PATCH] Add attribute lock buttons to the switchbot window Five per-slot toggles next to start/stop, shown only when the server sends the MTSB1 lock line; locked attributes are marked in the current attribute line. Server side: metin2-server docs/FEATURE-ATTR-LOCK.md. Co-Authored-By: Claude Opus 5.5 --- docs/AVA2-FEATURE-MIGRATION-PLAN.md | 1 + docs/AVA2-SWITCHBOT.md | 23 +++++++++-- src/platform/ScriptLib/SwitchbotScript.inc | 45 +++++++++++++++++++--- tests/wiki/test_switchbot.py | 31 ++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index b30269a7..8655e251 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -125,3 +125,4 @@ 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。 +2026-10-15 属性锁定(阶段 6 后半,参考 AVA2 ENABLE_LOCK_ATTR):服务端新表 mt_item_attr_lock(按物品 ID 存锁定掩码,各核心启动时自动建表;不改动物品结构、db 缓存和客户端封包),CItem::ChangeAttribute 保留锁定槽位,手动、自动、GM 洗练全部生效;精炼时锁定随属性复制;最多 2 组,加成武器的 0/1 槽算一组;锁定免费。客户端在自动洗练窗口里加 5 个锁定按钮。已在 203 channel5 测试核心联机验收(洗 10 次锁定的 2 条不变,重新登录后仍在),未部署正式环境,未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。 diff --git a/docs/AVA2-SWITCHBOT.md b/docs/AVA2-SWITCHBOT.md index 4eb60740..c9a43b02 100644 --- a/docs/AVA2-SWITCHBOT.md +++ b/docs/AVA2-SWITCHBOT.md @@ -1,4 +1,4 @@ -# AVA2 自动洗练适配进度(v1,不含属性锁定) +# AVA2 自动洗练适配进度(v1 + 属性锁定) 2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考。服务端规则和协议见 `metin2-server/docs/FEATURE-SWITCHBOT.md`。 @@ -23,9 +23,26 @@ - 命令间隔至少 0.3 秒,同一时间只有一个请求在等待回复。4 秒没有回复就显示“服务器尚未启用自动洗练”。`attrs` 只接受完整、连续的回复,不完整的整批丢弃。 - 文字都用 GBK 可编码的字符(不用 ◀/▶/⚠)。 +## 属性锁定(2026-10-15) + +- 服务端规则、存储和协议见 `metin2-server/docs/FEATURE-ATTR-LOCK.md`。锁定的槽位在所有洗练方式下都保持不变:手动用道具、自动洗练、GM 命令。 +- 窗口底部“开始/停止”右边有 5 个按钮,每条有属性的普通槽一个:`锁N` 表示未锁,`【锁N】` 表示已锁。点击切换。选属性时和洗练中不能点。 +- 当前属性行里,已锁的属性前面加 `[锁]`。状态行提示“已锁定的属性在每次洗练中保持不变”。 +- 服务端在 `attrs_end` 之后下发 `MTSB1 lock CELL VNUM MASK`。旧服务端不发这一行,所以不显示按钮。 +- 错误提示:`slot`(该条属性不能锁定),`max`(最多锁定 2 条,且至少留 1 条可洗),`db`(保存失败)。 +- 与 AVA2 的差异: + - 锁定免费。AVA2 每次锁定或解锁消耗 50348/50349,203 的 item_proto 里没有这两个道具。 + - 锁定状态存在服务端独立表里,不占属性槽。 + - 不要求物品正好有 5 条属性,但至少要留 1 条不锁。 +- 联机测试(channel5 13040,盔甲 11209,背包 19 格): + - 锁第 2、4 条后,自动洗 10 次,这两条每次都不变,其余 3 条每次都变; + - 锁第 3 组时提示 max; + - 重新登录后锁定状态仍在; + - 解锁后表里的行被删除。 + ## 证据 -- `tests/wiki/test_switchbot.py`(CTest:`port.switchbot`)共 6 项通过,覆盖: +- `tests/wiki/test_switchbot.py`(CTest:`port.switchbot`)共 7 项通过(第 7 项是锁定协议和按钮),覆盖: - 请求节流和合并; - 不完整的 attrs 回复被拒绝; - start 命令的拼装和校验; @@ -54,4 +71,4 @@ - Android 真机测试。 - 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/SwitchbotScript.inc b/src/platform/ScriptLib/SwitchbotScript.inc index acf1cf5f..3b454720 100644 --- a/src/platform/ScriptLib/SwitchbotScript.inc +++ b/src/platform/ScriptLib/SwitchbotScript.inc @@ -1,6 +1,7 @@ // 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. +// only picks the item, scroll and targets and shows progress. The lock buttons toggle the server-side attribute +// lock that every reroll keeps. Protocol: metin2-server docs/FEATURE-SWITCHBOT.md, FEATURE-ATTR-LOCK.md. static const char c_szSwitchbotScript[] = R"PY(# -*- coding: utf-8 -*- import app import chat @@ -21,6 +22,7 @@ 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 +LOCK_GROUPS=2 SEND_GAP=.3 TIMEOUT=4. REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达到次数上限','no_scroll':'洗练道具已用完', @@ -28,7 +30,8 @@ REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达 'stopped':'已手动停止','idle':'未运行'} ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练', 'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练', - 'unsupported':'服务器尚未启用自动洗练'} + 'unsupported':'服务器尚未启用自动洗练','slot':'该条属性不能锁定','max':'最多锁定 2 条,且至少留 1 条可洗', + 'db':'锁定保存失败,请稍后再试'} def Number(text,low=0,high=4294967295): @@ -43,7 +46,7 @@ class Bot(object): 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 + self.allowed={};self.locks={};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 @@ -67,6 +70,14 @@ class Bot(object): parts.append('%d %d'%(apply,value)) self.error=None;self.Enqueue('start',' '.join(parts));return None + def Lock(self,cell,slot): + if not 0<=slot<5 or cell not in self.locks:return + self.error=None;self.Enqueue('lock','/mt_switchbot 1 lock %d %d'%(cell,slot)) + + def LockMask(self,cell,vnum): + lock=self.locks.get(cell) + return lock[1] if lock and lock[0]==vnum else 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']) @@ -106,6 +117,10 @@ class Bot(object): 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=='lock': + # Follows attrs_end (and answers lock). Servers without the lock never send it, so no buttons show. + if len(words)!=4:raise ValueError('lock') + self.locks[Number(words[1])]=(Number(words[2]),Number(words[3],0,31));self.Answered('lock');self.Notify('lock') 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]), @@ -155,13 +170,14 @@ class SwitchbotWindow(ui.Window): 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.lockButtons=[Action(self,0,0,40,'',lambda i=i:self.ToggleLock(i)) for i in range(5)] 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 + self.pickPrev,self.pickNext,self.startButton,self.stopButton]+self.altButtons+self.lockButtons+rows def Open(self): self.Reflow();self.Scan();self.SetCenterPosition();self.Show();self.SetTop();self.bot.Status();self.RequestAttrs() @@ -181,6 +197,7 @@ class SwitchbotWindow(ui.Window): # 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']) + if selected[0] in self.bot.locks:self.bot.locks[state['cell']]=self.bot.locks[selected[0]] 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), @@ -235,6 +252,14 @@ class SwitchbotWindow(ui.Window): target=self.alternatives[self.alt][index];maximum=self.Maximum(target[0]) target[1]=max(1,min(maximum,target[1]+direction*Step(maximum)));self.Refresh() + def Locks(self): + selected=self.Selected() + return self.bot.LockMask(*selected) if selected else None + + def ToggleLock(self,slot): + selected=self.Selected() + if selected and not self.bot.Running():self.bot.Lock(selected[0],slot);self.Refresh() + def Remove(self,index):del self.alternatives[self.alt][index];self.Refresh() def Start(self): @@ -280,6 +305,11 @@ class SwitchbotWindow(ui.Window): for b in (self.pickPrev,self.pickNext): if self.picking and pages>1:b.Show() else:b.Hide() + locks=self.Locks() + for i,b in enumerate(self.lockButtons): + filled=selected and player.GetItemAttribute(player.INVENTORY,selected[0],i)[0] + if locks is None or self.picking or not filled:b.Hide();continue + b.SetText(L(B(u'【锁%d】'%(i+1) if locks>>i&1 else u'锁%d'%(i+1))));b.SetEnabled(not running);b.Show() self.pickPrev.SetEnabled(self.pickPage>0);self.pickNext.SetEnabled(self.pickPage+1>i&1 else '')+AffectText(apply,value)) return ' / '.join(parts) def Reflow(self): @@ -313,6 +344,8 @@ class SwitchbotWindow(ui.Window): bottom=height-104 self.pickPrev.SetPosition(18,bottom);self.pickNext.SetPosition(width-98,bottom) self.startButton.SetPosition(18,bottom);self.stopButton.SetPosition(146,bottom) + step=(width-292)//5 + for i,b in enumerate(self.lockButtons):b.SetPosition(274+i*step,bottom);b.SetSize(step-4,48);b.label.SetPosition((step-4)//2,24) self.status.SetPosition(18,height-46);self.current.SetPosition(18,height-22) self.SetCenterPosition();self.Refresh() diff --git a/tests/wiki/test_switchbot.py b/tests/wiki/test_switchbot.py index 7775c6f4..5d219f7f 100644 --- a/tests/wiki/test_switchbot.py +++ b/tests/wiki/test_switchbot.py @@ -118,11 +118,40 @@ class SwitchbotTests(unittest.TestCase): 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'])) + lock_errors={'slot','max','db'} + self.assertTrue(reasons-{'invalid','running','item','equipped','no_attr','scroll','target','busy'}-lock_errors<=set(self.ns['REASONS'])) + self.assertTrue(lock_errors<=set(self.ns['ERRORS'])) 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_lock_protocol_and_buttons(self): + self.bot.Lock(10,0);self.assertEqual(self.sent,[]) # no lock line yet: old server, no request + self.load(self.bot);self.bot.Receive('MTSB1 lock 10 11209 2') + self.assertEqual(self.bot.LockMask(10,11209),2);self.assertIsNone(self.bot.LockMask(10,11210)) + self.bot.Lock(10,5);self.bot.Lock(10,0);self.advance() + self.assertEqual(self.sent[-1],'/mt_switchbot 1 lock 10 0') + self.bot.Receive('MTSB1 lock 10 11209 3');self.assertIsNone(self.bot.waiting);self.assertEqual(self.bot.LockMask(10,11209),3) + self.bot.Lock(10,1);self.advance();self.bot.Receive('MTSB1 error lock max');self.assertEqual(self.bot.error,'max') + for bad in ('MTSB1 lock 10 11209','MTSB1 lock 10 11209 32'):self.bot.Receive(bad) + self.assertEqual(self.bot.LockMask(10,11209),3) + # Window: one button per filled slot, hidden while picking, locked attributes marked. + game=sys.modules['game'];gw=game.GameWindow();gw.interface=types.SimpleNamespace(wndMiniMap=Widget()) + game.GameWindow.Open(gw);w=self.ns['g_window'];w.Open() + self.assertFalse(any(b.shown for b in w.lockButtons)) + self.load(w.bot);w.bot.Receive('MTSB1 lock 10 11209 2') + self.assertEqual([b.shown for b in w.lockButtons],[True,True,False,False,False]) + self.assertEqual([b.text for b in w.lockButtons[:2]],['锁1','【锁2】']);self.assertIn('[锁]',w.current.text.split(' / ')[1]) + self.assertIn('保持不变',w.status.text) + self.time+=.5;w.OnUpdate();w.bot.Receive('MTSB1 state 0 0 0 0 0 0 0 idle') # answers the status request + sent=len(self.sent);w.lockButtons[0].event();self.time+=.5;w.OnUpdate() + self.assertEqual(self.sent[sent:],['/mt_switchbot 1 lock 10 0']) + w.TogglePick();self.assertFalse(any(b.shown for b in w.lockButtons));w.TogglePick() + w.bot.Receive('MTSB1 state 1 10 11209 0 100 71084 9 running');self.assertFalse(w.lockButtons[0].enabled) + sent=len(self.sent);w.lockButtons[0].event();self.time+=.5;w.OnUpdate() + self.assertNotIn('/mt_switchbot 1 lock 10 0',self.sent[sent:]) + game.GameWindow.Close(gw) + 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())