auto_pathfind.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. """auto_pathfind.py — 专用自动寻路计时脚本(无战斗分支, 纯跑图)。
  2. 复刻真实客户端【自动寻路】实现思路:
  3. 1. 传送落地(任务链 CS3031 → CS1011 @act_move,N)
  4. 2. 加载地图数据(d401.map 等), 从落脚点 BFS 全图可走连通域
  5. 3. 选离落脚点欧氏距离最远的可达可走格作为目标(保证图内+可达)
  6. 4. 用真客户端还原的 A*(cpp_reversed/path_find.py: aiManager::FindPath
  7. 8向/直线10/对角14/欧氏²启发/防切角)按 48x32 包围盒分段拼接规划路径
  8. 5. CS5013(act=1,X,Y) 注册寻路会话 → 沿路径逐腿 CS3013 跑
  9. (同向连吃 2 格对齐客户端 checkNextMovePos 跑步步长, pre=预测落点)
  10. 6. 到达 → CS5013(act=3 FINISH); 全程计时+帧统计
  11. 用法:
  12. uv run python scripts/auto_pathfind.py -u <账号> -p <密码> \
  13. --zone 14558825 --name 凌风踏雪 --map 地牢一层东 --act act_move,2
  14. 可选: --target x,y 指定目标(默认自动选最远可达点); --dry-run 只规划不发包。
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import logging
  19. import sys
  20. import time
  21. from collections import deque
  22. from pathlib import Path
  23. ROOT = Path(__file__).resolve().parents[2]
  24. sys.path.insert(0, str(ROOT / "cpp_reversed"))
  25. sys.path.insert(0, str(ROOT / "protocol_bot" / "src"))
  26. import requests # noqa: E402
  27. from pbot.config import Account # noqa: E402
  28. from pbot.game.messages import protocol_map_name # noqa: E402
  29. from pbot.game.session import enter_world # noqa: E402
  30. from pbot.login.box_client import enter_game_client, login # noqa: E402
  31. from pbot.transport.gateway import GatewayConnection, login_gate_with_retry # noqa: E402
  32. from pbot.types import ZoneId # noqa: E402
  33. from pbot.world.update import WorldUpdater # noqa: E402
  34. from map_parser import MapData, load as load_map # noqa: E402
  35. from path_find import find_path, path_is_valid # noqa: E402
  36. logging.basicConfig(level=logging.INFO,
  37. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  38. stream=sys.stdout)
  39. _LOG = logging.getLogger("auto_pathfind")
  40. DIRS = ((0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1))
  41. DIS_X, DIS_Y = 24, 16 # 真客户端 updateLimits 包围盒(PathFindAStar.lua:52-53)
  42. RUN_STEP_S = 0.60 # 步频(真客户端 0.55-0.6s, M2 封速门 1.4s 由 SC1 门控保证)
  43. PENDING_TIMEOUT_S = 1.0
  44. ARRIVE_DIST = 1
  45. MAP_FILES = {
  46. "地牢一层东": "d401.map",
  47. "天然洞穴一层": "d011.map",
  48. "比奇大城": "0.map",
  49. "盟重土城": "3.map",
  50. }
  51. PORTS = (8021, 8061, 8001, 8041)
  52. def bfs_walkable(m: MapData, start: tuple[int, int]) -> dict[tuple[int, int], int]:
  53. """从 start 做 8 向 BFS(含防切角), 返回 {格: 步数} — 服务器可走域近似。"""
  54. walk = [[m.get_cell(x, y).is_walkable
  55. for x in range(m.width)] for y in range(m.height)]
  56. dist: dict[tuple[int, int], int] = {start: 0}
  57. q: deque[tuple[int, int]] = deque([start])
  58. while q:
  59. x, y = q.popleft()
  60. for dx, dy in DIRS:
  61. nx, ny = x + dx, y + dy
  62. if not (0 <= nx < m.width and 0 <= ny < m.height):
  63. continue
  64. if not walk[ny][nx] or (nx, ny) in dist:
  65. continue
  66. if dx and dy and (not walk[y][x + dx] or not walk[y + dy][x]):
  67. continue # 防切角: 真客户端 A* 同款规则
  68. dist[(nx, ny)] = dist[(x, y)] + 1
  69. q.append((nx, ny))
  70. return dist
  71. def pick_farthest(dist: dict[tuple[int, int], int],
  72. start: tuple[int, int]) -> tuple[int, int]:
  73. """欧氏距离最远的可达格(相同取步数多者)。"""
  74. return max(dist, key=lambda p: ((p[0] - start[0]) ** 2 + (p[1] - start[1]) ** 2,
  75. dist[p]))
  76. def plan_multiseg(m: MapData, beg: tuple[int, int],
  77. end: tuple[int, int]) -> list[tuple[int, int]] | None:
  78. """分段 A*: 每段 waypoint 限制在 48x32 包围盒内(真客户端
  79. PathFindController 多段调用拼接思路), 段终点被墙挡时螺旋扰动重试。
  80. """
  81. full: list[tuple[int, int]] = [beg]
  82. cur = beg
  83. while cur != end:
  84. dx, dy = end[0] - cur[0], end[1] - cur[1]
  85. d = max(abs(dx), abs(dy))
  86. if d == 0:
  87. break
  88. step = min(20, d)
  89. wx = cur[0] + round(dx * step / d)
  90. wy = cur[1] + round(dy * step / d)
  91. wx = max(cur[0] - DIS_X, min(cur[0] + DIS_X, wx))
  92. wy = max(cur[1] - DIS_Y, min(cur[1] + DIS_Y, wy))
  93. got: list[tuple[int, int]] | None = None
  94. for r in range(9):
  95. cands = [(wx + ox, wy + oy)
  96. for ox in range(-r, r + 1) for oy in range(-r, r + 1)
  97. if max(abs(ox), abs(oy)) == r]
  98. for c in cands:
  99. if not (0 <= c[0] < m.width and 0 <= c[1] < m.height):
  100. continue
  101. seg = find_path(m, cur[0], cur[1], c[0], c[1])
  102. if seg:
  103. got = seg
  104. break
  105. if got:
  106. break
  107. if not got:
  108. return None
  109. full.extend(got[1:])
  110. cur = got[-1]
  111. if len(full) > 3000:
  112. return None
  113. return full
  114. def compress_run_legs(path: list[tuple[int, int]]) -> list[tuple[int, int]]:
  115. """沿路径同向连吃 2 格(客户端 checkNextMovePos 跑步=2 格同向合并)。"""
  116. legs: list[tuple[int, int]] = []
  117. i = 0
  118. while i < len(path) - 1:
  119. if i + 2 < len(path):
  120. d1 = (path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1])
  121. d2 = (path[i + 2][0] - path[i + 1][0], path[i + 2][1] - path[i + 1][1])
  122. if d1 == d2 and d1 in DIRS:
  123. legs.append(path[i + 2])
  124. i += 2
  125. continue
  126. legs.append(path[i + 1])
  127. i += 1
  128. return legs
  129. def heading_of(a: tuple[int, int], b: tuple[int, int]) -> int:
  130. dx = max(-1, min(1, b[0] - a[0]))
  131. dy = max(-1, min(1, b[1] - a[1]))
  132. return next((i for i, (ox, oy) in enumerate(DIRS) if ox == dx and oy == dy), 4)
  133. def send_move(conn: GatewayConnection, sid: int, me_x: int, me_y: int,
  134. heading: int, land_x: int, land_y: int, proto: str) -> None:
  135. """CS3013 跑一腿: recog=0, p1=dir, p2=起点(Y<<16|X), p3=sid, pre=落点。"""
  136. _ = conn.send_frame(3013, 0, heading, (me_y << 16) | me_x, sid,
  137. proto.encode(), pre=(land_y << 16) | land_x)
  138. def main() -> int:
  139. ap = argparse.ArgumentParser()
  140. ap.add_argument("-u", "--user", required=True)
  141. ap.add_argument("-p", "--password", required=True)
  142. ap.add_argument("--zone", type=int, required=True)
  143. ap.add_argument("--name", required=True)
  144. ap.add_argument("--map", default="地牢一层东")
  145. ap.add_argument("--act", default="act_move,2", help="传送按钮 Act(不带@)")
  146. ap.add_argument("--target", default="", help="x,y 指定目标(默认自动最远)")
  147. ap.add_argument("--dry-run", action="store_true", help="只规划不发帧")
  148. a = ap.parse_args()
  149. map_file = ROOT / "protocol_bot" / "assets" / "maps" / MAP_FILES[a.map]
  150. _LOG.info("[地图] %s → %s", a.map, map_file.name)
  151. # ---- 登录进游戏 ----
  152. http = requests.Session()
  153. http.trust_env = False
  154. http.verify = False
  155. jwt, uid = login(Account(username=a.user, password=a.password))
  156. _LOG.info("[登录] uid=%d", uid)
  157. _ = enter_game_client(http, jwt, a.zone)
  158. key32, sid, rname, tkey, gport = login_gate_with_retry(
  159. a.zone, jwt, uid, a.name, PORTS)
  160. _LOG.info("[票据] sid=%d 角色=%s 端口=%d", sid, rname, gport)
  161. stop = [False]
  162. conn, world, updater = enter_world(a.zone, rname, tkey, sid, gport, uid,
  163. stop=stop)
  164. _LOG.info("[出生] %s @(%d,%d)", world.me.map_name, world.me.x, world.me.y)
  165. try:
  166. # ---- 传送(已在目标图则跳过); 失败重试(任务态冷却 3-5 分钟可解) ----
  167. if world.me.map_name != a.map:
  168. for attempt in range(6):
  169. _LOG.info("[传送] %s → CS1011 @%s (第%d次)",
  170. world.me.map_name, a.act, attempt + 1)
  171. _ = conn.send_frame(
  172. 3031, 0, 0, 0, sid,
  173. (ROOT / "protocol_bot" / "assets" / "cs3031_full.bin")
  174. .read_bytes())
  175. time.sleep(1.0)
  176. _ = conn.send_frame(
  177. 1011, 0, 0, 0, sid,
  178. f'{{"UserID":"999999999","Act":"@{a.act}","index":999999999}}'
  179. .encode())
  180. deadline = time.time() + 25.0
  181. while time.time() < deadline:
  182. for f in conn.recv_frames(0.2):
  183. updater.apply(world, f)
  184. if world.me.map_name == a.map:
  185. break
  186. if world.me.map_name == a.map:
  187. break
  188. _LOG.warning("[传送] 未受理(可能任务态冷却), 60s 后重试")
  189. time.sleep(60.0)
  190. if world.me.map_name != a.map:
  191. _LOG.error("[传送] 6 次未受理 — 退出")
  192. return 1
  193. proto = protocol_map_name(world.me.map_id, world.me.map_name)
  194. for _ in range(2):
  195. _ = conn.send_frame(5012, 0, 0, 0, sid, proto.encode())
  196. _ = conn.send_frame(5027, 0, 0, 0, sid, proto.encode())
  197. _ = conn.send_frame(5012, 0, 0, 0, 32611848, b"cTh")
  198. _LOG.info("[落地] %s @(%d,%d)", world.me.map_name, world.me.x,
  199. world.me.y)
  200. time.sleep(2.0)
  201. deadline = time.time() + 3.0
  202. while time.time() < deadline:
  203. for f in conn.recv_frames(0.2):
  204. updater.apply(world, f)
  205. start = (world.me.x, world.me.y)
  206. _LOG.info("[落脚点] (%d,%d)", *start)
  207. # ---- 地图数据必须与实际所在图一致 ----
  208. actual = world.me.map_name
  209. if actual != a.map:
  210. _LOG.error("[地图] 实际在 %s 与目标 %s 不符 — 退出", actual, a.map)
  211. return 1
  212. if actual not in MAP_FILES:
  213. _LOG.error("[地图] %s 无 .map 数据 — 退出", actual)
  214. return 1
  215. m = load_map(str(ROOT / "protocol_bot" / "assets" / "maps"
  216. / MAP_FILES[actual]))
  217. if not (0 <= start[0] < m.width and 0 <= start[1] < m.height):
  218. _LOG.error("[地图] 落脚点 (%d,%d) 超出 %s (%dx%d) — 退出",
  219. start[0], start[1], MAP_FILES[actual], m.width, m.height)
  220. return 1
  221. _LOG.info("[地图数据] %s (%dx%d) 已加载", MAP_FILES[actual],
  222. m.width, m.height)
  223. # ---- 选目标: 图内 + 可达 + 最远 ----
  224. t0 = time.time()
  225. dist = bfs_walkable(m, start)
  226. if a.target:
  227. tx, ty = (int(v) for v in a.target.split(","))
  228. assert (tx, ty) in dist, f"目标 ({tx},{ty}) 不可达或不可走"
  229. goal = (tx, ty)
  230. else:
  231. goal = pick_farthest(dist, start)
  232. _LOG.info("[目标] (%d,%d) BFS步数=%d 可达域=%d格 选择耗时 %.2fs",
  233. goal[0], goal[1], dist[goal], len(dist), time.time() - t0)
  234. # ---- 规划(真客户端 A* 分段拼接) ----
  235. t1 = time.time()
  236. path = plan_multiseg(m, start, goal)
  237. if path is None:
  238. _LOG.error("[规划] 无路 — 退出")
  239. return 1
  240. legs = compress_run_legs(path)
  241. assert path_is_valid(m, path)
  242. _LOG.info("[规划] %d 步 → %d 跑腿, 规划耗时 %.2fs",
  243. len(path), len(legs), time.time() - t1)
  244. if a.dry_run:
  245. for i in range(0, len(path), 20):
  246. _LOG.info("[路径] %s", path[i:i + 20])
  247. return 0
  248. # ---- CS5013 START ----
  249. _ = conn.send_frame(5013, goal[0], goal[1], 1, sid, b"")
  250. _LOG.info("[5013] START → (%d,%d)", *goal)
  251. # ---- 逐腿跑 + 计时 ----
  252. t_start = time.time()
  253. confirmed = rejected = silent = 0
  254. pending = False
  255. pending_t = 0.0
  256. li = 0
  257. last_t = 0.0
  258. while li < len(legs):
  259. now = time.time()
  260. for f in conn.recv_frames(0.02):
  261. before = len(world.events)
  262. updater.apply(world, f)
  263. for e in world.events:
  264. if e.kind == "self_move_confirmed":
  265. confirmed += 1
  266. pending = False
  267. elif e.kind in ("pull_back", "anchor_echo"):
  268. rejected += 1
  269. pending = False
  270. if len(world.events) > before:
  271. world.events.clear()
  272. if pending and now - pending_t >= PENDING_TIMEOUT_S:
  273. pending = False
  274. silent += 1
  275. if pending:
  276. continue
  277. if now - last_t < RUN_STEP_S:
  278. continue
  279. # 位置可能被服务器纠正过 — 从真实位置接续最近腿
  280. me = (world.me.x, world.me.y)
  281. while li < len(legs) and max(abs(legs[li][0] - me[0]),
  282. abs(legs[li][1] - me[1])) <= 2:
  283. li += 1
  284. if li >= len(legs):
  285. break
  286. if max(abs(legs[li][0] - me[0]), abs(legs[li][1] - me[1])) > 4:
  287. # 偏离路径(拉回): 从当前位置重新规划剩余路
  288. _LOG.info("[偏航] 我(%d,%d) vs 腿%s — 重规划", *me, legs[li])
  289. rest = plan_multiseg(m, me, goal)
  290. if rest is None:
  291. _LOG.error("[重规划] 无路 — ABORT")
  292. break
  293. legs = compress_run_legs(rest)
  294. li = 0
  295. continue
  296. nxt = legs[li]
  297. h = heading_of(me, nxt)
  298. dist2 = 2 if (abs(nxt[0] - me[0]) == 2 or abs(nxt[1] - me[1]) == 2) else 1
  299. dxu = max(-1, min(1, nxt[0] - me[0]))
  300. dyu = max(-1, min(1, nxt[1] - me[1]))
  301. send_move(conn, sid, me[0], me[1], h,
  302. me[0] + dxu * dist2, me[1] + dyu * dist2,
  303. protocol_map_name(world.me.map_id, world.me.map_name))
  304. _LOG.info("[跑] #%d (%d,%d) dir=%d → (%d,%d)",
  305. li + 1, me[0], me[1], h, nxt[0], nxt[1])
  306. pending = True
  307. pending_t = now
  308. last_t = now
  309. li += 1
  310. elapsed = time.time() - t_start
  311. _ = conn.send_frame(5013, 0, 0, 3, sid, b"")
  312. final = (world.me.x, world.me.y)
  313. arrived = max(abs(final[0] - goal[0]), abs(final[1] - goal[1])) <= ARRIVE_DIST + 1
  314. _LOG.info("=" * 60)
  315. _LOG.info("[结果] %s | 起点(%d,%d) → 目标(%d,%d) 终点(%d,%d)",
  316. "✓到达" if arrived else "✗未达", start[0], start[1],
  317. goal[0], goal[1], final[0], final[1])
  318. _LOG.info("[计时] 总耗时 %.1fs (%.1f分钟) | 计划 %d 腿 实跑 %d 腿",
  319. elapsed, elapsed / 60, len(legs), li)
  320. _LOG.info("[帧统计] SC1确认=%d SC28拒=%d 静默=%d", confirmed, rejected, silent)
  321. _LOG.info("[5013] FINISH 已发")
  322. return 0 if arrived else 2
  323. finally:
  324. stop[0] = True
  325. conn.close()
  326. if __name__ == "__main__":
  327. sys.exit(main())