test_kill.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. """KillBehavior 打怪行为验证(不发真实网络 — SpyConn 桩)。"""
  2. import json
  3. import time
  4. from pbot.bot.kill import HIT_INTERVAL_S, KillBehavior
  5. from pbot.bot.wander import WAIT_MAP
  6. from pbot.types import ActorId
  7. from pbot.world.actor import ActorKey, Monster, Npc
  8. from pbot.world.mirror import WorldMirror
  9. from tests.test_wander import SpyConn
  10. BIQI_MAP = "比奇大城"
  11. def biqi_world(x: int = 293, y: int = 619) -> WorldMirror:
  12. """已在比奇、SC50/SC51 就绪的世界。"""
  13. world = WorldMirror()
  14. world.me.map_name = BIQI_MAP
  15. world.me.map_id = "0"
  16. world.me.actor_key = 7
  17. world.me.uid = "100861001020304"
  18. world.me.x, world.me.y = x, y
  19. return world
  20. def wait_world(x: int = 23, y: int = 27) -> WorldMirror:
  21. world = WorldMirror()
  22. world.me.map_name = WAIT_MAP
  23. world.me.actor_key = 7
  24. world.me.x, world.me.y = x, y
  25. return world
  26. def dungeon_world(x: int = 198, y: int = 28) -> WorldMirror:
  27. """地牢一层东: 任务副本图, 全图坐标 y<50。"""
  28. world = WorldMirror()
  29. world.me.map_name = "地牢一层东"
  30. world.me.map_id = "地牢一层东"
  31. world.me.actor_key = 7
  32. world.me.uid = "100861001020304"
  33. world.me.x, world.me.y = x, y
  34. return world
  35. def mk_monster(key: int, x: int, y: int) -> Monster:
  36. return Monster(key=ActorKey(key), x=x, y=y, race=1049, name="鸡",
  37. is_dead=False)
  38. def fresh_kill(sid: int, spy: SpyConn, world: WorldMirror,
  39. kill_target: int = 2) -> KillBehavior:
  40. b = KillBehavior(sid=sid, kill_target=kill_target,
  41. renwu_act="renwu_mov,2")
  42. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  43. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage] # 步进门已开
  44. spy.sent.clear() # on_enter 的挂机声明 3 帧不计入各用例断言
  45. spy.pres.clear()
  46. return b
  47. def die(world: WorldMirror, key: int) -> None:
  48. """模拟服务器 SC32: 置死 + 发事件(与 update.py 行为一致)。"""
  49. m = world.monsters.pop(key, None)
  50. if m is not None:
  51. world.monsters[key] = Monster(key=m.key, x=m.x, y=m.y, race=m.race,
  52. name=m.name, is_dead=True)
  53. world.record("mon_die", m.name if m else "?", key)
  54. def sent_ids(spy: SpyConn) -> list[int]:
  55. return [s[0] for s in spy.sent]
  56. # ---- 传送链: 等待图 → 比奇 ----
  57. def test_kill_already_in_quest_map_skips_renwu_chain() -> None:
  58. """Given: 已在任务副本图(地牢/洞穴) When: on_enter Then: 直接进 fight, 不发 3031/1011。
  59. 回归: 萌新3035 在地牢一层东跑图时, 旧 on_enter 无条件进 renwu_3031 →
  60. 触发 CS3031+CS1011 → 服务器可能重新传送/静默 → 角色被卡或挪点。
  61. 新逻辑: _arrived_quest_map 判定下直接进 fight, 配 LAND_QUIET_S 静置。
  62. """
  63. world = dungeon_world(198, 28)
  64. spy = SpyConn()
  65. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2")
  66. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  67. assert b._phase == "fight" # pyright: ignore[reportPrivateUsage]
  68. # 不发任务链帧(只有挂机声明 3 帧)
  69. assert sent_ids(spy) == [3199, 5286, 9102]
  70. # 落地静置: 0.5s 内不发任何移动/攻击帧
  71. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  72. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  73. assert sent_ids(spy) == [3199, 5286, 9102] # 仍只挂机声明
  74. def test_kill_from_wait_map_sends_3031_then_renwu_1011() -> None:
  75. """Given: 出生等待图 When: on_enter+两拍 Then: 先 3031 后 renwu_mov 1011。"""
  76. world = wait_world()
  77. spy = SpyConn()
  78. b = fresh_kill(sid=9, spy=spy, world=world)
  79. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType] # phase_t 刚置位, 0.2s 门未到 → 无帧
  80. assert sent_ids(spy) == []
  81. b._phase_t -= 0.3 # pyright: ignore[reportPrivateUsage]
  82. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType] # 发 3031
  83. assert sent_ids(spy) == [3031]
  84. b._phase_t -= 1.2 # pyright: ignore[reportPrivateUsage]
  85. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType] # 等 1s 后发 1011 renwu_mov
  86. ids = sent_ids(spy)
  87. assert 1011 in ids
  88. f1011 = next(s for s in spy.sent if s[0] == 1011)
  89. body: dict[str, object] = json.loads(f1011[5]) # pyright: ignore[reportAny]
  90. assert body["Act"] == "@renwu_mov,2"
  91. # 等待图无传送: 不预发比奇 ACK(5012/5027) — 只在异图 renwu 时预发
  92. assert 5012 not in ids
  93. def test_kill_biqi_ack_uses_protocol_name_zero() -> None:
  94. """Given: 等待图点接待NPC When: 发 CS1010 Then: 比奇 ACK body='0'(协议图名)。"""
  95. world = wait_world()
  96. world.npcs[501] = Npc(key=ActorKey(501), aid=ActorId("10032"), name="新手接待员",
  97. x=23, y=27, index=35)
  98. spy = SpyConn()
  99. b = fresh_kill(sid=9, spy=spy, world=world)
  100. b._phase_t -= 1.0 # pyright: ignore[reportPrivateUsage] # 跳过 3031 前置
  101. b._phase = "npc_click" # pyright: ignore[reportPrivateUsage]
  102. b._npc_aid = "10032" # pyright: ignore[reportPrivateUsage]
  103. b._npc_idx_cands = [35] # pyright: ignore[reportPrivateUsage]
  104. b._phase_t -= 0.5 # pyright: ignore[reportPrivateUsage]
  105. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  106. ids = sent_ids(spy)
  107. assert 1010 in ids
  108. assert ids.count(5012) == 4 and ids.count(5027) == 2 # 2轮 ACK: 每轮 5012x2+5027
  109. f5027 = next(s for s in spy.sent if s[0] == 5027)
  110. assert f5027[5] == b"0" # 比奇协议图名(不是显示名)
  111. cth = [s for s in spy.sent if s[0] == 5012 and s[4] == 32611848]
  112. assert len(cth) == 2 and cth[0][5] == b"cTh"
  113. # ---- 战斗: 选怪 → CS3014 → SC32 计杀 ----
  114. def test_kill_sends_3014_with_exact_body_layout() -> None:
  115. """Given: 比奇视野有怪 When: tick Then: 攻击帧 3014 body 与 backup 实证同构。"""
  116. world = biqi_world(293, 620)
  117. world.monsters[123] = mk_monster(123, 293, 621)
  118. spy = SpyConn()
  119. b = fresh_kill(sid=9, spy=spy, world=world)
  120. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  121. b._target_key = 123 # pyright: ignore[reportPrivateUsage]
  122. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  123. assert sent_ids(spy) == [3014]
  124. msg_id, recog, p1, p2, p3, body = spy.sent[0]
  125. assert (msg_id, recog, p1, p2, p3) == (3014, 0, 0, 0, 0)
  126. payload: dict[str, object] = json.loads(body) # pyright: ignore[reportAny]
  127. assert payload == {"skillID": 0, "dir": 4, "srcX": 293, "srcY": 620,
  128. "destX": 293, "destY": 621, "targetID": "123"}
  129. def test_kill_hit_interval_throttles() -> None:
  130. """Given: 刚攻击过 When: 立即再 tick Then: 不连发(HitTime 0.65s 节流)。"""
  131. world = biqi_world()
  132. world.monsters[1] = mk_monster(1, 293, 620)
  133. spy = SpyConn()
  134. b = fresh_kill(sid=9, spy=spy, world=world)
  135. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  136. b._target_key = 1 # pyright: ignore[reportPrivateUsage]
  137. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  138. assert len(spy.sent) == 1
  139. b._last_hit_t = time.time() - HIT_INTERVAL_S + 0.2 # pyright: ignore[reportPrivateUsage]
  140. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  141. assert len(spy.sent) == 1 # 节流窗口内, 零发包
  142. def test_kill_counts_only_owned_sc32() -> None:
  143. """Given: 6s 内攻击过 aid=123 When: mon_die 事件 Then: kills+1; 未攻击怪不计。"""
  144. world = biqi_world()
  145. world.monsters[123] = mk_monster(123, 293, 620)
  146. world.monsters[999] = mk_monster(999, 300, 630)
  147. spy = SpyConn()
  148. b = fresh_kill(sid=9, spy=spy, world=world, kill_target=2)
  149. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  150. b._target_key = 123 # pyright: ignore[reportPrivateUsage]
  151. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType] # 攻击 123 → 归属登记
  152. die(world, 123) # 没打过的 999 也死 → 不计
  153. die(world, 999)
  154. assert world.kills == 0
  155. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  156. assert world.kills == 1
  157. # 超归属窗口的击杀不计
  158. b._my_hits[555] = time.time() - 60.0 # pyright: ignore[reportPrivateUsage]
  159. world.record("mon_die", "鹿", 555)
  160. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  161. assert world.kills == 1
  162. def test_kill_reaches_target_count_and_done() -> None:
  163. """Given: kill_target=2 已计 1 When: 第二杀入账 When: tick → DONE。"""
  164. world = biqi_world()
  165. world.monsters[1] = mk_monster(1, 293, 620)
  166. spy = SpyConn()
  167. b = fresh_kill(sid=9, spy=spy, world=world, kill_target=2)
  168. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  169. b._target_key = 1 # pyright: ignore[reportPrivateUsage]
  170. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  171. die(world, 1)
  172. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  173. assert world.kills == 1
  174. assert sent_ids(spy).count(3014) == 1
  175. # 第二只: 攻击→死→tick 应 DONE
  176. world.monsters[2] = mk_monster(2, 293, 620)
  177. b._last_hit_t = 0.0 # pyright: ignore[reportPrivateUsage]
  178. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  179. die(world, 2)
  180. result = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  181. assert result.name == "DONE"
  182. def test_kill_target_filter_biqi_coordinates_only() -> None:
  183. """Given: 怪 y=30(等待图残留)与 y=620(比奇) When: 选怪 Then: 只选比奇怪。"""
  184. world = biqi_world(293, 620)
  185. world.monsters[10] = mk_monster(10, 293, 30)
  186. world.monsters[11] = mk_monster(11, 294, 622)
  187. spy = SpyConn()
  188. b = fresh_kill(sid=9, spy=spy, world=world)
  189. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  190. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  191. m = b._current_target(world) # pyright: ignore[reportPrivateUsage]
  192. assert m is not None and int(m.key) == 11
  193. def test_kill_walks_when_out_of_attack_radius() -> None:
  194. """Given: 怪在 5 格外 When: tick Then: 跑近(3013) 而非攻击。"""
  195. world = biqi_world()
  196. world.monsters[7] = mk_monster(7, 293, 625) # dist=5
  197. spy = SpyConn()
  198. b = fresh_kill(sid=9, spy=spy, world=world)
  199. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  200. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  201. assert sent_ids(spy) == [3013]
  202. def test_kill_no_monster_spawns_via_npc() -> None:
  203. """Given: 视野无怪有【点我刷怪】NPC 且冷却已过 When: tick Then: 点 NPC(1010)。"""
  204. world = biqi_world()
  205. world.npcs[900] = Npc(key=ActorKey(900), aid=ActorId("777"), name="点我刷怪",
  206. x=293, y=620, index=12)
  207. spy = SpyConn()
  208. b = fresh_kill(sid=9, spy=spy, world=world)
  209. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  210. b._no_mon_since = time.time() - 5.0 # pyright: ignore[reportPrivateUsage]
  211. b._spawn_cd_t = time.time() - 10.0 # pyright: ignore[reportPrivateUsage]
  212. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  213. f1010 = next(s for s in spy.sent if s[0] == 1010)
  214. body: dict[str, object] = json.loads(f1010[5]) # pyright: ignore[reportAny]
  215. assert body == {"UserID": "777", "index": 12}
  216. assert sent_ids(spy).count(3014) == 0
  217. def test_kill_death_waits_revive_then_resumes() -> None:
  218. """Given: 战斗中自己死亡 When: me_die→me_revive Then: 清视野→goto 重走。"""
  219. world = biqi_world(293, 610) # 离杀怪区 12 格
  220. world.monsters[1] = mk_monster(1, 293, 612)
  221. spy = SpyConn()
  222. b = fresh_kill(sid=9, spy=spy, world=world)
  223. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  224. b._target_key = 1 # pyright: ignore[reportPrivateUsage]
  225. world.record("me_die")
  226. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  227. assert b._phase == "dead" # pyright: ignore[reportPrivateUsage]
  228. world.record("me_revive")
  229. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  230. assert b._phase == "goto" # pyright: ignore[reportPrivateUsage]
  231. assert len(world.monsters) == 0 # 复活清视野
  232. def test_kill_sc31_lethal_credit_only_by_me() -> None:
  233. """Given: SC31 Hp=0 AttackUserID=me When: tick Then: 计杀; 他人攻击不计。"""
  234. world = biqi_world()
  235. world.me.uid = "14537716001790385545000065553"
  236. world.monsters[123] = mk_monster(123, 293, 620)
  237. spy = SpyConn()
  238. b = fresh_kill(sid=9, spy=spy, world=world)
  239. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  240. b._target_key = 123 # pyright: ignore[reportPrivateUsage]
  241. world.record("struck", "123 hp=0/25 by=14537716001790385545000065553", 123)
  242. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  243. assert world.kills == 1
  244. # 他人击杀: 攻击者 uid 不匹配 → 不计
  245. world.monsters[124] = mk_monster(124, 294, 620)
  246. world.record("struck", "124 hp=0/25 by=99999999999999999999999999", 124)
  247. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  248. assert world.kills == 1
  249. def test_kill_sc31_non_lethal_ignored() -> None:
  250. """Given: SC31 Hp>0 When: tick Then: 不计杀, 怪未标死。"""
  251. world = biqi_world()
  252. world.me.uid = "14537716001790385545000065553"
  253. world.monsters[123] = mk_monster(123, 293, 620)
  254. spy = SpyConn()
  255. b = fresh_kill(sid=9, spy=spy, world=world)
  256. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  257. world.record("struck", "123 hp=20/25 by=14537716001790385545000065553", 123)
  258. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  259. assert world.kills == 0
  260. assert not world.monsters[123].is_dead
  261. def test_kill_declares_guaji_on_enter() -> None:
  262. """Given: on_enter When: 声明 Then: 发 3199@startguaji+5286+9102 三连。"""
  263. world = biqi_world()
  264. spy = SpyConn()
  265. b = KillBehavior(sid=9, kill_target=2)
  266. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  267. ids = sent_ids(spy)
  268. assert ids == [3199, 5286, 9102]
  269. f3199 = next(s for s in spy.sent if s[0] == 3199)
  270. body: dict[str, object] = json.loads(f3199[5]) # pyright: ignore[reportAny]
  271. assert body["act"] == "@startguaji"
  272. assert spy.sent[2][1] == 1 # 9102 recog=1
  273. def test_kill_reroll_disabled_keeps_wandering() -> None:
  274. """Given: allow_reroll=False 无怪超 45s When: tick Then: 不发 3031, 漫游跑图。"""
  275. world = biqi_world()
  276. spy = SpyConn()
  277. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  278. allow_reroll=False)
  279. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  280. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  281. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  282. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  283. spy.sent.clear()
  284. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  285. ids = sent_ids(spy)
  286. assert 3031 not in ids
  287. assert 1011 not in ids
  288. # 漫游走/跑一步刷新视野
  289. assert any(i in (3011, 3013) for i in ids)
  290. def test_kill_waypoint_runs_toward_point_then_holds() -> None:
  291. """Given: waypoint=(300,619) 在东方无怪 When: tick Then: 向东跑不重掷落点。
  292. 回归: PBOT_WAYPOINT — 无怪时应先赶到指定点位附近再游走找怪,
  293. 而非随机漫游或重走 renwu 链。
  294. """
  295. world = biqi_world(293, 619)
  296. spy = SpyConn()
  297. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  298. allow_reroll=False, waypoint=(300, 619))
  299. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  300. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  301. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  302. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  303. spy.sent.clear()
  304. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  305. run = next(f for f in spy.sent if f[0] in (3011, 3013))
  306. assert run[2] == 2 # p1=heading: 向东(DIRS[2]=(+1,0))
  307. def test_kill_waypoint_arrives_switches_to_global_search() -> None:
  308. """Given: 赶到 waypoint 6 格内 When: tick Then: 弃点位切全局探索。
  309. 回归: 旧逻辑驻留小圈游走, 边界处赶点/游走互相拉扯产生
  310. (198,30)↔(200,28) A↔B 跨界振荡。人类玩法: 到点后直接满图跑。
  311. """
  312. world = biqi_world(299, 620)
  313. spy = SpyConn()
  314. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  315. allow_reroll=False, waypoint=(300, 619))
  316. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  317. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  318. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  319. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  320. spy.sent.clear()
  321. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  322. # 已到点: waypoint 被弃, 转全局探索(设了新探索锚点)
  323. assert b._waypoint is None # pyright: ignore[reportPrivateUsage]
  324. assert b._search_anchor is not None # pyright: ignore[reportPrivateUsage]
  325. def test_kill_dungeon_finite_target_uses_novel_anchor() -> None:
  326. """Given: 任务图 kill=100 无怪 When: 选锚点 Then: 走新颖度分支非 bound。
  327. 回归: 新颖度锚点曾仅 land_only/kill>9998 生效, 有限目标在矿区
  328. 走 bound 默认±25 且 x≤220/y≤50 钳制(旧新手图遗产), 跑图钉死角落。
  329. """
  330. world = dungeon_world(85, 18)
  331. spy = SpyConn()
  332. b = KillBehavior(sid=9, kill_target=100, renwu_act="renwu_mov,2",
  333. allow_reroll=False)
  334. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  335. assert b._novel_anchor_mode() # pyright: ignore[reportPrivateUsage]
  336. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  337. b._phase_t = 0.0 # pyright: ignore[reportPrivateUsage] # 跳过落地静置
  338. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  339. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  340. spy.sent.clear()
  341. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  342. anchor = b._search_anchor # pyright: ignore[reportPrivateUsage]
  343. assert anchor is not None
  344. def test_kill_biqi_finite_target_keeps_bound_anchor() -> None:
  345. """Given: 比奇大城 kill=100 When: 选锚点 Then: 仍走 bound 分支防跑丢。"""
  346. world = biqi_world(293, 619)
  347. spy = SpyConn()
  348. b = KillBehavior(sid=9, kill_target=100, renwu_act="renwu_mov,2")
  349. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  350. assert not b._novel_anchor_mode() # pyright: ignore[reportPrivateUsage]
  351. def test_kill_low_hp_in_dungeon_flees() -> None:
  352. """Given: 任务图血 20/100 且有目标 When: tick Then: 弃目标进 flee, 目标冷却, 移动跑远。"""
  353. world = dungeon_world(198, 28)
  354. world.me.hp, world.me.max_hp = 20, 100
  355. world.monsters[100] = mk_monster(100, 200, 30)
  356. spy = SpyConn()
  357. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2")
  358. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  359. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  360. b._phase_t = 0.0 # pyright: ignore[reportPrivateUsage]
  361. b._target_key = 100 # pyright: ignore[reportPrivateUsage]
  362. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  363. spy.sent.clear()
  364. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  365. assert b._phase == "flee" # pyright: ignore[reportPrivateUsage]
  366. assert b._retry_after.get(100) is not None # pyright: ignore[reportPrivateUsage]
  367. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  368. assert any(i in (3011, 3013) for i in sent_ids(spy))
  369. def test_kill_flee_resumes_when_healed() -> None:
  370. """Given: flee 中血回 60/100 When: tick Then: 复战回 fight。"""
  371. world = dungeon_world(198, 28)
  372. world.me.hp, world.me.max_hp = 60, 100
  373. spy = SpyConn()
  374. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2")
  375. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  376. b._phase = "flee" # pyright: ignore[reportPrivateUsage]
  377. spy.sent.clear()
  378. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  379. assert b._phase == "fight" # pyright: ignore[reportPrivateUsage]
  380. def test_kill_low_hp_in_town_does_not_flee() -> None:
  381. """Given: 主城血 20/100(安全区) When: tick Then: 不进 flee。"""
  382. world = biqi_world(293, 619)
  383. world.me.hp, world.me.max_hp = 20, 100
  384. spy = SpyConn()
  385. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2")
  386. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  387. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  388. spy.sent.clear()
  389. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  390. assert b._phase == "fight" # pyright: ignore[reportPrivateUsage]
  391. def test_kill_elite_never_targeted_and_radius_avoided() -> None:
  392. """Given: #100 已标记精英, #101 在其 12 格内, #102 在 15 格外 When: 选怪 Then: 只选 #102。"""
  393. world = dungeon_world(198, 28)
  394. world.monsters[100] = mk_monster(100, 190, 30)
  395. world.monsters[101] = mk_monster(101, 195, 32)
  396. world.monsters[102] = mk_monster(102, 207, 33)
  397. spy = SpyConn()
  398. b = KillBehavior(sid=9, kill_target=50, renwu_act="renwu_mov,2")
  399. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  400. b._elite.add(100) # pyright: ignore[reportPrivateUsage]
  401. m = b._pick_target(world) # pyright: ignore[reportPrivateUsage]
  402. assert m is not None and int(m.key) == 102
  403. def test_kill_heavy_strike_marks_elite() -> None:
  404. """Given: 我被打单刀 70/305(≥15%) When: 事件吸收 Then: 攻击者(by)进 _elite。"""
  405. world = dungeon_world(198, 28)
  406. world.me.max_hp = 305
  407. spy = SpyConn()
  408. b = KillBehavior(sid=9, kill_target=50, renwu_act="renwu_mov,2")
  409. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  410. b._world = world # pyright: ignore[reportPrivateUsage]
  411. world.record("struck",
  412. f"{world.me.actor_key} hp=235/305 dmg=70 by=999", 999)
  413. b._absorb_events(world) # pyright: ignore[reportPrivateUsage]
  414. assert 999 in b._elite # pyright: ignore[reportPrivateUsage]
  415. def test_kill_light_strike_not_elite() -> None:
  416. """Given: 我被打单刀 8/305(<15%) When: 事件吸收 Then: 不标记。"""
  417. world = dungeon_world(198, 28)
  418. world.me.max_hp = 305
  419. spy = SpyConn()
  420. b = KillBehavior(sid=9, kill_target=50, renwu_act="renwu_mov,2")
  421. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  422. world.record("struck",
  423. f"{world.me.actor_key} hp=297/305 dmg=8 by=999", 999)
  424. b._absorb_events(world) # pyright: ignore[reportPrivateUsage]
  425. assert 999 not in b._elite # pyright: ignore[reportPrivateUsage]
  426. def test_kill_my_strike_on_monster_not_elite() -> None:
  427. """Given: 我打怪出重刀(受害者=怪) When: 事件吸收 Then: 我不被标记(方向校验)。"""
  428. world = dungeon_world(198, 28)
  429. world.me.max_hp = 305
  430. spy = SpyConn()
  431. b = KillBehavior(sid=9, kill_target=50, renwu_act="renwu_mov,2")
  432. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  433. world.record("struck", "123 hp=0/230 dmg=50 by=" + world.me.uid, 123)
  434. b._absorb_events(world) # pyright: ignore[reportPrivateUsage]
  435. assert not b._elite # pyright: ignore[reportPrivateUsage]
  436. def test_kill_current_elite_target_triggers_flee() -> None:
  437. """Given: 交战中目标被标记精英 When: tick Then: 弃战进 flee。"""
  438. world = dungeon_world(198, 28)
  439. world.me.hp, world.me.max_hp = 300, 305
  440. world.monsters[100] = mk_monster(100, 200, 30)
  441. spy = SpyConn()
  442. b = KillBehavior(sid=9, kill_target=50, renwu_act="renwu_mov,2")
  443. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  444. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  445. b._phase_t = 0.0 # pyright: ignore[reportPrivateUsage]
  446. b._target_key = 100 # pyright: ignore[reportPrivateUsage]
  447. b._elite.add(100) # pyright: ignore[reportPrivateUsage]
  448. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  449. spy.sent.clear()
  450. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  451. assert b._phase == "flee" # pyright: ignore[reportPrivateUsage]
  452. def test_map_switch_resets_grid() -> None:
  453. """Given: 3.map 已载入(墙格 A) When: 切图 d401 Then: GRID 重置, 只剩新图墙。
  454. 回归: GRID 全局单例跨图累积 — 盟重96779+d401 4202 并集封死可走区,
  455. 3039 次 A* 无路热循环(v5 实证)。
  456. """
  457. from pbot.bot.kill import GRID
  458. world = dungeon_world(198, 28)
  459. spy = SpyConn()
  460. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2")
  461. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  462. GRID.mark_blocked((999, 999)) # 模拟上一图遗留墙格
  463. world.me.map_name = "比奇大城" # 切图
  464. world.me.map_id = "0"
  465. b._loaded_maps.discard("比奇大城") # pyright: ignore[reportPrivateUsage]
  466. b._load_map_if_needed(world) # pyright: ignore[reportPrivateUsage]
  467. assert (999, 999) not in GRID.blocked
  468. def test_novel_anchor_skips_blocked_cells() -> None:
  469. """Given: 候选锚点带大量墙格 When: 换锚 Then: 选出的锚点不在墙格集。"""
  470. from pbot.bot.kill import GRID
  471. world = dungeon_world(50, 50)
  472. spy = SpyConn()
  473. b = KillBehavior(sid=9, kill_target=100, renwu_act="renwu_mov,2")
  474. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  475. for x in range(20, 81):
  476. for y in range(20, 81):
  477. GRID.mark_blocked((x, y))
  478. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  479. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  480. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  481. spy.sent.clear()
  482. for _ in range(10):
  483. b._search_anchor = None # pyright: ignore[reportPrivateUsage]
  484. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  485. anchor = b._search_anchor # pyright: ignore[reportPrivateUsage]
  486. if anchor is not None:
  487. assert anchor not in GRID.blocked
  488. GRID.reset()
  489. def test_kill_waypoint_wall_detour_changes_direction() -> None:
  490. """Given: 赶点时 SC28 拉回(撞墙) When: 再 tick Then: 禁行格子并重规划。
  491. 回归: 地牢一层东 @(186,29) 赶点 dir=2 连发 30+ 次全 SC28 拒绝。
  492. 新架构: 弹回格 mark_blocked + A* 重新规划 — 不再原地硬撞。
  493. """
  494. from pbot.bot.kill import GRID
  495. world = biqi_world(299, 620)
  496. spy = SpyConn()
  497. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  498. allow_reroll=False, waypoint=(400, 620))
  499. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  500. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  501. b._no_mon_since = time.time() - 50.0 # pyright: ignore[reportPrivateUsage]
  502. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  503. spy.sent.clear()
  504. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  505. assert spy.sent[-1][2] == 2 # 第一刀朝东
  506. # 模拟服务器 SC28 拉回: 位置不变 + pull_back 事件(= 撞墙)
  507. world.record("pull_back", "old=(299,620) server=(299,620)")
  508. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  509. spy.sent.clear()
  510. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  511. assert b._rejected == 0 # pyright: ignore[reportPrivateUsage] # 已消费
  512. # 撞墙后的东向目标格被标记禁行 — 后续规划会避开
  513. blocked_now = len(GRID.blocked)
  514. assert blocked_now >= 1
  515. run = next(f for f in spy.sent if f[0] in (3011, 3013))
  516. assert run[2] != 2 # 不再朝东硬撞 — 已换新路径绕行
  517. def test_kill_dungeon_monster_low_y_is_selectable() -> None:
  518. """Given: 地牢一层东(任务副本, 全图 y<50) 雷达怪 When: tick Then: 选怪攻击。
  519. 回归: BIQI_MIN_Y=50 过滤把地牢全图坐标的怪当'等待图残留'丢弃 →
  520. 眼前有怪也 0 选怪, 只会朝 waypoint 硬冲。人类玩家看见就打。
  521. """
  522. world = dungeon_world(198, 28)
  523. world.monsters[51] = Monster(key=ActorKey(51), x=200, y=30, race=0,
  524. name="51", is_dead=False)
  525. spy = SpyConn()
  526. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  527. allow_reroll=False, waypoint=(205, 29))
  528. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  529. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  530. b._phase_t = 0.0 # pyright: ignore[reportPrivateUsage] # 跳过 LAND_QUIET_S
  531. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage] # 步进门开
  532. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  533. assert 3014 in sent_ids(spy) # 直接开打, 不是绕路
  534. def test_kill_never_targets_player_sized_ids() -> None:
  535. """Given: 怪表里混入玩家短ID(7位数) When: 选怪 Then: 拒绝选大数 ID。
  536. 回归: SC14 把玩家攻击广播误入怪表(2293955), bot 连砍 90 刀打真人。
  537. 玩家角色与宠物永不攻击。
  538. """
  539. world = dungeon_world(198, 28)
  540. world.monsters[2293955] = Monster(key=ActorKey(2293955), x=200, y=30,
  541. race=0, name="0", is_dead=False)
  542. world.monsters[51] = Monster(key=ActorKey(51), x=200, y=30, race=0,
  543. name="51", is_dead=False)
  544. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  545. allow_reroll=False)
  546. b.on_enter(SpyConn(), world) # pyright: ignore[reportArgumentType]
  547. m = b._pick_target(world) # pyright: ignore[reportPrivateUsage]
  548. assert m is not None and int(m.key) == 51
  549. def test_kill_waypoint_detour_prefers_on_course() -> None:
  550. """Given: 赶点路径被 NPC 占位 When: 避障 Then: 挑离目标最近的绕行向。
  551. 回归: (200,30)↔(198,28) 永久振荡 — 避障固定取 side[0],
  552. 两点互为对方回退向。
  553. """
  554. world = dungeon_world(198, 28)
  555. world.npcs[51] = Npc(key=ActorKey(51), aid=ActorId("777"), name="NPC",
  556. x=201, y=28, index=1)
  557. spy = SpyConn()
  558. b = KillBehavior(sid=9, kill_target=2, renwu_act="renwu_mov,2",
  559. allow_reroll=False, waypoint=(205, 29))
  560. b.on_enter(spy, world) # pyright: ignore[reportArgumentType]
  561. b._phase = "fight" # pyright: ignore[reportPrivateUsage]
  562. b._phase_t = time.time() - 100.0 # pyright: ignore[reportPrivateUsage]
  563. b._last_step_t = 0.0 # pyright: ignore[reportPrivateUsage]
  564. spy.sent.clear()
  565. _ = b.tick(spy, world) # pyright: ignore[reportArgumentType]
  566. run = next(f for f in spy.sent if f[0] in (3011, 3013))
  567. # 绕行向必须仍朝目标方向带推进: dx 分量 >= 0(东), 不能选西向回退
  568. assert run[2] in (0, 1, 2, 3, 4) # 拒绝 5(左下) 6(左) 7(左上)
  569. def test_update_sc10_actor_coords_live_in_p1() -> None:
  570. """Given: SC10 body.info[0]=ID + X/Y(2026-09-30 cap_live2 定案)
  571. When: apply Then: 怪坐标正确可选。
  572. 布局翻案: 本服 SC10 key=body.info[0], 坐标=body.X/Y; 帧头
  573. p1=(y<<16)|x 只是坐标回显。旧定案(p1 当实体ID)是 run7
  574. "打坐标数字零结算"根因。
  575. """
  576. import json as _json
  577. from pbot.codec.frame import Frame
  578. from pbot.world.update import WorldUpdater
  579. body = _json.dumps({"cs": 0, "info": ["44871", "稻草人", "稻草人"],
  580. "X": 327, "Y": 331, "race": 1049},
  581. separators=(",", ":")).encode()
  582. f = Frame(msg_id=10, recog=1049, p1=(331 << 16) | 327, p2=0, p3=48,
  583. pre=0, tail=0, body=body)
  584. world = WorldMirror()
  585. WorldUpdater().apply(world, f)
  586. m = world.monsters.get(44871)
  587. assert m is not None
  588. assert (m.x, m.y) == (327, 331)
  589. assert m.race == 1049