From 4e2d39b61248a3d04e922cb28edcbd97d394d4a9 Mon Sep 17 00:00:00 2001 From: shen <> Date: Thu, 15 Oct 2026 02:28:01 -0700 Subject: [PATCH] Add feature queries, dungeon information and event calendar UI --- docs/AVA2-DUNGEON-CALENDAR.md | 34 ++++ docs/AVA2-FEATURE-MIGRATION-PLAN.md | 4 + docs/AVA2-READONLY-FEATURES.md | 45 +++++ docs/PORT-PLAN.md | 6 + src/platform/ScriptLib/CalendarScript.inc | 173 +++++++++++++++++ src/platform/ScriptLib/DetailsScript.inc | 135 +++++++++++++ src/platform/ScriptLib/DungeonScript.inc | 183 ++++++++++++++++++ .../ScriptLib/FeatureServiceScript.inc | 157 +++++++++++++++ src/platform/ScriptLib/PythonBoot.cpp | 10 + src/platform/ScriptLib/WikiEntryScript.inc | 111 +++++++++++ src/platform/ScriptLib/WikiScript.inc | 117 ++++++++++- src/port/CMakeLists.txt | 4 + tests/port/port_login_flow_server.cpp | 43 ++++ tests/port/port_login_flow_test.cpp | 37 +++- tests/wiki/test_service.py | 131 +++++++++++++ tests/wiki/test_timers.py | 67 +++++++ tools/wiki/README.md | 2 +- 17 files changed, 1248 insertions(+), 11 deletions(-) create mode 100644 docs/AVA2-DUNGEON-CALENDAR.md create mode 100644 docs/AVA2-READONLY-FEATURES.md create mode 100644 src/platform/ScriptLib/CalendarScript.inc create mode 100644 src/platform/ScriptLib/DetailsScript.inc create mode 100644 src/platform/ScriptLib/DungeonScript.inc create mode 100644 src/platform/ScriptLib/FeatureServiceScript.inc create mode 100644 src/platform/ScriptLib/WikiEntryScript.inc create mode 100644 tests/wiki/test_service.py create mode 100644 tests/wiki/test_timers.py diff --git a/docs/AVA2-DUNGEON-CALENDAR.md b/docs/AVA2-DUNGEON-CALENDAR.md new file mode 100644 index 00000000..291a374f --- /dev/null +++ b/docs/AVA2-DUNGEON-CALENDAR.md @@ -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 任务负责,查询就绪不等于保证入场。自动洗练/属性锁定、战令任务及领奖持久化、百科模型预览、批量开箱仍未实现,不能标记整体迁移完成。 diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index 9877e872..7ac676d8 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -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。 diff --git a/docs/AVA2-READONLY-FEATURES.md b/docs/AVA2-READONLY-FEATURES.md new file mode 100644 index 00000000..5ea9bbfe --- /dev/null +++ b/docs/AVA2-READONLY-FEATURES.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 模型预览、批量开箱仍未实现。完整剩余范围见迁移计划,不能把这批查询实现记为全部七项已完成。 diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 953f9429..e3992671 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -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。 diff --git a/src/platform/ScriptLib/CalendarScript.inc b/src/platform/ScriptLib/CalendarScript.inc new file mode 100644 index 00000000..7aee6934 --- /dev/null +++ b/src/platform/ScriptLib/CalendarScript.inc @@ -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 now0);self.next.SetEnabled(not self.pending and absolute+self.pageSize=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=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"; diff --git a/src/platform/ScriptLib/DetailsScript.inc b/src/platform/ScriptLib/DetailsScript.inc new file mode 100644 index 00000000..67757dc6 --- /dev/null +++ b/src/platform/ScriptLib/DetailsScript.inc @@ -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=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"; diff --git a/src/platform/ScriptLib/DungeonScript.inc b/src/platform/ScriptLib/DungeonScript.inc new file mode 100644 index 00000000..bedeaedb --- /dev/null +++ b/src/platform/ScriptLib/DungeonScript.inc @@ -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 iend: + 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"; diff --git a/src/platform/ScriptLib/FeatureServiceScript.inc b/src/platform/ScriptLib/FeatureServiceScript.inc new file mode 100644 index 00000000..e640020d --- /dev/null +++ b/src/platform/ScriptLib/FeatureServiceScript.inc @@ -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 now512: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=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"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 66de2a0d..8ac6423d 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -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); } diff --git a/src/platform/ScriptLib/WikiEntryScript.inc b/src/platform/ScriptLib/WikiEntryScript.inc new file mode 100644 index 00000000..e79dd6b0 --- /dev/null +++ b/src/platform/ScriptLib/WikiEntryScript.inc @@ -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"; diff --git a/src/platform/ScriptLib/WikiScript.inc b/src/platform/ScriptLib/WikiScript.inc index e94ae690..f3ace6d4 100644 --- a/src/platform/ScriptLib/WikiScript.inc +++ b/src/platform/ScriptLib/WikiScript.inc @@ -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'])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 offset0);self.dnext.SetEnabled(self.detailPage+10) + after=bool(self.remoteData and self.remote and self.remote[2]+len(self.remoteData['rows'])0 or before);self.dnext.SetEnabled(self.detailPage+1=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): diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 82acf65f..b791fadd 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -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) diff --git a/tests/port/port_login_flow_server.cpp b/tests/port/port_login_flow_server.cpp index b40ae652..a080492b 100644 --- a/tests/port/port_login_flow_server.cpp +++ b/tests/port/port_login_flow_server.cpp @@ -15,6 +15,7 @@ #include #include #include +#include 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(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 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(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1}; diff --git a/tests/port/port_login_flow_test.cpp b/tests/port/port_login_flow_test.cpp index 413e0343..f4475178 100644 --- a/tests/port/port_login_flow_test.cpp +++ b/tests/port/port_login_flow_test.cpp @@ -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); } diff --git a/tests/wiki/test_service.py b/tests/wiki/test_service.py new file mode 100644 index 00000000..b15fee92 --- /dev/null +++ b/tests/wiki/test_service.py @@ -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() diff --git a/tests/wiki/test_timers.py b/tests/wiki/test_timers.py new file mode 100644 index 00000000..c761db68 --- /dev/null +++ b/tests/wiki/test_timers.py @@ -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() diff --git a/tools/wiki/README.md b/tools/wiki/README.md index b5ffb950..9d74dbd8 100644 --- a/tools/wiki/README.md +++ b/tools/wiki/README.md @@ -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/道具修正后的实际结果以精炼窗口为准。 集成测试(本机完整资源可用时):