Add searchable wiki catalogue and biology material navigation

This commit is contained in:
shen
2026-10-15 00:48:50 -07:00
parent 6472d17306
commit 9d9993b007
21 changed files with 7926 additions and 3 deletions
+2
View File
@@ -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
+2
View File
@@ -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())
+15
View File
@@ -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
+23
View File
@@ -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"
}
+114
View File
@@ -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。
+43
View File
@@ -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 真机验收未做,不能把本机触控模式测试写成真机验证。
+4
View File
@@ -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`。
+28
View File
@@ -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}")
+27
View File
@@ -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";
+6
View File
@@ -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);
}
+98
View File
@@ -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";
+286
View File
@@ -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";
+5
View File
@@ -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 },
+66 -3
View File
@@ -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'"));
+87
View File
@@ -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()
+31
View File
@@ -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 真机验收。
+112
View File
@@ -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))