Add feature queries, dungeon information and event calendar UI
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# AVA2 副本与活动日历适配进度
|
||||
|
||||
2026-10-15。修改 metin2-client 和 metin2-server;AVA2 只读参考。
|
||||
|
||||
## 已实现
|
||||
|
||||
百科顶部增加“副本信息”“活动日历”入口,沿用触屏按钮与可移动窗口,缩放时重新排版。
|
||||
|
||||
副本首批接入当前服实际安装的 `dragon_lair_access` 和 `dragon_lair_weekly`。服务端直接调用已加载任务的只读 settings 函数,读取当前冷却、加入窗口、门票及动态需求数量;读取当前角色 quest flags 和全服 event flags。保持 Lua 栈和当前任务/角色上下文,不调用 CHARACTER::GetQuestFlag(其内部 GetPC 会切换任务上下文),不设置任务状态、不创建物品、不传送、不消耗门票。
|
||||
|
||||
龙穴按任务原规则显示共享场次:加入窗口为闭区间,申请新场次要求冷却严格小于零,因此到期秒仍在等待。个人周任务冷却从接取任务开始,与共享龙穴计时分别显示;当前收集阶段从原任务状态读取。界面提供入口说明和登录期间的单个到期提醒,提醒必须经过一次新的服务端快照确认;场次延期不会按旧到期时间误报。休眠或时间跳变使旧快照失效,重连/换角色销毁提醒。
|
||||
|
||||
活动列表按 UTC 展示排期、频道/阵营范围、当前状态及开始/结束提醒。只适配当前服已有的月光宝箱和圣诞袜掉落;同一份 `port_events.tsv` 同时驱动查询和原始 `CreateQuestDropItem` 分支的启用条件。原概率、怪物等级/种类限制、随机数调用、生成物品流程沿用原实现;重叠排期取布尔并集,不叠加概率。原管理员 event flags 仍可独立启用这些活动,日历展示的是计划时段。
|
||||
|
||||
调度按服务器当前 Unix 时间计算 `[start,end)`,没有开服补发/定时发奖,也不向 DB 写全局开关。服务重启后从文件恢复,所有共享 locale 的 core 在五秒内发现原子替换的新配置。删除/非法配置使计划效果关闭,并返回 schedule_unavailable;不伪造空日历。有效空文件表示明确无计划活动。
|
||||
|
||||
## 配置
|
||||
|
||||
服务端示例:`metin2-server/tools/calendar/port_events.tsv.example`。复制到 **LocaleService_GetBasePath() / port_events.tsv**;不是客户端资源或 quest/object。部署时必须让所有 core 读取同一个文件,先验证再用原子 rename 发布。未部署任何生产排期,本批不会自动开启活动。
|
||||
|
||||
格式与运维/验证说明见 `metin2-server/docs/FEATURE-DUNGEON-CALENDAR.md`。协议在既有 MTF1 上增量扩展 capability 16/32,旧服降级为未启用;未知能力位忽略,未知协议版本拒绝。所有页面带 request ID 和服务器时间;缺行/重复行/非法时间和范围不提交界面。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
- `tests/wiki/test_service.py`:新增副本完整性、日历范围和未来能力位兼容测试;与目录/提醒测试合计 22 项通过。
|
||||
- `tests/wiki/test_timers.py`:验证副本原始到期边界、延期、休眠后查询、日历频道范围、一次提醒及配置删除取消。
|
||||
- 服务端 `tests/features/test_calendar.py`:编译实际解析/调度/查询主体,验证区间边界、频道/阵营、重叠不叠加、重启、同秒同大小文件原子替换、非法配置关闭;查询与掉落开关一致。
|
||||
- 服务端 `tests/features/dungeon_lua_probe.cpp`:在 203 独立进程中使用实际 Lua 5.0.3 库和现网编译 quest state 文件验证 settings、动态门票、共享/个人时间、状态、上下文及栈保持;不连接游戏/数据库,不修改运行状态。
|
||||
- `build/wiki-source/dungeon-calendar-integration.log`:隔离加密登录、查询、分页/提醒开关,以及原有触控背包、装备、商店、仓库、战斗回归通过。
|
||||
- macOS `dungeon-calendar-mac-build.log`、Android `dungeon-calendar-android-build.log` 构建通过;Android 未进行真机验收。
|
||||
- `dungeon-info.png`、`event-calendar.png`(另有短屏 `event-calendar-small.png`)为实际原生宿主渲染,使用隔离协议 fixture,不代表生产活动安排。
|
||||
- FreeBSD 输出仅在 `/root/metin2-features-dev/bin/game`,没有替换 `/usr/metin2` 程序或重启服务。
|
||||
|
||||
没有承诺所有副本已迁移:其他副本、NPC 导航/传送、通关次数、最快时间与排名仍待接入。龙穴入口最终资格和门票检查仍由原 NPC 任务负责,查询就绪不等于保证入场。自动洗练/属性锁定、战令任务及领奖持久化、百科模型预览、批量开箱仍未实现,不能标记整体迁移完成。
|
||||
@@ -112,3 +112,7 @@
|
||||
每阶段交付:客户端提交、服务端提交(如需)、协议与字段文档、资料生成脚本、资源清单、自动测试、macOS 联机记录、Android 真机交互记录、部署/回退说明。
|
||||
完成条件:隔离环境通过后再安排 203 部署,实际任务闭环通过方可标记完成;编译、模拟包和静态比较不能替代联机验收。
|
||||
2026-10-15 进度:阶段 0 的资料/界面基础与阶段 1 的静态查询子批已实现并完成本机回归;详情见 AVA2-WIKI-IMPLEMENTATION.md。服务端握手/查询协议、模型预览、阶段 2 以后均未完成,未部署上述新功能到 203。
|
||||
|
||||
2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。
|
||||
|
||||
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# AVA2 迁移:掉落、宝箱和角色属性查询
|
||||
|
||||
日期:2026-10-15。阶段 0 查询基础、阶段 2/3 的只读查询、阶段 4 属性面板已实现;生产服联机验收尚未完成。
|
||||
|
||||
## 这批代码
|
||||
|
||||
- metin2-server:`ITEM_MANAGER::PortWikiDrops/PortWikiChest` 读取已加载的同一份数据;`/mt_features` 提供能力协商、怪物掉落、物品反查来源、宝箱和自身属性快照。
|
||||
- metin2-client:`mt_feature_service` 管理会话请求、能力、超时、分页与原子提交;百科的详情页接入远程数据和来源跳转,增加详细属性面板。
|
||||
- 怪物目标栏有“掉落”入口。触控背包单击显示固定提示,提示中的“百科 / 宝箱预览”可打开查询;开始拖动前隐藏固定提示,保留原穿脱、背包移动和出售流程。
|
||||
- 百科“详细属性”可查看当前服务器实际支持的属性,分类、筛选和翻页;面板可见时每三秒同步,没有数据时显示未知,不用零值代替失败。
|
||||
- 原始 AVA2/root、ava2-client、现有游戏物品和经济规则没有更改。本批查询没有开箱、洗练、奖励或 DB 写入。
|
||||
|
||||
## 数据和语义
|
||||
|
||||
怪物:通用掉落的角色等级范围、独立掉落组、击杀组候选、等级组、手套/增益资格组及怪物模板额外掉落。通用变身球按原规则过滤,其他组保持原生成分支的候选行为。返回权重/条件,不把经过等级、倍率和特殊事件修正的最终概率伪算成静态百分比。
|
||||
|
||||
仍未覆盖全部 `CreateQuestDropItem`、硬编码事件、矿石额外掉宝石和各地图特殊分支。UI 明确标示范围;零行只表示当前已覆盖范围没有配置,不能宣布没有任何获取途径。
|
||||
|
||||
宝箱:仅处理走 `GiveItemFromSpecialItemGroup` 的 GIFTBOX/TREASURE_BOX,以及 NORMAL/PCT 两种已确认模式。NORMAL 为按权重抽一项;PCT 为各项独立与 1..100 随机数比较,支持多项/无奖励。金币、经验、召唤怪物、减速、扣血、中毒和怪物组均单独标示,不当成物品 ID。钥匙、资格和消耗保留原校验;没有新增批量开箱。QUEST/SPECIAL 组与自定义开箱不伪装通用宝箱。
|
||||
|
||||
属性:服务端用当前编译的 POINT 常量访问实际属性,客户端仅使用稳定文字键;不复制 AVA2 面板的点号。HP/SP/等级用原角色 getter,其他用实际 point。研究怪物加成对 Boss 的排除规则及活动倍率另行显示的边界在面板说明,不把汇总值当伤害计算器。
|
||||
|
||||
## 协议与旧服
|
||||
|
||||
只复用现有聊天命令/命令回包的封装,不复制 AVA2 包头,不改变物品/属性数组布局。契约见 metin2-server `docs/FEATURE-QUERY-PROTOCOL.md`。
|
||||
|
||||
未协商能力前不发送业务查询。旧服不支持时会超时并显示“未启用”,不显示空掉落;服务端每角色限流,客户端串行发送,有限次 rate 重试。响应与 request ID/种类/目标/偏移匹配,完整 begin/row/end 后才更新 UI。换角色清理等待队列和回调。`mt_wiki.ServerCapabilities()` 返回 None/布尔以区分未知与已协商。
|
||||
|
||||
## 验证
|
||||
|
||||
- `tests/wiki/test_service.py`:7 项协议测试,含旧服、过期包、缺行、错误行、负数属性、限流重试、关闭取消和两端属性键一致。
|
||||
- `tests/wiki/test_catalogue.py`:7 项已有模板目录测试通过。
|
||||
- 服务端 `tests/features/test_queries.py`:编译真实查询/命令主体及原始分组类,对通用/独立/击杀/等级/手套/附加掉落、宝箱模式和特殊奖励、反查、分页、限流、参数、HP getter 和 RNG 未推进进行验证。结合真实 FreeBSD 完整游戏构建,不用模拟编译替代目标编译。
|
||||
- `build/wiki-source/features-integration-final.log`:隔离加密登录/游戏连接、能力协商、来源跳转、跨页、宝箱奖励和属性同步,加原有触控穿脱/拖动/商店/战斗回归。
|
||||
- `features-server-build.log`:203 独立副本的 FreeBSD 编译;只输出至 `/root/metin2-features-dev/bin/game`。
|
||||
- `features-english-regression.log`:原英文资源完整回归通过。
|
||||
- `features-tooltip-lifecycle.log`:实际 Python 2 宿主验证原固定提示跟随设置不被重置,过期弱引用不会中断下一次拖动。
|
||||
- macOS 实际渲染:`chest-preview.png`、`character-details.png`。截图数据来自隔离协议 fixture,不是 203 实际宝箱奖励;fixture 未提供的属性显示未知。
|
||||
- `features-android-build-final.log`:Android 编译成功;没有安装/真机验收。
|
||||
|
||||
## 明确剩余
|
||||
|
||||
没有替换运行中的 203 游戏二进制,也没有迁移 DB;当前连接旧服会显示查询未支持。生产联机与 Android 真机验证仍需后续安排。
|
||||
后续已接入龙穴共享场次、个人周任务、真实门票/冷却查询与提醒,以及活动日历和月光/圣诞袜实际排期效果;范围和验证见 `AVA2-DUNGEON-CALENDAR.md`。其余副本尚未适配。
|
||||
自动洗练与属性锁定、战令任务、Vulkan 模型预览、批量开箱仍未实现。完整剩余范围见迁移计划,不能把这批查询实现记为全部七项已完成。
|
||||
@@ -265,3 +265,9 @@ Mac 和 Android 更新包在 dist/*AVA2Bio-20261014.zip,Android 包含 APK 与
|
||||
### 2026-10-15:AVA2 百科查询首批
|
||||
|
||||
用户授权按 AVA2 七项迁移计划开始开发。已接入当前服务端模板目录、中文/ID 搜索、物品/怪物分页、升级材料关联跳转、生物学家材料图标入口及独立 wiki 资源包。服务端仅增加公开资料只读导出工具;203 未部署或重启。阶段 0 查询协议/能力握手及阶段 1 模型预览尚未完成,掉落/宝箱及其余功能仍待实现。范围、证据与限制见 `AVA2-WIKI-IMPLEMENTATION.md`。
|
||||
|
||||
### 2026-10-15:只读掉落/宝箱/属性查询
|
||||
|
||||
新增当前服务端已加载分组的只读查询、来源反查、NORMAL/PCT 宝箱模式、角色有效属性快照,以及客户端能力协商/请求匹配/分页/降级。接入百科、怪物目标和触控背包提示;保持原开箱及经济行为。FreeBSD 独立构建、协议及触控回归通过;没有替换 203 运行程序。未完成副本、洗练、日历/战令与模型预览,边界及证据见 `AVA2-READONLY-FEATURES.md`。
|
||||
|
||||
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// PORT: list calendar and opt-in reminders, backed by the same schedule as real server drops.
|
||||
static const char c_szCalendarScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import chat
|
||||
import game
|
||||
import grp
|
||||
import time
|
||||
import ui
|
||||
import wndMgr
|
||||
import mt_feature_service
|
||||
from mt_biolog import Action, Text, L
|
||||
|
||||
g_window=None
|
||||
g_tick=None
|
||||
NAMES={1:'月光宝箱掉落',2:'圣诞袜掉落'}
|
||||
|
||||
|
||||
def Stamp(value):return time.strftime('%m-%d %H:%M',time.gmtime(value))
|
||||
|
||||
|
||||
class CalendarWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.SetWindowName('EventCalendarWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.data=None;self.pending=False;self.generation=0;self.offset=0;self.rowPage=0;self.pageSize=4
|
||||
self.nextQuery=0;self.received=0;self.lastTick=app.GetTime();self.stale=True;self.watch=None;self.dimensions=None
|
||||
self.heading=Text(self,18,18,'活动日历 · UTC',0xffffd68b)
|
||||
self.closeButton=Action(self,0,6,60,'关闭',self.Hide)
|
||||
self.refreshButton=Action(self,18,52,80,'刷新',self.Query)
|
||||
self.remindButton=Action(self,110,52,154,'活动提醒:关',self.Remind)
|
||||
self.status=Text(self,18,110,'正在读取服务器排期')
|
||||
self.rows=[Text(self,24,0,'',0xffefdfc3) for i in range(4)]
|
||||
self.dates=[Text(self,24,0,'',0xffb2aa9a) for i in range(4)]
|
||||
self.buttons=[Action(self,0,0,90,'提醒',lambda i=i:self.Watch(i)) for i in range(4)]
|
||||
self.prev=Action(self,18,0,80,'上一页',lambda:self.Page(-1));self.next=Action(self,0,0,80,'下一页',lambda:self.Page(1));self.pages=Text(self,120,0)
|
||||
self.note=Text(self,18,0,'活动沿用原掉落条件和概率;不保证每次击杀掉落。',0xffb2aa9a)
|
||||
self.Reflow();self.Hide()
|
||||
|
||||
def Open(self):
|
||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
||||
|
||||
def Query(self):
|
||||
if self.pending:return
|
||||
self.pending=True;self.generation+=1;generation=self.generation
|
||||
self.status.SetText(L('正在同步服务器排期…'));self.refreshButton.SetEnabled(False)
|
||||
def received(data,error):
|
||||
if generation!=self.generation:return
|
||||
self.pending=False;self.refreshButton.SetEnabled(True);self.nextQuery=app.GetTime()+15
|
||||
if error:
|
||||
self.data=None;self.stale=True
|
||||
if error=='unsupported':self.watch=None
|
||||
self.status.SetText(L('服务器尚未发布有效排期' if error in ('unsupported','schedule_unavailable') else '排期同步失败,请刷新'))
|
||||
else:
|
||||
self.data=data;self.received=app.GetTime();self.stale=False
|
||||
self.status.SetText(L('服务器排期 / UTC;频道和阵营范围见各条目'))
|
||||
self.Notify()
|
||||
self.Refresh()
|
||||
if mt_feature_service.g_service:mt_feature_service.g_service.Request('calendar',1,self.offset,received)
|
||||
else:received(None,'unsupported')
|
||||
|
||||
def Refresh(self):
|
||||
rows=self.data['rows'][self.rowPage*self.pageSize:(self.rowPage+1)*self.pageSize] if self.data is not None and not self.stale else []
|
||||
for i,(label,button) in enumerate(zip(self.rows,self.buttons)):
|
||||
if i>=len(rows):label.Hide();self.dates[i].Hide();button.Hide();continue
|
||||
event=rows[i];identifier,kind,start,end,channel,empire,active=event
|
||||
now=self.data['time']+max(0,app.GetTime()-self.received)
|
||||
state='已结束' if now>=end else '未开始' if now<start else '进行中' if active else '本排期范围不适用'
|
||||
scope=('全频道' if not channel else '频道%d'%channel)+' / '+('全阵营' if not empire else '阵营%d'%empire)
|
||||
label.SetText(L('%s · %s %s'%(NAMES[kind],state,scope)));label.Show();button.Show()
|
||||
self.dates[i].SetText('%s ~ %s UTC'%(Stamp(start),Stamp(end)));self.dates[i].Show()
|
||||
button.SetText('取消提醒' if self.watch is not None and self.watch['id']==identifier else '提醒')
|
||||
total=self.data['total'] if self.data else 0;absolute=self.offset+self.rowPage*self.pageSize
|
||||
self.pages.SetText('%d / %d'%(absolute//self.pageSize+1,max(1,(total+self.pageSize-1)//self.pageSize)))
|
||||
self.prev.SetEnabled(not self.pending and absolute>0);self.next.SetEnabled(not self.pending and absolute+self.pageSize<total)
|
||||
self.remindButton.SetText('活动提醒:'+('开' if self.watch else '关'))
|
||||
self.remindButton.SetEnabled(self.watch is not None)
|
||||
if self.data is not None and not self.data['rows']:self.status.SetText(L('服务器已发布空排期,当前没有计划活动。'))
|
||||
|
||||
def Watch(self,index):
|
||||
if self.data is None or self.stale:return
|
||||
rows=self.data['rows'][self.rowPage*self.pageSize:(self.rowPage+1)*self.pageSize]
|
||||
if index>=len(rows):return
|
||||
e=rows[index]
|
||||
if self.watch is not None and self.watch['id']==e[0]:self.watch=None
|
||||
else:
|
||||
self.watch=dict(id=e[0],kind=e[1],start=e[2],end=e[3],began=bool(e[6]),offset=self.offset)
|
||||
self.status.SetText(L('%s:%s — %s UTC'%(NAMES[e[1]],Stamp(e[2]),Stamp(e[3]))))
|
||||
self.Refresh()
|
||||
|
||||
def Remind(self):
|
||||
self.watch=None;self.Refresh()
|
||||
|
||||
def Notify(self):
|
||||
if self.watch is None:return
|
||||
e=next((r for r in self.data['rows'] if r[0]==self.watch['id']),None)
|
||||
if e is None:
|
||||
# A reload can move an event to another page. Cancel rather than announce unconfirmed times.
|
||||
if self.offset==self.watch['offset']:self.watch=None
|
||||
return
|
||||
w=self.watch;w.update(start=e[2],end=e[3],offset=self.offset)
|
||||
now=self.data['time']
|
||||
if now>=e[3]:
|
||||
if w['began']:chat.AppendChat(chat.CHAT_TYPE_INFO,L(NAMES[e[1]]+'的计划时段已结束。'))
|
||||
self.watch=None
|
||||
elif e[6] and not w['began']:
|
||||
chat.AppendChat(chat.CHAT_TYPE_INFO,L(NAMES[e[1]]+'已开始,沿用原怪物掉落规则。'));w['began']=True
|
||||
|
||||
def Page(self,step):
|
||||
if self.pending:return
|
||||
total=self.data['total'] if self.data else 0;target=self.offset+self.rowPage*self.pageSize+step*self.pageSize
|
||||
if not 0<=target<total:return
|
||||
offset=(target//8)*8;self.rowPage=(target%8)//self.pageSize
|
||||
if offset!=self.offset:self.offset=offset;self.data=None;self.Query()
|
||||
else:self.Refresh()
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
if dims==self.dimensions:return
|
||||
self.dimensions=dims;width=min(720,max(500,dims[0]-16));height=min(450,max(300,dims[1]-16));self.SetSize(width,height)
|
||||
self.closeButton.SetPosition(width-76,6)
|
||||
count=4 if height>=380 else 2
|
||||
if count!=self.pageSize:self.pageSize=count;self.rowPage=0
|
||||
spacing=min(54,max(32,(height-210)//self.pageSize))
|
||||
for i,(row,button) in enumerate(zip(self.rows,self.buttons)):row.SetPosition(24,134+i*spacing);self.dates[i].SetPosition(24,150+i*spacing);button.SetPosition(width-112,124+i*spacing)
|
||||
self.prev.SetPosition(18,height-74);self.next.SetPosition(width-98,height-74);self.pages.SetPosition(120,height-56);self.note.SetPosition(18,height-18)
|
||||
self.SetCenterPosition()
|
||||
|
||||
def OnUpdate(self):
|
||||
now=app.GetTime();gap=now-self.lastTick;self.lastTick=now
|
||||
if gap>5 or gap<0:self.stale=True;self.nextQuery=0
|
||||
if (self.IsShow() or self.watch is not None) and not self.pending and now>=self.nextQuery and (self.data is not None or self.watch is not None or self.nextQuery==0):
|
||||
if not self.IsShow() and self.watch:self.offset=self.watch['offset'];self.rowPage=0
|
||||
self.Query()
|
||||
if self.IsShow():self.Reflow();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 Hide(self):ui.Window.Hide(self)
|
||||
def OnPressEscapeKey(self):self.Hide();return True
|
||||
def Destroy(self):
|
||||
self.generation+=1;self.watch=None;self.data=None;self.Hide()
|
||||
for b in self.buttons+[self.closeButton,self.refreshButton,self.remindButton,self.prev,self.next]:b.event=None
|
||||
|
||||
|
||||
class ReminderTick(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.AddFlag('not_pick');self.SetSize(0,0);self.Show()
|
||||
def OnUpdate(self):
|
||||
if g_window and not g_window.IsShow():g_window.OnUpdate()
|
||||
|
||||
|
||||
def Toggle():
|
||||
if g_window:
|
||||
if g_window.IsShow():g_window.Hide()
|
||||
else:g_window.Open()
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
|
||||
def Open(self):
|
||||
global g_window,g_tick
|
||||
_open(self);g_window=CalendarWindow();g_tick=ReminderTick()
|
||||
|
||||
def Close(self):
|
||||
global g_window,g_tick
|
||||
if g_tick:g_tick.Hide();g_tick=None
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
_close(self)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
)PY";
|
||||
@@ -0,0 +1,135 @@
|
||||
// PORT: AVA2-style categorized character details, sourced from this server's effective point values.
|
||||
static const char c_szDetailsScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import game
|
||||
import grp
|
||||
import ui
|
||||
import wndMgr
|
||||
import player
|
||||
import mt_feature_service
|
||||
from mt_biolog import Action, Text, L
|
||||
from mt_wiki import B, U
|
||||
|
||||
g_window=None
|
||||
STAT_LAYOUT=[
|
||||
('基础','level','等级',''),('基础','hp','当前生命',''),('基础','maxhp','最大生命',''),('基础','sp','当前魔力',''),('基础','maxsp','最大魔力',''),
|
||||
('基础','attack','物理攻击等级',''),('基础','defense','物理防御等级',''),('基础','magic_attack','魔法攻击等级',''),('基础','magic_defense','魔法防御等级',''),
|
||||
('攻击','monster','对怪物加成','%'),('攻击','stone','对矿石加成','%'),('攻击','boss','对首领加成','%'),('攻击','human','对半人加成','%'),
|
||||
('攻击','animal','对动物加成','%'),('攻击','orc','对兽人加成','%'),('攻击','milgyo','对密教加成','%'),('攻击','undead','对不死加成','%'),('攻击','devil','对恶魔加成','%'),
|
||||
('攻击','warrior','对战士加成','%'),('攻击','assassin','对忍者加成','%'),('攻击','sura','对修罗加成','%'),('攻击','shaman','对萨满加成','%'),
|
||||
('攻击','critical','暴击概率','%'),('攻击','penetrate','穿透概率','%'),('攻击','poison','中毒概率','%'),('攻击','stun','眩晕概率','%'),('攻击','slow','减速概率','%'),
|
||||
('攻击','skill_damage','技能伤害加成','%'),('攻击','normal_damage','普通攻击加成','%'),('攻击','melee_magic','近战及魔法攻击加成','%'),
|
||||
('抗性','block','格挡概率','%'),('抗性','dodge','闪避概率','%'),('抗性','resist_human','半人抗性','%'),
|
||||
('抗性','resist_warrior','战士抗性','%'),('抗性','resist_assassin','忍者抗性','%'),('抗性','resist_sura','修罗抗性','%'),('抗性','resist_shaman','萨满抗性','%'),
|
||||
('抗性','sword','单手剑抗性','%'),('抗性','twohand','双手剑抗性','%'),('抗性','dagger','匕首抗性','%'),('抗性','bell','铃铛抗性','%'),('抗性','fan','扇子抗性','%'),('抗性','bow','弓箭抗性','%'),
|
||||
('抗性','magic','魔法抗性','%'),('抗性','fire','火焰抗性','%'),('抗性','electric','雷电抗性','%'),('抗性','wind','风抗性','%'),('抗性','poison_reduce','中毒伤害减免','%'),
|
||||
('抗性','skill_defense','技能伤害减免','%'),('抗性','normal_defense','普通攻击伤害减免','%'),('抗性','anti_critical','暴击抵抗','%'),('抗性','anti_penetrate','穿透抵抗','%'),
|
||||
('速度恢复','attack_speed','攻击速度',''),('速度恢复','cast_speed','施法速度',''),('速度恢复','move_speed','移动速度',''),
|
||||
('速度恢复','hp_regen','生命恢复加成','%'),('速度恢复','sp_regen','魔力恢复加成','%'),('速度恢复','steal_hp','吸取生命','%'),('速度恢复','steal_sp','吸取魔力','%'),
|
||||
('速度恢复','exp_bonus','角色经验加成','%'),('速度恢复','gold_bonus','角色金币加成','%'),('速度恢复','item_bonus','角色掉落加成','%')]
|
||||
GROUPS=('全部','基础','攻击','抗性','速度恢复')
|
||||
|
||||
|
||||
class DetailsWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.SetWindowName('CharacterDetailsWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.values=None;self.page=0;self.group=0;self.generation=0;self.pending=False;self.nextQuery=0;self.dimensions=None
|
||||
self.heading=Text(self,18,18,'角色详细属性',0xffffd68b)
|
||||
self.closeButton=Action(self,0,6,60,'关闭',self.Hide)
|
||||
self.groupButton=Action(self,18,52,96,'全部',self.NextGroup)
|
||||
self.inputBox=ui.SlotBar();self.inputBox.SetParent(self);self.inputBox.SetPosition(124,60);self.inputBox.SetSize(210,28);self.inputBox.Show()
|
||||
self.search=ui.EditLine();self.search.SetParent(self.inputBox);self.search.SetPosition(6,5);self.search.SetSize(198,20);self.search.SetMax(60);self.search.SetReturnEvent(self.Search);self.search.SetEscapeEvent(self.Hide);self.search.Show()
|
||||
self.searchButton=Action(self,344,52,64,'筛选',self.Search)
|
||||
self.refreshButton=Action(self,418,52,64,'刷新',self.Query)
|
||||
self.status=Text(self,18,106,'尚未同步角色属性')
|
||||
self.rows=[Text(self,24,0,'',0xffefdfc3) for i in range(8)]
|
||||
self.prev=Action(self,18,0,80,'上一页',lambda:self.Page(-1));self.next=Action(self,0,0,80,'下一页',lambda:self.Page(1));self.pages=Text(self,120,0)
|
||||
self.note=Text(self,18,0,'研究对怪加成不计入首领;活动倍率另行显示。',0xffb2aa9a)
|
||||
self.Reflow();self.Hide()
|
||||
|
||||
def Open(self):
|
||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
||||
|
||||
def Query(self):
|
||||
if self.pending:return
|
||||
self.pending=True;self.generation+=1;generation=self.generation
|
||||
self.status.SetText(L('正在同步服务器属性…'));self.refreshButton.SetEnabled(False)
|
||||
def received(data,error):
|
||||
if generation!=self.generation:return
|
||||
self.pending=False;self.nextQuery=app.GetTime()+3;self.refreshButton.SetEnabled(True)
|
||||
if error:
|
||||
self.values=None;self.status.SetText(L('服务端尚未启用详细属性' if error=='unsupported' else '属性同步失败,请稍后刷新'))
|
||||
else:
|
||||
self.values=dict(data['rows']);self.status.SetText(L('服务器有效属性;不等同于对所有目标的最终伤害'))
|
||||
self.Refresh()
|
||||
if mt_feature_service.g_service:mt_feature_service.g_service.Request('stats',0,0,received)
|
||||
else:received(None,'unsupported')
|
||||
|
||||
def Search(self):
|
||||
self.page=0;self.Refresh()
|
||||
|
||||
def NextGroup(self):
|
||||
self.group=(self.group+1)%len(GROUPS);self.page=0;self.groupButton.SetText(GROUPS[self.group]);self.Refresh()
|
||||
|
||||
def Page(self,step):
|
||||
self.page+=step;self.Refresh()
|
||||
|
||||
def Refresh(self):
|
||||
for r in self.rows:r.Hide()
|
||||
query=player.FeatureLocaleToUTF8(self.search.GetText()).decode('utf-8').lower()
|
||||
visible=[r for r in STAT_LAYOUT if (self.group==0 or r[0]==GROUPS[self.group]) and (not query or query in U(r[2]).lower())]
|
||||
pages=max(1,(len(visible)+self.rowCount-1)//self.rowCount);self.page=max(0,min(self.page,pages-1))
|
||||
for i,(_,key,label,unit) in enumerate(visible[self.page*self.rowCount:(self.page+1)*self.rowCount]):
|
||||
value=(str(self.values[key])+unit) if self.values is not None and key in self.values else '—'
|
||||
self.rows[i].SetText(L(B(U(label)+u': '+U(value))));self.rows[i].Show()
|
||||
self.pages.SetText('%d / %d'%(self.page+1,pages));self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page+1<pages)
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
if dims==self.dimensions:return
|
||||
self.dimensions=dims;width=min(600,max(500,dims[0]-16));height=min(480,max(300,dims[1]-16));self.SetSize(width,height)
|
||||
self.rowCount=min(8,max(2,(height-204)//32));self.closeButton.SetPosition(width-76,6)
|
||||
for i,r in enumerate(self.rows):r.SetPosition(24,136+32*i)
|
||||
self.prev.SetPosition(18,height-56);self.next.SetPosition(width-98,height-56);self.pages.SetPosition(120,height-38);self.note.SetPosition(18,height-80)
|
||||
self.SetCenterPosition();self.Refresh()
|
||||
|
||||
def OnUpdate(self):
|
||||
self.Reflow()
|
||||
if self.IsShow() and not self.pending and self.values is not None and app.GetTime()>=self.nextQuery:self.Query()
|
||||
|
||||
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 Hide(self):
|
||||
if hasattr(self,'search'):self.search.KillFocus()
|
||||
if hasattr(self,'generation'):self.generation+=1;self.pending=False
|
||||
ui.Window.Hide(self)
|
||||
|
||||
def OnPressEscapeKey(self):self.Hide();return True
|
||||
|
||||
def Destroy(self):
|
||||
self.Hide();self.values=None;self.search.SetReturnEvent(None);self.search.SetEscapeEvent(None)
|
||||
for b in (self.closeButton,self.groupButton,self.searchButton,self.refreshButton,self.prev,self.next):b.event=None
|
||||
|
||||
|
||||
def Toggle():
|
||||
if g_window:
|
||||
if g_window.IsShow():g_window.Hide()
|
||||
else:g_window.Open()
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
|
||||
def Open(self):
|
||||
global g_window
|
||||
_open(self);g_window=DetailsWindow()
|
||||
|
||||
def Close(self):
|
||||
global g_window
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
_close(self)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
)PY";
|
||||
@@ -0,0 +1,183 @@
|
||||
// PORT: dungeon information from installed quests; original NPC admission remains authoritative.
|
||||
static const char c_szDungeonScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import chat
|
||||
import game
|
||||
import grp
|
||||
import ui
|
||||
import wndMgr
|
||||
import mt_feature_service
|
||||
import mt_wiki
|
||||
from mt_wiki import B
|
||||
from mt_biolog import Action, Text, L
|
||||
|
||||
g_window=None
|
||||
|
||||
|
||||
def Duration(seconds):
|
||||
seconds=max(0,int(seconds))
|
||||
return '%d天 %02d:%02d:%02d'%(seconds//86400,(seconds//3600)%24,(seconds//60)%60,seconds%60) if seconds>=86400 else '%02d:%02d:%02d'%(seconds//3600,(seconds//60)%60,seconds%60)
|
||||
|
||||
|
||||
class DungeonWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.SetWindowName('DungeonInformationWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||
self.values=None;self.pending=False;self.generation=0;self.received=0;self.serverTime=0;self.nextQuery=0
|
||||
self.lastTick=app.GetTime();self.reminder=None;self.tab=0;self.dimensions=None;self.stale=True
|
||||
self.heading=Text(self,18,18,'副本信息',0xffffd68b)
|
||||
self.closeButton=Action(self,0,6,60,'关闭',self.Hide)
|
||||
self.lairButton=Action(self,18,52,150,'龙穴',lambda:self.Select(0))
|
||||
self.weeklyButton=Action(self,180,52,150,'龙穴周任务',lambda:self.Select(1))
|
||||
self.refreshButton=Action(self,342,52,80,'刷新',self.Query)
|
||||
self.status=Text(self,18,108,'正在读取任务状态')
|
||||
self.rows=[Text(self,24,142+32*i,'',0xffefdfc3) for i in range(6)]
|
||||
self.remindButton=Action(self,18,0,146,'到期提醒:关',self.Remind)
|
||||
self.entryButton=Action(self,180,0,146,'查看入口',self.Entry)
|
||||
self.note=Text(self,18,0,'当前仅接入已核实的龙穴及相关周任务。',0xffb2aa9a)
|
||||
self.Reflow();self.Hide()
|
||||
|
||||
def Open(self):
|
||||
if mt_wiki.g_window:mt_wiki.g_window.Load()
|
||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
||||
|
||||
def Select(self,tab):
|
||||
self.tab=tab;self.RenderRows()
|
||||
|
||||
def Query(self):
|
||||
if self.pending:return
|
||||
self.pending=True;self.generation+=1;generation=self.generation
|
||||
self.status.SetText(L('正在同步服务器任务…'));self.refreshButton.SetEnabled(False)
|
||||
def received(data,error):
|
||||
if generation!=self.generation:return
|
||||
self.pending=False;self.refreshButton.SetEnabled(True);self.nextQuery=app.GetTime()+15
|
||||
if error:
|
||||
self.stale=True;self.values=None
|
||||
if error=='unsupported':self.reminder=None
|
||||
self.status.SetText(L('服务端未启用此副本查询' if error=='unsupported' else '状态未同步,请稍后刷新'))
|
||||
else:
|
||||
self.values=dict(data['rows']);self.received=app.GetTime();self.serverTime=data['time'];self.stale=False
|
||||
self.status.SetText(L('已同步任务状态;入场仍由原 NPC 检查'))
|
||||
self.CheckReminder()
|
||||
self.RenderRows()
|
||||
if mt_feature_service.g_service:mt_feature_service.g_service.Request('dungeons',0,0,received)
|
||||
else:received(None,'unsupported')
|
||||
|
||||
def Now(self):
|
||||
return self.serverTime+max(0,app.GetTime()-self.received)
|
||||
|
||||
def RenderRows(self):
|
||||
lines=[];v=self.values
|
||||
if v is not None and not self.stale:
|
||||
now=self.Now()
|
||||
if self.tab==0:
|
||||
if now<=v['lair_join_end']:state='队伍加入窗口(需要口令)'
|
||||
elif now<=v['lair_end']:state='场次占用中'
|
||||
else:state='可向入口 NPC 申请新场次'
|
||||
catalogue=mt_wiki.g_window.catalogue if mt_wiki.g_window else None
|
||||
ticket=catalogue.FindItem(v['lair_ticket']) if catalogue else None
|
||||
name=B(ticket['name']) if ticket else '门票'
|
||||
lines=['状态:'+state,'新场次等待:'+Duration(v['lair_end']+1-now),
|
||||
'加入窗口:'+Duration(v['lair_join_end']+1-now),
|
||||
'%s:需要 %d,持有 %d'%(name,v['lair_tickets'],v['lair_owned']),
|
||||
'场次为全服共享;不是个人通关冷却。','入场口令、门票及 NPC 锁由原任务校验。']
|
||||
elif 'weekly_end' in v:
|
||||
lines=['个人周任务等待:'+Duration(v['weekly_end']+1-now),
|
||||
'当前任务:'+('收集中' if v.get('weekly_active') else '未在收集阶段'),
|
||||
'收集剩余:'+(Duration(v['weekly_hunt_end']-now) if v.get('weekly_active') else '—'),
|
||||
'周任务与龙穴入场计时独立。','接取时开始计时,完成后不会重置冷却。','资格、收集材料和奖励由原任务检查。']
|
||||
else:lines=['服务端未安装或未提供周任务查询。']
|
||||
for i,row in enumerate(self.rows):
|
||||
if i<len(lines):row.SetText(L(lines[i]));row.Show()
|
||||
else:row.Hide()
|
||||
self.remindButton.SetText('到期提醒:'+('开' if self.reminder is not None and self.reminder[0]==self.tab else '关'))
|
||||
self.remindButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_end' in v))
|
||||
self.entryButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_npc' in v))
|
||||
|
||||
def Entry(self):
|
||||
if self.values is None or self.stale:return
|
||||
v=self.values
|
||||
text='请前往流放者洞窟深处,与修罗之魂对话进入龙穴。' if self.tab==0 else '请前往流放者洞窟二层,与猛将之魂对话接取周任务。'
|
||||
self.status.SetText(L(text))
|
||||
|
||||
def Remind(self):
|
||||
if self.values is None or self.stale:return
|
||||
if self.reminder is not None and self.reminder[0]==self.tab:self.reminder=None
|
||||
else:
|
||||
end=self.values.get('lair_end' if self.tab==0 else 'weekly_end',0)
|
||||
if end<self.Now():return
|
||||
self.reminder=(self.tab,end)
|
||||
self.RenderRows()
|
||||
|
||||
def CheckReminder(self):
|
||||
if self.reminder is None or self.values is None or self.stale:return
|
||||
tab,old=self.reminder;key='lair_end' if tab==0 else 'weekly_end'
|
||||
if key not in self.values:self.reminder=None;return
|
||||
end=self.values[key]
|
||||
# A later run/quest extends the authoritative deadline; never announce the old one.
|
||||
self.reminder=(tab,end)
|
||||
if self.Now()>end:
|
||||
chat.AppendChat(chat.CHAT_TYPE_INFO,L('龙穴新场次等待已结束,请向入口申请。' if tab==0 else '龙穴周任务等待已结束,请向 NPC 确认资格。'))
|
||||
self.reminder=None
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
if dims==self.dimensions:return
|
||||
self.dimensions=dims;width=min(660,max(500,dims[0]-16));height=min(450,max(300,dims[1]-16));self.SetSize(width,height)
|
||||
self.closeButton.SetPosition(width-76,6)
|
||||
spacing=min(32,max(16,(height-250)//5))
|
||||
for i,row in enumerate(self.rows):row.SetPosition(24,132+i*spacing)
|
||||
self.remindButton.SetPosition(18,height-74);self.entryButton.SetPosition(180,height-74);self.note.SetPosition(18,height-18)
|
||||
self.SetCenterPosition()
|
||||
|
||||
def OnUpdate(self):
|
||||
now=app.GetTime();gap=now-self.lastTick;self.lastTick=now
|
||||
if gap>5 or gap<0:self.stale=True;self.nextQuery=0
|
||||
active=self.IsShow() or self.reminder is not None
|
||||
if active and not self.pending and now>=self.nextQuery and (self.values is not None or self.reminder is not None or self.stale and self.nextQuery==0):self.Query()
|
||||
if self.IsShow():self.Reflow();self.RenderRows()
|
||||
# Reminders only fire after a fresh server query confirms expiry.
|
||||
if self.reminder is not None and not self.pending and self.values is not None and self.Now()>self.reminder[1] and now>=self.nextQuery:self.Query()
|
||||
|
||||
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 Hide(self):
|
||||
ui.Window.Hide(self)
|
||||
|
||||
def OnPressEscapeKey(self):self.Hide();return True
|
||||
|
||||
def Destroy(self):
|
||||
self.generation+=1;self.reminder=None;self.values=None;self.Hide()
|
||||
for b in (self.closeButton,self.lairButton,self.weeklyButton,self.refreshButton,self.remindButton,self.entryButton):b.event=None
|
||||
|
||||
|
||||
class ReminderTick(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.AddFlag('not_pick');self.SetSize(0,0);self.Show()
|
||||
def OnUpdate(self):
|
||||
if g_window and not g_window.IsShow():g_window.OnUpdate()
|
||||
|
||||
|
||||
def Toggle():
|
||||
if g_window:
|
||||
if g_window.IsShow():g_window.Hide()
|
||||
else:g_window.Open()
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
g_tick=None
|
||||
|
||||
def Open(self):
|
||||
global g_window,g_tick
|
||||
_open(self);g_window=DungeonWindow();g_tick=ReminderTick()
|
||||
|
||||
def Close(self):
|
||||
global g_window,g_tick
|
||||
if g_tick:g_tick.Hide();g_tick=None
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
_close(self)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
)PY";
|
||||
@@ -0,0 +1,157 @@
|
||||
// PORT read-only feature protocol. Requests are serialized, bounded and tied to this game session.
|
||||
static const char c_szFeatureServiceScript[] = R"PY(
|
||||
import app
|
||||
import game
|
||||
import net
|
||||
import ui
|
||||
from mt_wiki_data import Number
|
||||
|
||||
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32}
|
||||
g_service=None
|
||||
g_tick=None
|
||||
_serial=0
|
||||
|
||||
|
||||
def Signed(text):
|
||||
value=int(text)
|
||||
if len(text)>20 or not -(1<<63)<=value<(1<<63):raise ValueError('invalid stat value')
|
||||
return value
|
||||
|
||||
|
||||
class Service(object):
|
||||
def __init__(self):
|
||||
self.queue=[];self.pending=None;self.mask=None;self.nextSend=app.GetTime()+0.5
|
||||
self.deadline=0;self.closed=False
|
||||
|
||||
def Request(self,kind,subject,offset,callback):
|
||||
global _serial
|
||||
if kind not in BITS or not 0<=subject<=4294967295 or not 0<=offset<=4096 or len(self.queue)>=8:
|
||||
callback(None,'invalid');return 0
|
||||
if (kind in ('stats','dungeons') and (subject or offset)) or (kind=='calendar' and subject!=1):
|
||||
callback(None,'invalid');return 0
|
||||
_serial=(_serial%2147483647)+1
|
||||
self.queue.append(dict(id=_serial,kind=kind,subject=subject,offset=offset,callback=callback,retries=0))
|
||||
return _serial
|
||||
|
||||
def Tick(self):
|
||||
global _serial
|
||||
now=app.GetTime()
|
||||
if self.closed:return
|
||||
if self.pending and now>self.deadline:
|
||||
if self.pending['kind']=='cap':
|
||||
self.mask=0;self.pending=None
|
||||
else:self.Finish(None,'timeout')
|
||||
self.nextSend=now+0.3
|
||||
if self.pending or not self.queue or now<self.nextSend:return
|
||||
if self.mask is None:
|
||||
_serial=(_serial%2147483647)+1
|
||||
self.pending=dict(id=_serial,kind='cap',subject=0,offset=0,callback=None,retries=0)
|
||||
net.SendChatPacket('/mt_features 1 %d cap'%_serial)
|
||||
else:
|
||||
self.pending=self.queue.pop(0)
|
||||
p=self.pending
|
||||
if not self.mask&BITS[p['kind']]:
|
||||
self.Finish(None,'unsupported');return
|
||||
text='/mt_features 1 %d %s'%(p['id'],p['kind'])
|
||||
if p['kind'] not in ('stats','dungeons'):text+=' %d %d'%(p['subject'],p['offset'])
|
||||
net.SendChatPacket(text)
|
||||
self.deadline=now+4.0;self.nextSend=now+0.3
|
||||
|
||||
def Finish(self,result,error=None):
|
||||
pending=self.pending;self.pending=None
|
||||
if pending and pending.get('callback') and not self.closed:pending['callback'](result,error)
|
||||
|
||||
def Receive(self,line):
|
||||
if not line.startswith('MTF1 '):return False
|
||||
p=self.pending
|
||||
try:
|
||||
w=line.split()
|
||||
if len(w)<3 or len(line)>512:return True
|
||||
request=Number(w[2],2147483647)
|
||||
if not p or p['id']!=request:return True
|
||||
op=w[1]
|
||||
if op=='cap' and len(w)==6 and p['kind']=='cap':
|
||||
if Number(w[3])!=1:raise ValueError('unsupported protocol')
|
||||
self.mask=Number(w[4],4294967295);Number(w[5]);self.pending=None
|
||||
elif op=='error' and len(w)==4:
|
||||
if w[3]=='rate' and p['kind']!='cap' and p['retries']<2:
|
||||
p['retries']+=1;self.queue.insert(0,p);self.pending=None;self.nextSend=app.GetTime()+0.4
|
||||
else:self.Finish(None,w[3])
|
||||
elif op=='begin' and len(w)==9 and p['kind']!='cap':
|
||||
if 'result' in p or w[3]!=p['kind'] or Number(w[4])!=p['subject'] or Number(w[5])!=p['offset']:
|
||||
raise ValueError('mismatched response')
|
||||
total=Number(w[6],128 if p['kind'] in ('stats','dungeons') else 256 if p['kind']=='calendar' else 4096)
|
||||
if total<p['offset']:raise ValueError('invalid page')
|
||||
p['result']=dict(kind=w[3],subject=p['subject'],offset=p['offset'],total=total,mode=Number(w[7],11),
|
||||
time=Number(w[8]),rows=[])
|
||||
elif op in ('row','stat','event') and 'result' in p:
|
||||
result=p['result'];rows=result['rows']
|
||||
limit=result['total'] if p['kind'] in ('stats','dungeons') else min(8,result['total']-p['offset'])
|
||||
if len(rows)>=limit or Number(w[3])!=p['offset']+len(rows):raise ValueError('invalid row order')
|
||||
if op=='row' and len(w)==12 and p['kind'] not in ('stats','dungeons','calendar'):
|
||||
values=[Number(v) for v in w[4:]]
|
||||
if values[2] not in (0,1,2,3,4,5,10,11,20,21) or not values[0]:raise ValueError('invalid row')
|
||||
rows.append(values)
|
||||
elif op=='event' and len(w)==11 and p['kind']=='calendar':
|
||||
values=[Number(v) for v in w[4:]]
|
||||
if not values[0] or values[1] not in (1,2) or values[2]>=values[3] or values[4]>255 or values[5]>3 or values[6]>1:raise ValueError('invalid calendar event')
|
||||
if any(r[0]==values[0] for r in rows):raise ValueError('duplicate event')
|
||||
rows.append(values)
|
||||
elif op=='stat' and len(w)==6 and p['kind'] in ('stats','dungeons'):
|
||||
if not w[4].replace('_','').isalpha() or len(w[4])>32 or any(r[0]==w[4] for r in rows):raise ValueError('invalid stat')
|
||||
rows.append((w[4],Signed(w[5])))
|
||||
else:raise ValueError('invalid row format')
|
||||
elif op=='end' and len(w)==4 and 'result' in p:
|
||||
result=p['result'];expected=result['total'] if p['kind'] in ('stats','dungeons') else min(8,result['total']-p['offset'])
|
||||
if Number(w[3])!=expected or len(result['rows'])!=expected:raise ValueError('incomplete reply')
|
||||
if p['kind']=='dungeons':
|
||||
values=dict(result['rows'])
|
||||
required=('lair_end','lair_join_end','lair_phase','lair_ticket','lair_tickets','lair_boss','lair_owned','lair_alive','lair_npc','lair_map')
|
||||
if any(k not in values or values[k]<0 for k in required):raise ValueError('incomplete dungeon snapshot')
|
||||
if values['lair_phase']>2 or not values['lair_ticket'] or values['lair_join_end']>values['lair_end']:raise ValueError('invalid dungeon state')
|
||||
weekly=('weekly_end','weekly_hunt_end','weekly_active','weekly_npc','weekly_map')
|
||||
if any(k in values for k in weekly) and any(k not in values or values[k]<0 for k in weekly):raise ValueError('incomplete weekly state')
|
||||
if values.get('weekly_active',0)>1:raise ValueError('invalid weekly state')
|
||||
self.Finish(result)
|
||||
else:raise ValueError('unknown response')
|
||||
except (ValueError,TypeError,IndexError,OverflowError):
|
||||
if p and self.pending is p:
|
||||
if p['kind']=='cap':self.mask=0;self.pending=None
|
||||
else:self.Finish(None,'bad_reply')
|
||||
return True
|
||||
|
||||
def Close(self):
|
||||
self.closed=True;self.pending=None;self.queue=[]
|
||||
|
||||
|
||||
class TickWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self);self.AddFlag('not_pick');self.SetSize(0,0);self.Show()
|
||||
def OnUpdate(self):
|
||||
if g_service:g_service.Tick()
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
_command=game.GameWindow.BINARY_ServerCommand_Run
|
||||
|
||||
def Open(self):
|
||||
global g_service,g_tick
|
||||
_open(self);g_service=Service();g_tick=TickWindow()
|
||||
|
||||
def Close(self):
|
||||
global g_service,g_tick
|
||||
if g_service:g_service.Close();g_service=None
|
||||
if g_tick:g_tick.Hide();g_tick=None
|
||||
_close(self)
|
||||
|
||||
def Command(self,line):
|
||||
if line.startswith('MTF1 '):
|
||||
if g_service:g_service.Receive(line)
|
||||
return 1
|
||||
return _command(self,line)
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
game.GameWindow.BINARY_ServerCommand_Run=Command
|
||||
)PY";
|
||||
@@ -993,7 +993,12 @@ namespace PythonBoot
|
||||
#include "BiologScript.inc"
|
||||
#include "FeatureBootstrapScript.inc"
|
||||
#include "WikiDataScript.inc"
|
||||
#include "FeatureServiceScript.inc"
|
||||
#include "WikiScript.inc"
|
||||
#include "DetailsScript.inc"
|
||||
#include "DungeonScript.inc"
|
||||
#include "CalendarScript.inc"
|
||||
#include "WikiEntryScript.inc"
|
||||
|
||||
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
||||
{
|
||||
@@ -1061,7 +1066,12 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
||||
install_script_module("mt_features", c_szFeatureBootstrapScript);
|
||||
install_script_module("mt_biolog", c_szBiologScript);
|
||||
install_script_module("mt_wiki_data", c_szWikiDataScript);
|
||||
install_script_module("mt_feature_service", c_szFeatureServiceScript);
|
||||
install_script_module("mt_wiki", c_szWikiScript);
|
||||
install_script_module("mt_details", c_szDetailsScript);
|
||||
install_script_module("mt_dungeons", c_szDungeonScript);
|
||||
install_script_module("mt_calendar", c_szCalendarScript);
|
||||
install_script_module("mt_wiki_entries", c_szWikiEntryScript);
|
||||
if (g_touch_input)
|
||||
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// PORT query entry points: enemy target, and a pinned touch inventory tooltip.
|
||||
static const char c_szWikiEntryScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import chr
|
||||
import player
|
||||
import uitarget
|
||||
import uiToolTip
|
||||
import uiInventory
|
||||
import weakref
|
||||
import wndMgr
|
||||
from mt_biolog import Action
|
||||
import mt_wiki
|
||||
_pinned=None
|
||||
|
||||
|
||||
def OpenMob(vid):
|
||||
race=chr.GetVirtualNumber(vid)
|
||||
wiki=mt_wiki.g_window
|
||||
if not wiki:return
|
||||
wiki.Open()
|
||||
row=wiki.catalogue.mobs.get(race) if wiki.catalogue else None
|
||||
if row:
|
||||
wiki.Select('mobs',row);wiki.QueryRemote('mob',race)
|
||||
|
||||
|
||||
_target=uitarget.TargetBoard.SetTargetVID
|
||||
_enemy=uitarget.TargetBoard.SetEnemyVID
|
||||
_hp=uitarget.TargetBoard.SetHP
|
||||
|
||||
|
||||
def Target(self,vid):
|
||||
_target(self,vid)
|
||||
if hasattr(self,'wikiDropButton'):self.wikiDropButton.Hide()
|
||||
|
||||
|
||||
def Enemy(self,vid):
|
||||
_enemy(self,vid)
|
||||
if not hasattr(self,'wikiDropButton'):
|
||||
owner=weakref.proxy(self)
|
||||
self.wikiDropButton=Action(self,0,0,72,'掉落',lambda:OpenMob(owner.GetTargetVID()))
|
||||
PositionTarget(self)
|
||||
self.wikiDropButton.Show()
|
||||
|
||||
|
||||
def PositionTarget(self):
|
||||
if getattr(self,'wikiDropButton',None):
|
||||
x,y=self.GetGlobalPosition()
|
||||
self.wikiDropButton.SetPosition(min(self.GetWidth()+4,max(0,wndMgr.GetScreenWidth()-x-76)),0)
|
||||
|
||||
|
||||
def HP(self,value):
|
||||
_hp(self,value);PositionTarget(self)
|
||||
|
||||
|
||||
_clear=uiToolTip.ItemToolTip.ClearToolTip
|
||||
_inventory=uiToolTip.ItemToolTip.SetInventoryItem
|
||||
_out=uiInventory.InventoryWindow.OverOutItem
|
||||
|
||||
|
||||
def Clear(self):
|
||||
if getattr(self,'wikiItemButton',None):
|
||||
self.wikiItemButton.event=None;self.wikiItemButton.Hide();self.wikiItemButton=None
|
||||
if getattr(self,'featurePinned',False):self.SetFollow(True)
|
||||
self.featurePinned=False
|
||||
_clear(self)
|
||||
|
||||
|
||||
def Inventory(self,slot,window_type=player.INVENTORY):
|
||||
global _pinned
|
||||
_inventory(self,slot,window_type)
|
||||
mobile=sys.modules.get('mt_mobile_inventory')
|
||||
if not mobile or not mobile.tap:return
|
||||
vnum=player.GetItemIndex(window_type,slot)
|
||||
if not vnum:return
|
||||
owner=weakref.proxy(self)
|
||||
def open_item():
|
||||
if player.GetItemIndex(window_type,slot)!=vnum:return
|
||||
owner.HideToolTip();mt_wiki.OpenItem(vnum)
|
||||
wiki=mt_wiki.g_window
|
||||
if wiki and wiki.catalogue:
|
||||
row=wiki.catalogue.FindItem(vnum)
|
||||
if row and row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):wiki.QueryRemote('chest',vnum)
|
||||
y=self.toolTipHeight
|
||||
self.wikiItemButton=Action(self,8,y,max(80,self.GetWidth()-16),'百科 / 宝箱预览',open_item)
|
||||
self.AppendSpace(52);self.SetFollow(False);self.featurePinned=True;_pinned=weakref.proxy(self)
|
||||
x,y=self.GetGlobalPosition()
|
||||
self.SetPosition(max(0,min(x,wndMgr.GetScreenWidth()-self.GetWidth())),max(0,min(y,wndMgr.GetScreenHeight()-self.GetHeight())))
|
||||
|
||||
|
||||
def Out(self):
|
||||
if self.tooltipItem and getattr(self.tooltipItem,'featurePinned',False):return
|
||||
_out(self)
|
||||
|
||||
|
||||
_mobile=sys.modules.get('mt_mobile_inventory')
|
||||
if _mobile:
|
||||
_drag=_mobile.SetDrag
|
||||
def Drag(active):
|
||||
if active and _pinned is not None:
|
||||
try:_pinned.HideToolTip()
|
||||
except ReferenceError:pass
|
||||
_drag(active)
|
||||
_mobile.SetDrag=Drag
|
||||
|
||||
uitarget.TargetBoard.SetTargetVID=Target
|
||||
uitarget.TargetBoard.SetEnemyVID=Enemy
|
||||
uitarget.TargetBoard.SetHP=HP
|
||||
uiToolTip.ItemToolTip.ClearToolTip=Clear
|
||||
uiToolTip.ItemToolTip.SetInventoryItem=Inventory
|
||||
uiInventory.InventoryWindow.OverOutItem=Out
|
||||
)PY";
|
||||
@@ -1,5 +1,5 @@
|
||||
// PORT: AVA2-inspired encyclopedia, adapted to current server templates and touch controls.
|
||||
// Gameplay/loot is never computed here. The schema explicitly advertises static data only.
|
||||
// Static templates plus negotiated read-only server views; no gameplay mutation or loot RNG here.
|
||||
static const char c_szWikiScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import game
|
||||
@@ -10,13 +10,22 @@ import player
|
||||
import ui
|
||||
import wndMgr
|
||||
import weakref
|
||||
import mt_feature_service
|
||||
from mt_biolog import Action, Text, L
|
||||
from mt_wiki_data import Catalogue
|
||||
|
||||
PATH='wiki/catalogue.txt'
|
||||
g_window=None
|
||||
g_entry=None
|
||||
CAPABILITIES={'schema':1,'static_catalogue':True,'live_drops':False,'chest_preview':False,'model_preview':False}
|
||||
def ServerCapabilities():
|
||||
mask=mt_feature_service.g_service.mask if mt_feature_service.g_service else None
|
||||
return {'schema':1,'static_catalogue':True,'model_preview':False,
|
||||
'live_drops':None if mask is None else bool(mask&1),
|
||||
'chest_preview':None if mask is None else bool(mask&2),
|
||||
'attributes':None if mask is None else bool(mask&4),
|
||||
'sources':None if mask is None else bool(mask&8),
|
||||
'dungeons':None if mask is None else bool(mask&16),
|
||||
'calendar':None if mask is None else bool(mask&32)}
|
||||
|
||||
|
||||
def U(text):
|
||||
@@ -46,7 +55,11 @@ class WikiWindow(ui.Window):
|
||||
self.mode='items';self.category=0;self.page=0;self.detailPage=0
|
||||
self.catalogue=None;self.error='';self.selected=None;self.results=[];self.links=[]
|
||||
self.dimensions=None;self.lastQuery=None;self.history=[]
|
||||
self.remote=None;self.remoteData=None;self.remoteError=None;self.remoteSerial=0
|
||||
self.heading=Text(self,18,18,'游戏百科',0xffffd68b)
|
||||
self.detailsButton=Action(self,130,6,120,'详细属性',self.OpenDetails)
|
||||
self.dungeonButton=Action(self,258,6,96,'副本信息',self.OpenDungeons)
|
||||
self.calendarButton=Action(self,362,6,80,'活动日历',self.OpenCalendar)
|
||||
self.closeButton=Action(self,0,6,52,'关闭',self.Hide)
|
||||
self.backButton=Action(self,0,6,72,'返回',self.Back)
|
||||
self.searchBox=ui.SlotBar();self.searchBox.SetParent(self);self.searchBox.SetPosition(18,54)
|
||||
@@ -72,6 +85,18 @@ class WikiWindow(ui.Window):
|
||||
self.Reflow()
|
||||
self.Hide()
|
||||
|
||||
def OpenCalendar(self):
|
||||
import mt_calendar
|
||||
mt_calendar.Toggle()
|
||||
|
||||
def OpenDungeons(self):
|
||||
import mt_dungeons
|
||||
mt_dungeons.Toggle()
|
||||
|
||||
def OpenDetails(self):
|
||||
import mt_details
|
||||
mt_details.Toggle()
|
||||
|
||||
def Load(self):
|
||||
if self.catalogue:return True
|
||||
try:
|
||||
@@ -112,6 +137,13 @@ class WikiWindow(ui.Window):
|
||||
self.page+=step;self.Refresh()
|
||||
|
||||
def DetailPage(self,step):
|
||||
if self.remote and self.remoteData:
|
||||
pages=max(1,(len(self.links)+self.rowCount-1)//self.rowCount)
|
||||
kind,subject,offset=self.remote
|
||||
if step>0 and self.detailPage+1>=pages and offset+len(self.remoteData['rows'])<self.remoteData['total']:
|
||||
self.QueryRemote(kind,subject,offset+8);return
|
||||
if step<0 and self.detailPage==0 and offset>0:
|
||||
self.QueryRemote(kind,subject,max(0,offset-8));return
|
||||
self.detailPage+=step;self.RefreshDetail()
|
||||
|
||||
def Refresh(self):
|
||||
@@ -132,9 +164,12 @@ class WikiWindow(ui.Window):
|
||||
|
||||
def Select(self,mode,row,remember=True):
|
||||
if remember and self.selected:self.history=(self.history+[self.selected])[-24:]
|
||||
self.remote=None;self.remoteData=None;self.remoteSerial+=1
|
||||
self.selected=(mode,row['id']);self.detailPage=0;self.RefreshDetail()
|
||||
|
||||
def Back(self):
|
||||
if self.remote:
|
||||
self.remote=None;self.remoteData=None;self.remoteSerial+=1;self.detailPage=0;self.RefreshDetail();return
|
||||
if self.history:
|
||||
self.selected=self.history.pop();self.detailPage=0;self.RefreshDetail()
|
||||
|
||||
@@ -142,6 +177,14 @@ class WikiWindow(ui.Window):
|
||||
offset=self.detailPage*self.rowCount+index
|
||||
if offset<len(self.links) and self.links[offset][1]:
|
||||
vnum=self.links[offset][1]
|
||||
if isinstance(vnum,tuple):
|
||||
if vnum[0]=='query':self.QueryRemote(vnum[1],vnum[2],0)
|
||||
elif vnum[0]=='mobs':
|
||||
row=self.catalogue.mobs.get(vnum[1])
|
||||
if row:self.Select('mobs',row)
|
||||
else:self.QueryRemote('mob',vnum[1],0)
|
||||
elif vnum[0]=='items':self.Open(vnum[1])
|
||||
return
|
||||
row=self.catalogue.FindItem(vnum)
|
||||
if row:self.Select('items',row)
|
||||
else:self.status.SetText(L(B('目标物品未收录:#%d'%vnum)))
|
||||
@@ -149,7 +192,7 @@ class WikiWindow(ui.Window):
|
||||
def RefreshDetail(self):
|
||||
for b in self.detailButtons:b.Hide()
|
||||
self.links=[];self.icon.Hide()
|
||||
self.backButton.SetEnabled(bool(self.history))
|
||||
self.backButton.SetEnabled(bool(self.history) or self.remote is not None)
|
||||
if not self.selected or not self.catalogue:
|
||||
self.dprev.SetEnabled(False);self.dnext.SetEnabled(False)
|
||||
return
|
||||
@@ -189,18 +232,74 @@ class WikiWindow(ui.Window):
|
||||
else:
|
||||
cost=self.catalogue.FindItem(offer['cost'])
|
||||
lines.append((u'研究兑换:'+(cost['name'] if cost else u'#%d'%offer['cost'])+u' ×%d'%offer['amount'],offer['cost'] if cost else 0))
|
||||
lines.append((u'怪物掉落来源:尚未接入',0))
|
||||
if row['kind']=='ITEM_GIFTBOX':lines.append((u'宝箱奖励预览:尚未接入',0))
|
||||
lines.append((u'查看获取来源',('query','sources',vnum)))
|
||||
if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum)))
|
||||
else:
|
||||
lines=[(u'类型:'+U('矿石' if row['kind']=='STONE' else '怪物')+u' / '+U(RANK_NAMES.get(row['rank'],'普通')),0),(u'等级:%d 生命:%d'%(row['level'],row['hp']),0),
|
||||
(u'基础攻击:%d—%d'%(row['attack_min'],row['attack_max']),0),
|
||||
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'掉落列表:尚未接入',0)]
|
||||
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'查看服务端掉落列表',('query','mob',vnum))]
|
||||
if self.remote:lines=self.RemoteLines()
|
||||
self.links=lines
|
||||
pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount)
|
||||
self.detailPage=max(0,min(self.detailPage,pages-1))
|
||||
for i,(label,link) in enumerate(lines[self.detailPage*self.rowCount:(self.detailPage+1)*self.rowCount]):
|
||||
self.detailButtons[i].SetText(B(label,max(15,(self.split-40)//12)));self.detailButtons[i].SetEnabled(bool(link));self.detailButtons[i].label.SetPackedFontColor(0xffefdbac if link else 0xffc8bea8);self.detailButtons[i].Show()
|
||||
self.dprev.SetEnabled(self.detailPage>0);self.dnext.SetEnabled(self.detailPage+1<pages)
|
||||
before=bool(self.remoteData and self.remote and self.remote[2]>0)
|
||||
after=bool(self.remoteData and self.remote and self.remote[2]+len(self.remoteData['rows'])<self.remoteData['total'])
|
||||
self.dprev.SetEnabled(self.detailPage>0 or before);self.dnext.SetEnabled(self.detailPage+1<pages or after)
|
||||
|
||||
def QueryRemote(self,kind,subject,offset=0):
|
||||
self.remote=(kind,subject,offset);self.remoteData=None;self.remoteError=None;self.detailPage=0;self.remoteSerial+=1
|
||||
serial=self.remoteSerial
|
||||
self.RefreshDetail()
|
||||
def received(data,error):
|
||||
if self.remoteSerial!=serial or self.remote!=(kind,subject,offset):return
|
||||
self.remoteData=data;self.remoteError=error;self.RefreshDetail()
|
||||
if mt_feature_service.g_service:mt_feature_service.g_service.Request(kind,subject,offset,received)
|
||||
else:received(None,'unsupported')
|
||||
|
||||
def RemoteLines(self):
|
||||
if self.remoteError:
|
||||
text={'unsupported':'服务端尚未启用此查询','unsupported_chest':'此道具未接入通用宝箱预览',
|
||||
'missing':'服务端不存在此目标','timeout':'查询超时,请返回后重试','bad_reply':'查询数据异常,请更新客户端',
|
||||
'rate':'操作过快,请稍后重试','too_many':'来源过多,请按怪物分别查询'}.get(self.remoteError,'查询失败,请返回后重试')
|
||||
return [(U(text),0)]
|
||||
if self.remoteData is None:return [(U('正在向服务器查询…'),0)]
|
||||
data=self.remoteData;kind,subject,offset=self.remote
|
||||
lines=[]
|
||||
if kind=='chest':
|
||||
lines.append((U('各项独立判定,可能多项或无奖励' if data['mode']==11 else '按权重随机抽取一项'),0))
|
||||
lines.append((U('仅预览;条件、钥匙与消耗仍由服务器校验'),0))
|
||||
else:
|
||||
lines.append((U('已加载常规掉落;任务/活动/矿石额外掉落未全覆盖'),0))
|
||||
lines.append((U('列出候选及限制,不表示最终中奖率'),0))
|
||||
for vnum,count,rule,low,high,weight,total,rare in data['rows']:
|
||||
target=0
|
||||
if kind=='chest' and vnum<=7:
|
||||
label={1:U('金币'),2:U('经验'),3:U('召唤怪物'),4:U('减速效果'),5:U('生命损失'),6:U('中毒效果'),7:U('召唤怪物组')}.get(vnum,U('特殊效果'))
|
||||
label+=u' %d'%count
|
||||
elif kind=='sources' and rule<20:
|
||||
row=self.catalogue.mobs.get(vnum);label=row['name'] if row else u'怪物 #%d'%vnum
|
||||
target=('mobs',vnum);label+=u' ×%d'%count
|
||||
else:
|
||||
row=self.catalogue.FindItem(vnum);label=row['name'] if row else u'物品 #%d'%vnum
|
||||
target=('items',vnum)
|
||||
if kind!='sources':label+=u' ×%d'%count
|
||||
lines.append((label,target))
|
||||
if kind=='chest':
|
||||
detail=(u'独立判定:%d/100'%min(weight,100)) if rule==11 else (u'权重:%d / %d'%(weight,total))
|
||||
if rare:detail+=u' 可能附带随机属性'
|
||||
elif rule>=20:detail=U('宝箱来源;点击可继续查看预览')
|
||||
elif rule==0:detail=u'通用掉落;角色等级 %d—%d'%(low,high)
|
||||
elif rule==2:detail=u'击杀组候选权重 %d/%d'%(weight,total)
|
||||
elif rule==3:detail=u'等级组;角色至少 %d 级'%low
|
||||
elif rule==4:detail=U('需掉宝手套或对应增益资格')
|
||||
elif rule==5:detail=U('怪物模板附加掉落')
|
||||
else:detail=U('怪物独立掉落组')
|
||||
lines.append((detail,0))
|
||||
if not data['rows']:lines.append((U('当前查询范围内无配置;不代表所有途径均不存在'),0))
|
||||
lines.append((U('第 %d—%d 条,共 %d 条')%(offset+1 if data['total'] else 0,offset+len(data['rows']),data['total']),0))
|
||||
return lines
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
@@ -243,8 +342,8 @@ class WikiWindow(ui.Window):
|
||||
|
||||
def Destroy(self):
|
||||
self.Hide();self.search.SetReturnEvent(None);self.search.SetEscapeEvent(None)
|
||||
for b in self.listButtons+self.detailButtons+[self.closeButton,self.backButton,self.searchButton,self.itemTab,self.mobTab,self.categoryButton,self.prev,self.next,self.dprev,self.dnext]:b.event=None
|
||||
self.catalogue=None
|
||||
for b in self.listButtons+self.detailButtons+[self.detailsButton,self.dungeonButton,self.calendarButton,self.closeButton,self.backButton,self.searchButton,self.itemTab,self.mobTab,self.categoryButton,self.prev,self.next,self.dprev,self.dnext]:b.event=None
|
||||
self.remoteSerial+=1;self.catalogue=None
|
||||
|
||||
|
||||
class Entry(Action):
|
||||
|
||||
@@ -312,6 +312,10 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
if(MT_WIKI_PYTHON3)
|
||||
add_test(NAME port.wiki_catalogue
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_catalogue.py)
|
||||
add_test(NAME port.feature_timers
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_timers.py)
|
||||
add_test(NAME port.feature_service
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_service.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)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
using namespace mtnet::classic;
|
||||
|
||||
@@ -911,6 +912,48 @@ bool FakeLoginServer::ServeGame(Connection& c, int index)
|
||||
if (!c.Send(chat) || !c.Send(cmd.c_str(), cmd.size() + 1)) return Fail(name + ": biolog reply");
|
||||
}
|
||||
}
|
||||
else if (text.rfind("/mt_features ",0)==0) {
|
||||
std::istringstream in(text);std::string command,kind;unsigned version=0,request=0,subject=0,offset=0;
|
||||
in>>command>>version>>request>>kind>>subject>>offset;
|
||||
auto reply=[&](const std::string& cmd) {
|
||||
GCChatHead chat={HDR_GC_CHAT,static_cast<std::uint16_t>(sizeof(GCChatHead)+cmd.size()+1),5,0,1};
|
||||
return c.Send(chat)&&c.Send(cmd.c_str(),cmd.size()+1);
|
||||
};
|
||||
const std::string r=std::to_string(request);
|
||||
if(kind=="cap") {if(!reply("MTF1 cap "+r+" 1 63 1700000000"))return Fail(name+": feature cap");}
|
||||
else if(kind=="calendar") {
|
||||
if(!reply("MTF1 begin "+r+" calendar 1 0 2 0 1700000000") ||
|
||||
!reply("MTF1 event "+r+" 0 1 1 1700000000 1700003600 0 0 1") ||
|
||||
!reply("MTF1 event "+r+" 1 2 2 1700086400 1700090000 2 0 0") ||
|
||||
!reply("MTF1 end "+r+" 2"))return Fail(name+": calendar");
|
||||
}
|
||||
else if(kind=="dungeons") {
|
||||
const std::pair<const char*,long long> values[]={
|
||||
{"lair_end",1700003600},{"lair_join_end",1700000300},{"lair_phase",1},
|
||||
{"lair_ticket",30179},{"lair_tickets",3},{"lair_boss",2430},{"lair_owned",7},
|
||||
{"lair_alive",1},{"lair_npc",30121},{"lair_map",208},
|
||||
{"weekly_end",1700561600},{"weekly_hunt_end",1700007200},{"weekly_active",1},
|
||||
{"weekly_npc",30122},{"weekly_map",73}};
|
||||
if(!reply("MTF1 begin "+r+" dungeons 0 0 15 0 1700000000"))return Fail(name+": dungeon begin");
|
||||
for(unsigned i=0;i<15;++i)
|
||||
if(!reply("MTF1 stat "+r+" "+std::to_string(i)+" "+values[i].first+" "+std::to_string(values[i].second)))return Fail(name+": dungeon stat");
|
||||
if(!reply("MTF1 end "+r+" 15"))return Fail(name+": dungeon end");
|
||||
}
|
||||
else if(kind=="stats") {
|
||||
if(!reply("MTF1 begin "+r+" stats 0 0 3 0 1700000000") ||
|
||||
!reply("MTF1 stat "+r+" 0 maxhp 1500") || !reply("MTF1 stat "+r+" 1 stone 10") ||
|
||||
!reply("MTF1 stat "+r+" 2 resist_human 4") || !reply("MTF1 end "+r+" 3"))return Fail(name+": feature stats");
|
||||
} else {
|
||||
unsigned total=kind=="mob"?10:kind=="chest"?2:1;
|
||||
if(!reply("MTF1 begin "+r+" "+kind+" "+std::to_string(subject)+" "+std::to_string(offset)+" "+std::to_string(total)+" "+(kind=="chest"?"10":"0")+" 1700000000"))return Fail(name+": feature begin");
|
||||
unsigned sent=0;
|
||||
for(unsigned i=offset;i<total && sent<8;++i,++sent) {
|
||||
std::string row=kind=="mob"?"30006 1 1 0 0 100 0 0":kind=="sources"?"101 1 0 1 30 100 0 0":i==0?"19 1 10 0 0 80 100 0":"1 500 10 0 0 20 100 0";
|
||||
if(!reply("MTF1 row "+r+" "+std::to_string(i)+" "+row))return Fail(name+": feature row");
|
||||
}
|
||||
if(!reply("MTF1 end "+r+" "+std::to_string(sent)))return Fail(name+": feature end");
|
||||
}
|
||||
}
|
||||
else if (text == "/server_bonuses") {
|
||||
const std::string cmd = "ServerBonuses 200 200 200 200 200 0 200 0";
|
||||
GCChatHead chat = {HDR_GC_CHAT, static_cast<std::uint16_t>(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1};
|
||||
|
||||
@@ -560,7 +560,7 @@ int main(int argc, char** argv)
|
||||
if (!live && std::getenv("MT_TEST_WIKI")) {
|
||||
CHECK(py("import mt_wiki, mt_wiki_data, mt_biolog, player\n"
|
||||
"assert mt_wiki.g_window and mt_wiki.g_entry\n"
|
||||
"assert not mt_wiki.CAPABILITIES['live_drops']\n"
|
||||
"assert mt_wiki.ServerCapabilities()['live_drops'] is None\n"
|
||||
"mt_wiki.g_entry.OnUpdate()"));
|
||||
std::string xy;
|
||||
CHECK(PythonBoot::Evaluate("'%d %d' % mt_wiki.g_entry.GetGlobalPosition()", &xy, nullptr));
|
||||
@@ -605,6 +605,41 @@ int main(int argc, char** argv)
|
||||
"_missing.Open()\nassert _missing.error and not _missing.catalogue\n"
|
||||
"_missing.Destroy()\nmt_wiki.PATH=_saved_path"));
|
||||
PythonBoot::SetUISize(800,600);
|
||||
CHECK(py("_wiki.Open(30006)\n_wiki.QueryRemote('sources',30006)"));
|
||||
CHECK(pump_until(7,[]{return py_true("_wiki.remoteData is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['live_drops']\nassert _wiki.remoteData['rows'][0][0]==101\n"
|
||||
"_wiki.Follow(next(i for i,p in enumerate(_wiki.links) if isinstance(p[1],tuple) and p[1][0]=='mobs'))\n"
|
||||
"assert _wiki.selected==('mobs',101)\n_wiki.QueryRemote('mob',101)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None");}));
|
||||
CHECK(py("assert _wiki.remoteData['total']==10 and len(_wiki.remoteData['rows'])==8\n_wiki.QueryRemote('mob',101,8)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None and _wiki.remoteData['offset']==8");}));
|
||||
CHECK(py("assert len(_wiki.remoteData['rows'])==2\n_wiki.Back()\n_wiki.QueryRemote('chest',50011)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None and _wiki.remoteData['kind']=='chest'");}));
|
||||
CHECK(py("assert _wiki.remoteData['mode']==10\nassert _wiki.remoteData['rows'][1][0]==1\n"
|
||||
"assert any(p[0].startswith(u'金币') for p in _wiki.links)\n_wiki.Hide()\n"
|
||||
"import mt_details\nmt_details.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_details.g_window.values is not None");}));
|
||||
CHECK(py("assert mt_details.g_window.values['stone']==10\n"
|
||||
"assert mt_details.g_window.values['maxhp']==1500\n"
|
||||
"assert mt_details.g_window.values['resist_human']==4\nmt_details.g_window.Hide()"));
|
||||
CHECK(count_syserr("mt_feature_service: install failed")==0);
|
||||
CHECK(py("import mt_dungeons\nmt_dungeons.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_dungeons.g_window.values is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['dungeons']\n"
|
||||
"_dungeon=mt_dungeons.g_window\nassert _dungeon.values['lair_tickets']==3\n"
|
||||
"assert _dungeon.values['weekly_end']>_dungeon.values['lair_end']\n"
|
||||
"_dungeon.Remind()\nassert _dungeon.reminder[0]==0\n"
|
||||
"_dungeon.Select(1)\n_dungeon.Entry()\n_dungeon.Hide()"));
|
||||
CHECK(py("import mt_calendar\nmt_calendar.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_calendar.g_window.data is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['calendar']\n"
|
||||
"_calendar=mt_calendar.g_window\nassert _calendar.data['rows'][0][6]==1\n"
|
||||
"_calendar.Watch(1)\nassert _calendar.watch['id']==2\n"
|
||||
"_calendar.Remind()\nassert _calendar.watch is None\n_calendar.Hide()"));
|
||||
CHECK(count_syserr("mt_calendar: install failed")==0);
|
||||
CHECK(count_syserr("mt_dungeons: install failed")==0);
|
||||
CHECK(count_syserr("mt_details: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki_entries: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki_data: install failed")==0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import sys
|
||||
import ast
|
||||
import re
|
||||
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 ServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.time=1.;self.sent=[];self.replies=[]
|
||||
app=types.ModuleType('app');app.GetTime=lambda:self.time
|
||||
net=types.ModuleType('net');net.SendChatPacket=self.sent.append
|
||||
game=types.ModuleType('game');game.GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,BINARY_ServerCommand_Run=lambda s,line:7))
|
||||
ui=types.ModuleType('ui');ui.Window=object
|
||||
data=types.ModuleType('mt_wiki_data');exec(script('WikiDataScript.inc'),data.__dict__)
|
||||
self.previous={n:sys.modules.get(n) for n in ('app','net','game','ui','mt_wiki_data')}
|
||||
sys.modules.update(app=app,net=net,game=game,ui=ui,mt_wiki_data=data)
|
||||
self.ns={};exec(script('FeatureServiceScript.inc'),self.ns)
|
||||
self.s=self.ns['Service']()
|
||||
|
||||
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 request(self,kind='mob',subject=101,offset=0):
|
||||
rid=self.s.Request(kind,subject,offset,lambda result,error:self.replies.append((result,error)))
|
||||
self.time+=.6;self.s.Tick()
|
||||
if self.s.pending['kind']=='cap':
|
||||
cap=self.s.pending['id'];self.s.Receive('MTF1 cap %d 1 15 1700000000'%cap)
|
||||
self.time+=.4;self.s.Tick()
|
||||
return rid
|
||||
|
||||
def test_capability_handshake_atomic_page_and_stale_replies(self):
|
||||
rid=self.request()
|
||||
self.assertEqual(len(self.sent),2)
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 1 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 row %d 0 30006 1 1 0 0 100 0 0'%(rid+100))
|
||||
self.assertFalse(self.replies)
|
||||
self.s.Receive('MTF1 row %d 0 30006 1 1 0 0 100 0 0'%rid)
|
||||
self.assertFalse(self.replies)
|
||||
self.s.Receive('MTF1 end %d 1'%rid)
|
||||
self.assertEqual(self.replies[0][0]['rows'][0][0],30006)
|
||||
self.assertIsNone(self.replies[0][1])
|
||||
|
||||
def test_old_server_timeout_is_unsupported_not_empty_results(self):
|
||||
self.s.Request('mob',101,0,lambda d,e:self.replies.append((d,e)))
|
||||
self.time+=1;self.s.Tick();self.time+=5;self.s.Tick();self.time+=1;self.s.Tick()
|
||||
self.assertEqual(self.replies,[(None,'unsupported')])
|
||||
self.assertEqual(len(self.sent),1)
|
||||
|
||||
def test_partial_duplicate_and_bad_numeric_rejected(self):
|
||||
for bad in ('MTF1 end {r} 1','MTF1 row {r} 1 19 1 1 0 0 1 0 0','MTF1 row {r} 0 19 -1 1 0 0 1 0 0'):
|
||||
rid=self.request()
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 1 0 1700000000'%rid)
|
||||
self.s.Receive(bad.format(r=rid))
|
||||
self.assertEqual(self.replies[-1],(None,'bad_reply'))
|
||||
|
||||
def test_stats_signed_values_and_complete_snapshot(self):
|
||||
rid=self.request('stats',0)
|
||||
self.s.Receive('MTF1 begin %d stats 0 0 2 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 stat %d 0 stone -10'%rid);self.s.Receive('MTF1 stat %d 1 maxhp 1500'%rid)
|
||||
self.s.Receive('MTF1 end %d 2'%rid)
|
||||
self.assertEqual(dict(self.replies[-1][0]['rows']),dict(stone=-10,maxhp=1500))
|
||||
|
||||
def test_rate_retries_bounded_and_close_cancels_callbacks(self):
|
||||
rid=self.request()
|
||||
for i in range(3):
|
||||
self.s.Receive('MTF1 error %d rate'%rid)
|
||||
self.time+=1;self.s.Tick()
|
||||
self.assertEqual(self.replies[-1],(None,'rate'))
|
||||
rid=self.request();self.s.Close();self.s.Receive('MTF1 end %d 0'%rid)
|
||||
self.assertEqual(len(self.replies),1)
|
||||
|
||||
def test_stats_keys_match_current_server_not_ava2_point_numbers(self):
|
||||
path=ROOT.parent/'metin2-server/src/server/game/src/port_features.h'
|
||||
if not path.exists():self.skipTest('server checkout not installed')
|
||||
tree=ast.parse(script('DetailsScript.inc'))
|
||||
layout=next(ast.literal_eval(n.value) for n in tree.body if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='STAT_LAYOUT' for t in n.targets))
|
||||
keys=re.findall(r'\{"([a-z_]+)",POINT_[A-Z_]+\}',path.read_text())
|
||||
self.assertEqual(set(keys),{r[1] for r in layout})
|
||||
self.assertEqual(len(keys),len(set(keys)))
|
||||
|
||||
def test_dungeon_capability_snapshot_and_partial_rejection(self):
|
||||
self.s.mask=31
|
||||
rows=dict(lair_end=1700003600,lair_join_end=1700000300,lair_phase=1,lair_ticket=30179,
|
||||
lair_tickets=3,lair_boss=2430,lair_owned=7,lair_alive=1,lair_npc=30121,lair_map=208)
|
||||
for valid in (True,False):
|
||||
rid=self.request('dungeons',0)
|
||||
self.assertTrue(self.sent[-1].endswith(' dungeons'))
|
||||
values=list(rows.items()) if valid else list(rows.items())[:-1]
|
||||
self.s.Receive('MTF1 begin %d dungeons 0 0 %d 0 1700000000'%(rid,len(values)))
|
||||
for i,(k,v) in enumerate(values):self.s.Receive('MTF1 stat %d %d %s %d'%(rid,i,k,v))
|
||||
self.s.Receive('MTF1 end %d %d'%(rid,len(values)))
|
||||
if valid:self.assertEqual(dict(self.replies[-1][0]['rows']),rows)
|
||||
else:self.assertEqual(self.replies[-1],(None,'bad_reply'))
|
||||
|
||||
def test_calendar_real_interval_rows_and_invalid_scope(self):
|
||||
self.s.mask=63
|
||||
for scope,expected in ((0,None),(256,'bad_reply')):
|
||||
rid=self.request('calendar',1)
|
||||
self.s.Receive('MTF1 begin %d calendar 1 0 1 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 event %d 0 1 1 1700000000 1700003600 %d 0 1'%(rid,scope))
|
||||
self.s.Receive('MTF1 end %d 1'%rid)
|
||||
self.assertEqual(self.replies[-1][1],expected)
|
||||
if expected is None:self.assertEqual(self.replies[-1][0]['rows'][0][1],1)
|
||||
|
||||
def test_future_capability_bits_do_not_disable_known_features(self):
|
||||
self.s.Request('stats',0,0,lambda d,e:None)
|
||||
self.time+=1;self.s.Tick();rid=self.s.pending['id']
|
||||
self.s.Receive('MTF1 cap %d 1 65551 1700000000'%rid)
|
||||
self.assertEqual(self.s.mask,65551)
|
||||
self.time+=1;self.s.Tick()
|
||||
self.assertTrue(self.sent[-1].endswith(' stats'))
|
||||
|
||||
def test_empty_page_distinct_from_unsupported_and_invalid_requests(self):
|
||||
rid=self.request()
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 0 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 end %d 0'%rid)
|
||||
self.assertEqual(self.replies[-1][0]['rows'],[])
|
||||
self.assertIsNone(self.replies[-1][1])
|
||||
self.s.Request('delete',0,0,lambda d,e:self.replies.append((d,e)))
|
||||
self.assertEqual(self.replies[-1],(None,'invalid'))
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Test the actual embedded UI timer methods with a deterministic clock (no rendering mocks)."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import types
|
||||
import unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def window_class(filename,name,clock,messages):
|
||||
text=(ROOT/'src/platform/ScriptLib'/filename).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||
tree=ast.parse(text)
|
||||
tree.body=[n for n in tree.body if isinstance(n,(ast.ClassDef,ast.FunctionDef))]
|
||||
ns=dict(ui=types.SimpleNamespace(Window=object),app=types.SimpleNamespace(GetTime=lambda:clock[0]),
|
||||
chat=types.SimpleNamespace(CHAT_TYPE_INFO=1,AppendChat=lambda kind,text:messages.append(text)),
|
||||
L=lambda text:text,NAMES={1:'moon',2:'sock'})
|
||||
exec(compile(tree,filename,'exec'),ns)
|
||||
return ns[name]
|
||||
|
||||
|
||||
class TimerTests(unittest.TestCase):
|
||||
def setUp(self):self.clock=[10.];self.messages=[]
|
||||
|
||||
def test_dungeon_reminder_only_confirmed_snapshot_and_extended_deadline(self):
|
||||
cls=window_class('DungeonScript.inc','DungeonWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.reminder=(0,100);w.stale=True;w.values={'lair_end':100};w.serverTime=101;w.received=10.
|
||||
w.CheckReminder();self.assertFalse(self.messages)
|
||||
w.stale=False;w.values['lair_end']=200;w.CheckReminder()
|
||||
self.assertEqual(w.reminder,(0,200));self.assertFalse(self.messages)
|
||||
w.serverTime=200;w.CheckReminder();self.assertFalse(self.messages) # strict original deadline
|
||||
w.serverTime=201;w.CheckReminder();w.CheckReminder()
|
||||
self.assertEqual(len(self.messages),1);self.assertIsNone(w.reminder)
|
||||
|
||||
def test_dungeon_sleep_invalidates_display_and_requeries_before_notification(self):
|
||||
cls=window_class('DungeonScript.inc','DungeonWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.lastTick=1.;w.stale=False;w.nextQuery=999;w.pending=False;w.values={};w.reminder=(0,5)
|
||||
w.IsShow=lambda:False;calls=[];w.Query=lambda:calls.append('query')
|
||||
w.Now=lambda:1000
|
||||
w.OnUpdate()
|
||||
self.assertTrue(w.stale);self.assertTrue(calls);self.assertFalse(self.messages)
|
||||
|
||||
def test_calendar_actual_scope_and_start_end_notifications_once(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.offset=0;w.watch=dict(id=1,began=False,offset=0)
|
||||
w.data=dict(time=100,rows=[[1,1,100,200,2,0,0]])
|
||||
w.Notify();self.assertFalse(self.messages) # other channel cannot trigger start
|
||||
w.data['rows'][0][-1]=1;w.Notify();w.Notify();self.assertEqual(len(self.messages),1)
|
||||
w.data['time']=200;w.Notify();w.Notify();self.assertEqual(len(self.messages),2);self.assertIsNone(w.watch)
|
||||
|
||||
def test_short_screen_two_row_pages_do_not_skip_server_rows(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.pending=False;w.offset=0;w.rowPage=0;w.pageSize=2;w.data=dict(total=10)
|
||||
calls=[];w.Refresh=lambda:None;w.Query=lambda:calls.append(w.offset)
|
||||
seen=[]
|
||||
for i in range(5):
|
||||
seen.append(w.offset+w.rowPage*w.pageSize)
|
||||
w.Page(1)
|
||||
if w.data is None:w.data=dict(total=10)
|
||||
self.assertEqual(seen,[0,2,4,6,8]);self.assertEqual(calls,[8])
|
||||
|
||||
def test_calendar_removed_event_cancels_without_fake_end(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.offset=0;w.watch=dict(id=1,began=True,offset=0);w.data=dict(time=500,rows=[])
|
||||
w.Notify();self.assertIsNone(w.watch);self.assertFalse(self.messages)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -17,7 +17,7 @@ python3 tests/wiki/test_catalogue.py
|
||||
|
||||
`mt_features` 为标准库调用及显式 level 导入保留原生 Python 导入语义;原 packed 游戏脚本的其他四参数导入继续原路径,原始 system.py 字节不改。中文搜索通过 `player.FeatureLocaleToUTF8` 使用同一套原生代码页映射,不要求 Python CJK 扩展。
|
||||
|
||||
能力声明 `CAPABILITIES` 当前只打开静态目录;尚未实现服务端能力握手、实时查询协议、掉落/宝箱或模型预览。UI 明示缺失项。升级展示为配方基础值,NPC/道具修正后的实际结果以精炼窗口为准。
|
||||
`mt_wiki.ServerCapabilities()` 区分未知/启用状态;只读能力协商、掉落/来源/宝箱和属性查询见 docs/AVA2-READONLY-FEATURES.md。模型预览仍未实现,旧服未启用时显示未支持。升级展示为配方基础值,NPC/道具修正后的实际结果以精炼窗口为准。
|
||||
|
||||
集成测试(本机完整资源可用时):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user