装备校验链/快捷栏/公会/好友情侣/交易仓库五项差距修复(增量 129)

- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排,
  新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS),
  帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案
- 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、
  物品槽失配置灰(服务器回收/换武器后刷新)
- 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、
  被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1)
- 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/
  near/far/divorce 命令事件→在线/在身边状态与离婚隐藏
- 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示;
  仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close;
  仓库金钱经核实 40250 无 wire 协议,UI 定界只读
- app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫
- 相机 look_at 零向量守卫(首帧/传送落点)
- 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为
  HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路)
- docs/CLIENT-GAP.md 同步增量 129

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
This commit is contained in:
shenlei
2026-09-07 21:43:30 +09:00
co-authored by Claude Code
parent 61272b75e9
commit 8092ff44e2
25 changed files with 1067 additions and 134 deletions
+50 -16
View File
@@ -1900,8 +1900,8 @@
| — | ❌ | 拍卖行 / auction house | 不做 | 当前 40250 默认构建未发现完整 ClientVS22 拍卖行 UI / handler;但客户端 `Packet.h` 保留 `__AUCTION__` 下的 `CG_AUCTION_CMD=205`,服务端也保留列表包 `=206`。当前默认服务端关闭该宏,因此本阶段不做;若启用需另行验收,不能称为“协议不存在” | | — | ❌ | 拍卖行 / auction house | 不做 | 当前 40250 默认构建未发现完整 ClientVS22 拍卖行 UI / handler;但客户端 `Packet.h` 保留 `__AUCTION__` 下的 `CG_AUCTION_CMD=205`,服务端也保留列表包 `=206`。当前默认服务端关闭该宏,因此本阶段不做;若启用需另行验收,不能称为“协议不存在” |
| P1 | 🟡 | Cube (제작) / 能量系统 | **首版**Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()``GameClient::send_cube_make/material_info/result_list``/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。`POINT_ENERGY` 已由 HUD 快照显示;仍缺原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 | | P1 | 🟡 | Cube (제작) / 能量系统 | **首版**Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()``GameClient::send_cube_make/material_info/result_list``/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。`POINT_ENERGY` 已由 HUD 快照显示;仍缺原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 |
| P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE=75` 分配 攻/坦/迅捷/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`)**;角色 / 治疗控件已按 40250 服务端 Leadership 规则本地门控(攻击≥10、坦克/迅捷≥20、辅助≥25、宗师≥35、防御≥40,治疗≥18);真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 仍需真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义和真实两账号组队验收 | | P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE=75` 分配 攻/坦/迅捷/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`+ 组队治疗(`party_use_skill(1,0)`)**;角色 / 治疗控件已按 40250 服务端 Leadership 规则本地门控(攻击≥10、坦克/迅捷≥20、辅助≥25、宗师≥35、防御≥40,治疗≥18);真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 仍需真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义和真实两账号组队验收 |
| P1 | 🟡 | 好友 / 情侣 | 好友列表、上线状态和私聊入口已有;`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) 已有尺寸断言、`EntityStore::LoverInfo` 状态、`M2Client.get_lover()` / `lover_changed``love_ui` 常驻显示伴侣名和 0–100 爱意值;`net.entity_store``love_ui_test.gd` 覆盖 | 补好友分组和完整 messenger 行为;情侣仍需完整交互 / 状态图标与 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 | | P1 | 🟡 | 好友 / 情侣 | 好友列表、上线状态和私聊入口已有;`GC_LOVER_INFO`(70B) / `GC_LOVE_POINT_UPDATE`(5B) 已有尺寸断言、`EntityStore::LoverInfo` 状态、`M2Client.get_lover()` / `lover_changed``love_ui` 常驻显示伴侣名和 0–100 爱意值;`net.entity_store``love_ui_test.gd` 覆盖 | 增量 129 补 messenger 三固定组(好友 / 公会 / 情侣,对齐 `uimessenger.py:274-295`)与 `lover_login/logout/near/far/divorce` 命令事件 → 情侣在线 / 在身边状态(`love_ui` 后缀 + `friend_ui` 分组行);仍需情侣 affect 表现、断线 / 切图生命周期、原版文案和真服务器端到端验收 |
| P1 | 🟡 | 公会(暂缓) | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD=82` 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD``CG_GUILD_SYMBOL_CRC=113` / `GC_GUILD_SYMBOL_DATA=133` 已支持原始符号文件下载 | 本阶段暂缓完整权限 / 等级页、公告与日志、成员管理、公会战应答、积分板、领地和公会建筑;恢复公会范围后再做完整真服验收 | | P1 | 🟡 | 公会 | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD=82` 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD``CG_GUILD_SYMBOL_CRC=113` / `GC_GUILD_SYMBOL_DATA=133` 已支持原始符号文件下载。**增量 129 恢复公会范围**:权限门(`GUILD_AUTH_*` 位控踢人 / 公告 / 技能升级 / 施放,grade 0 = 会长,数据未同步回退全开交服务器权威)、成员页资金行与踢人按钮、`!` 前缀公告发布、`/gskillup <vnum>` 技能升级、公会战宣战应答弹窗(state 3 → `/war`·`/nowar` | 仍缺公会日志页、积分板、领地和公会建筑;完整真服验收待进行 |
| — | ❌ | 龙魂精炼 | 当前目标 40250 服务端不提供该玩法;本阶段不纳入支持或验收,已有兼容代码仅保留作协议参考 | 若未来服务端增加该玩法,再另立协议、UI 和验收需求 | | — | ❌ | 龙魂精炼 | 当前目标 40250 服务端不提供该玩法;本阶段不纳入支持或验收,已有兼容代码仅保留作协议参考 | 若未来服务端增加该玩法,再另立协议、UI 和验收需求 |
| P1 | 🟡 | 世界玩法 | 有 `CG_WARP=65` 主动请求、`GC_WARP=65` 处理、`NetWorld` / `GameScene` 的无条件 `lAddr:wPort` 重连与换图清理首版、`CG_FISHING=82` / `GC_FISHING=89` 钓鱼方向与状态事件、`NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON=110` / `GC_DUNGEON=110` 副本请求与状态事件、`GC_DIG_MOTION=134` 挖矿动作广播、`GC_LAND_LIST=130` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE=9698` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地图 BGM / 天气配置、地形贴花、DungeonBlock;换图首版已落地,仍需真实 40250 端到端验收 | | P1 | 🟡 | 世界玩法 | 有 `CG_WARP=65` 主动请求、`GC_WARP=65` 处理、`NetWorld` / `GameScene` 的无条件 `lAddr:wPort` 重连与换图清理首版、`CG_FISHING=82` / `GC_FISHING=89` 钓鱼方向与状态事件、`NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON=110` / `GC_DUNGEON=110` 副本请求与状态事件、`GC_DIG_MOTION=134` 挖矿动作广播、`GC_LAND_LIST=130` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE=9698` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地图 BGM / 天气配置、地形贴花、DungeonBlock;换图首版已落地,仍需真实 40250 端到端验收 |
| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;增量 61 接入 EventManager 任务信号点、清除和 Atlas center adjust;增量 108 任务信号点越出小地图半径时按 `m2dev PythonMiniMap.cpp:441-462` 夹到圆周并改画指向目标的方向箭头(`SIGNAL_RIM_INSET=9.0`);Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、Atlas 侧同一信标夹边、完整 `.sub` 边缘指示图标、地图驱动天气配置和原版窗口视觉 | | P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;增量 61 接入 EventManager 任务信号点、清除和 Atlas center adjust;增量 108 任务信号点越出小地图半径时按 `m2dev PythonMiniMap.cpp:441-462` 夹到圆周并改画指向目标的方向箭头(`SIGNAL_RIM_INSET=9.0`);Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、Atlas 侧同一信标夹边、完整 `.sub` 边缘指示图标、地图驱动天气配置和原版窗口视觉 |
@@ -2029,7 +2029,7 @@ enum,不存在 `namespace CG` / `namespace GC`。本文此前的“68 + 109 =
1. 任务完整标签 / 输入 / 选道具 / 指引; 1. 任务完整标签 / 输入 / 选道具 / 指引;
2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 40250/ClientVS22 无,除非另起协议); 2. 商店 START_EX、仓库密码、商城、私人商店、Cube(拍卖行 40250/ClientVS22 无,除非另起协议);
3. 组队角色、情侣完整交互、领地与公会建筑;公会完整页面暂缓 3. 组队角色、情侣完整交互、公会日志 / 积分板、领地与公会建筑;公会完整页面已随增量 129 恢复
4. 钓鱼、副本、坐骑、观战者和 Atlas。 4. 钓鱼、副本、坐骑、观战者和 Atlas。
### M3UI 与表现 1:1 ### M3UI 与表现 1:1
@@ -2485,9 +2485,23 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
头发保留。`net_world._apply_field_updates()` 确认 `set_armor→set_weapon→set_hair` 字段序已 1:1 于 头发保留。`net_world._apply_field_updates()` 确认 `set_armor→set_weapon→set_hair` 字段序已 1:1 于
`NetworkActorManager.cpp:473`。新增 canonical 测试 #18 `part_hiding_test.gd`45 断言)+ `NetworkActorManager.cpp:473`。新增 canonical 测试 #18 `part_hiding_test.gd`45 断言)+
`equip_model_test.gd` 补 7 段。 `equip_model_test.gd` 补 7 段。
**仍为 seam**:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性 4 项按名读取 ~~仍为 seam:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性 4 项按名读取
留待接服务器 `char_item.cpp` 比对;**变身态**40250 实体表无 `poly_race` 字段,`_is_poly()` 留待接服务器 `char_item.cpp` 比对;变身态(40250 实体表无 `poly_race` 字段,`_is_poly()`
`false`,等 `GC_CHARACTER_UPDATE` 带变身 race;**远端 PC 换模链首版已在增量 99 落地**—— `false`,等 `GC_CHARACTER_UPDATE` 带变身 race。~~ **增量 129(§4.1 装备校验链精确化)**
已对照 40250 服务器源码逐行核实链路并重排 `equip_rules.gd.can_equip()`——
`char_item.cpp:7462 CanEquipNow`JOB antiflag → LIMIT_LEVEL/STR/INT/DEX/CON → WEARABLE_UNIQUE
同组去重,`IsSameSpecialGroup` 客户端退化为同 vnum 判定)→ `item.cpp:472 FindEquipCell`(新增
1:1 复刻 `find_equip_cell()`else-if 优先级 BODY→HEAD→FOOTS→WRIST→WEAPON→SHIELD→NECK→EAR→
ARROWRING→RING1 空则 RING2UNIQUE→UNIQUE1 空则 UNIQUE2COSTUME 按 sub_typeBELT 固定,
DS/SPECIAL_DS 走龙魂专用路径返回 -1)→ `char_item.cpp:6100 EquipItem` 后置检查(礼服
vnum 11901..11904 + 骑乘禁止 → 变身中除 `WEAR_ARROW` 一律禁止 → `FN_check_item_sex` 性别 →
`IsRideItem` 已骑乘禁止)。**帝国 antiflag 从装备门降级为 tooltip 提示**(`empire_warning()`
服务器装备路径从不检查、仅显示层语义);`LIMIT_PCBANG(6)` 不是门禁(仅标记),
`LIMIT_REAL_TIME(7)` / `LIMIT_REAL_TIME_START_FIRST_USE(8)` 由 socket 驱动到期,客户端职责只有
tooltip 倒计时(`item_tooltip.gd` 补「PC房专用道具」「首次使用后计时」与到期文案);变身门读
实体 `polymorph`/`poly_vnum` + affect 220`_equip_ctx()` 同步采集 st/dx/ht/iq 四属性与穿戴表
0..10 + 新装备槽 90+19..90+23)。`equip_rules_test.gd``find_equip_cell` 与后置检查断言。
**远端 PC 换模链首版已在增量 99 落地**——
`game_scene``ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA `game_scene``ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA
远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]``net_world` 按四字段调用 setter 远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]``net_world` 按四字段调用 setter
不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见 不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见
@@ -2508,12 +2522,21 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间。仍缺 CJK 文案 / 字体、其他 技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间。仍缺 CJK 文案 / 字体、其他
特殊分支、装备对比(参考源码未发现对应方法)和完整计时来源语义。 特殊分支、装备对比(参考源码未发现对应方法)和完整计时来源语义。
4. **Safebox 是列表和自动找空位。** 当前 deposit 使用下一个空位,缺少 45 格网格、分页、指定 4. **Safebox 金币 / 改密已按 40250 真实能力定界(增量 129),网格仍为首版。** 金币存取经核实
目标格、仓库内移动、金币存取、密码修改、重试限制、完整错误状态和原版互斥规则。 **40250 无 wire 协议**`HEADER_CG_SAFEBOX_MONEY` 服务端不存在、`GC_SAFEBOX_MONEY_CHANGE(84)`
见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/safebox_ui.gd:7)。 从不发送、ClientVS22 `SendSafeBoxMoneyPacket` 为 assert-stub、原版 `RefreshSafeboxMoney`
`pass`——故 UI 金币只读展示(不伪造协议包,避免状态失步)。`close()` 补发聊天命令
`/safebox_close``cmd.cpp:350`,对齐原版关窗即服务器侧关闭);改密走
`/safebox_change_password <old> <new>``cmd.cpp:354`,各 1..6 位)三段密码 ConfirmationDialog
`mall_ui.close()` 同步补 `/mall_close`。仍缺 45 格网格、分页、指定目标格、仓库内移动、重试
限制和原版互斥规则。见 [`safebox_ui.gd:7`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/safebox_ui.gd:7)。
5. **交易缺少事务状态。** 当前是两列文字列表,没有锁定、接受后修改自动取消接受、物品占用、 5. **交易结束 / 失败提示已补(增量 129),事务状态仍缺** 已核实 `GC_END` 对成功与取消发同码
服务器回滚、交易失败恢复、对方物品变化提示和交易中的背包锁定。 `exchange.cpp` `EXCHANGE_END``Cancel()`),包级不可区分——`EntityStore.ExchangeState` 新增
`last_notice` 捕获 `EXCHANGE_GC_ALREADY(6)` / `EXCHANGE_GC_LESS_GOLD(7)`(新协议 wire 名
`LESS_ELK`),`exchange_ui` 结束时 5s 浮层提示(「对方已经在交易中」/「金币不足」)+ 交易中
状态行内联提示;最终结果以 `CHAT_TYPE_INFO` 为准。仍缺锁定、接受后修改自动取消接受、物品
占用、交易失败恢复和交易中的背包锁定。
6. **NPC 商店已有货位网格与购买确认,但仍非完整原版商店。** 增量 59 已接入 5×8/40 格货位、 6. **NPC 商店已有货位网格与购买确认,但仍非完整原版商店。** 增量 59 已接入 5×8/40 格货位、
空位禁用、货架全局槽号、tooltip 和 BUY 确认(固定数量 1);仍缺真实图标 / `.sub`、非金币货币、 空位禁用、货架全局槽号、tooltip 和 BUY 确认(固定数量 1);仍缺真实图标 / `.sub`、非金币货币、
@@ -2522,16 +2545,27 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移
7. **私人商店是兼容实现。** 虽然 `CG_MYSHOP` 已能发送,但原版库存模型、拖放、价格编辑、 7. **私人商店是兼容实现。** 虽然 `CG_MYSHOP` 已能发送,但原版库存模型、拖放、价格编辑、
开店前检查、库存锁定、撤下回滚和其他玩家查看流程尚未完整迁移。 开店前检查、库存锁定、撤下回滚和其他玩家查看流程尚未完整迁移。
8. **快捷栏类型映射和键位主语义已修正,刷新边界仍不完整** 参考端可执行类型只有 8. **快捷栏死亡 / 变身 / 失配刷新已补(增量 129),特殊技能冷却仍缺** 参考端可执行类型只有
`INVENTORY(1)``SKILL(2)``EMOTION(3)`,当前已统一该编号、接通表情执行、4×8 显示、 `INVENTORY(1)``SKILL(2)``EMOTION(3)`,当前已统一该编号、接通表情执行、4×8 显示、
inventory → quickslot 投放以及数字 / F1F4 / Shift 入口;仍缺特殊技能冷却、死亡 / 变身 / inventory → quickslot 投放以及数字 / F1F4 / Shift 入口;增量 129 接 `entity_dead`(死亡全槽
换武器后的完整服务端刷新。见 [`quickbar.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quickbar.gd)。 置灰 + 激活守卫)、`affect_added`/`affect_removed`affect 220 = 变身,技能槽置灰)、
`inventory_changed`(物品槽 `get_item` 为空 / vnum==0 时置灰并显示 `×vnum`,覆盖换武器 /
服务器回收)。仍缺特殊技能冷却。见
[`quickbar.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quickbar.gd)。
9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、 9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、
死亡、距离状态和多账号行为还未完成真实验收。 死亡、距离状态和多账号行为还未完成真实验收。
10. **好友、情侣、公会只有首版状态展示。** Messenger 分组、上线/离线、私聊生命周期、情侣 10. **好友 / 公会 / 情侣 messenger 与公会权限已有完整首版(增量 129)。** `friend_ui.gd`
交互、公会权限、公告、日志、成员管理、公会战应答、领地和公会建筑均不完整或暂缓。 原版三固定组(好友 / 公会 / 情侣,`uimessenger.py:274-295`)分列:好友行、公会行(在线点 /
会长标记 / 点击私聊)、情侣行(♥ 在线点 + 「在身边」标记);情侣在线 / 在身边 / 离婚由
`CHAT_TYPE_COMMAND` 事件 `lover_login/logout/near/far/divorce``marriage.cpp`)驱动
`LoverInfo.online/near``love_ui` 状态后缀与自动隐藏。公会:`_my_auth()` 按自身 grade 查
auth 位(`guild.h:94-97` ADD/REMOVE/NOTICE/USE_SKILL)门控踢人按钮、`!` 前缀公告、技能施放 /
`/gskillup` 升级,成员数据未同步时回退全开(服务器权威,对齐原版无前端门);公会战宣战
应答弹窗(state 3 = `GUILD_WAR_RECV_DECLARE``/war <name>` 接受 / `/nowar <name>` 拒绝);
成员页资金行(存 / 取包保留 1:1,40250 服务端已禁用 `CG 13/14` 处理器)。仍缺:私聊生命
周期细节、情侣 affect 表现、公会日志页、积分板、领地和公会建筑、真服端到端验收。
### 5. P1:任务、NPC 和世界玩法 ### 5. P1:任务、NPC 和世界玩法
+27 -18
View File
@@ -718,7 +718,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
break; break;
case EXCHANGE_GC_ALREADY: case EXCHANGE_GC_ALREADY:
case EXCHANGE_GC_LESS_GOLD: case EXCHANGE_GC_LESS_GOLD:
m_world.mut_exchange_notice(); // 带上子头值(6 = 对方已在交易中 / 7 = 金钱不足),UI 据此给
// 定点提示;成败的权威文案走 CHAT_TYPE_INFO 聊天通道。
m_world.mut_exchange_notice(p.subheader);
break; break;
default: default:
break; break;
@@ -825,13 +827,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
uint8_t flag = body[2]; uint8_t flag = body[2];
const uint8_t *cur = body + 3; const uint8_t *cur = body + 3;
const uint8_t *end = body + len; const uint8_t *end = body + len;
auto take_str = [&](size_t cap) -> std::string { // Every optional field is fixed-width on the wire (the server writes
// the whole reserved buffer, NUL padding included), so advance by the
// field width and not by the string length.
auto take_str = [&](size_t width) -> std::string {
if (static_cast<size_t>(end - cur) < width) {
cur = end;
return {};
}
size_t n = 0; size_t n = 0;
while (cur + n < end && cur[n] != 0 && n < cap) { while (n < width && cur[n] != 0) {
++n; ++n;
} }
std::string s(reinterpret_cast<const char *>(cur), n); std::string s(reinterpret_cast<const char *>(cur), n);
cur += (cur + n < end) ? n + 1 : n; // skip NUL if present cur += width;
return s; return s;
}; };
auto take_i32 = [&]() -> int32_t { auto take_i32 = [&]() -> int32_t {
@@ -844,25 +853,34 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}; };
std::string title, clock_name, counter_name, icon; std::string title, clock_name, counter_name, icon;
int32_t clock_value = 0, counter_value = 0; int32_t clock_value = 0, counter_value = 0;
// QUEST_SEND_IS_BEGIN carries its own byte: set means "quest begins",
// clear means "quest ends". Its presence alone is not the value.
bool is_begin = false;
if (flag & QUEST_SEND_IS_BEGIN) {
if (cur < end) {
is_begin = *cur != 0;
++cur;
}
}
if (flag & QUEST_SEND_TITLE) { if (flag & QUEST_SEND_TITLE) {
title = take_str(28); title = take_str(QUEST_INFO_TITLE_SIZE);
} }
if (flag & QUEST_SEND_CLOCK_NAME) { if (flag & QUEST_SEND_CLOCK_NAME) {
clock_name = take_str(16); clock_name = take_str(QUEST_INFO_CLOCK_NAME_SIZE);
} }
if (flag & QUEST_SEND_CLOCK_VALUE) { if (flag & QUEST_SEND_CLOCK_VALUE) {
clock_value = take_i32(); clock_value = take_i32();
} }
if (flag & QUEST_SEND_COUNTER_NAME) { if (flag & QUEST_SEND_COUNTER_NAME) {
counter_name = take_str(16); counter_name = take_str(QUEST_INFO_COUNTER_NAME_SIZE);
} }
if (flag & QUEST_SEND_COUNTER_VALUE) { if (flag & QUEST_SEND_COUNTER_VALUE) {
counter_value = take_i32(); counter_value = take_i32();
} }
if (flag & QUEST_SEND_ICON_FILE) { if (flag & QUEST_SEND_ICON_FILE) {
icon = take_str(24); icon = take_str(QUEST_INFO_ICON_FILE_SIZE);
} }
m_world.mut_quest_info(index, flag, (flag & QUEST_SEND_IS_BEGIN) != 0, title, clock_name, m_world.mut_quest_info(index, flag, is_begin, title, clock_name,
clock_value, counter_name, counter_value, icon); clock_value, counter_name, counter_value, icon);
return true; return true;
} }
@@ -1497,15 +1515,6 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
return true; return true;
} }
case HDR_GC_QUEST_SCRIPT:
// Optional extended quest UI data. Consume it even when the UI is absent.
return true;
case HDR_GC_EXTENDED_STATUS:
// Optional server status payload; framing is sufficient for connection safety.
return true;
case HDR_GC_EXTENDED_CHARACTER:
// Optional extended character payload; consume without requiring its UI.
return true;
default: default:
m_last_error = "phase " + m_phase_name + " does not handle GC header " + m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header); std::to_string(header);
+40 -3
View File
@@ -5,6 +5,7 @@
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <fcntl.h> #include <fcntl.h>
#include <netdb.h> #include <netdb.h>
@@ -16,6 +17,27 @@
namespace mtnet::classic { namespace mtnet::classic {
namespace { namespace {
// MT_NET_HEX=1 dumps the leading bytes of every framed packet next to the
// existing wire trace. Framing bugs are only diagnosable from the actual
// bytes, and the per-packet header/size line alone cannot show where a
// mis-sized packet drags the stream out of alignment.
bool hex_dump_enabled() {
static const bool on = std::getenv("MT_NET_HEX") != nullptr;
return on;
}
void trace_hex(const char *tag, const char *name, uint8_t header, const uint8_t *p, size_t avail) {
if (!hex_dump_enabled()) {
return;
}
const size_t dump = avail < 24 ? avail : 24;
std::fprintf(stderr, "[classic:%s] %s hdr=%u hex:", name, tag, header);
for (size_t i = 0; i < dump; ++i) {
std::fprintf(stderr, " %02x", p[i]);
}
std::fprintf(stderr, "\n");
}
constexpr size_t RECV_CHUNK = 32 * 1024; constexpr size_t RECV_CHUNK = 32 * 1024;
uint32_t now_ms() { uint32_t now_ms() {
@@ -486,14 +508,28 @@ void ClassicStream::dispatch() {
disconnect(); disconnect();
return; return;
} }
if (m_recv.readable() < dh.size) { uint32_t framed = dh.size;
if (header == HDR_GC_QUEST_INFO) {
// GC_QUEST_INFO always reports size == 6 on the wire; its real
// length is flag-driven (see quest_info_packet_size).
uint8_t head[QUEST_INFO_HEAD_SIZE];
if (!m_recv.peek(head, sizeof(head))) {
return;
}
framed = static_cast<uint32_t>(
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1]));
}
if (m_recv.readable() < framed) {
return; // whole packet not here yet return; // whole packet not here yet
} }
if (m_trace) {
trace_hex("recv", m_trace_name.c_str(), header, m_recv.read_ptr(), m_recv.readable());
}
m_recv.discard(sizeof(DynHead)); m_recv.discard(sizeof(DynHead));
uint32_t body_len = dh.size - sizeof(DynHead); uint32_t body_len = framed - sizeof(DynHead);
const uint8_t *body = m_recv.read_ptr(); const uint8_t *body = m_recv.read_ptr();
if (m_trace) { if (m_trace) {
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, dh.size); std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, framed);
} }
bool ok = !on_packet || on_packet(header, body, body_len); bool ok = !on_packet || on_packet(header, body, body_len);
discard_recv(body_len); discard_recv(body_len);
@@ -557,6 +593,7 @@ void ClassicStream::dispatch() {
const uint8_t *p = m_recv.read_ptr(); const uint8_t *p = m_recv.read_ptr();
if (m_trace) { if (m_trace) {
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%d dynamic=0\n", m_trace_name.c_str(), header, total); std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%d dynamic=0\n", m_trace_name.c_str(), header, total);
trace_hex("recv", m_trace_name.c_str(), header, p, m_recv.readable());
if (header == HDR_GC_LOGIN_SUCCESS_NEWSLOT || header == HDR_GC_SKILL_COOLTIME_END) { if (header == HDR_GC_LOGIN_SUCCESS_NEWSLOT || header == HDR_GC_SKILL_COOLTIME_END) {
const int next = m_recv.readable() > static_cast<size_t>(total) ? p[total] : -1; const int next = m_recv.readable() > static_cast<size_t>(total) ? p[total] : -1;
if (next < 0) { if (next < 0) {
+28 -9
View File
@@ -276,12 +276,6 @@ enum : uint8_t {
HDR_GC_EMPIRE = 90, HDR_GC_EMPIRE = 90,
HDR_GC_PARTY_LINK = 91, HDR_GC_PARTY_LINK = 91,
HDR_GC_PARTY_UNLINK = 92, HDR_GC_PARTY_UNLINK = 92,
// 40250 extended quest/script notification; payload is dynamically framed.
HDR_GC_QUEST_SCRIPT = 148,
// Extended servers also emit a dynamically framed mount/status notice here.
HDR_GC_EXTENDED_STATUS = 59,
// 40250 extended character/status packet (dynamic framing).
HDR_GC_EXTENDED_CHARACTER = 105,
HDR_GC_REFINE_INFORMATION_OLD = 95, HDR_GC_REFINE_INFORMATION_OLD = 95,
HDR_GC_OBSERVER_ADD = 96, HDR_GC_OBSERVER_ADD = 96,
HDR_GC_OBSERVER_REMOVE = 97, HDR_GC_OBSERVER_REMOVE = 97,
@@ -1063,6 +1057,34 @@ enum : uint8_t {
QUEST_SEND_COUNTER_VALUE = 1 << 5, QUEST_SEND_COUNTER_VALUE = 1 << 5,
QUEST_SEND_ICON_FILE = 1 << 6, QUEST_SEND_ICON_FILE = 1 << 6,
}; };
// Field widths written by PC::SendQuestInfoPakcet() (questpc.cpp) and read back
// by the original client's field-by-field Recv() in RecvQuestInfoPacket().
inline constexpr int QUEST_INFO_HEAD_SIZE = 6; // header + u16 size + u16 index + u8 flag
inline constexpr int QUEST_INFO_IS_BEGIN_SIZE = 1;
inline constexpr int QUEST_INFO_TITLE_SIZE = 30 + 1;
inline constexpr int QUEST_INFO_CLOCK_NAME_SIZE = 16 + 1;
inline constexpr int QUEST_INFO_CLOCK_VALUE_SIZE = 4;
inline constexpr int QUEST_INFO_COUNTER_NAME_SIZE = 16 + 1;
inline constexpr int QUEST_INFO_COUNTER_VALUE_SIZE = 4;
inline constexpr int QUEST_INFO_ICON_FILE_SIZE = 24 + 1;
// The 40250 server fills the fixed head with `size = sizeof(packet_quest_info)`
// (6) and then appends the optional fields while only bumping its own local
// copy of `size` — the count on the wire never covers the tail. Framing this
// packet off its size field therefore leaves the tail in the stream and
// desyncs every packet after it, so derive the real length from `flag` the way
// the original client does.
constexpr int quest_info_packet_size(uint8_t flag) {
int n = QUEST_INFO_HEAD_SIZE;
if (flag & QUEST_SEND_IS_BEGIN) n += QUEST_INFO_IS_BEGIN_SIZE;
if (flag & QUEST_SEND_TITLE) n += QUEST_INFO_TITLE_SIZE;
if (flag & QUEST_SEND_CLOCK_NAME) n += QUEST_INFO_CLOCK_NAME_SIZE;
if (flag & QUEST_SEND_CLOCK_VALUE) n += QUEST_INFO_CLOCK_VALUE_SIZE;
if (flag & QUEST_SEND_COUNTER_NAME) n += QUEST_INFO_COUNTER_NAME_SIZE;
if (flag & QUEST_SEND_COUNTER_VALUE) n += QUEST_INFO_COUNTER_VALUE_SIZE;
if (flag & QUEST_SEND_ICON_FILE) n += QUEST_INFO_ICON_FILE_SIZE;
return n;
}
// --- GC party --- // --- GC party ---
inline constexpr int PARTY_AFFECT_SLOT_MAX_NUM = 7; inline constexpr int PARTY_AFFECT_SLOT_MAX_NUM = 7;
@@ -1465,9 +1487,6 @@ constexpr bool is_dynamic_gc(uint8_t h) {
case HDR_GC_GUILD: case HDR_GC_GUILD:
case HDR_GC_MESSENGER: case HDR_GC_MESSENGER:
case HDR_GC_QUEST_INFO: case HDR_GC_QUEST_INFO:
case HDR_GC_QUEST_SCRIPT:
case HDR_GC_EXTENDED_STATUS:
case HDR_GC_EXTENDED_CHARACTER:
case HDR_GC_DUEL_START: case HDR_GC_DUEL_START:
case HDR_GC_SYNC_POSITION: case HDR_GC_SYNC_POSITION:
// client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its // client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its
+54 -2
View File
@@ -1083,6 +1083,26 @@ void EntityStore::mut_lover(const std::string &name, uint8_t love_point) {
m_lover.name = name; m_lover.name = name;
m_lover.love_point = love_point; m_lover.love_point = love_point;
m_lover.valid = !name.empty(); m_lover.valid = !name.empty();
if (!m_lover.valid) {
m_lover.online = false;
m_lover.near = false;
}
m_lover_dirty = true;
}
void EntityStore::mut_lover_online(bool online) {
if (!m_lover.valid || m_lover.online == online) {
return;
}
m_lover.online = online;
m_lover_dirty = true;
}
void EntityStore::mut_lover_near(bool near) {
if (!m_lover.valid || m_lover.near == near) {
return;
}
m_lover.near = near;
m_lover_dirty = true; m_lover_dirty = true;
} }
@@ -1369,7 +1389,7 @@ void EntityStore::mut_guild_invite(uint32_t guild_id, const std::string &guild_n
} }
void EntityStore::mut_exchange_start(uint32_t partner_vid) { void EntityStore::mut_exchange_start(uint32_t partner_vid) {
m_exchange = ExchangeState{}; m_exchange = ExchangeState{}; // last_notice 一并清零
m_exchange.active = true; m_exchange.active = true;
m_exchange.partner_vid = partner_vid; m_exchange.partner_vid = partner_vid;
m_exchange_dirty = true; m_exchange_dirty = true;
@@ -1415,7 +1435,8 @@ void EntityStore::mut_exchange_end() {
m_exchange_dirty = true; m_exchange_dirty = true;
} }
void EntityStore::mut_exchange_notice() { void EntityStore::mut_exchange_notice(uint8_t code) {
m_exchange.last_notice = code;
m_exchange_dirty = true; m_exchange_dirty = true;
} }
@@ -2598,6 +2619,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
case EXCHANGE_GC_END: case EXCHANGE_GC_END:
x = ExchangeState{}; x = ExchangeState{};
break; break;
case EXCHANGE_GC_ALREADY:
case EXCHANGE_GC_LESS_ELK:
// 与 classic 解析路径一致:只带这两个可机器判定的附加码。
x.last_notice = (uint8_t)p.subheader;
break;
default: default:
break; break;
} }
@@ -3319,6 +3345,32 @@ void EntityStore::apply_server_command(const std::string &line) {
mut_friend_invite(tok[1]); mut_friend_invite(tok[1]);
return; return;
} }
if (tok[0] == "lover_login") {
mut_lover_online(true);
m_server_commands.push_back({ServerCommandEvent::LoverLogin, 1, 0});
return;
}
if (tok[0] == "lover_logout") {
mut_lover_online(false);
mut_lover_near(false);
m_server_commands.push_back({ServerCommandEvent::LoverLogout, 0, 0});
return;
}
if (tok[0] == "lover_near") {
mut_lover_near(true);
m_server_commands.push_back({ServerCommandEvent::LoverNear, 1, 0});
return;
}
if (tok[0] == "lover_far") {
mut_lover_near(false);
m_server_commands.push_back({ServerCommandEvent::LoverFar, 0, 0});
return;
}
if (tok[0] == "lover_divorce") {
mut_lover("", 0);
m_server_commands.push_back({ServerCommandEvent::LoverDivorce, 0, 0});
return;
}
if (tok[0] == "gift") { if (tok[0] == "gift") {
m_server_commands.push_back({ServerCommandEvent::GiftAvailable, 0, 0}); m_server_commands.push_back({ServerCommandEvent::GiftAvailable, 0, 0});
return; return;
+14 -1
View File
@@ -309,6 +309,10 @@ struct ExchangeState {
int64_t peer_gold = 0; int64_t peer_gold = 0;
bool self_accept = false; bool self_accept = false;
bool peer_accept = false; bool peer_accept = false;
// EXCHANGE_GC_ALREADY / LESS_GOLD 的线上的子头值(0 = 无)。服务器在成功与
// 取消两种结局都发 GC_ENDexchange.cpp EXCHANGE_END 统一 Cancel),成败区分
// 只靠 CHAT_TYPE_INFO 文案;这里只承载这两个机器可判的附加码。
uint8_t last_notice = 0;
}; };
// --- guild (GC_GUILD) -------------------------------------------------------- // --- guild (GC_GUILD) --------------------------------------------------------
@@ -414,6 +418,11 @@ struct ServerCommandEvent {
MobileAuthRequired, MobileAuthRequired,
ComboChanged, ComboChanged,
GiftAvailable, GiftAvailable,
LoverLogin,
LoverLogout,
LoverNear,
LoverFar,
LoverDivorce,
}; };
Kind kind = PartyRequestDenied; Kind kind = PartyRequestDenied;
uint32_t value = 0; // item vnum / price, depending on kind uint32_t value = 0; // item vnum / price, depending on kind
@@ -460,6 +469,8 @@ struct LoverInfo {
std::string name; std::string name;
uint8_t love_point = 0; uint8_t love_point = 0;
bool valid = false; bool valid = false;
bool online = false; // CHAT_TYPE_COMMAND "lover_login"/"lover_logout"marriage.cpp
bool near = false; // CHAT_TYPE_COMMAND "lover_near"/"lover_far"
}; };
// The local player's full stat array (GC_PLAYER_POINTS), indexed by EPointTypes. // The local player's full stat array (GC_PLAYER_POINTS), indexed by EPointTypes.
@@ -645,6 +656,8 @@ public:
void mut_marker_delete(int32_t id); void mut_marker_delete(int32_t id);
void mut_observer(ObserverEvent::Kind kind, uint32_t vid, int32_t x, int32_t y); void mut_observer(ObserverEvent::Kind kind, uint32_t vid, int32_t x, int32_t y);
void mut_lover(const std::string &name, uint8_t love_point); void mut_lover(const std::string &name, uint8_t love_point);
void mut_lover_online(bool online);
void mut_lover_near(bool near);
void mut_love_point(uint8_t love_point); void mut_love_point(uint8_t love_point);
void mut_fishing(uint8_t subheader, uint32_t info, uint8_t dir); void mut_fishing(uint8_t subheader, uint32_t info, uint8_t dir);
void mut_dungeon(uint8_t subheader, int32_t x, int32_t y, bool has_destination); void mut_dungeon(uint8_t subheader, int32_t x, int32_t y, bool has_destination);
@@ -675,7 +688,7 @@ public:
void mut_exchange_gold(bool self, int64_t gold); void mut_exchange_gold(bool self, int64_t gold);
void mut_exchange_accept(bool self, bool accepted); void mut_exchange_accept(bool self, bool accepted);
void mut_exchange_end(); void mut_exchange_end();
void mut_exchange_notice(); // server-side ALREADY / LESS_GOLD notice void mut_exchange_notice(uint8_t code); // EXCHANGE_GC_ALREADY / LESS_GOLD 子头值
void mut_safebox_open(int size); void mut_safebox_open(int size);
void mut_safebox_money(int64_t money); void mut_safebox_money(int64_t money);
void mut_safebox_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags, void mut_safebox_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
+63
View File
@@ -245,6 +245,9 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_safebox_gold"), &M2Client::get_safebox_gold); ClassDB::bind_method(D_METHOD("get_safebox_gold"), &M2Client::get_safebox_gold);
ClassDB::bind_method(D_METHOD("get_safebox_items"), &M2Client::get_safebox_items); ClassDB::bind_method(D_METHOD("get_safebox_items"), &M2Client::get_safebox_items);
ClassDB::bind_method(D_METHOD("safebox_password", "password"), &M2Client::safebox_password); ClassDB::bind_method(D_METHOD("safebox_password", "password"), &M2Client::safebox_password);
ClassDB::bind_method(D_METHOD("safebox_change_password", "old_password", "new_password"),
&M2Client::safebox_change_password);
ClassDB::bind_method(D_METHOD("safebox_close"), &M2Client::safebox_close);
// item-mall (창고몰) // item-mall (창고몰)
ClassDB::bind_method(D_METHOD("is_mall_open"), &M2Client::is_mall_open); ClassDB::bind_method(D_METHOD("is_mall_open"), &M2Client::is_mall_open);
ClassDB::bind_method(D_METHOD("get_mall_size"), &M2Client::get_mall_size); ClassDB::bind_method(D_METHOD("get_mall_size"), &M2Client::get_mall_size);
@@ -252,6 +255,7 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("mall_checkout", "mall_pos", "inv_window", "inv_cell"), ClassDB::bind_method(D_METHOD("mall_checkout", "mall_pos", "inv_window", "inv_cell"),
&M2Client::mall_checkout); &M2Client::mall_checkout);
ClassDB::bind_method(D_METHOD("mall_password", "password"), &M2Client::mall_password); ClassDB::bind_method(D_METHOD("mall_password", "password"), &M2Client::mall_password);
ClassDB::bind_method(D_METHOD("mall_close"), &M2Client::mall_close);
// private (PC) shop // private (PC) shop
ClassDB::bind_method(D_METHOD("open_private_shop", "sign", "items"), ClassDB::bind_method(D_METHOD("open_private_shop", "sign", "items"),
&M2Client::open_private_shop); &M2Client::open_private_shop);
@@ -314,6 +318,8 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("guild_remove_member", "pid"), &M2Client::guild_remove_member); ClassDB::bind_method(D_METHOD("guild_remove_member", "pid"), &M2Client::guild_remove_member);
ClassDB::bind_method(D_METHOD("guild_offer", "amount"), &M2Client::guild_offer); ClassDB::bind_method(D_METHOD("guild_offer", "amount"), &M2Client::guild_offer);
ClassDB::bind_method(D_METHOD("guild_charge_gsp", "amount"), &M2Client::guild_charge_gsp); ClassDB::bind_method(D_METHOD("guild_charge_gsp", "amount"), &M2Client::guild_charge_gsp);
ClassDB::bind_method(D_METHOD("guild_deposit_money", "amount"), &M2Client::guild_deposit_money);
ClassDB::bind_method(D_METHOD("guild_withdraw_money", "amount"), &M2Client::guild_withdraw_money);
ClassDB::bind_method(D_METHOD("guild_change_grade_name", "grade", "name"), ClassDB::bind_method(D_METHOD("guild_change_grade_name", "grade", "name"),
&M2Client::guild_change_grade_name); &M2Client::guild_change_grade_name);
ClassDB::bind_method(D_METHOD("guild_change_grade_authority", "grade", "authority"), ClassDB::bind_method(D_METHOD("guild_change_grade_authority", "grade", "authority"),
@@ -1236,6 +1242,8 @@ Dictionary M2Client::get_lover() const {
d["valid"] = lover.valid; d["valid"] = lover.valid;
d["name"] = String::utf8(lover.name.c_str()); d["name"] = String::utf8(lover.name.c_str());
d["love_point"] = (int)lover.love_point; d["love_point"] = (int)lover.love_point;
d["online"] = lover.online;
d["near"] = lover.near;
return d; return d;
} }
@@ -1373,6 +1381,9 @@ Dictionary M2Client::get_exchange() const {
d["peer_gold"] = (int64_t)x.peer_gold; d["peer_gold"] = (int64_t)x.peer_gold;
d["self_accept"] = x.self_accept; d["self_accept"] = x.self_accept;
d["peer_accept"] = x.peer_accept; d["peer_accept"] = x.peer_accept;
// EXCHANGE_GC_ALREADY(6)/LESS_GOLD(7) 的子头值;0 = 无。成功/取消的区分
// 只看 CHAT_TYPE_INFO 文案(服务器两种结局都发 GC_END)。
d["notice"] = (int)x.last_notice;
auto slots = [](const mtnet::ExchangeSlot *arr) { auto slots = [](const mtnet::ExchangeSlot *arr) {
Array a; Array a;
for (int i = 0; i < 12; ++i) { for (int i = 0; i < 12; ++i) {
@@ -1528,6 +1539,23 @@ bool M2Client::mall_password(const String &password) {
return say(0, String("/mall_password ") + password); return say(0, String("/mall_password ") + password);
} }
bool M2Client::safebox_change_password(const String &old_password, const String &new_password) {
// 服务器 do_safebox_change_password 要求两个参数各 1..6 字符。
if (old_password.is_empty() || old_password.length() > 6 ||
new_password.is_empty() || new_password.length() > 6) {
return false;
}
return say(0, String("/safebox_change_password ") + old_password + " " + new_password);
}
bool M2Client::safebox_close() {
return say(0, "/safebox_close");
}
bool M2Client::mall_close() {
return say(0, "/mall_close");
}
// --- private (PC) shop --- // --- private (PC) shop ---
bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) { bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) {
@@ -1783,6 +1811,25 @@ bool M2Client::guild_offer(int amount) {
return game && is_in_game() && mtnet::bounds::u32(amount) && return game && is_in_game() && mtnet::bounds::u32(amount) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount); game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
} }
bool M2Client::guild_deposit_money(int amount) {
// 40250 服务器禁用了存款处理(input_main.cpp GUILD_CG_DEPOSIT_MONEY 直接
// return);原版 UI 仍发送该包,这里保持同样的线上行为。
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DEPOSIT_MONEY,
(uint32_t)amount);
return game && is_in_game() && mtnet::bounds::u32(amount) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_DEPOSIT_MONEY, (uint32_t)amount);
}
bool M2Client::guild_withdraw_money(int amount) {
// 同上:40250 服务器禁用取款(且原逻辑仅会长可取,上限 500000)。
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_WITHDRAW_MONEY,
(uint32_t)amount);
return game && is_in_game() && mtnet::bounds::u32(amount) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_WITHDRAW_MONEY, (uint32_t)amount);
}
bool M2Client::guild_charge_gsp(int amount) { bool M2Client::guild_charge_gsp(int amount) {
if (!mtnet::bounds::i32(amount)) return false; if (!mtnet::bounds::i32(amount)) return false;
if (classic_sess) return is_in_game() && if (classic_sess) return is_in_game() &&
@@ -3170,6 +3217,14 @@ void M2Client::pump_classic() {
case mtnet::ServerCommandEvent::PartyRequestDenied: case mtnet::ServerCommandEvent::PartyRequestDenied:
emit_signal("party_request_denied"); emit_signal("party_request_denied");
break; break;
case mtnet::ServerCommandEvent::LoverLogin:
case mtnet::ServerCommandEvent::LoverLogout:
case mtnet::ServerCommandEvent::LoverNear:
case mtnet::ServerCommandEvent::LoverFar:
case mtnet::ServerCommandEvent::LoverDivorce:
// LoverInfo 由 mutator 同步更新;lover_changed 信号在
// take_lover_dirty() 处统一发出,这里无需单独 emit。
break;
case mtnet::ServerCommandEvent::SafeboxPasswordRequired: case mtnet::ServerCommandEvent::SafeboxPasswordRequired:
emit_signal("safebox_password_required"); emit_signal("safebox_password_required");
break; break;
@@ -3680,6 +3735,14 @@ void M2Client::pump_game() {
case mtnet::ServerCommandEvent::PartyRequestDenied: case mtnet::ServerCommandEvent::PartyRequestDenied:
emit_signal("party_request_denied"); emit_signal("party_request_denied");
break; break;
case mtnet::ServerCommandEvent::LoverLogin:
case mtnet::ServerCommandEvent::LoverLogout:
case mtnet::ServerCommandEvent::LoverNear:
case mtnet::ServerCommandEvent::LoverFar:
case mtnet::ServerCommandEvent::LoverDivorce:
// LoverInfo 由 mutator 同步更新;lover_changed 信号在
// take_lover_dirty() 处统一发出,这里无需单独 emit。
break;
case mtnet::ServerCommandEvent::SafeboxPasswordRequired: case mtnet::ServerCommandEvent::SafeboxPasswordRequired:
emit_signal("safebox_password_required"); emit_signal("safebox_password_required");
break; break;
+10
View File
@@ -161,6 +161,11 @@ public:
int get_safebox_gold() const; int get_safebox_gold() const;
godot::Array get_safebox_items() const; // [{cell, vnum, count}] godot::Array get_safebox_items() const; // [{cell, vnum, count}]
bool safebox_password(const godot::String &password); bool safebox_password(const godot::String &password);
// 40250 的仓库关闭/改密走 chat 命令(cmd.cpp do_safebox_close /
// do_safebox_change_password,均 GM_PLAYER),与 safebox_password 同通道。
bool safebox_change_password(const godot::String &old_password,
const godot::String &new_password);
bool safebox_close();
// --- item-mall (창고몰) --- // --- item-mall (창고몰) ---
bool is_mall_open() const; bool is_mall_open() const;
@@ -168,6 +173,7 @@ public:
godot::Array get_mall_items() const; // [{cell, vnum, count}] godot::Array get_mall_items() const; // [{cell, vnum, count}]
bool mall_checkout(int mall_pos, int inv_window, int inv_cell); bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
bool mall_password(const godot::String &password); bool mall_password(const godot::String &password);
bool mall_close(); // "/mall_close"cmd.cpp do_mall_close
// --- private (PC) shop --- // --- private (PC) shop ---
// items: Array of {vnum, count, inv_cell, price, display_pos} // items: Array of {vnum, count, inv_cell, price, display_pos}
@@ -193,6 +199,10 @@ public:
bool guild_remove_member(int pid); bool guild_remove_member(int pid);
bool guild_offer(int amount); bool guild_offer(int amount);
bool guild_charge_gsp(int amount); bool guild_charge_gsp(int amount);
// 40250 服务器把这两个子头禁用了(input_main.cpp 直接 return,死代码在下方),
// 但原版 ClientVS22 UI 仍照发(uiguild.py OnDeposit/OnWithdraw)——保持 1:1。
bool guild_deposit_money(int amount);
bool guild_withdraw_money(int amount);
bool guild_change_grade_name(int grade, const godot::String &name); bool guild_change_grade_name(int grade, const godot::String &name);
bool guild_change_grade_authority(int grade, int authority); bool guild_change_grade_authority(int grade, int authority);
bool guild_change_member_grade(int pid, int grade); bool guild_change_member_grade(int pid, int grade);
+4 -1
View File
@@ -400,7 +400,10 @@ func _build_char_list() -> void:
if not client.has_method("change_name") or not bool(client.call("change_name", idx, name)): if not client.has_method("change_name") or not bool(client.call("change_name", idx, name)):
if screen.has_method("on_char_name_send_failed"): if screen.has_method("on_char_name_send_failed"):
screen.on_char_name_send_failed()) screen.on_char_name_send_failed())
if client.has_signal("guild_marks_ready"): # 重连会重建选人界面再走一遍本函数;ONE_SHOT 只有在信号触发过才自动断开,
# 首次连接若还挂着会报 already connected——先查再连。
if client.has_signal("guild_marks_ready") \
and not client.guild_marks_ready.is_connected(_on_marks_ready_for_select):
client.guild_marks_ready.connect(_on_marks_ready_for_select, CONNECT_ONE_SHOT) client.guild_marks_ready.connect(_on_marks_ready_for_select, CONNECT_ONE_SHOT)
func _forward_char_evt(a = null, sig := "") -> void: func _forward_char_evt(a = null, sig := "") -> void:
+1
View File
@@ -0,0 +1 @@
uid://bohd5850be0ex
+130 -33
View File
@@ -1,13 +1,20 @@
# equip_rules.gd —— 装备穿戴合法性校验(纯函数校验器)。 # equip_rules.gd —— 装备穿戴合法性校验(纯函数校验器)。
# #
# 语义顺序对齐服务器 `char_item.cpp::CanEquipNow` # 语义顺序 1:1 对齐服务器真实链(已核对 40250 源码)
# antiflag(性别 / 职业 / 帝国) → wearable_flag 与目标 wear 槽匹配 → limit(等级 / 属性)。 # `char_item.cpp:7462 CanEquipNow`JOB antiflag → limit(LEVEL/STR/INT/DEX/CON)
# → WEARABLE_UNIQUE 同组去重(结婚戒指另需已婚,客户端不判)。
# `char_item.cpp:6100 EquipItem`(拿到 `item.cpp:472 FindEquipCell` 的槽位后):
# 燕尾服+骑乘禁止 → 变身中除 WEAR_ARROW 一律禁止 → `FN_check_item_sex`(性别)
# → 骑乘物品已骑乘禁止 → 攻击/施法后 1.5s 冷却(客户端不判)。
# **帝国 antiflag 在装备路径上从不检查**(只在掉落/商店显示层)——已移出装备门,
# 见 empire_warning(),供 tooltip 提示用。
# LIMIT_PCBANG / LIMIT_REAL_TIME* 不是装备门禁(item.cpp:1899 PCBANG 仅标记;
# REAL_TIME* 由 socket0/socket1 驱动到期,客户端职责只有 tooltip 倒计时)。
# #
# 参考枚举 1:1 自 `REF/GameLib/ItemData.h``EItemAntiFlag` / `EItemWearableFlag` / # 参考枚举 1:1 自 `REF/GameLib/ItemData.h``EItemAntiFlag` / `EItemWearableFlag` /
# `EWearPositions` / `ELimitTypes` / `EItemType` / `EArmorSubTypes` / `ECostumeSubTypes`)。 # `EWearPositions` / `ELimitTypes` / `EItemType` / `EArmorSubTypes` / `ECostumeSubTypes`)。
# 40250 客户端本身**不做** `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / # 40250 客户端本身**不做** `CanEquipNow` 前置门(真正的仲裁在服务器)。这里按 §3.9
# `GetLimit`,真正的仲裁在服务器)。这里按 §3.9 `__CanShot` 的方式在客户端防御式重建一道前置 # `__CanShot` 的方式在客户端防御式重建一道前置门,被拦下时给出与服务器一致的拒绝码。
# 门——精确的 tie-break 顺序、PCBANG / REAL_TIME 类 limit、已穿同槽替换等留待接服务器比对(seam)。
extends RefCounted extends RefCounted
# --- EItemAntiFlagItemData.hclassic 排列:帝国位在 1<<9..1<<11--- # --- EItemAntiFlagItemData.hclassic 排列:帝国位在 1<<9..1<<11---
@@ -65,10 +72,18 @@ const LIMIT_REAL_TIME := 7
# --- EItemType(相关项)+ 子类 --- # --- EItemType(相关项)+ 子类 ---
const ITEM_TYPE_WEAPON := 1 const ITEM_TYPE_WEAPON := 1
const ITEM_TYPE_ARMOR := 2 const ITEM_TYPE_ARMOR := 2
const ITEM_TYPE_UNIQUE := 16
const ITEM_TYPE_TOTEM := 26
const ITEM_TYPE_COSTUME := 28 const ITEM_TYPE_COSTUME := 28
const ITEM_TYPE_DS := 29
const ITEM_TYPE_SPECIAL_DS := 30
const ITEM_TYPE_RING := 33 const ITEM_TYPE_RING := 33
const ITEM_TYPE_BELT := 34 const ITEM_TYPE_BELT := 34
# --- EUniqueSubTypesitem_length.h:骑乘/坐骑物品,服务器 IsRideItem---
const UNIQUE_SPECIAL_RIDE := 2
const UNIQUE_SPECIAL_MOUNT_RIDE := 3
const ARMOR_BODY := 0 const ARMOR_BODY := 0
const ARMOR_HEAD := 1 const ARMOR_HEAD := 1
const ARMOR_SHIELD := 2 const ARMOR_SHIELD := 2
@@ -103,6 +118,8 @@ static func job_of_race(race: int) -> int:
# 物品默认落在哪个 wear 槽(type / sub_type 决定)。不可穿戴 → -1。 # 物品默认落在哪个 wear 槽(type / sub_type 决定)。不可穿戴 → -1。
# 戒指无穿戴表时默认 RING1;有穿戴表请用 find_equip_cellRING1 空则 1 否则 2
# 与服务器 item.cpp:472 一致)。
static func default_wear_for(item: Dictionary) -> int: static func default_wear_for(item: Dictionary) -> int:
var itype := int(item.get("type", -1)) var itype := int(item.get("type", -1))
var sub := int(item.get("sub_type", -1)) var sub := int(item.get("sub_type", -1))
@@ -163,47 +180,91 @@ static func slot_accepts(item: Dictionary, wear: int) -> bool:
return (int(item.get("wear_flags", 0)) & need) != 0 return (int(item.get("wear_flags", 0)) & need) != 0
# 服务器 `item.cpp:472 CItem::FindEquipCell` 的 1:1 复刻。
# worn : 当前穿戴表 {wear_cell: vnum}(客户端来自 get_equipment())。
# else-if 优先级:BODY→HEAD→FOOTS→WRIST→WEAPON→SHIELD→NECK→EAR→ARROW
# RING→RING1 空则 RING1 否则 RING2UNIQUE→UNIQUE1 空则 UNIQUE1 否则 UNIQUE2。
# 龙魂石(DS/SPECIAL_DS)服务器返回 WEAR_MAX_NUM+sub,但客户端走
# dragon_soul_ui 的专用协议路径,这里返回 -1 不进装备门。
static func find_equip_cell(item: Dictionary, worn: Dictionary = {}) -> int:
var itype := int(item.get("type", -1))
var sub := int(item.get("sub_type", -1))
var flags := int(item.get("wear_flags", 0))
if (flags == 0 or itype == ITEM_TYPE_TOTEM) and itype != ITEM_TYPE_COSTUME \
and itype != ITEM_TYPE_DS and itype != ITEM_TYPE_SPECIAL_DS \
and itype != ITEM_TYPE_RING and itype != ITEM_TYPE_BELT:
return -1
if itype == ITEM_TYPE_DS or itype == ITEM_TYPE_SPECIAL_DS:
return -1 # 见上:客户端 DS 专用路径
if itype == ITEM_TYPE_COSTUME:
return WEAR_COSTUME_HAIR if sub == COSTUME_HAIR else WEAR_COSTUME_BODY
if itype == ITEM_TYPE_RING:
return WEAR_RING2 if worn.has(WEAR_RING1) else WEAR_RING1
if itype == ITEM_TYPE_BELT:
return WEAR_BELT
if flags & WEARABLE_BODY:
return WEAR_BODY
if flags & WEARABLE_HEAD:
return WEAR_HEAD
if flags & WEARABLE_FOOTS:
return WEAR_FOOTS
if flags & WEARABLE_WRIST:
return WEAR_WRIST
if flags & WEARABLE_WEAPON:
return WEAR_WEAPON
if flags & WEARABLE_SHIELD:
return WEAR_SHIELD
if flags & WEARABLE_NECK:
return WEAR_NECK
if flags & WEARABLE_EAR:
return WEAR_EAR
if flags & WEARABLE_ARROW:
return WEAR_ARROW
if flags & WEARABLE_UNIQUE:
return WEAR_UNIQUE2 if worn.has(WEAR_UNIQUE1) else WEAR_UNIQUE1
return -1
# 骑乘 / 坐骑物品(服务器 `item.cpp:1695 IsRideItem`UNIQUE + RIDE / MOUNT_RIDE)。
static func is_ride_item(item: Dictionary) -> bool:
return int(item.get("type", -1)) == ITEM_TYPE_UNIQUE \
and (int(item.get("sub_type", -1)) == UNIQUE_SPECIAL_RIDE \
or int(item.get("sub_type", -1)) == UNIQUE_SPECIAL_MOUNT_RIDE)
# 帝国 antiflag:服务器装备路径不检查(显示层语义)。返回冲突的 anti 位(0 = 无冲突),
# tooltip 用它给出提示,不作为装备门。
static func empire_warning(item: Dictionary, empire: int) -> int:
var bit := int(EMPIRE_ANTIFLAG.get(empire, 0))
if bit == 0:
return 0
return int(item.get("anti_flags", 0)) & bit
static func _fail(code: String, need := 0) -> Dictionary: static func _fail(code: String, need := 0) -> Dictionary:
return {"ok": false, "code": code, "need": need} return {"ok": false, "code": code, "need": need}
# 主校验。 # 主校验(顺序 1:1 = 服务器 CanEquipNow → FindEquipCell → EquipItem 后置检查)
# item : proto 字典 —— {type, sub_type, anti_flags, wear_flags, limits:[{type,value}]} # item : proto 字典 —— {type, sub_type, anti_flags, wear_flags, limits:[{type,value}], vnum?}
# wear : 目标 EWearPositions(或新装备槽 19..23);传 -1 用 default_wear_for(item) # wear : 目标槽;传 -1 用 find_equip_cell(有 ctx.worn 时含 RING/UNIQUE 占用翻转)
# ctx : {race, sex, job, empire, level, st, dx, ht, iq} # ctx : {race, sex, job, empire, level, st, dx, ht, iq,
# —— sex/job 缺省时由 race 推;属性 st/dx/ht/iq 缺省或 < 0 = 不判(seam # worn: {cell: vnum}, # 当前穿戴(RING/UNIQUE 占用判断
# 返回 {ok: bool, code: String, need: int}。code 见下方常量注释;need = 失败 limit 的要求值。 # polymorphed: bool, # 变身中(POINT_POLYMORPH / affect 220
# riding: bool} # 骑乘中
# —— sex/job 缺省时由 race 推;属性 st/dx/ht/iq 缺省或 < 0 = 不判
# 返回 {ok: bool, code: String, need: int}。need = 失败 limit 的要求值。
static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionary: static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionary:
if item.is_empty(): if item.is_empty():
return _fail("NO_ITEM") return _fail("NO_ITEM")
var target := wear if wear >= 0 else default_wear_for(item)
if target < 0:
return _fail("NOT_EQUIPPABLE")
var anti := int(item.get("anti_flags", 0)) var anti := int(item.get("anti_flags", 0))
var race := int(ctx.get("race", 0)) var race := int(ctx.get("race", 0))
var sex := int(ctx.get("sex", sex_of_race(race))) var sex := int(ctx.get("sex", sex_of_race(race)))
var job := int(ctx.get("job", job_of_race(race))) var job := int(ctx.get("job", job_of_race(race)))
var empire := int(ctx.get("empire", 0)) var worn: Dictionary = ctx.get("worn", {})
# 1) anti-flag:性别 # ---- CanEquipNowchar_item.cpp:7462----
if sex == SEX_FEMALE and (anti & ANTIFLAG_FEMALE): # 1) anti-flag:职业(服务器只查 JOB;性别在拿到槽位后查,见 5))
return _fail("ANTI_SEX")
if sex == SEX_MALE and (anti & ANTIFLAG_MALE):
return _fail("ANTI_SEX")
# 2) anti-flag:职业
if job >= 0 and job < JOB_ANTIFLAG.size() and (anti & int(JOB_ANTIFLAG[job])): if job >= 0 and job < JOB_ANTIFLAG.size() and (anti & int(JOB_ANTIFLAG[job])):
return _fail("ANTI_JOB") return _fail("ANTI_JOB")
# 3) anti-flag:帝国 # 2) limit(等级 / 属性)—— 逐条 GetLimitPCBANG / REAL_TIME* 不是门禁(见文件头)
var emp_bit := int(EMPIRE_ANTIFLAG.get(empire, 0))
if emp_bit != 0 and (anti & emp_bit):
return _fail("ANTI_EMPIRE")
# 4) wearable_flag 与目标槽匹配
if not slot_accepts(item, target):
return _fail("NOT_WEARABLE_HERE")
# 5) limit(等级 / 属性)—— 逐条 GetLimit
for lim in item.get("limits", []): for lim in item.get("limits", []):
var t := int(lim.get("type", 0)) var t := int(lim.get("type", 0))
var v := int(lim.get("value", 0)) var v := int(lim.get("value", 0))
@@ -226,7 +287,43 @@ static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionar
if _stat(ctx, "ht") >= 0 and _stat(ctx, "ht") < v: if _stat(ctx, "ht") >= 0 and _stat(ctx, "ht") < v:
return _fail("LIMIT_CON", v) return _fail("LIMIT_CON", v)
_: _:
pass # PCBANG / REAL_TIME* → 服务器判(seam pass
# 3) WEARABLE_UNIQUE 同组去重:UNIQUE1/UNIQUE2 已有同组物品则拒绝。
# 服务器用 IsSameSpecialGroup;穿戴表(worn: cell→vnum)拿不到被占物品的
# proto,这里退化为同 vnum 判定——只拦下确定的重复,其余交服务器权威。
# 结婚戒指另需已婚,客户端不判。
if int(item.get("wear_flags", 0)) & WEARABLE_UNIQUE and item.get("vnum", 0) is int:
for cell in [WEAR_UNIQUE1, WEAR_UNIQUE2]:
if int(worn.get(cell, 0)) == int(item.get("vnum", 0)) \
and int(item.get("vnum", 0)) != 0:
return _fail("UNIQUE_DUP")
# ---- FindEquipCellitem.cpp:472----
var target := find_equip_cell(item, worn) if wear < 0 else wear
if target < 0:
return _fail("NOT_EQUIPPABLE")
# 显式指定槽时校验物品与槽匹配(服务器对显式 cell 不做这层校验,属客户端防御)。
if wear >= 0 and not slot_accepts(item, target):
return _fail("NOT_WEARABLE_HERE")
# ---- EquipItem 后置检查(char_item.cpp:6122+----
# 4) 燕尾服(vnum 11901..11904)骑乘中不可穿
if target == WEAR_BODY and bool(ctx.get("riding", false)):
var vnum := int(item.get("vnum", 0))
if vnum >= 11901 and vnum <= 11904:
return _fail("RIDE_TUXEDO")
# 5) 变身中除 WEAR_ARROW 一律禁止
if bool(ctx.get("polymorphed", false)) and target != WEAR_ARROW:
return _fail("POLYMORPH")
# 6) FN_check_item_sexchar_item.cpp:189
if sex == SEX_FEMALE and (anti & ANTIFLAG_FEMALE):
return _fail("ANTI_SEX")
if sex == SEX_MALE and (anti & ANTIFLAG_MALE):
return _fail("ANTI_SEX")
# 7) 骑乘物品已骑乘禁止(item.cpp:1695 IsRideItem
if bool(ctx.get("riding", false)) and is_ride_item(item):
return _fail("RIDE_ITEM")
# 攻击/施法后 1.5s 冷却(char_item.cpp:6143)客户端不判,交服务器。
return {"ok": true, "code": "", "need": 0} return {"ok": true, "code": "", "need": 0}
+67 -11
View File
@@ -1,4 +1,6 @@
# equip_rules_test —— §4.1 修改 2装备穿戴合法性校验(EquipRules)1:1 自检。 # equip_rules_test —— 装备穿戴合法性校验(EquipRules)1:1 自检。
# 顺序对齐服务器真实链:CanEquipNow(JOB→limit→UNIQUE 去重) → FindEquipCell →
# EquipItem 后置(燕尾服/变身/性别/骑乘)。
# canonical #17。 godot --headless --path project --script equip_rules_test.gd # canonical #17。 godot --headless --path project --script equip_rules_test.gd
extends SceneTree extends SceneTree
@@ -24,8 +26,10 @@ func _init() -> void:
_test_wearable() _test_wearable()
_test_limits() _test_limits()
_test_order_and_newslots() _test_order_and_newslots()
_test_find_equip_cell()
_test_post_checks()
if failed == 0: if failed == 0:
print("PASS: equip_rules_test (§4.1 CanEquipNow antiflag→wearable→limit 1:1)") print("PASS: equip_rules_test (CanEquipNow→FindEquipCell→EquipItem 后置 1:1)")
quit(0) quit(0)
else: else:
quit(1) quit(1)
@@ -78,10 +82,13 @@ func _test_antiflag() -> void:
_ck(EquipRules.can_equip(no_warrior, -1, {"race": 0})["code"] == "ANTI_JOB", "ANTI_WARRIOR blocks warrior (race 0)") _ck(EquipRules.can_equip(no_warrior, -1, {"race": 0})["code"] == "ANTI_JOB", "ANTI_WARRIOR blocks warrior (race 0)")
_ck(EquipRules.can_equip(no_warrior, -1, {"race": 1})["ok"], "ANTI_WARRIOR ok for assassin (race 1)") _ck(EquipRules.can_equip(no_warrior, -1, {"race": 1})["ok"], "ANTI_WARRIOR ok for assassin (race 1)")
# 帝国 antiflag 不是装备门(服务器装备路径从不检查)——can_equip 放行,
# 冲突位只由 empire_warning() 给 tooltip。
var no_emp_a := _weapon({"anti_flags": EquipRules.ANTIFLAG_EMPIRE_A}) var no_emp_a := _weapon({"anti_flags": EquipRules.ANTIFLAG_EMPIRE_A})
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 1})["code"] == "ANTI_EMPIRE", "ANTI_EMPIRE_A blocks empire 1") _ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 1})["ok"], "ANTI_EMPIRE no longer blocks equip")
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 2})["ok"], "ANTI_EMPIRE_A ok for empire 2") _ck(EquipRules.empire_warning(no_emp_a, 1) == EquipRules.ANTIFLAG_EMPIRE_A, "empire_warning reports conflict")
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 0})["ok"], "no empire -> anti empire skipped") _ck(EquipRules.empire_warning(no_emp_a, 2) == 0, "empire_warning clean for empire 2")
_ck(EquipRules.empire_warning(no_emp_a, 0) == 0, "empire_warning clean without empire")
func _test_wearable() -> void: func _test_wearable() -> void:
var res := EquipRules.can_equip(_weapon(), EquipRules.WEAR_BODY, {"race": 0}) var res := EquipRules.can_equip(_weapon(), EquipRules.WEAR_BODY, {"race": 0})
@@ -109,16 +116,22 @@ func _test_limits() -> void:
_ck(EquipRules.can_equip(zero, -1, {"race": 0, "level": 1})["ok"], "limit value 0 ignored") _ck(EquipRules.can_equip(zero, -1, {"race": 0, "level": 1})["ok"], "limit value 0 ignored")
func _test_order_and_newslots() -> void: func _test_order_and_newslots() -> void:
# anti-flag 先于 limit:两个都失败 → 返回 ANTI_SEX # limit 先于性别(服务器顺序:CanEquipNow 的 limit 在 EquipItem 的
# FN_check_item_sex 之前):两个都失败 → 返回 LIMIT_LEVEL
var both := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE, var both := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE,
"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) "limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]})
_ck(EquipRules.can_equip(both, -1, {"race": 1, "level": 1})["code"] == "ANTI_SEX", _ck(EquipRules.can_equip(both, -1, {"race": 1, "level": 1})["code"] == "LIMIT_LEVEL",
"antiflag checked before limit") "limit checked before sex antiflag (server order)")
# JOB 仍是最先的 antiflag 检查
var job_first := _weapon({"anti_flags": EquipRules.ANTIFLAG_WARRIOR,
"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]})
_ck(EquipRules.can_equip(job_first, -1, {"race": 0, "level": 1})["code"] == "ANTI_JOB",
"job antiflag checked before limit")
# wearable 先于 limit # 显式槽不匹配(客户端防御层)在 limit 之后
var wl := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) var wl := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]})
_ck(EquipRules.can_equip(wl, EquipRules.WEAR_BODY, {"race": 0, "level": 1})["code"] == "NOT_WEARABLE_HERE", _ck(EquipRules.can_equip(wl, EquipRules.WEAR_BODY, {"race": 0, "level": 99})["code"] == "NOT_WEARABLE_HERE",
"wearable checked before limit") "explicit slot mismatch -> NOT_WEARABLE_HERE")
# 新装备槽:ring / belt / costume 按 type 判定,无 wear_flags 也能穿 # 新装备槽:ring / belt / costume 按 type 判定,无 wear_flags 也能穿
_ck(EquipRules.can_equip({"type": 33}, EquipRules.WEAR_RING1, {"race": 0})["ok"], "ring at RING1 ok") _ck(EquipRules.can_equip({"type": 33}, EquipRules.WEAR_RING1, {"race": 0})["ok"], "ring at RING1 ok")
@@ -129,3 +142,46 @@ func _test_order_and_newslots() -> void:
# 空物品 # 空物品
_ck(EquipRules.can_equip({}, -1, {})["code"] == "NO_ITEM", "empty item -> NO_ITEM") _ck(EquipRules.can_equip({}, -1, {})["code"] == "NO_ITEM", "empty item -> NO_ITEM")
func _test_find_equip_cell() -> void:
# item.cpp:472 的 else-if 优先级与 RING/UNIQUE 占用翻转。
_ck(EquipRules.find_equip_cell(_weapon()) == EquipRules.WEAR_WEAPON, "weapon cell")
_ck(EquipRules.find_equip_cell(_weapon({"wear_flags": (1 << 0) | (1 << 4)})) == EquipRules.WEAR_BODY,
"BODY wins over WEAPON in else-if chain")
_ck(EquipRules.find_equip_cell({"type": 33}) == EquipRules.WEAR_RING1, "ring -> RING1 when free")
_ck(EquipRules.find_equip_cell({"type": 33}, {EquipRules.WEAR_RING1: 1234}) == EquipRules.WEAR_RING2,
"ring -> RING2 when RING1 occupied")
var uniq := {"type": 2, "wear_flags": 1 << 7, "vnum": 70001}
_ck(EquipRules.find_equip_cell(uniq) == EquipRules.WEAR_UNIQUE1, "unique -> UNIQUE1 when free")
_ck(EquipRules.find_equip_cell(uniq, {EquipRules.WEAR_UNIQUE1: 70002}) == EquipRules.WEAR_UNIQUE2,
"unique -> UNIQUE2 when UNIQUE1 occupied")
# 无 wear_flag 且非 COSTUME/DS/RING/BELT → -1TOTEM 同)
_ck(EquipRules.find_equip_cell({"type": 3}) == -1, "no wear flag -> -1")
_ck(EquipRules.find_equip_cell({"type": 26, "wear_flags": 1 << 0}) == -1, "totem -> -1")
# 龙魂石走客户端专用路径
_ck(EquipRules.find_equip_cell({"type": 29, "sub_type": 3}) == -1, "DS -> -1 (dedicated path)")
func _test_post_checks() -> void:
# EquipItem 后置门(char_item.cpp:6122+)。
# 变身中除 ARROW 外一律拒绝
var bow := _weapon({"wear_flags": 1 << 9})
_ck(EquipRules.can_equip(_weapon(), -1, {"race": 0, "polymorphed": true})["code"] == "POLYMORPH",
"polymorphed blocks weapon")
_ck(EquipRules.can_equip(bow, -1, {"race": 0, "polymorphed": true})["ok"],
"polymorphed allows ARROW")
# 燕尾服骑乘禁止(vnum 11901..11904
var tux := {"type": 2, "sub_type": 0, "wear_flags": 1 << 0, "vnum": 11901}
_ck(EquipRules.can_equip(tux, -1, {"race": 0, "riding": true})["code"] == "RIDE_TUXEDO",
"tuxedo blocked while riding")
_ck(EquipRules.can_equip(tux, -1, {"race": 0, "riding": false})["ok"], "tuxedo ok on foot")
# 骑乘物品已骑乘禁止(UNIQUE + RIDE/MOUNT_RIDE 子类)
var horse := {"type": 16, "sub_type": 2, "wear_flags": 1 << 7, "vnum": 50051}
_ck(EquipRules.can_equip(horse, -1, {"race": 0, "riding": true})["code"] == "RIDE_ITEM",
"ride item blocked while riding")
_ck(EquipRules.can_equip(horse, -1, {"race": 0, "riding": false})["ok"], "ride item ok on foot")
# UNIQUE 同 vnum 去重
var u := {"type": 2, "wear_flags": 1 << 7, "vnum": 70001}
_ck(EquipRules.can_equip(u, -1, {"race": 0, "worn": {EquipRules.WEAR_UNIQUE1: 70001}})["code"] == "UNIQUE_DUP",
"same vnum in UNIQUE1 -> UNIQUE_DUP")
_ck(EquipRules.can_equip(u, -1, {"race": 0, "worn": {EquipRules.WEAR_UNIQUE1: 70002}})["ok"],
"different vnum in UNIQUE1 ok")
+9 -2
View File
@@ -311,7 +311,14 @@ func snap_to_target() -> void:
global_position = _desired_pos(true) global_position = _desired_pos(true)
else: else:
global_position = _desired_pos(true) global_position = _desired_pos(true)
look_at(_look_target(), Vector3.UP) _look_at_safe()
# 相机位置与注视点重合时(首帧、传送落点、极近距离)方向向量为零,look_at() 会
# 报错并且不改变朝向。跳过这一帧,下一帧插值分开后自然恢复。
func _look_at_safe() -> void:
var look_to := _look_target()
if not global_position.is_equal_approx(look_to):
look_at(look_to, Vector3.UP)
var _shake := 0.0 # 当前抖动强度(米),指数衰减 var _shake := 0.0 # 当前抖动强度(米),指数衰减
var _shake_decay := 8.0 var _shake_decay := 8.0
@@ -350,7 +357,7 @@ func _process(dt: float) -> void:
_shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0)) _shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0))
else: else:
_shake = 0.0 _shake = 0.0
look_at(_look_target(), Vector3.UP) _look_at_safe()
if camera_mode == CameraMode.NORMAL: if camera_mode == CameraMode.NORMAL:
_fade_occluders() _fade_occluders()
+32 -13
View File
@@ -182,19 +182,8 @@ func setup(m2client: Node, assets_root: String,
if net_world.has_method("set_camera"): if net_world.has_method("set_camera"):
net_world.set_camera(cam) net_world.set_camera(cam)
net_world.set_local_node(player) net_world.set_local_node(player)
net_world.entity_added.connect(func(node: Node3D, _vid: int): net_world.entity_added.connect(_on_pickable_added)
# Model-factory upgrades replace an earlier placeholder node in place. net_world.entity_removed.connect(_on_pickable_removed)
# Drop queued-for-free entries before registering the rebuilt pick target.
for old in pc.pickables.duplicate():
if old == null or not is_instance_valid(old):
pc.pickables.erase(old)
if node not in pc.pickables:
pc.pickables.append(node))
net_world.entity_removed.connect(func(vid: int):
for pickable in pc.pickables.duplicate():
if pickable == null or not is_instance_valid(pickable) \
or int(pickable.get_meta("vid", -1)) == vid:
pc.pickables.erase(pickable))
if _ui_mobile: if _ui_mobile:
hud = MobileUiRoot.new() hud = MobileUiRoot.new()
@@ -611,6 +600,36 @@ func _setup_mobile_ui() -> void:
}) })
set_process(true) set_process(true)
# 可点选实体列表维护。pickables 是 Array[Node3D]TypedArray),对已释放的实例
# 调用 erase()/has() 会被类型校验拒绝并报错,所以一律按下标倒序 remove_at()。
func _drop_stale_pickables(drop_vid: int = -1) -> void:
if pc == null:
return
var list: Array = pc.pickables
for i in range(list.size() - 1, -1, -1):
# 不要把元素赋给 Node3D 类型的变量:已释放的实例会在类型校验时再次报错。
var n = list[i]
if n == null or not is_instance_valid(n):
list.remove_at(i)
elif n.is_queued_for_deletion():
list.remove_at(i)
elif drop_vid >= 0 and int(n.get_meta("vid", -1)) == drop_vid:
list.remove_at(i)
# 模型工厂升级会原地替换先前的占位节点,注册新的点选目标前先清掉待删条目。
func _on_pickable_added(node: Node3D, _vid: int) -> void:
if pc == null or node == null:
return
_drop_stale_pickables()
var list: Array = pc.pickables
for existing in list:
if existing == node:
return
list.append(node)
func _on_pickable_removed(vid: int) -> void:
_drop_stale_pickables(vid)
func _lifecycle_node() -> Node: func _lifecycle_node() -> Node:
# AppFlow owns the single lifecycle observer across login/select/game. The # AppFlow owns the single lifecycle observer across login/select/game. The
# game scene only borrows it for Android back and focus recovery. # game scene only borrows it for Android back and focus recovery.
+13 -3
View File
@@ -17,6 +17,9 @@ class FakeClient extends Node:
func get_guild() -> Dictionary: return guild func get_guild() -> Dictionary: return guild
func get_guild_members() -> Array: return members func get_guild_members() -> Array: return members
func get_guild_grades() -> Array: return grades func get_guild_grades() -> Array: return grades
func get_main_pid() -> int: return 2 # Newbiegrade 3auth 0 → 无踢人权)
func guild_deposit_money(v: int) -> bool: calls.append(["deposit", v]); return true
func guild_withdraw_money(v: int) -> bool: calls.append(["withdraw", v]); return true
func refine(pos, type) -> bool: calls.append(["refine", pos, type]); return true func refine(pos, type) -> bool: calls.append(["refine", pos, type]); return true
var _fail := 0 var _fail := 0
@@ -56,10 +59,17 @@ func _run() -> void:
] ]
fc.guild_changed.emit() fc.guild_changed.emit()
_ck(gu._info.text.contains("Dragons") and gu._info.text.contains("Lv.12"), "公会信息行") _ck(gu._info.text.contains("Dragons") and gu._info.text.contains("Lv.12"), "公会信息行")
_ck(gu._pages[0].get_child_count() == 2, "2 成员行") # 成员页 = 1 资金行 + 2 成员行(成员行是 HBoxLabel [+ 踢出按钮]
var rows: Array = gu._pages[0].get_children()
_ck(rows.size() == 3, "资金行 + 2 个成员行")
_ck(String((rows[0].get_child(0) as Label).text).contains("500000"), "资金行显示公会资金")
var member_labels: Array[String] = []
for r in rows.slice(1):
member_labels.append(String((r.get_child(0) as Label).text))
_ck(r.get_child_count() == 1, "无 REMOVE_MEMBER 权限 -> 无踢出按钮")
# 会长排前(grade 1 < grade 3 # 会长排前(grade 1 < grade 3
_ck(String((gu._pages[0].get_child(0) as Label).text).begins_with("Master"), "按 grade 排序") _ck(member_labels[0].begins_with("Master"), "按 grade 排序")
_ck(String((gu._pages[0].get_child(0) as Label).text).contains("[会长]"), "grade 名解析") _ck(member_labels[0].contains("[会长]"), "grade 名解析")
# --- 精炼对话框 --- # --- 精炼对话框 ---
var ru: Node = RefineUI.new() var ru: Node = RefineUI.new()
+17 -2
View File
@@ -32,13 +32,28 @@ func _run() -> void:
get_root().add_child(love) get_root().add_child(love)
love.setup(client, canvas) love.setup(client, canvas)
_check(not love._root.visible, "no lover keeps panel hidden") _check(not love._root.visible, "no lover keeps panel hidden")
client.lover = {"valid": true, "name": "Partner", "love_point": 63} client.lover = {"valid": true, "name": "Partner", "love_point": 63,
"online": false, "near": false}
client.lover_changed.emit(client.lover) client.lover_changed.emit(client.lover)
await process_frame await process_frame
_check(love._root.visible, "lover info shows panel") _check(love._root.visible, "lover info shows panel")
_check(love._name.text == "♥ Partner", "lover name rendered") _check(love._name.text == "♥ Partner(离线)", "lover name + offline state rendered")
_check(int(love._bar.value) == 63, "lover point rendered") _check(int(love._bar.value) == 63, "lover point rendered")
client.lover["love_point"] = 88 client.lover["love_point"] = 88
client.lover_changed.emit(client.lover) client.lover_changed.emit(client.lover)
await process_frame await process_frame
_check(int(love._bar.value) == 88, "love point update refreshes bar") _check(int(love._bar.value) == 88, "love point update refreshes bar")
# lover_login / lover_nearCHAT_TYPE_COMMANDmarriage.cpp)→ 状态后缀。
client.lover["online"] = true
client.lover_changed.emit(client.lover)
await process_frame
_check(love._name.text == "♥ Partner(在线)", "lover_online state rendered")
client.lover["near"] = true
client.lover_changed.emit(client.lover)
await process_frame
_check(love._name.text == "♥ Partner(在身边)", "lover_near state rendered")
# lover_divorce → store 清空 LoverInfo → 面板隐藏。
client.lover = {"valid": false, "name": "", "love_point": 0}
client.lover_changed.emit(client.lover)
await process_frame
_check(not love._root.visible, "lover divorce hides panel")
+50 -1
View File
@@ -40,6 +40,15 @@ var _accept_pending := false
var _anchor := Vector2.ZERO var _anchor := Vector2.ZERO
var _anchor_valid := false var _anchor_valid := false
# EXCHANGE_GC_ALREADY / LESS_GOLD 的线上的子头值(服务器 exchange.cpp)。
# GC_END 在成功与取消两种结局都会发,成败文案只走 CHAT_TYPE_INFO 聊天通道。
const NOTICE_ALREADY := 6
const NOTICE_LESS_GOLD := 7
var _was_active := false
var _notice_shown := false
var _end_note: Label
var _end_note_timer: Timer
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client client = m2client
proto = proto_node proto = proto_node
@@ -123,6 +132,11 @@ func refresh() -> void:
return return
var x: Dictionary = client.get_exchange() var x: Dictionary = client.get_exchange()
if not x.get("active", false): if not x.get("active", false):
if _was_active:
# 窗口开着时服务器关掉了交易(成功或取消,二者同发 GC_END)。
_show_end_note(int(x.get("notice", 0)))
_was_active = false
_notice_shown = false
_root.visible = false _root.visible = false
_next_display = 0 _next_display = 0
_offered_cells.clear() _offered_cells.clear()
@@ -131,6 +145,7 @@ func refresh() -> void:
_gold_input.text = "" _gold_input.text = ""
_status.text = "" _status.text = ""
return return
_was_active = true
_root.visible = true _root.visible = true
_capture_anchor() _capture_anchor()
_render_self_grid(x.get("self_items", [])) _render_self_grid(x.get("self_items", []))
@@ -147,9 +162,43 @@ func refresh() -> void:
_accept_btn.disabled = me _accept_btn.disabled = me
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1) _accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受" _root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
_status.text = "" var notice := int(x.get("notice", 0))
if notice != 0 and not _notice_shown:
_notice_shown = true
_status.text = _notice_text(notice)
else:
_status.text = ""
_refresh_mobile_inventory() _refresh_mobile_inventory()
func _notice_text(code: int) -> String:
match code:
NOTICE_ALREADY:
return "对方已经在交易中"
NOTICE_LESS_GOLD:
return "金币不足"
return ""
# 交易被服务器关闭时的一条短暂浮动提示(成败详情以 CHAT_TYPE_INFO 聊天为准)。
func _show_end_note(code: int) -> void:
if _root == null or _root.get_parent() == null:
return
if _end_note == null:
_end_note = Label.new()
_end_note.add_theme_font_size_override("font_size", 15)
_end_note.set_anchors_preset(Control.PRESET_CENTER_TOP)
_end_note.position = Vector2(-110, 130)
_end_note.size = Vector2(220, 24)
_end_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_root.get_parent().add_child(_end_note)
_end_note_timer = Timer.new()
_end_note_timer.one_shot = true
_end_note_timer.timeout.connect(func(): if _end_note: _end_note.visible = false)
add_child(_end_note_timer)
var extra := _notice_text(code)
_end_note.text = ("交易结束(%s" % extra) if extra != "" else "交易结束,结果见聊天提示"
_end_note.visible = true
_end_note_timer.start(5.0)
func _apply_mobile_layout() -> void: func _apply_mobile_layout() -> void:
if _root == null: if _root == null:
return return
+92 -4
View File
@@ -29,6 +29,11 @@ func setup(m2client: Node, parent: Node) -> void:
client.friends_changed.connect(refresh) client.friends_changed.connect(refresh)
if client.has_signal("friend_invite_ask"): if client.has_signal("friend_invite_ask"):
client.friend_invite_ask.connect(_on_friend_invite) client.friend_invite_ask.connect(_on_friend_invite)
# 原版 messenger 是三固定分组(uimessenger.py MessengerFriendGroup/GuildGroup/
# FamilyGroup);公会与情侣组数据变化时同样刷新。
for sig in ["guild_changed", "lover_changed"]:
if client.has_signal(sig):
client.connect(sig, refresh)
func is_open() -> bool: func is_open() -> bool:
return _root != null and _root.visible return _root != null and _root.visible
@@ -58,15 +63,98 @@ func refresh() -> void:
return return
for c in _list.get_children(): for c in _list.get_children():
c.queue_free() c.queue_free()
var friends: Array = client.get_friends() # 三固定分组(对齐原版 MessengerWindow):好友 / 公会 / 情侣。
_section("好友")
var friends: Array = client.get_friends() if client.has_method("get_friends") else []
if friends.is_empty(): if friends.is_empty():
var e := Label.new() var e := Label.new()
e.text = "(好友列表为空)" e.text = "(好友列表为空)"
e.add_theme_font_size_override("font_size", 11) e.add_theme_font_size_override("font_size", 11)
e.modulate = Color(0.6, 0.6, 0.6)
_list.add_child(e) _list.add_child(e)
return else:
for f in friends: for f in friends:
_list.add_child(_row(f)) _list.add_child(_row(f))
_section("公会")
if client.has_method("get_guild") and client.get_guild().get("in_guild", false) \
and client.has_method("get_guild_members"):
var members: Array = client.get_guild_members()
members.sort_custom(func(a, b):
return int(a.get("grade", 99)) < int(b.get("grade", 99)))
for m in members:
_list.add_child(_guild_row(m))
else:
var g := Label.new()
g.text = "(未加入公会)"
g.add_theme_font_size_override("font_size", 11)
g.modulate = Color(0.6, 0.6, 0.6)
_list.add_child(g)
_section("情侣")
if client.has_method("get_lover") and client.get_lover().get("valid", false):
_list.add_child(_lover_row(client.get_lover()))
else:
var l := Label.new()
l.text = "(暂无)"
l.add_theme_font_size_override("font_size", 11)
l.modulate = Color(0.6, 0.6, 0.6)
_list.add_child(l)
func _section(title: String) -> void:
var h := Label.new()
h.text = "%s" % title
h.add_theme_font_size_override("font_size", 12)
h.modulate = Color(0.75, 0.82, 0.95)
_list.add_child(h)
# 公会组成员:在线态来自 GC_GUILD_LOGIN/LOGOUT;点按同样发起私聊。
func _guild_row(m: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0)
var dot := Label.new()
dot.text = ""
dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO
dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
dot.modulate = Color(0.4, 0.9, 0.4) if m.get("online", false) else Color(0.4, 0.4, 0.4)
row.add_child(dot)
var nm := Button.new()
nm.text = String(m.get("name", "?"))
nm.flat = true
nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0)
nm.alignment = HORIZONTAL_ALIGNMENT_LEFT
nm.pressed.connect(func() -> void: whisper_to.emit(String(m.get("name", ""))))
row.add_child(nm)
if int(m.get("grade", 1)) == 0:
var lead := Label.new()
lead.text = "会长"
lead.modulate = Color(0.95, 0.8, 0.4)
lead.add_theme_font_size_override("font_size", 10)
row.add_child(lead)
return row
# 情侣组成员:lover_login/logout/near/farCHAT_TYPE_COMMANDmarriage.cpp)。
func _lover_row(lover: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0)
var dot := Label.new()
dot.text = ""
dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO
dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
dot.modulate = Color(0.95, 0.4, 0.55) if lover.get("online", false) else Color(0.4, 0.4, 0.4)
row.add_child(dot)
var nm := Button.new()
nm.text = String(lover.get("name", "?"))
nm.flat = true
nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0)
nm.alignment = HORIZONTAL_ALIGNMENT_LEFT
nm.pressed.connect(func() -> void: whisper_to.emit(String(lover.get("name", ""))))
row.add_child(nm)
if lover.get("near", false):
var near := Label.new()
near.text = "在身边"
near.modulate = Color(1.0, 0.6, 0.7)
near.add_theme_font_size_override("font_size", 10)
row.add_child(near)
return row
func _row(f: Dictionary) -> Control: func _row(f: Dictionary) -> Control:
var row := HBoxContainer.new() var row := HBoxContainer.new()
+159 -4
View File
@@ -34,6 +34,13 @@ var _create_dialog: ConfirmationDialog
var _create_name_edit: LineEdit var _create_name_edit: LineEdit
var _create_status: Label var _create_status: Label
var _mobile_mode := false var _mobile_mode := false
var _war_ask_dialog: ConfirmationDialog
# GUILD_AUTH_*(服务器 guild.h:94-97):等级权限位。会长(grade 0)恒全权。
const AUTH_ADD_MEMBER := 1 << 0
const AUTH_REMOVE_MEMBER := 1 << 1
const AUTH_NOTICE := 1 << 2
const AUTH_USE_SKILL := 1 << 3
const MOBILE_SIZE := Vector2(700, 340) const MOBILE_SIZE := Vector2(700, 340)
@@ -51,6 +58,10 @@ func setup(m2client: Node, parent: Node, table: Object = null) -> void:
client.guild_marks_ready.connect(func(_n): refresh()) client.guild_marks_ready.connect(func(_n): refresh())
if client.has_signal("guild_mark_uploaded"): if client.has_signal("guild_mark_uploaded"):
client.guild_mark_uploaded.connect(_on_mark_uploaded) client.guild_mark_uploaded.connect(_on_mark_uploaded)
# GUILD_WAR_RECV_DECLARE(3) 时弹被宣战应答框(原版 AcceptGuildWarDialog
# 接受发 /war <name>,拒绝发 /nowar <name>,均走 chat 通道)。
if client.has_signal("guild_war_event"):
client.guild_war_event.connect(_on_guild_war_event)
func is_open() -> bool: func is_open() -> bool:
return _root != null and _root.visible return _root != null and _root.visible
@@ -102,6 +113,70 @@ func refresh() -> void:
2: _fill_war(int(g.get("id", 0))) 2: _fill_war(int(g.get("id", 0)))
3: _fill_comments() 3: _fill_comments()
# 我的等级权限位(会长 grade 0 恒 0xF;否则取 grades[grade-1].auth)。
# 成员/等级数据未同步(成员表为空、找不到自己)时**返回全权**——权限只是 UI 预判,
# 不可判定时不拦操作,由服务器权威兜底(与原版一致:原版也不在前端做这层门)。
const AUTH_ALL := AUTH_ADD_MEMBER | AUTH_REMOVE_MEMBER | AUTH_NOTICE | AUTH_USE_SKILL
func _my_auth() -> int:
if client == null or not client.has_method("get_guild_grades") \
or not client.has_method("get_main_pid") \
or not client.has_method("get_guild_members"):
return AUTH_ALL
var my_pid := int(client.get_main_pid())
var my_grade := -1
for m in client.get_guild_members():
if int(m.get("pid", 0)) == my_pid:
my_grade = int(m.get("grade", -1))
break
if my_grade == -1:
return AUTH_ALL # 成员表里还没有自己(未同步)
if my_grade == 0:
return AUTH_ALL # GUILD_LEADER_GRADE
var grades: Array = client.get_guild_grades()
if my_grade >= 1 and my_grade <= grades.size():
return int(grades[my_grade - 1].get("auth", 0))
return AUTH_ALL
# 被宣战应答(对齐原版 __GuildWar_OpenAskDialog / OnAccept / OnDecline)。
func _on_guild_war_event(state: int, opp_guild_id: int, opp_name: String) -> void:
if state != 3 or opp_guild_id == 0: # GUILD_WAR_RECV_DECLARE
return
if is_instance_valid(_war_ask_dialog):
_war_ask_dialog.queue_free()
_war_ask_dialog = ConfirmationDialog.new()
_war_ask_dialog.title = "公会战邀请"
var shown := opp_name if opp_name != "" else "#%d" % opp_guild_id
_war_ask_dialog.dialog_text = "公会 [%s] 向我方宣战,接受吗?" % shown
_war_ask_dialog.ok_button_text = "接受"
_war_ask_dialog.cancel_button_text = "拒绝"
_war_ask_dialog.confirmed.connect(func() -> void:
if client.has_method("declare_guild_war"):
client.declare_guild_war(opp_name) # /war <name>
_war_ask_dialog.queue_free()
_war_ask_dialog = null)
_war_ask_dialog.canceled.connect(func() -> void:
if client.has_method("say") and opp_name != "":
client.say(0, "/nowar " + opp_name) # 原版 __GuildWar_OnDecline
_war_ask_dialog.queue_free()
_war_ask_dialog = null)
if _root and _root.get_parent():
_root.get_parent().add_child(_war_ask_dialog)
if _mobile_mode:
_war_ask_dialog.set_meta("mobile_modal", true)
_war_ask_dialog.set_meta("mobile_title", "公会战邀请")
_war_ask_dialog.popup_centered()
func _my_grade_is_leader() -> bool:
if client == null or not client.has_method("get_main_pid") \
or not client.has_method("get_guild_members"):
return false
var my_pid := int(client.get_main_pid())
for m in client.get_guild_members():
if int(m.get("pid", 0)) == my_pid:
return int(m.get("grade", -1)) == 0 # GUILD_LEADER_GRADE
return false
func _fill_members() -> void: func _fill_members() -> void:
var page := _pages[0] var page := _pages[0]
for c in page.get_children(): for c in page.get_children():
@@ -125,14 +200,58 @@ func _fill_members() -> void:
var grades: Array = client.get_guild_grades() var grades: Array = client.get_guild_grades()
var members: Array = client.get_guild_members() var members: Array = client.get_guild_members()
# 公会资金存取(GUILD_CG_DEPOSIT/WITHDRAW_MONEY = 13/14)。40250 服务器把
# 两个处理分支都禁用了(input_main.cpp 直接 return),但原版 uiguild.py 的
# OnDeposit/OnWithdraw 仍照发——保持 1:1,结果以服务器回执为准。
if client.has_method("guild_deposit_money"):
var money := HBoxContainer.new()
money.add_theme_constant_override("separation", 6)
page.add_child(money)
var ginfo: Dictionary = client.get_guild()
var mlabel := Label.new()
mlabel.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
mlabel.custom_minimum_size = Vector2(300, 40) if _mobile_mode else Vector2(160, 0)
mlabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
mlabel.text = "公会资金: %d" % int(ginfo.get("gold", 0))
money.add_child(mlabel)
var amount := LineEdit.new()
amount.placeholder_text = "金额"
amount.max_length = 9
amount.custom_minimum_size = Vector2(160, 40) if _mobile_mode else Vector2(96, 26)
money.add_child(amount)
var dep := Button.new()
dep.text = "存入"
dep.tooltip_text = "40250 服务器已禁用公会存款"
dep.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26)
dep.pressed.connect(func():
var v := int(amount.text)
if v > 0:
client.guild_deposit_money(v))
money.add_child(dep)
var wdr := Button.new()
wdr.text = "取出"
wdr.tooltip_text = "40250 服务器已禁用公会取款"
wdr.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 26)
wdr.pressed.connect(func():
var v := int(amount.text)
if v > 0:
client.guild_withdraw_money(v))
money.add_child(wdr)
var can_kick := (_my_auth() & AUTH_REMOVE_MEMBER) != 0
var my_pid := int(client.get_main_pid()) if client.has_method("get_main_pid") else 0
members.sort_custom(func(a, b): return int(a.get("grade", 99)) < int(b.get("grade", 99))) members.sort_custom(func(a, b): return int(a.get("grade", 99)) < int(b.get("grade", 99)))
for m in members: for m in members:
var gi := int(m.get("grade", 0)) var gi := int(m.get("grade", 0))
var gname := "" var gname := ""
if gi >= 1 and gi <= grades.size(): if gi >= 1 and gi <= grades.size():
gname = String(grades[gi - 1].get("name", "")) gname = String(grades[gi - 1].get("name", ""))
var mrow := HBoxContainer.new()
mrow.add_theme_constant_override("separation", 6)
page.add_child(mrow)
var row := Label.new() var row := Label.new()
row.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO row.custom_minimum_size = Vector2(560, 34) if _mobile_mode else Vector2.ZERO
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12) row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
row.text = "%s Lv.%d [%s] 贡献 %d%s" % [ row.text = "%s Lv.%d [%s] 贡献 %d%s" % [
@@ -140,7 +259,17 @@ func _fill_members() -> void:
gname if gname != "" else str(gi), gname if gname != "" else str(gi),
int(m.get("offer", 0)), int(m.get("offer", 0)),
(" 在线" if m.get("online", false) else " 离线") + ("" if m.get("general", false) else "")] (" 在线" if m.get("online", false) else " 离线") + ("" if m.get("general", false) else "")]
page.add_child(row) mrow.add_child(row)
# 成员管理:REMOVE_MEMBER 权限位可见的踢出按钮(自己与会长不可踢,服务器兜底)。
var pid := int(m.get("pid", 0))
if can_kick and pid != 0 and pid != my_pid and gi != 0:
var kick := Button.new()
kick.text = "踢出"
kick.custom_minimum_size = Vector2(72, 34) if _mobile_mode else Vector2(44, 24)
kick.pressed.connect(func():
if client.has_method("guild_remove_member"):
client.guild_remove_member(pid))
mrow.add_child(kick)
func _fill_comments() -> void: func _fill_comments() -> void:
var page := _pages[3] var page := _pages[3]
@@ -154,6 +283,10 @@ func _fill_comments() -> void:
var actions := HBoxContainer.new() var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 5) actions.add_theme_constant_override("separation", 5)
page.add_child(actions) page.add_child(actions)
var board_status := Label.new()
board_status.add_theme_font_size_override("font_size", 11)
board_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
page.add_child(board_status)
var edit := LineEdit.new() var edit := LineEdit.new()
edit.placeholder_text = "公告内容(最多 50 字)" edit.placeholder_text = "公告内容(最多 50 字)"
edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(245, 26) edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(245, 26)
@@ -163,7 +296,14 @@ func _fill_comments() -> void:
post.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26) post.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26)
post.pressed.connect(func(): post.pressed.connect(func():
var text := edit.text.strip_edges() var text := edit.text.strip_edges()
if text != "" and client.has_method("guild_post_comment"): if text == "":
return
# 服务器 input_main.cpp:2819'!' 前缀(公告)需要 GUILD_AUTH_NOTICE。
if text.begins_with("!") and (_my_auth() & AUTH_NOTICE) == 0:
board_status.text = "发布公告需要相应权限"
return
board_status.text = ""
if client.has_method("guild_post_comment"):
client.guild_post_comment(text)) client.guild_post_comment(text))
actions.add_child(post) actions.add_child(post)
var refresh_btn := Button.new() var refresh_btn := Button.new()
@@ -226,6 +366,11 @@ func _fill_skills() -> void:
page.add_child(head) page.add_child(head)
var levels: Array = sk.get("levels", []) var levels: Array = sk.get("levels", [])
var meta := _guild_skill_rows() var meta := _guild_skill_rows()
var auth := _my_auth()
var can_use_skill := (auth & AUTH_USE_SKILL) != 0
# do_guildskillup 只认会长(GUILD_LEADER_GRADE);技能点 > 0 才显示升级按钮。
var is_leader := auth == AUTH_ALL and _my_grade_is_leader()
var has_point := int(sk.get("skill_point", 0)) > 0
for i in levels.size(): for i in levels.size():
var lv := int(levels[i]) var lv := int(levels[i])
var nm := "公会技能 %d" % i var nm := "公会技能 %d" % i
@@ -242,12 +387,22 @@ func _fill_skills() -> void:
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.text = "%s Lv %d" % [nm, lv] lbl.text = "%s Lv %d" % [nm, lv]
row.add_child(lbl) row.add_child(lbl)
if sid >= 0 and lv > 0: # 施放:服务器 guild.cpp:1248 要求 GUILD_AUTH_USE_SKILL。
if sid >= 0 and lv > 0 and can_use_skill:
var btn := Button.new() var btn := Button.new()
btn.text = "施放" btn.text = "施放"
btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24) btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24)
btn.pressed.connect(func(): client.use_guild_skill(sid, 0)) btn.pressed.connect(func(): client.use_guild_skill(sid, 0))
row.add_child(btn) row.add_child(btn)
# 升级:/gskillup <技能 vnum>cmd_general.cpp do_guildskillup,仅会长)。
if sid >= 0 and is_leader and has_point:
var up := Button.new()
up.text = "升级"
up.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24)
up.pressed.connect(func():
if client.has_method("say"):
client.say(0, "/gskillup %d" % sid))
row.add_child(up)
func _fill_war(my_guild_id: int) -> void: func _fill_war(my_guild_id: int) -> void:
var page := _pages[2] var page := _pages[2]
+41 -2
View File
@@ -666,14 +666,47 @@ func can_equip_item(vnum: int, wear := -1) -> Dictionary:
return {"ok": false, "code": "NO_ITEM", "need": 0} return {"ok": false, "code": "NO_ITEM", "need": 0}
return EquipRules.can_equip(proto.item(vnum), wear, _equip_ctx()) return EquipRules.can_equip(proto.item(vnum), wear, _equip_ctx())
# 组装 EquipRules.can_equip 需要的运行期上下文。属性点客户端未按名透出 → 传 -1(seam)。 # EPointTypes(服务器 char.h):ST=12 HT=13 DX=14 IQ=15MOUNT=109(骑乘中坐骑 vnum)。
const POINT_ST := 12
const POINT_HT := 13
const POINT_DX := 14
const POINT_IQ := 15
const POINT_MOUNT := 109
# 组装 EquipRules.can_equip 需要的运行期上下文(服务器真实链所需字段全集)。
# 属性四维从 get_points()["points"] 全量数组取;worn 是 {wear_cell: vnum} 穿戴表,
# 供 RING/UNIQUE 占用翻转与 UNIQUE 同组去重;polymorphed/riding 供 EquipItem 后置门。
func _equip_ctx() -> Dictionary: func _equip_ctx() -> Dictionary:
var ctx := {"race": 0, "empire": 0, "level": 0, "st": -1, "dx": -1, "ht": -1, "iq": -1} var ctx := {"race": 0, "empire": 0, "level": 0, "st": -1, "dx": -1, "ht": -1, "iq": -1,
"worn": {}, "polymorphed": false, "riding": false}
if client == null: if client == null:
return ctx return ctx
if client.has_method("get_points"): if client.has_method("get_points"):
var p: Dictionary = client.get_points() var p: Dictionary = client.get_points()
ctx["level"] = int(p.get("level", 0)) ctx["level"] = int(p.get("level", 0))
var vals: Array = p.get("points", [])
if vals.size() > POINT_MOUNT:
ctx["st"] = int(vals[POINT_ST])
ctx["ht"] = int(vals[POINT_HT])
ctx["dx"] = int(vals[POINT_DX])
ctx["iq"] = int(vals[POINT_IQ])
ctx["riding"] = int(vals[POINT_MOUNT]) > 0
# 穿戴表 {wear_cell: vnum}:旧 11 格来自 get_equipment()(下标即 wear cell);
# 新装备槽 19..23costume/ring/belt)留在 INVENTORY 全局 cell 90+wear(见上方常量)。
var worn := {}
if client.has_method("get_equipment"):
var eq: Array = client.get_equipment()
for i in eq.size():
var vnum := int(eq[i].get("vnum", 0)) if eq[i] is Dictionary else 0
if vnum != 0:
worn[i] = vnum
if client.has_method("get_inventory"):
for it in client.get_inventory():
if it is Dictionary:
var cell := int(it.get("cell", -1))
if cell >= EQUIP_BASE + WEAR_COSTUME_BODY and cell < EQUIP_BASE + WEAR_COUNT:
worn[cell - EQUIP_BASE] = int(it.get("vnum", 0))
ctx["worn"] = worn
if client.has_method("get_entities"): if client.has_method("get_entities"):
for e in client.get_entities(): for e in client.get_entities():
if bool(e.get("is_main", false)): if bool(e.get("is_main", false)):
@@ -681,6 +714,8 @@ func _equip_ctx() -> Dictionary:
ctx["empire"] = int(e.get("empire", 0)) ctx["empire"] = int(e.get("empire", 0))
if int(e.get("level", 0)) > 0: if int(e.get("level", 0)) > 0:
ctx["level"] = int(e.get("level", 0)) ctx["level"] = int(e.get("level", 0))
ctx["polymorphed"] = int(e.get("polymorph", 0)) != 0 \
or int(e.get("poly_vnum", 0)) != 0
break break
return ctx return ctx
@@ -689,6 +724,10 @@ func _equip_reject_hint(verdict: Dictionary) -> String:
"ANTI_SEX": return "该装备不符合你的性别" "ANTI_SEX": return "该装备不符合你的性别"
"ANTI_JOB": return "该装备不符合你的职业" "ANTI_JOB": return "该装备不符合你的职业"
"ANTI_EMPIRE": return "该装备不符合你的帝国" "ANTI_EMPIRE": return "该装备不符合你的帝国"
"UNIQUE_DUP": return "同类特殊装备只能佩戴一件"
"POLYMORPH": return "变身状态下无法更换装备"
"RIDE_TUXEDO": return "骑乘状态下无法穿礼服"
"RIDE_ITEM": return "已经在骑乘中了"
"NOT_WEARABLE_HERE": return "该装备不能穿在这个位置" "NOT_WEARABLE_HERE": return "该装备不能穿在这个位置"
"LIMIT_LEVEL": return "需要等级 %d" % int(verdict.get("need", 0)) "LIMIT_LEVEL": return "需要等级 %d" % int(verdict.get("need", 0))
"LIMIT_STR": return "需要力量 %d" % int(verdict.get("need", 0)) "LIMIT_STR": return "需要力量 %d" % int(verdict.get("need", 0))
+23 -6
View File
@@ -223,6 +223,10 @@ func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictio
for limit in proto_data.get("limits", []): for limit in proto_data.get("limits", []):
var limit_type := int(limit.get("type", 0)) var limit_type := int(limit.get("type", 0))
var limit_value := int(limit.get("value", 0)) var limit_value := int(limit.get("value", 0))
if limit_type == 6:
# LIMIT_PCBANG 只是“网吧专用”标记,不是装备门禁(服务器 item.cpp:1899)。
lines.append("PC房专用道具")
continue
if limit_type != 0 and limit_value > 0: if limit_type != 0 and limit_value > 0:
lines.append("%s%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value]) lines.append("%s%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value])
for apply in proto_data.get("applies", []): for apply in proto_data.get("applies", []):
@@ -291,19 +295,32 @@ func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, socket
return return
var value := int(sockets[0]) var value := int(sockets[0])
var remaining := 0 var remaining := 0
if limit_type == 7 or limit_type == 8: if limit_type == 8:
# REAL_TIME_START_FIRST_USEsocket1 = 使用计数。未启用(0)时 socket0 是
# 时长秒数而非到期时间戳;启用后服务器把 socket0 换成到期 unix ts
#char_item.cpp:6100 尾部 cLimitRealTimeFirstUseIndex 分支)。
var used := int(sockets[1]) if sockets.size() > 1 else 0
if used == 0:
lines.append("首次使用后计时:%s" % _duration_text(value))
return
remaining = maxi(0, value - int(Time.get_unix_time_from_system()))
elif limit_type == 7:
# REAL_TIMEsocket0 = 到期 unix tsitem_length.h:328-347)。
remaining = maxi(0, value - int(Time.get_unix_time_from_system())) remaining = maxi(0, value - int(Time.get_unix_time_from_system()))
else: else:
remaining = maxi(0, value) remaining = maxi(0, value)
if remaining <= 0: if remaining <= 0:
lines.append("剩余时间:已超时") lines.append("剩余时间:已超时")
return return
var minutes := remaining / 60 lines.append("剩余时间:%s" % _duration_text(remaining))
var seconds := remaining % 60
var text := "%d" % seconds func _duration_text(seconds: int) -> String:
var minutes := seconds / 60
var rest := seconds % 60
var text := "%d" % rest
if minutes > 0: if minutes > 0:
text = "%d%s" % [minutes, text] if seconds > 0 else "%d" % minutes text = "%d%s" % [minutes, text] if rest > 0 else "%d" % minutes
lines.append("剩余时间:%s" % text) return text
func _append_special_item_data(lines: Array[String], item_type: int, vnum: int, func _append_special_item_data(lines: Array[String], item_type: int, vnum: int,
values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void: values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void:
+8 -1
View File
@@ -19,10 +19,17 @@ func refresh() -> void:
return return
var lover: Dictionary = client.get_lover() var lover: Dictionary = client.get_lover()
var valid := bool(lover.get("valid", false)) var valid := bool(lover.get("valid", false))
# lover_divorceCHAT_TYPE_COMMAND)→ store 清空 LoverInfo → valid=false 自动隐藏。
_root.visible = valid and not _mobile_mode _root.visible = valid and not _mobile_mode
if not valid: if not valid:
return return
_name.text = "" + String(lover.get("name", "")) # 上/下线与靠近/远离由服务器 lover_login/logout/near/far 命令驱动
#marriage.cpp;原版 affectShower 只在“靠近”时亮心形)。
var online := bool(lover.get("online", false))
var near := bool(lover.get("near", false))
var state := "(在身边)" if near else "(在线)" if online else "(离线)"
_name.text = "" + String(lover.get("name", "")) + state
_name.modulate = Color(1.0, 0.6, 0.72) if near else Color(1.0, 0.72, 0.82)
_bar.value = clampi(int(lover.get("love_point", 0)), 0, 100) _bar.value = clampi(int(lover.get("love_point", 0)), 0, 100)
_bar.tooltip_text = "爱意值:%d%%" % int(_bar.value) _bar.tooltip_text = "爱意值:%d%%" % int(_bar.value)
+3
View File
@@ -49,6 +49,9 @@ func get_mobile_window() -> Control:
return _root return _root
func close() -> void: func close() -> void:
# 对齐原版 MallWindow.Close():先发 /mall_close 让服务器关掉商城仓库。
if client and client.has_method("mall_close") and is_open():
client.mall_close()
if item_mouse and item_mouse.has_method("unregister_owner"): if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self) item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached(): if item_mouse.has_method("is_attached") and item_mouse.is_attached():
+56 -1
View File
@@ -65,7 +65,19 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
client.quickslots_changed.connect(restore_from_server) client.quickslots_changed.connect(restore_from_server)
if client.has_signal("skills_changed"): if client.has_signal("skills_changed"):
client.skills_changed.connect(_refresh_page) client.skills_changed.connect(_refresh_page)
restore_from_server() # 物品槽引用的是背包 cell:装备/销毁/丢弃后服务器 SyncQuickslot 会推
# quickslots_changed;这里再挂 inventory_changed 兜住时序窗口(置灰失效引用)。
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): _refresh_page())
# 死亡/复活:死亡中槽位全部置灰(对齐原版 POS_DEAD 全链路禁用)。
if client.has_signal("entity_dead"):
client.entity_dead.connect(_on_entity_dead)
# 变身(AFFECT_POLYMORPH=220):技能槽整体遮罩,结束(re-broadcast)后解除。
if client.has_signal("affect_added"):
client.affect_added.connect(_on_affect_added)
if client.has_signal("affect_removed"):
client.affect_removed.connect(_on_affect_removed)
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。 # 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。 # 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。
@@ -140,6 +152,9 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
return return
if _now() < s.cd_end: if _now() < s.cd_end:
return return
# 死亡中不可使用(服务器 POS_DEAD 拦截一切使用;这里提前本地拦截)。
if _player_dead():
return
if s.kind == "skill": if s.kind == "skill":
var mi: int = table.motion_idx_of(s.id) if table else 0 var mi: int = table.motion_idx_of(s.id) if table else 0
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
@@ -220,6 +235,35 @@ func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: floa
var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y) var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y)
return fposmod(fallback + camera_delta + angle_from_up, 360.0) return fposmod(fallback + camera_delta + angle_from_up, 360.0)
# 服务器权威的死亡态(GC add chr state DEAD);复活后 entity_info 会再触发刷新。
func _player_dead() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and bool(me.get("dead", false))
# 变身态(POINT_POLYMORPH / affect 220):技能槽遮罩的数据源。
func _polymorphed() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and (int(me.get("polymorph", 0)) != 0 or int(me.get("poly_vnum", 0)) != 0)
func _on_entity_dead(vid: int) -> void:
if client and client.has_method("get_main_vid") and vid == client.get_main_vid():
_refresh_page()
# 只关心 AFFECT_POLYMORPH(220) 的增减,避免每次 buff 变化都整页重刷。
const AFFECT_POLYMORPH := 220
func _on_affect_added(affect: Dictionary) -> void:
if int(affect.get("type", 0)) == AFFECT_POLYMORPH:
_refresh_page()
func _on_affect_removed(type: int) -> void:
if type == AFFECT_POLYMORPH:
_refresh_page()
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏 # net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。 # 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
func activate_reserved(global_slot: int) -> bool: func activate_reserved(global_slot: int) -> bool:
@@ -422,9 +466,20 @@ func _refresh_slot(slot: int) -> void:
grade_text = _skill_grade_text(int(s.id)) grade_text = _skill_grade_text(int(s.id))
elif s.kind == "item": elif s.kind == "item":
txt = "#%d" % s.id txt = "#%d" % s.id
# 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。
var dim := false
if s.kind == "item" and client and client.has_method("get_item"):
var it: Dictionary = client.get_item(1, s.id)
if it.is_empty() or int(it.get("vnum", 0)) == 0:
dim = true
txt = "×%d" % s.id
# 变身中技能槽整体遮罩(服务器只放行箭矢类物品);死亡中全部置灰。
if (s.kind == "skill" and _polymorphed()) or _player_dead():
dim = true
var icon: TextureRect = _slots[slot].icon var icon: TextureRect = _slots[slot].icon
icon.texture = tex icon.texture = tex
icon.visible = tex != null icon.visible = tex != null
_slots[slot].btn.modulate = Color(0.42, 0.42, 0.42) if dim else Color(1, 1, 1)
_slots[slot].lbl.text = txt if tex == null else "" _slots[slot].lbl.text = txt if tex == null else ""
_slots[slot].grade.text = grade_text _slots[slot].grade.text = grade_text
_slots[slot].grade.visible = grade_text != "" and tex != null _slots[slot].grade.visible = grade_text != "" and tex != null
+76 -1
View File
@@ -30,6 +30,7 @@ var _mobile_inventory_title: Label
var _mobile_inventory_scroll: ScrollContainer var _mobile_inventory_scroll: ScrollContainer
var _mobile_inventory_list: VBoxContainer var _mobile_inventory_list: VBoxContainer
var _mobile_deposit_pending := {} var _mobile_deposit_pending := {}
var _change_pw_dialog: ConfirmationDialog
const SAFE_PAGE_SLOTS := 45 const SAFE_PAGE_SLOTS := 45
const SAFE_WINDOW := 3 const SAFE_WINDOW := 3
@@ -62,6 +63,10 @@ func get_mobile_window() -> Control:
return _root return _root
func close() -> void: func close() -> void:
# 对齐原版 SafeboxWindow.Close():先发 /safebox_close 让服务器同步关仓
#CloseSafebox 命令回来后 is_safebox_open() 变 false),本地只是立即收尾。
if client and client.has_method("safebox_close") and is_open():
client.safebox_close()
if item_mouse and item_mouse.has_method("unregister_owner"): if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self) item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached(): if item_mouse.has_method("is_attached") and item_mouse.is_attached():
@@ -130,7 +135,9 @@ func refresh() -> void:
_page = clampi(_page, 0, pages - 1) _page = clampi(_page, 0, pages - 1)
_title.text = "仓库(第 %d/%d 页)" % [_page + 1, pages] _title.text = "仓库(第 %d/%d 页)" % [_page + 1, pages]
_page_label.text = "%d / %d" % [_page + 1, pages] _page_label.text = "%d / %d" % [_page + 1, pages]
_gold.text = "仓库金币: %d" % client.get_safebox_gold() # 40250 无仓库金钱存取协议(HEADER_CG_SAFEBOX_MONEY 不存在,GC 84 从不发送,
# 原版 RefreshSafeboxMoney 也是 pass)——只读展示,通常恒 0。
_gold.text = "仓库金币: %d(本版本不可存取)" % client.get_safebox_gold()
for c in _list.get_children(): for c in _list.get_children():
c.queue_free() c.queue_free()
_render_grid() _render_grid()
@@ -302,6 +309,67 @@ func _ask_password(kind: String) -> void:
else: else:
_password_dialog.popup_centered() _password_dialog.popup_centered()
# 修改仓库密码:/safebox_change_password <旧> <新>(服务器两个参数各限 1..6 位)。
func _ask_change_password() -> void:
if not is_open():
return
if is_instance_valid(_change_pw_dialog):
_change_pw_dialog.queue_free()
_change_pw_dialog = ConfirmationDialog.new()
_change_pw_dialog.title = "修改仓库密码"
_change_pw_dialog.dialog_text = "旧密码 / 新密码(1~6 位)/ 再输入一次新密码"
_change_pw_dialog.ok_button_text = "确认"
_change_pw_dialog.cancel_button_text = "取消"
var box := VBoxContainer.new()
var edits: Array[LineEdit] = []
for hint in ["旧密码", "新密码", "确认新密码"]:
var row := HBoxContainer.new()
var lbl := Label.new()
lbl.text = hint
lbl.custom_minimum_size = Vector2(96, 0)
row.add_child(lbl)
var edit := LineEdit.new()
edit.secret = true
edit.max_length = 6
edit.placeholder_text = hint
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(160, 28)
row.add_child(edit)
box.add_child(row)
edits.append(edit)
_change_pw_dialog.add_child(box)
_change_pw_dialog.confirmed.connect(func() -> void:
var old_pw := edits[0].text
var new_pw := edits[1].text
if new_pw != edits[2].text:
if is_instance_valid(_status):
_status.text = "两次输入的新密码不一致"
elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 \
or old_pw.length() > 6:
if is_instance_valid(_status):
_status.text = "密码须为 16 位"
elif not client.safebox_change_password(old_pw, new_pw):
if is_instance_valid(_status):
_status.text = "修改密码请求发送失败"
else:
if is_instance_valid(_status):
_status.text = "已提交修改密码请求"
_change_pw_dialog.queue_free()
_change_pw_dialog = null)
_change_pw_dialog.canceled.connect(func() -> void:
_change_pw_dialog.queue_free()
_change_pw_dialog = null)
if _mobile_mode:
_change_pw_dialog.set_meta("mobile_modal", true)
_change_pw_dialog.set_meta("mobile_title", "修改仓库密码")
_change_pw_dialog.min_size = Vector2i(560, 320)
_change_pw_dialog.size = Vector2i(560, 320)
_root.add_child(_change_pw_dialog)
if _mobile_mode:
_change_pw_dialog.popup_centered(Vector2i(560, 320))
else:
_change_pw_dialog.popup_centered()
func _row(it: Dictionary) -> Control: func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0) row.custom_minimum_size = Vector2(300, 0)
@@ -368,6 +436,13 @@ func _build(parent: Node) -> void:
next.size = Vector2(28, 24) next.size = Vector2(28, 24)
next.pressed.connect(func(): _change_page(1)) next.pressed.connect(func(): _change_page(1))
_root.add_child(next) _root.add_child(next)
var change_pw := Button.new()
change_pw.text = "改密"
change_pw.position = Vector2(186, 6)
change_pw.size = Vector2(42, 24)
change_pw.add_theme_font_size_override("font_size", 11)
change_pw.pressed.connect(_ask_change_password)
_root.add_child(change_pw)
_gold = Label.new() _gold = Label.new()
_gold.text = "仓库金币: 0" _gold.text = "仓库金币: 0"
_gold.position = Vector2(12, 30) _gold.position = Vector2(12, 30)