From 6472d173062fdd07e5b9b869668ae5c5efb2058c Mon Sep 17 00:00:00 2001 From: shen <> Date: Wed, 14 Oct 2026 19:12:38 -0700 Subject: [PATCH] Add mobile touch UI and AVA2 biology system with server tools --- .gitignore | 1 + CMakeLists.txt | 12 +- android/sync_client.py | 2 + audit/manifest.json | 3 + data/zh_40250/README.md | 34 +- data/zh_40250/item_names_zh_overlay.txt | 292 ++++++++++- data/zh_40250/item_stones_server.tsv | 15 + data/zh_40250/itemdesc_zh_overlay.txt | 1 + data/zh_40250/mob_names_zh_overlay.txt | 16 + data/zh_40250/skilldesc_zh_overlay.txt | 24 +- data/zh_40250/weapon_names_source_audit.tsv | 292 +++++++++++ docs/AUTO-HUNT-ENHANCEMENTS.md | 9 + docs/AVA2-40250-COMPARISON.md | 189 ++++++++ docs/D2R-MOBILE-REFERENCE.md | 20 + docs/MOBILE-TOUCH-OPTIMIZATION.md | 109 +++++ docs/PORT-PLAN.md | 15 + scripts/build_hd_ui.py | 4 + scripts/build_zh_locale.py | 43 +- scripts/package_mac_app.sh | 15 +- src/host/live_client.cpp | 8 +- src/host/texture_decode.cpp | 5 + src/host/touch_controller.h | 283 ++++++----- src/host/touch_layout.h | 41 ++ src/host/vk_frame.cpp | 14 +- src/platform/CMakeLists.txt | 2 +- src/platform/ScriptLib/BiologScript.inc | 457 ++++++++++++++++++ src/platform/ScriptLib/MobileHUDScript.inc | 280 +++++++++++ .../ScriptLib/MobileInventoryScript.inc | 125 +++++ src/platform/ScriptLib/PythonBoot.cpp | 81 ++++ src/platform/ScriptLib/PythonBoot.h | 7 + src/port/CMakeLists.txt | 5 + src/port/EterLib/NetStream.cpp | 1 + src/port/UserInterface/PythonPlayer.h | 9 +- .../UserInterface/PythonPlayerAutoHunt.cpp | 89 +++- .../PythonPlayerEventHandler.cpp | 2 + tests/host/touch_layout_test.cpp | 42 ++ tests/port/autohunt_crowd_checks.inc | 43 ++ tests/port/biolog_proto_patch.cpp | 60 +++ tests/port/locale_proto_patch.cpp | 37 +- tests/port/port_login_flow_server.cpp | 46 +- tests/port/port_login_flow_server.h | 3 + tests/port/port_login_flow_test.cpp | 250 +++++++++- tools/server/biolog/README.md | 71 +++ tools/server/biolog/ava2_biolog_core.h | 389 +++++++++++++++ tools/server/biolog/ava2_biolog_shop.h | 26 + tools/server/biolog/biolog_remote.h | 110 +++++ tools/server/biolog/catalog.json | 329 +++++++++++++ tools/server/biolog/deploy_ava2.sh | 29 ++ tools/server/biolog/deploy_game.sh | 23 + tools/server/biolog/import_ava2.py | 56 +++ tools/server/biolog/install_ava2_data.py | 39 ++ tools/server/biolog/make_assets.py | 65 +++ tools/server/biolog/patch_points.py | 43 ++ tools/server/biolog/port_bio_reset.quest | 7 + tools/server/biolog/prepare_source.py | 29 ++ tools/server/biolog/reference.json | 5 + tools/server/biolog/shop.json | 156 ++++++ tools/server/biolog/tests/ava2_core_test.cpp | 103 ++++ tools/server/biolog/tests/item.h | 3 + tools/server/biolog/tests/item_manager.h | 2 + tools/server/biolog/tests/questpc.h | 23 + tools/server/biolog/tests/test_ava2.py | 25 + tools/server/biolog/tests/test_combat.py | 52 ++ tools/server/npc-shops/README.md | 8 +- tools/server/npc-shops/install.sh | 31 +- tools/server/npc-shops/item-mall/items.tsv | 17 + tools/server/npc-shops/item-mall/shop.conf | 2 +- tools/server/npc-shops/item-mall/stones.tsv | 15 + 68 files changed, 4447 insertions(+), 197 deletions(-) create mode 100644 data/zh_40250/item_stones_server.tsv create mode 100644 data/zh_40250/weapon_names_source_audit.tsv create mode 100644 docs/AVA2-40250-COMPARISON.md create mode 100644 docs/D2R-MOBILE-REFERENCE.md create mode 100644 docs/MOBILE-TOUCH-OPTIMIZATION.md create mode 100644 src/host/touch_layout.h create mode 100644 src/platform/ScriptLib/BiologScript.inc create mode 100644 src/platform/ScriptLib/MobileHUDScript.inc create mode 100644 src/platform/ScriptLib/MobileInventoryScript.inc create mode 100644 tests/host/touch_layout_test.cpp create mode 100644 tests/port/autohunt_crowd_checks.inc create mode 100644 tests/port/biolog_proto_patch.cpp create mode 100644 tools/server/biolog/README.md create mode 100644 tools/server/biolog/ava2_biolog_core.h create mode 100644 tools/server/biolog/ava2_biolog_shop.h create mode 100644 tools/server/biolog/biolog_remote.h create mode 100644 tools/server/biolog/catalog.json create mode 100644 tools/server/biolog/deploy_ava2.sh create mode 100644 tools/server/biolog/deploy_game.sh create mode 100644 tools/server/biolog/import_ava2.py create mode 100644 tools/server/biolog/install_ava2_data.py create mode 100644 tools/server/biolog/make_assets.py create mode 100644 tools/server/biolog/patch_points.py create mode 100644 tools/server/biolog/port_bio_reset.quest create mode 100644 tools/server/biolog/prepare_source.py create mode 100644 tools/server/biolog/reference.json create mode 100644 tools/server/biolog/shop.json create mode 100644 tools/server/biolog/tests/ava2_core_test.cpp create mode 100644 tools/server/biolog/tests/item.h create mode 100644 tools/server/biolog/tests/item_manager.h create mode 100644 tools/server/biolog/tests/questpc.h create mode 100644 tools/server/biolog/tests/test_ava2.py create mode 100644 tools/server/biolog/tests/test_combat.py create mode 100644 tools/server/npc-shops/item-mall/stones.tsv diff --git a/.gitignore b/.gitignore index d81f050c..ab7f3a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /build/ /build-*/ /cmake-build-*/ +/dist/ compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f4f9b00..3406645b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,7 +16,17 @@ option(MT_BUILD_TOOLS "Build libgr2 validation tools" OFF) option(MT_BUILD_PORT "Build the 40250 port and its tests" ON) option(MT_BUILD_NATIVE_RENDER "Build the SDL3 and Vulkan client" OFF) -add_subdirectory(src/gr2) +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/../sdk/gr2) + message(STATUS "Using shared SDK: ../sdk/gr2") + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../sdk/gr2 ${CMAKE_CURRENT_BINARY_DIR}/sdk_gr2) +else() + add_subdirectory(src/gr2) +endif() + +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/../sdk/speedtree) + message(STATUS "Using shared SDK: ../sdk/speedtree") + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../sdk/speedtree ${CMAKE_CURRENT_BINARY_DIR}/sdk_speedtree) +endif() add_subdirectory(third_party) add_subdirectory(src/codepage) if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER) diff --git a/android/sync_client.py b/android/sync_client.py index 26aa6d63..5cf7fcb4 100644 --- a/android/sync_client.py +++ b/android/sync_client.py @@ -50,6 +50,8 @@ def run_as(script, **kwargs): def zh_inputs_digest(client_base): h = hashlib.sha256() 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(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()) diff --git a/audit/manifest.json b/audit/manifest.json index eb5b6d3a..a6dadd6d 100644 --- a/audit/manifest.json +++ b/audit/manifest.json @@ -4268,6 +4268,9 @@ "last_test_result": "PARTIAL", "commands": [ "2026-09-30: native UI HiDPI font refresh and sampling checks PASS; previous font runtime fails 9 new assertions. Full native CTest: 29 pass, codepage and live login skipped. Actual mac app HD archive/launch/screenshot checks recorded in audit/evidence/2026-09-30-mac-ui-clarity.md. Aggregate HUD parity remains PARTIAL." + ], + "notes": [ + "2026-10-02: SCREEN waypoint atlas must retain original black RGB under alpha=0; HD minimap.dds colour bleed reproduced, native five-frame waypoint smoke renders clean rings after original-atlas fallback. build/minimap-fix-smoke.png. Full Windows visual parity remains unverified." ] }, "remaining": [ diff --git a/data/zh_40250/README.md b/data/zh_40250/README.md index 3b9820cd..d8261082 100644 --- a/data/zh_40250/README.md +++ b/data/zh_40250/README.md @@ -14,7 +14,9 @@ 它们的审核译文(2026-09-28 确认)单独放在 `item_names_zh_overlay.txt`、 `mob_names_zh_overlay.txt`,界面回退键译文在 `locale_game_zh_overlay.txt`; `scripts/build_zh_locale.py` 构建时以 overlay 覆盖快照同 VNUM / 同键的值。 -旧的龙驹中文表与线上表有 21 个相同 VNUM 不同名;不把旧表覆盖到服务端。 +中文译名以 `server_extracted/40250_deploy` 为优先参考;客户端技能和物品说明参考 +`server_extracted/client_locale/longju/locale/taiwan`。与资料冲突的译名通过 overlay 修正, +线上原始快照保留不动。`server_extracted/database` 与部署表有少数地图名差异时采用部署表。 服务端名称若再更新,需重新只读导出并核对差异后更新本快照。 ## 任务文本(translate.lua) @@ -36,15 +38,16 @@ `scripts/build_zh_locale.py` 以 40250 的 `skilldesc.txt` 为底,逐行取龙驹同 VNUM 的名称、说明和条件列(2–8) 以及效果列(17/20/23)。客户端把效果列当作 `_snprintf` 格式串传入最小值、最大值,所以效果列只有在格式参数与英文一致时才替换。 -`skilldesc_zh_overlay.txt` 按 `VNUM\t列号\t译文` 覆盖龙驹缺的或者不对的格。例如 126–128 在龙驹里叫吴/蜀/魏国方言, -这里改成盛唐语、秦皇语、汉武语;骑马技能的说明补上等级要求。 +`skilldesc_zh_overlay.txt` 按 `VNUM\t列号\t译文` 覆盖龙驹缺的或者不对的格。126–128 沿用资料里的吴/蜀/魏国方言;骑马技能说明沿用资料原文。 +效果列仍需适配当前客户端格式参数,例如反射机率使用 `%.1f%%`。 ## 整行覆盖(item_list.txt、itemdesc.txt) `item_list_zh_overlay.txt`、`itemdesc_zh_overlay.txt` 每行是目标文件的一整行(UTF-8,第一列 VNUM), `build_zh_locale.py` 用它替换同 VNUM 的行(`ROW_FILES`),其余行字节不变;VNUM 不存在就报错。 -用于服务端改了用途的物品,例如 50307 小狩猎套装礼包(`tools/server/hunt-set`)换成礼盒图标和中文说明, -70058 移动戒指(`tools/server/ring-warp`)写明不限等级、无限次、无冷却。 +50307 保留用户确认的自定义名称“小狩猎套装礼包”及原有说明;71118 恢复为勇气披风+及对应说明。 +70058 移动戒指(`tools/server/ring-warp`)的自定义说明保留;提取客户端资料没有该 VNUM 的说明。 +图标与使用限制不属于本次翻译修改。 ## 物品限制(item_limits_server.tsv) @@ -63,3 +66,24 @@ - 如果最后一行只剩一两个字,会把前面的行收窄一些重新折。 - 表里没有的格子保留英文,`zh-locale-report.json` 里的 `itemdesc.txt translation.english_rows` 会列出这些 VNUM。 - `itemdesc_zh_overlay.txt` 的整行覆盖在翻译之后执行,它的说明列也用同样的方式折行。 + +## 2026-10-01 资料对齐 + +修正 71118 的物品名和说明、16 个怪物/NPC/地图标记名及 12 个技能文本单元格。 +50307 按用户要求保留自定义礼包。 +UI 中为兼容当前客户端而调整的占位符、百分号及明显错字继续保留。 + +### 武器名称来源 + +武器译名以提取客户端 `server_extracted/client_locale/longju/locale/taiwan/item_proto` +的 `szLocaleName` 为准。该文件为 MIPX v1、记录宽度 152 字节,按类型 WEAPON(1)和 +VNUM 匹配,不复制属性。它与 `server_extracted/database`、`40250_deploy` 的同 VNUM +名称存在差异;武器优先采用提取客户端原型,其他名称继续参考部署表。 + +本次修正 29 组武器的 +0~+9,共 290 条译名,例如武士刀→短刀、风雷剑→龙虎刀、 +鬼牙剑→夺魂刀、碧玉刀→封神刀。逐条来源和修改前名称见 `weapon_names_source_audit.tsv`。 +修改通过 `item_names_zh_overlay.txt` 生效,原始线上名称快照保留不动。 + +## +6 镶嵌石配置修复(2026-10-02) + +当前 203 的 28700~28713 原为 ITEM_NONE,与 server_extracted 备份不同。`item_stones_server.tsv` 恢复备份中的类型、佩戴位置、属性和六个 value 字段;`locale_proto_patch` 只允许这 14 条记录的对应字段变化,其余非名称字段继续逐字节校验。服务端同份配置在 `tools/server/npc-shops/item-mall/stones.tsv`,商店安装时恢复。已有背包内同编号石头也使用新配置。 diff --git a/data/zh_40250/item_names_zh_overlay.txt b/data/zh_40250/item_names_zh_overlay.txt index 97f0fa4b..7eb81026 100644 --- a/data/zh_40250/item_names_zh_overlay.txt +++ b/data/zh_40250/item_names_zh_overlay.txt @@ -1368,7 +1368,7 @@ VNUM LOCALE_NAME 71115 野猪坐骑券(蓝) 71116 狼坐骑券(黄) 71117 狼坐骑券(蓝) -71118 虎坐骑券(黄) +71118 勇气披风+ 71119 虎坐骑券(蓝) 71120 狮坐骑券(黄) 71121 狮坐骑券(蓝) @@ -1608,3 +1608,293 @@ VNUM LOCALE_NAME 164300 传说黑龙石 164400 传说黑龙石 50307 小狩猎套装礼包 +10 短刀+0 +11 短刀+1 +12 短刀+2 +13 短刀+3 +14 短刀+4 +15 短刀+5 +16 短刀+6 +17 短刀+7 +18 短刀+8 +19 短刀+9 +140 龙虎刀+0 +141 龙虎刀+1 +142 龙虎刀+2 +143 龙虎刀+3 +144 龙虎刀+4 +145 龙虎刀+5 +146 龙虎刀+6 +147 龙虎刀+7 +148 龙虎刀+8 +149 龙虎刀+9 +150 夺魂刀+0 +151 夺魂刀+1 +152 夺魂刀+2 +153 夺魂刀+3 +154 夺魂刀+4 +155 夺魂刀+5 +156 夺魂刀+6 +157 夺魂刀+7 +158 夺魂刀+8 +159 夺魂刀+9 +160 封神刀+0 +161 封神刀+1 +162 封神刀+2 +163 封神刀+3 +164 封神刀+4 +165 封神刀+5 +166 封神刀+6 +167 封神刀+7 +168 封神刀+8 +169 封神刀+9 +180 伏牺刀+0 +181 伏牺刀+1 +182 伏牺刀+2 +183 伏牺刀+3 +184 伏牺刀+4 +185 伏牺刀+5 +186 伏牺刀+6 +187 伏牺刀+7 +188 伏牺刀+8 +189 伏牺刀+9 +190 龙颚闪+0 +191 龙颚闪+1 +192 龙颚闪+2 +193 龙颚闪+3 +194 龙颚闪+4 +195 龙颚闪+5 +196 龙颚闪+6 +197 龙颚闪+7 +198 龙颚闪+8 +199 龙颚闪+9 +200 九龙神剑+0 +201 九龙神剑+1 +202 九龙神剑+2 +203 九龙神剑+3 +204 九龙神剑+4 +205 九龙神剑+5 +206 九龙神剑+6 +207 九龙神剑+7 +208 九龙神剑+8 +209 九龙神剑+9 +220 神兵‖天山苍穹刀+0 +221 神兵‖天山苍穹刀+1 +222 神兵‖天山苍穹刀+2 +223 神兵‖天山苍穹刀+3 +224 神兵‖天山苍穹刀+4 +225 神兵‖天山苍穹刀+5 +226 神兵‖天山苍穹刀+6 +227 神兵‖天山苍穹刀+7 +228 神兵‖天山苍穹刀+8 +229 神兵‖天山苍穹刀+9 +240 七窍破魂剑+0 +241 七窍破魂剑+1 +242 七窍破魂剑+2 +243 七窍破魂剑+3 +244 七窍破魂剑+4 +245 七窍破魂剑+5 +246 七窍破魂剑+6 +247 七窍破魂剑+7 +248 七窍破魂剑+8 +249 七窍破魂剑+9 +260 神兵‖晨星弑月刀+0 +261 神兵‖晨星弑月刀+1 +262 神兵‖晨星弑月刀+2 +263 神兵‖晨星弑月刀+3 +264 神兵‖晨星弑月刀+4 +265 神兵‖晨星弑月刀+5 +266 神兵‖晨星弑月刀+6 +267 神兵‖晨星弑月刀+7 +268 神兵‖晨星弑月刀+8 +269 神兵‖晨星弑月刀+9 +270 五虎伏魔剑+0 +271 五虎伏魔剑+1 +272 五虎伏魔剑+2 +273 五虎伏魔剑+3 +274 五虎伏魔剑+4 +275 五虎伏魔剑+5 +276 五虎伏魔剑+6 +277 五虎伏魔剑+7 +278 五虎伏魔剑+8 +279 五虎伏魔剑+9 +290 青焰刀+0 +291 青焰刀+1 +292 青焰刀+2 +293 青焰刀+3 +294 青焰刀+4 +295 青焰刀+5 +296 青焰刀+6 +297 青焰刀+7 +298 青焰刀+8 +299 青焰刀+9 +460 造化神器‖屠龙刀+0 +461 造化神器‖屠龙刀+1 +462 造化神器‖屠龙刀+2 +463 造化神器‖屠龙刀+3 +464 造化神器‖屠龙刀+4 +465 造化神器‖屠龙刀+5 +466 造化神器‖屠龙刀+6 +467 造化神器‖屠龙刀+7 +468 造化神器‖屠龙刀+8 +469 造化神器‖屠龙刀+9 +1100 冰牙+0 +1101 冰牙+1 +1102 冰牙+2 +1103 冰牙+3 +1104 冰牙+4 +1105 冰牙+5 +1106 冰牙+6 +1107 冰牙+7 +1108 冰牙+8 +1109 冰牙+9 +1120 乌皇剑+0 +1121 乌皇剑+1 +1122 乌皇剑+2 +1123 乌皇剑+3 +1124 乌皇剑+4 +1125 乌皇剑+5 +1126 乌皇剑+6 +1127 乌皇剑+7 +1128 乌皇剑+8 +1129 乌皇剑+9 +1160 神兵‖流光飞影+0 +1161 神兵‖流光飞影+1 +1162 神兵‖流光飞影+2 +1163 神兵‖流光飞影+3 +1164 神兵‖流光飞影+4 +1165 神兵‖流光飞影+5 +1166 神兵‖流光飞影+6 +1167 神兵‖流光飞影+7 +1168 神兵‖流光飞影+8 +1169 神兵‖流光飞影+9 +1170 吴钩+0 +1171 吴钩+1 +1172 吴钩+2 +1173 吴钩+3 +1174 吴钩+4 +1175 吴钩+5 +1176 吴钩+6 +1177 吴钩+7 +1178 吴钩+8 +1179 吴钩+9 +2160 魔神弓+0 +2161 魔神弓+1 +2162 魔神弓+2 +2163 魔神弓+3 +2164 魔神弓+4 +2165 魔神弓+5 +2166 魔神弓+6 +2167 魔神弓+7 +2168 魔神弓+8 +2169 魔神弓+9 +2170 铁牌弓+0 +2171 铁牌弓+1 +2172 铁牌弓+2 +2173 铁牌弓+3 +2174 铁牌弓+4 +2175 铁牌弓+5 +2176 铁牌弓+6 +2177 铁牌弓+7 +2178 铁牌弓+8 +2179 铁牌弓+9 +2180 神兵‖紫禁炫翼弓+0 +2181 神兵‖紫禁炫翼弓+1 +2182 神兵‖紫禁炫翼弓+2 +2183 神兵‖紫禁炫翼弓+3 +2184 神兵‖紫禁炫翼弓+4 +2185 神兵‖紫禁炫翼弓+5 +2186 神兵‖紫禁炫翼弓+6 +2187 神兵‖紫禁炫翼弓+7 +2188 神兵‖紫禁炫翼弓+8 +2189 神兵‖紫禁炫翼弓+9 +3150 炎龙魂+0 +3151 炎龙魂+1 +3152 炎龙魂+2 +3153 炎龙魂+3 +3154 炎龙魂+4 +3155 炎龙魂+5 +3156 炎龙魂+6 +3157 炎龙魂+7 +3158 炎龙魂+8 +3159 炎龙魂+9 +3160 狱焰+0 +3161 狱焰+1 +3162 狱焰+2 +3163 狱焰+3 +3164 狱焰+4 +3165 狱焰+5 +3166 狱焰+6 +3167 狱焰+7 +3168 狱焰+8 +3169 狱焰+9 +3200 神兵‖九天灭世刀+0 +3201 神兵‖九天灭世刀+1 +3202 神兵‖九天灭世刀+2 +3203 神兵‖九天灭世刀+3 +3204 神兵‖九天灭世刀+4 +3205 神兵‖九天灭世刀+5 +3206 神兵‖九天灭世刀+6 +3207 神兵‖九天灭世刀+7 +3208 神兵‖九天灭世刀+8 +3209 神兵‖九天灭世刀+9 +3210 玄冥剑+0 +3211 玄冥剑+1 +3212 玄冥剑+2 +3213 玄冥剑+3 +3214 玄冥剑+4 +3215 玄冥剑+5 +3216 玄冥剑+6 +3217 玄冥剑+7 +3218 玄冥剑+8 +3219 玄冥剑+9 +4040 日影黄泉+0 +4041 日影黄泉+1 +4042 日影黄泉+2 +4043 日影黄泉+3 +4044 日影黄泉+4 +4045 日影黄泉+5 +4046 日影黄泉+6 +4047 日影黄泉+7 +4048 日影黄泉+8 +4049 日影黄泉+9 +5090 天神铃+0 +5091 天神铃+1 +5092 天神铃+2 +5093 天神铃+3 +5094 天神铃+4 +5095 天神铃+5 +5096 天神铃+6 +5097 天神铃+7 +5098 天神铃+8 +5099 天神铃+9 +5120 青竹铃+0 +5121 青竹铃+1 +5122 青竹铃+2 +5123 青竹铃+3 +5124 青竹铃+4 +5125 青竹铃+5 +5126 青竹铃+6 +5127 青竹铃+7 +5128 青竹铃+8 +5129 青竹铃+9 +5150 神兵‖法神杖+0 +5151 神兵‖法神杖+1 +5152 神兵‖法神杖+2 +5153 神兵‖法神杖+3 +5154 神兵‖法神杖+4 +5155 神兵‖法神杖+5 +5156 神兵‖法神杖+6 +5157 神兵‖法神杖+7 +5158 神兵‖法神杖+8 +5159 神兵‖法神杖+9 +7160 七翎扇+0 +7161 七翎扇+1 +7162 七翎扇+2 +7163 七翎扇+3 +7164 七翎扇+4 +7165 七翎扇+5 +7166 七翎扇+6 +7167 七翎扇+7 +7168 七翎扇+8 +7169 七翎扇+9 diff --git a/data/zh_40250/item_stones_server.tsv b/data/zh_40250/item_stones_server.tsv new file mode 100644 index 00000000..bb194237 --- /dev/null +++ b/data/zh_40250/item_stones_server.tsv @@ -0,0 +1,15 @@ +# vnum type subtype wear apply_type apply_value value0 value1 value2 value3 value4 value5 +28700 10 0 16 16 25 0 0 0 0 0 17 +28701 10 0 16 15 25 0 0 0 0 0 18 +28702 10 0 16 9 50 0 0 0 0 0 19 +28703 10 0 16 59 55 0 0 0 0 0 20 +28704 10 0 16 60 55 0 0 0 0 0 21 +28705 10 0 16 61 55 0 0 0 0 0 22 +28706 10 0 16 62 55 0 0 0 0 0 23 +28707 10 0 16 63 25 0 0 0 0 0 24 +28708 10 0 1 27 25 0 0 0 0 0 25 +28709 10 0 1 28 25 0 0 0 0 0 26 +28710 10 0 1 2 1000 0 0 0 0 0 27 +28711 10 0 1 1 2000 0 0 0 0 0 28 +28712 10 0 1 54 500 0 0 0 0 0 29 +28713 10 0 1 8 50 0 0 0 0 0 30 diff --git a/data/zh_40250/itemdesc_zh_overlay.txt b/data/zh_40250/itemdesc_zh_overlay.txt index 1749d604..cda2f1d0 100644 --- a/data/zh_40250/itemdesc_zh_overlay.txt +++ b/data/zh_40250/itemdesc_zh_overlay.txt @@ -1,3 +1,4 @@ # vnum name description: whole itemdesc.txt rows (build_zh_locale.py ROW_FILES) 50307 小狩猎套装礼包 打开后按职业获得一套1级+9装备:武器、盔甲、头盔、盾牌、手镯、鞋子、项链、耳环,每件5条满值属性。需留15格背包空位,每个角色限开一次。 70058 移动戒指 此戒指可将你传送到本国一村、二村或各主要地图,任何等级都可使用,可无限次使用,没有冷却时间。不可交易。 +71118 勇气披风+ 古代战士的披风. 对敌人有莫名的吸引力 | 使用后每10秒召唤一次身边的怪物 | 升级时必备物品 diff --git a/data/zh_40250/mob_names_zh_overlay.txt b/data/zh_40250/mob_names_zh_overlay.txt index ad75d458..ba9b81ed 100644 --- a/data/zh_40250/mob_names_zh_overlay.txt +++ b/data/zh_40250/mob_names_zh_overlay.txt @@ -230,3 +230,19 @@ VNUM LOCALE_NAME 33011 碧玉炼圣 33012 翡翠炼圣 33013 青玉炼圣 +10033 天意一层 10594 7525 +10034 天意二层 10594 7525 +10066 赤鬼木林 9915 11195 +10079 天意一层 1364 13436 +10080 天意二层 2413 12755 +10081 天意一层 99 13618 +10083 天意一层 1360 14972 +10084 天意二层 2414 14289 +10085 天意一层 99 15154 +10087 天意一层 1360 16508 +10088 天意二层 2414 15825 +10089 天意一层 99 16690 +10091 天意一层 1360 18044 +10092 天意二层 2414 17361 +20075 亡灵塔防具师 +20076 亡灵塔饰品师 diff --git a/data/zh_40250/skilldesc_zh_overlay.txt b/data/zh_40250/skilldesc_zh_overlay.txt index ac6a0d9f..a7c59c37 100644 --- a/data/zh_40250/skilldesc_zh_overlay.txt +++ b/data/zh_40250/skilldesc_zh_overlay.txt @@ -5,15 +5,15 @@ VNUM COLUMN TEXT 19 8 不会被击倒 20 20 气绝机率 %.0f%% 64 20 对方攻击失败机率增加 -65 20 近身伤害反射 %.1f%% -126 2 盛唐语 -126 5 可以听懂盛唐国的语言 -127 2 秦皇语 -127 5 可以听懂秦皇国的语言 -128 2 汉武语 -128 5 可以听懂汉武国的语言 -137 5 骑马奔跑并攻击周围的敌人(50级起) -138 5 击倒前方拦截的敌人(52级起) -139 5 攻击周围的敌人(55级起) -140 5 攻击拦截的前方敌人(50级起) -152 17 最大生命值 +%.0f%% +65 20 物理攻击反射机率 %.1f%% +126 2 吴国方言 +126 5 可以听与说吴国的方言 +127 2 蜀国方言 +127 5 可以听与说蜀国的方言 +128 2 魏国方言 +128 5 可以听与说魏国的方言 +137 5 骑马奔跑并攻击周围的敌人 +138 5 击倒前方拦截的敌人 +139 5 攻击周围的敌人 +140 5 攻击拦截的前方敌人 +152 17 提升最高Hp +%.0f%% diff --git a/data/zh_40250/weapon_names_source_audit.tsv b/data/zh_40250/weapon_names_source_audit.tsv new file mode 100644 index 00000000..9116dafe --- /dev/null +++ b/data/zh_40250/weapon_names_source_audit.tsv @@ -0,0 +1,292 @@ +# Source: server_extracted/client_locale/longju/locale/taiwan/item_proto (MIPX v1, stride 152, type WEAPON=1) +VNUM PREVIOUS_NAME SOURCE_NAME +10 武士刀+0 短刀+0 +11 武士刀+1 短刀+1 +12 武士刀+2 短刀+2 +13 武士刀+3 短刀+3 +14 武士刀+4 短刀+4 +15 武士刀+5 短刀+5 +16 武士刀+6 短刀+6 +17 武士刀+7 短刀+7 +18 武士刀+8 短刀+8 +19 武士刀+9 短刀+9 +140 风雷剑+0 龙虎刀+0 +141 风雷剑+1 龙虎刀+1 +142 风雷剑+2 龙虎刀+2 +143 风雷剑+3 龙虎刀+3 +144 风雷剑+4 龙虎刀+4 +145 风雷剑+5 龙虎刀+5 +146 风雷剑+6 龙虎刀+6 +147 风雷剑+7 龙虎刀+7 +148 风雷剑+8 龙虎刀+8 +149 风雷剑+9 龙虎刀+9 +150 鬼牙剑+0 夺魂刀+0 +151 鬼牙剑+1 夺魂刀+1 +152 鬼牙剑+2 夺魂刀+2 +153 鬼牙剑+3 夺魂刀+3 +154 鬼牙剑+4 夺魂刀+4 +155 鬼牙剑+5 夺魂刀+5 +156 鬼牙剑+6 夺魂刀+6 +157 鬼牙剑+7 夺魂刀+7 +158 鬼牙剑+8 夺魂刀+8 +159 鬼牙剑+9 夺魂刀+9 +160 碧玉刀+0 封神刀+0 +161 碧玉刀+1 封神刀+1 +162 碧玉刀+2 封神刀+2 +163 碧玉刀+3 封神刀+3 +164 碧玉刀+4 封神刀+4 +165 碧玉刀+5 封神刀+5 +166 碧玉刀+6 封神刀+6 +167 碧玉刀+7 封神刀+7 +168 碧玉刀+8 封神刀+8 +169 碧玉刀+9 封神刀+9 +180 飞火流云刀+0 伏牺刀+0 +181 飞火流云刀+1 伏牺刀+1 +182 飞火流云刀+2 伏牺刀+2 +183 飞火流云刀+3 伏牺刀+3 +184 飞火流云刀+4 伏牺刀+4 +185 飞火流云刀+5 伏牺刀+5 +186 飞火流云刀+6 伏牺刀+6 +187 飞火流云刀+7 伏牺刀+7 +188 飞火流云刀+8 伏牺刀+8 +189 飞火流云刀+9 伏牺刀+9 +190 黑翼魔神刀+0 龙颚闪+0 +191 黑翼魔神刀+1 龙颚闪+1 +192 黑翼魔神刀+2 龙颚闪+2 +193 黑翼魔神刀+3 龙颚闪+3 +194 黑翼魔神刀+4 龙颚闪+4 +195 黑翼魔神刀+5 龙颚闪+5 +196 黑翼魔神刀+6 龙颚闪+6 +197 黑翼魔神刀+7 龙颚闪+7 +198 黑翼魔神刀+8 龙颚闪+8 +199 黑翼魔神刀+9 龙颚闪+9 +200 龙爪赤魂刀+0 九龙神剑+0 +201 龙爪赤魂刀+1 九龙神剑+1 +202 龙爪赤魂刀+2 九龙神剑+2 +203 龙爪赤魂刀+3 九龙神剑+3 +204 龙爪赤魂刀+4 九龙神剑+4 +205 龙爪赤魂刀+5 九龙神剑+5 +206 龙爪赤魂刀+6 九龙神剑+6 +207 龙爪赤魂刀+7 九龙神剑+7 +208 龙爪赤魂刀+8 九龙神剑+8 +209 龙爪赤魂刀+9 九龙神剑+9 +220 苍穹刀+0 神兵‖天山苍穹刀+0 +221 苍穹刀+1 神兵‖天山苍穹刀+1 +222 苍穹刀+2 神兵‖天山苍穹刀+2 +223 苍穹刀+3 神兵‖天山苍穹刀+3 +224 苍穹刀+4 神兵‖天山苍穹刀+4 +225 苍穹刀+5 神兵‖天山苍穹刀+5 +226 苍穹刀+6 神兵‖天山苍穹刀+6 +227 苍穹刀+7 神兵‖天山苍穹刀+7 +228 苍穹刀+8 神兵‖天山苍穹刀+8 +229 苍穹刀+9 神兵‖天山苍穹刀+9 +240 五刃刀+0 七窍破魂剑+0 +241 五刃刀+1 七窍破魂剑+1 +242 五刃刀+2 七窍破魂剑+2 +243 五刃刀+3 七窍破魂剑+3 +244 五刃刀+4 七窍破魂剑+4 +245 五刃刀+5 七窍破魂剑+5 +246 五刃刀+6 七窍破魂剑+6 +247 五刃刀+7 七窍破魂剑+7 +248 五刃刀+8 七窍破魂剑+8 +249 五刃刀+9 七窍破魂剑+9 +260 弑月刀+0 神兵‖晨星弑月刀+0 +261 弑月刀+1 神兵‖晨星弑月刀+1 +262 弑月刀+2 神兵‖晨星弑月刀+2 +263 弑月刀+3 神兵‖晨星弑月刀+3 +264 弑月刀+4 神兵‖晨星弑月刀+4 +265 弑月刀+5 神兵‖晨星弑月刀+5 +266 弑月刀+6 神兵‖晨星弑月刀+6 +267 弑月刀+7 神兵‖晨星弑月刀+7 +268 弑月刀+8 神兵‖晨星弑月刀+8 +269 弑月刀+9 神兵‖晨星弑月刀+9 +270 紫云伏魔刀+0 五虎伏魔剑+0 +271 紫云伏魔刀+1 五虎伏魔剑+1 +272 紫云伏魔刀+2 五虎伏魔剑+2 +273 紫云伏魔刀+3 五虎伏魔剑+3 +274 紫云伏魔刀+4 五虎伏魔剑+4 +275 紫云伏魔刀+5 五虎伏魔剑+5 +276 紫云伏魔刀+6 五虎伏魔剑+6 +277 紫云伏魔刀+7 五虎伏魔剑+7 +278 紫云伏魔刀+8 五虎伏魔剑+8 +279 紫云伏魔刀+9 五虎伏魔剑+9 +290 赤焰刀+0 青焰刀+0 +291 赤焰刀+1 青焰刀+1 +292 赤焰刀+2 青焰刀+2 +293 赤焰刀+3 青焰刀+3 +294 赤焰刀+4 青焰刀+4 +295 赤焰刀+5 青焰刀+5 +296 赤焰刀+6 青焰刀+6 +297 赤焰刀+7 青焰刀+7 +298 赤焰刀+8 青焰刀+8 +299 赤焰刀+9 青焰刀+9 +1100 无极双刀+0 冰牙+0 +1101 无极双刀+1 冰牙+1 +1102 无极双刀+2 冰牙+2 +1103 无极双刀+3 冰牙+3 +1104 无极双刀+4 冰牙+4 +1105 无极双刀+5 冰牙+5 +1106 无极双刀+6 冰牙+6 +1107 无极双刀+7 冰牙+7 +1108 无极双刀+8 冰牙+8 +1109 无极双刀+9 冰牙+9 +1120 夜魔异刃双刀+0 乌皇剑+0 +1121 夜魔异刃双刀+1 乌皇剑+1 +1122 夜魔异刃双刀+2 乌皇剑+2 +1123 夜魔异刃双刀+3 乌皇剑+3 +1124 夜魔异刃双刀+4 乌皇剑+4 +1125 夜魔异刃双刀+5 乌皇剑+5 +1126 夜魔异刃双刀+6 乌皇剑+6 +1127 夜魔异刃双刀+7 乌皇剑+7 +1128 夜魔异刃双刀+8 乌皇剑+8 +1129 夜魔异刃双刀+9 乌皇剑+9 +1160 流光飞影+0 神兵‖流光飞影+0 +1161 流光飞影+1 神兵‖流光飞影+1 +1162 流光飞影+2 神兵‖流光飞影+2 +1163 流光飞影+3 神兵‖流光飞影+3 +1164 流光飞影+4 神兵‖流光飞影+4 +1165 流光飞影+5 神兵‖流光飞影+5 +1166 流光飞影+6 神兵‖流光飞影+6 +1167 流光飞影+7 神兵‖流光飞影+7 +1168 流光飞影+8 神兵‖流光飞影+8 +1169 流光飞影+9 神兵‖流光飞影+9 +1170 闪燕双刀+0 吴钩+0 +1171 闪燕双刀+1 吴钩+1 +1172 闪燕双刀+2 吴钩+2 +1173 闪燕双刀+3 吴钩+3 +1174 闪燕双刀+4 吴钩+4 +1175 闪燕双刀+5 吴钩+5 +1176 闪燕双刀+6 吴钩+6 +1177 闪燕双刀+7 吴钩+7 +1178 闪燕双刀+8 吴钩+8 +1179 闪燕双刀+9 吴钩+9 +2160 魔神武尊弓+0 魔神弓+0 +2161 魔神武尊弓+1 魔神弓+1 +2162 魔神武尊弓+2 魔神弓+2 +2163 魔神武尊弓+3 魔神弓+3 +2164 魔神武尊弓+4 魔神弓+4 +2165 魔神武尊弓+5 魔神弓+5 +2166 魔神武尊弓+6 魔神弓+6 +2167 魔神武尊弓+7 魔神弓+7 +2168 魔神武尊弓+8 魔神弓+8 +2169 魔神武尊弓+9 魔神弓+9 +2170 金鹏万里弓+0 铁牌弓+0 +2171 金鹏万里弓+1 铁牌弓+1 +2172 金鹏万里弓+2 铁牌弓+2 +2173 金鹏万里弓+3 铁牌弓+3 +2174 金鹏万里弓+4 铁牌弓+4 +2175 金鹏万里弓+5 铁牌弓+5 +2176 金鹏万里弓+6 铁牌弓+6 +2177 金鹏万里弓+7 铁牌弓+7 +2178 金鹏万里弓+8 铁牌弓+8 +2179 金鹏万里弓+9 铁牌弓+9 +2180 炫翼弓+0 神兵‖紫禁炫翼弓+0 +2181 炫翼弓+1 神兵‖紫禁炫翼弓+1 +2182 炫翼弓+2 神兵‖紫禁炫翼弓+2 +2183 炫翼弓+3 神兵‖紫禁炫翼弓+3 +2184 炫翼弓+4 神兵‖紫禁炫翼弓+4 +2185 炫翼弓+5 神兵‖紫禁炫翼弓+5 +2186 炫翼弓+6 神兵‖紫禁炫翼弓+6 +2187 炫翼弓+7 神兵‖紫禁炫翼弓+7 +2188 炫翼弓+8 神兵‖紫禁炫翼弓+8 +2189 炫翼弓+9 神兵‖紫禁炫翼弓+9 +3150 飞龙裂空斩+0 炎龙魂+0 +3151 飞龙裂空斩+1 炎龙魂+1 +3152 飞龙裂空斩+2 炎龙魂+2 +3153 飞龙裂空斩+3 炎龙魂+3 +3154 飞龙裂空斩+4 炎龙魂+4 +3155 飞龙裂空斩+5 炎龙魂+5 +3156 飞龙裂空斩+6 炎龙魂+6 +3157 飞龙裂空斩+7 炎龙魂+7 +3158 飞龙裂空斩+8 炎龙魂+8 +3159 飞龙裂空斩+9 炎龙魂+9 +3160 炎狱霸王斩+0 狱焰+0 +3161 炎狱霸王斩+1 狱焰+1 +3162 炎狱霸王斩+2 狱焰+2 +3163 炎狱霸王斩+3 狱焰+3 +3164 炎狱霸王斩+4 狱焰+4 +3165 炎狱霸王斩+5 狱焰+5 +3166 炎狱霸王斩+6 狱焰+6 +3167 炎狱霸王斩+7 狱焰+7 +3168 炎狱霸王斩+8 狱焰+8 +3169 炎狱霸王斩+9 狱焰+9 +3200 灭世刀+0 神兵‖九天灭世刀+0 +3201 灭世刀+1 神兵‖九天灭世刀+1 +3202 灭世刀+2 神兵‖九天灭世刀+2 +3203 灭世刀+3 神兵‖九天灭世刀+3 +3204 灭世刀+4 神兵‖九天灭世刀+4 +3205 灭世刀+5 神兵‖九天灭世刀+5 +3206 灭世刀+6 神兵‖九天灭世刀+6 +3207 灭世刀+7 神兵‖九天灭世刀+7 +3208 灭世刀+8 神兵‖九天灭世刀+8 +3209 灭世刀+9 神兵‖九天灭世刀+9 +3210 银翎斩+0 玄冥剑+0 +3211 银翎斩+1 玄冥剑+1 +3212 银翎斩+2 玄冥剑+2 +3213 银翎斩+3 玄冥剑+3 +3214 银翎斩+4 玄冥剑+4 +3215 银翎斩+5 玄冥剑+5 +3216 银翎斩+6 玄冥剑+6 +3217 银翎斩+7 玄冥剑+7 +3218 银翎斩+8 玄冥剑+8 +3219 银翎斩+9 玄冥剑+9 +4040 逆天夺魄双刀+0 日影黄泉+0 +4041 逆天夺魄双刀+1 日影黄泉+1 +4042 逆天夺魄双刀+2 日影黄泉+2 +4043 逆天夺魄双刀+3 日影黄泉+3 +4044 逆天夺魄双刀+4 日影黄泉+4 +4045 逆天夺魄双刀+5 日影黄泉+5 +4046 逆天夺魄双刀+6 日影黄泉+6 +4047 逆天夺魄双刀+7 日影黄泉+7 +4048 逆天夺魄双刀+8 日影黄泉+8 +4049 逆天夺魄双刀+9 日影黄泉+9 +5090 凤鸣乾坤铃+0 天神铃+0 +5091 凤鸣乾坤铃+1 天神铃+1 +5092 凤鸣乾坤铃+2 天神铃+2 +5093 凤鸣乾坤铃+3 天神铃+3 +5094 凤鸣乾坤铃+4 天神铃+4 +5095 凤鸣乾坤铃+5 天神铃+5 +5096 凤鸣乾坤铃+6 天神铃+6 +5097 凤鸣乾坤铃+7 天神铃+7 +5098 凤鸣乾坤铃+8 天神铃+8 +5099 凤鸣乾坤铃+9 天神铃+9 +5120 蛇魔夺魂铃+0 青竹铃+0 +5121 蛇魔夺魂铃+1 青竹铃+1 +5122 蛇魔夺魂铃+2 青竹铃+2 +5123 蛇魔夺魂铃+3 青竹铃+3 +5124 蛇魔夺魂铃+4 青竹铃+4 +5125 蛇魔夺魂铃+5 青竹铃+5 +5126 蛇魔夺魂铃+6 青竹铃+6 +5127 蛇魔夺魂铃+7 青竹铃+7 +5128 蛇魔夺魂铃+8 青竹铃+8 +5129 蛇魔夺魂铃+9 青竹铃+9 +5150 法神杖+0 神兵‖法神杖+0 +5151 法神杖+1 神兵‖法神杖+1 +5152 法神杖+2 神兵‖法神杖+2 +5153 法神杖+3 神兵‖法神杖+3 +5154 法神杖+4 神兵‖法神杖+4 +5155 法神杖+5 神兵‖法神杖+5 +5156 法神杖+6 神兵‖法神杖+6 +5157 法神杖+7 神兵‖法神杖+7 +5158 法神杖+8 神兵‖法神杖+8 +5159 法神杖+9 神兵‖法神杖+9 +7160 骨魔卷云扇+0 七翎扇+0 +7161 骨魔卷云扇+1 七翎扇+1 +7162 骨魔卷云扇+2 七翎扇+2 +7163 骨魔卷云扇+3 七翎扇+3 +7164 骨魔卷云扇+4 七翎扇+4 +7165 骨魔卷云扇+5 七翎扇+5 +7166 骨魔卷云扇+6 七翎扇+6 +7167 骨魔卷云扇+7 七翎扇+7 +7168 骨魔卷云扇+8 七翎扇+8 +7169 骨魔卷云扇+9 七翎扇+9 +460 屠龙刀+0 造化神器‖屠龙刀+0 +461 屠龙刀+1 造化神器‖屠龙刀+1 +462 屠龙刀+2 造化神器‖屠龙刀+2 +463 屠龙刀+3 造化神器‖屠龙刀+3 +464 屠龙刀+4 造化神器‖屠龙刀+4 +465 屠龙刀+5 造化神器‖屠龙刀+5 +466 屠龙刀+6 造化神器‖屠龙刀+6 +467 屠龙刀+7 造化神器‖屠龙刀+7 +468 屠龙刀+8 造化神器‖屠龙刀+8 +469 屠龙刀+9 造化神器‖屠龙刀+9 diff --git a/docs/AUTO-HUNT-ENHANCEMENTS.md b/docs/AUTO-HUNT-ENHANCEMENTS.md index 39bde921..59875fa8 100644 --- a/docs/AUTO-HUNT-ENHANCEMENTS.md +++ b/docs/AUTO-HUNT-ENHANCEMENTS.md @@ -41,3 +41,12 @@ F8 或移动端自动狩猎设置打开面板,选择已学会的技能。选 集成测试使用假服并模拟骑乘/存活确认;真实服务器拒绝条件、复杂地图上的长期寻路和真机长时间挂机尚需实测。 中文面板已用原生 Vulkan 渲染器检查手机布局,截图位于 `build/qa/autohunt-enhance-ui/settings.png`;截图使用模拟的可选技能列表,实际已学技能过滤由上述集成测试覆盖。 + + +## 2026-10-02:拥堵与拾取抢占 + +自动狩猎检测近身且无静态阻挡的合法怪物。拾取期间出现这类怪物立即清除物品预约和路径,转为战斗;近身有两只以上怪物,或追击 1.5 秒没有进展时,也优先清理近身目标。保留当前近身目标,避免频繁换向。拥堵期间及解除后的 3 秒不补拉、不被外围石头抢占;首领过滤仍生效。暂缓的物品跳过 2 秒,后续重新扫描。 + +已处于原有可攻击距离且直线无静态阻挡时,直接进入攻击流程,不再要求走到固定 100 cm 站位。攻击进展由本地碰撞命中回调更新,不再由攻击动画刷新;这是客户端命中证据,并非服务器伤害确认。施法和眩晕期间保留等待。移动进展按到当前路点的最佳距离减少或通过路点更新,横向绕圈不刷新。物品尝试另有 15 秒总期限,超时清除路径和移动。 + +新增假服回归覆盖拾取转战斗和威胁消失后的重新拾取。真实密集怪群、骑乘和墙边拥堵需真机验证。本次不加入动态 A* 怪物障碍,也不修改服务端。 diff --git a/docs/AVA2-40250-COMPARISON.md b/docs/AVA2-40250-COMPARISON.md new file mode 100644 index 00000000..f4f2307f --- /dev/null +++ b/docs/AVA2-40250-COMPARISON.md @@ -0,0 +1,189 @@ +# AVA2 与 40250 客户端差异审查 + +日期:2026-10-03。结论:同源引擎上的大幅功能分支,协议与数据不兼容;不是当前跨平台客户端的可直接替换升级版。 + +## 范围与证据边界 + +- AVA2 客户端:`AVA2/TEST/3. Source BIN/Client`;脚本和解包资源:`AVA2/TEST/1. Client DEV/pack_maker`。 +- 40250 客户端:`40250/Server Client TMP4/ClientVS22/source`;原始 Python:`40250/Server Client TMP4/Client/Eternexus/root`。 +- AVA2 服务端:只读取 `FILES+SRC.tgz`,将 Server 源码提取到 `metin2-client/build/ava2-comparison/server`,没有执行包内脚本、启动服务器或导入数据库。 +- 40250 服务端:`40250/Server Client TMP4/Server/metin2/src/server`。 +- 当前项目与原始 40250 分开说明:当前项目已经是 SDL3/Vulkan arm64,并有 5 页背包、中文、移动操作、自动狩猎等扩展,不能把这些算成“原始 40250 功能”。 +- 用户提供的[发布帖](https://metin2hub.com/threads/ava2-org-serverfiles-and-source-full-version.1269/)主页面直接读取返回 403;搜索索引能找到同一帖标题及 2023-01-15 的发帖信息,但未据此认定功能完整性。以下功能结论来自本地源码。 +- 本次完成目录清点、文件内容比较、主要编译开关及构建列表核对、重点协议/数据/碰撞/拾取路径审阅。没有逐函数证明两个客户端等价,也没有运行 AVA2 Windows 客户端。因此“源码接入”不等于“真机稳定”。 + +## 1. 差异规模 + +| 清点项 | AVA2 | 40250 | 说明 | +|---|---:|---:|---| +| 客户端 `.h/.cpp/.c/.py` 文件 | 738 | 651 | 递归清点,含备份与非活动代码 | +| 同相对路径文件 | 639 | 639 | 其中 150 个只归一化 CRLF 后完全相同,489 个不同 | +| AVA2 独有源码文件 | 99 | — | 含新增功能、反作弊、Discord、备份文件,不是 99 个功能 | +| root 顶层 Python 文件 | 162 | 74 | AVA2 新增 88 个同名比较中不存在的文件,含旧版/备份脚本 | +| AVA2 uiscript Python 文件 | 166 | — | 递归统计;未用此数量推断全部界面已启用 | +| 服务端 `.h/.cpp` 文件 | 571 | 513 | 442 个同路径文件,其中 120 个归一化后相同、322 个不同;含第三方 Lua 头文件 | + +文件不同包括注释、编码、功能分支和实质逻辑;不能将 489/639 当成功能变化比例。机器可读清单在 `build/ava2-comparison`。 + +## 2. 底层架构:延续旧引擎,没有升级到新引擎 + +| 项目 | 原始 40250 | AVA2 | 当前 metin2-client | +|---|---|---|---| +| 客户端平台 | Windows C++ | Windows C++ | macOS/Android arm64 | +| 图形接口 | Direct3D 8 | 仍引用 Direct3D 8 | SDL3 + Vulkan,macOS 经 MoltenVK | +| Python | 2.7 系列 | 活动 ScriptLib 引用 Python2,头文件为 2.7.6 | 内嵌 2.7.18 | +| 构建 | Windows 工程 | Win32 工程,v142 工具链 | CMake / Android Gradle | +| 客户端二进制 | 原版 Windows 路线 | `bin_ava2.exe` 实测 PE32 Intel 80386 | 当前产物 arm64 64 位 | +| 服务端部署 | 原版 FreeBSD 路线 | README 指定 FreeBSD 11 x32 + MySQL 5.6;game Makefile 为 g++11、`-m32`、C++14 | 现有 203 为已适配 40250 服务端 | + +AVA2 目录额外包含 Python22(2.2.3),但当前 ScriptLib 实际 include 指向 Python2(2.7.6);不能据目录名误认活动解释器是 2.2。工程叫 VC90/VC100 也不能直接认定当前工具集版本,活动 vcxproj 里是 v142。 + +证据:[EterLib/StdAfx.h](); [ScriptLib/StdAfx.h](); [UserInterface/UserInterface_VC90.vcxproj](); [game/src/Makefile]() + +## 3. 协议:基础编号已经不同 + +| 封包 | 40250 | AVA2 | +|---|---:|---:| +| CG_ATTACK | 2 | 225 | +| CG_CHAT | 3 | 231 | +| CG_ITEM_DROP | 12 | 224 | +| CG_USE_SKILL | 52 | 233 | +| CG_FISHING | 82 | 222 | +| CG_STATE_CHECKER | 206 | 94 | + +上述 AVA2 attack/chat/skill/state 数字在其服务端 packet.h 中也有对应定义。这不是只有客户端 UI 的改动。 + +原始头文件文字扫描:AVA2 找到 267 个显式赋值的 CG/GC 名称,40250 为 217;同名 6 个编号改变,AVA2 独有名称 51 个。此统计含条件关闭的声明,而且不能替代预处理后的实际封包注册表。 + +新增代表:离线商店 CG57/58、商店搜索 CG143、洗属性 CG171、肩带 CG211、副本信息 CG213、掉落信息 CG215、合成 CG216、日历 CG220、战令 CG230、批量拾取 CG235;相应新增 GC 返回包。关闭的 combat zone/CShield/element spell 也有声明,不能全部当成活动功能。 + +加密同样不同:40250 `ServiceDefs.h` 开启 `_IMPROVED_PACKET_ENCRYPTION_`;AVA2 客户端与服务端 service.h 都将它注释掉,AccountConnector 中保留并选择旧的 `SetSecurityMode` 分支。当前跨平台项目使用的 DH2/CTR 路线不能原样接 AVA2。 + +证据:[UserInterface/Packet.h](); [UserInterface/Packet.h](); [EterBase/ServiceDefs.h](); [EterBase/ServiceDefs.h](); [UserInterface/AccountConnector.cpp]() + +## 4. 相同编号的封包,也可能完全不同 + +| 结构/常量 | 40250 | AVA2 | 影响 | +|---|---|---|---| +| `TPacketGCPoints.points` | Windows 32 位 long | long long | 属性数组元素变 8 字节 | +| `TPacketGCPointChange` | amount/value 为 long | amount/value 为 long long | 同名同编号不能照原长度分帧 | +| `TPacketGCItemSet2.count` | BYTE | DWORD | 物品数量字段扩宽 | +| 物品 socket 数组 | 3 | 8 | 结构长度、特殊物品 socket 索引变化 | +| 物品属性数组 | 7 | 15 | 网络/数据库/角色物品结构变化 | +| 物品附加字段 | 无 AVA2 扩展 | glove randomAttr[7]、自定义 name[25] 等 | 依启用开关继续变长 | +| `TPacketGCDead` | header + vid | 增加复活类型数组 | 死亡/复活处理变化 | +| 每账号角色 | 4 | 开关启用后 5 | 选角数组与登录成功包改变 | +| 金钱/交易/商店价格 | 32 位相关字段 | 多处 long long | 必须连同 DB、Python 接口和格式化迁移 | + +**15 个属性数组不等于每件装备能正常洗出 15 条普通属性**:AVA2 仍定义普通 5 条、稀有 2 条,另外扩展槽的用途要按对应系统检查。**8 个 socket 也不等于能镶 8 颗石头**;唯一物品计时等也使用 socket。 + +证据:[UserInterface/Packet.h](); [UserInterface/Packet.h](); [UserInterface/GameType.h](); [UserInterface/GameType.h](); [UserInterface/Packet.h]() + +## 5. 背包、装备与角色成长 + +| 项目 | 原始 40250 | AVA2 | +|---|---|---| +| 背包 | 2 页、90 格 | 开启扩展,4 页、180 格 | +| 特殊收纳 | 原版背包/龙魂/腰带等 | 新增专门收纳窗口与特殊库存常量 | +| 装备 | 原版身体/武器/首饰等,预留 32 槽 | 肩带及肩带时装、武器/坐骑时装、手套、护符、宠物槽、发光槽、坐骑皮肤等 | +| 服务端装备槽上限 | 32 | 新增槽后枚举至 36;需要核对客户端映射,不可依注释复制 | +| 普通等级源码上限 | 120 | 250 | +| 征服者等级 | 无此分支 | 宏开启,源码上限 30,Sungma/Yohara 相关字段 | +| 龙魂 | 原版等级体系 | 增加神话等级、洗属性和效果控制 | +| 货币上限常量 | 2,000,000,000 | 2,000,000,000,000,000LL | +| 技能 | 原版 | 7/8 技能扩展、额外元素相关字段 | + +这里是源码常量,不代表打包服实际经验表、任务或地图支持玩家练到 250。当前 metin2-client 已是 5 页、225 格,**AVA2 的默认 4 页并不是对当前项目的背包容量升级**。 + +客户端 `CItemData::WEAR_MAX_NUM` 仍显式写 32,但服务端 wear 枚举新增项后到 36,客户端也存在独立的新装备计数/槽映射。此处需要专项逐槽核对;静态看到差异不能直接宣称已产生越界。 + +证据:[UserInterface/GameType.h](); [common/length.h](); [common/length.h](); [GameLib/ItemData.h](); [common/length.h]() + +## 6. 新增玩法系统及接入证据 + +下表比较的是原始 40250;现有项目自定义扩展另算。 + +| 系统 | AVA2 证据 | 状态/边界 | +|---|---|---| +| 离线商店、跨商店搜索 | 客户端 PythonPrivateShopSearch / uiofflineshop;服务端 offlineshop_manager/search;DB ClientManagerOfflineshop | 客户端/服务端宏开启,必须迁移 DB 表和协议 | +| 自动洗属性 switchbot | PythonSwitchbot、uiswitchbot;服务端 new_switchbot;CG/GC171 | 有真实编译接入,不是只有脚本 | +| 5+2 属性加成操作、属性锁定/复制 | __BL_67_ATTR__、ENABLE_LOCK_ATTR、ENABLE_COPY_ATTR_ITEM,uiAttr67Add | 不等于扩展数组每一槽都有普通洗属性入口 | +| Wiki / 掉落查询 / 宝箱预览 | PythonWiki、PythonDropItem、uiwiki、uiChestDrop,target/drop 包 | 依赖资料和服务端返回,不是界面贴图即可 | +| 模型预览 | CPythonRenderTargetModule、EterLib CRenderTarget、uirendertarget | 需要离屏渲染、相机和资源绑定 | +| 副本资料/计时/排名 | PythonDungeonInfo、dungeon_info、uidungeoninfo | 服务端和 Python 互相依赖 | +| 战令 Battle Pass | PythonBattlePass、uibattlepass、battlepass、questlua_battlepass | 宏开启并加入构建 | +| 生物学家任务 UI | biolog、uibiyolog / uiprofessionalbiolog | 扩展任务及网络/UI 配合 | +| 宠物成长、孵化、喂养、技能 | New_PetSystem、PythonSkillPet、多个宠物 UI | 比原版 PetSystem 更丰富;包里也有 old/copia 版本,不能全算启用 | +| 肩带吸收/时装 | PythonSash、uisash、服务端 __SASH_SYSTEM__ | 增加物品、装备槽和封包 | +| 日历、活动、帝国战争 | char_event_calendar、new_events、uicalendar/uikingdomswar | 多种活动实现并存,需确认实际入口 | +| 小游戏 | fishgame、Catch King,对应脚本/包/宏 | 普通钓鱼也改了包头 | +| 新副本内容 | Meley、Zodiac、船防御相关宏及源码/资源 | 静态存在,不等于每个副本任务流程已实测 | +| 便捷操作 | 换线、批量精炼、合成改版、反经验、自选技能、装扮隐藏、装备切换、排序 | 有独立宏/源码/脚本,迁移复杂度不同 | +| Discord | discord_rpc、ENABLE_DISCORD_RPC | 活动宏开启,但 app ID 配置值为 0,不能称现成可用集成 | +| 银行/支援/狼人与战斗区 | 代码或脚本存在 | 开关不一致或关闭,不能列为无条件可用功能,见后文 | + +证据:[UserInterface/Locale_inc.h](); [UserInterface/UserInterface.cpp](); [UserInterface/UserInterface_VC90.vcxproj](); [game/src/Makefile]() + +## 7. 与近期自动狩猎问题直接相关的差异 + +### 7.1 敌对怪物碰撞被忽略 + +AVA2 启用 `ENABLE_MOBS_WITHOUT_COLLISIONS`。`CActorInstance::IsIgnoreCollision` 对 enemy 返回 TRUE,也对特定 race 和 campfire 返回 TRUE;InstanceBaseBattle 的角色碰撞检测在这个返回值为真时跳过该 actor。 + +这会让客户端移动不被敌对怪群实体碰撞阻挡,是更改基础碰撞规则;我们的当前优化保留碰撞,通过近身清怪、拾取抢占和停止补拉解围。这两个方案的手感与语义不同,不能将 AVA2 的开关称作同一套自动狩猎修复。它也不代表忽略墙、地图阻挡或所有服务器校验。 + +### 7.2 自动拾取是批量协议扩展 + +AVA2 的 `CPythonPlayer::SendPickUPItemPacket` 收集物品 VID,500 ms 节流、过滤归属后调用 `SendItemsPickUpPacket`,新增 CG235。它不同于当前项目基于单个物品、路径、预约和超时的拾取。 + +本次没有找到与当前项目同名的完整 `AutoHunt` 状态机;原版已有的 `__Update_AutoAttack` 不能当成新增完整挂机系统。自动拾取、技能流派选择、自动洗属性也不等于具备当前自动狩猎的引怪、寻路、恢复和配置能力。进一步核对 `uiautoskill.py`:其窗口仅在技能流派尚未选择时打开,按钮发送选择流派命令;`ENABLE_AUTO_SELECT_SKILL` 不是自动循环施法。 + +证据:[GameLib/ActorInstanceCollisionDetection.cpp](); [UserInterface/InstanceBaseBattle.cpp](); [UserInterface/PythonPlayer.cpp]() + +## 8. 资源、Python 与渲染 + +- AVA2 活动 `USE_FOX_FS` 走 FoxFS API(FoxFS_Create/LoadA/GetA),包索引按每行一个包名读取;包里提供 FoxFS Windows `.lib`,本地 pack 名称也不走普通成对 EIX/EPK 清单。这是当前 EPK 加载器不能直接替换的依赖。 +- 存在大量已解包目录,因此可以挑选资源复用;没有必要为了导入一个模型就先移植 FoxFS。 +- pack_maker 清点得到 45 个资源目录、93,874 个文件,含重复/备份、工具或压缩文件,不是 93,874 个独立游戏资产。 +- 增加 Yohara/征服者地图、Zodiac、Meley、新宠物、装扮、离线商店、掉落查询、新选角等资源目录。目录存在不等于所有模型格式都经过当前 libgr2 验证。 +- Python root 仍用 Python 2 语法和 `app/net/player/item` 一类原版 C++ 模块,但接口已扩展;复制整个 root 会遇到缺少 C++ 方法、常量、封包和数据等问题。 +- ItemManager 支持 MIPX/MIPT 和结构自动检测,PythonNonPlayer 也有 mob_proto 结构兼容分支;这些是 proto 表结构转换能力,**不是服务器协议自动兼容能力**。 +- EterLib 仍 D3D8,新增 offscreen RenderTarget,用于 Wiki/商店/NPC 模型预览;大量闪光、特效、缩放和显示选项属于旧引擎扩展,没有证据表明换成现代渲染引擎。 + +证据:[UserInterface/UserInterface.cpp](); [EterPack/EterPackManager.cpp](); [GameLib/ItemManager.cpp](); [system.py]() + +## 9. 数据库与文件包的区别 + +`MYSQL56.tgz` 实际主要是 MySQL 数据目录备份,不是通用 SQL 导出:清点 252 个 `.frm`、193 对 `.MYI/.MYD`、5 个 `.ibd` 等。存在 `offline_shop_item/npc/search/log`、`battle_pass`、`new_petsystem`、`ranking_dungeon`、`date_dungeon`、`itemshop` 等表。 + +因此不能简单把它当 `.sql` 导入现有 MySQL 8,更不能覆盖当前账号/人物/物品数据库。选移功能时应从源码 SQL 读写和隔离的原运行环境还原 schema,编写逐表迁移。当前版本没有导入、启动或验证该备份,表名清点不代表 schema 已校验。 + +AVA2 item/proto/player 表还需要匹配 8 sockets、15 attrs、glove randomAttr、long long gold、conqueror 等结构。我们当前给“11”配置的 7 条属性和 +6 石头无需因比较而修改;本次未连接 203。 + +## 10. 打包源码中的不一致与未确认项 + +1. 客户端 `ENABLE_WOLFMAN_CHARACTER` 注释,服务端 CommonDefines.h 开启。该静态配置不一致值得检查,不能直接宣布狼人在提供的 exe 中可玩。 +2. 客户端 `ENABLE_MANAGER_BANK_SYSTEM` 开启,服务端对应宏注释。不能把银行当作已经两端一致启用的系统。 +3. 客户端 `ENABLE_SUPPORT_SYSTEM` 注释,服务端开启;角色支援代码/脚本存在,但入口和最终二进制需要核对。 +4. CShield 的宏关闭,但旧保护组件、相关静态库、反作弊源码仍在;移植时要检查活动构建,不宜按文件名一律迁移。 +5. 有 `oldrendertarget`、`*_old.py`、`- Copy.cpp`、`copia`、多套商城/宠物/离线商店实现。文件数量不能当系统数量。 +6. `Locale_inc.h` 有 `ENABLE_HIGHLIGHT_SYSTEM // disabled because its bug` 这样的注释与开关状态矛盾,表明注释不能替代实际预处理结果。 +7. 未证明提供的 Windows exe 与这份 vcxproj/宏是同一次构建;源码宣告功能与成品实际功能要区分。 +8. 未做双方 GR2/地图/EPK-FoxFS 格式全量交叉读取、逐技能数值比较、任务语料比较或性能测量;不能据系统更多推断更稳定、更快、更省电。 + +证据:[UserInterface/Locale_inc.h](); [common/CommonDefines.h](); [UserInterface/Locale_inc.h](); [common/CommonDefines.h]() + +## 11. 对当前项目的迁移建议 + +| 目标 | 建议 | 主要依赖 | +|---|---|---| +| 只想改善打怪体验 | 保留当前主线;按需要评估是否采用“怪物无碰撞”作为单独玩法变更 | 碰撞规则和真服移动验证 | +| 换地图、怪物、特效、装扮 | 先离线导入少量已解包资源 | 资源读取、race/MSM/MSA、proto、挂点 | +| Wiki、宝箱掉落、副本资料 UI | 适合分模块引入 | 服务端查询、Python 接口、UI 和数据 | +| 模型预览 | 可移植,改写 D3D 离屏渲染为现有 Vulkan 目标 | 相机、RT、材质和 UI 合成 | +| 换线、批量精炼、自动洗属性 | 分别立功能,不搬整个 AVA2 root | 服务端处理、封包、状态安全与 UI | +| 离线商店、宠物成长、肩带、征服者/手套/护符 | 重型系统迁移,需要独立设计和验收 | 协议、DB、数值、资源、逻辑和 UI 全链 | +| 直接连接 AVA2 服务器 | 单独协议后端或专用分支 | 旧加密、包号/长度、角色/物品/金钱、Python 接口 | +| 将 AVA2 当作新底座替换当前项目 | 不建议直接替换 | 会重新承担 Win32→arm64、专有库、运行时和完整回归成本 | + +最合理的用法:把 AVA2 当作功能及资源参考库,选择少数实际需要的系统逐个移植。若目标是完整兼容 AVA2,按“另一套客户端/服务端协议项目”估算,不能当成替换几个脚本的升级。 diff --git a/docs/D2R-MOBILE-REFERENCE.md b/docs/D2R-MOBILE-REFERENCE.md new file mode 100644 index 00000000..4ba3a151 --- /dev/null +++ b/docs/D2R-MOBILE-REFERENCE.md @@ -0,0 +1,20 @@ +# D2R Mobile 可参考的客户端设计 + +检查日期:2026-10-14。来源:[jhaizhou-ops/d2r-mobile](https://github.com/jhaizhou-ops/d2r-mobile),版本 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`。 +本次只修改 metin2-client;下表除技能布局外均为后续建议。 + +| 优先级 | 参考设计与源码 | Metin2 可采用的方式 | +| --- | --- | --- | +| 已应用 | [单层技能弧与独立移动区](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.c) | 原项目四个副技能,本客户端扩为六个同半径快捷槽;保留大攻击键与浮动摇杆,统一绘制和命中几何。 | +| 高 | [手势取消和状态身份检查](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.c) | 切后台、断线、切场景、尺寸变化时明确收回手指与持续攻击;给延迟触摸动作绑定场景/手势序号,避免旧动作在新场景执行。先补系统取消事件的覆盖。 | +| 高 | [物品手势](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.h) | 在现有单击操作面板、长按使用、拖放基础上,扩大商店/仓库落点、增加拖动目标提示;每一步沿用原客户端请求与确认流程。 | +| 高 | [触摸脚本和结果断言](https://github.com/jhaizhou-ops/d2r-mobile/tree/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/verify/touchcases) | 为摇杆+攻击+技能多指组合、取消手势、背包拖动建立可重放触点序列,同时检查发包和物品结果。复用现有离线登录服务器,避免只凭截图判断操作正确。 | +| 中 | [底部 HUD 重排](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/NativeHUD/README.zh-CN.md) | 以 Metin2 自有生命/法力/经验组件和资源重排为紧凑底栏,保留真实数据;药水、背包等常用入口按手指尺寸统一排布。此次只给现有底栏预留空间。 | +| 中 | [图形状态缓存和管线预热](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/gfx/README.zh-CN.md) | 在现有 Vulkan 管线缓存上采集真实场景常用状态,预热进地图必用组合;用首次入图最长帧和持续温度验证收益。其 NVN→Metal 实现不能直接替换本项目 D3D8→Vulkan 路径。 | +| 中 | [技能状态和显示分离](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_game_state.h) | 图标、冷却、数量来自一致的状态快照;切角色/地图后使旧状态失效。本项目已有图标和冷却桥接,可补生命周期一致性。 | + +其 README 还展示了手柄/触摸自动切换、30fps 省电、掉落过滤、高价值掉落震动与屏外目标指引。这些适合后续客户端体验迭代:优先接入已有帧率策略、掉落显示和小地图系统,避免重复实现已有功能。 +其中定向拖动施法应先按 Metin2 的各技能目标类型设计,不能把另一款游戏的施法模型直接照搬。 + +该项目是离线 Switch 程序的 Apple 平台兼容层;二进制改写、NVN 接口和离线存档事务与本项目在线客户端架构不同。 +它公布的帧率、耗电和启动时间是该项目的测量结果,不能据此推算 Metin2 的性能。建议借鉴分层、缓存、触控和验证方法,再在本客户端实测。 diff --git a/docs/MOBILE-TOUCH-OPTIMIZATION.md b/docs/MOBILE-TOUCH-OPTIMIZATION.md new file mode 100644 index 00000000..a86725fd --- /dev/null +++ b/docs/MOBILE-TOUCH-OPTIMIZATION.md @@ -0,0 +1,109 @@ +# 原生移动端触控优化 + +2026-09-30 首批优化,适用于 SDL3 / Vulkan 客户端。移动端 UI 由 PORT 模块提供,资源中的 40250 Python 脚本不改写。 + +## 已实现 + +- 六个快捷键围绕攻击键形成单层弧线;自动狩猎放在弧线外侧。按钮、间距和摇杆随 UI 尺寸一起调整。 +- 保留真实物品 / 技能图标和数量,增加技能冷却秒数、未学习遮罩和激活技能绿环。状态来自 CPythonPlayer;技能可否释放仍由原有逻辑判断,不把这些展示状态当作完整施法条件。 +- 根据 SDL 窗口显示倍率与实际像素高度,把 48dp 换算为 UI 坐标下的触摸目标。战斗按钮直径和物品操作行使用这一目标;现有背包格子与其他原版窗口尚未整体重排。 +- 单击背包/装备显示原物品说明,双击调用原使用/穿戴/卸下方法;不再弹出移动端操作面板。 +- 拖动调用原有物品移动和快捷槽绑定流程;拖到 NPC 商店背景或货架先显示出售确认。双击和拖放核对页码、物品编号、数量、背包可见性与确认状态,失效手势取消;长按继续沿用原右键使用行为。 +- 修复每帧重排导致浮动摇杆起点回到默认位置;同一按钮和 UI 手势保留原手指所有权,避免另一根手指覆盖。 + +实现入口:`src/host/touch_controller.h`、`src/host/vk_frame.cpp`、`src/platform/ScriptLib/PythonBoot.cpp`、`MobileInventoryScript.inc`。 + +## 验证 + +```sh +cmake --build build/release --target mt_native_render -j8 +cmake --build build/native --target port_login_flow_test -j8 +MT_TEST_MOBILE=1 ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure +ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure +./android/build.sh --no-deploy +``` + +移动模式的离线登录测试额外覆盖摇杆跨帧保留起点、真实触点单击说明、双击穿脱、拖动换位、出售确认/取消与翻页失效。桌面模式继续覆盖既有登录、战斗、商店、仓库流程。 + +## 后续工作 + +- 可保存的按钮位置 / 大小、自定义固定 / 浮动摇杆和视角灵敏度。 +- 目标切换、就近 NPC 交互与拾取入口。 +- 背包格子及商店、任务窗口的完整手机布局、拖放目标高亮。 +- Android 真机验证多指连续战斗、挖孔安全区、屏幕密度和长时间手持体验。构建通过及桌面截图不等于真机手感验收。 + +## 2026-10-14:参考 D2R Mobile 的单层技能弧 + +参考仓库 [d2r-mobile](https://github.com/jhaizhou-ops/d2r-mobile),检查版本 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`。 +参考入口是 `dm/ios/Sources/dm_touch_controls.c` 的 `dm_tc_layout_make`:主操作键旁的单层技能弧,以及移动区与战斗区分开。 +本次采用布局思路,未引入其游戏资源或兼容层代码。 + +- 保留左下浮动摇杆、右下大攻击键,六个快捷槽全部在同一条弧线上,不拆成两层,也不将第五、第六槽移到底部。 +- 六槽相对攻击键的距离相同,相邻角度相差 24 度。间距覆盖实际命中使用的 1.25 倍半径,避免相邻命中区域交叠。 +- 整组上移,为原有生命、法力、经验和菜单底栏留出空间。自动狩猎在弧线左侧独立放置,保留点击切换与长按设置。 +- 六槽编号、图标、数量、冷却、拖入绑定和原有使用逻辑保持对应。摇杆头与底盘同步缩放。 +- `--mobile` 在 Mac 验收时同样隐藏原桌面快捷栏,与 Android 展示路径一致。桌面模式沿用原布局。 +- 几何统一放在 `src/host/touch_layout.h`,渲染和触摸命中共用计算结果。 + +检查命令: + +```sh +cmake --build build/native --target host_touch_layout_test port_login_flow_test -j8 +ctest --test-dir build/native -R '^host.touch_layout$' --output-on-failure +MT_TEST_MOBILE=1 ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure +ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure +JOBS=8 ./android/build.sh --no-deploy +``` + +几何检查覆盖 640×360、800×600、超宽横屏、放大触摸目标和平板,检查安全边界、单层顺序、最小触摸尺寸和命中间距。 +Mac 移动模式离线场景渲染截图在 `build/touch-layout-validation/mobile.png`。Android 真机手持与多指手感仍需实测。 + +其他值得借鉴的设计及适配优先级见 [D2R-MOBILE-REFERENCE.md](D2R-MOBILE-REFERENCE.md)。 + +## 2026-10-14:按用户截图调整 HUD 分区 + +参考用户提供的横屏实机截图,在上一版单层技能弧上继续调整。实现仅在触摸模式安装的 PORT 模块 `src/platform/ScriptLib/MobileHUDScript.inc`,不修改原资源包里的 Python 文件。 + +- 左上:职业头像、角色名、等级、生命/法力数值及原版红、蓝、耐力三条;有效能量加成显示剩余分钟,状态图标放在下方。所有数值来自当前玩家数据,没有收到最大值时显示 `--`。 +- 右上:角色、背包、好友、设置四个圆形入口;小地图向下留出菜单行。头像也可打开角色面板。 +- 左侧:任务、帮助、聊天,沿用现有界面动作。矮屏或触摸目标放大时改为横排,避开摇杆。原来的小帮助图标不重复显示。 +- 左下:浮动摇杆;右下:大攻击键及六技能单层弧;空槽保留编号,绑定后显示原图标和数量/冷却。 +- 底部:第 7、8 个本地快捷槽,可绑定药水等物品。点击和拖入绑定与原快捷栏一致;没有额外生成物品或修改物品效果。 +- 以深棕底和金铜色圆边统一菜单及战斗控件,聊天消息移到摇杆右侧。原桌面底栏在触摸模式隐藏,菜单通过原有回调打开。 +- HUD 显隐接入原任务栏的 Show/Hide,离开游戏时恢复原方法并释放回调。HUD 容器本身不可命中,只有实际按钮接收点击,移动区域不会被整屏容器挡住。 + +验证文件在 `build/mobile-hud-validation/`:`mobile.png` 是 Mac 移动模式的离线场景截图,截图中的生命/法力使用测试钩子设置以展示条形效果,非真实服务器状态;`mobile-test.log` 与 `desktop-test.log` 为回归记录。 +`host.touch_layout` 同时检查新增物品快捷槽与技能、攻击、自动狩猎的扩展命中范围不交叠。 +Android 产物为 `android/app/build/outputs/apk/debug/app-debug.apk`,未自动安装到手机;真机多指和长期手持体验仍需实测。 + +### 经验球与原版状态栏样式恢复 + +用户要求四颗经验球留在左下、头像及红蓝耐力条沿用之前的样式。左上改为复用原 TaskBar 的 `Gauge_Board`:原边框、HP/SP 动画贴图、恢复覆盖层和黄色 ST 条整体重挂到移动 HUD,职业头像仍取原角色资源。状态变化调用原 `RefreshStatus` / `RefreshStamina` 更新,保留原计算逻辑。 + +`EXP_Gauge_Board` 与四个 `EXPGauge` 点整体移到左下角;继续使用原版按四分之一填充经验球的 `SetExperience`,旁边增加经验百分比,移除顶部新画的经验细条。隐藏/显示 HUD、屏幕尺寸变化和离开游戏的父节点恢复都覆盖这些组件。 + +最新预览 `build/mobile-hud-validation/mobile.png` 中的 850/1000 HP、420/600 SP、70/100 耐力和 62.50% 经验来自离线测试钩子,用于检查颜色、填充和位置。 + +后续微调:头像外圈缩为 34 个 UI 像素,与原版红蓝耐力三条的外框整体同高;裁掉旧状态底图左侧的大球装饰。移动 HUD 将聊天窗口查看和输入状态的背景色设为透明,保留消息文字、滚动和输入功能,离开游戏恢复原实例颜色。已检查离线渲染预览并重新构建 Android APK。 + +## 物品手势调整:单击说明、双击穿脱、拖放移动/出售 + +- `MobileInventoryScript.inc` 移除单击操作面板。单击只显示原 Tooltip,不把物品挂到指针;双击使用原 `UseItemSlot`,穿戴与卸下仍由原客户端校验并向服务端发请求。 +- 双击核对第一次点选的窗口、页码、全局格子、物品编号、数量及时间。换页、换物品、出现确认窗口后不执行旧双击。 +- 拖动仍先走原 `SelectItemSlot` 拿起,再交给原背包、装备或快捷槽的目标处理。NPC 商店背景/标题/货架均可作为出售落点;通过实际拾取到的窗口及祖先识别,遮挡在上面的其他窗口不会触发出售。 +- 商店出售调用原背包 `__SellItem`,保留禁止出售检查、确认/取消和确认时物品身份复核;不直接绕过确认发出售包。私人商店不增加此入口。 +- 开着背包/商店时,窗口绘制在战斗覆盖层前面,命中也优先窗口;HUD 菜单回到普通 UI 层,避免遮住装备格。 +- 不修改服务端,也不绕过商店开启时不能卸装备等已有规则。长按仍使用原右键行为;拆分堆叠和一键整理未在此次增加。 + +客户端内的离线测试夹具增加装备穿/脱和物品换位回复,只在 `MT_TEST_MOBILE` 测试环境启用。回归覆盖单击无使用请求、双击穿戴/卸下的实际格子变化、拖动换位、换页取消、出售取消保留物品以及确认后物品/金币变化。运行日志:`build/mobile-hud-validation/gestures-test.log`。 + +## D2R 默认摇杆与技能外观(2026-10-14) + +参照 d2r-mobile `dm/ios/Sources/DmTouchOverlay.m`(参考检出 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`)的颜色、透明度和构图,用客户端绘图命令独立绘制,不引入参考项目的游戏贴图。 + +- 技能使用深色圆底、古铜外圈和暗色内圈;闲置底色 alpha 0.48、外圈 0.54,按住恢复至 0.92 / 1.0。 +- 空技能槽显示小铜色加号;已绑定的 Metin2 图标保持清晰,占直径 64%,保留冷却、激活和数量提示。 +- 浮动摇杆改为闲置淡圆环、带双圈和四角星的摇杆头;操作时增强透明度并显示朝向弧,移除十字刻度和蓝色连线。摇杆头半径为 23 × UI 缩放。 +- 圆底使用不重叠扫描条绘制,避免原先矩形交叠形成十字暗块。保留原命中区域、浮动原点、移动计算和六技能单层弧线。 + +验证:`host.touch_layout` 与 `MT_TEST_MOBILE=1` 的 `port.login_flow` 均通过(57.16 秒),浮动原点测试改为检查实际底圈起点。macOS 渲染预览为 `build/mobile-hud-validation/d2r-style.png`;Android APK 构建成功,未安装到真机。日志为 `build/mobile-style-test.log`、`build/mobile-style-android-build.log`。 diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index d6b29a64..b8574e37 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -246,3 +246,18 @@ python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py check ``` 然后按第 4 节"下一步"继续,每完成一步在第 4、6 节更新状态并提交。 + +2026-10-14 用户授权扩展:小地图旁增加“研究”入口,适配 AVA2 风格生物学家窗口。 +客户端模块 `BiologScript.inc` 和 `tools/server/biolog/` 复用当前服务器 30~94 级收集任务,保留 NPC 状态、原冷却和奖励,不迁移成 AVA2 的 `bio.*`。 +新增远程接取/逐份提交/任务石/领奖入口,药水为原任务 71035;92/94 级奖励继续通过原任务对话选择。 +203 已部署该扩展,13 个 game cores 恢复;备份 `/root/backup-biolog/20261015-023604`。 +专用测试角色已验证接取、扣料、冷却拦截、重登一致;528 组原 NPC/远程 Lua 对照和桌面/触摸回归通过。详见 `tools/server/biolog/README.md`。 + +2026-10-14 后续用户明确要求完全使用 AVA2 生物学家实现,且不考虑旧角色。已替换此前的原任务远程适配: +使用 AVA2 原 do_bio 和 13 阶段(30~105 级)配置,独立 bio.* 进度;71035/30250 道具、选择奖励、70028 重选、22 项原研究商店及新增研究属性战斗效果接入。 +203 备份 `/root/backup-biolog-ava2/20261015-030954`;旧 collect_quest 事件已停用,不迁移原进度、不清除旧角色历史属性。 +新增物品与图标通过 Client/pack/biolog.eix/.epk 和首位 Index 注册提供,升级客户端时须同步此资源补丁。 +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 资源补丁。 diff --git a/scripts/build_hd_ui.py b/scripts/build_hd_ui.py index 639d9186..27d1a99a 100644 --- a/scripts/build_hd_ui.py +++ b/scripts/build_hd_ui.py @@ -81,6 +81,10 @@ def read_entry(keys, epk: Path, e: dict) -> bytes: def wanted(name: str, locale: str) -> bool: + # SCREEN waypoint sprites need their original black RGB under alpha=0. + # The shared minimap atlas must not pass through alpha-edge colour bleeding. + if name == "d:/ymir work/ui/minimap.dds": + return False if not name.endswith(IMAGE_EXTS): return False return (name.startswith("d:/ymir work/ui/") or name.startswith("icon/") diff --git a/scripts/build_zh_locale.py b/scripts/build_zh_locale.py index 8b94f2eb..83e5037f 100644 --- a/scripts/build_zh_locale.py +++ b/scripts/build_zh_locale.py @@ -348,10 +348,13 @@ def build(en: Path, zh: Path, output: Path, client_base: Path, names_dir / "mob_names_zh_overlay.txt") # item_limits_server.tsv: item limits changed on our server, so the tooltip matches what it enforces. limits = names_dir / "item_limits_server.tsv" + if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file(): + raise ValueError("stone overlay requires the item limits table argument") result = subprocess.run( [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), str(item_names), str(mob_names), str(locale_dir / "item_proto"), - str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else []), + str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else []) + + ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else []), check=True, capture_output=True, text=True, ) report["proto"] = { @@ -383,6 +386,41 @@ def build(en: Path, zh: Path, output: Path, client_base: Path, return report_path +def build_biolog(output: Path, pack_tool: Path): + """AVA2 biology is part of the current client; rebuilding the locale must retain its item patch.""" + import importlib.util + import tarfile + root = REPO / "tools/server/biolog" + ava = REPO.parent / "AVA2" + archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz" + if not archive.is_file(): + raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference") + proto_tool = tool("biolog_proto_patch") + if not proto_tool.is_file(): + raise ValueError("build biolog_proto_patch before generating the Chinese client") + stage = output.parent / "biolog" + stage.mkdir(parents=True, exist_ok=True) + proto = stage / "ava2-item-proto.txt" + with tarfile.open(archive, "r:gz") as source: + member = source.extractfile("home/srv1/db/item_proto.txt") + if member is None: + raise ValueError("AVA2 item table missing") + proto.write_bytes(member.read()) + spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.prepare(proto, ava / "1. Client DEV/pack_maker", output.parent / "locale-src/zh", + stage, proto_tool.resolve(), pack_tool.resolve()) + for extension in ("eix", "epk"): + shutil.copy2(stage / ("biolog." + extension), output / "pack" / ("biolog." + extension)) + index = output / "pack/Index" + lines = index.read_bytes().replace(b"\r\n", b"\n").splitlines() + if b"biolog" not in lines: + lines[1:1] = [b"*", b"biolog"] + index.write_bytes(b"\r\n".join(lines) + b"\r\n") + return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True} + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--english", type=Path, default=DEFAULT_EN) @@ -395,6 +433,9 @@ def main(): args = parser.parse_args() report_path = build(args.english, args.chinese, args.output, args.client_base, 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_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}") diff --git a/scripts/package_mac_app.sh b/scripts/package_mac_app.sh index a1622dee..2036f228 100755 --- a/scripts/package_mac_app.sh +++ b/scripts/package_mac_app.sh @@ -12,6 +12,7 @@ bin=$build/src/host/mt_native_render [[ -d "$client/pack" ]] || { echo "missing Client/pack: $client" >&2; exit 1; } [[ -f "$pak" ]] || { echo "missing HD UI archive: $pak (run scripts/build_hd_ui.py first)" >&2; exit 1; } python3 "$repo/scripts/verify_hd_ui.py" "$pak" +signing_identity=${MT_CODESIGN_IDENTITY:--} version=$(git -C "$repo" describe --always --dirty 2>/dev/null || echo dev) mkdir -p "${out:A:h}" stage=$(mktemp -d "${out:A}.tmp.XXXXXX") @@ -21,8 +22,11 @@ cp "$bin" "$stage/Contents/MacOS/mt_native_render" cp "$build/src/host/native.vert.spv" "$build/src/host/native.frag.spv" "$build/src/host/python27.zip" "$stage/Contents/Resources/" cp "$repo/branding/icon/macos/AppIcon.icns" "$stage/Contents/Resources/" cp "$pak" "$stage/Contents/Resources/hd_ui.pak" -# Clone private assets on APFS; fall back to an ordinary copy on other filesystems. -cp -cR "$client" "$stage/Contents/Resources/Client" 2>/dev/null || ditto "$client" "$stage/Contents/Resources/Client" +# Materialize source links so the bundle is portable; clone asset data on APFS. +cp -cRL "$client" "$stage/Contents/Resources/Client" 2>/dev/null || { + mkdir -p "$stage/Contents/Resources/Client" + rsync -aL "$client/" "$stage/Contents/Resources/Client/" +} for lib in libvulkan.1.dylib libSDL3.0.dylib; do dependency=$(otool -L "$bin" | awk -v name="$lib" '$1 ~ name "$" {print $1; exit}') [[ -f "$dependency" ]] || { echo "cannot resolve $lib: $dependency" >&2; exit 1; } @@ -72,11 +76,12 @@ cat > "$stage/Contents/Info.plist" <LSMinimumSystemVersion12.0 LSApplicationCategoryTypepublic.app-category.role-playing-games NSHighResolutionCapable +NSLocalNetworkUsageDescription连接局域网中的 Metin2 游戏服务器,查询频道状态并登录游戏。 PLIST -for library in "$stage"/Contents/Frameworks/*.dylib; do codesign --force --sign - "$library"; done -codesign --force --sign - "$stage/Contents/MacOS/mt_native_render" -codesign --force --deep --sign - "$stage" +for library in "$stage"/Contents/Frameworks/*.dylib; do codesign --force --sign "$signing_identity" "$library"; done +codesign --force --sign "$signing_identity" --identifier org.metin2port.client.renderer "$stage/Contents/MacOS/mt_native_render" +codesign --force --deep --sign "$signing_identity" "$stage" codesign --verify --deep --strict "$stage" # Publish only after validating the complete replacement bundle. rm -rf "$out" diff --git a/src/host/live_client.cpp b/src/host/live_client.cpp index e2a271ea..fa2845cb 100644 --- a/src/host/live_client.cpp +++ b/src/host/live_client.cpp @@ -57,7 +57,6 @@ bool py_eval_true(const char* expr) { return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1"); } -#ifdef __ANDROID__ bool hide_mobile_taskbar_quickslots() { return py_exec( "_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n" @@ -65,7 +64,6 @@ bool hide_mobile_taskbar_quickslots() { " _mobile_quickslot.Hide()\n" "_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n"); } -#endif #if defined(__APPLE__) && TARGET_OS_OSX bool configure_macos_numeric_quickslots() { @@ -305,10 +303,8 @@ int run_live_client( })) throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")"); -#ifdef __ANDROID__ - if (!hide_mobile_taskbar_quickslots()) - throw std::runtime_error("failed to hide the desktop quick-slot bar on Android"); -#endif + if (PythonBoot::TouchInput() && !hide_mobile_taskbar_quickslots()) + throw std::runtime_error("failed to hide the desktop quick-slot bar in touch mode"); #if defined(__APPLE__) && TARGET_OS_OSX if (!configure_macos_numeric_quickslots()) throw std::runtime_error("failed to configure numeric quick slots on macOS"); diff --git a/src/host/texture_decode.cpp b/src/host/texture_decode.cpp index d2ab4729..3f92d698 100644 --- a/src/host/texture_decode.cpp +++ b/src/host/texture_decode.cpp @@ -188,6 +188,11 @@ bool read_live_pack_texture(const std::string& vpath, std::vector& } bool read_hd_ui_texture(const std::string& vpath, std::vector& bytes) { + // The minimap atlas contains SCREEN-blended waypoint frames. Their black RGB + // background is significant even at alpha=0 (INVDESTCOLOR + ONE ignores alpha). + // HD alpha-edge colour bleeding destroys it; retain the original packed atlas. + for (const auto& candidate : pack_name_candidates(vpath)) + if (candidate == "d:/ymir work/ui/minimap.dds") return false; static auto pak = open_hd_ui_pak(); if (!pak || vpath.empty()) return false; for (const auto& candidate : pack_name_candidates(vpath)) diff --git a/src/host/touch_controller.h b/src/host/touch_controller.h index 3bbc95d9..352393e5 100644 --- a/src/host/touch_controller.h +++ b/src/host/touch_controller.h @@ -9,6 +9,7 @@ #include #include "UIRenderCommands.h" +#include "touch_layout.h" #ifdef MT_NATIVE_HAS_LIVE_CLIENT #include "platform/ScriptLib/PythonBoot.h" @@ -17,7 +18,7 @@ class TouchController { public: struct ButtonDef { - int id; // 1 = attack, 2..7 = quick slots 1..6, 8 = auto hunt + int id; // 1 = attack, 2..7 = skills, 8 = auto hunt, 9..10 = bottom quick slots int dik; float x, y, radius; const char* label; @@ -38,6 +39,7 @@ public: #endif } bool is_enabled() const { return enabled_; } + void set_minimum_touch_target(float logicalSize) { minimum_touch_target_ = std::max(48.0f, logicalSize); } // Short vibration when a long press fires (the host wires it to the platform). void set_haptic(void (*haptic)()) { haptic_ = haptic; } @@ -54,13 +56,14 @@ public: screen_h_ = height; safe_inset_ = float(std::max(0, safe_inset)); // Position joystick on lower-left - joystick_base_x_ = safe_inset_ + std::max(90.0f, float(width) * 0.12f); - joystick_base_y_ = float(height) - std::max(90.0f, float(height) * 0.22f); + layout_buttons(); if (!joystick_active_) { + // Retain the floating origin while held; rendering updates screen size every frame. + joystick_base_x_ = safe_inset_ + std::max(joystick_radius_ + 24.0f, float(width) * 0.12f); + joystick_base_y_ = float(height) - std::max(joystick_radius_ + 24.0f, float(height) * 0.22f); joystick_knob_x_ = joystick_base_x_; joystick_knob_y_ = joystick_base_y_; } - layout_buttons(); } // Called once per frame to maintain continuous analog movement @@ -118,14 +121,19 @@ public: const float px = norm_x * float(screen_w_); const float py = norm_y * float(screen_h_); const bool controls_visible = gameplay_controls_visible(); + bool ui_hit = false; +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + ui_hit = PythonBoot::IsPointInsideActiveUI(int(px), int(py)); +#endif // The mobile overlay owns only the movement and combat control regions. - if (controls_visible) { + if (controls_visible && !ui_hit) { const int btn_idx = find_button(px, py); if (btn_idx >= 0) { auto& btn = buttons_[btn_idx]; + if (btn.finger_id >= 0) return true; // keep the original finger ownership #ifdef MT_NATIVE_HAS_LIVE_CLIENT - if (is_quick_slot_button(btn.id) && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2)) + if (is_quick_slot_button(btn.id) && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(quick_slot_index(btn.id))) return true; #endif btn.pressed = true; @@ -145,7 +153,8 @@ public: #ifdef MT_NATIVE_HAS_LIVE_CLIENT // Only hover here (the tooltip shows while the finger rests); what the finger does next picks // the gesture: lift = click, slide = press + drag, hold = right click, second tap = double click. - if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) { + if (ui_hit) { + if (ui_touch_finger_id_ >= 0) return true; // one UI gesture owns its pointer ui_touch_finger_id_ = finger_id; ui_gesture_ = UiGesture::Pending; ui_start_x_ = px; @@ -214,6 +223,8 @@ public: // or scroll bar captures), then follow the finger. ui_gesture_ = UiGesture::Dragging; last_tap_time_ = -1.0; + PythonBoot::SetInventoryDrag(true); + PythonBoot::UIMouseMove(int(ui_start_x_), int(ui_start_y_)); PythonBoot::UIMouseButton(1, true, int(ui_start_x_), int(ui_start_y_)); } PythonBoot::UIMouseMove(int(px), int(py)); @@ -297,12 +308,16 @@ public: if (ui_gesture_ == UiGesture::Dragging) { // Dropped on a skill button: that quick slot takes the dragged icon. const int btn_idx = gameplay_controls_visible() ? find_button(px, py) : -1; - if (btn_idx >= 0 && is_quick_slot_button(buttons_[btn_idx].id) && PythonBoot::UIIsAttaching()) { - PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(buttons_[btn_idx].id - 2); + if (PythonBoot::FinishInventoryDrag(int(px), int(py))) { + PythonBoot::UIMouseButton(1, false, int(ui_start_x_), int(ui_start_y_)); + } else if (btn_idx >= 0 && is_quick_slot_button(buttons_[btn_idx].id) && + !PythonBoot::IsPointInsideActiveUI(int(px), int(py)) && PythonBoot::UIIsAttaching()) { + PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(quick_slot_index(buttons_[btn_idx].id)); PythonBoot::UIMouseButton(1, false, int(ui_start_x_), int(ui_start_y_)); } else { PythonBoot::UIMouseButton(1, false, int(px), int(py)); } + PythonBoot::SetInventoryDrag(false); } else if (ui_gesture_ == UiGesture::Pending) { const double now = get_time_sec(); const int x = int(ui_start_x_), y = int(ui_start_y_); @@ -312,13 +327,17 @@ public: // mouse user would have seen and a finger hides, so put it back first. if (last_tap_attached_ && PythonBoot::UIIsAttaching()) PythonBoot::UIDeattachObject(); + PythonBoot::SetInventoryDoubleTap(true); PythonBoot::UIMouseDoubleClick(x, y); PythonBoot::UIMouseButton(1, false, x, y); + PythonBoot::SetInventoryDoubleTap(false); last_tap_time_ = -1.0; } else { const bool was_attaching = PythonBoot::UIIsAttaching(); + PythonBoot::SetInventoryTap(true); PythonBoot::UIMouseButton(1, true, x, y); PythonBoot::UIMouseButton(1, false, x, y); + PythonBoot::SetInventoryTap(false); last_tap_time_ = now; last_tap_x_ = ui_start_x_; last_tap_y_ = ui_start_y_; @@ -400,11 +419,9 @@ public: // Right-side attack and quick-slot buttons. for (const auto& btn : buttons_) { - const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle; - draw_filled_disc(commands, btn.x, btn.y, btn.radius, col); - draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16); + draw_control_disc(commands, btn.x, btn.y, btn.radius, btn.pressed); - const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF; + const uint32_t icon_col = 0xFFFFFFFF; const float r = btn.radius; #ifdef MT_NATIVE_HAS_LIVE_CLIENT @@ -416,13 +433,13 @@ public: std::string skill_icon; float uv[4]; int count = 0; - if (PythonBoot::LocalQuickSlotIcon(btn.id - 2, &skill_icon, uv, &count)) { + if (PythonBoot::LocalQuickSlotIcon(quick_slot_index(btn.id), &skill_icon, uv, &count)) { UIRenderCommand icon{}; icon.kind = UIRenderCommand::Image; - icon.x1 = btn.x - r * 0.68f; - icon.y1 = btn.y - r * 0.68f; - icon.x2 = btn.x + r * 0.68f; - icon.y2 = btn.y + r * 0.68f; + icon.x1 = btn.x - r * 0.64f; + icon.y1 = btn.y - r * 0.64f; + icon.x2 = btn.x + r * 0.64f; + icon.y2 = btn.y + r * 0.64f; icon.argb = icon_col; icon.text = skill_icon; icon.su = uv[0]; @@ -430,6 +447,17 @@ public: icon.eu = uv[2]; icon.ev = uv[3]; commands.push_back(std::move(icon)); + const auto state = PythonBoot::LocalQuickSlotState(quick_slot_index(btn.id)); + if (!state.learned || state.remaining > 0.0f) { + draw_filled_disc(commands, btn.x, btn.y, r * 0.82f, 0xA0000000); + if (state.remaining > 0.0f) { + const std::string seconds = std::to_string(int(std::ceil(state.remaining))); + const float h = r * 0.55f; + draw_segment_text(commands, seconds, btn.x - segment_text_width(seconds, h) / 2, + btn.y - h / 2, h); + } + } + if (state.active) draw_circle(commands, btn.x, btn.y, r * 0.92f, 0xFF80FF90, 24); if (count > 0) { const std::string digits = std::to_string(std::min(count, 9999)); const float h = r * 0.26f; @@ -441,6 +469,14 @@ public: } #endif + if (btn.id >= 9) { + const std::string label = std::to_string(quick_slot_index(btn.id) + 1); + const float h = r * 0.65f; + draw_segment_text(commands, label, btn.x - segment_text_width(label, h) / 2, + btn.y - h / 2, h, 0xFFD8C49A); + continue; + } + switch (btn.id) { case 1: { // ATK: crossed swords const float s = r * 0.35f; @@ -451,67 +487,60 @@ public: draw_line(commands, btn.x + s*0.4f - g, btn.y - s*0.4f - g, btn.x + s*0.4f + g, btn.y - s*0.4f + g, icon_col); break; } - case 2: { // S1: I - const float h = r * 0.35f; - draw_line(commands, btn.x, btn.y - h, btn.x, btn.y + h, icon_col); - draw_line(commands, btn.x - 4.0f, btn.y - h, btn.x + 4.0f, btn.y - h, icon_col); - draw_line(commands, btn.x - 4.0f, btn.y + h, btn.x + 4.0f, btn.y + h, icon_col); - break; - } - case 3: { // S2: II - const float h = r * 0.35f; - draw_line(commands, btn.x - 4.0f, btn.y - h, btn.x - 4.0f, btn.y + h, icon_col); - draw_line(commands, btn.x + 4.0f, btn.y - h, btn.x + 4.0f, btn.y + h, icon_col); - break; - } - case 4: { // S3: III - const float h = r * 0.35f; - draw_line(commands, btn.x - 6.0f, btn.y - h, btn.x - 6.0f, btn.y + h, icon_col); - draw_line(commands, btn.x, btn.y - h, btn.x, btn.y + h, icon_col); - draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col); - break; - } - case 5: { // S4: IV - const float h = r * 0.35f; - draw_line(commands, btn.x - 7.0f, btn.y - h, btn.x - 7.0f, btn.y + h, icon_col); - draw_line(commands, btn.x, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col); - draw_line(commands, btn.x + 14.0f, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col); - break; - } - case 6: { // S5: V - const float h = r * 0.35f; - draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col); - draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col); - break; - } - case 7: { // S6: VI - const float h = r * 0.35f; - draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col); - draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col); - draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col); + case 2: case 3: case 4: case 5: case 6: case 7: { + // Unassigned slots use the reference's small bronze plus. + const float arm = r * 0.30f; + const float half_width = std::max(1.5f, r * 0.085f) * 0.5f; + const uint32_t bronze = btn.pressed ? 0xFF997847 : 0xE6997847; + draw_rect_bar(commands, btn.x - arm, btn.y - half_width, + btn.x + arm, btn.y + half_width, bronze); + draw_rect_bar(commands, btn.x - half_width, btn.y - arm, + btn.x + half_width, btn.y + arm, bronze); break; } } } - // Left-side movement joystick. - draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24); - draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16); - draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_, - joystick_base_x_ - joystick_radius_ + 8.0f, joystick_base_y_, 0x90FFFFFF); - draw_line(commands, joystick_base_x_ + joystick_radius_ - 8.0f, joystick_base_y_, - joystick_base_x_ + joystick_radius_, joystick_base_y_, 0x90FFFFFF); - draw_line(commands, joystick_base_x_, joystick_base_y_ - joystick_radius_, - joystick_base_x_, joystick_base_y_ - joystick_radius_ + 8.0f, 0x90FFFFFF); - draw_line(commands, joystick_base_x_, joystick_base_y_ + joystick_radius_ - 8.0f, - joystick_base_x_, joystick_base_y_ + joystick_radius_, 0x90FFFFFF); - - if (joystick_active_) { - draw_line(commands, joystick_base_x_, joystick_base_y_, joystick_knob_x_, joystick_knob_y_, 0xB000FFFF); + // D2R-style floating stick: faint bronze base, star thumb and movement arc. + draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, + joystick_active_ ? 0x52997847 : 0x1F997847, 64); + const uint32_t thumb_fill = joystick_active_ ? 0xCC211B10 : 0x2E211B10; + const uint32_t thumb_edge = joystick_active_ ? 0xCCB89545 : 0x2EB89545; + draw_filled_disc(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, thumb_fill); + draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, thumb_edge, 48); + draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_ * 0.91f, + joystick_active_ ? 0x995B4725 : 0x225B4725, 48); + const float star_radius = joystick_knob_radius_ * 0.55f; + const float inner = star_radius * 0.18f; + const int rows = int(std::ceil(star_radius * 2.0f)); + for (int i = 0; i < rows; ++i) { + const float y = -star_radius + (i + 0.5f) * (star_radius * 2.0f / rows); + const float d = std::abs(y); + const float half = d < inner ? star_radius - (star_radius - inner) * d / inner + : inner * (star_radius - d) / (star_radius - inner); + draw_rect_bar(commands, joystick_knob_x_ - half, + joystick_knob_y_ - star_radius + i * (star_radius * 2.0f / rows), + joystick_knob_x_ + half, + joystick_knob_y_ - star_radius + (i + 1) * (star_radius * 2.0f / rows), + joystick_active_ ? 0xCCD4AE5F : 0x2ED4AE5F); + } + const float dx = joystick_knob_x_ - joystick_base_x_; + const float dy = joystick_knob_y_ - joystick_base_y_; + if (joystick_active_ && dx * dx + dy * dy > 0.01f) { + const float angle = std::atan2(dy, dx); + const float scale = joystick_radius_ / 65.0f; + for (int i = 0; i < 20; ++i) { + const float a = angle - 3.14159265f / 6.0f + i * 3.14159265f / 60.0f; + const float b = a + 3.14159265f / 60.0f; + for (int stroke = 0; stroke < 2; ++stroke) { + const float radius = joystick_radius_ + (3.0f + stroke) * scale; + draw_line(commands, joystick_base_x_ + std::cos(a) * radius, + joystick_base_y_ + std::sin(a) * radius, + joystick_base_x_ + std::cos(b) * radius, + joystick_base_y_ + std::sin(b) * radius, 0xB3997847); + } + } } - const uint32_t knob_color = joystick_active_ ? 0xB040A0FF : 0x6040A0FF; - draw_filled_disc(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, knob_color); - draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_ * 0.5f, 0x80FFFFFF, 12); } // Desktop mouse testing simulation @@ -617,7 +646,8 @@ private: } static constexpr int kAutoHuntButton = 8; - static bool is_quick_slot_button(int id) { return id >= 2 && id <= 7; } + static bool is_quick_slot_button(int id) { return (id >= 2 && id <= 7) || id == 9 || id == 10; } + static int quick_slot_index(int id) { return id >= 9 ? id - 3 : id - 2; } // A circular arrow; green while the hunt runs. static void draw_auto_hunt_button(std::vector& commands, const ButtonDef& btn) { @@ -654,7 +684,7 @@ private: void init_buttons() { buttons_.clear(); // Lower-right combat controls. All menus and status UI remain the original 40250 UI. - buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040}); + buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0xC04C2818, 0xE0A77832}); buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF}); buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0}); buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080}); @@ -662,66 +692,38 @@ private: buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0}); buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0}); buttons_.push_back({kAutoHuntButton, 0, 0, 0, 24.0f, "AUTO", 0x80707070, 0xD0FFFFFF}); + buttons_.push_back({9, 0x08, 0, 0, 28.0f, "7", 0xB032211B, 0xE0664B2D}); + buttons_.push_back({10, 0x09, 0, 0, 28.0f, "8", 0xB032211B, 0xE0664B2D}); + for (auto& btn : buttons_) { + if (is_quick_slot_button(btn.id)) { + btn.color_idle = 0xA032211B; + btn.color_pressed = 0xE08C6836; + } + } } void layout_buttons() { - const float W = float(screen_w_) - safe_inset_; - const float H = float(screen_h_); + const auto layout = mt_host::make_touch_layout(float(screen_w_), float(screen_h_), + safe_inset_, minimum_touch_target_); + joystick_radius_ = 65.0f * layout.scale; + joystick_knob_radius_ = 23.0f * layout.scale; for (auto& btn : buttons_) { - switch (btn.id) { - case 1: - btn.x = W - 75.0f; - btn.y = H - 85.0f; - btn.radius = std::min(46.0f, H * 0.12f); - break; - case 2: - btn.x = W - 360.0f; - btn.y = H - 75.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case 3: - btn.x = W - 295.0f; - btn.y = H - 75.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case 4: - btn.x = W - 230.0f; - btn.y = H - 75.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case 5: - btn.x = W - 165.0f; - btn.y = H - 85.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case 6: - btn.x = W - 125.0f; - btn.y = H - 155.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case 7: - btn.x = W - 75.0f; - btn.y = H - 225.0f; - btn.radius = std::min(28.0f, H * 0.08f); - break; - case kAutoHuntButton: - btn.x = W - 75.0f; - btn.y = H - 295.0f; - btn.radius = std::min(24.0f, H * 0.07f); - break; - } + const auto& circle = layout.buttons[btn.id - 1]; + btn.x = circle.x; + btn.y = circle.y; + btn.radius = circle.radius; } } int find_button(float x, float y) { + int nearest = -1; + float best = 1.25f; for (size_t i = 0; i < buttons_.size(); ++i) { const auto& btn = buttons_[i]; - const float dist = std::hypot(x - btn.x, y - btn.y); - if (dist <= btn.radius * 1.25f) { - return int(i); - } + const float distance = std::hypot(x - btn.x, y - btn.y) / btn.radius; + if (distance <= best) { best = distance; nearest = int(i); } } - return -1; + return nearest; } void trigger_button(int dik, bool pressed) { @@ -729,7 +731,7 @@ private: if (dik == 0x39) { PythonBoot::SetAttackKey(pressed); } - if (dik >= 0x02 && dik <= 0x07) { + if (dik >= 0x02 && dik <= 0x09) { if (pressed) PythonBoot::UseLocalQuickSlot(dik - 0x02); return; @@ -810,14 +812,26 @@ private: static void draw_filled_disc(std::vector& commands, float cx, float cy, float radius, uint32_t argb) { - // Base rectangular fill - draw_rect_bar(commands, cx - radius * 0.65f, cy - radius * 0.65f, cx + radius * 0.65f, cy + radius * 0.65f, (argb & 0x00FFFFFF) | 0x55000000); - // Cross fills for roundness - draw_rect_bar(commands, cx - radius * 0.85f, cy - radius * 0.35f, cx + radius * 0.85f, cy + radius * 0.35f, (argb & 0x00FFFFFF) | 0x55000000); - draw_rect_bar(commands, cx - radius * 0.35f, cy - radius * 0.85f, cx + radius * 0.35f, cy + radius * 0.85f, (argb & 0x00FFFFFF) | 0x55000000); - // Border rings - draw_circle(commands, cx, cy, radius, argb, 20); - draw_circle(commands, cx, cy, radius - 1.0f, (argb & 0x00FFFFFF) | 0x40000000, 20); + // Non-overlapping scanlines preserve the requested alpha across the disc. + const int rows = std::max(1, int(std::ceil(radius * 2.0f))); + const float height = radius * 2.0f / rows; + for (int i = 0; i < rows; ++i) { + const float y = -radius + (i + 0.5f) * height; + const float half = std::sqrt(std::max(0.0f, radius * radius - y * y)); + draw_rect_bar(commands, cx - half, cy - radius + i * height, + cx + half, cy - radius + (i + 1) * height, argb); + } + } + + static void draw_control_disc(std::vector& commands, + float cx, float cy, float radius, bool held) { + draw_filled_disc(commands, cx, cy, radius, held ? 0xEB16110C : 0x7A16110C); + const float width = std::max(1.5f, radius * 0.075f); + for (int i = 0; i < int(std::ceil(width)); ++i) + draw_circle(commands, cx, cy, radius - 0.5f - i, + held ? 0xFF997847 : 0x8A997847, 64); + draw_circle(commands, cx, cy, radius - width * 1.3f, + held ? 0xE61F170D : 0x8A1F170D, 64); } static double get_time_sec() { @@ -855,8 +869,9 @@ private: float joystick_base_y_ = 580.0f; float joystick_knob_x_ = 140.0f; float joystick_knob_y_ = 580.0f; + float minimum_touch_target_ = 48.0f; float joystick_radius_ = 65.0f; - float joystick_knob_radius_ = 28.0f; + float joystick_knob_radius_ = 23.0f; float move_angle_ = 0.0f; // Zones are fractions of the screen; distances are logical UI pixels. diff --git a/src/host/touch_layout.h b/src/host/touch_layout.h new file mode 100644 index 00000000..84ce3729 --- /dev/null +++ b/src/host/touch_layout.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace mt_host { + +struct TouchCircle { float x, y, radius; }; + +struct TouchLayout { + // Attack, six skills, auto hunt, two bottom item quick slots. + std::array buttons; + float scale; +}; + +inline TouchLayout make_touch_layout(float width, float height, float safe_inset, float minimum_target) { + TouchLayout layout{}; + layout.scale = std::max(minimum_target / 52.0f, + std::clamp(std::min(height / 600.0f, (width - 2 * safe_inset) / 800.0f), 0.65f, 1.35f)); + const float s = layout.scale; + const float ax = width - safe_inset - 78 * s; + // Leave the original status/menu strip clear below the lowest skill. + const float ay = height - 136 * s; + layout.buttons[0] = {ax, ay, 44 * s}; + // PORT: D2R Mobile's main-button + single skill arc arrangement, extended to six slots. + // 24-degree spacing at radius 176 keeps even the expanded (1.25x) hit circles separate. + for (int slot = 0; slot < 6; ++slot) { + const float angle = (160.0f + 24.0f * slot) * 3.14159265f / 180.0f; + layout.buttons[slot + 1] = {ax + std::cos(angle) * 176 * s, + ay + std::sin(angle) * 176 * s, 28 * s}; + } + // Keep the settings/toggle action outside the arc, away from repeated attack taps. + layout.buttons[7] = {ax - 246 * s, ay - 84 * s, 26 * s}; + const float item_center = std::min(width / 2, layout.buttons[1].x - 114 * s); + layout.buttons[8] = {item_center - 36 * s, height - 38 * s, 28 * s}; + layout.buttons[9] = {item_center + 36 * s, height - 38 * s, 28 * s}; + return layout; +} + +} // namespace mt_host diff --git a/src/host/vk_frame.cpp b/src/host/vk_frame.cpp index e7b64fe2..57e8e64e 100644 --- a/src/host/vk_frame.cpp +++ b/src/host/vk_frame.cpp @@ -315,11 +315,23 @@ void VulkanWindow::prepare_ui(std::uint32_t ui_width, std::uint32_t ui_height, c const uint32_t target_ui_h = ui_height ? ui_height : 640; update_fps_counter(); if (touch_controller.is_enabled() || show_fps_) { - std::vector combined_ui = ui_commands; + std::vector combined_ui; if (touch_controller.is_enabled()) { + // Convert a 48dp target to this frame's UI coordinates, independently of 3D scale. + int pixel_h = 0; + SDL_GetWindowSizeInPixels(window_, nullptr, &pixel_h); + const float density = SDL_GetWindowDisplayScale(window_); + const float target = pixel_h > 0 && density > 0 ? + 48.0f * density * float(target_ui_h) / float(pixel_h) : 48.0f; + touch_controller.set_minimum_touch_target(target); +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + PythonBoot::SetInventoryTouchTarget(target); +#endif touch_controller.update_screen_size(int(target_ui_w), int(target_ui_h), ui_safe_inset()); if (!SkipDiagnosticRender("touch")) touch_controller.append_ui_commands(combined_ui); } + // Inventory, shop and confirmation windows cover the combat overlay, as their hit tests do. + combined_ui.insert(combined_ui.end(), ui_commands.begin(), ui_commands.end()); if (show_fps_ && fps_ >= 0) { // Top centre, clear of the original affect bar and the mini-map. const float inset = float(std::min(ui_safe_inset(), int(target_ui_w) / 8)); diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt index 3deee0d5..f94368f7 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT TARGET mtpython) list(FILTER MT_PLATFORM_SOURCES EXCLUDE REGEX "/ScriptLib/") endif() add_library(port_platform STATIC ${MT_PLATFORM_SOURCES}) -target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype mt::libgr2) +target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype mt::libgr2 speedtree) foreach(src IN LISTS MT_PLATFORM_SOURCES) mt_port_apply_defines(${src}) endforeach() diff --git a/src/platform/ScriptLib/BiologScript.inc b/src/platform/ScriptLib/BiologScript.inc new file mode 100644 index 00000000..c97656f9 --- /dev/null +++ b/src/platform/ScriptLib/BiologScript.inc @@ -0,0 +1,457 @@ +// PORT: AVA2 biology (bio.*), with native touch-sized controls and original command vocabulary. +static const char c_szBiologScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import game +import grp +import item +import net +import player +import ui +import uiToolTip +import wndMgr +import weakref +import os +import sys +L = player.AutoHuntLocalize +g_window = None +g_entry = None +CATALOG = {1: {'index': 1, 'level': 30, 'material': 30006, 'count': 10, 'cooldown': 600, 'chance': 60, 'stone': 30220, 'bonuses': [{'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 30}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 2: {'index': 2, 'level': 40, 'material': 30047, 'count': 15, 'cooldown': 600, 'chance': 60, 'stone': 30221, 'bonuses': [{'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 40}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 3: {'index': 3, 'level': 50, 'material': 30015, 'count': 15, 'cooldown': 900, 'chance': 60, 'stone': 30222, 'bonuses': [{'point': 'POINT_ATTBONUS_MONSTER', 'label': '对怪物伤害 %', 'value': 5}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 4: {'index': 4, 'level': 60, 'material': 30050, 'count': 20, 'cooldown': 900, 'chance': 60, 'stone': 30223, 'bonuses': [{'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 4}, {'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 4}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 5: {'index': 5, 'level': 70, 'material': 300004, 'count': 25, 'cooldown': 1800, 'chance': 60, 'stone': 30224, 'bonuses': [{'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 6}, {'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 6}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 6: {'index': 6, 'level': 80, 'material': 300005, 'count': 30, 'cooldown': 1800, 'chance': 60, 'stone': 30225, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1000}, {'point': 'POINT_CASTING_SPEED', 'label': '施法速度', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 7: {'index': 7, 'level': 85, 'material': 300006, 'count': 40, 'cooldown': 2700, 'chance': 60, 'stone': 30226, 'bonuses': [{'point': 'POINT_RESIST_WARRIOR', 'label': '战士抗性 %', 'value': 10}, {'point': 'POINT_RESIST_ASSASSIN', 'label': '忍者抗性 %', 'value': 10}, {'point': 'POINT_RESIST_SURA', 'label': '修罗抗性 %', 'value': 10}, {'point': 'POINT_RESIST_SHAMAN', 'label': '萨满抗性 %', 'value': 10}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 8: {'index': 8, 'level': 90, 'material': 300007, 'count': 50, 'cooldown': 3600, 'chance': 60, 'stone': 30227, 'bonuses': [{'point': 'POINT_ATTBONUS_WARRIOR', 'label': '对战士伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_ASSASSIN', 'label': '对忍者伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_SURA', 'label': '对修罗伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_SHAMAN', 'label': '对萨满伤害 %', 'value': 10}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 9: {'index': 9, 'level': 92, 'material': 300008, 'count': 10, 'cooldown': 7200, 'chance': 60, 'stone': 30228, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1200}, {'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 120}, {'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 50}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 10: {'index': 10, 'level': 94, 'material': 300009, 'count': 20, 'cooldown': 7200, 'chance': 60, 'stone': 30229, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1500}, {'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 140}, {'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 60}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 11: {'index': 11, 'level': 98, 'material': 300010, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30230, 'bonuses': [{'point': 'POINT_ATTBONUS_MONSTER', 'label': '对怪物伤害 %', 'value': 10}, {'point': 'POINT_RESIST_HUMAN', 'label': '半人抗性 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 12: {'index': 12, 'level': 100, 'material': 300011, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30231, 'bonuses': [{'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 10}, {'point': 'POINT_MELEE_MAGIC_ATT_BONUS_PER', 'label': '近战及魔法攻击 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 13: {'index': 13, 'level': 105, 'material': 300012, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30232, 'bonuses': [{'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_HUMAN', 'label': '对半人伤害 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}} +SHOP = [{'vnum': 30220, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 5000000}, {'vnum': 30221, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 7000000}, {'vnum': 30222, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 10000000}, {'vnum': 30223, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 13000000}, {'vnum': 30224, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 20000000}, {'vnum': 30225, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 25000000}, {'vnum': 30226, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 50000000}, {'vnum': 30227, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 100000000}, {'vnum': 30228, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 200000000}, {'vnum': 30229, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 200000000}, {'vnum': 30230, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 30231, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 30232, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 300004, 'count': 1, 'cost': 30091, 'amount': 5, 'gold': 0}, {'vnum': 300005, 'count': 1, 'cost': 30197, 'amount': 3, 'gold': 0}, {'vnum': 300006, 'count': 1, 'cost': 30198, 'amount': 2, 'gold': 0}, {'vnum': 300007, 'count': 1, 'cost': 30196, 'amount': 2, 'gold': 0}, {'vnum': 300008, 'count': 1, 'cost': 70031, 'amount': 3, 'gold': 0}, {'vnum': 300009, 'count': 1, 'cost': 70031, 'amount': 5, 'gold': 0}, {'vnum': 300010, 'count': 1, 'cost': 30520, 'amount': 7, 'gold': 0}, {'vnum': 300011, 'count': 1, 'cost': 30521, 'amount': 7, 'gold': 0}, {'vnum': 300012, 'count': 1, 'cost': 30518, 'amount': 7, 'gold': 0}] + +def BonusText(bonus): + return bonus['label'].replace(" %", "")+" +%d"%bonus['value']+("%" if bonus['label'].endswith("%") else "") + + +def Text(parent, x, y, text="", color=0xffe8dec8): + line = ui.TextLine() + line.SetParent(parent) + line.AddFlag("not_pick") + line.SetPosition(x, y) + line.SetText(L(text)) + line.SetPackedFontColor(color) + line.Show() + return line + + +class Action(ui.Window): + def __init__(self, parent, x, y, width, text, event): + ui.Window.__init__(self) + if parent: + self.SetParent(parent) + self.SetPosition(x, y) + self.SetSize(width, 48) + self.event = event + self.enabled = True + self.down = False + self.label = Text(self, width/2, 24, text) + self.label.SetHorizontalAlignCenter() + self.label.SetVerticalAlignCenter() + self.Show() + + def SetText(self, text): + self.label.SetText(L(text)) + + def SetEnabled(self, enabled): + self.enabled = bool(enabled) + self.label.SetPackedFontColor(0xffefdbac if enabled else 0xff817c70) + + def OnRender(self): + x, y = self.GetGlobalPosition() + grp.SetColor(0xdd684f2f if self.down else 0xdd2d261d) + grp.RenderBar(x, y, self.GetWidth(), self.GetHeight()) + grp.SetColor(0xffb99a66 if self.enabled else 0xff57534b) + grp.RenderBox(x, y, self.GetWidth(), self.GetHeight()) + + def OnMouseLeftButtonDown(self): + self.down = self.enabled + return True + + def OnMouseLeftButtonUp(self): + invoke = self.down and self.enabled and self.IsIn() + self.down = False + if invoke: + self.event() + return True + + def OnMouseOverOut(self): + self.down = False + + +class BiologWindow(ui.Window): + def __init__(self): + ui.Window.__init__(self) + self.SetWindowName("BiologWindow") + self.AddFlag("float") + self.AddFlag("movable") + self.SetSize(540, 400) + self.heading = Text(self, 22, 18, "生物学家研究", 0xffffd68b) + self.closeButton = Action(self, 470, 6, 48, "关闭", self.Hide) + self.title = Text(self, 22, 48) + self.phase = Text(self, 22, 68) + self.material = Text(self, 104, 92) + self.progress = Text(self, 104, 114) + self.owned = Text(self, 104, 136) + self.cooldown = Text(self, 22, 160) + self.reward = [Text(self, 22+(i%2)*260, 188+(i/2)*22) for i in range(4)] + self.message = Text(self, 22, 378, "正在同步研究进度", 0xffb3aa98) + self.icon = ui.ExpandedImageBox() + self.icon.SetParent(self) + self.icon.SetPosition(26, 92) + self.icon.AddFlag("not_pick") + self.tooltip = uiToolTip.ItemToolTip() + self.tooltip.Hide() + self.enhance = Action(self, 22, 288, 158, "精华:开启", lambda:self.Option(0)) + self.skip = Action(self, 190, 288, 158, "跳过冷却:开启", lambda:self.Option(1)) + self.reminder = Action(self, 358, 288, 160, "到期提醒:开启", lambda:self.Option(2)) + self.submit = Action(self, 22, 344, 158, "提交材料", self.Act) + self.shopButton = Action(self, 190, 344, 158, "研究商店", self.Shop) + self.resetButton = Action(self, 358, 344, 160, "重选奖励", self.Reset) + self.choices = [Action(self, 22+(i%2)*260, 188+(i/2)*54, 238, "", lambda i=i:self.Gift(i)) for i in range(4)] + self.prev = Action(self, 22, 288, 158, "上一项", lambda:self.Browse(-1)) + self.next = Action(self, 358, 288, 160, "下一项", lambda:self.Browse(1)) + self.options = [True, True, True] # AVA2 defaults: enhancer, time skip, reminder. + self.index = 0 + self.count = 0 + self.deadline = 0 + self.serverTime = 0 + self.receivedAt = 0 + self.pendingUntil = 0 + self.nextQuery = app.GetTime()+1 + self.queryStarted = 0 + self.mode = "research" + self.bonuses = [0]*13 + self.browse = 0 + self.hasState = False + self.iconVnum = 0 + self.iconHover = False + self.lastRemaining = 0 + self.dimensions = None + self.LoadOptions() + self.Hide() + self.Refresh() + + def OnRender(self): + x,y = self.GetGlobalPosition() + grp.SetColor(0xf51b1916) + grp.RenderBar(x,y,self.GetWidth(),self.GetHeight()) + grp.SetColor(0xff9b7b48) + grp.RenderBox(x,y,self.GetWidth(),self.GetHeight()) + + def PreferencePath(self): + # Keep preferences outside a signed macOS app; Android falls back to its writable client dir. + base = os.environ.get("HOME", ".") + if sys.platform.startswith("linux"): + base = "." + folder = os.path.join(base, ".metin2", "biolog") + name = player.GetName() + return folder, os.path.join(folder, name.encode("hex")+".cfg") + + def LoadOptions(self): + try: + folder,path = self.PreferencePath() + values = [int(x) for x in open(path,"r").read().strip().split("#")] + if len(values)==3 and all(type(x) in (bool,int) and x in (0,1) for x in values): + self.options = map(bool, values) + except (IOError, OSError, ValueError, TypeError): + pass + + def Option(self, index): + self.options[index] = not self.options[index] + try: + folder,path = self.PreferencePath() + if not os.path.isdir(folder): os.makedirs(folder) + with open(path,"w") as stream: stream.write("#".join(str(int(x)) for x in self.options)) + except (IOError,OSError): + pass + self.Refresh() + + def Toggle(self): + if self.IsShow(): self.Hide() + else: + self.mode = "research" + self.SetCenterPosition() + self.Show() + self.SetTop() + self.Query() + + def Query(self): + now = app.GetTime() + self.nextQuery = now+3 + if not self.queryStarted:self.queryStarted=now + net.SendChatPacket("/bio status") + + def SetData(self, words): + if len(words)!=3:return False + try:index,count,deadline=map(int,words) + except (ValueError,TypeError):return False + if index not in CATALOG or count<0 or count>CATALOG[index]['count']+1 or deadline<0:return False + self.index,self.count,self.deadline=index,count,deadline + self.receivedAt=app.GetTime() + self.hasState=True + self.pendingUntil=0 + self.Refresh() + return True + + def SetPhase(self, index, stone): + try:index=int(index) + except (ValueError,TypeError):return + if index not in CATALOG:return + self.index=index + self.count=CATALOG[index]['count']+(0 if stone else 1) + self.hasState=True + self.pendingUntil=0 + self.Refresh() + + def Phase(self): + if not self.hasState:return "loading" + if self.index>=14:return "complete" + row=CATALOG[self.index] + if self.count=offer['amount'] if offer['cost'] else player.GetElk()>=offer['gold'] + if self.mode=="reset" or self.index not in CATALOG:return False + row=CATALOG[self.index] + if player.GetStatus(player.LEVEL)0 and (self.Remaining()==0 or (self.options[1] and self.ItemCount(row['skip'])>0)) + return phase=="stone" and self.ItemCount(row['stone'])>0 + + def Act(self): + if not self.CanAct():return + self.pendingUntil=app.GetTime()+5 + if self.mode=="shop":net.SendChatPacket("/bio buy %d"%self.browse) + else:net.SendChatPacket("/bio mission %d %d"%(int(self.options[0]),int(self.options[1]))) + self.nextQuery=app.GetTime()+1 + self.Refresh() + + def Gift(self, choice): + if app.GetTime()=len(CATALOG[index]['bonuses']):return + if self.mode=="reset": + if self.ItemCount(70028)<1 or self.bonuses[index-1]==choice+1:return + command="/bio reset %d %d"%(index,choice) + else: + if self.Phase()!="gift":return + command="/bio gift %d"%choice + self.pendingUntil=app.GetTime()+5 + net.SendChatPacket(command) + self.nextQuery=app.GetTime()+1 + self.Refresh() + + def Shop(self): + self.mode="research" if self.mode=="shop" else "shop" + self.browse=0 + self.Refresh() + + def ResetEntries(self): + return [i+1 for i,value in enumerate(self.bonuses) if value and i+1=self.pendingUntil) + self.shopButton.SetText("返回研究" if self.mode=="shop" else "研究商店") + self.resetButton.SetText("返回研究" if self.mode=="reset" else "重选奖励") + self.message.SetText(L("请求处理中……" if app.GetTime()=14 and self.mode=="research": + self.title.SetText(L("全部研究已完成")) + self.phase.SetText(L("30~105 级研究奖励已领取。")) + self.icon.Hide() + for line in (self.material,self.progress,self.owned,self.cooldown):line.SetText("") + self.submit.SetText("已完成") + return + if self.mode=="shop": + offer=SHOP[self.browse];vnum=offer['vnum'] + self.title.SetText(L("研究商店 %d / %d")%(self.browse+1,len(SHOP))) + self.phase.SetText(L("与 AVA2 生物学家商店相同的物品与兑换条件。")) + self.progress.SetText(L("购买数量:%d")%offer['count']) + cost=(self.ItemName(offer['cost'])+L(" × %d")%offer['amount']) if offer['cost'] else L("金币 %d")%offer['gold'] + self.cooldown.SetText(L("价格:")+cost) + self.submit.SetText("购买") + for b in (self.enhance,self.skip,self.reminder):b.Hide() + self.prev.Show();self.next.Show() + else: + index=self.ResetIndex() if self.mode=="reset" else self.index + if not index: + self.title.SetText(L("暂无可重选的奖励")) + self.phase.SetText(L("完成有奖励选项的研究后,可消耗重选道具。")) + self.icon.Hide() + for line in (self.material,self.progress,self.owned,self.cooldown):line.SetText("") + self.submit.SetEnabled(False) + return + row=CATALOG[index] + phase="gift" if self.mode=="reset" else self.Phase() + vnum=row['stone'] if phase=="stone" else row['material'] + self.title.SetText(L("%d 级研究 · %d / 13")%(row['level'],index)) + text={"collect":"收集材料", "stone":"提交任务石", "gift":"选择研究奖励"}.get(phase,"") + self.phase.SetText(L(text)) + self.progress.SetText(L("进度:%d / %d")%(min(self.count,row['count']) if self.mode!="reset" else row['count'],row['count'])) + remaining=self.Remaining() if phase=="collect" else 0 + self.cooldown.SetText(L("冷却 %02d:%02d:%02d · 基础成功率 %d%%")%(remaining/3600,(remaining/60)%60,remaining%60,row['chance'])) + self.submit.SetText("提交任务石" if phase=="stone" else "提交材料") + if phase=="gift": + for b in (self.enhance,self.skip,self.reminder):b.Hide() + for i,bonus in enumerate(row['bonuses']): + b=self.choices[i];b.SetText(BonusText(bonus));b.Show() + b.SetEnabled(app.GetTime()>=self.pendingUntil and (self.mode!="reset" or (self.ItemCount(70028)>0 and self.bonuses[index-1]!=i+1))) + else: + for line,bonus in zip(self.reward,row['bonuses']):line.SetText(L(BonusText(bonus))) + if self.mode=="reset": + for b in (self.enhance,self.skip,self.reminder):b.Hide() + self.prev.Show();self.next.Show() + self.message.SetText(L("消耗重选道具 ×1,当前选择:%d")%self.bonuses[index-1]) + elif player.GetStatus(player.LEVEL)=self.nextQuery and (self.IsShow() or not self.hasState):self.Query() + remaining=self.Remaining() + if self.options[2] and self.lastRemaining>0 and remaining==0 and self.Phase()=="collect": + chat.AppendChat(chat.CHAT_TYPE_NOTICE,L("[生物学家] 可以再次提交研究材料了。")) + self.lastRemaining=remaining + if not self.IsShow():return + dimensions=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight()) + if dimensions!=self.dimensions: + self.dimensions=dimensions + height=min(400,dimensions[1]-12) + self.SetSize(540,height) + self.message.SetPosition(22,height-20) + for b in (self.submit,self.shopButton,self.resetButton):b.SetPosition(b.GetLocalPosition()[0],height-56) + for b in (self.enhance,self.skip,self.reminder,self.prev,self.next):b.SetPosition(b.GetLocalPosition()[0],height-112) + self.SetCenterPosition() + self.Refresh() + x,y=self.GetGlobalPosition();mx,my=wndMgr.GetMousePosition() + hover=self.iconVnum and x+24<=mx=2: + text={"level":"等级不足,暂时无法继续研究。","material":"研究材料不足。","cooldown":"冷却中,需要冷却药剂才能立即提交。","success":"研究成功,进度增加。","failure":"研究失败,材料已消耗。","stone":"请先准备所需任务石。","complete":"所有生物学家研究已完成。","busy":"请先关闭交易、商店或任务对话。","bought":"购买成功。","cost":"金币或兑换材料不足。","reset":"研究奖励已重新选择。","reset_item":"需要重选道具。"}.get(words[1],"") + if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text)) + g_window.pendingUntil=0 + except (ValueError,TypeError,IndexError):pass + return 1 + return _command(self,line) + +game.GameWindow.Open=Open +game.GameWindow.Close=Close +game.GameWindow.BINARY_ServerCommand_Run=Command +)PY"; diff --git a/src/platform/ScriptLib/MobileHUDScript.inc b/src/platform/ScriptLib/MobileHUDScript.inc new file mode 100644 index 00000000..b4c887e5 --- /dev/null +++ b/src/platform/ScriptLib/MobileHUDScript.inc @@ -0,0 +1,280 @@ +// PORT: mobile presentation; menu actions and player data use the existing client APIs. +static const char c_szMobileHUDScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import game +import grp +import math +import net +import player +import ui +import uiCharacter +import weakref +import wndMgr +import mt_mobile_inventory + +L = player.AutoHuntLocalize +g_hud = None + + +def _Text(parent, text): + line = ui.TextLine() + line.SetParent(parent) + line.AddFlag("not_pick") + line.SetText(L(text)) + line.SetOutline() + line.Show() + return line + + +class RoundAction(ui.Window): + def __init__(self, parent, text, action, icon=None): + ui.Window.__init__(self) + self.SetParent(parent) + self.action = action + self.down = False + self.label = _Text(self, text) + self.label.SetHorizontalAlignCenter() + self.label.SetVerticalAlignCenter() + self.icon = None + if icon: + self.icon = ui.ExpandedImageBox() + self.icon.SetParent(self) + self.icon.AddFlag("not_pick") + self.icon.LoadImage(icon) + self.iconSize = (self.icon.GetWidth(), self.icon.GetHeight()) + self.icon.Show() + self.Show() + + def Place(self, x, y, size): + self.SetPosition(int(x), int(y)) + self.SetSize(int(size), int(size)) + self.label.SetPosition(int(size/2), int(size/2)) + if self.icon: + edge = size * .62 + self.icon.SetScale(edge / max(1, self.iconSize[0]), edge / max(1, self.iconSize[1])) + self.icon.SetPosition(int((size-edge)/2), int((size-edge)/2)) + + def OnRender(self): + x, y = self.GetGlobalPosition() + r = self.GetWidth()/2.0 + grp.SetColor(0xE0664B2D if self.down else 0xBB281A16) + # Horizontal strips fill a round button without requiring extra image assets. + for row in xrange(0, self.GetHeight(), 3): + dy = min(r, row + 1.5 - r) + half = math.sqrt(max(0, r*r - dy*dy)) + grp.RenderBar(x+r-half, y+row, half*2, min(3, self.GetHeight()-row)) + grp.SetColor(0xFFE6CB8D if self.down else 0xCCB99A66) + for i in xrange(32): + a = i * math.pi / 16 + b = (i+1) * math.pi / 16 + xx, yy = x+r+math.cos(a)*(r-1), y+r+math.sin(a)*(r-1) + grp.RenderLine(xx, yy, (math.cos(b)-math.cos(a))*(r-1), (math.sin(b)-math.sin(a))*(r-1)) + + def OnMouseLeftButtonDown(self): + self.down = True + return True + + def OnMouseLeftButtonUp(self): + held = self.down + self.down = False + if held and self.IsIn(): + self.action() + return True + + def OnMouseOverOut(self): + self.down = False + + +class Status(ui.Window): + def __init__(self, parent, taskbar, action): + ui.Window.__init__(self) + self.SetParent(parent) + self.AddFlag("not_pick") + self.taskbar = weakref.proxy(taskbar) + self.frame = ui.ExpandedImageBox() + self.frame.SetParent(self) + self.frame.AddFlag("not_pick") + self.frame.LoadImage("d:/ymir work/ui/game/taskbar/gauge.sub") + # Keep the original bar frame, cropping away the large legacy orb on its left. + self.frame.SetRenderingRect(-56.0/self.frame.GetWidth(), -12.0/self.frame.GetHeight(), + 0, (46.0-self.frame.GetHeight())/self.frame.GetHeight()) + self.frame.Show() + self.gauge = taskbar.GetChild("Gauge_Board") + self.gaugePosition = self.gauge.GetLocalPosition() + self.gauge.SetParent(self) + self.gauge.AddFlag("not_pick") + self.gauge.SetAlpha(0) + self.gauge.Show() + for name in ("RampageGauge", "RampageGauge2"): + if taskbar.IsChild(name): + taskbar.GetChild(name).Hide() + self.face = RoundAction(self, "", action, uiCharacter.FACE_IMAGE_DICT.get(net.GetMainActorRace())) + self.name = _Text(self, "") + self.hp = _Text(self, "") + self.sp = _Text(self, "") + self.energy = _Text(self, "") + self.hp.SetHorizontalAlignCenter() + self.sp.SetHorizontalAlignCenter() + self.values = (0, 1, 0, 1, 0, 1) + self.lastStatus = None + self.Show() + + def Place(self, scale): + self.scale = scale + self.SetPosition(int(12*scale), int(10*scale)) + self.SetSize(int(290*scale), int(88*scale)) + # The original three bars are native-size: their outer frame is 34 pixels tall. + self.face.Place(18*scale, 14*scale+12, 34) + self.name.SetPosition(int(66*scale), 0) + # The original frame, animated HP/SP textures, recovery overlays and ST strip stay intact. + bar_x = int(18*scale) + 34 - 56 + self.gauge.SetPosition(bar_x, int(14*scale)) + self.frame.SetPosition(bar_x, int(14*scale)) + self.hp.SetHorizontalAlignLeft() + self.sp.SetHorizontalAlignLeft() + self.hp.SetPosition(bar_x+166, int(24*scale)) + self.sp.SetPosition(bar_x+166, int(42*scale)) + self.energy.SetPosition(int(66*scale), int(68*scale)) + + def Refresh(self): + hp, mh = player.GetStatus(player.HP), player.GetStatus(player.MAX_HP) + sp, ms = player.GetStatus(player.SP), player.GetStatus(player.MAX_SP) + exp, me = player.GetStatus(player.EXP), player.GetStatus(player.NEXT_EXP) + self.values = (hp, mh, sp, ms, exp, me) + status = self.values + (player.GetStatus(player.STAMINA), player.GetStatus(player.MAX_STAMINA), + player.GetStatus(player.HP_RECOVERY), player.GetStatus(player.SP_RECOVERY)) + if status != self.lastStatus: + self.taskbar.RefreshStatus() + self.lastStatus = status + self.name.SetText("%s Lv.%d" % (player.GetName(), player.GetStatus(player.LEVEL))) + self.hp.SetText("%d / %d" % (hp, mh) if mh > 0 else "--") + self.sp.SetText("%d / %d" % (sp, ms) if ms > 0 else "--") + energy = player.GetStatus(player.ENERGY) + remaining = player.GetStatus(player.ENERGY_END_TIME) - app.GetGlobalTimeStamp() + if energy > 0 and remaining > 0: + self.energy.SetText(L("能量 +%d %d分钟") % (energy, (remaining+59)/60)) + self.energy.Show() + else: + self.energy.Hide() + + def Restore(self): + self.gauge.Hide() + self.gauge.SetAlpha(1) + self.gauge.SetParent(self.taskbar) + self.gauge.SetPosition(*self.gaugePosition) + self.face.action = None + + +class HUD(ui.Window): + def __init__(self, window): + ui.Window.__init__(self) + self.SetWindowName("MobileHUD") + self.AddFlag("not_pick") + self.window = weakref.proxy(window) + interface = window.interface + self.taskbar = weakref.proxy(interface.wndTaskBar) + self.original = (self.taskbar.Show, self.taskbar.Hide, self.taskbar.IsShow) + self.original[1]() + self.taskbar.Show = self.Show + self.taskbar.Hide = self.Hide + self.taskbar.IsShow = self.IsShow + self.chat = weakref.proxy(interface.wndChat) + self.chatColors = (self.chat.BOARD_START_COLOR, self.chat.BOARD_END_COLOR, self.chat.BOARD_MIDDLE_COLOR) + self.chat.BOARD_START_COLOR = self.chat.BOARD_END_COLOR = self.chat.BOARD_MIDDLE_COLOR = 0 + self.status = Status(self, interface.wndTaskBar, interface.ToggleCharacterWindowStatusPage) + self.experience = self.taskbar.expGaugeBoard + self.experiencePosition = self.experience.GetLocalPosition() + self.experience.SetParent(self) + self.experience.AddFlag("not_pick") + self.experience.Show() + for point in self.taskbar.expGauge: + point.AddFlag("not_pick") + self.experienceText = _Text(self.experience, "") + self.top = [] + for text, index, action in (("", 0, interface.ToggleCharacterWindowStatusPage), + ("", 1, interface.ToggleInventoryWindow), + ("", 2, interface.ToggleMessenger), + ("", 3, interface.ToggleSystemDialog)): + self.top.append(RoundAction(self, text, action, self.taskbar.toggleButtonDict[index].GetUpVisualFileName())) + self.side = [RoundAction(self, "任务", lambda: interface.ToggleCharacterWindow("QUEST")), + RoundAction(self, "帮助", interface.OpenHelpWindow), + RoundAction(self, "聊天", interface.ToggleChat)] + self.dimensions = None + self.OnUpdate() + self.Show() + + def Show(self): + ui.Window.Show(self) + self.SetTop() + + def OnUpdate(self): + w, h = wndMgr.GetScreenWidth(), wndMgr.GetScreenHeight() + target = max(48, mt_mobile_inventory.touch_target) + dimensions = (w, h, target) + if dimensions != self.dimensions: + self.dimensions = dimensions + s = max(target/52.0, min(1.35, max(.65, min(h/600.0, w/800.0)))) + self.SetSize(w, h) + self.status.Place(s) + self.experience.SetPosition(int(12*s), int(h-self.experience.GetHeight()-6*s)) + self.experienceText.SetPosition(self.experience.GetWidth()+6, 10) + size, gap = max(target, 48*s), 8*s + for i, button in enumerate(self.top): + button.Place(w-12*s-(4-i)*(size+gap), 10*s, size) + for i, button in enumerate(self.side): + if h < 520 or s > 1.25: + button.Place(12*s+i*(size+gap), 140*s, size) + else: + button.Place(12*s, 140*s+i*(size+gap), size) + self.window.affectShower.SetPosition(int(18*s), int(104*s)) + minimap = self.window.interface.wndMiniMap + minimap.SetWindowHorizontalAlignLeft() + minimap.SetWindowVerticalAlignTop() + minimap.SetPosition(int(w-minimap.GetWidth()-12*s), int(72*s)) + chat = self.window.interface.wndChat + chat.SetPosition(int(180*s), int(h-86*s)) + chat.Refresh() + # The original help prompt would duplicate the touch-sized left-side entry. + self.window.interface.wndGameButton.gameButtonDict["HELP"].Hide() + energy = self.window.interface.wndEnergyBar + if energy: + energy.Hide() + self.status.Refresh() + # Match TaskBar.SetExperience's unsigned 32-bit experience counters. + exp, total = (value & 0xffffffff for value in self.status.values[4:6]) + self.experienceText.SetText((L("经验 %.2f%%") % (100.0*exp/total)) if total > 0 else L("经验 --")) + + def Destroy(self): + self.Hide() + self.chat.BOARD_START_COLOR, self.chat.BOARD_END_COLOR, self.chat.BOARD_MIDDLE_COLOR = self.chatColors + self.taskbar.Show, self.taskbar.Hide, self.taskbar.IsShow = self.original + self.status.Restore() + self.experience.Hide() + self.experience.SetParent(self.taskbar) + self.experience.SetPosition(*self.experiencePosition) + for button in self.top + self.side: + button.action = None + self.window = None + + +_open = game.GameWindow.Open +_close = game.GameWindow.Close + + +def Open(self): + global g_hud + _open(self) + g_hud = HUD(self) + + +def Close(self): + global g_hud + if g_hud: + g_hud.Destroy() + g_hud = None + _close(self) + + +game.GameWindow.Open = Open +game.GameWindow.Close = Close +)PY"; diff --git a/src/platform/ScriptLib/MobileInventoryScript.inc b/src/platform/ScriptLib/MobileInventoryScript.inc new file mode 100644 index 00000000..575d4cdd --- /dev/null +++ b/src/platform/ScriptLib/MobileInventoryScript.inc @@ -0,0 +1,125 @@ +// PORT: touch inventory gestures; original methods own use, move and sale requests. +static const char c_szMobileInventoryScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import constInfo +import game +import mouseModule +import player +import shop +import uiInventory +import uiShop + +# These flags are scoped by the native touch controller, never by desktop mouse input. +tap = False +double_tap = False +dragging = False +touch_target = 48 +_last = None +_last_time = -1 +_drag = None +_OriginalSelect = uiInventory.InventoryWindow.SelectItemSlot +_OriginalUse = uiInventory.InventoryWindow.UseItemSlot +_OriginalShopLoad = uiShop.ShopDialog.LoadDialog + + +def _ShopLoad(self): + result = _OriginalShopLoad(self) + # The original loader names child windows but leaves this root unnamed. + self.SetWindowName("ShopDialog") + return result + + +def _Snapshot(owner, slot): + import weakref + cell = owner._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot) + return (owner if isinstance(owner, weakref.ProxyTypes) else weakref.proxy(owner), owner.inventoryPageIndex, cell, + player.GetItemIndex(cell), player.GetItemCount(cell)) + + +def _Valid(snapshot): + if not snapshot: + return False + try: + owner, page, cell, vnum, count = snapshot + return (owner.IsShow() and owner.inventoryPageIndex == page and vnum != 0 and + player.GetItemIndex(cell) == vnum and player.GetItemCount(cell) == count and + not constInfo.GET_ITEM_QUESTION_DIALOG_STATUS()) + except ReferenceError: + return False + + +def _Select(self, slot): + global _last, _last_time, _drag + mouse = mouseModule.mouseController + if tap and not mouse.isAttached() and not constInfo.GET_ITEM_QUESTION_DIALOG_STATUS() and app.GetCursor() not in (app.SELL, app.BUY): + _last = _Snapshot(self, slot) + _last_time = app.GetTime() + if _last[3]: + self.ShowToolTip(_last[2]) + return + snapshot = _Snapshot(self, slot) if dragging and not mouse.isAttached() else None + result = _OriginalSelect(self, slot) + if snapshot and mouse.isAttached() and mouse.GetAttachedType() == player.SLOT_TYPE_INVENTORY: + _drag = snapshot + return result + + +def _Use(self, slot): + global _last + if double_tap: + snapshot = _last + _last = None + if (not _Valid(snapshot) or not 0 <= app.GetTime()-_last_time <= .5 or + snapshot != _Snapshot(self, slot)): + return + return _OriginalUse(self, slot) + + +def SetDrag(active): + global dragging, _drag, _last + dragging = active + _drag = None + _last = None + + +def Drop(shop_target): + """Consume stale inventory drags and NPC-shop drops; other targets use the normal UI path.""" + global _drag + mouse = mouseModule.mouseController + if not _drag or not mouse.isAttached(): + return False + snapshot = _drag + if (not _Valid(snapshot) or mouse.GetAttachedType() != player.SLOT_TYPE_INVENTORY or + mouse.GetAttachedSlotNumber() != snapshot[2] or mouse.GetAttachedItemIndex() != snapshot[3] or + mouse.GetAttachedItemCount() != snapshot[4]): + mouse.DeattachObject() + _drag = None + return True + if not shop_target or not shop.IsOpen() or shop.IsPrivateShop(): + return False + # Always use the original inventory sale dialog, including anti-sell checks and identity recheck. + owner, page, cell, vnum, count = snapshot + mouse.DeattachObject() + _drag = None + owner._InventoryWindow__SellItem(cell) + return True + + +def Reset(): + global tap, double_tap, dragging, _last, _drag + tap = double_tap = dragging = False + _last = _drag = None + + +_Close = game.GameWindow.Close + +def Close(self): + Reset() + _Close(self) + + +uiInventory.InventoryWindow.SelectItemSlot = _Select +uiInventory.InventoryWindow.UseItemSlot = _Use +uiShop.ShopDialog.LoadDialog = _ShopLoad +game.GameWindow.Close = Close +)PY"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index c7e7a64d..9449626b 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -161,6 +161,7 @@ struct GameSingletons std::unique_ptr g_game_singletons; bool g_host_hardware_cursor_enabled = false; bool g_touch_input = false; +int g_inventory_touch_target = 0; bool fail(std::string* error, const std::string& text) { @@ -526,6 +527,76 @@ bool quick_slot_image_uv(CGraphicImage* image, std::string* texture, float uv[4] } } +// PORT: presentation only; skill validation remains in RequestUseLocalQuickSlot. +QuickSlotState LocalQuickSlotState(int localSlotIndex) +{ + QuickSlotState state; + if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return state; + DWORD type = SLOT_TYPE_NONE, position = 0; + auto& player = CPythonPlayer::Instance(); + player.GetLocalQuickSlotData(static_cast(localSlotIndex), &type, &position); + if (type == SLOT_TYPE_SKILL) { + state.learned = player.GetSkillLevel(position) > 0; + state.active = player.IsSkillActive(position); + if (player.IsSkillCoolTime(position)) + state.remaining = std::max(0.0f, player.GetSkillCoolTime(position) - player.GetSkillElapsedCoolTime(position)); + } + return state; +} + +void SetInventoryTouchTarget(float logicalSize) +{ + if (!g_touch_input || !g_launcher) return; + const int size = std::max(48, int(std::ceil(logicalSize))); + if (size == g_inventory_touch_target) return; + std::string error; + if (RunLine(("__import__('mt_mobile_inventory').touch_target = " + std::to_string(size)).c_str(), &error)) + g_inventory_touch_target = size; +} + +void SetInventoryTap(bool tap) +{ + if (!g_touch_input) return; + std::string error; + RunLine(tap ? "__import__('mt_mobile_inventory').tap = True" : + "__import__('mt_mobile_inventory').tap = False", &error); +} + +void SetInventoryDoubleTap(bool tap) +{ + if (!g_touch_input) return; + std::string error; + RunLine(tap ? "__import__('mt_mobile_inventory').double_tap = True" : + "__import__('mt_mobile_inventory').double_tap = False", &error); +} + +void SetInventoryDrag(bool dragging) +{ + if (!g_touch_input) return; + std::string error; + RunLine(dragging ? "__import__('mt_mobile_inventory').SetDrag(True)" : + "__import__('mt_mobile_inventory').SetDrag(False)", &error); +} + +bool FinishInventoryDrag(int x, int y) +{ + if (!g_touch_input || !g_window_manager) return false; + UIMouseMove(x, y); + bool shop_target = false; + for (auto* picked = g_window_manager->GetPointWindow(); picked; picked = picked->GetParent()) + if (std::string(picked->GetName()) == "ShopDialog") { + shop_target = true; + break; + } + std::string result, error; + if (!Evaluate(shop_target ? "__import__('mt_mobile_inventory').Drop(True)" : + "__import__('mt_mobile_inventory').Drop(False)", &result, &error)) { + UIDeattachObject(); + return true; // Never fall back to a direct shop sale after a failed confirmation handler. + } + return result == "True" || result == "1"; +} + bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count) { if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false; @@ -915,8 +986,11 @@ namespace PythonBoot #include "FrameRateOptionScript.inc" #include "UIScaleOptionScript.inc" #include "InventoryPagesScript.inc" +#include "MobileInventoryScript.inc" #include "MallHintScript.inc" #include "ServerBonusScript.inc" +#include "MobileHUDScript.inc" +#include "BiologScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -975,8 +1049,15 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_framerate", c_szFrameRateOptionScript); install_script_module("mt_uiscale", c_szUIScaleOptionScript); install_script_module("mt_inventory", c_szInventoryPagesScript); + if (g_touch_input) { + g_inventory_touch_target = 0; + install_script_module("mt_mobile_inventory", c_szMobileInventoryScript); + } install_script_module("mt_mallhint", c_szMallHintScript); install_script_module("mt_bonuses", c_szServerBonusScript); + install_script_module("mt_biolog", c_szBiologScript); + if (g_touch_input) + install_script_module("mt_mobile_hud", c_szMobileHUDScript); } return true; } diff --git a/src/platform/ScriptLib/PythonBoot.h b/src/platform/ScriptLib/PythonBoot.h index dce18aab..9402be11 100644 --- a/src/platform/ScriptLib/PythonBoot.h +++ b/src/platform/ScriptLib/PythonBoot.h @@ -87,6 +87,13 @@ bool AutoHuntIsEnabled(); bool AutoHuntAvailable(); // The skill or inventory item in the local quick slot: its UI texture name, the icon's (su, sv, eu, ev) // inside it and the item count (0 for a skill). +struct QuickSlotState { float remaining = 0; bool learned = true; bool active = false; }; +QuickSlotState LocalQuickSlotState(int localSlotIndex); +void SetInventoryTap(bool tap); +void SetInventoryDoubleTap(bool tap); +void SetInventoryDrag(bool dragging); +bool FinishInventoryDrag(int x, int y); +void SetInventoryTouchTarget(float logicalSize); bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count); // Touch gestures: a Win32 WM_LBUTTONDBLCLK (40250 CPythonApplication::OnMouseLeftButtonDoubleClick), // whether an icon hangs on the cursor (CWindowManager::IsAttaching) and mouseController.DeattachObject(). diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 6d94c23d..3dd84f59 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -196,6 +196,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) add_executable(port_text_test ${PROJECT_SOURCE_DIR}/tests/port/port_text_test.cpp) add_executable(host_ui_quality_test ${PROJECT_SOURCE_DIR}/tests/host/ui_quality_test.cpp) add_test(NAME host.ui_quality COMMAND host_ui_quality_test) + add_executable(host_touch_layout_test ${PROJECT_SOURCE_DIR}/tests/host/touch_layout_test.cpp) + add_test(NAME host.touch_layout COMMAND host_touch_layout_test) add_executable(host_autohunt_pull_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_pull_test.cpp) add_test(NAME host.autohunt_pull COMMAND host_autohunt_pull_test) add_executable(host_autohunt_navigation_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_navigation_test.cpp) @@ -301,6 +303,7 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) ${PROJECT_SOURCE_DIR}/tests/port/port_login_flow_server.cpp ${PROJECT_SOURCE_DIR}/tests/port/classic/classic_cipher.cpp) target_link_libraries(port_login_flow_test PRIVATE port_platform) + target_include_directories(port_login_flow_test PRIVATE "${PROJECT_SOURCE_DIR}/src" "${PROJECT_SOURCE_DIR}/src/platform/EterLib") add_dependencies(port_login_flow_test mtpython_stdlib) add_test(NAME port.login_flow COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) @@ -308,6 +311,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) # 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) + add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp) + target_link_libraries(biolog_proto_patch PRIVATE port_platform) # Locale packs in the original format (scripts/build_zh_locale.py packs locale/zh with it). add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp) target_link_libraries(eterpack_pack PRIVATE port_platform) diff --git a/src/port/EterLib/NetStream.cpp b/src/port/EterLib/NetStream.cpp index 42178559..87528b78 100644 --- a/src/port/EterLib/NetStream.cpp +++ b/src/port/EterLib/NetStream.cpp @@ -422,6 +422,7 @@ bool CNetworkStream::Connect(const CNetworkAddress& c_rkNetAddr, int limitSec) if (error != WSAEWOULDBLOCK) { + std::fprintf(stderr, "[network] connect socket=%d error=%d (%s)\n", m_sock, error, std::strerror(error)); Tracen("error != WSAEWOULDBLOCK"); Clear(); OnConnectFailure(); diff --git a/src/port/UserInterface/PythonPlayer.h b/src/port/UserInterface/PythonPlayer.h index 46ee704d..28e250e2 100644 --- a/src/port/UserInterface/PythonPlayer.h +++ b/src/port/UserInterface/PythonPlayer.h @@ -738,6 +738,7 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer std::vector AutoHunt_GetAvailableSkills(); void AutoHunt_SetSkills(const std::vector& indices); void AutoHunt_OnDeath(); + void AutoHunt_OnHit(DWORD victim); // PORT: actual collision hit, not attack animation int AutoHunt_GetMountState() const { return m_iAutoHuntMountState; } const SAutoHuntConfig& AutoHunt_GetConfig(); void AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig); @@ -759,7 +760,9 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer void __AutoHunt_ResetMovement(); void __AutoHunt_MigrateSkills(); bool __AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBase& target); // PORT extension - bool __AutoHunt_RequestPull(CInstanceBase& main, float now); // PORT extension + DWORD __AutoHunt_FindCloseTarget(CInstanceBase& main, int* count = nullptr); + bool __AutoHunt_CanAttackHere(CInstanceBase& main, CInstanceBase& target); + bool __AutoHunt_RequestPull(CInstanceBase& main, float now); // PORT extension DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow); DWORD __AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain); void __AutoHunt_PurgeGathered(); @@ -769,7 +772,9 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer void __AutoHunt_LoadConfig(); void __AutoHunt_SaveConfig(); - bool m_isAutoHuntLegacySkills = false; + float m_fAutoHuntCrowdUntil = 0, m_fAutoHuntItemStart = 0; + float m_fAutoHuntWaypointBest = 1e9f; + bool m_isAutoHuntLegacySkills = false; int m_iAutoHuntMountState = 0; // 0 normal, 1 wait dismount, 2 cast, 3 wait remount DWORD m_dwAutoHuntPendingSkill = 0; float m_fAutoHuntMountDeadline = 0, m_fAutoHuntMountNextTime = 0; diff --git a/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/src/port/UserInterface/PythonPlayerAutoHunt.cpp index 8718f139..32c28968 100644 --- a/src/port/UserInterface/PythonPlayerAutoHunt.cpp +++ b/src/port/UserInterface/PythonPlayerAutoHunt.cpp @@ -288,6 +288,7 @@ void CPythonPlayer::__AutoHunt_Stop(int eReason) m_iAutoHuntMountState = 0; if (auto* main=NEW_GetMainActorPtr()) main->NEW_Stop(); m_eAutoHuntState = AUTO_HUNT_OFF; + m_fAutoHuntCrowdUntil = 0; m_eAutoHuntStopReason = eReason; m_dwAutoHuntTargetVID = 0; m_dwAutoHuntItemID = 0; @@ -345,11 +346,48 @@ bool CPythonPlayer::__AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBas return m_kAutoHuntConfig.bAttackBoss || !mob || mob->bRank < c_byMobRankBoss; } +// PORT: local combat can interrupt loot/blocked pursuit without changing manual input. +void CPythonPlayer::AutoHunt_OnHit(DWORD victim) +{ + if (AutoHunt_IsEnabled() && m_eAutoHuntState == AUTO_HUNT_ENGAGE && victim == m_dwAutoHuntTargetVID) + m_fAutoHuntProgressTime = CTimer::Instance().GetCurrentSecond(); +} + +bool CPythonPlayer::__AutoHunt_CanAttackHere(CInstanceBase& main, CInstanceBase& target) +{ + if (main.IsInSafe() || !main.NEW_IsClickableDistanceDestInstance(target)) return false; + TPixelPosition goal; target.NEW_GetPixelPosition(&goal); + std::vector line; + return __AutoHunt_BuildPath(goal, 0.f, line) && line.size() == 1 && PixelDistance(line.back(), goal) < 1.f; +} + +DWORD CPythonPlayer::__AutoHunt_FindCloseTarget(CInstanceBase& main, int* count) +{ + DWORD best = 0; float distance = 1e9f; int nearby = 0; + auto& manager = CPythonCharacterManager::Instance(); + for (auto i = manager.CharacterInstanceBegin(); i != manager.CharacterInstanceEnd(); ++i) { + auto* actor = *i; + if (!actor || !__AutoHunt_IsTargetAllowed(main, *actor) || actor->IsStone() || + main.GetDistance(actor) > c_fAutoHuntSkillDistance || + !main.NEW_IsClickableDistanceDestInstance(*actor)) continue; + if (!__AutoHunt_CanAttackHere(main, *actor)) continue; + ++nearby; + if (actor->GetVirtualID() == m_dwAutoHuntTargetVID) best = actor->GetVirtualID(); + if (best == m_dwAutoHuntTargetVID && best) continue; + const float d = main.GetDistance(actor); + if (d < distance) { distance = d; best = actor->GetVirtualID(); } + } + if (count) *count = nearby; + return best; +} + // PORT: cloak collection and the server pursuit leash follow the hunting range. bool CPythonPlayer::__AutoHunt_RequestPull(CInstanceBase& main, float now) { const int maxHP = GetStatus(POINT_MAX_HP); - if (m_kAutoHuntConfig.iGatherCount <= 0 || m_isAutoHuntGathering || now < m_fAutoHuntNextPullTime || + int nearby = 0; __AutoHunt_FindCloseTarget(main, &nearby); + if (nearby >= 2 || now < m_fAutoHuntCrowdUntil || + m_kAutoHuntConfig.iGatherCount <= 0 || m_isAutoHuntGathering || now < m_fAutoHuntNextPullTime || main.IsInSafe() || maxHP <= 0 || GetStatus(POINT_HP) * 100 < maxHP * c_iAutoHuntGatherHPPercent) return false; if (m_kAutoHuntConfig.bAttackStone) { @@ -738,6 +776,28 @@ void CPythonPlayer::__Update_AutoHunt() return; if (__AutoHunt_UpdateMount(rkInstMain,fNow)) return; + // PORT: finish local combat before moving through a pack to loot or pursue. + if (m_eAutoHuntState == AUTO_HUNT_SEARCH || m_eAutoHuntState == AUTO_HUNT_RETURN || + m_eAutoHuntState == AUTO_HUNT_LOOT || m_eAutoHuntState == AUTO_HUNT_ENGAGE) { + int nearby = 0; + const DWORD close = __AutoHunt_FindCloseTarget(rkInstMain, &nearby); + const bool stalled = m_eAutoHuntState == AUTO_HUNT_ENGAGE && + fNow - m_fAutoHuntProgressTime > 1.5f; + const bool interruptLoot = m_eAutoHuntState == AUTO_HUNT_LOOT && close; + if (nearby >= 2 || (close && stalled) || interruptLoot) { + m_fAutoHuntCrowdUntil = fNow + 3.f; + if (close && (m_eAutoHuntState != AUTO_HUNT_ENGAGE || close != m_dwAutoHuntTargetVID)) { + if (m_dwAutoHuntItemID) m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID] = fNow + 2.f; + m_dwAutoHuntItemID = 0; + __ClearReservedAction(); __ClearAutoAttackTargetActorID(); + rkInstMain.NEW_Stop(); + AutoHuntTrace("local combat interrupts state %d: target %u", m_eAutoHuntState, unsigned(close)); + __AutoHunt_Engage(close, fNow); + } + } + } + + switch (m_eAutoHuntState) { case AUTO_HUNT_PAUSED: @@ -861,7 +921,7 @@ void CPythonPlayer::__Update_AutoHunt() return; } - if (m_kAutoHuntConfig.bAttackStone && !pkInstVictim->IsStone()) { + if (fNow >= m_fAutoHuntCrowdUntil && m_kAutoHuntConfig.bAttackStone && !pkInstVictim->IsStone()) { const DWORD preferred = __AutoHunt_FindTarget(rkInstMain, fNow); CInstanceBase* actor = NEW_FindActorPtr(preferred); if (actor && actor->IsStone()) { @@ -878,7 +938,7 @@ void CPythonPlayer::__Update_AutoHunt() m_fAutoHuntBestDistance = fDistance; m_fAutoHuntProgressTime = fNow; } - if (rkInstMain.IsAttacking() || rkInstMain.IsUsingSkill()) + if (rkInstMain.IsUsingSkill() || rkInstMain.IsStun()) { m_fAutoHuntProgressTime = fNow; } @@ -888,7 +948,8 @@ void CPythonPlayer::__Update_AutoHunt() if (isBlockedBySafeZone || fNow - m_fAutoHuntProgressTime > c_fAutoHuntNoProgressTime || fNow - m_fAutoHuntStateTime > c_fAutoHuntTargetTimeout) { - m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + c_fAutoHuntSkipTime; + m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + 3.f; + m_fAutoHuntCrowdUntil = fNow + 3.f; __ClearAutoAttackTargetActorID(); __ClearReservedAction(); __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop(); m_dwAutoHuntTargetVID = 0; @@ -899,7 +960,10 @@ void CPythonPlayer::__Update_AutoHunt() TPixelPosition goal; pkInstVictim->NEW_GetPixelPosition(&goal); // Use the planner even in attack range: never swing or cast through a wall. - if (!__AutoHunt_MoveTo(goal,100.f,fNow)) { + if (__AutoHunt_CanAttackHere(rkInstMain, *pkInstVictim)) { + if (!m_vecAutoHuntPath.empty()) rkInstMain.NEW_Stop(); + __AutoHunt_ResetMovement(); + } else if (!__AutoHunt_MoveTo(goal,100.f,fNow)) { m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID]=fNow+c_fAutoHuntSkipTime; __ClearAutoAttackTargetActorID(); __ClearReservedAction(); rkInstMain.NEW_Stop(); m_dwAutoHuntTargetVID=0; m_eAutoHuntState=AUTO_HUNT_SEARCH; return; @@ -936,10 +1000,12 @@ void CPythonPlayer::__Update_AutoHunt() { m_dwAutoHuntItemID = 0; // picked up (or gone) } - else if (fNow - m_fAutoHuntProgressTime > c_fAutoHuntItemTimeout) + else if (fNow - m_fAutoHuntProgressTime > c_fAutoHuntItemTimeout || + fNow - m_fAutoHuntItemStart > c_fAutoHuntLootTimeout) { m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID] = fNow + c_fAutoHuntItemSkipTime; m_dwAutoHuntItemID = 0; + __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop(); __ClearReservedAction(); } else @@ -970,6 +1036,7 @@ void CPythonPlayer::__Update_AutoHunt() // __OnPressItem without its once-per-item guard. m_dwAutoHuntItemID = dwItemID; + m_fAutoHuntItemStart = fNow; m_fAutoHuntProgressTime = fNow; __ClearReservedAction(); if (rkInstMain.NEW_IsClickableDistanceDestPixelPosition(kPPosItem)) @@ -995,6 +1062,7 @@ void CPythonPlayer::__AutoHunt_ResetMovement() m_vecAutoHuntPath.clear(); m_fAutoHuntPathTime=0; m_fAutoHuntPathMoveTime=0; + m_fAutoHuntWaypointBest=1e9f; m_fAutoHuntTargetScanTime=0; } @@ -1046,8 +1114,10 @@ bool CPythonPlayer::__AutoHunt_MoveTo(const TPixelPosition& goal, float arrival, m_kAutoHuntPathGoal=goal; m_fAutoHuntPathTime=now; m_fAutoHuntPathMoveTime=0; } - while (!m_vecAutoHuntPath.empty() && PixelDistance(here,m_vecAutoHuntPath.front())<=50.f) + while (!m_vecAutoHuntPath.empty() && PixelDistance(here,m_vecAutoHuntPath.front())<=50.f) { m_vecAutoHuntPath.erase(m_vecAutoHuntPath.begin()); + m_fAutoHuntWaypointBest=1e9f; m_fAutoHuntProgressTime=now; + } // The final planned point may be an accessible attack/pickup position beside a blocked target cell. if (m_vecAutoHuntPath.size()==1 && PixelDistance(here,m_vecAutoHuntPath.front())<=arrival) m_vecAutoHuntPath.clear(); @@ -1059,9 +1129,10 @@ bool CPythonPlayer::__AutoHunt_MoveTo(const TPixelPosition& goal, float arrival, main->NEW_MoveToDestPixelPositionDirection(m_vecAutoHuntPath.front()); } // Waypoint progress can temporarily increase distance to the enemy while going around a wall. - if (main->IsWalking() && PixelDistance(here,m_kAutoHuntPathLastPosition)>30.f) { + const float remaining=PixelDistance(here,m_vecAutoHuntPath.front()); + if (remaining < m_fAutoHuntWaypointBest-30.f) { m_fAutoHuntProgressTime=now; - m_kAutoHuntPathLastPosition=here; + m_fAutoHuntWaypointBest=remaining; } return true; } diff --git a/src/port/UserInterface/PythonPlayerEventHandler.cpp b/src/port/UserInterface/PythonPlayerEventHandler.cpp index 8de2d34b..29815d13 100644 --- a/src/port/UserInterface/PythonPlayerEventHandler.cpp +++ b/src/port/UserInterface/PythonPlayerEventHandler.cpp @@ -135,6 +135,8 @@ void CPythonPlayerEventHandler::OnChangeShape() void CPythonPlayerEventHandler::OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket) { DWORD dwVIDVictim=rkActorVictim.GetVirtualID(); + // PORT: auto-hunt progress is driven by a hit rather than animation state. + if (isSendPacket) CPythonPlayer::Instance().AutoHunt_OnHit(dwVIDVictim); // Update Target CPythonPlayer::Instance().SetTarget(dwVIDVictim, FALSE); diff --git a/tests/host/touch_layout_test.cpp b/tests/host/touch_layout_test.cpp new file mode 100644 index 00000000..ed3c6e7d --- /dev/null +++ b/tests/host/touch_layout_test.cpp @@ -0,0 +1,42 @@ +#include "../../src/host/touch_layout.h" +#include + +int main() { + int failures = 0; + auto check = [&](bool ok, const char* message) { + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); ++failures; } + }; + struct Screen { float width, height, inset, target; }; + for (const auto screen : {Screen{640, 360, 24, 48}, Screen{800, 600, 0, 48}, + Screen{1300, 600, 40, 48}, Screen{1067, 600, 32, 72}, + Screen{1366, 1024, 62, 48}}) { + const auto layout = mt_host::make_touch_layout(screen.width, screen.height, screen.inset, screen.target); + const auto& attack = layout.buttons[0]; + float previous_angle = -1000; + float first_distance = 0; + for (int i = 0; i < int(layout.buttons.size()); ++i) { + const auto& b = layout.buttons[i]; + check(b.x - b.radius >= screen.inset && b.x + b.radius <= screen.width - screen.inset && + b.y - b.radius >= 0 && b.y + b.radius <= screen.height, "visible controls stay inside the safe area"); + check(2 * b.radius >= screen.target - 0.01f, "controls retain minimum touch size"); + if (i >= 1 && i <= 6) { + check(b.y + b.radius <= screen.height - 40 * layout.scale, + "skills leave the bottom status and menu strip clear"); + const float dx = b.x - attack.x, dy = b.y - attack.y; + const float distance = std::hypot(dx, dy); + if (i == 1) first_distance = distance; + check(std::abs(distance - first_distance) < 0.01f, "all six skills occupy one ring"); + float angle = std::atan2(dy, dx); + if (angle < 0) angle += 6.2831853f; + check(angle > previous_angle, "skill order follows the arc without reversing"); + previous_angle = angle; + } + for (int j = 0; j < i; ++j) { + const auto& other = layout.buttons[j]; + check(std::hypot(b.x - other.x, b.y - other.y) > 1.25f * (b.radius + other.radius), + "expanded touch areas cannot activate a neighboring control"); + } + } + } + return failures ? 1 : 0; +} diff --git a/tests/port/autohunt_crowd_checks.inc b/tests/port/autohunt_crowd_checks.inc new file mode 100644 index 00000000..78bf0ca8 --- /dev/null +++ b/tests/port/autohunt_crowd_checks.inc @@ -0,0 +1,43 @@ +// PORT: loot must yield to local combat, including actors outside the pull reply. +if (server && CPythonPlayer::Instance().NEW_GetMainActorPtr()) { + auto& player = CPythonPlayer::Instance(); + auto* main = player.NEW_GetMainActorPtr(); + auto& manager = CPythonCharacterManager::Instance(); + const auto saved = player.AutoHunt_GetConfig(); + TPixelPosition savedMain; main->NEW_GetPixelPosition(&savedMain); + auto* existing = manager.GetInstancePtr(FakeLoginServer::kMobVID); + TPixelPosition savedMob; existing->NEW_GetPixelPosition(&savedMob); + TPixelPosition origin = savedMob; origin.x += 1000; + main->NEW_Stop(); main->NEW_SetPixelPosition(origin); + CHECK(!main->IsInSafe()); + TPixelPosition far = origin; far.x += 20000; + existing->NEW_Stop(); existing->NEW_SetPixelPosition(far); + auto config = saved; config.iGatherCount = 0; config.iPickup = CPythonPlayer::AUTO_HUNT_PICKUP_ALL; + config.bAttackStone = false; config.vecSkillIndices.clear(); + player.AutoHunt_SetEnabled(false); player.AutoHunt_SetConfig(config); + const DWORD itemVID = 0x710010, mobVID = 0x710011; + CPythonItem::Instance().CreateItem(itemVID, 1, origin.x + 500, origin.y, origin.z, false); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_LOOT; })); + CInstanceBase::SCreateData data = {}; + data.m_bType = CActorInstance::TYPE_ENEMY; data.m_dwRace = existing->GetRace(); + data.m_dwVID = mobVID; data.m_dwMovSpd = 100; data.m_dwAtkSpd = 100; + TPixelPosition here; main->NEW_GetPixelPosition(&here); + data.m_lPosX = LONG(here.x + 80); data.m_lPosY = LONG(here.y); + data.m_stName = "LootInterruptFixture"; + auto* mob = manager.CreateInstance(data); + CHECK(mob); + CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_ENGAGE && player.GetTargetVID() == mobVID; })); + player.AutoHunt_SetEnabled(false); + manager.DeleteInstance(mobVID); + // Once the threat is gone, loot is eligible again rather than permanently abandoned. + main->NEW_Stop(); main->NEW_SetPixelPosition(origin); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_LOOT; })); + player.AutoHunt_SetEnabled(false); + CPythonItem::Instance().DeleteItem(itemVID); + existing->NEW_Stop(); existing->NEW_SetPixelPosition(savedMob); + main->NEW_Stop(); main->NEW_SetPixelPosition(savedMain); + player.AutoHunt_SetConfig(saved); + pump_frames(30); +} diff --git a/tests/port/biolog_proto_patch.cpp b/tests/port/biolog_proto_patch.cpp new file mode 100644 index 00000000..a2336c80 --- /dev/null +++ b/tests/port/biolog_proto_patch.cpp @@ -0,0 +1,60 @@ +// PORT: isolated item-table extension. All non-biology records stay byte-identical. +#include "GameLib/StdAfx.h" +#include "GameLib/ItemData.h" +#include "EterBase/lzo.h" +#include +#include +#include +#include +#include +#include +#include +#include +using Item=CItemData::TItemTable; +static constexpr std::array item_key{{173217,72619434,408587239,27973291}}; +static std::vector read_file(const char* path){ + std::ifstream stream(path,std::ios::binary); + if(!stream)throw std::runtime_error("cannot read proto"); + return {std::istreambuf_iterator(stream),std::istreambuf_iterator()}; +} +static DWORD word(const std::vector& data,size_t offset){ + if(offset+4>data.size())throw std::runtime_error("truncated proto"); + DWORD value;std::memcpy(&value,data.data()+offset,4);return value; +} +static void put_word(std::vector& data,size_t offset,DWORD value){std::memcpy(data.data()+offset,&value,4);} +static void write_file(const char* path,const std::vector& data){ + std::ofstream stream(path,std::ios::binary|std::ios::trunc); + if(!stream.write(reinterpret_cast(data.data()),data.size()))throw std::runtime_error("cannot write proto"); +} +int main(int argc,char** argv) { + if(argc!=4)return 2; + try { + static CLZO lzo; auto key=item_key; + auto input=read_file(argv[1]); + if(input.size()<20 || std::memcmp(input.data(),"MIPX",4) || word(input,4)!=1 || word(input,8)!=sizeof(Item))throw std::runtime_error("item proto stride"); + CLZObject decoded; + if(!CLZO::Instance().Decompress(decoded,input.data()+20,key.data()))throw std::runtime_error("item proto decode"); + if(decoded.GetSize()!=size_t(word(input,12))*sizeof(Item))throw std::runtime_error("proto count/size mismatch"); + std::map rows; + for(DWORD i=0;i>v>>type>>sub>>size>>anti>>flags>>buy>>sell))throw std::runtime_error("biology item row"); + std::array values{};for(auto& value:values)if(!(stream>>value))throw std::runtime_error("biology item values"); + std::string name;std::getline(stream,name);if(!name.empty()&&name[0]=='\t')name.erase(0,1); + if(name.size()>24)throw std::runtime_error("biology item name too long"); + Item r{};r.dwVnum=v;r.bType=type;r.bSubType=sub;r.bSize=size;r.dwAntiFlags=anti;r.dwFlags=flags; + r.dwIBuyItemPrice=buy;r.dwISellItemPrice=sell; + for(int i=0;i<6;++i)r.alValues[i]=values[i]; + std::memcpy(r.szName,name.data(),name.size());std::memcpy(r.szLocaleName,name.data(),name.size()); + rows[v]=r;++changed; + } + std::vector plain;for(const auto& pair:rows){const BYTE* bytes=reinterpret_cast(&pair.second);plain.insert(plain.end(),bytes,bytes+sizeof(Item));} + CLZObject encoded;if(!CLZO::Instance().CompressEncryptedMemory(encoded,plain.data(),plain.size(),key.data()))throw std::runtime_error("encode"); + std::vector result(input.begin(),input.begin()+20);put_word(result,12,rows.size());put_word(result,16,encoded.GetSize()); + result.insert(result.end(),encoded.GetBuffer(),encoded.GetBuffer()+encoded.GetSize()); + CLZObject verified;if(!CLZO::Instance().Decompress(verified,result.data()+20,key.data()) || verified.GetSize()!=plain.size() || std::memcmp(verified.GetBuffer(),plain.data(),plain.size()))throw std::runtime_error("round-trip"); + write_file(argv[3],result);std::printf("biology items=%zu total=%zu; verified round-trip\n",changed,rows.size());return 0; + }catch(const std::exception& e){std::fprintf(stderr,"%s\n",e.what());return 1;} +} diff --git a/tests/port/locale_proto_patch.cpp b/tests/port/locale_proto_patch.cpp index 126e0b8e..f00096b0 100644 --- a/tests/port/locale_proto_patch.cpp +++ b/tests/port/locale_proto_patch.cpp @@ -2,7 +2,7 @@ // Runtime loading remains the original 40250 path and binary format. // An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few // item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the -// server enforces; only aLimits of the listed items may change. +// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. #include "GameLib/StdAfx.h" #include "GameLib/ItemData.h" #include "UserInterface/StdAfx.h" @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ namespace { using Item = CItemData::TItemTable; using Mob = CPythonNonPlayer::TMobTable; +std::map> stones; constexpr std::array item_key{{173217, 72619434, 408587239, 27973291}}; constexpr std::array mob_key{{4813894, 18955, 552631, 6822045}}; @@ -99,6 +101,14 @@ void apply_limits(Record&, const std::map&, std::size_t&) {} template <> void apply_limits(Item& record, const std::map& limits, std::size_t& changed) { + const auto stone = stones.find(record.dwVnum); + if (stone != stones.end()) { + const auto& s = stone->second; + record.bType = s[0]; record.bSubType = s[1]; record.dwWearFlags = s[2]; + std::memset(record.aApplies, 0, sizeof(record.aApplies)); + record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4]; + for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i]; + } const auto found = limits.find(record.dwVnum); if (found == limits.end()) return; std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits)); @@ -110,6 +120,10 @@ bool limits_field(std::size_t, DWORD, const std::map&) { return f template <> bool limits_field(std::size_t offset, DWORD vnum, const std::map& limits) { + if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) || + (offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) || + (offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) || + (offset >= offsetof(Item, alValues) && offset < offsetof(Item, alValues)+sizeof(Item::alValues)))) return true; return limits.count(vnum) && offset >= offsetof(Item, aLimits) && offset < offsetof(Item, aLimits) + sizeof(Item::aLimits); } @@ -151,7 +165,7 @@ std::vector patch(const char* source, const char* names_path, std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record)); ++translated; } - // No item stats, VNUM, canonical name, or mob gameplay fields may change (but the listed item limits). + // Only locale names, listed limits and explicit stone overlay fields may change. for (DWORD i = 0; i < count; ++i) { const std::size_t start = std::size_t(i) * sizeof(Record); const std::size_t name_start = start + offsetof(Record, szLocaleName); @@ -185,14 +199,27 @@ std::vector patch(const char* source, const char* names_path, } // namespace int main(int argc, char** argv) { - if (argc != 7 && argc != 8) { + if (argc != 7 && argc != 8 && argc != 9) { std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob" - " [item_limits]\n"; + " [item_limits [item_stones]]\n"; return 2; } try { static CLZO lzo; - const auto item = patch(argv[1], argv[3], item_key, "MIPX", true, read_limits(argc == 8 ? argv[7] : nullptr)); + if (argc == 9) { + std::ifstream stream(argv[8]); + if (!stream) throw std::runtime_error("cannot read stone overlay"); + std::string line; + while (std::getline(stream, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream row(line); DWORD vnum; std::array values{}; + if (!(row >> vnum) || vnum < 28700 || vnum > 28713) throw std::runtime_error("bad stone vnum"); + for (auto& value : values) if (!(row >> value)) throw std::runtime_error("bad stone row"); + if (values[0] != 10 || !stones.emplace(vnum, values).second) throw std::runtime_error("bad stone type/duplicate"); + } + if (stones.size() != 14) throw std::runtime_error("stone overlay must contain all 14 stones"); + } + const auto item = patch(argv[1], argv[3], item_key, "MIPX", true, read_limits(argc >= 8 ? argv[7] : nullptr)); const auto mob = patch(argv[2], argv[4], mob_key, "MMPT", false); write_file(argv[5], item); write_file(argv[6], mob); diff --git a/tests/port/port_login_flow_server.cpp b/tests/port/port_login_flow_server.cpp index 78c5ffb7..b40ae652 100644 --- a/tests/port/port_login_flow_server.cpp +++ b/tests/port/port_login_flow_server.cpp @@ -730,6 +730,20 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) bool shopping = false; std::int32_t gold = kStartGold; std::uint32_t bag0 = 0; // vnum in inventory cell 0 (the fake character starts with an empty bag) + // Mobile gesture fixture only: a weapon can be equipped/unequipped and the purchased potion moved. + const bool mobile_fixture = std::getenv("MT_TEST_MOBILE") != nullptr; + bool mobile_equipped = false; + std::uint16_t potion_cell = 0; + auto item_set = [&](std::uint16_t cell, std::uint32_t vnum) { + auto set = zeroed(); + set.header = HDR_GC_ITEM_SET; + set.cell = {1, cell}; + set.vnum = vnum; + set.count = vnum ? 1 : 0; + return c.Send(set); + }; + if (!item_set(3, 30006)) return Fail(name + ": biology material fixture"); + if (mobile_fixture && !item_set(2, 19)) return Fail(name + ": mobile weapon fixture"); int quest_page = 0; // the quest dialogue's last page sent (1 question, 2 next, 3 done) bool safebox_open = false; std::uint32_t safebox0 = kSafeboxItemVnum; @@ -788,6 +802,27 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) if (header == HDR_CG_PONG) { if (!burst_pong) { Event(name + ":burst_pong"); burst_pong = true; } continue; + } + if (header == HDR_CG_ITEM_USE) { + CGItemUse use; + std::memcpy(&use, packet, sizeof(use)); + Event(name + ":item_use " + std::to_string(use.pos.cell)); + if (mobile_fixture && use.pos.window_type == 1 && + use.pos.cell == (mobile_equipped ? 229 : 2)) { + mobile_equipped = !mobile_equipped; + if (!item_set(use.pos.cell, 0) || !item_set(mobile_equipped ? 229 : 2, 19)) + return Fail(name + ": mobile equipment reply"); + } + } + if (header == HDR_CG_ITEM_MOVE) { + CGItemMove move; + std::memcpy(&move, packet, sizeof(move)); + Event(name + ":item_move " + std::to_string(move.pos.cell) + " " + std::to_string(move.change_pos.cell)); + if (mobile_fixture && bag0 && move.pos.cell == potion_cell && move.change_pos.cell <= 1) { + potion_cell = move.change_pos.cell; + if (!item_set(move.pos.cell, 0) || !item_set(potion_cell, bag0)) + return Fail(name + ": mobile move reply"); + } } // CInputMain::ItemPickup → CHARACTER::PickupItem → CItem::RemoveFromGround → EncodeRemovePacket. if (header == HDR_CG_ITEM_PICKUP) @@ -868,6 +903,14 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) return Fail(name + ": send GC_SAFEBOX_SET"); } } + else if (text == "/bio status" || text == "/bio mission 1 0") { + const bool submitted = text == "/bio mission 1 0"; + for (const std::string& cmd : {std::string("biotime 1000"), + submitted ? std::string("biodata 1 4 1600") : std::string("biodata 1 3 0")}) { + GCChatHead chat = {HDR_GC_CHAT, static_cast(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1}; + if (!c.Send(chat) || !c.Send(cmd.c_str(), cmd.size() + 1)) return Fail(name + ": biolog reply"); + } + } else if (text == "/server_bonuses") { const std::string cmd = "ServerBonuses 200 200 200 200 200 0 200 0"; GCChatHead chat = {HDR_GC_CHAT, static_cast(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1}; @@ -922,7 +965,7 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) CGAttack attack; std::memcpy(&attack, packet, sizeof(attack)); Event(name + ":attack " + std::to_string(attack.type) + " " + std::to_string(attack.victim_vid)); - if (attack.victim_vid == kMobVID && mob_hp > 0) + if (combatFixtureDamage && attack.victim_vid == kMobVID && mob_hp > 0) { --mob_hp; GCDamageInfo damage = {HDR_GC_DAMAGE_INFO, kMobVID, 1 /* DAMAGE_NORMAL */, 100}; @@ -1026,6 +1069,7 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) else if (sub == SHOP_CG_BUY && shopping && packet[3] == 0 && bag0 == 0) { bag0 = kShopItemVnum; + potion_cell = 0; GCItemSet set = zeroed(); set.header = HDR_GC_ITEM_SET; set.cell = {1, 0}; // INVENTORY, cell 0 diff --git a/tests/port/port_login_flow_server.h b/tests/port/port_login_flow_server.h index 3461a249..a6ce921e 100644 --- a/tests/port/port_login_flow_server.h +++ b/tests/port/port_login_flow_server.h @@ -73,6 +73,9 @@ public: bool Failed() { return !Error().empty(); } bool Has(const std::string& event); + // Keep the later manual combat fixture alive during auto-hunt checks. + std::atomic combatFixtureDamage{true}; + struct Connection; private: diff --git a/tests/port/port_login_flow_test.cpp b/tests/port/port_login_flow_test.cpp index 47af07d0..e9015ece 100644 --- a/tests/port/port_login_flow_test.cpp +++ b/tests/port/port_login_flow_test.cpp @@ -37,6 +37,8 @@ // Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead. #include "ScriptLib/StdAfx.h" #include "../../src/platform/ScriptLib/PythonBoot.h" +#define MT_NATIVE_HAS_LIVE_CLIENT 1 +#include "../../src/host/touch_controller.h" #include "../../src/platform/ScriptLib/PythonHost.h" #include "../../src/platform/UserInterface/ServerClock.h" #include "../../src/platform/UserInterface/UserInterface.h" @@ -54,6 +56,7 @@ #include "GameLib/ItemManager.h" #include "UserInterface/PythonItem.h" #include "UserInterface/StdAfx.h" +#include "UserInterface/PythonNetworkStream.h" #include "UserInterface/PythonPlayer.h" #include "UserInterface/PythonExchange.h" #include "UserInterface/PythonTextTail.h" @@ -260,6 +263,8 @@ int main(int argc, char** argv) CHECK(GetPlatformServerTime() >= 123456789 && GetPlatformServerTime() < 123457789); CHECK(GetPlatformServerTimeStamp() >= 123456789 && GetPlatformServerTimeStamp() <= 123456791); PythonBoot::SetUISize(800, 600); + const bool mobile_test = std::getenv("MT_TEST_MOBILE") != nullptr; + PythonBoot::SetTouchInput(mobile_test); const bool ran = PythonBoot::RunMainScript("", &error); if (!ran) std::fprintf(stderr, "RunMainScript: %s\n", error.c_str()); @@ -376,7 +381,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) == 'Three-Way Cut', skill.GetSkillName(1, 0)\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.GetSkillType(1) == skill.SKILL_TYPE_ACTIVE, skill.GetSkillType(1)\n" "assert skill.GetSkillCoolTime(1, 1.0) == 12, skill.GetSkillCoolTime(1, 1.0)")); } @@ -415,6 +420,7 @@ int main(int argc, char** argv) // has to read all of it: an unknown or mis-sized packet makes CheckPacket log "Unknown packet // header" and drop the receive buffer. Offline the fake server ends with a ping the client // must answer; live the stream has to stay in the game window and connected for 10 s. + const bool burst = pump_until(live ? 10 : 30, [&] { return failed() || (server && server->Has("game2:burst_pong")); }); @@ -424,6 +430,121 @@ int main(int argc, char** argv) CHECK(live || (GetPlatformServerTime() < 120000 && GetPlatformServerTimeStamp() < 120000)); CHECK(py_true(gaming)); CHECK(py("import net\nassert net.IsConnect(), 'disconnected in the game phase'")); + + // Opt-in, dedicated fixture account only: real server biolog smoke, no generic gameplay mutations. + if (live && std::getenv("MT_LIVE_BIOLOG")) { + CHECK(pump_until(5, [] { return py_true("not _stream.curtain.IsShow()"); })); + CHECK(py("import mt_biolog, player\nmt_biolog.Toggle()")); + CHECK(pump_until(8, [] { return py_true("mt_biolog.g_window.hasState"); })); + CHECK(py("_bio=mt_biolog.g_window\nimport sys\nsys.stderr.write('AVA2 bio index=%d count=%d material=%d remaining=%d\\n' % (_bio.index,_bio.count,player.GetItemCountByVnum(30006),_bio.Remaining()))")); + const std::string mode=env("MT_LIVE_BIOLOG"); + if (mode=="resume") { + CHECK(py_true("_bio.index==1 and player.GetItemCountByVnum(30006)==1 and _bio.Remaining()>0")); + } else if(mode=="gift4") { + CHECK(py_true("_bio.index==4 and _bio.Phase()=='stone' and _bio.CanAct()")); + CHECK(py("_bio.Act()")); + CHECK(pump_until(5, [] { return py_true("_bio.Phase()=='gift'"); })); + CHECK(py("_bio.Gift(0)")); + CHECK(pump_until(5, [] { return py_true("_bio.index==5 and player.GetStatus(240)==4"); })); + CHECK(py("net.SendChatPacket('/bio gift 0')")); + pump_until(0.4, [] { return false; }); + CHECK(py_true("_bio.index==5 and player.GetStatus(240)==4")); + } else if(mode=="reset") { + CHECK(py_true("_bio.index==5 and player.GetStatus(240)==4")); + CHECK(py("_note_before=player.GetItemCountByVnum(70028)\nnet.SendItemUsePacket(1)")); + CHECK(pump_until(5, [] { return py_true("_bio.mode=='reset' and _bio.ResetIndex()==4"); })); + CHECK(py("assert player.GetItemCountByVnum(70028)==_note_before\n_bio.Gift(1)")); + CHECK(pump_until(5, [] { return py_true("_bio.bonuses[3]==2 and player.GetStatus(240)==0 and player.GetStatus(241)==4"); })); + CHECK(py_true("player.GetItemCountByVnum(70028)==_note_before-1")); + } else if(mode=="gift13") { + CHECK(py_true("_bio.index==13 and _bio.Phase()=='gift'")); + CHECK(py("assert player.GetItemCountByVnum(70028)==_note_before\n_bio.Gift(1)")); + CHECK(pump_until(5, [] { return py_true("_bio.index==14 and _bio.Phase()=='complete' and player.GetStatus(43)==10"); })); + } else if(mode=="complete") { + CHECK(pump_until(5, [] { return py_true("_bio.index==14 and _bio.bonuses[12]==2 and 13 in _bio.ResetEntries()"); })); + CHECK(py_true("player.GetStatus(43)==10")); + } else if(mode=="shop") { + CHECK(py("_bio.Shop()\n_bio.browse=13\n_bio.Refresh()\nassert mt_biolog.SHOP[13]['vnum']==300004\nassert _bio.CanAct()\n_bio.Act()")); + CHECK(pump_until(5, [] { return py_true("player.GetItemCountByVnum(300004)==1 and player.GetItemCountByVnum(30091)==0"); })); + } else { + CHECK(py_true("_bio.index==1 and _bio.count==0 and _bio.CanAct()")); + CHECK(py("_bio.Act()")); + CHECK(pump_until(5, [] { return py_true("player.GetItemCountByVnum(30006)==1 and _bio.Remaining()>0"); })); + CHECK(py_true("_bio.Remaining()<=601")); + if(mode=="aux") { + CHECK(py_true("_bio.count==1 and player.GetItemCountByVnum(71035)==0 and _bio.CanAct()")); + CHECK(py("_bio.Act()")); + CHECK(pump_until(5, [] { return py_true("player.GetItemCountByVnum(30006)==0 and player.GetItemCountByVnum(30250)==0"); })); + } else { + CHECK(py_true("not _bio.CanAct()")); + CHECK(py("net.SendChatPacket('/bio mission 1 1')")); + pump_until(0.5, [] { return false; }); + CHECK(py_true("player.GetItemCountByVnum(30006)==1")); + } + } + CHECK(py("import app\napp.Exit()")); + pump_until(2, [] { return !PythonBoot::IsAppLooping(); }); + PythonBoot::Stop(); + std::printf("biolog live fixture: %s\n", g_failures ? "FAILED" : "PASS"); + return g_failures ? 1 : 0; + } + + if (mobile_test) { + // Input remains blocked by the original phase curtain until its fade finishes. + CHECK(pump_until(5, [] { return py_true("not _stream.curtain.IsShow()"); })); + CHECK(py("import mt_mobile_hud, ui, player\n" + "_hud = mt_mobile_hud.g_hud\n" + "_hud.taskbar.Show()\n" + "_hud.OnUpdate()\n" + "_hud.window.interface.wndInventory.Hide()")); + CHECK(py_true("_hud.IsShow()")); + CHECK(py_true("not ui.Window.IsShow(_hud.taskbar)")); + CHECK(py_true("len(_hud.top) == 4 and len(_hud.side) == 3")); + CHECK(py_true("all(button.GetWidth() >= 48 for button in _hud.top + _hud.side)")); + CHECK(py_true("_hud.status.values[0] == player.GetStatus(player.HP)")); + CHECK(py_true("_hud.experience is _hud.taskbar.expGaugeBoard and _hud.experience.IsShow()")); + CHECK(py_true("len(_hud.taskbar.expGauge) == 4")); + CHECK(py_true("_hud.status.gauge is _hud.taskbar.GetChild('Gauge_Board')")); + CHECK(py_true("_hud.experience.GetGlobalPosition()[1] > 500")); + TouchController touch; + touch.set_enabled(true); + touch.update_screen_size(800, 600); + std::string menuXY; + CHECK(PythonBoot::Evaluate("'%d %d' % _hud.top[1].GetGlobalPosition()", &menuXY, nullptr)); + int menuX = 0, menuY = 0; + CHECK(std::sscanf(menuXY.c_str(), "'%d %d'", &menuX, &menuY) == 2 || + std::sscanf(menuXY.c_str(), "%d %d", &menuX, &menuY) == 2); + const float nx = float(menuX + 24) / 800, ny = float(menuY + 24) / 600; + CHECK(touch.on_finger_down(102, nx, ny)); + CHECK(py_true("__import__('wndMgr').IsPickedWindow(_hud.top[1].hWnd)")); + CHECK(touch.on_finger_up(102, nx, ny)); + CHECK(py_true("_hud.window.interface.wndInventory.IsShow()")); + CHECK(py("_hud.window.interface.wndInventory.Hide()\n" + "_hud.taskbar.Hide()\n" + "assert not _hud.IsShow()\n" + "_hud.taskbar.Show()\n" + "assert _hud.IsShow()")); + } + if (!live) { + CHECK(pump_until(5, [] { return py_true("not _stream.curtain.IsShow()"); })); + CHECK(py("import mt_biolog\nassert mt_biolog.g_window and mt_biolog.g_entry\n" + "mt_biolog.g_entry.OnUpdate()")); + // Exercise the actual minimap entry hit target, not a direct Toggle callback. + std::string entryXY; + CHECK(PythonBoot::Evaluate("'%d %d' % mt_biolog.g_entry.GetGlobalPosition()", &entryXY, nullptr)); + int entryX = 0, entryY = 0; + CHECK(std::sscanf(entryXY.c_str(), "'%d %d'", &entryX, &entryY) == 2 || + std::sscanf(entryXY.c_str(), "%d %d", &entryX, &entryY) == 2); + PythonBoot::UIMouseMove(entryX + 30, entryY + 24); + CHECK(py_true("__import__('wndMgr').IsPickedWindow(mt_biolog.g_entry.hWnd)")); + PythonBoot::UIMouseButton(1, true, entryX + 30, entryY + 24); + PythonBoot::UIMouseButton(1, false, entryX + 30, entryY + 24); + CHECK(pump_until(3, [] { return py_true("mt_biolog.g_window.IsShow() and mt_biolog.g_window.hasState"); })); + CHECK(py("_bio=mt_biolog.g_window\nimport player\n_old_level=player.GetStatus(player.LEVEL)\nplayer.SetStatus(player.LEVEL,105)\n_bio.options=[True,False,True]\n_bio.Refresh()\nassert _bio.count==3 and _bio.CanAct()\nassert not _bio.SetData('99 0 0'.split())\n_bio.Act()\nassert not _bio.CanAct()\n_bio.Act()")); + 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()")); + } CHECK(count_syserr("Unknown packet header") == 0); CHECK(count_syserr(kUnhandledPacket) == 0); CHECK(count_syserr("does not handle this header") == 0); @@ -745,8 +866,11 @@ int main(int argc, char** argv) std::printf("port_login_flow_test: %s\n", e.c_str()); } } - #include "autohunt_pull_checks.inc" + if (server) server->combatFixtureDamage = false; + #include "autohunt_pull_checks.inc" #include "autohunt_recovery_checks.inc" + #include "autohunt_crowd_checks.inc" + if (server) server->combatFixtureDamage = true; // 9. (批次 4-b) Combat: a left click on the stray dog reaches CPythonPlayer::__OnClickActor, which targets it // (CG_TARGET → GC_TARGET, uitarget shows the HP board) and walks into range to attack. Each attack frame's // hit (CPythonPlayerEventHandler::OnHit) sends CG_ATTACK; the fake server answers GC_DAMAGE_INFO + GC_TARGET, @@ -918,17 +1042,101 @@ int main(int argc, char** argv) return py_true(("__import__('player').GetItemIndex(0) == " + std::to_string(FakeLoginServer::kShopItemVnum) + " and __import__('player').GetElk() == " + bought_gold).c_str()); })); - pump_ui_refresh(); - click(1, bag0); - CHECK(py_true("__import__('mouseModule').mouseController.isAttached()")); - click(1, std::make_pair(shelf0.first + 32, shelf0.second)); - CHECK(pump_until(2, [&] { return server->Has("game2:shop 3 0 1"); })); + pump_ui_refresh(); + if (mobile_test) { + CHECK(py("import mt_mobile_inventory, mouseModule, player\n" + "_bag = _stream.curPhaseWindow.interface.wndInventory")); + // Existing game rules forbid taking equipment off while shopping. + PythonBoot::UIKey(DIK_ESCAPE, true); + PythonBoot::UIKey(DIK_ESCAPE, false); + CHECK(pump_until(2, [] { return py_true("not shop.IsOpen()"); })); + pump_ui_refresh(); // let the server's SHOP_END finish before reopening the bag + CHECK(py("_bag.Show()\n_bag.SetTop()")); + TouchController touch; + touch.set_enabled(true); + touch.update_screen_size(800, 600); + auto tap_at = [&](std::pair at) { + CHECK(touch.on_finger_down(301, float(at.first)/800, float(at.second)/600)); + CHECK(touch.on_finger_up(301, float(at.first)/800, float(at.second)/600)); + pump_frames(1); + }; + auto drag_to = [&](std::pair from, std::pair to) { + CHECK(touch.on_finger_down(302, float(from.first)/800, float(from.second)/600)); + CHECK(touch.on_finger_motion(302, float(to.first)/800, float(to.second)/600)); + CHECK(py_true("mouseModule.mouseController.isAttached()")); + CHECK(touch.on_finger_up(302, float(to.first)/800, float(to.second)/600)); + }; + const auto weapon = std::make_pair(bag0.first+64, bag0.second); + tap_at(weapon); + CHECK(py_true("_bag.tooltipItem.IsShow() and not mouseModule.mouseController.isAttached()")); + CHECK(!server->Has("game2:item_use 2")); + tap_at(weapon); + CHECK(pump_until(2, [&] { return server->Has("game2:item_use 2") && + py_true("player.GetItemIndex(2) == 0 and player.GetItemIndex(player.EQUIPMENT_SLOT_START+4) == 19"); })); + pump_ui_refresh(); + auto equipped = slot0("wndInventory.wndEquip"); + equipped.first += 3; equipped.second += 3; // weapon cell's original local (3,3) + tap_at(equipped); + tap_at(equipped); + CHECK(pump_until(2, [&] { return server->Has("game2:item_use 229") && + py_true("player.GetItemIndex(2) == 19 and player.GetItemIndex(player.EQUIPMENT_SLOT_START+4) == 0"); })); + CHECK(py("net.SendOnClickPacket(" + std::to_string(FakeLoginServer::kShopVID) + ")")); + CHECK(pump_until(2, [] { return py_true("shop.IsOpen()"); })); + pump_ui_refresh(); + const auto bag1 = std::make_pair(bag0.first+32, bag0.second); + drag_to(bag0, bag1); + CHECK(pump_until(2, [&] { return server->Has("game2:item_move 0 1") && + py_true("player.GetItemIndex(0) == 0 and player.GetItemIndex(1) == 27001"); })); + pump_ui_refresh(); + drag_to(bag1, bag0); + CHECK(pump_until(2, [&] { return server->Has("game2:item_move 1 0") && + py_true("player.GetItemIndex(0) == 27001 and player.GetItemIndex(1) == 0"); })); + pump_ui_refresh(); + // A page change invalidates the first tap, even when the second touch is at the same pixels. + tap_at(weapon); + const auto before_stale = server->Events().size(); + CHECK(py("_bag.SetInventoryPage(1)")); + tap_at(weapon); + CHECK(py("_bag.SetInventoryPage(0)\nassert not mouseModule.mouseController.isAttached()")); + pump_frames(2); + auto recent = server->Events(); + for (size_t i = before_stale; i < recent.size(); ++i) + CHECK(recent[i].find(":item_use ") == std::string::npos); + pump_ui_refresh(); + auto shop_blank = slot0("dlgShop"); // title/body background, not a shelf slot + // A stale drag must not sell whatever is now in that slot. + CHECK(touch.on_finger_down(303, float(bag0.first)/800, float(bag0.second)/600)); + CHECK(touch.on_finger_motion(303, float(shop_blank.first)/800, float(shop_blank.second)/600)); + CHECK(py("_bag.SetInventoryPage(1)")); + CHECK(touch.on_finger_up(303, float(shop_blank.first)/800, float(shop_blank.second)/600)); + CHECK(py("assert not mouseModule.mouseController.isAttached()\n" + "assert not __import__('constInfo').GET_ITEM_QUESTION_DIALOG_STATUS()\n" + "_bag.SetInventoryPage(0)")); + pump_ui_refresh(); + drag_to(bag0, shop_blank); + CHECK(py_true("_bag.questionDialog and _bag.questionDialog.IsShow()")); + CHECK(!server->Has("game2:shop 3 0 1")); + CHECK(py("_bag.OnCloseQuestionDialog()")); + CHECK(py_true("player.GetItemIndex(0) == 27001")); + drag_to(bag0, shop_blank); + CHECK(py("_bag.SellItem()")); + } else { + click(1, bag0); + CHECK(py_true("__import__('mouseModule').mouseController.isAttached()")); + click(1, std::make_pair(shelf0.first + 32, shelf0.second)); + } + CHECK(pump_until(2, [&] { return server->Has("game2:shop 3 0 1"); })); + const std::string sold_gold = std::to_string(FakeLoginServer::kStartGold - (int) FakeLoginServer::kShopItemPrice + FakeLoginServer::kShopSellPrice); CHECK(pump_until(2, [&] { return py_true(("__import__('player').GetItemIndex(0) == 0 and __import__('player').GetElk() == " + sold_gold).c_str()); })); - PythonBoot::UIKey(DIK_ESCAPE, true); - PythonBoot::UIKey(DIK_ESCAPE, false); + if (mobile_test) { + CHECK(py("_stream.curPhaseWindow.interface.dlgShop.Close()")); + } else { + PythonBoot::UIKey(DIK_ESCAPE, true); + PythonBoot::UIKey(DIK_ESCAPE, false); + } CHECK(pump_until(2, [&] { return server->Has("game2:shop 0"); })); CHECK(pump_until(2, [] { return py_true("not __import__('shop').IsOpen() and not _stream.curPhaseWindow.interface.dlgShop.IsShow()"); })); } @@ -1312,7 +1520,8 @@ int main(int argc, char** argv) want.push_back(e); for (const auto& e : events) if (e.rfind(prefix, 0) == 0 && e.find(":move ") == std::string::npos && - e.find(":pickup ") == std::string::npos && + e.find(":pickup ") == std::string::npos && e.find(":item_use ") == std::string::npos && + e.find(":item_move ") == std::string::npos && e.find(":exchange ") == std::string::npos && e.find(":chat ") == std::string::npos && e.find(":target ") == std::string::npos && e.find(":attack ") == std::string::npos && e.find(":click ") == std::string::npos && e.find(":shop ") == std::string::npos && @@ -1324,6 +1533,27 @@ int main(int argc, char** argv) dump(*server); } + if (mobile_test) { + TouchController touch; + touch.set_enabled(true); + touch.update_screen_size(800, 600); + CHECK(!PythonBoot::IsPointInsideActiveUI(240, 270)); + CHECK(touch.on_finger_down(101, 0.3f, 0.45f)); + touch.on_finger_motion(101, 0.325f, 0.46666667f); + touch.update_screen_size(800, 600); // renderer does this each frame + std::vector overlay; + touch.append_ui_commands(overlay); + bool kept_origin = false; + for (const auto& cmd : overlay) + if (cmd.argb == 0x52997847 && cmd.kind == UIRenderCommand::Line) + // The base circle's first segment starts at (origin.x + radius, origin.y). + kept_origin |= std::abs(cmd.x1 - (240.0f + mt_host::make_touch_layout(800, 600, 0, 48).scale * 65.0f)) < 0.1f && + std::abs(cmd.y1 - 270.0f) < 0.1f; + CHECK(kept_origin); + touch.on_finger_up(101, 0.325f, 0.46666667f); + + } + CHECK(py("import app\napp.Exit()")); pump_until(2, [] { return !PythonBoot::IsAppLooping(); }); CHECK(!PythonBoot::IsAppLooping()); diff --git a/tools/server/biolog/README.md b/tools/server/biolog/README.md new file mode 100644 index 00000000..24ca77bc --- /dev/null +++ b/tools/server/biolog/README.md @@ -0,0 +1,71 @@ +# AVA2 生物学家系统 + +本实现已替代最初的 collect_quest 远程窗口。任务状态使用 AVA2 的 `bio.level`、`bio.count`、`bio.time` 和 `bio.bonusN`;不迁移旧角色。 + +## 依据与规则 + +- 原始逻辑:`build/ava2-comparison/server/game/src/constants.cpp`、`cmd_general.cpp::do_bio`、`char.cpp::CheckBio`;原始客户端 `AVA2/1. Client DEV/pack_maker/root/uibiyolog.py`。 +- `import_ava2.py` 提取 13 阶段表和 do_bio 主体,输出 `ava2_biolog_core.h`、`catalog.json`、`reference.json`。保留算法与状态变更顺序,仅适配分词、消息本地化和当前协议中的点数编号。 +- 阶段为 30/40/50/60/70/80/85/90/92/94/98/100/105。基础成功率全部 60%;等待时间分别为 600/600/900/900/1800/1800/2700/3600/7200/7200/10800/10800/10800 秒。 +- 材料、数量、任务石、全部奖励值及是否选择奖励,均取原表。最后一份材料成功后进入任务石阶段;交石后自动发放全部固定奖励,或等待玩家选择一个奖励。没有原版额外宝箱。 +- 精华 71035 提高本次成功率 50 个百分点(60+50,必定成功);开启但未持有时沿用基础概率。冷却药剂 30250 只在仍处于冷却且选择跳过时消耗。提交失败也扣材料并开始下一次冷却,保持 AVA2 行为。 +- 默认开启精华、跳过冷却、到期提醒,按角色保存偏好。奖励在窗口直接选择;70028 从背包使用打开重选界面,重选消耗一份并只替换对应阶段的已选奖励。 + +## 实现 + +`src/platform/ScriptLib/BiologScript.inc` 通过 PythonBoot 注入 `mt_biolog`,界面适配鼠标和触摸,使用原有 `biodata`、`biostone`、`bioodul`、`bioempty` 消息。 + +服务端 `biolog_remote.h` 提供 `/bio status`、`mission <精华0/1> <跳过0/1>`、`gift <0起选项>`;增加 scoped 的 `buy <商品索引>` 和 `reset <阶段索引> <选项>` 以适配当前客户端协议。原 `/biolog status` 仅作查询别名,旧 act 不再执行。 + +原 do_bio 中伪造的空奖励选项可能让任务前进却不发奖。适配层拒绝非法选项、溢出的布尔值和额外参数;交易、仓库、商店、任务协程、死亡、传送期间不能变更研究状态。合法请求与 AVA2 对照验证。 + +### 属性实际效果 + +`patch_points.py` 增加 POINT_ATTBONUS_STONE=240、POINT_ATTBONUS_BOSS=241、POINT_RESIST_HUMAN=220;保留原 255 点数据包大小。PointChange、持久化 AFFECT_COLLECT 和实际 CalcAttBonus 均接入这些属性。 + +陨石/Boss 伤害的插入顺序和半人抗性的 PvP/NPC 算法取 AVA2;研究给予的怪物加成按 AVA2 排除 Boss。当前服务器原有非研究装备属性保持原规则。近战/魔法加成 132 已有实际效果。 + +### 商店及资源 + +`shop.json` / `ava2_biolog_shop.h` 来自 AVA2 MYSQL56 的 shop=8、shop_item 表:13 颗任务石按原金币价格出售,9 种后期材料按原指定材料数量兑换,共 22 项。当前服务器原 shop=8 为烟花店,保持它不变;新 UI 的研究商店使用独立命令,沿用 AVA2 的支付及发物顺序。 + +`make_assets.py` 从 AVA2 `home/srv1/db/item_proto.txt` 和解包客户端提取 29 个研究相关物品定义、图标和物品列表,输出当前 156 字节格式 proto 和小型 `biolog.eix/.epk`。名称/描述为中文。`tests/port/biolog_proto_patch.cpp` 对其他物品记录不做变更,并校验编码回读。 + +资源包必须作为当前 `Client/pack/Index` 的首个条目: + +``` +PACK +* +biolog +...原有条目... +``` + +Android APK 不包含完整游戏资源;必须同时更新 `biolog.eix`、`biolog.epk` 与 Index。正常使用 `android/push-client.sh --locale zh` 同步即可。商店所用兑换货币在当前服已有物品定义;没有把 AVA2 的其他地图、世界 Boss 或全服掉落系统一并替换。 + +## 构建、部署和回滚 + +1. `python3 import_ava2.py AVA2_GAME_SOURCE OUTPUT`,核对 reference.json;生成的主体不手改。 +2. 使用 `make_assets.py` 准备物品和补丁包。生成命令/输入参数见脚本。 +3. 备份当前源码,在服务器执行 `prepare_source.py GAME_SRC STAGE`、`patch_points.py GAME_SRC`,隔离输出 `gmake -j2 GAMEDIR=BUILD_DIR`。char.h 变化需要重编依赖。 +4. 在隔离目录用当前 qc 编译 `port_bio_reset.quest`。 +5. `sh deploy_ava2.sh BUILD_DIR`。先等待所有 game cores 正常保存退出,再停 db/auth;备份原 game、物品表、旧任务对象,加载新增物品后重启服务。 + +`install_ava2_data.py` 将 455 个旧 collect_quest_lv*、collect_quest_reset 事件移到备份目录,避免两个研究系统同时生效;原始任务源文件与现有角色数据库未批量修改。 + +2026-10-14 已部署到 203:独立构建 `/root/biolog-ava2-build-20261014`,回滚备份 `/root/backup-biolog-ava2/20261015-030954`(服务器时区)。源码修改前备份在构建目录的 source-backup。 + +回滚需停 game/db/auth,恢复备份 game、data/item_proto.txt、data/item_names.txt 和 data/legacy-objects.json 中的任务文件;移除 port_bio_reset 对象,再启动服务。部署前 source-backup 可恢复源码。新系统不继承 collect_quest 的 flag;不对旧角色已存在的历史属性做清除。 + +## 验证 + +- `tests/test_ava2.py AVA2_GAME_SOURCE`:6,674 组原始 do_bio 与适配入口对照,涵盖 13 阶段、概率边界、材料/石头、冷却、辅助道具、固定/选择奖励累计;验证重复领奖、非法参数、22 项商店和奖励重选。 +- `tests/test_combat.py PATCHED_BATTLE_CPP`:编译实际 CalcAttBonus,检查陨石/Boss 加成、研究怪物加成排除 Boss、半人抗性。 +- `port.login_flow`:桌面与触摸均通过,包含真实点击入口、消息处理、重复点击拦截、冷却、各阶段奖励选项显示。 +- 专用角色联机 initial/aux/gift4/gift13/shop:真实扣材料、精华必成、冷却药剂消耗、石头转选奖、点数 240 生效、最终完成、兑换扣料均通过。重登和背包使用重选道具另有 resume/reset 模式。 +- 日志、资源生成记录及截图在忽略的 `build/biolog-ava2-validation/`。离线截图使用演示数据;未自动安装到 Android 真机。 + +最终验收补充:70028 的 ITEM_QUEST 类型、堆叠与数值也按 AVA2 导入(因此总计 29 个模板)。 +第二次物品备份 `/root/backup-biolog-ava2/20261015-031709`,完成角色奖励列表补充的 game 备份 `/root/backup-biolog-ava2-game/20261015-032329`。 +常规 `scripts/build_zh_locale.py` 已自动生成并注册 biolog 补丁;Android 同步摘要纳入其配置及生成器。 +Mac 本地签名包已校验并从独立 bundle 资源完成渲染启动检查;Android 更新 ZIP 包含 APK、三份资源文件及同步脚本。 +最终联机 resume/reset/complete 均通过,覆盖从背包使用 70028、不提前扣券、只替换所选奖励、完成全部研究后重登保留选择记录。五个专用测试账号已设为 BLOCK,临时 AVA2 MyISAM 参考数据库已移除。两个 ZIP 均完成 CRC 校验,Mac bundle 签名校验通过。 diff --git a/tools/server/biolog/ava2_biolog_core.h b/tools/server/biolog/ava2_biolog_core.h new file mode 100644 index 00000000..4b3af4e3 --- /dev/null +++ b/tools/server/biolog/ava2_biolog_core.h @@ -0,0 +1,389 @@ +// Generated from AVA2 constants.cpp + cmd_general.cpp by import_ava2.py. +static const unsigned bio_data[][17] = { + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {30, 30006, 10, 600, 60, 30220, POINT_DEF_GRADE_BONUS, 30, 0, 0, 0, 0, 0, 0, 0, 71035, 30250}, + {40, 30047, 15, 600, 60, 30221, POINT_ATT_GRADE_BONUS, 40, 0, 0, 0, 0, 0, 0, 0, 71035, 30250}, + {50, 30015, 15, 900, 60, 30222, POINT_ATTBONUS_MONSTER, 5, 0, 0, 0, 0, 0, 0, 0, 71035, 30250}, + {60, 30050, 20, 900, 60, 30223, POINT_ATTBONUS_STONE, 4, POINT_ATTBONUS_BOSS, 4, 0, 0, 0, 0, 1, 71035, 30250}, + {70, 300004, 25, 1800, 60, 30224, POINT_ATTBONUS_BOSS, 6, POINT_ATTBONUS_STONE, 6, 0, 0, 0, 0, 1, 71035, 30250}, + {80, 300005, 30, 1800, 60, 30225, POINT_MAX_HP, 1000, POINT_CASTING_SPEED, 10, 0, 0, 0, 0, 1, 71035, 30250}, + {85, 300006, 40, 2700, 60, 30226, POINT_RESIST_WARRIOR, 10, POINT_RESIST_ASSASSIN, 10, POINT_RESIST_SURA, 10, POINT_RESIST_SHAMAN, 10, 0, 71035, 30250}, + {90, 300007, 50, 3600, 60, 30227, POINT_ATTBONUS_WARRIOR, 10, POINT_ATTBONUS_ASSASSIN, 10, POINT_ATTBONUS_SURA, 10, POINT_ATTBONUS_SHAMAN, 10, 0, 71035, 30250}, + {92, 300008, 10, 7200, 60, 30228, POINT_MAX_HP, 1200, POINT_DEF_GRADE_BONUS, 120, POINT_ATT_GRADE_BONUS, 50, 0, 0, 1, 71035, 30250}, + {94, 300009, 20, 7200, 60, 30229, POINT_MAX_HP, 1500, POINT_DEF_GRADE_BONUS, 140, POINT_ATT_GRADE_BONUS, 60, 0, 0, 1, 71035, 30250}, + {98, 300010, 35, 10800, 60, 30230, POINT_ATTBONUS_MONSTER, 10, POINT_RESIST_HUMAN, 10, 0, 0, 0, 0, 1, 71035, 30250}, + {100, 300011, 35, 10800, 60, 30231, POINT_ATTBONUS_STONE, 10, POINT_MELEE_MAGIC_ATT_BONUS_PER, 10, 0, 0, 0, 0, 1, 71035, 30250}, + {105, 300012, 35, 10800, 60, 30232, POINT_ATTBONUS_BOSS, 10, POINT_ATTBONUS_HUMAN, 10, 0, 0, 0, 0, 1, 71035, 30250}, +}; +static const unsigned bio_max = 14; +static ACMD(do_ava2_bio) +{ + if (ch->GetLevel() < 30) + return; + + std::vector vecArgs; + BioSplitArgument(argument, vecArgs); + if (vecArgs.size() < 2) { return; } + + int level = ch->GetQuestFlag("bio.level"); + + if (level >= bio_max) + return; + else if (level < 1) + return; + else if (ch->GetLevel() < bio_data[level][0]) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice level %d", bio_data[level][0]); + return; + } + + int count = ch->GetQuestFlag("bio.count"); + int time = ch->GetQuestFlag("bio.time"); + + if (vecArgs[1] == "mission") + { + if (vecArgs.size() < 4) { return; } + BYTE isOzut = 0; + BYTE isUnutkanlik = 0; + + str_to_number(isOzut, vecArgs[2].c_str()); + str_to_number(isUnutkanlik, vecArgs[3].c_str()); + + if (count < bio_data[level][2]) + { + if (ch->CountSpecifyItem(bio_data[level][1]) < 1) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice material %d", bio_data[level][1]); + return; + } + + //bool isUnutkanlikDrink = false; + + if (isUnutkanlik) + { + if (ch->CountSpecifyItem(bio_data[level][16]) > 0 && time > get_global_time())//fix + //if (ch->CountSpecifyItem(bio_data[level][16]) > 0) + { + ch->RemoveSpecifyItem(bio_data[level][16], 1); + //isUnutkanlikDrink = true; + } + else + { + if (time > get_global_time()) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice cooldown %d", bio_data[level][16]); + return; + } + } + } + else + { + if (time > get_global_time()) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice cooldown %d",bio_data[level][16]); + return; + } + } + + + bool isOzutDrink = false; + if (isOzut) + { + if (ch->CountSpecifyItem(bio_data[level][15]) > 0) + { + ch->RemoveSpecifyItem(bio_data[level][15], 1); + isOzutDrink = true; + } + } + + int prob = isOzutDrink ? bio_data[level][4] + 50 : bio_data[level][4]; + ch->RemoveSpecifyItem(bio_data[level][1], 1); + if (prob >= number(1, 100)) + { + count += 1; + time = get_global_time() + bio_data[level][3]; + ch->SetQuestFlag("bio.count", count); + ch->SetQuestFlag("bio.time", time); + ch->ChatPacket(CHAT_TYPE_COMMAND, "biodata %d %d %d", level, count, time); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice success %d", bio_data[level][1]); + } + else + { + time = get_global_time() + bio_data[level][3]; + ch->SetQuestFlag("bio.time", time); + ch->ChatPacket(CHAT_TYPE_COMMAND, "biodata %d %d %d", level, count, time); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice failure %d", bio_data[level][1]); + } + + if (bio_data[level][5] != 0) + { + if (count == bio_data[level][2]) + ch->ChatPacket(CHAT_TYPE_COMMAND, "biostone %d", level); + return; + } + + + } + + if (bio_data[level][5] != 0) + { + if (count == bio_data[level][2]) + { + if (ch->CountSpecifyItem(bio_data[level][5]) < 1) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice stone %d", bio_data[level][5]); + return; + } + else + { + ch->RemoveSpecifyItem(bio_data[level][5], 1); + ch->SetQuestFlag("bio.count", count+1); + + if (bio_data[level][14] == 0) + { + ch->SetQuestFlag("bio.count", 0); + ch->SetQuestFlag("bio.level", level + 1); + ch->SetQuestFlag("bio.time", 0); + + if (bio_data[level][6] != 0) + { + long value = bio_data[level][7]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][6]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][6], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][6]), bio_data[level][7]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][8] != 0) + { + long value = bio_data[level][9]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][8]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][8], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][8]), bio_data[level][9]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][10] != 0) + { + long value = bio_data[level][11]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][10]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][10], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][10]), bio_data[level][11]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][12] != 0) + { + long value = bio_data[level][13]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][12]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + + ch->AddAffect(AFFECT_COLLECT, bio_data[level][12], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][12]), bio_data[level][13]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + int newLevel = level + 1; + if (newLevel >= bio_max) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice complete"); + ch->ChatPacket(CHAT_TYPE_COMMAND, "bioempty"); + return; + } + ch->ChatPacket(CHAT_TYPE_COMMAND, "biodata %d %d %d", newLevel,0,0); + } + else + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "bioodul %d", level); + return; + } + } + } + else if (count == bio_data[level][2]+1) + return; + } + else + { + if (count == bio_data[level][2]) + { + if (bio_data[level][14] == 0) + { + ch->SetQuestFlag("bio.count", 0); + ch->SetQuestFlag("bio.level", level + 1); + ch->SetQuestFlag("bio.time", 0); + + if (bio_data[level][6] != 0) + { + long value = bio_data[level][7]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][6]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][6], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][6]), bio_data[level][7]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][8] != 0) + { + long value = bio_data[level][9]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][8]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][8], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][8]), bio_data[level][9]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][10] != 0) + { + long value = bio_data[level][11]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][10]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][10], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][10]), bio_data[level][11]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][12] != 0) + { + long value = bio_data[level][13]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][12]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][12], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][12]), bio_data[level][13]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + int newLevel = level + 1; + if (newLevel >= bio_max) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice complete"); + ch->ChatPacket(CHAT_TYPE_COMMAND, "bioempty"); + return; + } + ch->ChatPacket(CHAT_TYPE_COMMAND, "biodata %d %d %d", newLevel, 0, 0); + } + else + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "bioodul %d", level); + return; + } + } + } + } + else if (vecArgs[1] == "gift") + { + if (vecArgs.size() < 3) { return; } + BYTE index = 0; + str_to_number(index, vecArgs[2].c_str()); + + if (index > 3) + return; + + if (bio_data[level][5] != 0) + { + if (count != bio_data[level][2] + 1) + return; + } + else + { + if (count != bio_data[level][2]) + return; + } + + ch->SetQuestFlag("bio.count", 0); + ch->SetQuestFlag("bio.level", level + 1); + ch->SetQuestFlag("bio.time", 0); + + if (bio_data[level][6] != 0 && index == 0) + { + long value = bio_data[level][7]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][6]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][6], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][6]), bio_data[level][7]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][8] != 0 && index == 1) + { + long value = bio_data[level][9]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][8]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][8], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][8]), bio_data[level][9]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + if (bio_data[level][10] != 0 && index == 2) + { + long value = bio_data[level][11]; + CAffect* affect = ch->FindAffect(AFFECT_COLLECT, bio_data[level][10]); + if (affect) { + value += affect->lApplyValue; + ch->RemoveAffect(affect); + } + ch->AddAffect(AFFECT_COLLECT, bio_data[level][10], value, AFF_NONE, INFINITE_AFFECT_DURATION, 0, false); + + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice reward %d %d", BioPointIdentity(bio_data[level][10]), bio_data[level][11]); + //ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice next"); + } + + char flag[100]; + sprintf(flag, "bio.bonus%d", level); + ch->SetQuestFlag(flag, index + 1); + + int newLevel = level + 1; + if (newLevel >= bio_max) + { + ch->ChatPacket(CHAT_TYPE_COMMAND, "BioNotice complete"); + ch->ChatPacket(CHAT_TYPE_COMMAND, "bioempty"); + return; + } + + ch->ChatPacket(CHAT_TYPE_COMMAND, "biodata %d %d %d", newLevel, 0, 0); + } +} diff --git a/tools/server/biolog/ava2_biolog_shop.h b/tools/server/biolog/ava2_biolog_shop.h new file mode 100644 index 00000000..27939778 --- /dev/null +++ b/tools/server/biolog/ava2_biolog_shop.h @@ -0,0 +1,26 @@ +// AVA2 shop 8 snapshot; IDs are scoped to /bio buy, no current shop is replaced. +struct BioShopEntry {unsigned vnum,count,cost,amount,gold;}; +static const BioShopEntry kBioShop[] = { + {30220,1,0,0,5000000}, + {30221,1,0,0,7000000}, + {30222,1,0,0,10000000}, + {30223,1,0,0,13000000}, + {30224,1,0,0,20000000}, + {30225,1,0,0,25000000}, + {30226,1,0,0,50000000}, + {30227,1,0,0,100000000}, + {30228,1,0,0,200000000}, + {30229,1,0,0,200000000}, + {30230,1,0,0,500000000}, + {30231,1,0,0,500000000}, + {30232,1,0,0,500000000}, + {300004,1,30091,5,0}, + {300005,1,30197,3,0}, + {300006,1,30198,2,0}, + {300007,1,30196,2,0}, + {300008,1,70031,3,0}, + {300009,1,70031,5,0}, + {300010,1,30520,7,0}, + {300011,1,30521,7,0}, + {300012,1,30518,7,0}, +}; diff --git a/tools/server/biolog/biolog_remote.h b/tools/server/biolog/biolog_remote.h new file mode 100644 index 00000000..bce11404 --- /dev/null +++ b/tools/server/biolog/biolog_remote.h @@ -0,0 +1,110 @@ +// PORT: AVA2 bio.* system. The extracted core preserves its 13 stages and reward accumulation. +#include "questpc.h" +#include "item_manager.h" +#include "item.h" +#include +#include +static void BioSplitArgument(const char* argument, std::vector& out) { + // AVA2 split_argument includes the sub-command at index 1. + out.push_back("bio"); std::istringstream input(argument); std::string word; + while (input >> word) out.push_back(word); +} +static unsigned BioPointIdentity(unsigned point) { return point; } +#include "ava2_biolog_core.h" + +static void SendBioState(LPCHARACTER ch) { + int stage=ch->GetQuestFlag("bio.level"); + if (stage==0) {stage=1;ch->SetQuestFlag("bio.level",1);} + if (stage<1 || stage>=int(bio_max)) ch->ChatPacket(CHAT_TYPE_COMMAND,"bioempty"); + else { + int count=ch->GetQuestFlag("bio.count"); + ch->ChatPacket(CHAT_TYPE_COMMAND,"biotime %d",int(get_global_time())); + ch->ChatPacket(CHAT_TYPE_COMMAND,"biodata %d %d %d",stage,count,ch->GetQuestFlag("bio.time")); + if (count>=int(bio_data[stage][2])) { + if (count==int(bio_data[stage][2]) && bio_data[stage][5]) ch->ChatPacket(CHAT_TYPE_COMMAND,"biostone %d",stage); + else if (bio_data[stage][14]) ch->ChatPacket(CHAT_TYPE_COMMAND,"bioodul %d",stage); + } + } + // Completed characters still need their selections for the 70028 reselection UI. + std::ostringstream bonuses; + for (unsigned i=1;iGetQuestFlag(flag); + } + ch->ChatPacket(CHAT_TYPE_COMMAND,"biobonuses%s",bonuses.str().c_str()); +} + +#include "ava2_biolog_shop.h" + +static bool BioShopBuy(LPCHARACTER ch, unsigned index) { + if (index>=sizeof(kBioShop)/sizeof(kBioShop[0])) return false; + const BioShopEntry& offer=kBioShop[index]; + if ((offer.cost && ch->CountSpecifyItem(offer.cost)GetGold()ChatPacket(CHAT_TYPE_COMMAND,"BioNotice cost"); return true; + } + LPITEM item=ch->AutoGiveItem(offer.vnum,offer.count,-1,true); + if (!item) return true; + if (offer.cost) ch->RemoveSpecifyItem(offer.cost,offer.amount); + else ch->PointChange(POINT_GOLD,-int(offer.gold),false); + ITEM_MANAGER::instance().FlushDelayedSave(item); + ch->ChatPacket(CHAT_TYPE_COMMAND,"BioNotice bought"); + return true; +} + +static void BioResetBonus(LPCHARACTER ch,unsigned index,unsigned choice) { + if (index<1 || index>=bio_max || index>=unsigned(ch->GetQuestFlag("bio.level")) || + !bio_data[index][14] || choice>3 || !bio_data[index][6+choice*2]) return; + char flag[40];snprintf(flag,sizeof(flag),"bio.bonus%u",index); + int old=ch->GetQuestFlag(flag)-1; + if(old<0 || old>3 || old==int(choice)) return; + if(ch->CountSpecifyItem(70028)<1) {ch->ChatPacket(CHAT_TYPE_COMMAND,"BioNotice reset_item");return;} + unsigned oldPoint=bio_data[index][6+old*2]; + CAffect* previous=ch->FindAffect(AFFECT_COLLECT,oldPoint); + // Missing persisted rewards must not create a new bonus through a reset request. + if(!previous || previous->lApplyValuelApplyValue-bio_data[index][7+old*2]; + ch->RemoveAffect(previous); + if(remaining)ch->AddAffect(AFFECT_COLLECT,oldPoint,remaining,AFF_NONE,INFINITE_AFFECT_DURATION,0,false); + unsigned point=bio_data[index][6+choice*2]; + int value=bio_data[index][7+choice*2]; + CAffect* existing=ch->FindAffect(AFFECT_COLLECT,point); + if(existing) {value+=existing->lApplyValue;ch->RemoveAffect(existing);} + ch->RemoveSpecifyItem(70028,1); + ch->AddAffect(AFFECT_COLLECT,point,value,AFF_NONE,INFINITE_AFFECT_DURATION,0,false); + ch->SetQuestFlag(flag,choice+1); + ch->ChatPacket(CHAT_TYPE_COMMAND,"BioNotice reset"); +} + +ACMD(do_port_biolog) { + quest::PC* pc=quest::CQuestManager::instance().GetPCForce(ch->GetPlayerID()); + if (!pc || !pc->IsLoaded()) return; + std::vector args;BioSplitArgument(argument,args); + if (args.size()==2 && args[1]=="status") {SendBioState(ch);return;} + if (pc->IsRunning() || !ch->CanHandleItem() || ch->IsDead() || ch->IsStun() || ch->IsWarping() || + ch->GetExchange() || ch->GetShop() || ch->GetMyShop() || ch->IsOpenSafebox() || ch->IsCubeOpen()) { + ch->ChatPacket(CHAT_TYPE_COMMAND,"BioNotice busy"); return; + } + if(args.size()==3 && args[1]=="buy") { + unsigned index=999;std::istringstream parser(args[2]);char extra; + if((parser>>index) && !(parser>>extra)) BioShopBuy(ch,index); + SendBioState(ch);return; + } + if(args.size()==4 && args[1]=="reset") { + unsigned index=999,choice=999;char extra; + std::istringstream a(args[2]),b(args[3]); + if((a>>index) && !(a>>extra) && (b>>choice) && !(b>>extra)) BioResetBonus(ch,index,choice); + SendBioState(ch);return; + } + int stage=ch->GetQuestFlag("bio.level"); + if (stage<1 || stage>=int(bio_max)) return; + // Reject forged booleans/empty reward choices before AVA2's byte parsing can wrap them. + if (args.size()==4 && args[1]=="mission" && (args[2]=="0" || args[2]=="1") && (args[3]=="0" || args[3]=="1")) { + do_ava2_bio(ch,argument,cmd,subcmd); + } else if (args.size()==3 && args[1]=="gift" && args[2].size()==1 && args[2][0]>='0' && args[2][0]<='3') { + unsigned choice=args[2][0]-'0'; + if (!bio_data[stage][14] || !bio_data[stage][6+choice*2]) return; + do_ava2_bio(ch,argument,cmd,subcmd); + } else return; + SendBioState(ch); +} diff --git a/tools/server/biolog/catalog.json b/tools/server/biolog/catalog.json new file mode 100644 index 00000000..5c9bd314 --- /dev/null +++ b/tools/server/biolog/catalog.json @@ -0,0 +1,329 @@ +[ + { + "index": 1, + "level": 30, + "material": 30006, + "count": 10, + "cooldown": 600, + "chance": 60, + "stone": 30220, + "bonuses": [ + { + "point": "POINT_DEF_GRADE_BONUS", + "label": "防御力", + "value": 30 + } + ], + "choose": false, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 2, + "level": 40, + "material": 30047, + "count": 15, + "cooldown": 600, + "chance": 60, + "stone": 30221, + "bonuses": [ + { + "point": "POINT_ATT_GRADE_BONUS", + "label": "攻击力", + "value": 40 + } + ], + "choose": false, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 3, + "level": 50, + "material": 30015, + "count": 15, + "cooldown": 900, + "chance": 60, + "stone": 30222, + "bonuses": [ + { + "point": "POINT_ATTBONUS_MONSTER", + "label": "对怪物伤害 %", + "value": 5 + } + ], + "choose": false, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 4, + "level": 60, + "material": 30050, + "count": 20, + "cooldown": 900, + "chance": 60, + "stone": 30223, + "bonuses": [ + { + "point": "POINT_ATTBONUS_STONE", + "label": "对陨石伤害 %", + "value": 4 + }, + { + "point": "POINT_ATTBONUS_BOSS", + "label": "对 Boss 伤害 %", + "value": 4 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 5, + "level": 70, + "material": 300004, + "count": 25, + "cooldown": 1800, + "chance": 60, + "stone": 30224, + "bonuses": [ + { + "point": "POINT_ATTBONUS_BOSS", + "label": "对 Boss 伤害 %", + "value": 6 + }, + { + "point": "POINT_ATTBONUS_STONE", + "label": "对陨石伤害 %", + "value": 6 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 6, + "level": 80, + "material": 300005, + "count": 30, + "cooldown": 1800, + "chance": 60, + "stone": 30225, + "bonuses": [ + { + "point": "POINT_MAX_HP", + "label": "生命值", + "value": 1000 + }, + { + "point": "POINT_CASTING_SPEED", + "label": "施法速度", + "value": 10 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 7, + "level": 85, + "material": 300006, + "count": 40, + "cooldown": 2700, + "chance": 60, + "stone": 30226, + "bonuses": [ + { + "point": "POINT_RESIST_WARRIOR", + "label": "战士抗性 %", + "value": 10 + }, + { + "point": "POINT_RESIST_ASSASSIN", + "label": "忍者抗性 %", + "value": 10 + }, + { + "point": "POINT_RESIST_SURA", + "label": "修罗抗性 %", + "value": 10 + }, + { + "point": "POINT_RESIST_SHAMAN", + "label": "萨满抗性 %", + "value": 10 + } + ], + "choose": false, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 8, + "level": 90, + "material": 300007, + "count": 50, + "cooldown": 3600, + "chance": 60, + "stone": 30227, + "bonuses": [ + { + "point": "POINT_ATTBONUS_WARRIOR", + "label": "对战士伤害 %", + "value": 10 + }, + { + "point": "POINT_ATTBONUS_ASSASSIN", + "label": "对忍者伤害 %", + "value": 10 + }, + { + "point": "POINT_ATTBONUS_SURA", + "label": "对修罗伤害 %", + "value": 10 + }, + { + "point": "POINT_ATTBONUS_SHAMAN", + "label": "对萨满伤害 %", + "value": 10 + } + ], + "choose": false, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 9, + "level": 92, + "material": 300008, + "count": 10, + "cooldown": 7200, + "chance": 60, + "stone": 30228, + "bonuses": [ + { + "point": "POINT_MAX_HP", + "label": "生命值", + "value": 1200 + }, + { + "point": "POINT_DEF_GRADE_BONUS", + "label": "防御力", + "value": 120 + }, + { + "point": "POINT_ATT_GRADE_BONUS", + "label": "攻击力", + "value": 50 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 10, + "level": 94, + "material": 300009, + "count": 20, + "cooldown": 7200, + "chance": 60, + "stone": 30229, + "bonuses": [ + { + "point": "POINT_MAX_HP", + "label": "生命值", + "value": 1500 + }, + { + "point": "POINT_DEF_GRADE_BONUS", + "label": "防御力", + "value": 140 + }, + { + "point": "POINT_ATT_GRADE_BONUS", + "label": "攻击力", + "value": 60 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 11, + "level": 98, + "material": 300010, + "count": 35, + "cooldown": 10800, + "chance": 60, + "stone": 30230, + "bonuses": [ + { + "point": "POINT_ATTBONUS_MONSTER", + "label": "对怪物伤害 %", + "value": 10 + }, + { + "point": "POINT_RESIST_HUMAN", + "label": "半人抗性 %", + "value": 10 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 12, + "level": 100, + "material": 300011, + "count": 35, + "cooldown": 10800, + "chance": 60, + "stone": 30231, + "bonuses": [ + { + "point": "POINT_ATTBONUS_STONE", + "label": "对陨石伤害 %", + "value": 10 + }, + { + "point": "POINT_MELEE_MAGIC_ATT_BONUS_PER", + "label": "近战及魔法攻击 %", + "value": 10 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + }, + { + "index": 13, + "level": 105, + "material": 300012, + "count": 35, + "cooldown": 10800, + "chance": 60, + "stone": 30232, + "bonuses": [ + { + "point": "POINT_ATTBONUS_BOSS", + "label": "对 Boss 伤害 %", + "value": 10 + }, + { + "point": "POINT_ATTBONUS_HUMAN", + "label": "对半人伤害 %", + "value": 10 + } + ], + "choose": true, + "enhancer": 71035, + "skip": 30250 + } +] diff --git a/tools/server/biolog/deploy_ava2.sh b/tools/server/biolog/deploy_ava2.sh new file mode 100644 index 00000000..69214f46 --- /dev/null +++ b/tools/server/biolog/deploy_ava2.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Switch the running 203 runtime to AVA2 bio.* and reload the added item templates. +set -eu +SERVER=${SERVER:-/usr/metin2/server} +OUT=${1:?usage: deploy_ava2.sh BUILD_DIR} +PYTHON=${PYTHON:-/usr/local/bin/python3.9} +HERE=$(cd "$(dirname "$0")" && pwd) +BACKUP=/root/backup-biolog-ava2/$(date +%Y%m%d-%H%M%S) +PIDS=/usr/metin2/.mt40250-pids +[ -x "$OUT/game" ] && [ -s "$OUT/items/items-server.tsv" ] && [ -s "$OUT/quest-stage/object/state/port_bio_reset" ] +mkdir -p "$BACKUP" +cp -p "$SERVER/share/bin/game" "$BACKUP/game" +cp -p "$PIDS" "$BACKUP/pids" +# Wait for graceful game saves before stopping their database process. +awk '$2 != "db" && $2 != "auth" {print $1}' "$PIDS" | while read -r pid; do kill -TERM "$pid" 2>/dev/null || true; done +attempt=0 +while ps ax -o command | grep -q '^[.]/game' && [ "$attempt" -lt 180 ]; do + sleep 1;attempt=$((attempt+1)) +done +if ps ax -o command | grep -q '^[.]/game'; then echo "game processes did not exit; backup $BACKUP" >&2;exit 1;fi +sh "$HERE/../stop_server.sh" +attempt=0 +while ps ax -o command | grep -q '^[.]/\(db\|auth\)' && [ "$attempt" -lt 30 ]; do sleep 1;attempt=$((attempt+1));done +if ps ax -o command | grep -q '^[.]/\(db\|auth\)'; then echo "db/auth did not exit" >&2;exit 1;fi +"$PYTHON" "$HERE/install_ava2_data.py" "$SERVER" "$OUT/items" "$OUT/quest-stage" "$BACKUP/data" +cp -p "$OUT/game" "$SERVER/share/bin/game.biolog-new" +mv "$SERVER/share/bin/game.biolog-new" "$SERVER/share/bin/game" +sh "$HERE/../start_server.sh" +echo "AVA2 biolog installed; backup: $BACKUP" diff --git a/tools/server/biolog/deploy_game.sh b/tools/server/biolog/deploy_game.sh new file mode 100644 index 00000000..d44ba087 --- /dev/null +++ b/tools/server/biolog/deploy_game.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Game-only update when item templates/quest objects have not changed. +set -eu +NEW=${1:?usage: deploy_game.sh NEW_GAME} +SERVER=${SERVER:-/usr/metin2/server} +PIDS=/usr/metin2/.mt40250-pids +BACKUP=/root/backup-biolog-ava2-game/$(date +%Y%m%d-%H%M%S) +[ -x "$NEW" ] && [ -f "$PIDS" ] +mkdir -p "$BACKUP" +cp -p "$SERVER/share/bin/game" "$BACKUP/game" +cp -p "$PIDS" "$BACKUP/pids" +cp -p "$NEW" "$SERVER/share/bin/game.bio-next" +awk '$2!="db" && $2!="auth" {print $1}' "$PIDS" | while read -r pid; do kill -TERM "$pid" 2>/dev/null || true; done +attempt=0 +while ps ax -o command | grep -q '^[.]/game' && [ "$attempt" -lt 180 ]; do sleep 1;attempt=$((attempt+1));done +if ps ax -o command | grep -q '^[.]/game';then echo "game cores did not stop; backup $BACKUP" >&2;exit 1;fi +mv "$SERVER/share/bin/game.bio-next" "$SERVER/share/bin/game" +awk '$2=="db" || $2=="auth" {print}' "$BACKUP/pids" > "$PIDS" +awk '$2!="db" && $2!="auth" {print $2}' "$BACKUP/pids" | while read -r name;do + (cd "$SERVER/$name" && exec ./game) >> "$SERVER/$name/console.log" 2>&1 & + echo "$! $name" >> "$PIDS" +done +echo "Game update installed; backup: $BACKUP" diff --git a/tools/server/biolog/import_ava2.py b/tools/server/biolog/import_ava2.py new file mode 100644 index 00000000..ec6fa37b --- /dev/null +++ b/tools/server/biolog/import_ava2.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Extract AVA2's active biology table and command body, retaining reference provenance. +Only command dispatch, notification transport and three PORT point numbers are adapted. +""" +import argparse, hashlib, json, re +from pathlib import Path +HERE=Path(__file__).resolve().parent +NAMES={ + 'POINT_DEF_GRADE_BONUS':'防御力','POINT_ATT_GRADE_BONUS':'攻击力','POINT_ATTBONUS_MONSTER':'对怪物伤害 %', + 'POINT_ATTBONUS_STONE':'对陨石伤害 %','POINT_ATTBONUS_BOSS':'对 Boss 伤害 %','POINT_MAX_HP':'生命值', + 'POINT_CASTING_SPEED':'施法速度','POINT_RESIST_WARRIOR':'战士抗性 %','POINT_RESIST_ASSASSIN':'忍者抗性 %', + 'POINT_RESIST_SURA':'修罗抗性 %','POINT_RESIST_SHAMAN':'萨满抗性 %','POINT_ATTBONUS_WARRIOR':'对战士伤害 %', + 'POINT_ATTBONUS_ASSASSIN':'对忍者伤害 %','POINT_ATTBONUS_SURA':'对修罗伤害 %','POINT_ATTBONUS_SHAMAN':'对萨满伤害 %', + 'POINT_RESIST_HUMAN':'半人抗性 %','POINT_MELEE_MAGIC_ATT_BONUS_PER':'近战及魔法攻击 %','POINT_ATTBONUS_HUMAN':'对半人伤害 %'} + +def extract(reference): + constants=(reference/'constants.cpp').read_bytes().decode('latin1') + table=re.search(r'const DWORD bio_data\[\]\[17\] = \{(.*?)\n\};',constants,re.S).group(1) + rows=[] + for match in re.finditer(r'\{([^{}]+)\}', table): + values=[x.strip() for x in match[1].split(',')]; assert len(values)==17 + rows.append([v if v.startswith('POINT_') else int(v) for v in values]) + assert len(rows)==14 + return rows + +def generate(reference,output): + rows=extract(reference) + source=(reference/'cmd_general.cpp').read_bytes().decode('latin1').replace('\r','') + start=source.index('ACMD(do_bio)\n');end=source.index('\n#endif',start) + body=source[start:end] + # Maintain the upstream algorithm verbatim. Adapter guards live outside this function. + body=body.replace('ACMD(do_bio)', 'static ACMD(do_ava2_bio)') + body=body.replace('split_argument(argument, vecArgs);','BioSplitArgument(argument, vecArgs);') + notices={'562':'level','563':'material','564':'cooldown','565':'success','566':'failure','567':'stone','568':'reward','569':'next','570':'complete'} + for key,name in notices.items(): + body=body.replace('CHAT_TYPE_INFO, "'+key+'"','CHAT_TYPE_COMMAND, "BioNotice '+name+'"') + body=body.replace('CHAT_TYPE_INFO, "'+key+' I%d"','CHAT_TYPE_COMMAND, "BioNotice '+name+' %d"') + body=body.replace('CHAT_TYPE_INFO, "'+key+' E%d:%d"','CHAT_TYPE_COMMAND, "BioNotice '+name+' %d %d"') + body=body.replace('pointToApply(', 'BioPointIdentity(') + # The level notice in upstream forgot its printf placeholder. Keep its level argument visible. + body=body.replace('"BioNotice level"','"BioNotice level %d"') + table='static const unsigned bio_data[][17] = {\n'+''.join(' {'+', '.join(map(str,row))+'},\n' for row in rows)+'};\nstatic const unsigned bio_max = 14;\n' + generated='// Generated from AVA2 constants.cpp + cmd_general.cpp by import_ava2.py.\n'+table+body+'\n' + output.mkdir(parents=True,exist_ok=True) + (output/'ava2_biolog_core.h').write_text(generated) + catalog=[] + for i,row in enumerate(rows[1:],1): + catalog.append(dict(index=i,level=row[0],material=row[1],count=row[2],cooldown=row[3],chance=row[4],stone=row[5], + bonuses=[dict(point=row[j],label=NAMES[row[j]],value=row[j+1]) for j in (6,8,10,12) if row[j]],choose=bool(row[14]),enhancer=row[15],skip=row[16])) + (output/'catalog.json').write_text(json.dumps(catalog,ensure_ascii=False,indent=2)+'\n') + (output/'reference.json').write_text(json.dumps({name:hashlib.sha256((reference/name).read_bytes()).hexdigest() for name in ('constants.cpp','cmd_general.cpp','char.cpp')},indent=2)+'\n') + return catalog + +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('reference',type=Path);parser.add_argument('output',type=Path) + args=parser.parse_args();generate(args.reference,args.output) diff --git a/tools/server/biolog/install_ava2_data.py b/tools/server/biolog/install_ava2_data.py new file mode 100644 index 00000000..ef92e3c9 --- /dev/null +++ b/tools/server/biolog/install_ava2_data.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Activate AVA2 biology items and quarantine legacy research events (cores/db must be stopped). +Original quest sources, role flags and unrelated item rows remain untouched. +""" +import json,shutil,sys +from pathlib import Path +server,assets,quest_stage,backup=map(Path,sys.argv[1:5]) +if backup.exists():raise SystemExit('use a fresh backup directory') +backup.mkdir(parents=True) +conf=server/'share/conf' +for filename in ('item_proto.txt','item_names.txt'): + shutil.copy2(conf/filename,backup/filename) +items={int(line.split(b'\t')[0]):line for line in (assets/'items-server.tsv').read_bytes().splitlines()} +proto=conf/'item_proto.txt';output=[] +for line in proto.read_bytes().splitlines(): + key=line.split(b'\t',1)[0] + if not key.isdigit() or int(key) not in items:output.append(line) +output.extend(items[k] for k in sorted(items)) +proto.write_bytes(b'\n'.join(output)+b'\n') +names=conf/'item_names.txt';output=[] +for line in names.read_bytes().splitlines(): + key=line.split(b'\t',1)[0] + if not key.isdigit() or int(key) not in items:output.append(line) +output.extend(str(k).encode()+b'\t'+items[k].split(b'\t')[1] for k in sorted(items)) +names.write_bytes(b'\n'.join(output)+b'\n') +quest=server/'share/locale/newcibn/quest';manifest=[] +for file in sorted((quest/'object').rglob('*')): + if file.is_file() and (file.name.startswith('collect_quest_lv') or file.name.startswith('collect_quest_reset') or file.name.startswith('xxx_collect_quest_lv')): + relative=file.relative_to(quest);destination=backup/relative;destination.parent.mkdir(parents=True,exist_ok=True) + shutil.move(str(file),str(destination));manifest.append(str(relative)) +# Reset item delegates to the same new UI, which validates persisted AVA2 bonus selections. +for file in sorted((quest_stage/'object').rglob('*')): + if file.is_file(): + destination=quest/file.relative_to(quest_stage);destination.parent.mkdir(parents=True,exist_ok=True) + if destination.exists(): + prior=backup/'reset-objects'/file.relative_to(quest_stage);prior.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(destination,prior) + shutil.copy2(file,destination) +(backup/'legacy-objects.json').write_text(json.dumps(manifest,indent=2)+'\n') +print('AVA2 items installed:',len(items),'legacy quest events quarantined:',len(manifest)) diff --git a/tools/server/biolog/make_assets.py b/tools/server/biolog/make_assets.py new file mode 100644 index 00000000..38da858d --- /dev/null +++ b/tools/server/biolog/make_assets.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Stage the exact AVA2 biology items/icons for the current 156-byte proto format. +No whole AVA2 locale/protocol is imported. Outputs are a reproducible, small client patch pack. +""" +import argparse,json,shutil,subprocess +from pathlib import Path +HERE=Path(__file__).resolve().parent +NAMES={70028:'研究奖励重选券',300004:'榉木树枝',300005:'图吉木板',300006:'红鬼树枝',300007:'首领笔记',300008:'嫉妒宝石',300009:'智慧宝石',300010:'凤凰羽毛',300011:'红色单片镜',300012:'古老配方',30229:'智慧灵魂石',30230:'凤凰灵魂石',30231:'红镜灵魂石',30232:'古方灵魂石',30250:'研究冷却药剂',71035:'研究员精华'} + +def prepare(proto,ava,locale,output,tool,packer): + catalog=json.loads((HERE/'catalog.json').read_text());wanted={v for r in catalog for v in (r['material'],r['stone'],r['enhancer'],r['skip']) if v}|{70028} + rows={} + for line in proto.read_bytes().splitlines(): + cols=line.split(b'\t') + if cols[0].isdigit() and int(cols[0]) in wanted: rows[int(cols[0])]=cols + assert rows.keys()==wanted + output.mkdir(parents=True,exist_ok=True) + server=[];numeric=[] + listrows={} + for line in (ava/'locale/locale/es/item_list.txt').read_bytes().splitlines(): + c=line.split(); + if c and c[0].isdigit() and int(c[0]) in wanted:listrows[int(c[0])]=c + assert listrows.keys()==wanted + assets=output/'assets';(assets/'icon/item').mkdir(parents=True,exist_ok=True) + # Chinese names for existing IDs come from the already reviewed current locale. + names={} + for filename in ('item_names_zh.txt','item_names_zh_overlay.txt'): + path=HERE.parents[2]/'data/zh_40250'/filename + for line in path.read_text().splitlines(): + c=line.split('\t',1) + if len(c)==2 and c[0].isdigit():names[int(c[0])]=c[1] + for v,c in sorted(rows.items()): + original_name=c[1].decode('cp949','replace') + name=NAMES.get(v,names.get(v,original_name)) + c[1]=name.encode('gbk');server.append(b'\t'.join(c)) + types={b'ITEM_NONE':0,b'ITEM_MATERIAL':5,b'ITEM_QUEST':18} + flags={b'NONE':0,b'ITEM_STACKABLE':4,b'LOG':8192} + antis={b'NONE':0,b'ANTI_DROP':128,b'ANTI_SELL':256,b'ANTI_GIVE':8192,b'ANTI_MYSHOP':65536} + parse=lambda s,m:sum(m[x.strip()] for x in s.split(b'|')) + assert all(c[i] in (b'0',b'',b'LIMIT_NONE',b'APPLY_NONE',b'NONE') for i in (12,14,15,16,17,18,19,20,21,22,23)) + numeric.append('%d\t%d\t0\t%d\t%d\t%d\t%d\t%d\t%s\t%s' % (v,types[c[2]],int(c[4]),parse(c[5],antis),parse(c[6],flags),int(c[9]),int(c[10]),'\t'.join(str(int(c[i] or b'0')) for i in range(24,30)),name)) + icon=listrows[v][2].decode('ascii');shutil.copy2(ava/'icon'/icon,assets/icon) + (output/'items-server.tsv').write_bytes(b'\n'.join(server)+b'\n') + (output/'items-client.tsv').write_bytes(('\n'.join(numeric)+'\n').encode('gbk')) + target=assets/'locale/zh';target.mkdir(parents=True,exist_ok=True) + subprocess.run([str(tool),str(locale/'item_proto'),str(output/'items-client.tsv'),str(target/'item_proto')],check=True) + # Replace only the matching item_list/description rows; all other current entries stay byte-identical. + for filename in ('item_list.txt','itemdesc.txt'): + lines=(locale/filename).read_bytes().splitlines();result=[] + for line in lines: + key=line.split(b'\t',1)[0] + if not key.isdigit() or int(key) not in wanted:result.append(line) + for v in sorted(wanted): + if filename=='item_list.txt':result.append(b'\t'.join(listrows[v])) + else: + name=NAMES.get(v,names.get(v,'研究材料')).encode('gbk') + description=('重新选择一次已经领取的生物学家奖励。' if v==70028 else '提高本次研究成功率 50 个百分点。' if v==71035 else '跳过本次提交的等待时间;仅在冷却期间消耗。' if v==30250 else '用于生物学家研究。').encode('gbk') + result.append(str(v).encode()+b'\t'+name+b'\t'+description) + (target/filename).write_bytes(b'\n'.join(result)+b'\n') + subprocess.run([str(packer),str(output/'biolog'),str(assets), ''],check=True) + (output/'items.json').write_text(json.dumps({v:NAMES.get(v,names.get(v,'研究材料')) for v in sorted(wanted)},ensure_ascii=False,indent=2)+'\n') + +if __name__=='__main__': + p=argparse.ArgumentParser();p.add_argument('proto',type=Path);p.add_argument('ava',type=Path);p.add_argument('locale',type=Path);p.add_argument('output',type=Path);p.add_argument('tool',type=Path);p.add_argument('packer',type=Path) + a=p.parse_args();prepare(a.proto,a.ava,a.locale,a.output,a.tool.resolve(),a.packer.resolve()) diff --git a/tools/server/biolog/patch_points.py b/tools/server/biolog/patch_points.py new file mode 100644 index 00000000..95c1ea9a --- /dev/null +++ b/tools/server/biolog/patch_points.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""PORT missing AVA2 biology stats into unused points (no packet-size change). +Existing non-biology monster bonuses keep the current server's combat rule. +""" +from pathlib import Path +import sys +source=Path(sys.argv[1]) + +def edit(name, marker, replacement, tag): + p=source/name;s=p.read_bytes() + if tag.encode() not in s: + if s.count(marker.encode())!=1:raise ValueError('source mismatch: '+name) + p.write_bytes(s.replace(marker.encode(),replacement.encode())) +edit('char.h','\tPOINT_MELEE_MAGIC_ATT_BONUS_PER = 132,', + '\tPOINT_MELEE_MAGIC_ATT_BONUS_PER = 132,\n\t// PORT AVA2 biology points, within the existing 255-point packet.\n\tPOINT_ATTBONUS_STONE = 240,\n\tPOINT_ATTBONUS_BOSS = 241,\n\tPOINT_RESIST_HUMAN = 220,','PORT AVA2 biology points') +edit('char.cpp','\t\tcase POINT_ATTBONUS_MONSTER:', + '\t\t// PORT AVA2 biology points\n\t\tcase POINT_ATTBONUS_STONE:\n\t\tcase POINT_ATTBONUS_BOSS:\n\t\tcase POINT_RESIST_HUMAN:\n\t\tcase POINT_ATTBONUS_MONSTER:', 'PORT AVA2 biology points') +p=source/'battle.cpp';s=p.read_bytes() +if b'PORT AVA2 biology combat' not in s: + start=s.index(b'int CalcAttBonus(');end=s.index(b'\nint ',start+1) + body=s[start:end] + body=body.replace(b'\tif (pkVictim->IsNPC())',b''' // PORT AVA2 biology combat: stone bonus precedes race modifiers, as upstream. + if (pkVictim->IsStone()) + iAtk += (iAtk * pkAttacker->GetPoint(POINT_ATTBONUS_STONE)) / 100; +\tif (pkVictim->IsNPC())''',1) + marker=b'\t\tiAtk += (iAtk * pkAttacker->GetPoint(POINT_ATTBONUS_MONSTER)) / 100;' + assert body.count(marker)==1 + body=body.replace(marker,b''' if (pkVictim->GetMobRank() >= MOB_RANK_BOSS && !pkVictim->IsStone()) + iAtk += (iAtk * pkAttacker->GetPoint(POINT_ATTBONUS_BOSS)) / 100; + int monsterBonus = pkAttacker->GetPoint(POINT_ATTBONUS_MONSTER); + if (pkVictim->GetMobRank() >= MOB_RANK_BOSS) { + CAffect* research = pkAttacker->FindAffect(AFFECT_COLLECT, POINT_ATTBONUS_MONSTER); + if (research) monsterBonus -= research->lApplyValue; + } + iAtk += (iAtk * monsterBonus) / 100;''') + body=body.replace(b'\treturn iAtk;',b''' if (pkAttacker->IsNPC() && pkVictim->IsPC() && pkAttacker->IsRaceFlag(RACE_FLAG_HUMAN)) + iAtk -= (iAtk * 30 * pkVictim->GetPoint(POINT_RESIST_HUMAN)) / 10000; + if (pkAttacker->IsPC() && pkVictim->IsPC()) + iAtk -= (iAtk * pkVictim->GetPoint(POINT_RESIST_HUMAN)) / 100; +\treturn iAtk;''',1) + s=s[:start]+body+s[end:] + if b'#include "affect.h"' not in s:s=s.replace(b'#include "char.h"',b'#include "char.h"\n#include "affect.h"') + p.write_bytes(s) diff --git a/tools/server/biolog/port_bio_reset.quest b/tools/server/biolog/port_bio_reset.quest new file mode 100644 index 00000000..1f703bf5 --- /dev/null +++ b/tools/server/biolog/port_bio_reset.quest @@ -0,0 +1,7 @@ +quest port_bio_reset begin + state start begin + when 70028.use begin + cmdchat("bioreselect") + end + end +end diff --git a/tools/server/biolog/prepare_source.py b/tools/server/biolog/prepare_source.py new file mode 100644 index 00000000..3a8dd1e8 --- /dev/null +++ b/tools/server/biolog/prepare_source.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Install the two small command hooks in a server source tree, keeping byte encoding intact.""" +from pathlib import Path +import shutil +import sys + +source, stage = map(Path, sys.argv[1:3]) +here = Path(__file__).resolve().parent +for filename, marker, insertion in ( + ('cmd_general.cpp', b'#include "autohunt_pull.h"', b'\n#include "biolog_remote.h"'), + ('cmd.cpp', b'ACMD(do_server_bonuses);', b'\nACMD(do_port_biolog);'), +): + path = source / filename + data = path.read_bytes() + if insertion.strip() not in data: + if data.count(marker) != 1: + raise RuntimeError('source hook mismatch: ' + str(path)) + data = data.replace(marker, marker + insertion) + if filename == 'cmd.cpp' and b'{ "biolog",' not in data: + marker = b'\t{ "server_bonuses",' + if data.count(marker) != 1: + raise RuntimeError('command registry mismatch') + data = data.replace(marker, b'\t{ "biolog", do_port_biolog, 0, POS_DEAD, GM_PLAYER },\n' + marker) + if filename == 'cmd.cpp' and b'{ "bio",' not in data: + marker = b'\t{ "biolog",' + data = data.replace(marker, b'\t{ "bio", do_port_biolog, 0, POS_DEAD, GM_PLAYER },\n' + marker) + path.write_bytes(data) +for name, root in [('biolog_remote.h', here), ('ava2_biolog_core.h', here), ('ava2_biolog_shop.h', here)]: + shutil.copy2(root / name, source / name) diff --git a/tools/server/biolog/reference.json b/tools/server/biolog/reference.json new file mode 100644 index 00000000..12b5f11e --- /dev/null +++ b/tools/server/biolog/reference.json @@ -0,0 +1,5 @@ +{ + "constants.cpp": "da80974ca68d9d6deead78c907ca3bf9fabad89a0a5da1d23126740bf13f14b2", + "cmd_general.cpp": "b4fc1cfe43af6695e86777ac96a199ce128d27130e8172dd1c2110efec7d48de", + "char.cpp": "e9af1478df9939a4a367f3a2bfeaf2096203962b1df7d1e4c69153e509550a5c" +} diff --git a/tools/server/biolog/shop.json b/tools/server/biolog/shop.json new file mode 100644 index 00000000..1ccf98a3 --- /dev/null +++ b/tools/server/biolog/shop.json @@ -0,0 +1,156 @@ +[ + { + "vnum": 30220, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 5000000 + }, + { + "vnum": 30221, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 7000000 + }, + { + "vnum": 30222, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 10000000 + }, + { + "vnum": 30223, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 13000000 + }, + { + "vnum": 30224, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 20000000 + }, + { + "vnum": 30225, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 25000000 + }, + { + "vnum": 30226, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 50000000 + }, + { + "vnum": 30227, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 100000000 + }, + { + "vnum": 30228, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 200000000 + }, + { + "vnum": 30229, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 200000000 + }, + { + "vnum": 30230, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 500000000 + }, + { + "vnum": 30231, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 500000000 + }, + { + "vnum": 30232, + "count": 1, + "cost": 0, + "amount": 0, + "gold": 500000000 + }, + { + "vnum": 300004, + "count": 1, + "cost": 30091, + "amount": 5, + "gold": 0 + }, + { + "vnum": 300005, + "count": 1, + "cost": 30197, + "amount": 3, + "gold": 0 + }, + { + "vnum": 300006, + "count": 1, + "cost": 30198, + "amount": 2, + "gold": 0 + }, + { + "vnum": 300007, + "count": 1, + "cost": 30196, + "amount": 2, + "gold": 0 + }, + { + "vnum": 300008, + "count": 1, + "cost": 70031, + "amount": 3, + "gold": 0 + }, + { + "vnum": 300009, + "count": 1, + "cost": 70031, + "amount": 5, + "gold": 0 + }, + { + "vnum": 300010, + "count": 1, + "cost": 30520, + "amount": 7, + "gold": 0 + }, + { + "vnum": 300011, + "count": 1, + "cost": 30521, + "amount": 7, + "gold": 0 + }, + { + "vnum": 300012, + "count": 1, + "cost": 30518, + "amount": 7, + "gold": 0 + } +] diff --git a/tools/server/biolog/tests/ava2_core_test.cpp b/tools/server/biolog/tests/ava2_core_test.cpp new file mode 100644 index 00000000..314021a5 --- /dev/null +++ b/tools/server/biolog/tests/ava2_core_test.cpp @@ -0,0 +1,103 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "questpc.h" +#include "item.h" +// PORT point numbers are intentionally not AVA2 protocol numbers; effects are compared by named point. +POINT_ENUMS +typedef unsigned char BYTE; +templatevoid str_to_number(T& result,const char* value){result=std::atoi(value);} +static int roll=1; +static int get_global_time(){return 1000;} +static int number(int,int){return roll;} +enum { CHAT_TYPE_COMMAND=5,CHAT_TYPE_INFO=1,AFFECT_COLLECT=515,AFF_NONE=0,INFINITE_AFFECT_DURATION=1892160000 }; +struct CAffect {unsigned point;int lApplyValue;}; +struct Character { + int level=105,gold=1000000000; + bool blocked=false; + std::map flags; + std::map items; + std::map effects; + std::vector replies; + unsigned GetPlayerID(){return 123;} + int GetLevel(){return level;} + int GetGold(){return gold;} + int GetQuestFlag(const std::string& s){auto i=flags.find(s);return i==flags.end()?0:i->second;} + void SetQuestFlag(const std::string& s,int n){if(n)flags[s]=n;else flags.erase(s);} + int CountSpecifyItem(unsigned id){auto i=items.find(id);return i==items.end()?0:i->second;} + void RemoveSpecifyItem(unsigned id,int n){assert(CountSpecifyItem(id)>=n);items[id]-=n;} + CAffect* FindAffect(int,unsigned point){auto i=effects.find(point);return i==effects.end()?nullptr:&i->second;} + void RemoveAffect(CAffect* a){effects.erase(a->point);} + void AddAffect(int,unsigned point,int n,int,int,int,bool){effects[point]={point,n};} + void PointChange(int,int n,bool){gold+=n;} + LPITEM AutoGiveItem(unsigned id,unsigned count,int,bool){items[id]+=count;static FakeItem item;return &item;} + bool CanHandleItem(){return !blocked;} + bool IsDead(){return false;}bool IsStun(){return false;}bool IsWarping(){return false;} + bool GetExchange(){return false;}bool GetShop(){return false;}bool GetMyShop(){return false;} + bool IsOpenSafebox(){return false;}bool IsCubeOpen(){return false;} + void ChatPacket(int,const char* format,...){char b[1024];va_list ap;va_start(ap,format);vsnprintf(b,sizeof(b),format,ap);va_end(ap);replies.push_back(b);} + std::string Snapshot()const{ + std::string s=std::to_string(gold); + for(const auto& p:flags)s+="|"+p.first+":"+std::to_string(p.second); + for(const auto& p:items)s+="|item"+std::to_string(p.first)+":"+std::to_string(p.second); + for(const auto& p:effects)s+="|point"+std::to_string(p.first)+":"+std::to_string(p.second.lApplyValue); + return s; + } +}; +typedef Character* LPCHARACTER; +#define ACMD(name) void name(Character* ch,const char* argument,int cmd,int subcmd) +#include "../biolog_remote.h" +#define split_argument BioSplitArgument +static unsigned pointToApply(unsigned value){return value;} +REFERENCE_COMMAND +int main(){ + int cases=0; + for(unsigned stage=1;stagevoid FlushDelayedSave(T*){} }; diff --git a/tools/server/biolog/tests/questpc.h b/tools/server/biolog/tests/questpc.h new file mode 100644 index 00000000..e77c5040 --- /dev/null +++ b/tools/server/biolog/tests/questpc.h @@ -0,0 +1,23 @@ +#pragma once +// Small test double for the PORT command's interaction with the existing quest engine. +#include +#include +namespace quest { +struct PC { + std::map flags; + bool running = false, loaded = true; + int GetFlag(const std::string& key) { return flags[key]; } + void SetFlag(const std::string& key, int value, bool = false) { flags[key] = value; } + bool IsRunning() const { return running; } + bool IsLoaded() const { return loaded; } +}; +struct CQuestManager { + PC pc; + int dispatched = 0; + bool available = true; + static CQuestManager& instance() { static CQuestManager manager; return manager; } + PC* GetPCForce(unsigned) { return &pc; } + unsigned GetQuestIndexByName(const std::string&) { return available ? 1 : 0; } + void QuestButton(unsigned, unsigned) { ++dispatched; pc.running = true; } +}; +} diff --git a/tools/server/biolog/tests/test_ava2.py b/tools/server/biolog/tests/test_ava2.py new file mode 100644 index 00000000..bfc81fd8 --- /dev/null +++ b/tools/server/biolog/tests/test_ava2.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import importlib.util,json,re,subprocess,sys,tempfile +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +reference=Path(sys.argv[1]);template=ROOT/'tests/ava2_core_test.cpp' +# Compare the generated 13-stage table with both upstream server and Python UI tables. +spec=importlib.util.spec_from_file_location('import_ava2',ROOT/'import_ava2.py');module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +rows=module.extract(reference);catalog=json.loads((ROOT/'catalog.json').read_text()) +assert len(rows)==14 and len(catalog)==13 +for row,c in zip(rows[1:],catalog): + assert row[:6]==[c['level'],c['material'],c['count'],c['cooldown'],c['chance'],c['stone']] +ui=ROOT.parents[2]/'src/platform/ScriptLib/BiologScript.inc';source=ui.read_text();data=re.search(r'^CATALOG = (.*)$',source,re.M).group(1) +import ast +assert ast.literal_eval(data)=={c['index']:c for c in catalog} +points=sorted({v for row in rows for v in row if isinstance(v,str)}) +raw=(reference/'cmd_general.cpp').read_bytes().decode('latin1').replace('\r','');start=raw.index('ACMD(do_bio)\n');end=raw.index('\n#endif',start) +original=raw[start:end].replace('ACMD(do_bio)','static ACMD(reference_do_bio)') +code=template.read_text().replace('POINT_ENUMS','enum {'+', '.join([points[0]+'=1']+points[1:]+['POINT_GOLD'])+'};').replace('REFERENCE_COMMAND',original) +with tempfile.TemporaryDirectory(prefix='biolog-ava2-') as folder: + folder=Path(folder);cpp=folder/'test.cpp';cpp.write_text(code) + # Keep relative production include paths resolvable when compiling the generated source. + cpp.write_text(cpp.read_text().replace('#include "../biolog_remote.h"','#include "'+str(ROOT/'biolog_remote.h')+'"')) + subprocess.run(['c++','-std=c++17','-I'+str(ROOT/'tests'),str(cpp),'-o',str(folder/'test')],check=True) + subprocess.run([str(folder/'test')],check=True) +print('PASS: 13-stage catalog and embedded client table match extracted AVA2 server constants') diff --git a/tools/server/biolog/tests/test_combat.py b/tools/server/biolog/tests/test_combat.py new file mode 100644 index 00000000..1e5ae1a9 --- /dev/null +++ b/tools/server/biolog/tests/test_combat.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compile the actual patched CalcAttBonus and assert the biology bonuses in real arithmetic.""" +from pathlib import Path +import re,subprocess,sys,tempfile +source=Path(sys.argv[1]).read_bytes().decode('latin1');start=source.index('int CalcAttBonus(');end=source.index('\nvoid Item_GetDamage',start);body=source[start:end] +assert 'PORT AVA2 biology combat' in body +names=sorted(set(re.findall(r'\b(?:POINT_|RACE_FLAG_|JOB_|UNIQUE_ITEM_)[A-Z_0-9]+',body))) +code=r''' +#include +#include +#include +#include +NAMES +enum {MOB_RANK_BOSS=4,AFFECT_COLLECT=515}; +struct CAffect {int lApplyValue;}; +struct Character { + bool pc=true,stone=false;int rank=1,job=-1; + std::map points;std::set races;CAffect research{0}; + bool IsPC(){return pc;}bool IsNPC(){return !pc;}bool IsStone(){return stone;} + int GetPoint(int n){return points[n];}int GetJob(){return job;}int GetMobRank(){return rank;} + int GetMarriageBonus(int){return 0;}bool IsRaceFlag(int n){return races.count(n);} + CAffect* FindAffect(int,int){return research.lApplyValue?&research:nullptr;} +}; +struct CHARACTER_MANAGER { + static CHARACTER_MANAGER& instance(){static CHARACTER_MANAGER m;return m;} + int GetMobDamageRate(Character*){return 100;} +}; +typedef Character* LPCHARACTER; +BODY +int main(){ + Character attacker,victim;victim.pc=false;victim.stone=true; + attacker.points[POINT_ATTBONUS_STONE]=10; + assert(CalcAttBonus(&attacker,&victim,1000)==1100); + victim.stone=false;victim.rank=MOB_RANK_BOSS;attacker.points.clear();attacker.points[POINT_ATTBONUS_BOSS]=6; + assert(CalcAttBonus(&attacker,&victim,1000)==1060); + // AVA2's research monster bonus excludes bosses; unrelated existing equipment still applies. + attacker.points.clear();attacker.points[POINT_ATTBONUS_MONSTER]=15;attacker.research.lApplyValue=5; + assert(CalcAttBonus(&attacker,&victim,1000)==1100); + victim.rank=1;assert(CalcAttBonus(&attacker,&victim,1000)==1150); + attacker.points.clear();attacker.research.lApplyValue=0;victim.pc=true;victim.points[POINT_RESIST_HUMAN]=10; + assert(CalcAttBonus(&attacker,&victim,1000)==900); + attacker.pc=false;attacker.races.insert(RACE_FLAG_HUMAN); + assert(CalcAttBonus(&attacker,&victim,1000)==970); + attacker.races.clear();assert(CalcAttBonus(&attacker,&victim,1000)==1000); + std::puts("PASS: patched combat applies stone/boss bonuses, excludes research monster bonus on bosses, and applies PvP/NPC human resistance"); +} +''' +code=code.replace('NAMES','enum {'+', '.join([names[0]+'=1']+names[1:])+'};').replace('BODY',body) +with tempfile.TemporaryDirectory(prefix='bio-combat-') as folder: + folder=Path(folder);(folder/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(folder/'test.cpp'),'-o',str(folder/'test')],check=True) + subprocess.run([str(folder/'test')],check=True) diff --git a/tools/server/npc-shops/README.md b/tools/server/npc-shops/README.md index e3be2fa1..b571ab99 100644 --- a/tools/server/npc-shops/README.md +++ b/tools/server/npc-shops/README.md @@ -13,7 +13,7 @@ | 目录 | 商店 | NPC | 位置 | |---|---|---|---| -| `item-mall/` | 14 / 16 `item_mall`(两页:增益、道具) | 9010 百店老板娘 | 杂货店 9003 旁 | +| `item-mall/` | 14 / 16 `item_mall`(三页:增益、道具、宝石) | 9010 百店老板娘 | 杂货店 9003 旁 | | `mount/` | 15 `mount` | 20368 坐骑商人(原「铁算盘」,未使用的货郎模型) | 马夫 20349 旁 | `install.sh` 对一个商店做这些事: @@ -42,6 +42,8 @@ | 双倍伤害丸、无视防御丸 | 各 15 万 | | 经验药水、夺宝手套 | 各 20 万 | | 物品属性转换秘籍 | 50 万 | +| 超级属性转换秘籍、超级属性追加秘籍 | 各 1 | +| +6 镶嵌石(28700~28713,全部 14 种,宝石页) | 各 1 | | 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 | | 追加物品属性秘籍、龙神的祝福书 | 各 100 万 | @@ -89,3 +91,7 @@ mysql -uroot player < $B/player-shop.sql - 商城:`/root/backup-itemmall/20260929-162714`。这份还没备份 mob_proto;9010 原来的 on_click 是 2。 - 坐骑:`/root/backup-npcshop/mount-20260929-172630`。更早的 mount 备份里还没有 mob_names.txt;20368 原名是 Peddler。 + +## +6 石头与红国位置修复(2026-10-02) + +`item-mall/stones.tsv` 来自 server_extracted 的 28700~28713 实际数值。203 原来的同编号为空类型,安装时以对应普通石头行恢复标志与镶嵌族,再写入 +6 数值;客户端同步表为 `data/zh_40250/item_stones_server.tsv`。红国老板娘改为本地坐标 678,575(杂货店前方),重复安装会更新已有 NPC 的坐标。 diff --git a/tools/server/npc-shops/install.sh b/tools/server/npc-shops/install.sh index 14928e49..5e5d277c 100755 --- a/tools/server/npc-shops/install.sh +++ b/tools/server/npc-shops/install.sh @@ -35,6 +35,33 @@ echo "$VILLAGES" | while read -r map x y dir; do cp -p "$MAPS/$map/npc.txt" "$BA mysqldump -uroot player shop shop_item item_proto mob_proto > "$BACKUP/player-shop.sql" echo "backup: $BACKUP" +# Optional stone restoration: reuse the corresponding valid +3 row for flags and +# socket family, then apply the extracted +6 values. Preserve its existing name. +if [ -f "$SHOP_DIR/stones.tsv" ]; then + awk -F'\t' -v OFS='\t' ' + NR==FNR {if ($1 !~ /^#/ && NF==12) {wanted[$1]=$6; count++} next} + {line[NR]=$0; order[++n]=$1; original[$1]=$0} + END { + for (i=1;i<=n;i++) { + v=order[i] + if (v in wanted) { + split(original[v], old, "\t") + base=28330+v-28700 + if (!(base in original)) exit 1 + split(original[base], a, "\t") + a[1]=v; a[2]=old[2]; a[10]=1; a[11]=0; a[20]=wanted[v] + for (j=1;j<=33;j++) printf "%s%s",a[j],(j==33 ? "\n" : OFS) + found++ + } else print original[v] + } + if (found!=count || count!=14) exit 1 + }' "$SHOP_DIR/stones.tsv" "$PROTO" > "$BACKUP/stones.new" + cat "$BACKUP/stones.new" > "$PROTO" + awk -F'\t' '$1 !~ /^#/ && NF==12 { + printf "UPDATE item_proto SET type=%d,subtype=%d,wearflag=%d,applytype0=%d,applyvalue0=%d,applytype1=0,applyvalue1=0,applytype2=0,applyvalue2=0,value0=%d,value1=%d,value2=%d,value3=%d,value4=%d,value5=%d,shop_buy_price=0 WHERE vnum=%d;\n",$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$1 + }' "$SHOP_DIR/stones.tsv" | mysql -uroot player +fi + # item_proto.txt column 10 is the price an NPC shop sells the item for (TItemTable::dwGold). awk -F'\t' -v OFS='\t' 'NR == FNR { if ($1 !~ /^#/ && NF >= 3) { price[$1] = $3; want++ } next } ($1 in price) { $10 = price[$1]; n++ } { print } @@ -105,7 +132,9 @@ fi echo "$VILLAGES" | while read -r map x y dir; do npc="$MAPS/$map/npc.txt" if awk -F'\t' -v v=$NPC_VNUM '$11 == v { found = 1 } END { exit !found }' "$npc"; then - echo "$map: NPC $NPC_VNUM already placed" + awk -F'\t' -v OFS='\t' -v v=$NPC_VNUM -v x="$x" -v y="$y" -v dir="$dir" '$11==v {$2=x; $3=y; $7=dir} {print}' "$npc" > "$BACKUP/$map.npc.new" + cat "$BACKUP/$map.npc.new" > "$npc" + echo "$map: NPC $NPC_VNUM positioned at $x,$y" else [ -z "$(tail -c 1 "$npc")" ] || echo >> "$npc" printf 'm\t%d\t%d\t0\t0\t0\t%d\t1m\t100\t1\t%d\n' "$x" "$y" "$dir" "$NPC_VNUM" >> "$npc" diff --git a/tools/server/npc-shops/item-mall/items.tsv b/tools/server/npc-shops/item-mall/items.tsv index 688d8188..a6e9d3e1 100644 --- a/tools/server/npc-shops/item-mall/items.tsv +++ b/tools/server/npc-shops/item-mall/items.tsv @@ -43,3 +43,20 @@ 71032 1 1000000 龙神的祝福书 70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp) 50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set) +71052 1 1 超级属性转换秘籍 +71051 1 1 超级属性追加秘籍 +[宝石] +28700 1 1 鸡血石+6 +28701 1 1 羊脂玉+6 +28702 1 1 羚角石+6 +28703 1 1 虹河石+6 +28704 1 1 青胆石+6 +28705 1 1 精石+6 +28706 1 1 鹰翼石+6 +28707 1 1 木鱼石+6 +28708 1 1 龙骨石+6 +28709 1 1 青石+6 +28710 1 1 彩芒石+6 +28711 1 1 鹿影石+6 +28712 1 1 东海石+6 +28713 1 1 帝王石+6 diff --git a/tools/server/npc-shops/item-mall/shop.conf b/tools/server/npc-shops/item-mall/shop.conf index 551d6d0e..4fc30084 100644 --- a/tools/server/npc-shops/item-mall/shop.conf +++ b/tools/server/npc-shops/item-mall/shop.conf @@ -2,7 +2,7 @@ SHOP_VNUM=14 SHOP_NAME=item_mall NPC_VNUM=9010 -VILLAGES="metin2_map_a1 686 565 8 +VILLAGES="metin2_map_a1 678 575 8 metin2_map_b1 598 689 4 metin2_map_c1 391 693 5" # Shop vnum of each page after the first (items.tsv has [tab] lines, so this is a shop_table_ex.txt shop). diff --git a/tools/server/npc-shops/item-mall/stones.tsv b/tools/server/npc-shops/item-mall/stones.tsv new file mode 100644 index 00000000..bb194237 --- /dev/null +++ b/tools/server/npc-shops/item-mall/stones.tsv @@ -0,0 +1,15 @@ +# vnum type subtype wear apply_type apply_value value0 value1 value2 value3 value4 value5 +28700 10 0 16 16 25 0 0 0 0 0 17 +28701 10 0 16 15 25 0 0 0 0 0 18 +28702 10 0 16 9 50 0 0 0 0 0 19 +28703 10 0 16 59 55 0 0 0 0 0 20 +28704 10 0 16 60 55 0 0 0 0 0 21 +28705 10 0 16 61 55 0 0 0 0 0 22 +28706 10 0 16 62 55 0 0 0 0 0 23 +28707 10 0 16 63 25 0 0 0 0 0 24 +28708 10 0 1 27 25 0 0 0 0 0 25 +28709 10 0 1 28 25 0 0 0 0 0 26 +28710 10 0 1 2 1000 0 0 0 0 0 27 +28711 10 0 1 1 2000 0 0 0 0 0 28 +28712 10 0 1 54 500 0 0 0 0 0 29 +28713 10 0 1 8 50 0 0 0 0 0 30