test_autofight_client_aligned.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. """AutoFightBehavior 客户端对齐重构验证(真客户端 Lua 反推的行为规格)。
  2. 规格来源(2026-09-30 客户端逆向定案):
  3. - AutoFindMonsterCommand.lua: 选怪=全视野 squLen 最近, 跳同格怪,
  4. IsHaveMaster(宠物)永不打
  5. - RequestLaunchCommand.lua: 普攻 minDis=1 — dist=0 客户端本地拒绝发包;
  6. 不在出刀位只发移动不发攻击
  7. - AutoProxy.lua SetAFKTargetDeath: 击杀后原地等 0.2s 再重扫
  8. - AssistProxy.lua checkAimlessMove: 无怪漫游=±50 随机中距点(|dx|+|dy|>20),
  9. 不是 BFS 最远点
  10. """
  11. import json
  12. import sys
  13. import time
  14. from pathlib import Path
  15. sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
  16. import pytest
  17. from run_juece13_wugong_autofight import (
  18. ATTACK_INTERVAL,
  19. ATTACK_RADIUS,
  20. KILL_WAIT_S,
  21. PAPET_MIN_ID,
  22. AutoFightBehavior,
  23. )
  24. from pbot.world.actor import ActorKey, Monster
  25. from pbot.world.mirror import WorldMirror
  26. from tests.test_wander import SpyConn
  27. MINE = "比奇矿区"
  28. def mk_mon(key: int, x: int, y: int, *, name: str = "矿区僵尸",
  29. dead: bool = False) -> Monster:
  30. return Monster(key=ActorKey(key), x=x, y=y, race=1049, name=name,
  31. is_dead=dead)
  32. def mine_world(x: int = 100, y: int = 100) -> WorldMirror:
  33. w = WorldMirror()
  34. w.me.map_name = MINE
  35. w.me.map_id = "d401"
  36. w.me.actor_key = 7
  37. w.me.uid = "14570166001790661813000091989"
  38. w.me.x, w.me.y = x, y
  39. w.me.hp, w.me.max_hp = 500, 575
  40. return w
  41. def fresh_af(spy: SpyConn, world: WorldMirror, **kw: object) -> AutoFightBehavior:
  42. """空闲态 AutoFightBehavior: 步进门全开, 事件清空."""
  43. walls = kw.pop("walls", {(0, 0), (0, 1)}) # type: ignore[arg-type]
  44. af = AutoFightBehavior(sid=99, map_name=MINE, walls=walls, **kw) # pyright: ignore[reportArgumentType]
  45. af.on_enter(spy, world)
  46. af._last_step_t = 0.0
  47. af._last_attack_t = 0.0
  48. world.events.clear()
  49. spy.sent.clear()
  50. spy.pres.clear()
  51. return af
  52. def tick_until(af: AutoFightBehavior, spy: SpyConn, world: WorldMirror,
  53. n: int = 1) -> None:
  54. for _ in range(n):
  55. af.tick(spy, world) # pyright: ignore[reportArgumentType]
  56. world.events.clear()
  57. # ---------- 选怪: 真客户端过滤链 ----------
  58. def test_pick_ignores_pet_ids() -> None:
  59. """7 位数短ID=宠物/玩家量级(客户端 IsHaveMaster 等价) — 永不选."""
  60. w = mine_world()
  61. w.monsters[PAPET_MIN_ID + 12345] = mk_mon(PAPET_MIN_ID + 12345, 101, 100)
  62. w.monsters[56751] = mk_mon(56751, 105, 100)
  63. af = fresh_af(SpyConn(), w)
  64. m = af._pick_monster(w)
  65. assert m is not None and int(m.key) == 56751
  66. def test_pick_nearest_in_full_view() -> None:
  67. """全视野(服务器推送范围)取最近, 不受旧 VISION=8 环扫限制."""
  68. w = mine_world()
  69. w.monsters[111] = mk_mon(111, 110, 100) # dist 10 — 旧版扫不到
  70. w.monsters[222] = mk_mon(222, 118, 100) # dist 18
  71. af = fresh_af(SpyConn(), w)
  72. m = af._pick_monster(w)
  73. assert m is not None and int(m.key) == 111
  74. def test_pick_skips_same_cell_monster() -> None:
  75. """同格(dist=0)怪客户端直接跳过(FindNearestMonster 明确排除)."""
  76. w = mine_world()
  77. w.monsters[111] = mk_mon(111, 100, 100) # 与我同格
  78. w.monsters[222] = mk_mon(222, 103, 100)
  79. af = fresh_af(SpyConn(), w)
  80. m = af._pick_monster(w)
  81. assert m is not None and int(m.key) == 222
  82. def test_pick_skips_dead_and_blacklisted() -> None:
  83. w = mine_world()
  84. w.monsters[111] = mk_mon(111, 101, 100, dead=True)
  85. w.monsters[222] = mk_mon(222, 102, 100)
  86. af = fresh_af(SpyConn(), w)
  87. af._big_monsters.add(222)
  88. assert af._pick_monster(w) is None
  89. # ---------- 逼近: 先走位后出刀, 永不进怪格 ----------
  90. def test_approach_last_step_is_walk_not_run() -> None:
  91. """dist=2 时发 3011(1格走) — 真客户端最后一步永远 1 格, 防冲进怪格."""
  92. spy = SpyConn()
  93. w = mine_world(100, 100)
  94. w.monsters[56751] = mk_mon(56751, 102, 100) # dist=2
  95. af = fresh_af(spy, w)
  96. tick_until(af, spy, w, n=3)
  97. walks = [f for f in spy.sent if f[0] == 3011]
  98. runs = [f for f in spy.sent if f[0] == 3013]
  99. assert walks, "dist=2 必须发 3011 走步"
  100. assert not runs, "dist=2 不允许 3013 跑步(会冲到 dist=0)"
  101. def test_approach_never_steps_into_monster_cell() -> None:
  102. """逼近终点=怪相邻格: 任何情况下发出的移动落点都不等于怪坐标."""
  103. spy = SpyConn()
  104. w = mine_world(100, 100)
  105. w.monsters[56751] = mk_mon(56751, 104, 100) # dist=4
  106. af = fresh_af(spy, w)
  107. t = 1000.0
  108. for _ in range(8):
  109. af._now_override = t
  110. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  111. pre = spy.pres[-1] if spy.pres else 0
  112. w.me.x, w.me.y = pre & 0xFFFF, (pre >> 16) & 0xFFFF
  113. af._pending = False
  114. af._last_step_t = 0.0
  115. w.events.clear()
  116. t += 1.0
  117. if max(abs(w.monsters[56751].x - w.me.x),
  118. abs(w.monsters[56751].y - w.me.y)) <= 1:
  119. break
  120. assert (w.me.x, w.me.y) != (104, 100), "绝不允许踩进怪格(dist=0 不结算)"
  121. assert max(abs(104 - w.me.x), abs(100 - w.me.y)) <= 1
  122. def test_attack_only_in_range_and_interval() -> None:
  123. """dist=1(出刀位)才发 3014; dist=0 不发(客户端本地拒绝)."""
  124. spy = SpyConn()
  125. w = mine_world(100, 100)
  126. w.monsters[56751] = mk_mon(56751, 101, 100) # dist=1
  127. af = fresh_af(spy, w)
  128. tick_until(af, spy, w, n=2)
  129. attacks = [f for f in spy.sent if f[0] == 3014]
  130. assert attacks, "dist=1 应立即出刀"
  131. # dist=0: 同格怪根本不该被选为目标
  132. w2 = mine_world(101, 100)
  133. w2.monsters[56751] = mk_mon(56751, 101, 100)
  134. spy2 = SpyConn()
  135. af2 = fresh_af(spy2, w2)
  136. tick_until(af2, spy2, w2, n=2)
  137. assert af2._target_key != 56751
  138. assert not [f for f in spy2.sent if f[0] == 3014], \
  139. "同格怪不许打(打不中且浪费节拍)"
  140. def test_attack_interval_enforced() -> None:
  141. """出刀间隔内不重复发 3014(客户端 CD≈0.9s, 我们沿用 ATTACK_INTERVAL)."""
  142. spy = SpyConn()
  143. w = mine_world(100, 100)
  144. w.monsters[56751] = mk_mon(56751, 101, 100)
  145. af = fresh_af(spy, w)
  146. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  147. n1 = len([f for f in spy.sent if f[0] == 3014])
  148. af._now_override = time.time() + 0.05 # 间隔内
  149. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  150. n2 = len([f for f in spy.sent if f[0] == 3014])
  151. assert n1 == 1 and n2 == 1, "间隔内不应再出刀"
  152. assert KILL_WAIT_S == 0.2 # AutoProxy 定案: 击杀后等 0.2s
  153. # ---------- 击杀后: 0.2s 原地等待 ----------
  154. def test_kill_then_wait_window() -> None:
  155. """目标死亡计杀后, KILL_WAIT_S 窗口内不选新怪不漫游."""
  156. spy = SpyConn()
  157. w = mine_world(100, 100)
  158. w.monsters[56751] = mk_mon(56751, 101, 100)
  159. w.monsters[999] = mk_mon(999, 105, 100)
  160. af = fresh_af(spy, w, kill_goal=5)
  161. tick_until(af, spy, w, n=2)
  162. assert af._target_key == 56751
  163. # SC31 hp=0 → is_dead
  164. w.monsters[56751] = mk_mon(56751, 101, 100, dead=True)
  165. af._now_override = time.time() + 1.0
  166. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  167. w.events.clear()
  168. assert af.kills == 1
  169. assert af._kill_wait_until > 0, "击杀后应进入等待窗口"
  170. af._now_override = time.time() + 1.05 # 仍在等待窗口内
  171. spy.sent.clear()
  172. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  173. assert af._target_key is None, "等待窗口内不应锁新目标"
  174. assert not [f for f in spy.sent if f[0] == 3014]
  175. af._now_override = af._kill_wait_until + 0.01 # 窗口已过
  176. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  177. assert af._target_key == 999
  178. # ---------- 漫游: 随机中距点(真客户端 checkAimlessMove) ----------
  179. def test_roam_random_midrange_not_farthest() -> None:
  180. """无怪漫游: ±50 内随机中距点(|dx|+|dy|>20), 不再 BFS 最远点."""
  181. spy = SpyConn()
  182. w = mine_world(100, 100)
  183. af = fresh_af(spy, w, farthest_fn=None)
  184. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  185. assert af._roam_dest is not None
  186. dx = abs(af._roam_dest[0] - 100)
  187. dy = abs(af._roam_dest[1] - 100)
  188. assert dx <= 50 and dy <= 50, "漫游点必须在 ±50 内"
  189. assert dx + dy > 20, "漫游点必须是中距点(太近=原地抖动)"
  190. def test_roam_dest_never_negative() -> None:
  191. """run9 实证: 出生在 (28,28) 时随机点出负坐标(-8,58)/(48,-12) —
  192. A* 必失败 → 0.6s 连刷 5 个漫游点空转. 漫游点必须钳制在地图内."""
  193. for _ in range(200):
  194. spy = SpyConn()
  195. w = mine_world(5, 5) # 地图角落地带
  196. af = fresh_af(spy, w, farthest_fn=None)
  197. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  198. assert af._roam_dest is not None
  199. x, y = af._roam_dest
  200. assert x >= 0 and y >= 0, f"负坐标漫游点 ({x},{y})"
  201. def test_vision_no_longer_caps_targeting() -> None:
  202. """锁定 18 格外的怪不被弃目标(旧 VISION+2=10 会弃)."""
  203. spy = SpyConn()
  204. w = mine_world(100, 100)
  205. w.monsters[111] = mk_mon(111, 118, 100)
  206. af = fresh_af(spy, w)
  207. tick_until(af, spy, w, n=1)
  208. assert af._target_key == 111
  209. af._now_override = time.time() + 5.0
  210. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  211. assert af._target_key == 111, "全视野目标不应因距离被弃"
  212. # ---------- SC10 宠物过滤(update.py) ----------
  213. def test_sc10_rejects_pet_ids() -> None:
  214. """SC10: 人形(race 0/1/150)入 humanoids 登记不入怪表; 7位数宠物量级
  215. 非人形 race 的仍拒(cap_client_mine2: 5-6位数才是怪)."""
  216. import json as _json
  217. from pbot.codec.bitbuf import encode6
  218. from pbot.codec.frame import Frame
  219. from pbot.world.update import WorldUpdater
  220. def sc10(info0: str, race: int) -> Frame:
  221. body = encode6(_json.dumps(
  222. {"info": [info0, "x", 1, 0, 1], "X": 100, "Y": 100,
  223. "race": race}).encode())
  224. return Frame(msg_id=10, recog=race, p1=(100 << 16) | 100, p2=0,
  225. p3=0, tail=0, pre=0, body=body)
  226. w = WorldMirror()
  227. w.me.map_name = MINE
  228. up = WorldUpdater()
  229. up.apply(w, sc10("288999", 150)) # 人形(英雄/玩家)
  230. assert 288999 not in w.monsters
  231. assert 288999 in w.humanoids
  232. up.apply(w, sc10("56751", 1049)) # 5位数怪
  233. assert 56751 in w.monsters
  234. def test_attack_radius_is_one() -> None:
  235. """近战结算距离=1(普攻 minDis=maxDis=1, 服务器权威)."""
  236. assert ATTACK_RADIUS == 1
  237. def test_skill_rotation_on_consecutive_hits() -> None:
  238. """真客户端攻击流(cap_client_mine2): skillID 在 {0,7,12,25,26} 轮换
  239. (普攻/刺杀/攻杀/半月/烈火) — 连续出刀轮换技能提高结算伤害."""
  240. spy = SpyConn()
  241. w = mine_world(100, 100)
  242. w.monsters[56751] = mk_mon(56751, 101, 100)
  243. af = fresh_af(spy, w)
  244. t = 1000.0
  245. seen = []
  246. for i in range(8):
  247. af._now_override = t
  248. af.tick(spy, w) # pyright: ignore[reportArgumentType]
  249. for f in spy.sent:
  250. if f[0] == 3014:
  251. seen.append(json.loads(f[5])["skillID"])
  252. spy.sent.clear()
  253. t += ATTACK_INTERVAL + 0.01
  254. assert set(seen) >= {0, 12, 25, 26}, f"技能应轮换, 实际: {seen}"
  255. @pytest.mark.parametrize("bad", [PAPET_MIN_ID, PAPET_MIN_ID * 9])
  256. def test_pet_min_id_threshold(bad: int) -> None:
  257. """PAPET_MIN_ID 起为宠物/玩家量级."""
  258. assert bad >= PAPET_MIN_ID
  259. # ---------- run14 事故固化: 低血逃跑死循环 / hp=0 待机 ----------
  260. def test_flee_target_blacklisted_not_recomputed_same_point() -> None:
  261. """run14: 逃跑点(向量*30)拉黑后, 下一 tick 必须算出新逃跑点
  262. (垂直扰动), 而非同向量重算同点 → 60ms 死循环 24s 挨打致死."""
  263. w = mine_world(x=216, y=37)
  264. w.me.hp, w.me.max_hp = 300, 628
  265. w.monsters[98172] = mk_mon(98172, 217, 38, name="黑色恶蛆")
  266. spy = SpyConn()
  267. af = fresh_af(spy, w)
  268. af._now_override = 1000.0 # noqa: SLF001
  269. af.tick(spy, w) # noqa: SLF001
  270. first = af._roam_dest # noqa: SLF001
  271. assert first is not None, "低血必须立即产生逃跑点"
  272. # 模拟该点 A* 失败被拉黑
  273. af._roam_blacklist[first] = 1e9 # noqa: SLF001
  274. af.tick(spy, w) # noqa: SLF001
  275. second = af._roam_dest # noqa: SLF001
  276. assert second is not None and second not in af._roam_blacklist # noqa: SLF001
  277. assert second != first, "拉黑后不得重算同一个逃跑点"
  278. def test_fleeing_never_selects_target() -> None:
  279. """run14: 逃跑中选怪→弃目标 60ms 循环 — 逃跑态禁止选怪."""
  280. w = mine_world(x=216, y=37)
  281. w.me.hp, w.me.max_hp = 300, 628 # < FLEE_RATIO=0.55
  282. w.monsters[98172] = mk_mon(98172, 217, 38)
  283. spy = SpyConn()
  284. af = fresh_af(spy, w)
  285. af._now_override = 1000.0 # noqa: SLF001
  286. for _ in range(5):
  287. af.tick(spy, w) # noqa: SLF001
  288. assert af._target_key is None, "低血逃跑中绝不允许锁定目标" # noqa: SLF001
  289. def test_dead_wait_blocks_all_actions() -> None:
  290. """run14: hp=0 死亡后仍出刀 4 次 — me_die 事件进入待机态,
  291. 屏蔽选怪/攻击/漫游直到换图 DONE."""
  292. from pbot.world.mirror import ActorEvent
  293. w = mine_world(x=216, y=37)
  294. w.me.hp, w.me.max_hp = 0, 628
  295. w.monsters[98172] = mk_mon(98172, 217, 38)
  296. spy = SpyConn()
  297. af = fresh_af(spy, w)
  298. af._now_override = 1000.0 # noqa: SLF001
  299. w.events.append(ActorEvent(kind="me_die", actor=None, detail="by=98217"))
  300. af.tick(spy, w) # noqa: SLF001
  301. assert af._dead_wait is True # noqa: SLF001
  302. w.events.clear()
  303. spy.sent.clear()
  304. for _ in range(3):
  305. af.tick(spy, w) # noqa: SLF001
  306. assert not spy.sent, "死亡待机态不得发送任何帧(攻击/移动均禁止)" # noqa: SLF001