test_monster_discovery.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """怪物发现机制修复验证(2026-09-30 run8 破案)。
  2. 抓包定案(cap_mine_walk 370s 矿区实走 / cap_live2 盟重挂机):
  3. - 本服 SC10 只推玩家(0帧怪物出现); 怪物被动源 = SC11 移动广播(5位数短ID)
  4. + SC14 攻击广播 — 旧 _on_actor_move 只更新已知怪, 未知 5 位数 ID 直接
  5. 丢弃 = "路过怪不杀"直接根因
  6. - SC10 真实布局(客户端 handle_InOfView + converActorData 同构):
  7. key=body.info[0], 坐标=body.X/body.Y, race 分类 0/1/150=玩家 50=NPC
  8. 其余=怪; 帧头 p1=(y<<16)|x 只是坐标回显, 绝不是实体 ID
  9. - SC10 6位数短id(288999 等, recog 5xxxx 人形外形)=人形怪/英雄/宠物,
  10. 不入怪表(玩家量级)
  11. """
  12. import sys
  13. from pathlib import Path
  14. sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
  15. sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
  16. import json
  17. from pbot.codec.bitbuf import encode6
  18. from pbot.codec.frame import Frame
  19. from pbot.world.mirror import WorldMirror
  20. from pbot.world.update import WorldUpdater
  21. def mk_frame(mid: int, recog: int = 0, p1: int = 0, p2: int = 0, p3: int = 0,
  22. body_json: dict | None = None) -> Frame:
  23. body = encode6(json.dumps(body_json or {}).encode()) \
  24. if body_json is not None else b""
  25. return Frame(msg_id=mid, recog=recog, p1=p1, p2=p2, p3=p3, tail=0,
  26. pre=0, body=body)
  27. def sc11(uid: str, x: int, y: int, d: int = 4) -> Frame:
  28. """SC11 移动广播: 头 recog=X p1=Y, body=encode6(JSON{UserID})."""
  29. return mk_frame(11, recog=x, p1=y, p2=d, body_json={"UserID": uid})
  30. # ---------- SC11 雷达入表 ----------
  31. def test_sc11_unknown_5digit_monster_enters_table() -> None:
  32. """未知 5 位数短ID移动广播 → 入怪表(矿区 43217/56431 实证=怪)."""
  33. w = WorldMirror()
  34. up = WorldUpdater()
  35. up.apply(w, sc11("43217", 103, 174))
  36. m = w.monsters.get(43217)
  37. assert m is not None, "5位数未知ID必须雷达入表"
  38. assert (m.x, m.y) == (103, 174)
  39. assert not m.is_dead
  40. def test_sc11_known_monster_updates_position() -> None:
  41. """已入表怪: SC11 只更新坐标(不覆盖名字/race)."""
  42. w = WorldMirror()
  43. up = WorldUpdater()
  44. up.apply(w, sc11("43217", 103, 174))
  45. up.apply(w, sc11("43217", 100, 170))
  46. m = w.monsters.get(43217)
  47. assert m is not None and (m.x, m.y) == (100, 170)
  48. def test_sc11_player_uid_ignored() -> None:
  49. """29位长uid(玩家)移动广播不入怪表(盟重 14504562… 实证)."""
  50. w = WorldMirror()
  51. up = WorldUpdater()
  52. long_uid = "14504562001790120981000064127"
  53. up.apply(w, sc11(long_uid, 330, 327))
  54. assert not w.monsters
  55. w.me.uid = long_uid
  56. up.apply(w, sc11(long_uid, 331, 328))
  57. assert not w.monsters
  58. def test_sc11_6digit_monster_enters() -> None:
  59. """真实客户端抓包定案(cap_client_mine2): 矿区怪短ID为6位数
  60. (111340/111345/100273 被真客户端攻击, 111377 经SC11广播) —
  61. 旧 >=100000 过滤把整代怪拒之门外."""
  62. w = WorldMirror()
  63. up = WorldUpdater()
  64. up.apply(w, sc11("111340", 65, 40))
  65. m = w.monsters.get(111340)
  66. assert m is not None and (m.x, m.y) == (65, 40)
  67. def test_sc11_7digit_still_rejected() -> None:
  68. """7位数(2293955 宠物/玩家量级, r45 误攻实证)仍不入表."""
  69. w = WorldMirror()
  70. up = WorldUpdater()
  71. up.apply(w, sc11("2293955", 65, 40))
  72. assert not w.monsters
  73. def test_sc31_attacker_enters_table() -> None:
  74. """SC31 受击: AttackUserID=攻击怪真实ID(cap_client_mine2 实证
  75. 111340 连打玩家 100+s) — 打我的怪立即入表(哪怕暂无坐标,
  76. SC14/SC11 到达后坐标补全). 受击揭示=反击的前提."""
  77. w = WorldMirror()
  78. w.me.uid = "14570166001790661813000091989"
  79. up = WorldUpdater()
  80. body = {"Hp": 457, "MaxHp": 575, "Damage": 6,
  81. "UserID": w.me.uid, "AttackUserID": "111340"}
  82. up.apply(w, mk_frame(31, body_json=body))
  83. m = w.monsters.get(111340)
  84. assert m is not None, "攻击我的怪必须入表(受击揭示)"
  85. assert m.name == "attacker"
  86. def test_sc31_attacker_coord_fills_when_known() -> None:
  87. """受击揭示的怪: 后续 SC14/SC11 到达时坐标补全."""
  88. w = WorldMirror()
  89. w.me.uid = "14570166001790661813000091989"
  90. up = WorldUpdater()
  91. up.apply(w, mk_frame(31, body_json={
  92. "Hp": 457, "MaxHp": 575, "Damage": 6,
  93. "UserID": w.me.uid, "AttackUserID": "111340"}))
  94. up.apply(w, mk_frame(14, body_json={
  95. "UserID": "111340", "Dir": 6, "X": 45, "Y": 59,
  96. "MagicID": 0, "ScriptEffect": 0}))
  97. m = w.monsters.get(111340)
  98. assert m is not None and (m.x, m.y) == (45, 59)
  99. def test_sc10_humanoid_registry_blocks_sc11() -> None:
  100. """SC10 人形(玩家/英雄 race 0/1/150)登记入 humanoids —
  101. 其 SC11 移动广播永不入怪表(盟重 288999 巫师之神实证=英雄)."""
  102. w = WorldMirror()
  103. w.me.map_name = "比奇矿区"
  104. up = WorldUpdater()
  105. up.apply(w, mon_sc10("288999", 150, 330, 327, name="巫师之神"))
  106. assert 288999 not in w.monsters
  107. up.apply(w, sc11("288999", 331, 328))
  108. assert 288999 not in w.monsters
  109. assert 288999 in w.humanoids
  110. def test_sc11_7digit_pet_ignored() -> None:
  111. """7位数(2293955 宠物/玩家量级, r45 误攻实证)不入怪表."""
  112. w = WorldMirror()
  113. up = WorldUpdater()
  114. up.apply(w, sc11("2293955", 330, 327))
  115. assert not w.monsters
  116. def test_sc30_removes_radar_monster() -> None:
  117. """SC30 出视野: 雷达入表的怪同样可移除(不残留幽灵目标)."""
  118. w = WorldMirror()
  119. up = WorldUpdater()
  120. up.apply(w, sc11("43217", 103, 174))
  121. up.apply(w, mk_frame(30, body_json={"UserID": "43217"}))
  122. assert 43217 not in w.monsters
  123. # ---------- SC10 重写(info[0]/X/Y/race) ----------
  124. def mon_sc10(info0: str, race: int, x: int, y: int, name: str = "矿区僵尸",
  125. recog: int = 1049) -> Frame:
  126. return mk_frame(10, recog=recog, p1=(y << 16) | x, p2=4 << 16,
  127. body_json={"info": [info0, name, 1, 0, 1],
  128. "X": x, "Y": y, "race": race, "rs": race})
  129. def test_sc10_monster_enters_via_info0_and_xy() -> None:
  130. """真怪物 SC10: key=info[0], 坐标=body.X/Y(非帧头打包值的拆解)."""
  131. w = WorldMirror()
  132. w.me.map_name = "比奇矿区"
  133. up = WorldUpdater()
  134. up.apply(w, mon_sc10("44871", 1049, 77, 122))
  135. m = w.monsters.get(44871)
  136. assert m is not None, "info[0] 才是实体ID"
  137. assert (m.x, m.y) == (77, 122)
  138. assert m.name == "矿区僵尸"
  139. def test_sc10_player_race_ignored() -> None:
  140. """race=0/1/150(玩家)不入怪表 — 即使 info[0] 是 5 位数."""
  141. w = WorldMirror()
  142. w.me.map_name = "比奇矿区"
  143. up = WorldUpdater()
  144. up.apply(w, mon_sc10("55555", 150, 331, 332, name="某玩家", recog=55543))
  145. assert not w.monsters
  146. def test_sc10_npc_goes_to_npc_table() -> None:
  147. """race=50 → NPC 表(不打)."""
  148. w = WorldMirror()
  149. w.me.map_name = "比奇矿区"
  150. up = WorldUpdater()
  151. up.apply(w, mon_sc10("10002", 50, 342, 338, name="传送员",
  152. recog=50))
  153. assert not w.monsters
  154. assert any("传送员" in n.name for n in w.npcs.values())
  155. def test_sc10_coordinate_not_used_as_id() -> None:
  156. """帧头 p1 坐标打包值绝不当实体ID(run7 #7536683=坐标值误当怪)."""
  157. w = WorldMirror()
  158. w.me.map_name = "比奇矿区"
  159. up = WorldUpdater()
  160. up.apply(w, mon_sc10("44871", 1049, 43, 115))
  161. assert 44871 in w.monsters
  162. bad_key = (115 << 16) | 43
  163. assert bad_key not in w.monsters
  164. assert all(k < 100000 for k in w.monsters)
  165. # ---------- §2.6 翻案修复: 双层编码 SC10 + p1 坐标回退 (2026-09-30 午后) ----------
  166. def test_batch_frame_double_encoded_sc10() -> None:
  167. """双层编码 -1 批量帧(解压后整体 6bit 文本态)内的 SC10 必须入镜.
  168. cap_client_mine2 t=169.89 实证: 矿区怪物 SC10 全藏于此形态,
  169. 旧 apply_batch 解析 0 子帧静默丢弃 = "零推怪"假结论根因.
  170. """
  171. import zlib
  172. from pbot.codec.frame import build_frame
  173. from pbot.game.session import apply_batch
  174. sc10_json = json.dumps({
  175. "info": ["113202", "僵尸"], "rs": 96,
  176. "IsBoss": False,
  177. }).encode()
  178. sc10 = build_frame(10, 1079, (27 << 16) | 39, 7, 134,
  179. encode6(sc10_json), pre=(27 << 16) | 39)
  180. batch = Frame(msg_id=-1, recog=207, p1=299995, p2=0, p3=-10000,
  181. pre=0, tail=207, body=zlib.compress(sc10))
  182. w = WorldMirror()
  183. w.me.map_name = "比奇矿区"
  184. apply_batch(w, WorldUpdater(), [batch])
  185. m = w.monsters.get(113202)
  186. assert m is not None, "双层编码批量帧内 SC10 必须入怪表"
  187. assert (m.x, m.y) == (39, 27), "坐标从帧头 p1=(y<<16)|x 回退解析"
  188. assert m.name == "僵尸"
  189. assert not m.is_boss
  190. def test_sc10_header_pre_large_value_accepted() -> None:
  191. """子帧头 pre=(y<<16)|x 压缩坐标(实测 2752563)不得当伪帧头丢弃.
  192. FrameSplitter 旧版 pre<2**13 约束 = SC10 整代丢失根因之一.
  193. """
  194. from pbot.codec.frame import FrameSplitter, build_frame
  195. sc10_json = json.dumps(
  196. {"info": ["100989", "僵尸"], "rs": 96}).encode()
  197. sc10 = build_frame(10, 1079, (70 << 16) | 38, 7, 134,
  198. encode6(sc10_json), pre=(70 << 16) | 38)
  199. frames = FrameSplitter().feed(sc10)
  200. assert len(frames) == 1, "pre 为大值的合法子帧必须被切出"
  201. assert frames[0].msg_id == 10
  202. def test_sc10_isboss_flag_entered() -> None:
  203. """SC10 档案 IsBoss=true → 镜像怪 is_boss=True(BOSS 识别免费通道)."""
  204. w = WorldMirror()
  205. w.me.map_name = "比奇矿区"
  206. up = WorldUpdater()
  207. up.apply(w, mon_sc10("99999", 96, 40, 70, name="尸王"))
  208. # mon_sc10 无 IsBoss 字段 — 直接补一帧带 IsBoss 的
  209. up.apply(w, mk_frame(10, recog=1079, p1=(70 << 16) | 40, p2=4 << 16,
  210. body_json={"info": ["88888", "触龙神"], "rs": 96,
  211. "X": 40, "Y": 70, "IsBoss": True}))
  212. assert w.monsters[88888].is_boss
  213. assert not w.monsters[99999].is_boss