Author SHA1 Message Date
shenleiandClaude Opus 5.5 a22c064587 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 <noreply@anthropic.com>
2026-10-15 23:06:00 +09:00
4 changed files with 90 additions and 10 deletions
+1
View File
@@ -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。
+20 -3
View File
@@ -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 后半):需要单独的存储设计。
- 属性锁定已完成(见上),尚未部署到正式环境。
+39 -6
View File
@@ -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<pages)
self.startButton.SetEnabled(not running and bool(selected) and any(self.alternatives));self.stopButton.SetEnabled(running)
self.status.SetText(L(B(self.StatusText(allowed))));self.current.SetText(self.CurrentText(selected))
@@ -290,14 +320,15 @@ class SwitchbotWindow(ui.Window):
if self.Selected() and allowed is None:return u'正在读取可洗属性…'
if state and (state['active'] or state['reason']!='idle'):
return u'%s · %d / %d 次 · 剩余道具 %d'%(U(REASONS[state['reason']]),state['rolls'],state['limit'],state['scrolls'])
if self.Locks():return u'已锁定的属性在每次洗练中保持不变'
return u'任一方案的属性全部达标即停止;只看前 5 条普通属性'
def CurrentText(self,selected):
if not selected:return ''
parts=[]
parts=[];locks=self.Locks() or 0
for i in range(5):
apply,value=player.GetItemAttribute(player.INVENTORY,selected[0],i)
if apply:parts.append(AffectText(apply,value))
if apply:parts.append((L('[锁]') if locks>>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()
+30 -1
View File
@@ -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())