Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32372c2a73 | ||
|
|
5dcac9cbd4 | ||
|
|
b14fb2ffe9 | ||
|
|
b8363693f1 | ||
|
|
10f8c9760e | ||
|
|
a22c064587 |
@@ -54,6 +54,13 @@
|
||||
服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级),
|
||||
由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。
|
||||
|
||||
## 物品占格与标志(item_shapes_server.tsv)
|
||||
|
||||
服务端给了新用途、改过占格或标志的物品(每行 VNUM、size、flags、anti flags,数值),由 `locale_proto_patch`
|
||||
写进客户端 `item_proto`,背包占格和堆叠与服务端一致;只允许这几条记录的这三个字段变化。目前是 VIP 会员卡
|
||||
38001/38002(`tools/server/vip-card`):40250 原为 size 0,背包里不显示也不能移动。
|
||||
该表跟在 `item_stones_server.tsv` 后面传入(位置参数),所以需要石头表也存在。
|
||||
|
||||
## 物品说明(itemdesc_translate_zh.tsv)
|
||||
|
||||
40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译,
|
||||
|
||||
@@ -685,8 +685,8 @@ VNUM LOCALE_NAME
|
||||
31092 超冷怪的皮肤碎片
|
||||
35000 药草+
|
||||
35001 虎肝+
|
||||
38001 VIP特权
|
||||
38002 抽奖券(今日)
|
||||
38001 VIP会员卡(7天)
|
||||
38002 VIP会员卡(30天)
|
||||
38003 抽奖券(明日)
|
||||
38004 抽奖重抽券
|
||||
38005 高级抽奖转盘券
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# vnum size flags anti flags: item_proto.txt size/flags changed on our server (tools/server/*), mirrored into the
|
||||
# client proto by locale_proto_patch so the item takes a cell and stacks like on the server.
|
||||
# 38001/38002 VIP 会员卡 (tools/server/vip-card): 40250 ships them size 0, no flags; now size 1,
|
||||
# ITEM_STACKABLE | ITEM_LOG (4 | 8192), ANTI_SELL (256).
|
||||
38001 1 8196 256
|
||||
38002 1 8196 256
|
||||
|
@@ -0,0 +1,45 @@
|
||||
# AVA2 批量开箱适配进度(v1)
|
||||
|
||||
2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考(`ENABLE_MULTI_CHEST`:`uiinventory.py` 的 Ctrl + 右键、`uichestdropinfo.py`、服务端 `do_chest_drop` / `CHARACTER::OpenChest`)。服务端规则和协议见 `metin2-server/docs/FEATURE-CHEST.md`。
|
||||
|
||||
## 与 AVA2 的差异
|
||||
|
||||
| 项目 | AVA2 | 本实现 |
|
||||
| --- | --- | --- |
|
||||
| 触发 | Ctrl + 右键打开带滑块的窗口;Ctrl + Z + 右键直接开整叠 | Ctrl + 右键打开数量窗口(1 / 10 / 50 / 全部),点“开启”;没有直接开整叠的组合键 |
|
||||
| 命令 | `/chest_drop WINDOW CELL COUNT` | `/mt_chest 1 open CELL COUNT`,不新增包头 |
|
||||
| 开箱过程 | 重写的 `GiveItemFromSpecialItemGroupNew` / `AutoGiveItemChest`,单次最多 1000 | 每个礼盒都调用原 `UseItem` 礼盒路径,检查、奖励组抽取、金币/经验、日志、扣除都不变;单次最多 200 |
|
||||
| 范围 | 一叠 | 同一种礼盒,先开选中的格子,再开背包里其他格子(203 上多数礼盒不叠加) |
|
||||
| 背包满 | 由 AutoGiveItemChest 处理 | 每开一个前要求至少 3 格空位,不足就停止,奖励不会掉在地上 |
|
||||
| 不允许批量 | — | 奖励组含刷怪、怪物组、减速、中毒、吸血的礼盒,以及非 ITEM_GIFTBOX 的物品(例如带任务脚本的宝箱) |
|
||||
|
||||
## 客户端
|
||||
|
||||
- 模块 `mt_chest`(`src/platform/ScriptLib/ChestScript.inc`),在 `PythonBoot.cpp` 中安装。替换了 `uiInventory.InventoryWindow.UseItemSlot`:按住 Ctrl 右键 ITEM_GIFTBOX 时打开窗口,其他情况调用原方法。原方法本身不使用 Ctrl(联机时检查过它的 `co_names`)。
|
||||
- `PythonItemModule.cpp` 新导出 `item.ITEM_TYPE_GIFTBOX`。
|
||||
- 窗口显示礼盒名称、背包内总数、数量按钮(`【】` 表示已选;“全部”最多 200)、开启按钮和上次结果。选中格子里的礼盒被移走时,自动改用背包里同一种礼盒的其他格子。
|
||||
- 等待回复时,以及两次发送间隔不足 0.5 秒时,“开启”按钮不可用。
|
||||
- 结果:聊天框一行“开启 N 个,金币 +N(停止原因)”,之后每行最多 5 种获得的物品(名称 ×数量)。单个礼盒的提示由服务端暂存,不再逐条刷屏;被原路径拒绝时转发一次拒绝提示。
|
||||
- 3 秒没有回复:在聊天框提示一次“服务器尚未启用批量开启”,之后 Ctrl + 右键也走原来的单个使用;再收到回复会自动恢复。
|
||||
- 经验不在汇总里(服务端只汇报背包物品和金币变化)。
|
||||
|
||||
## 证据
|
||||
|
||||
- `tests/wiki/test_chest.py`(CTest:`port.chest`)共 6 项通过,覆盖:
|
||||
- 发送一次、等待回复、获得行累积到 done、数量上限 200、负的金币差、发送间隔;
|
||||
- error 解析,畸形行和其他前缀;
|
||||
- 超时后标记不支持,迟到的回复会恢复;
|
||||
- 聊天汇总行(停止原因、金币、每行 5 种);
|
||||
- 钩子:不按 Ctrl 和非礼盒走原方法,Ctrl + 右键礼盒打开窗口,“全部”,选中格子消失后改用其他格子,命令拦截,超时后回退,窗口关闭;
|
||||
- 与服务端 `port_chest.h` 的常量、错误码和停止原因一致。
|
||||
- `tests/wiki` 全部 57 项通过(1 项跳过)。
|
||||
- 2026-10-15 在 203 channel5 测试核心(13040,独立二进制)用 macOS 客户端以 [SA]Admin 联机验收,礼盒用 `/item` 生成:
|
||||
- 50037(可叠加,30 个)开 10 个:`done 50037 10 10 done 0`,获得 6 种物品,背包差值与 `MTCH1 got` 完全一致;
|
||||
- 50070(不叠加,4 个分在 4 格)开全部:`done 50070 4 4 done 100000`,跨格子全部开启,金币 +100000 与余额变化一致;
|
||||
- 71036(奖励组刷怪):`error kind`,没有扣除;
|
||||
- 连发两条命令:第二条 `error rate`;
|
||||
- 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种;
|
||||
- 不按 Ctrl 右键:原单个使用,礼盒正常扣除;
|
||||
- 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。
|
||||
- 2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1(13000)用同一场景复验通过(50037 开 10 个 / 剩余 20 个、50070 跨 4 格、71036 拒绝、限速、普通右键不变)。
|
||||
- 未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。
|
||||
@@ -125,3 +125,6 @@
|
||||
|
||||
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 条不变,重新登录后仍在);2026-10-15 16:08 部署到 203 正式环境(服务端 dc71593),CH1 复验通过;未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。
|
||||
|
||||
2026-10-15 批量开箱(阶段 3 的交互子批,参考 AVA2 ENABLE_MULTI_CHEST /chest_drop):服务端 mt_chest 对同一种礼盒(ITEM_GIFTBOX)逐个调用原 UseItem 开箱路径,可跨格子(203 上多数礼盒不叠加),单次最多 200 个,背包少于 3 格空位即停止;奖励组含刷怪/减速/中毒/吸血的礼盒不允许批量。客户端 Ctrl + 右键礼盒打开数量窗口(1/10/50/全部),结果汇总到聊天框。已在 203 channel5 测试核心联机验收;2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1 复验通过;未做 Android 真机测试和触屏入口。见 AVA2-CHEST.md 和服务端 FEATURE-CHEST.md。
|
||||
|
||||
+20
-3
@@ -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 后半):需要单独的存储设计。
|
||||
- 属性锁定已完成(见上),2026-10-15 16:08 已部署到 203 正式环境(服务端 dc71593),CH1 联机验收通过。
|
||||
|
||||
@@ -350,11 +350,15 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
|
||||
limits = names_dir / "item_limits_server.tsv"
|
||||
if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
|
||||
raise ValueError("stone overlay requires the item limits table argument")
|
||||
if (names_dir / "item_shapes_server.tsv").is_file() and not (names_dir / "item_stones_server.tsv").is_file():
|
||||
raise ValueError("item shapes require the stone overlay argument")
|
||||
result = subprocess.run(
|
||||
[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 []),
|
||||
# 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,
|
||||
)
|
||||
report["proto"] = {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// PORT: AVA2-style batch chest opening (ENABLE_MULTI_CHEST, Ctrl + right click on a gift box), driven by the
|
||||
// server's /mt_chest command. Every box still goes through the original gift-box use path on the server; this
|
||||
// window only picks the box and the count and shows the summed gains. Without Ctrl, or against a server without
|
||||
// the command, the original single use runs. Protocol: metin2-server docs/FEATURE-CHEST.md.
|
||||
static const char c_szChestScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import chat
|
||||
import grp
|
||||
import item
|
||||
import net
|
||||
import player
|
||||
import ui
|
||||
import uiInventory
|
||||
import game
|
||||
from mt_biolog import Action, Text, L
|
||||
from mt_wiki import B
|
||||
|
||||
g_window=None
|
||||
MAX=200
|
||||
COUNTS=(1,10,50)
|
||||
SEND_GAP=.5
|
||||
TIMEOUT=3.
|
||||
GIFTBOX=getattr(item,'ITEM_TYPE_GIFTBOX',23)
|
||||
CHAT_ITEMS=5
|
||||
ERRORS={'rate':'操作过快','invalid':'参数无效','busy':'当前状态不能开启宝箱','item':'宝箱已不在原位置',
|
||||
'kind':'这种宝箱不能批量开启,请单个开启','full':'背包空位不足,至少保留 3 格'}
|
||||
REASONS={'done':'','empty':'宝箱已全部开启','full':'背包空位不足,已停止','busy':'状态变化,已停止','rejected':'宝箱无法开启,已停止'}
|
||||
|
||||
|
||||
def Number(text,low=0,high=4294967295):
|
||||
sign=-1 if text[:1]=='-' else 1
|
||||
digits=text[1:] if sign<0 else text
|
||||
if not digits.isdigit() or len(digits)>12:raise ValueError(text)
|
||||
value=sign*int(digits)
|
||||
if not low<=value<=high:raise ValueError(text)
|
||||
return value
|
||||
|
||||
|
||||
class Opener(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.gains=[];self.result=None;self.error=None
|
||||
|
||||
def Busy(self):return self.waiting is not None or self.now()<self.nextSend
|
||||
|
||||
def Open(self,cell,count):
|
||||
self.Tick()
|
||||
if self.unsupported or self.Busy():return False
|
||||
now=self.now();count=max(1,min(MAX,int(count)))
|
||||
self.send('/mt_chest 1 open %d %d'%(cell,count))
|
||||
self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP;self.gains=[];self.error=None
|
||||
return True
|
||||
|
||||
def Tick(self):
|
||||
if self.waiting is not None and self.now()>=self.waiting:
|
||||
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
|
||||
|
||||
def Notify(self,kind):
|
||||
if self.listener:self.listener(kind)
|
||||
|
||||
def Receive(self,line):
|
||||
if not line.startswith('MTCH1 '):return False
|
||||
words=line.split()
|
||||
try:
|
||||
if words[1]=='got' and len(words)==4:
|
||||
self.gains.append((Number(words[2]),Number(words[3],1)));return True
|
||||
if words[1]=='done' and len(words)==7 and words[5] in REASONS:
|
||||
vnum,asked,opened=[Number(w,0,100000000) for w in words[2:5]]
|
||||
self.result=dict(vnum=vnum,asked=asked,opened=opened,reason=words[5],
|
||||
gold=Number(words[6],-2**62,2**62),gains=self.gains)
|
||||
self.gains=[];self.error=None;kind='done'
|
||||
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
|
||||
else:return True
|
||||
except ValueError:return True
|
||||
self.waiting=None;self.unsupported=False;self.Notify(kind)
|
||||
return True
|
||||
|
||||
|
||||
def ItemName(vnum):
|
||||
item.SelectItem(vnum);return item.GetItemName()
|
||||
|
||||
|
||||
def Batchable(vnum):
|
||||
if not vnum:return False
|
||||
item.SelectItem(vnum);return item.GetItemType()==GIFTBOX
|
||||
|
||||
|
||||
def Summary(result):
|
||||
# Chat lines for one finished batch: the count line, then the gains a few items per line.
|
||||
lines=['批量开启 %s:开启 %d 个'%(ItemName(result['vnum']),result['opened'])+
|
||||
(',金币 %+d'%result['gold'] if result['gold'] else '')+
|
||||
('(%s)'%REASONS[result['reason']] if REASONS[result['reason']] else '')]
|
||||
gains=['%s ×%d'%(ItemName(vnum),count) for vnum,count in result['gains']]
|
||||
for i in range(0,len(gains),CHAT_ITEMS):lines.append('获得:'+','.join(gains[i:i+CHAT_ITEMS]))
|
||||
return lines
|
||||
|
||||
|
||||
class ChestWindow(ui.Window):
|
||||
def __init__(self,opener):
|
||||
ui.Window.__init__(self);self.SetWindowName('ChestWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.opener=opener;self.cell=-1;self.vnum=0;self.count=1;self.SetSize(332,286)
|
||||
self.heading=Text(self,18,18,'批量开启宝箱',0xffffd68b)
|
||||
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
|
||||
self.name=Text(self,18,58,'',0xffe8dec8)
|
||||
self.owned=Text(self,18,82,'',0xffc3b7a1)
|
||||
self.countButtons=[Action(self,18+i*76,108,68,'',lambda count=count:self.SetCount(count)) for i,count in enumerate(COUNTS)]
|
||||
self.allButton=Action(self,246,108,68,'全部',lambda:self.SetCount(MAX))
|
||||
self.openButton=Action(self,18,170,296,'',self.OpenBoxes)
|
||||
self.status=Text(self,18,234,'',0xffc3b7a1)
|
||||
self.hint=Text(self,18,258,'Ctrl + 右键宝箱打开本窗口,单次最多 %d 个'%MAX,0xff8f8676)
|
||||
self.Hide()
|
||||
|
||||
def Buttons(self):return [self.closeButton,self.allButton,self.openButton]+self.countButtons
|
||||
|
||||
def Select(self,cell,vnum):
|
||||
if self.vnum!=vnum:self.count=1;self.opener.result=None;self.opener.error=None
|
||||
self.cell=cell;self.vnum=vnum;self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
|
||||
|
||||
def Owned(self):return player.GetItemCountByVnum(self.vnum) if self.vnum else 0
|
||||
|
||||
def Amount(self):return max(1,min(self.count,self.Owned(),MAX))
|
||||
|
||||
def SetCount(self,count):self.count=count;self.Refresh()
|
||||
|
||||
def Cell(self):
|
||||
# The chosen cell while it still holds the box, otherwise any bag cell that does (the server does the same).
|
||||
if player.GetItemIndex(self.cell)==self.vnum:return self.cell
|
||||
for cell in range(player.INVENTORY_PAGE_SIZE*player.INVENTORY_PAGE_COUNT):
|
||||
if player.GetItemIndex(cell)==self.vnum:return cell
|
||||
return -1
|
||||
|
||||
def OpenBoxes(self):
|
||||
cell=self.Cell()
|
||||
if cell<0:self.Refresh();return
|
||||
if self.opener.Open(cell,self.Amount()):self.cell=cell
|
||||
self.Refresh()
|
||||
|
||||
def Refresh(self):
|
||||
owned=self.Owned()
|
||||
self.name.SetText(L(B(ItemName(self.vnum)) if self.vnum else ''))
|
||||
self.owned.SetText(L('背包内共 %d 个'%owned))
|
||||
for button,count in zip(self.countButtons,COUNTS):
|
||||
button.SetText(L('【%d】'%count if self.count==count else '%d'%count));button.SetEnabled(owned>0)
|
||||
self.allButton.SetText(L('【全部】' if self.count==MAX else '全部'));self.allButton.SetEnabled(owned>0)
|
||||
self.openButton.SetText(L('开启 %d 个'%self.Amount() if owned else '没有宝箱'))
|
||||
self.openButton.SetEnabled(owned>0 and not self.opener.Busy() and not self.opener.unsupported)
|
||||
self.status.SetText(L(self.StatusText()))
|
||||
|
||||
def StatusText(self):
|
||||
o=self.opener
|
||||
if o.unsupported:return '!服务器尚未启用批量开启'
|
||||
if o.waiting is not None:return '正在开启……'
|
||||
if o.error:return '!'+ERRORS[o.error]
|
||||
r=o.result
|
||||
if r and r['vnum']==self.vnum:
|
||||
text='上次:开启 %d/%d 个,获得 %d 种物品'%(r['opened'],min(r['asked'],r['opened']+self.Owned()),len(r['gains']))
|
||||
return text+(','+REASONS[r['reason']] if REASONS[r['reason']] else '')
|
||||
return '获得的物品会汇总显示在聊天框'
|
||||
|
||||
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 OnUpdate(self):
|
||||
busy=self.opener.Busy();self.opener.Tick()
|
||||
if busy and not self.opener.Busy():self.Refresh() # the send gap ran out, or the request timed out
|
||||
|
||||
def OnPressEscapeKey(self):self.Hide();return True
|
||||
|
||||
def Destroy(self):
|
||||
self.Hide()
|
||||
for b in self.Buttons():b.event=None
|
||||
|
||||
|
||||
def OnOpener(kind):
|
||||
if kind=='unsupported':chat.AppendChat(chat.CHAT_TYPE_INFO,L('服务器尚未启用批量开启,请单个开启宝箱'))
|
||||
elif kind=='done':
|
||||
for line in Summary(g_opener.result):chat.AppendChat(chat.CHAT_TYPE_INFO,L(line))
|
||||
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[g_opener.error]))
|
||||
if g_window:g_window.Refresh()
|
||||
|
||||
|
||||
g_opener=Opener(net.SendChatPacket,app.GetTime,OnOpener)
|
||||
|
||||
|
||||
def CtrlPressed():return app.IsPressed(app.DIK_LCONTROL) or app.IsPressed(app.DIK_RCONTROL)
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
_command=game.GameWindow.BINARY_ServerCommand_Run
|
||||
_use=uiInventory.InventoryWindow.UseItemSlot
|
||||
|
||||
def Open(self):
|
||||
global g_window
|
||||
_open(self);g_opener.waiting=None;g_window=ChestWindow(g_opener)
|
||||
|
||||
def Close(self):
|
||||
global g_window
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
_close(self)
|
||||
|
||||
def Command(self,line):
|
||||
if g_opener.Receive(line):return 1
|
||||
return _command(self,line)
|
||||
|
||||
def UseItemSlot(self,slot):
|
||||
if g_window and not g_opener.unsupported and CtrlPressed():
|
||||
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot)
|
||||
vnum=player.GetItemIndex(cell)
|
||||
if Batchable(vnum):g_window.Select(cell,vnum);return
|
||||
return _use(self,slot)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
game.GameWindow.BINARY_ServerCommand_Run=Command
|
||||
uiInventory.InventoryWindow.UseItemSlot=UseItemSlot
|
||||
)PY";
|
||||
@@ -1001,9 +1001,11 @@ namespace PythonBoot
|
||||
#include "WikiEntryScript.inc"
|
||||
#include "SwitchbotScript.inc"
|
||||
#include "PickupScript.inc"
|
||||
#include "ChestScript.inc"
|
||||
#include "DisplayScript.inc"
|
||||
#include "ChannelScript.inc"
|
||||
#include "BossScript.inc"
|
||||
#include "VipScript.inc"
|
||||
|
||||
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
||||
{
|
||||
@@ -1079,9 +1081,11 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
||||
install_script_module("mt_wiki_entries", c_szWikiEntryScript);
|
||||
install_script_module("mt_switchbot", c_szSwitchbotScript);
|
||||
install_script_module("mt_pickup", c_szPickupScript);
|
||||
install_script_module("mt_chest", c_szChestScript);
|
||||
install_script_module("mt_display", c_szDisplayScript);
|
||||
install_script_module("mt_channel", c_szChannelScript);
|
||||
install_script_module("mt_boss", c_szBossScript);
|
||||
install_script_module("mt_vip", c_szVipScript);
|
||||
if (g_touch_input)
|
||||
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// PORT: VIP panel. The server's VIP cards (38001 7 days, 38002 30 days, sold by 百店老板娘) extend the seven
|
||||
// original premium services of the account; this window shows what is left of each, asked with /mt_vip, and
|
||||
// reports card use in the chat. The premium icons (affects 500..506) are the original ones.
|
||||
// Protocol: metin2-server docs/FEATURE-VIP.md.
|
||||
static const char c_szVipScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import chat
|
||||
import grp
|
||||
import net
|
||||
import ui
|
||||
import game
|
||||
import weakref
|
||||
from mt_biolog import Action, Text, L
|
||||
|
||||
g_window=None
|
||||
g_entry=None
|
||||
SEND_GAP=1.
|
||||
TIMEOUT=3.
|
||||
MAX_DAYS=400
|
||||
CARDS={38001:7,38002:30}
|
||||
# PREMIUM_EXP .. PREMIUM_GOLD, the order of MTVIP1 times.
|
||||
SERVICES=('经验加成','物品掉落翻倍','仓库扩展到 3 页','金币直接进背包','钓鱼成功率提升','结婚亲密度加快','金币掉落翻倍')
|
||||
ERRORS={'rate':'操作过快,请稍后再试','invalid':'参数无效','full':'VIP 剩余时间不能超过 %d 天'%MAX_DAYS}
|
||||
|
||||
|
||||
def Number(text,low=0,high=2**31-1):
|
||||
if not text.isdigit() or len(text)>10:raise ValueError(text)
|
||||
value=int(text)
|
||||
if not low<=value<=high:raise ValueError(text)
|
||||
return value
|
||||
|
||||
|
||||
def Remaining(seconds):
|
||||
seconds=int(seconds)
|
||||
if seconds<=0:return '未开通'
|
||||
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600);minutes=rest//60
|
||||
if days:return '剩余 %d 天 %d 小时'%(days,hours)
|
||||
if hours:return '剩余 %d 小时 %d 分'%(hours,minutes)
|
||||
return '剩余 %d 分'%max(1,minutes)
|
||||
|
||||
|
||||
class Status(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.times=None;self.at=0;self.error=None;self.added=None
|
||||
|
||||
def Query(self):
|
||||
self.Tick()
|
||||
now=self.now()
|
||||
if self.waiting is not None or now<self.nextSend:return False
|
||||
self.send('/mt_vip 1 status');self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP
|
||||
return True
|
||||
|
||||
def Tick(self):
|
||||
if self.waiting is not None and self.now()>=self.waiting:
|
||||
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
|
||||
|
||||
def Left(self,index):
|
||||
if self.times is None:return 0
|
||||
return max(0,self.times[index]-(self.now()-self.at))
|
||||
|
||||
def Notify(self,kind):
|
||||
if self.listener:self.listener(kind)
|
||||
|
||||
def Receive(self,line):
|
||||
if not line.startswith('MTVIP1 '):return False
|
||||
words=line.split()
|
||||
try:
|
||||
if words[1]=='times' and len(words)==2+len(SERVICES):
|
||||
self.times=[Number(w) for w in words[2:]];self.at=self.now();self.error=None;kind='times'
|
||||
elif words[1]=='added' and len(words)==4:
|
||||
self.added=(Number(words[2]),Number(words[3],1,MAX_DAYS));kind='added'
|
||||
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
|
||||
else:return True
|
||||
except ValueError:return True
|
||||
if kind!='added':self.waiting=None
|
||||
self.unsupported=False;self.Notify(kind)
|
||||
return True
|
||||
|
||||
|
||||
class VipWindow(ui.Window):
|
||||
def __init__(self,status):
|
||||
ui.Window.__init__(self);self.SetWindowName('VipWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.status=status;self.SetSize(332,118+len(SERVICES)*24)
|
||||
self.heading=Text(self,18,18,'VIP 会员',0xffffd68b)
|
||||
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
|
||||
self.names=[Text(self,18,64+i*24,name,0xffe8dec8) for i,name in enumerate(SERVICES)]
|
||||
self.values=[Text(self,190,64+i*24,'',0xffc3b7a1) for i in range(len(SERVICES))]
|
||||
self.note=Text(self,18,self.GetHeight()-44,'',0xffc3b7a1)
|
||||
self.hint=Text(self,18,self.GetHeight()-22,'VIP 卡在各村百店老板娘处购买,使用后全部特权延长',0xff8f8676)
|
||||
self.second=-1
|
||||
self.Hide()
|
||||
|
||||
def Open(self):
|
||||
self.status.Query();self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
|
||||
|
||||
def Toggle(self):
|
||||
if self.IsShow():self.Hide()
|
||||
else:self.Open()
|
||||
|
||||
def Refresh(self):
|
||||
s=self.status
|
||||
for i,value in enumerate(self.values):
|
||||
left=s.Left(i)
|
||||
value.SetText(L(Remaining(left) if s.times is not None else '……'))
|
||||
value.SetPackedFontColor(0xff9fe08b if left>0 else 0xff8f8676)
|
||||
if s.unsupported:note='!服务器尚未启用 VIP 卡'
|
||||
elif s.error:note='!'+ERRORS[s.error]
|
||||
elif s.times is None:note='正在查询……'
|
||||
elif any(s.Left(i)>0 for i in range(len(SERVICES))):note='VIP 生效中'
|
||||
else:note='尚未开通 VIP'
|
||||
self.note.SetText(L(note))
|
||||
|
||||
def OnUpdate(self):
|
||||
self.status.Tick()
|
||||
second=int(app.GetTime())
|
||||
if second!=self.second:self.second=second;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.closeButton.event=None
|
||||
|
||||
|
||||
class Entry(Action):
|
||||
def __init__(self,window):
|
||||
Action.__init__(self,None,0,0,64,'VIP',Toggle)
|
||||
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('VipEntry');self.OnUpdate()
|
||||
|
||||
def OnUpdate(self):
|
||||
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+408)
|
||||
|
||||
|
||||
def OnStatus(kind):
|
||||
s=g_status
|
||||
if kind=='added':
|
||||
vnum,days=s.added
|
||||
chat.AppendChat(chat.CHAT_TYPE_INFO,L('VIP 会员已延长 %d 天,全部特权立即生效'%days))
|
||||
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
|
||||
if g_window:g_window.Refresh()
|
||||
|
||||
|
||||
g_status=Status(net.SendChatPacket,app.GetTime,OnStatus)
|
||||
|
||||
|
||||
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_status.waiting=None;g_status.times=None;g_window=VipWindow(g_status);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 g_status.Receive(line):return 1
|
||||
return _command(self,line)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
game.GameWindow.BINARY_ServerCommand_Run=Command
|
||||
)PY";
|
||||
@@ -326,6 +326,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_channel.py)
|
||||
add_test(NAME port.boss
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
|
||||
add_test(NAME port.chest
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_chest.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)
|
||||
|
||||
@@ -618,6 +618,7 @@ void initItem()
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_POLYMORPH", CItemData::ITEM_TYPE_POLYMORPH);
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_TREASURE_BOX", CItemData::ITEM_TYPE_TREASURE_BOX);
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_TREASURE_KEY", CItemData::ITEM_TYPE_TREASURE_KEY);
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_GIFTBOX", CItemData::ITEM_TYPE_GIFTBOX); // PORT: batch chest opening
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_PICK", CItemData::ITEM_TYPE_PICK);
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_BLEND", CItemData::ITEM_TYPE_BLEND);
|
||||
PyModule_AddIntConstant(poModule, "ITEM_TYPE_DS", CItemData::ITEM_TYPE_DS);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// Runtime loading remains the original 40250 path and binary format.
|
||||
// An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few
|
||||
// item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the
|
||||
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data.
|
||||
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. An optional shape table
|
||||
// (vnum, size, flags, anti flags) mirrors items the server gives a new use (tools/server/vip-card), so they
|
||||
// take a cell, stack and refuse NPC sale as the server does.
|
||||
#include "GameLib/StdAfx.h"
|
||||
#include "GameLib/ItemData.h"
|
||||
#include "UserInterface/StdAfx.h"
|
||||
@@ -25,6 +27,7 @@ namespace {
|
||||
using Item = CItemData::TItemTable;
|
||||
using Mob = CPythonNonPlayer::TMobTable;
|
||||
std::map<DWORD, std::array<long, 11>> stones;
|
||||
std::map<DWORD, std::array<DWORD, 3>> shapes;
|
||||
|
||||
constexpr std::array<DWORD, 4> item_key{{173217, 72619434, 408587239, 27973291}};
|
||||
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}};
|
||||
@@ -109,6 +112,12 @@ void apply_limits<Item>(Item& record, const std::map<DWORD, Limits>& limits, std
|
||||
record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4];
|
||||
for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i];
|
||||
}
|
||||
const auto shape = shapes.find(record.dwVnum);
|
||||
if (shape != shapes.end()) {
|
||||
record.bSize = static_cast<BYTE>(shape->second[0]);
|
||||
record.dwFlags = shape->second[1];
|
||||
record.dwAntiFlags = shape->second[2];
|
||||
}
|
||||
const auto found = limits.find(record.dwVnum);
|
||||
if (found == limits.end()) return;
|
||||
std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits));
|
||||
@@ -120,6 +129,8 @@ bool limits_field(std::size_t, DWORD, const std::map<DWORD, Limits>&) { return f
|
||||
|
||||
template <>
|
||||
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) {
|
||||
if (shapes.count(vnum) && (offset == offsetof(Item, bSize) ||
|
||||
(offset >= offsetof(Item, dwAntiFlags) && offset < offsetof(Item, dwFlags)+sizeof(Item::dwFlags)))) return true;
|
||||
if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) ||
|
||||
(offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) ||
|
||||
(offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) ||
|
||||
@@ -199,14 +210,25 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 7 && argc != 8 && argc != 9) {
|
||||
if (argc < 7 || argc > 10) {
|
||||
std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob"
|
||||
" [item_limits [item_stones]]\n";
|
||||
" [item_limits [item_stones [item_shapes]]]\n";
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
static CLZO lzo;
|
||||
if (argc == 9) {
|
||||
if (argc == 10) {
|
||||
std::ifstream stream(argv[9]);
|
||||
if (!stream) throw std::runtime_error("cannot read item shapes");
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::istringstream row(line); DWORD vnum; std::array<DWORD,3> values{};
|
||||
if (!(row >> vnum >> values[0] >> values[1] >> values[2]) || values[0] < 1 || values[0] > 3 ||
|
||||
!shapes.emplace(vnum, values).second) throw std::runtime_error("bad item shape row: " + line);
|
||||
}
|
||||
}
|
||||
if (argc >= 9) {
|
||||
std::ifstream stream(argv[8]);
|
||||
if (!stream) throw std::runtime_error("cannot read stone overlay");
|
||||
std::string line;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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 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.text=text;self.shown=True
|
||||
def SetEnabled(self,enabled):self.enabled=enabled
|
||||
|
||||
|
||||
NAMES={50100:'Box',27001:'Potion',110000:'Soul',30000:'Ore'}
|
||||
TYPES={50100:23,27001:3,30000:5}
|
||||
|
||||
|
||||
class ChestTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.time=1.;self.sent=[];self.chat=[];self.used=[];self.ctrl=False;self.bag={}
|
||||
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','item','net','player','ui','uiInventory','mt_biolog','mt_wiki')}
|
||||
app=mods['app'];app.GetTime=lambda:self.time;app.DIK_LCONTROL=29;app.DIK_RCONTROL=157
|
||||
app.IsPressed=lambda key:self.ctrl and key==29
|
||||
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
|
||||
mods['net'].SendChatPacket=self.sent.append
|
||||
selected=[0];it=mods['item']
|
||||
it.ITEM_TYPE_GIFTBOX=23;it.SelectItem=lambda v:selected.__setitem__(0,v)
|
||||
it.GetItemName=lambda:NAMES.get(selected[0],'?');it.GetItemType=lambda:TYPES.get(selected[0],0)
|
||||
pl=mods['player'];pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5
|
||||
pl.GetItemIndex=lambda c:self.bag.get(c,(0,0))[0]
|
||||
pl.GetItemCountByVnum=lambda v:sum(n for vnum,n in self.bag.values() if vnum==v)
|
||||
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
|
||||
BINARY_ServerCommand_Run=lambda s,line:7))
|
||||
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(UseItemSlot=lambda s,slot:self.used.append(slot),
|
||||
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+45))
|
||||
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
|
||||
mods['ui'].Window=Widget
|
||||
mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t
|
||||
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_chest'};exec(script('ChestScript.inc'),self.ns)
|
||||
self.opener=self.ns['Opener'](self.sent.append,lambda:self.time)
|
||||
|
||||
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 test_open_sends_once_and_collects_gains_until_done(self):
|
||||
self.assertTrue(self.opener.Open(50,7));self.assertFalse(self.opener.Open(50,7))
|
||||
self.assertEqual(self.sent,['/mt_chest 1 open 50 7'])
|
||||
self.time+=1;self.assertFalse(self.opener.Open(50,7)) # still waiting for the reply
|
||||
for line in ('MTCH1 got 27001 7','MTCH1 got 110000 2','MTCH1 done 50100 7 7 done 700'):
|
||||
self.assertTrue(self.opener.Receive(line))
|
||||
r=self.opener.result
|
||||
self.assertEqual((r['vnum'],r['asked'],r['opened'],r['reason'],r['gold']),(50100,7,7,'done',700))
|
||||
self.assertEqual(r['gains'],[(27001,7),(110000,2)]);self.assertEqual(self.opener.gains,[])
|
||||
self.assertTrue(self.opener.Open(50,500));self.assertEqual(self.sent[-1],'/mt_chest 1 open 50 200') # capped
|
||||
self.opener.Receive('MTCH1 done 50100 200 0 rejected -50');self.assertEqual(self.opener.result['gold'],-50)
|
||||
self.time+=.1;self.assertFalse(self.opener.Open(50,1)) # inside the send gap
|
||||
|
||||
def test_errors_malformed_and_foreign_lines(self):
|
||||
self.opener.Open(1,1);self.opener.Receive('MTCH1 error kind')
|
||||
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.waiting)
|
||||
for bad in ('MTCH1 done 1 1 1 done','MTCH1 done 1 1 1 boom 0','MTCH1 got x 1','MTCH1 got 1 0','MTCH1 error boom','MTCH1 done a 1 1 done 0'):
|
||||
self.assertTrue(self.opener.Receive(bad))
|
||||
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.result)
|
||||
self.assertFalse(self.opener.Receive('MTPU1 done 1 1 0'))
|
||||
|
||||
def test_timeout_marks_unsupported(self):
|
||||
events=[];self.opener.listener=events.append
|
||||
self.opener.Open(1,1);self.time+=3.1;self.assertFalse(self.opener.Open(1,1))
|
||||
self.assertTrue(self.opener.unsupported);self.assertEqual(events,['unsupported'])
|
||||
self.opener.Receive('MTCH1 done 50100 1 1 done 0');self.assertFalse(self.opener.unsupported) # a late reply re-enables
|
||||
|
||||
def test_summary_lines(self):
|
||||
lines=self.ns['Summary'](dict(vnum=50100,opened=12,reason='full',gold=1200,gains=[(27001,12)]+[(30000,1)]*5))
|
||||
self.assertEqual(lines[0],'批量开启 Box:开启 12 个,金币 +1200(背包空位不足,已停止)')
|
||||
self.assertEqual(lines[1],'获得:Potion ×12,Ore ×1,Ore ×1,Ore ×1,Ore ×1');self.assertEqual(lines[2],'获得:Ore ×1')
|
||||
self.assertEqual(self.ns['Summary'](dict(vnum=50100,opened=1,reason='done',gold=0,gains=[])),['批量开启 Box:开启 1 个'])
|
||||
|
||||
def test_ctrl_right_click_opens_window_and_hooks(self):
|
||||
game=sys.modules['game'];inv=sys.modules['uiInventory'].InventoryWindow();gw=game.GameWindow()
|
||||
self.bag={50:(50100,1),51:(50100,1),52:(27001,3),90:(50100,1)}
|
||||
game.GameWindow.Open(gw);w=self.ns['g_window']
|
||||
inv.UseItemSlot(5);self.assertEqual(self.used,[5]);self.assertFalse(w.IsShow()) # no Ctrl: original use
|
||||
self.ctrl=True;inv.UseItemSlot(7);self.assertEqual(self.used,[5,7]) # Ctrl on a potion: original use
|
||||
inv.UseItemSlot(5);self.assertEqual(self.used,[5,7]);self.assertTrue(w.IsShow())
|
||||
self.assertEqual((w.cell,w.vnum),(50,50100));self.assertIn('3',w.owned.text);self.assertEqual(w.openButton.text,'开启 1 个')
|
||||
w.allButton.event();self.assertEqual(w.openButton.text,'开启 3 个')
|
||||
del self.bag[50];w.openButton.event();self.assertEqual(self.sent,['/mt_chest 1 open 51 2']) # chosen cell gone: next box, 2 left
|
||||
self.assertFalse(w.openButton.enabled)
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 got 27001 2'),1)
|
||||
self.bag={52:(27001,6)}
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 done 50100 2 2 done 200'),1)
|
||||
self.assertEqual(self.chat,['批量开启 Box:开启 2 个,金币 +200','获得:Potion ×2'])
|
||||
self.assertIn('2/2',w.status.text);self.assertEqual(w.openButton.text,'没有宝箱')
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
|
||||
self.bag={60:(50100,1)};self.time+=1;w.openButton.event();self.time+=3.1;w.OnUpdate()
|
||||
self.assertTrue(self.ns['g_opener'].unsupported);self.assertIn('单个开启',self.chat[-1])
|
||||
inv.UseItemSlot(15);self.assertEqual(self.used,[5,7,15]) # unsupported server: Ctrl falls back to normal use
|
||||
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window'])
|
||||
|
||||
def test_server_constants_match(self):
|
||||
server=ROOT.parent/'metin2-server/src/server/game/src/port_chest.h'
|
||||
if not server.exists():self.skipTest('server checkout not installed')
|
||||
text=server.read_text(encoding='latin1')
|
||||
self.assertIn('kPortChestMax=200',text);self.assertEqual(self.ns['MAX'],200)
|
||||
self.assertIn('kPortChestGapMs=500',text);self.assertEqual(self.ns['SEND_GAP'],.5)
|
||||
self.assertTrue(set(re.findall(r'return "([a-z]+)";',text))<=set(self.ns['ERRORS']))
|
||||
self.assertTrue(set(re.findall(r'reason="([a-z]+)"',text))<=set(self.ns['REASONS']))
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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);self.color=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 SetPackedFontColor(self,color):self.color=color
|
||||
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
|
||||
|
||||
|
||||
class Minimap(Widget):
|
||||
def GetGlobalPosition(self):return (700,10)
|
||||
|
||||
|
||||
class VipTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.time=100.;self.sent=[];self.chat=[]
|
||||
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','net','ui','mt_biolog')}
|
||||
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
|
||||
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,c)
|
||||
self.previous={n:sys.modules.get(n) for n in mods}
|
||||
sys.modules.update(mods)
|
||||
self.ns={'__name__':'mt_vip'};exec(script('VipScript.inc'),self.ns)
|
||||
self.status=self.ns['Status'](self.sent.append,lambda:self.time)
|
||||
|
||||
def text(self,t,c):
|
||||
w=Widget();w.text=t;w.color=c;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 test_query_parses_times_and_counts_down(self):
|
||||
self.assertTrue(self.status.Query());self.assertFalse(self.status.Query())
|
||||
self.assertEqual(self.sent,['/mt_vip 1 status'])
|
||||
self.assertTrue(self.status.Receive('MTVIP1 times 604800 604800 0 604800 604800 604800 90'))
|
||||
self.assertIsNone(self.status.waiting);self.assertEqual(self.status.Left(2),0)
|
||||
self.time+=100;self.assertEqual(self.status.Left(0),604700);self.assertEqual(self.status.Left(6),0)
|
||||
self.time+=.5;self.assertTrue(self.status.Query()) # past the send gap
|
||||
|
||||
def test_added_error_malformed_and_foreign(self):
|
||||
events=[];self.status.listener=events.append
|
||||
self.status.Receive('MTVIP1 added 38002 30');self.assertEqual(self.status.added,(38002,30))
|
||||
self.status.Receive('MTVIP1 error full');self.assertEqual(self.status.error,'full')
|
||||
for bad in ('MTVIP1 times 1 2 3','MTVIP1 times 1 2 3 4 5 6 x','MTVIP1 times 1 2 3 4 5 6 -7','MTVIP1 added 38001 0',
|
||||
'MTVIP1 added 38001 401','MTVIP1 error boom','MTVIP1 boom'):
|
||||
self.assertTrue(self.status.Receive(bad))
|
||||
self.assertEqual(events,['added','error']);self.assertIsNone(self.status.times)
|
||||
self.assertFalse(self.status.Receive('MTCH1 error kind'))
|
||||
|
||||
def test_timeout_marks_unsupported(self):
|
||||
events=[];self.status.listener=events.append
|
||||
self.status.Query();self.time+=3.1;self.status.Tick()
|
||||
self.assertTrue(self.status.unsupported);self.assertEqual(events,['unsupported'])
|
||||
self.status.Receive('MTVIP1 times 0 0 0 0 0 0 0');self.assertFalse(self.status.unsupported)
|
||||
|
||||
def test_remaining_text(self):
|
||||
R=self.ns['Remaining']
|
||||
self.assertEqual(R(0),'未开通');self.assertEqual(R(-5),'未开通')
|
||||
self.assertEqual(R(7*86400+3*3600+59),'剩余 7 天 3 小时')
|
||||
self.assertEqual(R(5*3600+120),'剩余 5 小时 2 分');self.assertEqual(R(30),'剩余 1 分')
|
||||
|
||||
def test_window_entry_and_chat(self):
|
||||
game=sys.modules['game'];gw=game.GameWindow()
|
||||
gw.interface=types.SimpleNamespace(wndMiniMap=Minimap())
|
||||
game.GameWindow.Open(gw);w=self.ns['g_window'];entry=self.ns['g_entry']
|
||||
self.assertEqual(entry.pos,(632,418))
|
||||
entry.event();self.assertTrue(w.IsShow());self.assertEqual(self.sent,['/mt_vip 1 status'])
|
||||
self.assertEqual(w.note.text,'正在查询……');self.assertEqual(w.values[0].text,'……')
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 0 0 0 0 0 0 0'),1)
|
||||
self.assertEqual(w.note.text,'尚未开通 VIP');self.assertEqual(w.values[3].text,'未开通')
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 added 38001 7'),1)
|
||||
self.assertEqual(self.chat,['VIP 会员已延长 7 天,全部特权立即生效'])
|
||||
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 604800 604800 604800 604800 604800 604800 604800')
|
||||
self.assertEqual(w.values[6].text,'剩余 7 天 0 小时');self.assertEqual(w.note.text,'VIP 生效中')
|
||||
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 error full')
|
||||
self.assertEqual(self.chat[-1],'VIP 剩余时间不能超过 400 天');self.assertIn('400',w.note.text)
|
||||
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
|
||||
entry.event();self.assertFalse(w.IsShow())
|
||||
self.time+=2;entry.event();self.time+=3.1;w.OnUpdate()
|
||||
self.assertEqual(w.note.text,'!服务器尚未启用 VIP 卡')
|
||||
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry'])
|
||||
|
||||
def test_server_constants_match(self):
|
||||
server=ROOT.parent/'metin2-server/src/server/game/src/port_premium.h'
|
||||
if not server.exists():self.skipTest('server checkout not installed')
|
||||
text=server.read_text(encoding='latin1')
|
||||
self.assertIn('kPortVipMaxDays=400',text);self.assertEqual(self.ns['MAX_DAYS'],400)
|
||||
self.assertIn('kPortVipGapMs=1000',text);self.assertEqual(self.ns['SEND_GAP'],1.)
|
||||
cards={int(v):int(d) for v,d in re.findall(r'\{(\d+),(\d+)\}',text.split('kPortVipCards',1)[1].split(';',1)[0])}
|
||||
self.assertEqual(cards,self.ns['CARDS'])
|
||||
self.assertEqual(text.count('_expire"'),len(self.ns['SERVICES']))
|
||||
self.assertTrue(set(re.findall(r'MTVIP1 error ([a-z]+)',text))<=set(self.ns['ERRORS']))
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -46,6 +46,7 @@
|
||||
| +6 镶嵌石(28700~28713,全部 14 种,宝石页) | 各 1 |
|
||||
| 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 |
|
||||
| 追加物品属性秘籍、龙神的祝福书 | 各 100 万 |
|
||||
| VIP 会员卡 7 天 / 30 天(7 项会员特权全部延长,见 `../vip-card`) | 500 万 / 1800 万 |
|
||||
|
||||
## 坐骑(mount)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
71032 1 1000000 龙神的祝福书
|
||||
70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp)
|
||||
50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set)
|
||||
38001 1 5000000 VIP会员卡 7天,7项会员特权全部延长(../../vip-card)
|
||||
38002 1 18000000 VIP会员卡 30天(../../vip-card)
|
||||
71052 1 1 超级属性转换秘籍
|
||||
71051 1 1 超级属性追加秘籍
|
||||
[宝石]
|
||||
|
||||
|
@@ -106,6 +106,7 @@ data)
|
||||
sh "$HERE/ring-warp/install.sh"
|
||||
sh "$HERE/support-skills/install.sh"
|
||||
sh "$HERE/hunt-set/install.sh"
|
||||
sh "$HERE/vip-card/install.sh"
|
||||
sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/item-mall"
|
||||
sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/mount"
|
||||
start_server
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# VIP 会员卡(203 服务端)
|
||||
|
||||
服务端原有 7 种会员特权(`account.account` 的 `*_expire` 列,登录时读入,左上角图标 affect 500~506):
|
||||
|
||||
| 序号 | 特权 | 列 |
|
||||
|---|---|---|
|
||||
| 0 | 经验加成 | `silver_expire` |
|
||||
| 1 | 物品掉落翻倍 | `gold_expire` |
|
||||
| 2 | 仓库扩展到 3 页 | `safebox_expire` |
|
||||
| 3 | 金币直接进背包 | `autoloot_expire` |
|
||||
| 4 | 钓鱼成功率提升 | `fish_mind_expire` |
|
||||
| 5 | 结婚亲密度加快 | `marriage_fast_expire` |
|
||||
| 6 | 金币掉落翻倍 | `money_drop_rate_expire` |
|
||||
|
||||
40250 里没有游戏内开通的途径。VIP 卡用 40250 没用到的两个物品:
|
||||
|
||||
| 物品 | 名称 | 时长 | 百店老板娘「道具」页价格 |
|
||||
|---|---|---|---|
|
||||
| 38001 | VIP会员卡(7天) | 7 天 | 500 万金币 |
|
||||
| 38002 | VIP会员卡(30天) | 30 天 | 1800 万金币 |
|
||||
|
||||
价格在 `../npc-shops/item-mall/items.tsv`,改完重跑 npc-shops 的 install.sh 即可。
|
||||
|
||||
右键使用:7 项特权同时从「现在」和「当前到期时间」中较晚的那个往后延长,立即生效(图标、自动拾取、仓库页数)。
|
||||
剩余时间加上卡的时长超过 400 天时拒绝,卡不扣。两次使用至少间隔 1 秒。客户端小地图旁的「VIP」按钮打开面板,
|
||||
显示每项剩余时间。协议见 metin2-server `docs/FEATURE-VIP.md`。
|
||||
|
||||
## 经验加成 +100%
|
||||
|
||||
newcibn 的会员经验在 `char_battle.cpp` 里固定 ×1.2,之后再乘 `GetMobExpRate` 返回的事件标记
|
||||
`mob_exp_buyer`(默认 100)。`/e mob_exp_buyer 167` 后 1.2 × 1.67 ≈ 2.0,即 +100%。事件标记存在
|
||||
`player.quest`(pid 0),所有核心共享,部署时用 GM 角色设一次即可,重启后仍在。
|
||||
|
||||
## 文件
|
||||
|
||||
- `install.sh`:`item_proto.txt` 里 38001/38002 改为占 1 格、可堆叠、记日志、不能卖给 NPC(40250 原来是
|
||||
size 0,两张卡会叠在同一格、无法移动),服务端改名(`item_names.txt` + `item_proto.locale_name`,GB2312)。
|
||||
需要重启 db 和 game。备份在 `/root/backup-vip-card/<时间>/`。
|
||||
- 游戏端代码在 metin2-server(`port_premium.h`,分支 feature/vip-card)。
|
||||
|
||||
客户端:名称 `data/zh_40250/item_names_zh_overlay.txt`;占格和堆叠 `item_shapes_server.tsv`(由
|
||||
`locale_proto_patch` 写进客户端 item_proto,与服务端一致),由 `scripts/build_zh_locale.py` 打进 locale_zh。
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Installs the VIP cards (README.md): run on the server from this directory with a game built from
|
||||
# metin2-server feature/vip-card (port_premium.h), then restart db + game. 40250 ships 38001/38002 as unused
|
||||
# ITEM_NONE rows of size 0, which the inventory cannot hold (two of them share a cell, neither can move): this
|
||||
# gives them one cell, stacking, an item log and no NPC sale, and names them on the server (item_names.txt and
|
||||
# item_proto.locale_name; the client has its own names and the same size/flags, data/zh_40250).
|
||||
set -eu
|
||||
|
||||
SERVER=${SERVER:-/usr/metin2/server}
|
||||
PROTO="$SERVER/share/conf/item_proto.txt"
|
||||
NAMES="$SERVER/share/conf/item_names.txt"
|
||||
BACKUP=/root/backup-vip-card/$(date +%Y%m%d-%H%M%S)
|
||||
export LC_ALL=C
|
||||
|
||||
mkdir -p "$BACKUP"
|
||||
cp -p "$PROTO" "$NAMES" "$BACKUP/"
|
||||
mysqldump -uroot --no-create-info --replace player item_proto --where="vnum in (38001,38002)" > "$BACKUP/item_proto-rows.sql"
|
||||
echo "backup: $BACKUP"
|
||||
|
||||
# item_proto.txt: column 5 size, 6 anti flags, 7 flags.
|
||||
awk -F'\t' -v OFS='\t' '$1 == 38001 || $1 == 38002 { $5 = 1; $6 = "ANTI_SELL"; $7 = "ITEM_STACKABLE | LOG"; n++ } { print }
|
||||
END { if (n != 2) { print "item_proto.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
|
||||
"$PROTO" > "$BACKUP/item_proto.new"
|
||||
cat "$BACKUP/item_proto.new" > "$PROTO"
|
||||
|
||||
week=$(printf 'VIP会员卡(7天)' | iconv -f UTF-8 -t GB2312)
|
||||
month=$(printf 'VIP会员卡(30天)' | iconv -f UTF-8 -t GB2312)
|
||||
awk -F'\t' -v OFS='\t' -v week="$week" -v month="$month" '$1 == 38001 { $2 = week; n++ } $1 == 38002 { $2 = month; n++ } { print }
|
||||
END { if (n != 2) { print "item_names.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
|
||||
"$NAMES" > "$BACKUP/item_names.new"
|
||||
cat "$BACKUP/item_names.new" > "$NAMES"
|
||||
|
||||
# ITEM_STACKABLE | ITEM_LOG = 4 | 8192, ITEM_ANTIFLAG_SELL = 256.
|
||||
mysql -uroot player -e "UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$week' WHERE vnum = 38001;
|
||||
UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$month' WHERE vnum = 38002"
|
||||
echo "done; restart db and game"
|
||||
Reference in New Issue
Block a user