test_wander.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. """WanderBehavior/UnlockBehavior 走链行为验证(不发真实网络 — 桩发送)。"""
  2. import json
  3. import time
  4. from typing import final
  5. from pbot.bot.unlock import UnlockBehavior
  6. from pbot.bot.wander import DIRS, RUN_INTERVAL_S, STEP_INTERVAL_S, WAIT_MAP, WanderBehavior
  7. from pbot.codec.frame import Frame, build_frame, parse_frame
  8. from pbot.world.actor import FindTarget
  9. from pbot.world.mirror import WorldMirror
  10. @final
  11. class SpyConn:
  12. """记录 send_frame 参数; recv 不产生帧。"""
  13. def __init__(self) -> None:
  14. self.sent: list[tuple[int, int, int, int, int, bytes]]
  15. self.pres: list[int]
  16. self.sent = []
  17. self.pres = []
  18. def send_frame(self, msg_id: int, recog: int, p1: int, p2: int, p3: int,
  19. body: bytes = b"", tail: int | None = None, pre: int = 0) -> int:
  20. del tail
  21. self.sent.append((msg_id, recog, p1, p2, p3, body))
  22. self.pres.append(pre)
  23. return 0
  24. def send_raw(self, wire: bytes) -> None:
  25. import struct
  26. from pbot.codec.bitbuf import decode6
  27. pos = 0
  28. while pos < len(wire):
  29. h = struct.unpack("<9i", decode6(wire[pos:pos + 48]))
  30. blen = h[7]
  31. self.sent.append((h[1], h[2], h[3], h[4], h[5],
  32. wire[pos + 48:pos + 48 + blen]))
  33. pos += 48 + blen
  34. def recv_frames(self, timeout: float) -> list[Frame]:
  35. del timeout
  36. return []
  37. def ready_world(x: int, y: int) -> WorldMirror:
  38. """SC50/SC51 已就绪的世界。"""
  39. world = WorldMirror()
  40. world.me.map_name = WAIT_MAP
  41. world.me.actor_key = 7
  42. world.me.x, world.me.y = x, y
  43. return world
  44. def fast_wander(sid: int, spy: SpyConn, world: WorldMirror) -> WanderBehavior:
  45. """已 on_enter 且时间门已到点的 WanderBehavior。"""
  46. b = WanderBehavior(sid=sid)
  47. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  48. b._last_step_t = time.time() - STEP_INTERVAL_S - 0.05 # pyright: ignore[reportPrivateUsage]
  49. return b
  50. def fast_walker_no_run(spy: SpyConn, world: WorldMirror) -> WanderBehavior:
  51. """use_run=False 且时间门已到点的 WanderBehavior。"""
  52. b = WanderBehavior(sid=1, use_run=False)
  53. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  54. b._last_step_t = time.time() - STEP_INTERVAL_S # pyright: ignore[reportPrivateUsage]
  55. return b
  56. # ---- WanderBehavior: 单帧制(cap_unlock 定案) ----
  57. def test_wander_sends_single_3013_frame() -> None:
  58. """Given: 世界就绪 When: 一拍到点 Then: 发三帧组 3013+CS1+CS28x2(cap_gold t=4985)。"""
  59. world = ready_world(23, 27)
  60. spy = SpyConn()
  61. b = fast_wander(sid=159, spy=spy, world=world)
  62. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  63. kinds = [s[0] for s in spy.sent]
  64. assert kinds == [3011] # 单帧制(cap_live 14 步铁证: 零 CS1/CS28 上行)
  65. msg_id, recog, p1, p2, p3, body = spy.sent[0]
  66. del msg_id
  67. assert recog == 0
  68. assert p1 == 5 # 初始朝向
  69. assert p2 == (27 << 16) | 23 # 起点
  70. assert p3 == 159 # p3 = sid(cap_unlock/cap_live 双定案)
  71. assert body == WAIT_MAP.encode()
  72. def test_wander_walk_mode_uses_3011() -> None:
  73. """Given: use_run=False When: tick Then: 发 CS3011 走 1 格。"""
  74. world = ready_world(23, 27)
  75. spy = SpyConn()
  76. b = fast_walker_no_run(spy, world)
  77. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  78. assert spy.sent[0][0] == 3011
  79. def test_wander_3011_carries_landing_prediction_in_pre() -> None:
  80. """Given: 走一步 When: 发 3011 Then: pre=预测落点(Y<<16|X)非零。
  81. 回归: 2026-09-26 破案定案 — cap_round2 30 会话统计, pre=0 的 3011
  82. 215/215 被 SC28 拒, pre≠0 的 60/60 放行。真客户端 pre=落点预测。
  83. """
  84. world = ready_world(23, 27)
  85. spy = SpyConn()
  86. b = fast_wander(sid=1, spy=spy, world=world)
  87. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  88. x, y = 23, 27
  89. dx, dy = DIRS[5] # 初始朝向 5
  90. expected_pre = ((y + dy) << 16) | (x + dx)
  91. assert spy.pres[0] == expected_pre
  92. assert spy.pres[0] != 0
  93. def test_wander_run_mode_sends_3013_two_cells() -> None:
  94. """Given: use_run=True When: tick Then: 发 CS3013 且落点=起点+2格。
  95. 协议定案(cap_round2 sid=88 放行实证): 3013 与 3011 同构,
  96. 跑一步 = 沿 dir 跳 2 格, 步距 0.55s, 服务器仍回 SC1。
  97. """
  98. world = ready_world(23, 27)
  99. spy = SpyConn()
  100. b = WanderBehavior(sid=1, use_run=True)
  101. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  102. b._last_step_t = time.time() - RUN_INTERVAL_S * 2 # pyright: ignore[reportPrivateUsage]
  103. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  104. assert spy.sent[0][0] == 3013
  105. _, recog, p1, p2, p3, body = spy.sent[0]
  106. del recog, p3, body
  107. dx, dy = DIRS[5] # 初始朝向 5
  108. assert p1 == 5 and p2 == (27 << 16) | 23
  109. assert spy.pres[0] == ((27 + dy * 2) << 16) | (23 + dx * 2)
  110. def test_wander_holds_until_sc1_confirm() -> None:
  111. """Given: 上一步未获 SC1 确认 When: 到点再 tick Then: 不发下一步(链式咬合)。"""
  112. world = ready_world(23, 27)
  113. spy = SpyConn()
  114. b = fast_wander(sid=1, spy=spy, world=world)
  115. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  116. assert len(spy.sent) == 1
  117. b._last_step_t = time.time() # pyright: ignore[reportPrivateUsage] - 步距刚走完
  118. world.record("self_move_confirmed") # SC1 到达(释放 pending)
  119. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  120. assert len(spy.sent) == 1 # 步距未到, 不发
  121. b._last_step_t = time.time() - STEP_INTERVAL_S - 0.05 # pyright: ignore[reportPrivateUsage]
  122. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  123. assert len(spy.sent) == 2 # 确认+到点 → 放行下一步
  124. def test_wander_pull_back_releases_pending() -> None:
  125. """Given: SC28 拉回 When: tick Then: pending 释放且拒绝计数。"""
  126. world = ready_world(23, 27)
  127. spy = SpyConn()
  128. b = fast_wander(sid=1, spy=spy, world=world)
  129. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  130. world.record("pull_back")
  131. b._last_step_t = time.time() - STEP_INTERVAL_S - 0.05 # pyright: ignore[reportPrivateUsage] - 余量防时钟抖动
  132. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  133. assert len(spy.sent) == 2 # 拉回后以服务器位置重发(pending 已释放)
  134. del b
  135. def test_wander_anchor_echo_releases_pending() -> None:
  136. """Given: SC28 锚点回显(anchor_echo 事件) When: 到点 tick Then: 放行重发。
  137. 回归: 2026-09-24 冻结 bug — _absorb_events 不认 anchor_echo 导致
  138. pending 永挂, dir 恒 5 坐标不变(一发即停)。
  139. 新行为: 同向锁 3 步 — 撞回后保持同方向, 链式 3011 沿同方向发,
  140. SC1 一旦放行就跑通(避免 (200,30)↔(198,28) A↔B 振荡)。
  141. """
  142. world = ready_world(23, 27)
  143. spy = SpyConn()
  144. b = fast_wander(sid=1, spy=spy, world=world)
  145. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  146. assert len(spy.sent) == 1
  147. world.record("anchor_echo") # update.py 对 SC28 p3=32368232 记的事件名
  148. b._last_step_t = time.time() - STEP_INTERVAL_S - 0.05 # pyright: ignore[reportPrivateUsage]
  149. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  150. assert len(spy.sent) == 2
  151. assert spy.sent[1][2] == spy.sent[0][2] # p1=heading 同向锁住
  152. assert spy.sent[1][0] == 3011 # 仍发新帧(非冻结)
  153. def test_wander_pending_timeout_frees_silence() -> None:
  154. """Given: 帧被静默丢(无任何回包) When: 超过 PENDING_TIMEOUT_S Then: 重发。"""
  155. world = ready_world(23, 27)
  156. spy = SpyConn()
  157. b = fast_wander(sid=1, spy=spy, world=world)
  158. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  159. assert len(spy.sent) == 1
  160. b._last_step_t = time.time() - STEP_INTERVAL_S - 0.05 # pyright: ignore[reportPrivateUsage]
  161. b._pending_t = time.time() - 10.0 # pyright: ignore[reportPrivateUsage] - 模拟超时
  162. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  163. assert len(spy.sent) == 2
  164. def test_wander_new_heading_avoids_occupied_cells() -> None:
  165. """Given: 视野内 NPC/怪占满部分邻格 When: 换向 Then: 选中未占据方向。"""
  166. from pbot.types import ActorId
  167. from pbot.world.actor import ActorKey, Npc
  168. world = ready_world(23, 27)
  169. spy = SpyConn()
  170. b = fast_wander(sid=1, spy=spy, world=world)
  171. for h, (dx, dy) in enumerate(DIRS):
  172. if h in (5, 2): # 5=当前朝向(排除), 2=留作唯一空闲候选
  173. continue
  174. world.npcs[100 + h] = Npc(key=ActorKey(100 + h), aid=ActorId(f"a{h}"),
  175. name=f"n{h}", x=23 + dx, y=27 + dy, index=h)
  176. b._rejected = 1 # pyright: ignore[reportPrivateUsage] - 触发换向分支
  177. heading = b._pick_new_heading(world) # pyright: ignore[reportPrivateUsage]
  178. assert heading == 2
  179. def test_wander_waits_until_world_known() -> None:
  180. """Given: SC50/SC51 未到 When: tick Then: 不发帧。"""
  181. world = WorldMirror()
  182. spy = SpyConn()
  183. b = fast_wander(sid=1, spy=spy, world=world)
  184. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  185. assert spy.sent == []
  186. def test_dirs_table_matches_lua_source() -> None:
  187. """Given: Lua mmoConstants 方向表 When: 检查 Then: 与 backup 源码一致。"""
  188. assert DIRS[0] == (0, -1) # 上
  189. assert DIRS[2] == (1, 0) # 右
  190. assert DIRS[4] == (0, 1) # 下
  191. assert DIRS[6] == (-1, 0) # 左
  192. # ---- UnlockBehavior: 黄金时序状态机 ----
  193. def test_unlock_renwu_golden_timing() -> None:
  194. """Given: 已传比奇 When: renwu链 Then: 3031→[1.2s]→1011 分步发, 不连发。"""
  195. world = ready_world(289, 620)
  196. world.me.map_name = "比奇大城"
  197. world.me.map_id = "0"
  198. spy = SpyConn()
  199. u = UnlockBehavior(sid=159)
  200. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  201. u._phase_t = 0.0 # pyright: ignore[reportPrivateUsage] - 跳过 settle 等待
  202. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  203. kinds = [s[0] for s in spy.sent]
  204. assert kinds == [3031] # 第一拍只发 3031
  205. u._phase = "renwu_1011" # pyright: ignore[reportPrivateUsage] - 模拟对话框已点
  206. u._phase_t = 0.0 # pyright: ignore[reportPrivateUsage]
  207. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  208. kinds = [s[0] for s in spy.sent]
  209. assert kinds == [3031, 1011]
  210. body = spy.sent[1][5].decode()
  211. assert json.loads(body)["Act"] == "@renwu_mov,1"
  212. def test_unlock_retention_band_starts_npc_chain() -> None:
  213. """Given: 等待图滞留带(y<5) When: on_enter Then: 先走 NPC 视野等待, 不直发 renwu。"""
  214. world = ready_world(30, 0)
  215. spy = SpyConn()
  216. u = UnlockBehavior(sid=159)
  217. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  218. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  219. assert spy.sent == [] # 等NPC视野, 不盲发
  220. def test_unlock_uses_full_cs3031_body() -> None:
  221. """Given: 无 SC55 缓存 When: 发 3031 Then: body 为 562B 完整体(≥500B)。"""
  222. world = ready_world(289, 620)
  223. world.me.map_name = "比奇大城"
  224. spy = SpyConn()
  225. u = UnlockBehavior(sid=1)
  226. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  227. u._phase_t = 0.0 # pyright: ignore[reportPrivateUsage]
  228. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  229. body = spy.sent[0][5]
  230. assert len(body) >= 500 # 完整任务体, 非 200B 截断
  231. def test_unlock_accept_signal_enters_run() -> None:
  232. """Given: 1011 已发 When: SC20102 到达 Then: 进入冲刺并发 3013。"""
  233. world = ready_world(24, 27)
  234. spy = SpyConn()
  235. u = UnlockBehavior(sid=159)
  236. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  237. u._phase = "accept" # pyright: ignore[reportPrivateUsage]
  238. u._phase_t = time.time() # pyright: ignore[reportPrivateUsage]
  239. world.find_target = FindTarget(map_id=WAIT_MAP, x=16, y=34)
  240. world.record("auto_find")
  241. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  242. assert u._phase == "run" # pyright: ignore[reportPrivateUsage]
  243. kinds = [s[0] for s in spy.sent]
  244. assert kinds == [3013] # 受理同拍即起跑(事件把 last_step_t 归零)
  245. p2 = spy.sent[0][3]
  246. assert p2 == (27 << 16) | 24
  247. def test_unlock_arrival_sends_exit() -> None:
  248. """Given: 冲刺中距目标≤2 When: tick Then: 发 CS1011 @exit 收尾。"""
  249. world = ready_world(16, 32)
  250. world.find_target = FindTarget(map_id=WAIT_MAP, x=16, y=34)
  251. spy = SpyConn()
  252. u = UnlockBehavior(sid=1)
  253. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  254. u._phase = "run" # pyright: ignore[reportPrivateUsage]
  255. u._phase_t = time.time() # pyright: ignore[reportPrivateUsage]
  256. u._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  257. _ = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  258. kinds = [s[0] for s in spy.sent]
  259. assert kinds == [1011]
  260. body = spy.sent[0][5].decode()
  261. assert json.loads(body)["Act"] == "@exit"
  262. def test_unlock_done_when_confirmed_in_walkable() -> None:
  263. """Given: SC1 已确认且 y≥5 When: tick Then: DONE。"""
  264. world = ready_world(23, 27)
  265. spy = SpyConn()
  266. u = UnlockBehavior(sid=1)
  267. u.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  268. u._move_confirmed = 1 # pyright: ignore[reportPrivateUsage]
  269. world.record("self_move_confirmed")
  270. result = u.tick(spy, world) # pyright: ignore[reportArgumentType]
  271. assert result.value == "done"
  272. # ---- update.py 新增 SC 帧解析 ----
  273. def test_sc1_confirms_position() -> None:
  274. """Given: SC1 确认帧 When: updater.apply Then: 位置更新+事件。"""
  275. from pbot.world.update import WorldUpdater
  276. world = WorldMirror()
  277. world.me.uid = "u1"
  278. updater = WorldUpdater()
  279. wire = build_frame(1, 21, 28, 5, 32368232, b"", tail=817)
  280. f = parse_frame(wire)
  281. updater.apply(world, f)
  282. assert world.me.x == 21 and world.me.y == 28
  283. assert world.events[-1].kind == "self_move_confirmed"
  284. def test_sc28_anchor_echo_not_reject() -> None:
  285. """Given: SC28 p3=32368232(锚点回显) When: apply Then: 不计拒绝。"""
  286. from pbot.world.update import WorldUpdater
  287. world = WorldMirror()
  288. world.me.map_name = WAIT_MAP
  289. updater = WorldUpdater()
  290. wire = build_frame(28, 0, 23, 27, 32368232, b"")
  291. f = parse_frame(wire)
  292. updater.apply(world, f)
  293. kinds = [e.kind for e in world.events]
  294. assert "pull_back" not in kinds
  295. assert "anchor_echo" in kinds
  296. def test_sc31_my_strike_feeds_hp() -> None:
  297. """Given: SC31 受击者是我 When: apply Then: me.hp 实时更新。
  298. 回归: SC53 面板帧稀疏(仅登录/死亡推), 不喂 SC31 血量则
  299. 低血逃跑永远只在 hp=0(已死)时触发(d401 死亡循环)。
  300. """
  301. from pbot.codec.bitbuf import encode6
  302. from pbot.world.update import WorldUpdater
  303. world = WorldMirror()
  304. world.me.uid = "14558825001790500354000067138"
  305. world.me.actor_key = 21889350
  306. updater = WorldUpdater()
  307. body = encode6(json.dumps(
  308. {"Hp": 80, "MaxHp": 305, "Damage": 60,
  309. "UserID": "21889350",
  310. "AttackUserID": "61526"}).encode())
  311. wire = build_frame(31, 0, 0, 0, 0, body)
  312. f = parse_frame(wire)
  313. updater.apply(world, f)
  314. assert world.me.hp == 80 and world.me.max_hp == 305
  315. def test_sc27_revive_restores_full_hp() -> None:
  316. """Given: 死亡 hp=0 收 SC27 When: apply Then: 复活+回满血。
  317. 回归: 回城复活后 SC53 不重推, hp 残留 0 会立即误触发低血逃跑。
  318. """
  319. from pbot.world.update import WorldUpdater
  320. world = WorldMirror()
  321. world.me.uid = "u1"
  322. world.me.hp, world.me.max_hp = 0, 305
  323. world.me.is_dead = True
  324. updater = WorldUpdater()
  325. wire = build_frame(27, 0, 0, 0, 0, b"")
  326. f = parse_frame(wire)
  327. updater.apply(world, f)
  328. assert world.me.is_dead is False
  329. assert world.me.hp == 305
  330. def test_sc31_my_lethal_strike_records_me_die() -> None:
  331. """Given: SC31 受击者是我且 Hp=0 When: apply Then: is_dead + me_die 事件。
  332. 回归: 我被怪打死时服务器不发 SC32, 只有 SC31(hp=0)+SC634 回城 —
  333. 缺 me_die 则逃跑阶段继续发移动帧("跑尸")。
  334. """
  335. from pbot.codec.bitbuf import encode6
  336. from pbot.world.update import WorldUpdater
  337. world = WorldMirror()
  338. world.me.uid = "14558825001790500354000067138"
  339. world.me.actor_key = 21889350
  340. updater = WorldUpdater()
  341. body = encode6(json.dumps(
  342. {"Hp": 0, "MaxHp": 407, "Damage": 72,
  343. "UserID": "21889350",
  344. "AttackUserID": "62471"}).encode())
  345. wire = build_frame(31, 0, 0, 0, 0, body)
  346. f = parse_frame(wire)
  347. updater.apply(world, f)
  348. assert world.me.is_dead is True
  349. assert world.me.hp == 0
  350. assert "me_die" in [e.kind for e in world.events]
  351. def test_sc20102_sets_find_target() -> None:
  352. """Given: SC20102(body=encode6 JSON) When: apply Then: find_target 就位。"""
  353. from pbot.codec.bitbuf import encode6
  354. from pbot.world.update import WorldUpdater
  355. world = WorldMirror()
  356. updater = WorldUpdater()
  357. body = encode6(json.dumps(
  358. {"mapID": "等待地图", "x": 16, "y": 34,
  359. "targetType": 100, "targetIndex": [34]}).encode())
  360. wire = build_frame(20102, 0, 0, 0, 0, body)
  361. f = parse_frame(wire)
  362. updater.apply(world, f)
  363. assert world.find_target is not None
  364. assert world.find_target.x == 16 and world.find_target.y == 34
  365. def test_cs3013_wire_layout() -> None:
  366. """Given: 跑帧 When: build_frame Then: 布局同 3011(cap_unlock 同构)。"""
  367. wire = build_frame(3013, 0, 5, (27 << 16) | 23, 159, WAIT_MAP.encode())
  368. f = parse_frame(wire)
  369. assert f.msg_id == 3013 and f.p2 == (27 << 16) | 23 and f.p3 == 159