Author SHA1 Message Date
shenleiandClaude Opus 5.5 d11d46c469 Fix the zh locale build after the item shapes overlay
A stray comma turned the proto patch argument list into a tuple, so the
build failed with "bad operand type for unary +" since 5dcac9cb. The AVA2
biology step also accepts the reference unpacked under AVA2/TEST instead of
only the FILES+SRC.tgz archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:31:52 +09:00
shenleiandClaude Opus 5.5 8164c02709 Add the battle pass window (mt_battlepass)
战令 window opened by the 战令 button under the minimap or F6: season and
time left, missions with progress and per-mission claim, final reward,
done notices in chat. Talks to the server over /mt_bp 1 / MTBP1 lines and
reports an unsupported server after 3 s without an answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:11:43 +09:00
4 changed files with 535 additions and 7 deletions
+13 -7
View File
@@ -356,7 +356,7 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_proto"),
str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else [])
+ ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else []),
+ ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else [])
# item_shapes_server.tsv follows the stone overlay (positional): size and flags of new-use items.
+ ([str(names_dir / "item_shapes_server.tsv")] if (names_dir / "item_shapes_server.tsv").is_file() else []),
check=True, capture_output=True, text=True,
@@ -397,7 +397,10 @@ def build_biolog(output: Path, pack_tool: Path):
root = REPO / "tools/server/biolog"
ava = REPO.parent / "AVA2"
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
if not archive.is_file():
extracted = ava / "TEST/2. Backup SF+SRC and SQL/home/srv1/db/item_proto.txt"
if not archive.is_file() and extracted.is_file():
ava = ava / "TEST" # the reference unpacked under AVA2/TEST
elif not archive.is_file():
raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
proto_tool = tool("biolog_proto_patch")
if not proto_tool.is_file():
@@ -405,11 +408,14 @@ def build_biolog(output: Path, pack_tool: Path):
stage = output.parent / "biolog"
stage.mkdir(parents=True, exist_ok=True)
proto = stage / "ava2-item-proto.txt"
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
if not archive.is_file():
shutil.copyfile(extracted, proto)
else:
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
+364
View File
@@ -0,0 +1,364 @@
// PORT: battle pass (战令), from AVA2's uibattlepassex.py. The server runs monthly seasons of up to ten missions
// counted from real events (kills, fishing, refines, levels, NPC trades, play time); each mission has a reward and
// a final reward follows once all are done. This window asks with /mt_bp, shows progress live while open and
// claims rewards. Entry points: F6 and a "战令" button by the minimap. Protocol: metin2-server
// docs/FEATURE-BATTLEPASS.md.
static const char c_szBattlePassScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import item
import net
import nonplayer
import ui
import weakref
from mt_biolog import Action, Text, L
g_window=None
g_entry=None
SEND_GAP=.4
TIMEOUT=3.
PAGE=5
ROW=56
WIDTH=620
MAX_SLOT=10
# type: (name without sub, name with a sub vnum: kind of the vnum)
TYPES={1:('击杀怪物','击杀 ','mob'),2:('击杀 Boss','击杀 Boss ','mob'),3:('摧毁陨石','摧毁 ','mob'),4:('累计在线',None,None),
5:('钓鱼','钓到 ','item'),6:('成功强化装备','强化出 ','item'),7:('提升等级',None,None),8:('在 NPC 商店消费金币',None,None),
9:(None,'向 NPC 出售 ','item')}
ERRORS={'loading':'数据加载中,请稍后再试','slot':'没有这个任务','claimed':'奖励已经领取过了','incomplete':'任务尚未完成',
'space':'背包空间不足,请整理后再领取','config':'奖励配置有误,请联系管理员','db':'服务器繁忙,请稍后再试',
'rate':'操作过快,请稍后再试','invalid':'参数无效'}
def Number(text,low=0,high=10**12):
if not text.isdigit() or len(text)>13:raise ValueError(text)
value=int(text)
if not low<=value<=high:raise ValueError(text)
return value
def Rewards(words):
if len(words)%2:raise ValueError(words)
return [(Number(words[i],1,2**32-1),Number(words[i+1],1,10000)) for i in range(0,len(words),2)]
def Grouped(value):
text=str(int(value));parts=[]
while len(text)>3:parts.insert(0,text[-3:]);text=text[:-3]
return ','.join([text]+parts)
def Amount(kind,value,target):
# Play time is counted in seconds and shown in hours, or in minutes for a target under an hour.
if kind==4:
unit=3600. if target>=3600 else 60.
amount=value/unit
return ('%d'%amount) if amount==int(amount) else ('%.1f'%amount)
return Grouped(value)
def Unit(kind,target):
if kind!=4:return ''
return ' 小时' if target>=3600 else ' 分钟'
def SeasonName(season):
year,month=divmod(season,100)
if 2000<=year<=2999 and 1<=month<=12:return '%d 年 %d 月'%(year,month)
return '第 %d 期'%season
def Left(seconds):
seconds=int(seconds)
if seconds<=0:return '已结束'
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600)
if days:return '剩余 %d 天 %d 小时'%(days,hours)
return '剩余 %d 小时 %d 分'%(hours,max(1,rest//60))
class Pass(object):
# Protocol state only. 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.waiting=None;self.nextSend=0;self.unsupported=False
self.Clear()
def Clear(self):
self.season=None;self.missions={};self.final=None;self.reading=None;self.none=False;self.error=None;self.claiming=None
def Ready(self):
return self.waiting is None or self.now()>=self.waiting
def Post(self,text,expect=True):
now=self.now()
if now<self.nextSend:return False
self.send(text);self.nextSend=now+SEND_GAP
if expect:self.waiting=now+TIMEOUT
return True
def Query(self):
self.Tick()
if self.waiting is not None:return False
return self.Post('/mt_bp 1 info')
def Claim(self,slot):
if self.claiming is not None or not self.season:return False
if self.Post('/mt_bp 1 claim %d'%slot):self.claiming=slot;self.error=None;return True
return False
def Close(self):
self.send('/mt_bp 1 close')
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.claiming=None
if self.season is None:self.unsupported=True
self.Notify('timeout')
def SecondsLeft(self):
if not self.season:return 0
_,_,end,now,at=self.season
return end-(now+(self.now()-at))
def Notify(self,kind,value=None):
if self.listener:self.listener(kind,value)
def Receive(self,line):
if not line.startswith('MTBP1 '):return False
words=line.split()
try:self.Parse(words[1],words[2:])
except (ValueError,IndexError):self.reading=None
return True
def Parse(self,kind,args):
if kind=='season' and len(args)==5:
seasonId,start,end,now=[Number(w,0,2**32-1) for w in args[:4]];count=Number(args[4],1,MAX_SLOT)
self.reading=dict(season=(seasonId,start,end,now,self.now()),count=count,missions={},final=None)
elif kind=='mission' and self.reading is not None and len(args)>=6:
slot=Number(args[0],1,MAX_SLOT);kind=Number(args[1],1,9)
self.reading['missions'][slot]=dict(slot=slot,type=kind,sub=Number(args[2],0,2**32-1),target=Number(args[3],1),
value=Number(args[4]),claimed=Number(args[5],0,1)==1,rewards=Rewards(args[6:]))
elif kind=='final' and self.reading is not None and len(args)>=4:
self.reading['final']=dict(claimed=Number(args[0],0,1)==1,all=Number(args[1],0,1)==1,rewards=Rewards(args[2:]))
elif kind=='end' and not args and self.reading is not None:
r=self.reading;self.reading=None
if len(r['missions'])!=r['count'] or r['final'] is None:return
self.season=r['season'];self.missions=r['missions'];self.final=r['final'];self.none=False
self.Answered('info')
elif kind=='none' and not args:
self.Clear();self.none=True;self.Answered('none')
elif kind=='progress' and len(args)==2:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=min(m['target'],Number(args[1]));self.Notify('progress',slot)
elif kind=='done' and len(args)==1:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=m['target']
self.Notify('done',slot)
elif kind=='claimed' and len(args)==1:
slot=Number(args[0],0,MAX_SLOT)
if slot:
if slot in self.missions:self.missions[slot]['claimed']=True
elif self.final:self.final['claimed']=True
self.claiming=None;self.Notify('claimed',slot) # a full info follows
elif kind=='error' and len(args)==1 and args[0] in ERRORS:
self.error=args[0];self.claiming=None;self.Answered('error')
def Answered(self,kind):
self.waiting=None;self.unsupported=False;self.Notify(kind)
def AllDone(self):
return bool(self.missions) and all(m['value']>=m['target'] for m in self.missions.values())
def ItemName(vnum):
try:
item.SelectItem(vnum);name=item.GetItemName()
except Exception:name=''
return name if name else L('物品 %d'%vnum)
def MobName(vnum):
try:name=nonplayer.GetMonsterName(vnum)
except Exception:name=''
return name if name else L('怪物 %d'%vnum)
def MissionName(m):
# Locale text: the utf-8 parts go through L, game names are already in the locale's code page.
plain,prefix,kind=TYPES.get(m['type'],('任务',None,None))
if m['sub'] and prefix:return L(prefix)+(MobName(m['sub']) if kind=='mob' else ItemName(m['sub']))
return L(plain or '任务')
def RewardText(rewards):
return L(',').join([ItemName(vnum)+(' x%d'%count) for vnum,count in rewards])
class BattlePassWindow(ui.Window):
def __init__(self,state):
ui.Window.__init__(self);self.SetWindowName('BattlePassWindow');self.AddFlag('float');self.AddFlag('movable')
self.state=state;self.page=0
self.SetSize(WIDTH,74+PAGE*ROW+ROW+20+56)
self.heading=Text(self,18,18,'战令',0xffffd68b)
self.season=Text(self,80,18,'',0xffc3b7a1)
self.closeButton=Action(self,WIDTH-76,6,64,'关闭',self.Hide)
self.rows=[]
for i in range(PAGE):
y=64+i*ROW
self.rows.append(dict(name=Text(self,18,y+4,''),value=Text(self,330,y+4,''),
reward=Text(self,18,y+26,'',0xffb3aa98),button=Action(self,WIDTH-118,y,100,'领取',lambda i=i:self.ClaimRow(i))))
y=64+PAGE*ROW+8
self.finalName=Text(self,18,y+4,'最终奖励',0xffffd68b)
self.finalState=Text(self,330,y+4,'')
self.finalReward=Text(self,18,y+26,'',0xffb3aa98)
self.finalButton=Action(self,WIDTH-118,y,100,'领取',lambda:self.state.Claim(0) and self.Refresh())
y=self.GetHeight()-56
self.prev=Action(self,18,y,80,'上一页',lambda:self.Turn(-1))
self.pageText=Text(self,140,y+24,'');self.pageText.SetHorizontalAlignCenter()
self.next=Action(self,182,y,80,'下一页',lambda:self.Turn(1))
self.note=Text(self,280,y+16,'',0xffc3b7a1)
self.second=-1
self.Hide()
def Open(self):
self.state.error=None;self.state.Query();self.Refresh();self.SetCenterPosition();ui.Window.Show(self);self.SetTop()
def Toggle(self):
if self.IsShow():self.Hide()
else:self.Open()
def Hide(self):
state=self.__dict__.get('state') # ui.Window.__init__ hides the window before state is set
if self.IsShow() and state and state.season:state.Close()
ui.Window.Hide(self)
def Slots(self):
return sorted(self.state.missions)
def Pages(self):
return max(1,(len(self.Slots())+PAGE-1)//PAGE)
def Turn(self,delta):
self.page=max(0,min(self.Pages()-1,self.page+delta));self.Refresh()
def ClaimRow(self,i):
slots=self.Slots()[self.page*PAGE:]
if i<len(slots) and self.state.Claim(slots[i]):self.Refresh()
def Refresh(self):
s=self.state;self.page=min(self.page,self.Pages()-1)
slots=self.Slots()[self.page*PAGE:(self.page+1)*PAGE]
for i,row in enumerate(self.rows):
widgets=(row['name'],row['value'],row['reward'],row['button'])
if i>=len(slots):
for w in widgets:w.Hide()
continue
for w in widgets:w.Show()
m=s.missions[slots[i]];done=m['value']>=m['target']
row['name'].SetText(MissionName(m))
row['value'].SetText(Amount(m['type'],m['value'],m['target'])+' / '+Amount(m['type'],m['target'],m['target'])+L(Unit(m['type'],m['target'])))
row['value'].SetPackedFontColor(0xff9fe08b if done else 0xffe8dec8)
row['reward'].SetText(L('奖励:')+RewardText(m['rewards']) if m['rewards'] else L('无奖励'))
if m['claimed']:row['button'].SetText('已领取')
elif s.claiming==m['slot']:row['button'].SetText('领取中')
else:row['button'].SetText('领取' if done else '未完成')
row['button'].SetEnabled(done and not m['claimed'] and bool(m['rewards']) and s.claiming is None)
f=s.final
for w in (self.finalName,self.finalState,self.finalReward,self.finalButton):
if f:w.Show()
else:w.Hide()
if f:
ready=s.AllDone()
self.finalState.SetText(L('已领取' if f['claimed'] else '全部任务完成' if ready else '完成全部任务后可领取'))
self.finalReward.SetText(L('奖励:')+RewardText(f['rewards']))
self.finalButton.SetText('已领取' if f['claimed'] else '领取中' if s.claiming==0 else '领取')
self.finalButton.SetEnabled(ready and not f['claimed'] and s.claiming is None)
many=self.Pages()>1
for w in (self.prev,self.next,self.pageText):
if many and s.season:w.Show()
else:w.Hide()
self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page<self.Pages()-1)
self.pageText.SetText('%d / %d'%(self.page+1,self.Pages()))
if s.season:self.season.SetText(L(SeasonName(s.season[0])+' '+Left(s.SecondsLeft())))
else:self.season.SetText('')
if s.unsupported:note='!服务器尚未启用战令'
elif s.error:note='!'+ERRORS[s.error]
elif s.none:note='当前没有进行中的战令赛季'
elif not s.season:note='正在查询……'
elif s.claiming is not None:note='正在领取……'
else:note='击杀、钓鱼、强化等都会自动计入进度'
self.note.SetText(L(note))
def OnUpdate(self):
self.state.Tick()
second=int(app.GetTime())
if second!=self.second:self.second=second;self.Refresh()
def OnRender(self):
x,y=self.GetGlobalPosition();w=self.GetWidth()
grp.SetColor(0xf51b1916);grp.RenderBar(x,y,w,self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,self.GetHeight())
grp.SetColor(0xff4a3d2a)
for i in range(PAGE+1):grp.RenderLine(x+12,y+60+i*ROW,w-24,0)
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=self.finalButton.event=self.prev.event=self.next.event=None
for row in self.rows:row['button'].event=None
self.state=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('BattlePassEntry');self.OnUpdate()
def OnUpdate(self):
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+460)
def OnPass(kind,value):
s=g_pass
if kind=='done':
m=s.missions.get(value)
name=MissionName(m) if m else L('一项任务')
chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令任务完成:')+name+L(',按 F6 领取奖励'))
elif kind=='claimed':chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令奖励已放入背包' if value else '战令最终奖励已放入背包'))
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
if g_window and g_window.IsShow():g_window.Refresh()
g_pass=Pass(net.SendChatPacket,app.GetTime,OnPass)
def Toggle():
if g_window:g_window.Toggle()
_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_pass.waiting=None;g_pass.unsupported=False;g_pass.Clear()
g_window=BattlePassWindow(g_pass);g_entry=Entry(self)
keys=getattr(self,'onPressKeyDict',None)
if keys is not None and app.DIK_F6 not in keys:keys[app.DIK_F6]=lambda:Toggle()
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 g_pass.Receive(line):return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+2
View File
@@ -1010,6 +1010,7 @@ namespace PythonBoot
#include "BossScript.inc"
#include "VipScript.inc"
#include "ModelPreviewScript.inc"
#include "BattlePassScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -1091,6 +1092,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
install_script_module("mt_boss", c_szBossScript);
install_script_module("mt_vip", c_szVipScript);
install_script_module("mt_preview", c_szModelPreviewScript);
install_script_module("mt_battlepass", c_szBattlePassScript);
if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
+156
View File
@@ -0,0 +1,156 @@
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.Hide()
self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True
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.text=text;self.shown=True
def SetEnabled(self,enabled):self.enabled=enabled
NAMES={70043:'夺宝手套',71153:'经验药水',27992:'白珍珠',27987:'珍珠蚌'}
INFO=['MTBP1 season 202610 1790805600 1793487600 1790900000 6',
'MTBP1 mission 1 1 0 20000 150 0 70043 1 71153 5',
'MTBP1 mission 2 2 0 30 30 0 25041 2',
'MTBP1 mission 3 3 0 300 300 1 50513 3',
'MTBP1 mission 4 4 0 172800 5400 0 71094 2',
'MTBP1 mission 5 5 27987 100 3 0 27987 5',
'MTBP1 mission 9 9 27992 10 0 0',
'MTBP1 final 0 0 27992 2 27993 1',
'MTBP1 end']
class BattlePassTests(unittest.TestCase):
def setUp(self):
self.chat=[];self.sent=[];self.clock=100.
names=('app','chat','game','grp','item','net','nonplayer','ui','mt_biolog')
mods={n:types.ModuleType(n) for n in names}
app=mods['app'];app.GetTime=lambda:self.clock;app.DIK_F6=64
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,BINARY_ServerCommand_Run=lambda s,l:'old'))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=grp.RenderLine=lambda *a:None
self.selected=0
def select(v):self.selected=v
mods['item'].SelectItem=select;mods['item'].GetItemName=lambda:NAMES.get(self.selected,'')
mods['net'].SendChatPacket=self.sent.append
mods['nonplayer'].GetMonsterName=lambda v:{691:'狼王'}.get(v,'')
mods['ui'].Window=Widget
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t)
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_battlepass'};exec(script('BattlePassScript.inc'),self.ns)
self.game=mods['game'];self.gw=self.game.GameWindow()
self.gw.onPressKeyDict={};self.gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
self.game.GameWindow.Open(self.gw)
self.w=self.ns['g_window'];self.p=self.ns['g_pass']
def text(self,t):
w=Widget();w.text=t;w.shown=True;return w
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 feed(self,*lines):
for line in lines:self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,line),1)
def open(self):
self.gw.onPressKeyDict[64]();self.assertTrue(self.w.IsShow());self.assertEqual(self.sent[-1],'/mt_bp 1 info')
self.feed(*INFO)
def test_other_commands_pass_through(self):
self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,'MTVIP1 times 0 0 0 0 0 0 0'),'old')
self.assertIn(64,self.gw.onPressKeyDict)
def test_info_and_rows(self):
self.open();rows=self.w.rows
self.assertEqual(sorted(self.p.missions),[1,2,3,4,5,9]);self.assertEqual(self.w.Pages(),2)
self.assertEqual(rows[0]['name'].text,'击杀怪物');self.assertEqual(rows[0]['value'].text,'150 / 20,000')
self.assertEqual(rows[0]['reward'].text,'奖励:夺宝手套 x1,经验药水 x5')
self.assertFalse(rows[0]['button'].enabled);self.assertEqual(rows[0]['button'].text,'未完成')
self.assertTrue(rows[1]['button'].enabled);self.assertEqual(rows[1]['button'].text,'领取')
self.assertEqual(rows[2]['button'].text,'已领取');self.assertFalse(rows[2]['button'].enabled)
self.assertEqual(rows[3]['value'].text,'1.5 / 48 小时')
self.assertEqual(rows[4]['name'].text,'钓到 珍珠蚌')
self.assertIn('2026 年 10 月',self.w.season.text);self.assertIn('剩余 29 天 22 小时',self.w.season.text)
self.w.next.event();self.assertEqual(self.w.page,1)
self.assertEqual(rows[0]['name'].text,'向 NPC 出售 白珍珠');self.assertEqual(rows[0]['reward'].text,'无奖励')
self.assertFalse(rows[0]['button'].enabled);self.assertFalse(rows[1]['name'].shown)
self.assertFalse(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'完成全部任务后可领取')
def test_progress_done_and_claim(self):
self.open()
self.feed('MTBP1 progress 1 151');self.assertEqual(self.w.rows[0]['value'].text,'151 / 20,000')
self.feed('MTBP1 done 1');self.assertEqual(self.chat[-1],'战令任务完成:击杀怪物,按 F6 领取奖励')
self.assertTrue(self.w.rows[0]['button'].enabled)
self.clock+=1;self.w.rows[0]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1')
self.assertEqual(self.w.rows[0]['button'].text,'领取中');self.assertFalse(self.w.rows[1]['button'].enabled)
self.w.rows[1]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1') # one claim at a time
self.feed('MTBP1 claimed 1');self.assertEqual(self.chat[-1],'战令奖励已放入背包')
self.assertEqual(self.w.rows[0]['button'].text,'已领取')
self.feed(*INFO) # the server sends the full state after a claim
self.clock+=1;self.w.rows[1]['button'].event();self.feed('MTBP1 error space')
self.assertEqual(self.chat[-1],'背包空间不足,请整理后再领取');self.assertIn('背包空间不足',self.w.note.text)
self.assertTrue(self.w.rows[1]['button'].enabled)
def test_final(self):
self.open()
for slot in (1,4,5,9):self.feed('MTBP1 done %d'%slot)
self.assertTrue(self.p.AllDone());self.w.Refresh()
self.assertTrue(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'全部任务完成')
self.clock+=1;self.w.finalButton.event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 0')
self.feed('MTBP1 claimed 0');self.assertEqual(self.chat[-1],'战令最终奖励已放入背包')
self.assertEqual(self.w.finalButton.text,'已领取');self.assertFalse(self.w.finalButton.enabled)
def test_close_none_and_timeout(self):
self.open();self.w.OnPressEscapeKey();self.assertEqual(self.sent[-1],'/mt_bp 1 close');self.assertFalse(self.w.IsShow())
self.feed('MTBP1 done 2');self.assertEqual(self.chat[-1],'战令任务完成:击杀 Boss,按 F6 领取奖励')
self.clock+=1;self.w.Open();self.feed('MTBP1 none')
self.assertIsNone(self.p.season);self.assertEqual(self.w.note.text,'当前没有进行中的战令赛季')
self.assertFalse(self.w.rows[0]['name'].shown);self.assertFalse(self.w.finalButton.shown)
self.w.Hide();self.assertEqual(self.sent[-1],'/mt_bp 1 info') # no season, nothing to close
self.clock+=1;self.w.Open();self.clock+=3.5;self.w.OnUpdate()
self.assertTrue(self.p.unsupported);self.assertEqual(self.w.note.text,'!服务器尚未启用战令')
def test_bad_lines_are_dropped(self):
self.feed('MTBP1 season 1 2 3 4 0','MTBP1 end');self.assertIsNone(self.p.season)
self.feed(INFO[0],INFO[1],'MTBP1 end');self.assertIsNone(self.p.season) # count mismatch
self.feed(INFO[0],'MTBP1 mission 1 1 0 x 0 0',*INFO[2:]);self.assertIsNone(self.p.season)
self.feed('MTBP1 mission 1 1 0 5 0 0','MTBP1 end');self.assertIsNone(self.p.season) # no season line
self.feed('MTBP1 progress 99 1','MTBP1 error nope','MTBP1 bogus')
self.feed(*INFO);self.assertEqual(self.p.season[0],202610)
self.assertEqual(self.ns['Grouped'](50000000),'50,000,000');self.assertEqual(self.ns['Amount'](4,45,90),'0.8')
self.assertEqual(self.ns['Unit'](4,90),' 分钟');self.assertEqual(self.ns['Amount'](4,7200,172800),'2');self.assertEqual(self.ns['SeasonName'](7),'第 7 期')
def test_registered(self):
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
self.assertIn('#include "BattlePassScript.inc"',boot)
self.assertLess(boot.index('install_script_module("mt_preview"'),boot.index('install_script_module("mt_battlepass"'))
self.game.GameWindow.Close(self.gw);self.assertIsNone(self.ns['g_window'])
if __name__=='__main__':unittest.main()