diff --git a/src/platform/ScriptLib/BattlePassScript.inc b/src/platform/ScriptLib/BattlePassScript.inc new file mode 100644 index 00000000..26a13906 --- /dev/null +++ b/src/platform/ScriptLib/BattlePassScript.inc @@ -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.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): + 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