Add searchable wiki catalogue and biology material navigation
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Wiki TSV keeps a final TAB for recipes with an empty materials column.
|
||||
data/wiki/catalogue.txt whitespace=-blank-at-eol
|
||||
@@ -52,6 +52,8 @@ def zh_inputs_digest(client_base):
|
||||
inputs = [REPO / "scripts/build_zh_locale.py", client_base / "pack/Index"]
|
||||
inputs += [REPO / "tools/server/biolog" / name for name in ("catalog.json", "shop.json", "make_assets.py")]
|
||||
inputs.append(REPO / "tests/port/biolog_proto_patch.cpp")
|
||||
inputs += sorted((REPO / "data/wiki").glob("*"))
|
||||
inputs.append(REPO / "tools/wiki/build_catalogue.py")
|
||||
inputs += sorted(p for p in (REPO / "data/zh_40250").rglob("*") if p.is_file())
|
||||
for path in inputs:
|
||||
h.update(str(path.relative_to(REPO)).encode())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Wiki 公开资料包 v1
|
||||
|
||||
由 `tools/wiki/build_catalogue.py` 生成。仅含物品/怪物模板、升级配方与已有中文名称,不包含玩家资料或游戏图片。
|
||||
|
||||
`catalogue.txt` 使用 UTF-8、LF、TAB 分隔,无可执行内容。第一行为 `MTWIKI<TAB>1<TAB>payload_sha256`,SHA 对第一行之后的全部字节计算。完整清单与输入/生成器/中文表哈希在 manifest.json。
|
||||
|
||||
| 行类型 | 字段(按顺序) |
|
||||
|---|---|
|
||||
| I | 标记、ID、范围末端、名称、类型、子类、最低等级、占格、升级目标 ID、配方 ID |
|
||||
| M | 标记、ID、名称、类型、级别分类、等级、生命、最小攻击、最大攻击、经验、防御 |
|
||||
| R | 标记、配方 ID、基础费用、基础成功率、逗号分隔的 `材料ID:数量` |
|
||||
|
||||
限额:2 MiB、30,000 条记录、每单元格 256 字符;升级材料最多五种;客户端分页每次最多 12 项。重复 ID、重叠范围、未知 schema、非法数值和文本标记均拒绝,界面显示更新资料包提示。
|
||||
|
||||
这里是配置快照,不是运行中服务器状态证明:重新加载前的文件修改可能与实际内存不同。当前未声明实时掉落、最终精炼概率、宝箱、模型预览等能力;后续接入独立协议时将与静态 schema 分别版本化。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"catalogue_sha256": "7efea0e88002d717c5d29174cf264a4ba06b0594561d8d25bd6ab7c091da9159",
|
||||
"source_sha256": {
|
||||
"item_proto.txt": "bf3d2c9d72719f1c222266042766cbe2f3fff8f0951666344181b6add0a31009",
|
||||
"item_names.txt": "a6888dfdb0be64f8c8112445b4d54e399a0f8e6ca29799ba33b3bb4160adad2a",
|
||||
"mob_proto.txt": "883a9f877f140e9baead30346b629f2f2c0d7c9c1f538febb2e17f28c9e446ce",
|
||||
"mob_names.txt": "2c7a5275983268b668e21b9f4e39f6d58ee6fde04ada1cbb4a1fd8315bfad391",
|
||||
"refine.tsv": "df6d14fb31b7e281434c927cf96413c6471f0c6b93da752d1b20e1bd082990db"
|
||||
},
|
||||
"items": 5756,
|
||||
"monsters": 763,
|
||||
"refine_recipes": 407,
|
||||
"source": "203 public server tables; no player data",
|
||||
"generator_sha256": "9c4e1b6069179c36c080a1136d65cbe52a8e44b3577fb8391e8404f5162ef40b",
|
||||
"localization_sha256": {
|
||||
"item_names_zh.txt": "5604985d7f11039f3d3e38d4e2c07995e80ca3e4f6deb01fe57cd772e9cd0f70",
|
||||
"item_names_zh_overlay.txt": "5d8396f19e7184fafd691b7a255b328237bddc1cdfda8d3cf8a7b074eb528f83",
|
||||
"mob_names_zh.txt": "fdbfe4b3f3367d5842c0e9bbe0b5e5ea2dbe99fb6e566ef5789f65d3794eab5c",
|
||||
"mob_names_zh_overlay.txt": "7c44a5f6cff5c3c27c13cc4c7be91d619c20c67a8e091222e13ed392ea18d00b"
|
||||
},
|
||||
"scope": "template catalogue; acquisition and runtime rates not yet queried"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# AVA2 功能迁移计划
|
||||
|
||||
日期:2026-10-14。本文是计划,不代表功能已实现或授权部署。
|
||||
|
||||
## 范围与原则
|
||||
|
||||
- 目标客户端:metin2-client;目标服务端:metin2-server,来自 203 的源码快照。
|
||||
- AVA2 原始源码与 ava2-client 作为只读参考;不迁移整个 AVA2 root、协议或数据库。
|
||||
- 以 AVA2 的活动实现为功能依据;面向当前服务器的数据和物品规则适配,移动端布局沿用现有单层最多六技能方案。
|
||||
- 生物学家系统保持现有实现;百科可提供其材料查询入口。
|
||||
- 每阶段分别提交客户端、服务端改动;契约注明对应版本。数据库变更提供升级、验证及回退方案。
|
||||
- 不默认迁移 AVA2 的 8 sockets、15 attrs、64 位货币、征服者等级、装备槽或新副本内容。
|
||||
- 开发先用隔离测试运行目录与测试账号;上线前备份相关二进制、配置与新增数据,避免恢复整个数据库覆盖玩家后续进度。
|
||||
|
||||
## 阶段 0:共同基础与活动代码确认
|
||||
|
||||
1. 为每项建立 AVA2 入口、启用宏、Python/C++ 方法、网络请求、服务器处理、数据配置、数据库字段的追踪清单,记录源码哈希。
|
||||
2. 识别 old/copia/ex 等多版本实现,依据实际 import、调用、编译路径确定基准。日历与战令尤其不能按文件名任选一套。
|
||||
3. 定义版本化功能能力和扩展消息契约。检查当前包头占用,统一固定宽度字段、长度上限、分页、错误码、请求标识;不直接复制 AVA2 的包头编号。
|
||||
4. 查询与写操作分离。服务器确认道具消耗、奖励、任务进度、冷却和洗练结果;客户端只展示和发送请求。
|
||||
5. 资料使用当前服务器配置导出,包含版本/哈希与中文名;客户端打包和 Android 增量同步都纳入新资源。
|
||||
6. 公用分页、物品提示、搜索、空态、错误提示、触屏滚动、关闭与重连处理。小地图旁使用收纳入口,避免七个窗口入口长期遮挡画面。
|
||||
|
||||
验收:不支持功能的服务端不会触发客户端解析错误;未知/越界消息被拒绝;查询不阻塞游戏主循环;旧功能基本流程回归通过。
|
||||
|
||||
## 阶段 1:游戏百科
|
||||
|
||||
参考:uiwiki.py、wikiui.py、PythonWiki;模型预览另参考 RenderTarget。
|
||||
|
||||
客户端:物品/怪物分类、中文及 ID 搜索、详情、升级材料链、关联跳转;材料详情可进入掉落来源,生物学家材料可进入百科。
|
||||
服务端/工具:由当前 item_proto、mob_proto、refine 配置及实际可用物品清单生成目录;缺少或禁用内容不伪装可获取。
|
||||
数据:静态版本化资料包优先,服务端只补实时/受权限影响的数据;分页索引避免一次创建全部 UI。
|
||||
首批交付:2D 图标与文字查询;第二子批移植 Vulkan 离屏模型预览、独立相机、对象生命周期及尺寸适配,完成后才称 AVA2 对应预览已迁移。
|
||||
验收:升级链无错链、搜索可定位现有物品、缺失图标可回退、重复开关无资源泄漏;模型预览不污染主场景状态。
|
||||
|
||||
## 阶段 2:怪物掉落查询
|
||||
|
||||
参考:uitarget.py 的 InfoBoard、PythonDropItem、目标信息及掉落消息路径。
|
||||
|
||||
客户端:目标栏信息按钮、怪物详情掉落列表、按物品反查来源、与百科互跳。
|
||||
服务端:复用实际加载的掉落配置/解析结果,区分怪物组掉落、等级/任务/活动条件和特殊代码发奖;不只读取一份文本便声称覆盖所有来源。
|
||||
数据:区分数量、候选权重、基础概率、附加条件;未解析的来源明确标为未覆盖。动态倍率影响的最终概率不按静态权重伪造百分比。
|
||||
验收:按实际掉落分支选样本,对照等级/活动/任务条件;仅查询不改变掉落 RNG 或消耗状态;生物学家材料来源可反查。
|
||||
|
||||
## 阶段 3:宝箱预览
|
||||
|
||||
参考:uiChestDrop.py、uichestdropinfo.py 与服务器 special item group / 开箱路径。
|
||||
|
||||
客户端:物品提示进入预览、分页展示奖励与数量,绑定当前物品 ID 和实时背包位置;先交付只读预览。
|
||||
服务端:从真实开箱奖励组构建预览,标注“全部获得”“随机其一”“多次抽取”等真实规则;不能将权重直接当独立中奖率。
|
||||
完整交互子批:若 AVA2 活动入口包含数量选择/批量开箱,跟随其限制移植;校验背包容量、绑定、钥匙、金币及失败消费顺序。
|
||||
验收:预览与实际奖励候选集一致;预览无消耗;物品移动后旧请求不作用于另一件物品;批量不足、背包满、重复点击不会超额扣除。
|
||||
|
||||
## 阶段 4:角色详细属性面板
|
||||
|
||||
参考:uicharacterdetails.py / uicharacterdetails2.py,先核实实际入口与属性枚举。
|
||||
|
||||
客户端:按攻击、防御、职业/种族加成、抗性、速度、恢复等分类;中文搜索、单位、说明和已支持值展示。
|
||||
服务端:返回同一角色状态版本的有效属性快照,补充当前协议未同步的对石、对 Boss、半人抗性等值。
|
||||
边界:当前生物学家怪物加成对 Boss 的特殊处理必须在说明中体现;展示累计属性不等于承诺所有目标上的最终伤害百分比。首批不虚构“装备/任务/活动”分项来源。
|
||||
验收:穿脱装备、切换增益、生物学家领奖与重选后更新一致;死亡/复活、重登、切线不残留旧快照;显示值对应实际战斗计算使用值。
|
||||
|
||||
## 阶段 5:副本信息与冷却提示
|
||||
|
||||
参考:uidungeoninfo.py、uidungeontimer.py、PythonDungeonInfo、dungeon_info 与任务 Lua 接口。
|
||||
|
||||
客户端:副本列表、等级/组队要求、门票提示、冷却、入口按钮和完成提醒;仅列当前服已安装并有真实入口的副本。
|
||||
服务端:为每个副本建立任务标识、入口处理、冷却字段与写入时机映射;首次加载读取真实任务状态,禁止另建一套互不相通的客户端计时器。
|
||||
计时:服务器下发时间基准与到期时间,客户端只渲染倒计时;休眠恢复/重连重新同步。明确角色/账号、队伍成员及频道间的冷却作用域。
|
||||
持久化:优先复用现有 quest flags;确需新表时按角色/账号与副本键设计。通关次数、最快时间、排名分后续子批,不能用零占位声称已接入。
|
||||
验收:提前进入被服务器拒绝;门票消耗与传送顺序正确;重复请求不重复扣票;重登、重启与跨频道后冷却一致。
|
||||
|
||||
## 阶段 6:自动洗练与属性锁定
|
||||
|
||||
参考:uiswitchbot.py、PythonSwitchbot、new_switchbot,以及 ENABLE_LOCK_ATTR 对应的 item_attribute / char_item / item_addon。
|
||||
|
||||
前置核查:明确 AVA2 活动版本的工作槽数、候选组合数、普通/稀有属性适用范围、停止条件、材料与锁定上限。原版锁信息存于扩展属性区域,当前七属性布局不能直接容纳。
|
||||
存储决策:先编写字段映射与兼容测试。优先评估独立锁定元数据/表是否能完整承载其语义;若不能,必须单独规划物品结构、DB 缓存与网络格式升级,不能直接写入现有属性或 socket。
|
||||
客户端:放入装备、目标属性及阈值、候选组合、锁定状态、开始/停止、剩余材料与停止原因;触屏可选列表代替细小输入控件。
|
||||
服务端:在权威物品对象上执行受限频率任务;按物品唯一 ID 绑定,阻止运行中交易、出售、仓库转移和替换目标;统一所有手动/自动洗练路径的锁定行为。
|
||||
执行规则:每次洗练原子检查状态、消耗、生成、保存;同一组合内条件如何组合、多个候选如何匹配,严格按已确认 AVA2 规则。客户端不决定随机值或自行发批量循环洗练。
|
||||
断线策略:核实 AVA2 行为并明确恢复规则;不默认承诺离线洗练。断线/重启后的消耗和任务状态必须可核对。
|
||||
验收:满足目标即停止;材料耗尽、非法属性、锁定上限、平均/技能伤害特殊属性、物品转移、重复开始和重启恢复都不复制物品、不重复扣费、不覆盖其他装备。
|
||||
|
||||
## 阶段 7A:活动日历
|
||||
|
||||
参考:interfacemodule.py 中实际 uiEventCalendarNew 入口、uieventcalendarnew.py、char_event_calendar;旧 uicalendar 有入口被注释,不默认全部移植。
|
||||
|
||||
客户端:月历/列表、活动详情、进行中状态、开始/结束提醒;显示服务器时区及当前活动作用频道/阵营。
|
||||
服务端:唯一活动配置与调度器,同一配置驱动日历和真实倍率/掉落效果;仅支持当前服已实现的活动类型,不因日历有图标就导入对应玩法。
|
||||
运维:配置校验、重新加载、重启后的活动恢复和结束清理,避免每个 game core 重复发奖。
|
||||
验收:月末、跨日、服务器时间调整、断线恢复与进出活动区间正确;界面“进行中”与实际效果一致;重复调度不会重复开启/叠加奖励。
|
||||
|
||||
## 阶段 7B:战令任务
|
||||
|
||||
参考:uibattlepass.py 的实际 UiBattlePass 入口、PythonBattlePass、battlepass.cpp、questlua_battlepass;其余 ex 版本先做活动路径核实。
|
||||
|
||||
客户端:赛季、任务分类、进度、任务奖励/阶段奖励和领取状态;未适配的任务类型不展示成可完成。
|
||||
服务端:在击杀、开箱、通关、采集等真实完成事件挂钩计数,区分击杀归属、组队和重复回调;客户端不能上报任意增加进度。
|
||||
数据库:从 AVA2 实际 SQL 读写还原语义,使用新建迁移脚本;角色/账号归属、赛季 ID、任务 ID、完成量及领奖记录具有明确唯一键。禁止导入整个 AVA2 MySQL 数据目录。
|
||||
奖励:领奖校验、持久化记录与发奖采取能抵御重试和崩溃的幂等流程;背包满、断线和切线不能丢奖或重复领。高级战令如活动实现需要解锁道具,另建明确的资格与消费流程。
|
||||
验收:真实行为推进正确任务;任务/赛季边界、重启恢复、重复事件、并发领奖、赛季切换和背包满均可恢复;活动倍率不能意外放大战令计数。
|
||||
|
||||
## 交付顺序与阶段门槛
|
||||
|
||||
建议顺序:0 → 1 → 2 → 3 → 4 → 5 → 6 → 7A → 7B。
|
||||
|
||||
1/2/3 共用目录与查询层,先形成“生物学家材料 → 百科 → 怪物/宝箱来源”的完整闭环。
|
||||
4 作为后续战斗加成验证工具;5 的通关事件可复用于 7B;3 的开箱事件也可复用于 7B。
|
||||
活动日历与战令共用时间/通知基础,但进度、调度和领奖分别管理。
|
||||
|
||||
每阶段交付:客户端提交、服务端提交(如需)、协议与字段文档、资料生成脚本、资源清单、自动测试、macOS 联机记录、Android 真机交互记录、部署/回退说明。
|
||||
完成条件:隔离环境通过后再安排 203 部署,实际任务闭环通过方可标记完成;编译、模拟包和静态比较不能替代联机验收。
|
||||
2026-10-15 进度:阶段 0 的资料/界面基础与阶段 1 的静态查询子批已实现并完成本机回归;详情见 AVA2-WIKI-IMPLEMENTATION.md。服务端握手/查询协议、模型预览、阶段 2 以后均未完成,未部署上述新功能到 203。
|
||||
@@ -0,0 +1,43 @@
|
||||
# AVA2 功能迁移:百科首批
|
||||
|
||||
日期:2026-10-15。对应 AVA2-FEATURE-MIGRATION-PLAN.md 的阶段 0 部分基础与阶段 1 查询子批;不是七项功能全部完成。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 小地图旁“百科”入口,物品/怪物分类,中文/ID 搜索、分页及详情;仅创建当前页按钮。
|
||||
- 按真实模板范围解析 ID,不把范围展开成虚构变体。
|
||||
- 升级目标和材料可点击跳转、返回;展示数据库基础费用/概率,并提醒以实际精炼窗口为准。
|
||||
- 点击生物学家材料图标打开百科;详情显示研究阶段、材料数量和既有研究商店兑换信息。
|
||||
- 640×360 至普通桌面横屏布局,原移动技能布局保持不变。中文详情使用统一代码页转换,错误资源给出提示。
|
||||
- UTF-8 版本化目录与输入/生成器/中文名称表哈希,数据字段及大小校验。静态资料能力声明不假装实时掉落、宝箱或模型预览可用。
|
||||
- 新 wiki pack 纳入中文资源构建和 Android 增量更新。
|
||||
|
||||
当前目录:5756 个物品模板、763 个怪物/矿石模板、407 条升级配方。
|
||||
|
||||
ITEM_NONE 中包含研究任务石,因此不能一概视为无效物品。目录存在不等于已配置获取途径;怪物面板仅展示基础模板值,不冒充最终战斗属性。
|
||||
|
||||
## 服务端与资源
|
||||
|
||||
203 本次只执行公开表导出与只读 SELECT,没有部署、重启、数据库迁移或改动玩家数据。
|
||||
metin2-server 新增 tools/wiki/export_public_tables.py 和 tests/wiki/test_export.py,可重复导出公开配置和升级配方;凭据留在操作系统现有 MySQL 配置,不进入资料包。
|
||||
客户端工具见 tools/wiki/README.md。原 AVA2 及 ava2-client 未修改。
|
||||
|
||||
## 验证证据
|
||||
|
||||
证据目录:build/wiki-source/(忽略目录,不入库)。
|
||||
|
||||
- catalogue 合同测试 7/7:中文搜索、范围 ID、分页、升级/怪物字段、非法数据、清单哈希与可重复生成。
|
||||
- 服务端导出工具 2/2:仅读公开输入、只读查询、禁止复用输出目录、失败清理与非法数据库名拒绝。
|
||||
- wiki-integration-r2.log:中文客户端完整离线登录/界面/商店/战斗回归通过,并实际鼠标点击百科与生物学家材料入口。
|
||||
- wiki-touch-final.log:触控模式完整回归通过;额外验证 640×360 排版、缺失资料提示及标准库导入兼容。
|
||||
- english-regression.log:原英文资源完整离线回归通过。
|
||||
- mac-render-final.log / wiki-final.png:macOS 原生实际渲染与截图。
|
||||
- android-build-final.log:Android 编译产物;仅编译,尚未在手机安装或验证。
|
||||
|
||||
## 待完成
|
||||
|
||||
阶段 0 的服务端能力握手、查询扩展协议与统一功能收纳入口仍待后续接入;当前只打开静态目录能力,不发送新查询消息。
|
||||
阶段 1 的 Vulkan 离屏模型预览未实现;需独立相机、资源生命周期及实际渲染回归。
|
||||
阶段 2 的服务端真实掉落查询、阶段 3 宝箱奖励预览及其余属性/副本/洗练/日历/战令尚未实现。
|
||||
当前 UI 属 AVA2 功能方向的触屏适配,不宣称像素级复刻其全部百科布局或功能。
|
||||
203 联机及 Android 真机验收未做,不能把本机触控模式测试写成真机验证。
|
||||
@@ -261,3 +261,7 @@ AVA2 生物学家最终验收:6,674 组源实现对照、实际战斗计算、
|
||||
70028 原型按 AVA2 改为 ITEM_QUEST,资源模板总数 29;完成角色的历史选项也会返回,支持继续重选。
|
||||
最新备份:物品 `/root/backup-biolog-ava2/20261015-031709`,game `/root/backup-biolog-ava2-game/20261015-032329`。
|
||||
Mac 和 Android 更新包在 dist/*AVA2Bio-20261014.zip,Android 包含 APK 与必需的 biolog 资源补丁。
|
||||
|
||||
### 2026-10-15:AVA2 百科查询首批
|
||||
|
||||
用户授权按 AVA2 七项迁移计划开始开发。已接入当前服务端模板目录、中文/ID 搜索、物品/怪物分页、升级材料关联跳转、生物学家材料图标入口及独立 wiki 资源包。服务端仅增加公开资料只读导出工具;203 未部署或重启。阶段 0 查询协议/能力握手及阶段 1 模型预览尚未完成,掉落/宝箱及其余功能仍待实现。范围、证据与限制见 `AVA2-WIKI-IMPLEMENTATION.md`。
|
||||
|
||||
@@ -421,6 +421,33 @@ def build_biolog(output: Path, pack_tool: Path):
|
||||
return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True}
|
||||
|
||||
|
||||
|
||||
def build_wiki(output: Path, pack_tool: Path):
|
||||
"""A versioned public server catalogue; original script and proto packs stay intact."""
|
||||
source = REPO / "data/wiki/catalogue.txt"
|
||||
manifest = json.loads((source.parent / "manifest.json").read_text())
|
||||
import hashlib
|
||||
raw = source.read_bytes()
|
||||
header, payload = raw.split(b"\n", 1)
|
||||
if hashlib.sha256(payload).hexdigest() != manifest["catalogue_sha256"]:
|
||||
raise ValueError("wiki catalogue checksum mismatch; regenerate from server tables")
|
||||
if header != ("MTWIKI\t1\t" + manifest["catalogue_sha256"]).encode("ascii"):
|
||||
raise ValueError("unsupported wiki catalogue header")
|
||||
stage = output.parent / "wiki"
|
||||
assets = stage / "assets/wiki"
|
||||
assets.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, assets / "catalogue.txt")
|
||||
subprocess.run([str(pack_tool.resolve()), str(stage / "wiki"), str(assets.parent), ""], check=True)
|
||||
for extension in ("eix", "epk"):
|
||||
shutil.copy2(stage / ("wiki." + extension), output / "pack" / ("wiki." + extension))
|
||||
index = output / "pack/Index"
|
||||
lines = index.read_bytes().replace(b"\r\n", b"\n").splitlines()
|
||||
if b"wiki" not in lines:
|
||||
lines[1:1] = [b"*", b"wiki"]
|
||||
index.write_bytes(b"\r\n".join(lines) + b"\r\n")
|
||||
return manifest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--english", type=Path, default=DEFAULT_EN)
|
||||
@@ -435,6 +462,7 @@ def main():
|
||||
args.proto_tool, args.name_tables, args.pack_tool)
|
||||
report = json.loads(report_path.read_text())
|
||||
report["biolog"] = build_biolog(args.output, args.pack_tool)
|
||||
report["wiki"] = build_wiki(args.output, args.pack_tool)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
|
||||
print(f"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
|
||||
print(f"Report: {report_path}")
|
||||
|
||||
@@ -78,6 +78,32 @@ class Action(ui.Window):
|
||||
self.down = False
|
||||
|
||||
|
||||
class MaterialLink(ui.Window):
|
||||
def __init__(self, parent):
|
||||
ui.Window.__init__(self)
|
||||
self.owner = weakref.proxy(parent)
|
||||
self.down = False
|
||||
self.SetParent(parent)
|
||||
self.SetPosition(24, 92)
|
||||
self.SetSize(64, 64)
|
||||
self.Show()
|
||||
|
||||
def OnMouseLeftButtonDown(self):
|
||||
self.down = True
|
||||
return True
|
||||
|
||||
def OnMouseOverOut(self):
|
||||
self.down = False
|
||||
|
||||
def OnMouseLeftButtonUp(self):
|
||||
invoke = self.down and self.IsIn() and self.owner.iconVnum
|
||||
self.down = False
|
||||
if invoke:
|
||||
import mt_wiki
|
||||
mt_wiki.OpenItem(self.owner.iconVnum)
|
||||
return True
|
||||
|
||||
|
||||
class BiologWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self)
|
||||
@@ -99,6 +125,7 @@ class BiologWindow(ui.Window):
|
||||
self.icon.SetParent(self)
|
||||
self.icon.SetPosition(26, 92)
|
||||
self.icon.AddFlag("not_pick")
|
||||
self.materialLink = MaterialLink(self)
|
||||
self.tooltip = uiToolTip.ItemToolTip()
|
||||
self.tooltip.Hide()
|
||||
self.enhance = Action(self, 22, 288, 158, "精华:开启", lambda:self.Option(0))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// PORT: preserve the packed script importer for its original four-argument calls,
|
||||
// and support explicit stdlib relative/absolute imports used by Unicode/codecs.
|
||||
static const char c_szFeatureBootstrapScript[] = R"PY(
|
||||
import __builtin__
|
||||
import sys
|
||||
_legacy_import=__builtin__.__import__
|
||||
_native_import=_legacy_import.func_globals['old_import']
|
||||
_stdlib_prefix=sys.modules['codecs'].__file__.rsplit('/',1)[0]+'/'
|
||||
|
||||
|
||||
def FeatureImport(name, globals=None, locals=None, fromlist=None, level=-1):
|
||||
if level >= 0 or (globals and globals.get('__file__','').startswith(_stdlib_prefix)):
|
||||
return _native_import(name, globals, locals, fromlist, level)
|
||||
return _legacy_import(name, globals, locals, fromlist)
|
||||
|
||||
|
||||
__builtin__.__import__=FeatureImport
|
||||
)PY";
|
||||
@@ -991,6 +991,9 @@ namespace PythonBoot
|
||||
#include "ServerBonusScript.inc"
|
||||
#include "MobileHUDScript.inc"
|
||||
#include "BiologScript.inc"
|
||||
#include "FeatureBootstrapScript.inc"
|
||||
#include "WikiDataScript.inc"
|
||||
#include "WikiScript.inc"
|
||||
|
||||
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
||||
{
|
||||
@@ -1055,7 +1058,10 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
||||
}
|
||||
install_script_module("mt_mallhint", c_szMallHintScript);
|
||||
install_script_module("mt_bonuses", c_szServerBonusScript);
|
||||
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_wiki", c_szWikiScript);
|
||||
if (g_touch_input)
|
||||
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// PORT wiki catalogue v1. Data-only parser shared by runtime and catalogue contract tests.
|
||||
static const char c_szWikiDataScript[] = R"PY(
|
||||
SCHEMA = 1
|
||||
MAX_BYTES = 2097152
|
||||
MAX_ROWS = 30000
|
||||
|
||||
|
||||
def Number(text, upper=4294967295):
|
||||
if not text.isdigit() or len(text) > 10:
|
||||
raise ValueError('invalid unsigned catalogue number')
|
||||
value = int(text)
|
||||
if value > upper:
|
||||
raise ValueError('catalogue number out of range')
|
||||
return value
|
||||
|
||||
|
||||
class Catalogue(object):
|
||||
def __init__(self, raw):
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise ValueError('catalogue too large')
|
||||
lines = raw.decode('utf-8').splitlines()
|
||||
if not lines or len(lines) > MAX_ROWS+1:
|
||||
raise ValueError('empty or oversized catalogue')
|
||||
header = lines[0].split('\t')
|
||||
if len(header) != 3 or header[:2] != ['MTWIKI', '1'] or len(header[2]) != 64 or any(c not in '0123456789abcdef' for c in header[2]):
|
||||
raise ValueError('unsupported catalogue schema')
|
||||
self.version = header[2]
|
||||
self.items, self.mobs, self.recipes = {}, {}, {}
|
||||
for line in lines[1:]:
|
||||
c = line.split('\t')
|
||||
if any(len(v) > 256 or '\x00' in v or '|' in v for v in c):
|
||||
raise ValueError('invalid catalogue cell')
|
||||
if c[0] == 'I' and len(c) == 10:
|
||||
vnum, end = Number(c[1]), Number(c[2])
|
||||
if not vnum or end < vnum or end-vnum > 10000 or not c[3] or not c[4].startswith('ITEM_'):
|
||||
raise ValueError('invalid item')
|
||||
if vnum in self.items:
|
||||
raise ValueError('duplicate item')
|
||||
self.items[vnum] = dict(id=vnum, end=end, name=c[3], kind=c[4], subtype=c[5], level=Number(c[6],255),
|
||||
size=Number(c[7],10), refined=Number(c[8]), recipe=Number(c[9]))
|
||||
elif c[0] == 'M' and len(c) == 11:
|
||||
vnum = Number(c[1])
|
||||
if not vnum or vnum in self.mobs or not c[2] or c[3] not in ('MONSTER','STONE'):
|
||||
raise ValueError('invalid monster')
|
||||
self.mobs[vnum] = dict(id=vnum,name=c[2],kind=c[3],rank=c[4],level=Number(c[5],255),hp=Number(c[6]),
|
||||
attack_min=Number(c[7]),attack_max=Number(c[8]),exp=Number(c[9]),defense=Number(c[10]))
|
||||
elif c[0] == 'R' and len(c) == 5:
|
||||
rid=Number(c[1])
|
||||
if not rid or rid in self.recipes:
|
||||
raise ValueError('invalid recipe')
|
||||
mats=[]
|
||||
if c[4]:
|
||||
for pair in c[4].split(','):
|
||||
parts=pair.split(':')
|
||||
if len(parts)!=2:
|
||||
raise ValueError('invalid ingredient')
|
||||
mat,count=Number(parts[0]),Number(parts[1])
|
||||
if not mat or not count:raise ValueError('empty ingredient')
|
||||
mats.append((mat,count))
|
||||
if len(mats)>5:raise ValueError('too many ingredients')
|
||||
self.recipes[rid]=dict(cost=Number(c[2]),probability=Number(c[3],100),materials=mats)
|
||||
else:
|
||||
raise ValueError('invalid catalogue record')
|
||||
self.item_ids=sorted(self.items)
|
||||
self.mob_ids=sorted(self.mobs)
|
||||
if not self.item_ids or not self.mob_ids or not self.recipes:
|
||||
raise ValueError('incomplete catalogue')
|
||||
for left,right in zip(self.item_ids,self.item_ids[1:]):
|
||||
if self.items[left]['end']>=right:raise ValueError('overlapping item ranges')
|
||||
self.index={}
|
||||
for mode,records in (('items',self.items),('mobs',self.mobs)):
|
||||
self.index[mode]=dict((v,(r['name']+' '+str(v)).lower()) for v,r in records.items())
|
||||
|
||||
def FindItem(self, vnum):
|
||||
# Template ranges are kept as ranges, not expanded into thousands of fake variants.
|
||||
low,high=0,len(self.item_ids)
|
||||
while low<high:
|
||||
middle=(low+high)//2
|
||||
if self.item_ids[middle]<=vnum:low=middle+1
|
||||
else:high=middle
|
||||
if low:
|
||||
row=self.items[self.item_ids[low-1]]
|
||||
if vnum<=row['end']:return row
|
||||
return None
|
||||
|
||||
def Search(self, mode, query='', category='', page=0, page_size=6):
|
||||
if mode not in self.index or not 1<=page_size<=12 or len(query)>80:
|
||||
raise ValueError('invalid search')
|
||||
query=query.strip().lower()
|
||||
records=self.items if mode=='items' else self.mobs
|
||||
ids=self.item_ids if mode=='items' else self.mob_ids
|
||||
exact=self.FindItem(int(query)) if mode=='items' and query.isdigit() and len(query)<=10 else None
|
||||
matches=[v for v in ids if (not category or records[v]['kind']==category) and
|
||||
(not query or query in self.index[mode][v] or (exact and exact['id']==v))]
|
||||
pages=max(1,(len(matches)+page_size-1)//page_size)
|
||||
page=max(0,min(page,pages-1))
|
||||
return [records[v] for v in matches[page*page_size:(page+1)*page_size]],len(matches),page,pages
|
||||
)PY";
|
||||
@@ -0,0 +1,286 @@
|
||||
// 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 const char c_szWikiScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||
import app
|
||||
import game
|
||||
import grp
|
||||
import item
|
||||
import pack
|
||||
import player
|
||||
import ui
|
||||
import wndMgr
|
||||
import weakref
|
||||
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 U(text):
|
||||
return text if isinstance(text,unicode) else text.decode('utf-8')
|
||||
|
||||
|
||||
def B(text, limit=0):
|
||||
text=U(text)
|
||||
if limit and len(text)>limit:text=text[:limit-1]+u'…'
|
||||
return text.encode('utf-8')
|
||||
|
||||
|
||||
KINDS=(('', '全部物品'),('ITEM_WEAPON','武器'),('ITEM_ARMOR','防具首饰'),('ITEM_MATERIAL','材料'),
|
||||
('ITEM_QUEST','任务物品'),('ITEM_USE','消耗品'),('ITEM_GIFTBOX','宝箱'),('ITEM_METIN','宝石'))
|
||||
MOBKINDS=(('', '全部怪物'),('MONSTER','怪物'),('STONE','矿石'))
|
||||
TYPE_NAMES=dict(KINDS)
|
||||
TYPE_NAMES.update({'ITEM_NONE':'特殊道具','ITEM_UNIQUE':'特殊装备','ITEM_TOOL':'工具','ITEM_FISH':'鱼类','ITEM_ROD':'鱼竿','ITEM_PICK':'采矿镐','ITEM_RESOURCE':'资源','ITEM_SKILLBOOK':'技能书','ITEM_SKILLFORGET':'技能忘却书','ITEM_COSTUME':'时装','ITEM_SPECIAL':'特殊道具'})
|
||||
RANK_NAMES={'PAWN':'普通','S_PAWN':'弱小','KNIGHT':'精英','S_KNIGHT':'强力','BOSS':'首领','KING':'王者'}
|
||||
|
||||
|
||||
class WikiWindow(ui.Window):
|
||||
def __init__(self):
|
||||
ui.Window.__init__(self)
|
||||
self.SetWindowName('WikiWindow')
|
||||
self.AddFlag('float')
|
||||
self.AddFlag('movable')
|
||||
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.heading=Text(self,18,18,'游戏百科',0xffffd68b)
|
||||
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)
|
||||
self.searchBox.SetSize(320,28);self.searchBox.Show()
|
||||
self.search=ui.EditLine();self.search.SetParent(self.searchBox);self.search.SetPosition(8,6)
|
||||
self.search.SetSize(302,20);self.search.SetMax(80);self.search.SetReturnEvent(self.Search)
|
||||
self.search.SetEscapeEvent(self.Hide);self.search.Show()
|
||||
self.searchButton=Action(self,346,50,72,'搜索',self.Search)
|
||||
self.itemTab=Action(self,18,100,96,'物品',lambda:self.SetMode('items'))
|
||||
self.mobTab=Action(self,122,100,96,'怪物',lambda:self.SetMode('mobs'))
|
||||
self.categoryButton=Action(self,226,100,164,'全部物品',self.NextCategory)
|
||||
self.status=Text(self,18,85,'输入名称或 ID 搜索',0xffc3b7a1)
|
||||
self.title=Text(self,0,112,'选择左侧条目',0xffffd68b)
|
||||
self.icon=ui.ExpandedImageBox();self.icon.SetParent(self);self.icon.AddFlag('not_pick')
|
||||
self.listButtons=[Action(self,0,0,100,'',lambda i=i:self.SelectResult(i)) for i in range(8)]
|
||||
self.detailButtons=[Action(self,0,0,100,'',lambda i=i:self.Follow(i)) for i in range(8)]
|
||||
self.prev=Action(self,18,0,72,'上一页',lambda:self.Page(-1))
|
||||
self.next=Action(self,0,0,72,'下一页',lambda:self.Page(1))
|
||||
self.pageText=Text(self,110,0)
|
||||
self.dprev=Action(self,0,0,72,'详情上页',lambda:self.DetailPage(-1))
|
||||
self.dnext=Action(self,0,0,72,'详情下页',lambda:self.DetailPage(1))
|
||||
self.revision=Text(self,430,66,'未加载资料',0xffaaa08b)
|
||||
self.Reflow()
|
||||
self.Hide()
|
||||
|
||||
def Load(self):
|
||||
if self.catalogue:return True
|
||||
try:
|
||||
if not pack.Exist(PATH):raise ValueError('missing catalogue')
|
||||
self.catalogue=Catalogue(pack.Get(PATH))
|
||||
self.revision.SetText(L(B(u'资料版本 '+self.catalogue.version[:12])))
|
||||
self.error=''
|
||||
return True
|
||||
except (ValueError,TypeError,UnicodeError,RuntimeError,IOError):
|
||||
self.error='百科资料缺失或版本不支持,请更新资源包。'
|
||||
self.status.SetText(L(B(self.error,24)))
|
||||
return False
|
||||
|
||||
def Open(self,vnum=0):
|
||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop()
|
||||
if not self.Load():return
|
||||
self.Search()
|
||||
if vnum:
|
||||
row=self.catalogue.FindItem(int(vnum))
|
||||
if row:self.Select('items',row)
|
||||
else:self.status.SetText(L(B('当前资料未收录此物品。')))
|
||||
|
||||
def Query(self):
|
||||
value=self.search.GetText()
|
||||
return player.FeatureLocaleToUTF8(value).decode('utf-8').strip()
|
||||
|
||||
def Search(self):
|
||||
self.page=0;self.lastQuery=self.Query();self.Refresh()
|
||||
|
||||
def SetMode(self,mode):
|
||||
self.mode=mode;self.category=0;self.page=0;self.Refresh()
|
||||
|
||||
def NextCategory(self):
|
||||
choices=KINDS if self.mode=='items' else MOBKINDS
|
||||
self.category=(self.category+1)%len(choices);self.page=0;self.Refresh()
|
||||
|
||||
def Page(self,step):
|
||||
self.page+=step;self.Refresh()
|
||||
|
||||
def DetailPage(self,step):
|
||||
self.detailPage+=step;self.RefreshDetail()
|
||||
|
||||
def Refresh(self):
|
||||
for button in self.listButtons:button.Hide()
|
||||
if not self.catalogue:return
|
||||
choices=KINDS if self.mode=='items' else MOBKINDS
|
||||
kind,label=choices[self.category]
|
||||
self.categoryButton.SetText(label)
|
||||
self.results,count,self.page,pages=self.catalogue.Search(self.mode,self.Query(),kind,self.page,self.rowCount)
|
||||
for i,row in enumerate(self.results):
|
||||
self.listButtons[i].SetText(B(row['name'],max(9,(self.split-130)//12))+' #%d'%row['id']);self.listButtons[i].Show()
|
||||
self.status.SetText(L(B('%d 项;模板资料不保证可获取。'%count)))
|
||||
self.pageText.SetText(L(B('%d / %d'%(self.page+1,pages))))
|
||||
self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page+1<pages)
|
||||
|
||||
def SelectResult(self,index):
|
||||
if index<len(self.results):self.Select(self.mode,self.results[index])
|
||||
|
||||
def Select(self,mode,row,remember=True):
|
||||
if remember and self.selected:self.history=(self.history+[self.selected])[-24:]
|
||||
self.selected=(mode,row['id']);self.detailPage=0;self.RefreshDetail()
|
||||
|
||||
def Back(self):
|
||||
if self.history:
|
||||
self.selected=self.history.pop();self.detailPage=0;self.RefreshDetail()
|
||||
|
||||
def Follow(self,index):
|
||||
offset=self.detailPage*self.rowCount+index
|
||||
if offset<len(self.links) and self.links[offset][1]:
|
||||
vnum=self.links[offset][1]
|
||||
row=self.catalogue.FindItem(vnum)
|
||||
if row:self.Select('items',row)
|
||||
else:self.status.SetText(L(B('目标物品未收录:#%d'%vnum)))
|
||||
|
||||
def RefreshDetail(self):
|
||||
for b in self.detailButtons:b.Hide()
|
||||
self.links=[];self.icon.Hide()
|
||||
self.backButton.SetEnabled(bool(self.history))
|
||||
if not self.selected or not self.catalogue:
|
||||
self.dprev.SetEnabled(False);self.dnext.SetEnabled(False)
|
||||
return
|
||||
mode,vnum=self.selected
|
||||
row=(self.catalogue.items if mode=='items' else self.catalogue.mobs)[vnum]
|
||||
self.title.SetText(L(B(row['name'],max(8,(self.split-150)//12))+' #%d'%vnum))
|
||||
lines=[]
|
||||
if mode=='items':
|
||||
lines.append((u'类型:'+U(TYPE_NAMES.get(row['kind'],'其他物品')),0))
|
||||
lines.append((u'最低等级:%d 占格:%d'%(row['level'],row['size']),0))
|
||||
if row['end']!=vnum:lines.append((u'模板 ID 范围:%d—%d'%(vnum,row['end']),0))
|
||||
try:
|
||||
path=item.FeatureGetIconPath(vnum)
|
||||
if path:self.icon.LoadImage(path);self.icon.Show()
|
||||
except (RuntimeError,ValueError):pass
|
||||
target=row['refined'];recipe=self.catalogue.recipes.get(row['recipe'])
|
||||
if target:
|
||||
name=self.catalogue.FindItem(target)
|
||||
lines.append((u'升级 → '+(name['name'] if name else u'#%d'%target),target if name else 0))
|
||||
if recipe:
|
||||
lines.append((u'基础配方:%d 金币 / %d%%'%(recipe['cost'],recipe['probability']),0))
|
||||
for mat,count in recipe['materials']:
|
||||
ingredient=self.catalogue.FindItem(mat)
|
||||
lines.append((u'材料:'+(ingredient['name'] if ingredient else u'#%d'%mat)+u' ×%d'%count,mat if ingredient else 0))
|
||||
lines.append((u'实际费用/概率以精炼窗口为准',0))
|
||||
else:lines.append((u'升级配方尚未收录',0))
|
||||
else:lines.append((u'没有配置下一阶升级',0))
|
||||
import mt_biolog
|
||||
for stage in sorted(mt_biolog.CATALOG):
|
||||
bio=mt_biolog.CATALOG[stage]
|
||||
if vnum in (bio['material'],bio['stone']):
|
||||
lines.append((u'生物学家:%d 级研究'%bio['level'],0))
|
||||
if vnum==bio['material']:lines.append((u'阶段所需:%d 个'%bio['count'],0))
|
||||
offer=next((o for o in mt_biolog.SHOP if o['vnum']==vnum),None)
|
||||
if offer:
|
||||
if offer['gold']:lines.append((u'研究商店:%d 金币'%offer['gold'],0))
|
||||
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))
|
||||
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)]
|
||||
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)
|
||||
|
||||
def Reflow(self):
|
||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||
if dims==self.dimensions:return
|
||||
self.dimensions=dims
|
||||
width=min(760,max(600,dims[0]-16));height=min(480,max(300,dims[1]-16))
|
||||
self.SetSize(width,height);self.split=width//2
|
||||
self.categoryButton.SetSize(self.split-234,48);self.categoryButton.label.SetPosition((self.split-234)//2,24)
|
||||
self.rowCount=min(8,max(2,(height-216)//48))
|
||||
self.closeButton.SetPosition(width-70,6);self.backButton.SetPosition(width-150,6)
|
||||
self.title.SetPosition(self.split+48,122);self.icon.SetPosition(self.split+8,112)
|
||||
for i,b in enumerate(self.listButtons):
|
||||
b.SetPosition(18,154+i*48);b.SetSize(self.split-30,44);b.label.SetPosition((self.split-30)//2,22)
|
||||
for i,b in enumerate(self.detailButtons):
|
||||
b.SetPosition(self.split+8,154+i*48);b.SetSize(self.split-26,44);b.label.SetPosition((self.split-26)//2,22)
|
||||
self.prev.SetPosition(18,height-54);self.next.SetPosition(self.split-90,height-54);self.pageText.SetPosition(112,height-30)
|
||||
self.dprev.SetPosition(self.split+8,height-54);self.dnext.SetPosition(width-90,height-54)
|
||||
self.SetCenterPosition();self.Refresh();self.RefreshDetail()
|
||||
|
||||
def OnUpdate(self):
|
||||
if self.IsShow():self.Reflow()
|
||||
|
||||
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())
|
||||
grp.RenderLine(x+self.split,y+108,0,self.GetHeight()-170)
|
||||
|
||||
def OnMouseWheel(self,delta):
|
||||
x,y=self.GetGlobalPosition();mx,my=wndMgr.GetMousePosition()
|
||||
if mx>=x+self.split:self.DetailPage(-1 if delta>0 else 1)
|
||||
else:self.Page(-1 if delta>0 else 1)
|
||||
return True
|
||||
|
||||
def Hide(self):
|
||||
if hasattr(self,'search'):self.search.KillFocus()
|
||||
ui.Window.Hide(self)
|
||||
|
||||
def OnPressEscapeKey(self):
|
||||
self.Hide();return True
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Entry(Action):
|
||||
def __init__(self,window):
|
||||
Action.__init__(self,None,0,0,64,'百科',Toggle)
|
||||
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('WikiEntry');self.OnUpdate()
|
||||
|
||||
def OnUpdate(self):
|
||||
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+96)
|
||||
|
||||
|
||||
def Toggle():
|
||||
if g_window:
|
||||
if g_window.IsShow():g_window.Hide()
|
||||
else:g_window.Open()
|
||||
|
||||
|
||||
def OpenItem(vnum):
|
||||
if g_window:g_window.Open(vnum)
|
||||
|
||||
|
||||
_open=game.GameWindow.Open
|
||||
_close=game.GameWindow.Close
|
||||
|
||||
def Open(self):
|
||||
global g_window,g_entry
|
||||
_open(self);g_window=WikiWindow();g_entry=Entry(self)
|
||||
|
||||
|
||||
def Close(self):
|
||||
global g_window,g_entry
|
||||
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
|
||||
if g_window:g_window.Destroy();g_window=None
|
||||
_close(self)
|
||||
|
||||
|
||||
game.GameWindow.Open=Open
|
||||
game.GameWindow.Close=Close
|
||||
)PY";
|
||||
@@ -308,6 +308,11 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
add_test(NAME port.login_flow
|
||||
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP})
|
||||
set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77)
|
||||
find_program(MT_WIKI_PYTHON3 NAMES python3)
|
||||
if(MT_WIKI_PYTHON3)
|
||||
add_test(NAME port.wiki_catalogue
|
||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_catalogue.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)
|
||||
target_link_libraries(locale_proto_patch PRIVATE port_platform)
|
||||
|
||||
@@ -53,6 +53,18 @@ PyObject * itemSelectItem(PyObject * poSelf, PyObject * poArgs)
|
||||
return Py_BuildNone();
|
||||
}
|
||||
|
||||
// PORT encyclopedia lookup: do not mutate SelectItem state or use its fallback item.
|
||||
PyObject* itemFeatureGetIconPath(PyObject*, PyObject* args)
|
||||
{
|
||||
unsigned int id;
|
||||
if (!PyArg_ParseTuple(args, "I", &id)) return nullptr;
|
||||
CItemData* data = nullptr;
|
||||
if (!id || !CItemManager::Instance().GetItemDataPointer(id, &data) || !data)
|
||||
return Py_BuildValue("s", "");
|
||||
CGraphicSubImage* image = data->GetIconImage();
|
||||
return Py_BuildValue("s", image ? image->GetFileName() : "");
|
||||
}
|
||||
|
||||
PyObject * itemGetItemName(PyObject * poSelf, PyObject * poArgs)
|
||||
{
|
||||
CItemData * pItemData = CItemManager::Instance().GetSelectedItemDataPointer();
|
||||
@@ -514,6 +526,7 @@ void initItem()
|
||||
{ "GetItemDescription", itemGetItemDescription, METH_VARARGS },
|
||||
{ "GetItemSummary", itemGetItemSummary, METH_VARARGS },
|
||||
{ "GetIconImage", itemGetIconImage, METH_VARARGS },
|
||||
{ "FeatureGetIconPath", itemFeatureGetIconPath, METH_VARARGS },
|
||||
{ "GetIconImageFileName", itemGetIconImageFileName, METH_VARARGS },
|
||||
{ "GetItemSize", itemGetItemSize, METH_VARARGS },
|
||||
{ "GetItemType", itemGetItemType, METH_VARARGS },
|
||||
|
||||
@@ -2255,6 +2255,24 @@ PyObject* playerAutoHuntSetSkills(PyObject*,PyObject* args)
|
||||
return Py_BuildNone();
|
||||
}
|
||||
|
||||
// PORT feature search: use the same native codepage tables as rendered locale text.
|
||||
// Python's embedded stdlib does not require a CJK codec extension for this bridge.
|
||||
PyObject* playerFeatureLocaleToUTF8(PyObject*, PyObject* args)
|
||||
{
|
||||
char* text;
|
||||
if (!PyTuple_GetString(args, 0, &text)) return Py_BuildException();
|
||||
if (strlen(text) > 512) { PyErr_SetString(PyExc_ValueError, "feature text exceeds 512 bytes"); return nullptr; }
|
||||
const int count = MultiByteToWideChar(GetDefaultCodePage(), 0, text, -1, NULL, 0);
|
||||
if (count <= 0) return Py_BuildValue("s", "");
|
||||
std::vector<WCHAR> wide(count);
|
||||
MultiByteToWideChar(GetDefaultCodePage(), 0, text, -1, wide.data(), count);
|
||||
const int size = WideCharToMultiByte(CP_UTF8, 0, wide.data(), -1, NULL, 0, NULL, NULL);
|
||||
if (size <= 0) return Py_BuildValue("s", "");
|
||||
std::vector<char> utf8(size);
|
||||
WideCharToMultiByte(CP_UTF8, 0, wide.data(), -1, utf8.data(), size, NULL, NULL);
|
||||
return Py_BuildValue("s", utf8.data());
|
||||
}
|
||||
|
||||
// The auto hunt window's UTF-8 source strings in the locale's code page (locale.cfg), as the packed
|
||||
// locale_game.txt strings are.
|
||||
PyObject * playerAutoHuntLocalize(PyObject* poSelf, PyObject* poArgs)
|
||||
@@ -2455,6 +2473,7 @@ void initPlayer()
|
||||
{ "AutoHuntSetConfig", playerAutoHuntSetConfig, METH_VARARGS },
|
||||
{ "AutoHuntGetSkills", playerAutoHuntGetSkills, METH_VARARGS },
|
||||
{ "AutoHuntSetSkills", playerAutoHuntSetSkills, METH_VARARGS },
|
||||
{ "FeatureLocaleToUTF8", playerFeatureLocaleToUTF8, METH_VARARGS },
|
||||
{ "AutoHuntLocalize", playerAutoHuntLocalize, METH_VARARGS },
|
||||
|
||||
{ NULL, NULL, NULL },
|
||||
|
||||
@@ -130,7 +130,19 @@ static int count_syserr(const char* needle)
|
||||
static bool py(const std::string& source)
|
||||
{
|
||||
std::string error;
|
||||
if (PythonBoot::RunLine(source.c_str(), &error))
|
||||
std::string code = source;
|
||||
if (std::getenv("MT_TEST_WIKI")) {
|
||||
code = "# -*- coding: utf-8 -*-\ntry:\n";
|
||||
size_t start=0;
|
||||
while (start<source.size()) {
|
||||
size_t end=source.find('\n',start);
|
||||
if (end==std::string::npos) end=source.size();
|
||||
code += " "+source.substr(start,end-start)+"\n";
|
||||
start=end+1;
|
||||
}
|
||||
code += "except BaseException as _wiki_error:\n import sys\n sys.stderr.write('wiki integration error: '+repr(_wiki_error)+'\\n')\n raise\n";
|
||||
}
|
||||
if (PythonBoot::RunLine(code.c_str(), &error))
|
||||
return true;
|
||||
std::fprintf(stderr, "python: %s\n%s\n", source.c_str(), error.c_str());
|
||||
return false;
|
||||
@@ -381,7 +393,7 @@ int main(int argc, char** argv)
|
||||
// CPythonSkill read skilldesc.txt and skilltable.txt (RegisterSkillDesc/RegisterSkillTable); skill 1's
|
||||
// cool time is skilltable's constant "12".
|
||||
CHECK(py("import skill\n"
|
||||
"assert skill.GetSkillName(1, 0) and (__import__('os').getenv('MT_LIVE_BIOLOG') or skill.GetSkillName(1, 0) == 'Three-Way Cut'), skill.GetSkillName(1, 0)\n"
|
||||
"assert skill.GetSkillName(1, 0) and (__import__('os').getenv('MT_LIVE_BIOLOG') or __import__('os').getenv('MT_TEST_WIKI') or skill.GetSkillName(1, 0) == 'Three-Way Cut'), skill.GetSkillName(1, 0)\n"
|
||||
"assert skill.GetSkillType(1) == skill.SKILL_TYPE_ACTIVE, skill.GetSkillType(1)\n"
|
||||
"assert skill.GetSkillCoolTime(1, 1.0) == 12, skill.GetSkillCoolTime(1, 1.0)"));
|
||||
}
|
||||
@@ -544,6 +556,57 @@ int main(int argc, char** argv)
|
||||
CHECK(pump_until(3, [] { return py_true("_bio.count==4"); }));
|
||||
CHECK(py_true("not _bio.CanAct() and _bio.Remaining()>0"));
|
||||
CHECK(py("assert _bio.SetData('9 11 0'.split())\nassert _bio.Phase()=='gift'\nassert sum(button.IsShow() for button in _bio.choices)==3\nassert _bio.SetData('13 36 0'.split())\nassert sum(button.IsShow() for button in _bio.choices)==2\nplayer.SetStatus(player.LEVEL,_old_level)\n_bio.Hide()"));
|
||||
}
|
||||
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"
|
||||
"mt_wiki.g_entry.OnUpdate()"));
|
||||
std::string xy;
|
||||
CHECK(PythonBoot::Evaluate("'%d %d' % mt_wiki.g_entry.GetGlobalPosition()", &xy, nullptr));
|
||||
int x=0,y=0;
|
||||
CHECK(std::sscanf(xy.c_str(), "'%d %d'", &x,&y)==2 || std::sscanf(xy.c_str(), "%d %d", &x,&y)==2);
|
||||
PythonBoot::UIMouseMove(x+30,y+24);
|
||||
CHECK(py_true("__import__('wndMgr').IsPickedWindow(mt_wiki.g_entry.hWnd)"));
|
||||
PythonBoot::UIMouseButton(1,true,x+30,y+24);
|
||||
PythonBoot::UIMouseButton(1,false,x+30,y+24);
|
||||
CHECK(py("_wiki=mt_wiki.g_window\nassert _wiki.IsShow() and _wiki.catalogue, _wiki.error\n"
|
||||
"assert _wiki.catalogue.FindItem(300004)['name']==u'\\u6989\\u6728\\u6811\\u679d', repr(_wiki.catalogue.FindItem(300004)['name'])\n"
|
||||
"_wiki.search.SetText(player.AutoHuntLocalize('榉木'))\n_wiki.search.eventReturn()\n"
|
||||
"assert len(_wiki.results)==1 and _wiki.results[0]['id']==300004\n"
|
||||
"_wiki.SelectResult(0)\nassert _wiki.selected==('items',300004)\n"
|
||||
"import item\nitem.SelectItem(19)\n_selected_name=item.GetItemName()\n"
|
||||
"assert item.FeatureGetIconPath(300004)\nassert not item.FeatureGetIconPath(4294967295)\n"
|
||||
"assert item.GetItemName()==_selected_name\n"
|
||||
"_wiki.Open(10)\nassert any(link==11 for text,link in _wiki.links)\n"
|
||||
"_wiki.Follow(next(i for i,p in enumerate(_wiki.links) if p[1]==11))\n"
|
||||
"assert _wiki.selected==('items',11)\n_wiki.Back()\nassert _wiki.selected==('items',10)\n"
|
||||
"_wiki.search.SetText('')\n_wiki.SetMode('mobs')\nassert _wiki.results\n"
|
||||
"_wiki.NextCategory()\n_wiki.NextCategory()\nassert all(r['kind']=='STONE' for r in _wiki.results)\n"
|
||||
"_wiki.Page(999999)\nassert len(_wiki.results)<=_wiki.rowCount\n"
|
||||
"_wiki.Hide()\nassert not _wiki.IsShow()\n"
|
||||
"_bio.Toggle()\n_bio.SetData('5 0 0'.split())\n_bio.Refresh()\n"
|
||||
"assert _bio.iconVnum==300004\n"));
|
||||
CHECK(PythonBoot::Evaluate("'%d %d' % _bio.materialLink.GetGlobalPosition()", &xy, nullptr));
|
||||
CHECK(std::sscanf(xy.c_str(), "'%d %d'", &x,&y)==2 || std::sscanf(xy.c_str(), "%d %d", &x,&y)==2);
|
||||
PythonBoot::UIMouseMove(x+24,y+24);
|
||||
PythonBoot::UIMouseButton(1,true,x+24,y+24);
|
||||
PythonBoot::UIMouseButton(1,false,x+24,y+24);
|
||||
CHECK(py("assert _wiki.IsShow() and _wiki.selected==('items',300004)\n_wiki.Hide()\n_bio.Hide()"));
|
||||
PythonBoot::SetUISize(640,360);
|
||||
CHECK(py("_wiki.Open(10)\n_wiki.OnUpdate()\n"
|
||||
"assert _wiki.GetWidth()<=624 and _wiki.GetHeight()<=344\n"
|
||||
"assert _wiki.rowCount==2\n"
|
||||
"assert _wiki.categoryButton.GetLocalPosition()[0]+_wiki.categoryButton.GetWidth()<_wiki.split\n"
|
||||
"assert _wiki.listButtons[1].GetLocalPosition()[1]+44<=_wiki.prev.GetLocalPosition()[1]\n"
|
||||
"_wiki.Hide()\n"
|
||||
"import json\nassert json.loads('{\"schema\":1}')['schema']==1\n"
|
||||
"_missing=mt_wiki.WikiWindow()\n_saved_path=mt_wiki.PATH\nmt_wiki.PATH='wiki/not-installed.txt'\n"
|
||||
"_missing.Open()\nassert _missing.error and not _missing.catalogue\n"
|
||||
"_missing.Destroy()\nmt_wiki.PATH=_saved_path"));
|
||||
PythonBoot::SetUISize(800,600);
|
||||
CHECK(count_syserr("mt_wiki: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki_data: install failed")==0);
|
||||
}
|
||||
CHECK(count_syserr("Unknown packet header") == 0);
|
||||
CHECK(count_syserr(kUnhandledPacket) == 0);
|
||||
@@ -711,7 +774,7 @@ int main(int argc, char** argv)
|
||||
// item module (PythonItemModule.cpp) over the loaded item_proto: SelectItem + the selected item's fields.
|
||||
CHECK(py("import item\n"
|
||||
"item.SelectItem(19)\n"
|
||||
"assert item.GetItemName() == 'Sword+9', item.GetItemName()\n"
|
||||
"assert item.GetItemName() and (__import__('os').getenv('MT_TEST_WIKI') or item.GetItemName() == 'Sword+9'), item.GetItemName()\n"
|
||||
"assert item.GetItemType() == item.ITEM_TYPE_WEAPON and item.GetItemSubType() == item.WEAPON_SWORD\n"
|
||||
"assert item.GetValue(3) > 0, 'min attack'"));
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests execute the exact parser embedded in CPython 2, under Python 3 too."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'src/platform/ScriptLib/WikiDataScript.inc'
|
||||
ns = {}
|
||||
exec(SOURCE.read_text().split('R"PY(', 1)[1].rsplit(')PY";', 1)[0], ns)
|
||||
Catalogue = ns['Catalogue']
|
||||
RAW = (ROOT / 'data/wiki/catalogue.txt').read_bytes()
|
||||
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.catalogue = Catalogue(RAW)
|
||||
|
||||
def test_real_catalogue_manifest_and_unique_ranges(self):
|
||||
report = json.loads((ROOT / 'data/wiki/manifest.json').read_text())
|
||||
self.assertEqual(hashlib.sha256(RAW.split(b'\n', 1)[1]).hexdigest(), report['catalogue_sha256'])
|
||||
self.assertEqual(self.catalogue.version, report['catalogue_sha256'])
|
||||
self.assertEqual(len(self.catalogue.items), report['items'])
|
||||
self.assertEqual(len(self.catalogue.mobs), report['monsters'])
|
||||
self.assertEqual(len(self.catalogue.recipes), report['refine_recipes'])
|
||||
|
||||
def test_research_materials_and_chinese_search(self):
|
||||
for row in json.loads((ROOT / 'tools/server/biolog/catalog.json').read_text()):
|
||||
for field in ('material', 'stone', 'enhancer', 'skip'):
|
||||
self.assertIsNotNone(self.catalogue.FindItem(row[field]))
|
||||
row = self.catalogue.FindItem(300004)
|
||||
self.assertEqual(row['name'], '榉木树枝')
|
||||
results, count, page, pages = self.catalogue.Search('items', '榉木')
|
||||
self.assertIn(300004, [r['id'] for r in results])
|
||||
self.assertGreater(count, 0)
|
||||
|
||||
def test_template_ranges_resolve_without_expansion(self):
|
||||
row = self.catalogue.FindItem(110050)
|
||||
self.assertEqual((row['id'], row['end']), (110000, 110099))
|
||||
results, count, _, _ = self.catalogue.Search('items', '110050')
|
||||
self.assertIn(110000, [r['id'] for r in results])
|
||||
self.assertIsNone(self.catalogue.FindItem(4294967295))
|
||||
self.assertIsNone(self.catalogue.FindItem(0))
|
||||
|
||||
def test_pagination_filters_and_unicode_no_results(self):
|
||||
results, count, page, pages = self.catalogue.Search('mobs', category='STONE', page=999999, page_size=2)
|
||||
self.assertEqual(page, pages-1)
|
||||
self.assertTrue(all(r['kind']=='STONE' for r in results))
|
||||
results, count, page, pages = self.catalogue.Search('items', '不存在的测试物品')
|
||||
self.assertEqual((results,count,page,pages),([],0,0,1))
|
||||
results, count, page, pages = self.catalogue.Search('items', page=-20)
|
||||
self.assertEqual(page,0)
|
||||
self.assertLessEqual(len(results),6)
|
||||
for kwargs in ({'mode':'unknown'},{'mode':'items','page_size':99},{'mode':'items','query':'x'*81}):
|
||||
with self.assertRaises(ValueError):self.catalogue.Search(**kwargs)
|
||||
|
||||
def test_refine_links_and_materials_are_integer_data(self):
|
||||
sword = self.catalogue.FindItem(10)
|
||||
self.assertEqual(sword['refined'],11)
|
||||
recipe = self.catalogue.recipes[sword['recipe']]
|
||||
self.assertEqual((recipe['cost'],recipe['probability']),(600,90))
|
||||
self.assertEqual(self.catalogue.mobs[101]['hp'],126)
|
||||
self.assertEqual((self.catalogue.mobs[101]['attack_min'],self.catalogue.mobs[101]['attack_max']),(20,24))
|
||||
|
||||
def test_reject_bad_version_truncation_duplicate_and_markup(self):
|
||||
cases = [RAW.replace(b'MTWIKI\t1',b'MTWIKI\t9',1), RAW+b'I\tbroken\n',
|
||||
RAW+RAW.splitlines()[1]+b'\n', b'X'*2097153,
|
||||
RAW.replace(b'ITEM_WEAPON',b'ITEM_|Hlink',1), RAW.replace(b'\t600\t90\t',b'\t600\t101\t',1)]
|
||||
for raw in cases:
|
||||
with self.assertRaises((ValueError,UnicodeError)):Catalogue(raw)
|
||||
|
||||
def test_generator_deterministic_when_local_snapshot_exists(self):
|
||||
snapshot=ROOT/'build/wiki-source'
|
||||
if not (snapshot/'refine.tsv').is_file():self.skipTest('private source snapshot not installed')
|
||||
spec=importlib.util.spec_from_file_location('build_wiki',ROOT/'tools/wiki/build_catalogue.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
output=Path(folder)
|
||||
report=module.build(snapshot,output,ROOT/'data/zh_40250')
|
||||
self.assertEqual((output/'catalogue.txt').read_bytes(),RAW)
|
||||
self.assertEqual(report['catalogue_sha256'],self.catalogue.version)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
# 百科:当前服务器模板目录
|
||||
|
||||
AVA2 参考:`uiwiki.py` 的分类/搜索/升级材料与关联跳转;当前实现使用原生 PORT 界面和当前服务器数据,不加载整个 AVA2 root 或改变物品格式。
|
||||
|
||||
服务端的 `tools/wiki/export_public_tables.py` 只读导出 `share/conf` 下四个 proto/name 文件与 `player.refine_proto` 的配方列。导出目录放入客户端忽略的 `build/wiki-source/`:
|
||||
|
||||
```sh
|
||||
python3 tools/wiki/build_catalogue.py --source build/wiki-source
|
||||
python3 tests/wiki/test_catalogue.py
|
||||
```
|
||||
|
||||
输出 `data/wiki/catalogue.txt` 和 `manifest.json`,包含输入哈希与内容版本。名称使用当前服务器名称及已有中文表,不带角色/账号数据。ITEM_NONE 不能一概过滤:AVA2 生物学家任务石使用此类型。目录只表示模板存在,不保证获取途径。
|
||||
|
||||
`scripts/build_zh_locale.py` 自动把目录打为 `wiki.eix/epk`,注册在 pack/Index;Android 增量同步摘要包含目录和生成器。源资料更新后需重新生成、打包并同步两份 pack 和 Index;仅安装新 APK 无法更新静态资料。
|
||||
|
||||
运行时安装 `mt_features`、`mt_wiki_data`、`mt_wiki`。模块按版本/字段/长度/数值/范围校验后一次加载,仅创建当前页控件。首次点击小地图旁“百科”加载数据;支持中文/ID、分类、分页、范围 ID、升级目标/材料跳转与返回;点击生物学家材料图标打开对应详情。
|
||||
|
||||
`mt_features` 为标准库调用及显式 level 导入保留原生 Python 导入语义;原 packed 游戏脚本的其他四参数导入继续原路径,原始 system.py 字节不改。中文搜索通过 `player.FeatureLocaleToUTF8` 使用同一套原生代码页映射,不要求 Python CJK 扩展。
|
||||
|
||||
能力声明 `CAPABILITIES` 当前只打开静态目录;尚未实现服务端能力握手、实时查询协议、掉落/宝箱或模型预览。UI 明示缺失项。升级展示为配方基础值,NPC/道具修正后的实际结果以精炼窗口为准。
|
||||
|
||||
集成测试(本机完整资源可用时):
|
||||
|
||||
```sh
|
||||
cmake --build build/native --target port_login_flow_test -j8
|
||||
MT_TEST_WIKI=1 MT_TEST_MOBILE=1 MT_ASSETS_STRICT=1 \
|
||||
build/native/src/port/port_login_flow_test \
|
||||
"$PWD/build/zh-locale/Client" "$PWD/build/release/src/host/python27.zip"
|
||||
```
|
||||
|
||||
测试通过真实 Python 2 UI/鼠标命中验证小地图入口、中文输入、升级跳转/返回、生物学家材料入口、640×360 排版、缺失资料提示与退出,并回归原离线登录/背包/商店/战斗流程。触控模式是桌面宿主回归,不能代替 Android 真机验收。
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a bounded, versioned wiki catalogue from the current server's public tables.
|
||||
No account/character/configuration data is read. Output is UTF-8 TSV, never executable Python.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FILES = ('item_proto.txt', 'item_names.txt', 'mob_proto.txt', 'mob_names.txt', 'refine.tsv')
|
||||
|
||||
|
||||
def rows(path):
|
||||
for line in path.read_bytes().splitlines():
|
||||
cols = line.split(b'\t')
|
||||
if cols and cols[0].split(b'~')[0].isdigit():
|
||||
yield cols
|
||||
|
||||
|
||||
def names(path, overlay):
|
||||
result = {}
|
||||
for cols in rows(path):
|
||||
if len(cols) > 1:
|
||||
result[int(cols[0].split(b'~')[0])] = cols[1].decode('gbk', 'replace')
|
||||
for path in overlay:
|
||||
for line in path.read_text(encoding='utf-8').splitlines():
|
||||
c = line.split('\t', 1)
|
||||
if len(c) == 2 and c[0].isdigit():
|
||||
result[int(c[0])] = c[1]
|
||||
return result
|
||||
|
||||
|
||||
def clean(value):
|
||||
value = str(value)
|
||||
if any(c in value for c in '\t\r\n\x00') or len(value) > 256:
|
||||
raise ValueError('invalid catalogue cell')
|
||||
# Native text renderer recognizes pipe escape sequences; source names are plain text.
|
||||
return value.replace('|', '/')
|
||||
|
||||
|
||||
def build(source, output, names_dir):
|
||||
hashes = {name: hashlib.sha256((source / name).read_bytes()).hexdigest() for name in FILES}
|
||||
item_names = names(source / 'item_names.txt', [names_dir / 'item_names_zh.txt', names_dir / 'item_names_zh_overlay.txt'])
|
||||
mob_names = names(source / 'mob_names.txt', [names_dir / 'mob_names_zh.txt', names_dir / 'mob_names_zh_overlay.txt'])
|
||||
records, items, mobs, refines = [], {}, set(), {}
|
||||
for c in rows(source / 'item_proto.txt'):
|
||||
if len(c) < 33:
|
||||
raise ValueError('truncated item row')
|
||||
extent = list(map(int, c[0].split(b'~')))
|
||||
start, end = extent[0], extent[-1]
|
||||
if not 0 < start <= end < 2**32 or end - start > 10000 or start in items:
|
||||
raise ValueError('invalid/duplicate item range')
|
||||
kind, subtype = c[2].decode('ascii'), c[3].decode('ascii')
|
||||
if kind == 'ITEM_ELK':
|
||||
continue
|
||||
level = max([int(c[i+1]) for i in (14, 16) if c[i] == b'LEVEL'] + [0])
|
||||
name = item_names.get(start, '物品 #%d' % start)
|
||||
# I id range_end name type subtype minimum_level size refined_vnum refine_set
|
||||
row = ['I', start, end, name, kind, subtype, level, int(c[4]), int(c[11]), int(c[12])]
|
||||
items[start] = row
|
||||
records.append(row)
|
||||
extents = sorted((v[1], v[2]) for v in items.values())
|
||||
if any(a[1] >= b[0] for a, b in zip(extents, extents[1:])):
|
||||
raise ValueError('overlapping item ranges')
|
||||
for c in rows(source / 'mob_proto.txt'):
|
||||
if len(c) < 27:
|
||||
raise ValueError('truncated mob row')
|
||||
if c[3] not in (b'MONSTER', b'STONE'):
|
||||
continue
|
||||
vnum = int(c[0])
|
||||
if vnum in mobs:
|
||||
raise ValueError('duplicate monster')
|
||||
mobs.add(vnum)
|
||||
# M id name type rank level hp damage_min damage_max experience defense
|
||||
records.append(['M', vnum, mob_names.get(vnum, '怪物 #%d' % vnum), c[3].decode('ascii'),
|
||||
c[2].decode('ascii')] + [int(c[i]) for i in (5, 20, 18, 19, 25, 26)])
|
||||
for c in rows(source / 'refine.tsv'):
|
||||
if len(c) != 13:
|
||||
raise ValueError('refine table must contain 13 columns')
|
||||
values = list(map(int, c))
|
||||
rid, cost, prob = values[:3]
|
||||
if rid in refines or rid <= 0 or cost < 0 or not 0 <= prob <= 100:
|
||||
raise ValueError('invalid refine record')
|
||||
mats = [(values[i], values[i+1]) for i in range(3, 13, 2) if values[i] or values[i+1]]
|
||||
if any(v <= 0 or n <= 0 for v, n in mats):
|
||||
raise ValueError('invalid refine ingredient')
|
||||
refines[rid] = True
|
||||
records.append(['R', rid, cost, prob, ','.join('%d:%d' % pair for pair in mats)])
|
||||
if not items or not mobs or not refines or len(records) > 30000:
|
||||
raise ValueError('empty or oversized catalogue')
|
||||
payload = ''.join('\t'.join(clean(c) for c in row) + '\n' for row in records).encode('utf-8')
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / 'catalogue.txt').write_bytes(('MTWIKI\t1\t' + digest + '\n').encode('ascii') + payload)
|
||||
report = dict(schema=1, catalogue_sha256=digest, source_sha256=hashes,
|
||||
items=len(items), monsters=len(mobs), refine_recipes=len(refines),
|
||||
source='203 public server tables; no player data',
|
||||
generator_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
localization_sha256={name:hashlib.sha256((names_dir/name).read_bytes()).hexdigest() for name in ('item_names_zh.txt','item_names_zh_overlay.txt','mob_names_zh.txt','mob_names_zh_overlay.txt')},
|
||||
scope='template catalogue; acquisition and runtime rates not yet queried')
|
||||
(output / 'manifest.json').write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, default=REPO / 'data/wiki')
|
||||
parser.add_argument('--names-dir', type=Path, default=REPO / 'data/zh_40250')
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.source, args.output, args.names_dir), ensure_ascii=False, indent=2))
|
||||
Reference in New Issue
Block a user