run_juece32_stand_by_mine_npc.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. """run_juece32_stand_by_mine_npc.py — 裁决32区 创角 → 等待地图寻路跑 → 矿场总兵旁站立。
  2. 流程(全部复用仓库已实测链路):
  3. [1] 盒子 HTTP 登录(login: RSA+极验滑块自动破解)
  4. [2] EnterGameClient 选区上报(裁决32区 serverid=14594070, 2026-10-01 14:00 开服)
  5. [3] 登录服全链(2001→…→525) 自动创角: 名字随机纯中文(pipeline.naming),
  6. 重名/复用不符则换名重试(最多 3 次)
  7. [4] 进世界(enter_world: CS20+17帧初始化+心跳线程) — 新角色出生=等待地图
  8. [5] 落地静置 5s(服务器建视野, SC10 推 NPC)
  9. [5.5] 四处跑: BFS 可达域随机取 3 个远点, AutoMoveBehavior 逐点寻路跑
  10. (CS5013 START 注册 + 0139.map 权威墙 A* + CS3013 跑腿链 + FINISH)
  11. [5.8] 开启喝药保护: 进图 ≥62s 才受理(2026-09-26 定案时序门槛),
  12. enable_auto_heal 读态验证 + T1 同款 3 轮重试
  13. [6] 寻路跑向【矿场总兵】: 优先 world.npcs 实测坐标, 兜底坐标 (18,24)
  14. [6.5] T04 领锄头: CS1010{10041,43} → CS1011 @func38_get_hzc 三态判定
  15. (入包 Index:50001 → CS1003 穿锄; "5分钟后"弹窗 → @func_no 等 65s 重试;
  16. "活动已结束" → 跳过挖矿) — 开区 5-20 分钟窗口内有效
  17. [6.8] T05 挖矿: BFS 找 可走且邻墙 的面壁格 → CS3014{999,dir,src,dst}
  18. @0.68s 循环 → 产物三形态统计(自身播报/金币/入包) → 3013 走步停止
  19. [7] 到达 NPC 2 格内 → 原地站立(仅心跳+收帧), 默认 180s
  20. [8] 关闭连接, 进程退出
  21. 用法:
  22. uv run python scripts/run_juece32_stand_by_mine_npc.py \
  23. [-u z1098210167] [-p yang535006] [--zone 14605092] \
  24. [--name 某中文] [--hold-seconds 180] [--mine-seconds 600]
  25. """
  26. from __future__ import annotations
  27. import argparse
  28. import json
  29. import logging
  30. import random
  31. import re
  32. import sys
  33. import time
  34. import zlib
  35. from collections import deque
  36. from collections.abc import Iterator
  37. from pathlib import Path
  38. from typing import Final, Literal
  39. import requests
  40. from pbot.bot.automove import AutoMoveBehavior
  41. from pbot.bot.pathfind import load_walls_from_map
  42. from pbot.bot.runner import pump, run
  43. from pbot.codec.bitbuf import decode6
  44. from pbot.codec.frame import Frame, FrameSplitter
  45. from pbot.config import Account
  46. from pbot.errors import PbotError
  47. from pbot.game.equip import take_on
  48. from pbot.game.heal import enable_auto_heal
  49. from pbot.game.session import enter_world
  50. from pbot.login.box_client import enter_game_client, login
  51. from pbot.login.zone_ports import fetch_zone_ports
  52. from pbot.transport.gateway import GatewayConnection, login_gate_with_retry
  53. from pbot.types import ZoneId
  54. from pbot.world.mirror import WorldMirror
  55. from pbot.world.update import WorldUpdater
  56. ROOT: Final[Path] = Path(__file__).resolve().parents[1]
  57. if str(ROOT) not in sys.path:
  58. sys.path.insert(0, str(ROOT))
  59. from jiuyangfugu176.naming import random_cn_name # noqa: E402
  60. _LOG = logging.getLogger("juece32_stand")
  61. ZONE_DEFAULT: Final[int] = 14605092 # 裁决33区(2026-10-01 16:00 开服, 16:05 窗口)
  62. PORTS: Final[tuple[int, ...]] = (8081, 8041, 8021, 8061, 8001, 8063, 8043, 8003)
  63. # 2026-10-01 定案: 各区登录服端口由 modlist 下发的 serverlist/110585 提供
  64. # (加密格式未逆向), 不同物理机端口不同(32区=8041, 33区=8081)。脚本用
  65. # probe_login_port() 运行时自发现: 发 2001→104→100 探针, 收到 SC520 即真端口。
  66. WAIT_MAP: Final[str] = "等待地图"
  67. NPC_NAME: Final[str] = "矿场总兵"
  68. NPC_FALLBACK_POS: Final[tuple[int, int]] = (18, 24) # 用户指定坐标(2026-10-01)
  69. MAP_0139: Final[Path] = ROOT / "assets" / "maps" / "0139.map"
  70. MINE_W, MINE_H = 46, 50 # 0139.map 头部尺寸
  71. # T04 领锄头(2026-10-01 抓包定案, protocol_bot/docs/探索总结-20261001)
  72. HOE_NPC_CFG: Final[str] = "10041" # 矿场总兵 cfgID(CS1010 UserID 字段)
  73. HOE_NPC_MENU_INDEX: Final[int] = 43
  74. HOE_ACT: Final[str] = "@func38_get_hzc"
  75. HOE_ITEM_INDEX: Final[int] = 50001 # 鹤嘴锄 Index(入包帧判定)
  76. HOE_SWING_INTERVAL_S: Final[float] = 0.68 # HitTime(227 帧实测中位)
  77. HOE_HOE_MAX_TRIES: Final[int] = 10
  78. HOE_WAIT5_RETRY_S: Final[float] = 65.0
  79. # 全区播报: ★玩家<[名]>挖矿获得:<物品> — 2026-10-03 定案:
  80. # 只展示自己角色的产物(他人名过滤), 播报名=物品名来源(自身入包多为基础材料)
  81. # 物品名双格式(49/51区实证): 裸名"聚灵珠(小)" 或 富文本"<金刚石/FCOLOR=249>"
  82. _LOOT_RE: Final[re.Pattern[str]] = re.compile(
  83. r"玩家<\[([^\]]{1,12})\][^>]*>挖矿获得[::]\s*(?:<([^<>\",,/]{1,20})"
  84. r"[^<>]*>|([^<\",,]{1,20}))")
  85. _GOLD_RE: Final[re.Pattern[str]] = re.compile(r'"ID":1,"Value":(\d+)')
  86. DIR_VECS: Final[tuple[tuple[int, int], ...]] = (
  87. (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1))
  88. _ENTER_RETRIES: Final[int] = 4
  89. _ENTER_RETRY_SLEEP_S: Final[float] = 12.0
  90. _NAME_ATTEMPTS: Final[int] = 3
  91. _HEAL_ROUNDS: Final[int] = 3 # T1 同款: 三轮重试
  92. _HEAL_GATE_S: Final[float] = 62.0 # 进图后面板请求受理门槛(2026-09-26 定案)
  93. _SETTLE_S: Final[float] = 5.0
  94. _MOVE_BUDGET_S: Final[float] = 120.0 # 单段寻路时长上限
  95. _ROAM_MIN_DIST: Final[int] = 10 # 漫游远点最小 BFS 距离
  96. _ARRIVE_DIST: Final[int] = 2
  97. def setup_logging() -> None:
  98. """全量 INFO 日志到 stdout。"""
  99. logging.basicConfig(
  100. level=logging.INFO,
  101. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  102. stream=sys.stdout,
  103. )
  104. def banner(msg: str, *args: object) -> None:
  105. """阶段分隔条日志。"""
  106. _LOG.info("======== %s ========", msg % args if args else msg)
  107. def _is_pure_cn(name: str) -> bool:
  108. """全中文检查(每个字符都在 CJK 基本区)。"""
  109. return bool(name) and all("\u4e00" <= c <= "\u9fff" for c in name)
  110. def reachable_cells(start: tuple[int, int],
  111. walls: set[tuple[int, int]]) -> dict[tuple[int, int], int]:
  112. """0139 权威墙 BFS: 起点 reachable 域 + 每格 BFS 距离。"""
  113. dist = {start: 0}
  114. dq = deque([start])
  115. while dq:
  116. x, y = dq.popleft()
  117. for dx, dy in DIR_VECS:
  118. nxt = (x + dx, y + dy)
  119. if nxt in dist or not (0 <= nxt[0] < MINE_W and 0 <= nxt[1] < MINE_H):
  120. continue
  121. if nxt in walls:
  122. continue
  123. dist[nxt] = dist[(x, y)] + 1
  124. dq.append(nxt)
  125. return dist
  126. def pick_roam_waypoints(start: tuple[int, int], walls: set[tuple[int, int]],
  127. count: int) -> list[tuple[int, int]]:
  128. """可达域内随机取 count 个 BFS 距离 ≥ _ROAM_MIN_DIST 的远点。"""
  129. dist = reachable_cells(start, walls)
  130. far = [c for c, d in dist.items() if d >= _ROAM_MIN_DIST]
  131. random.shuffle(far)
  132. return far[:count]
  133. def _iter_batch_texts(frames: list[Frame]) -> Iterator[str]:
  134. """SC-1 zlib 批量帧 → 子帧流文本(decode6 后), 供产物/弹窗扫描。"""
  135. for f in frames:
  136. if f.msg_id != -1:
  137. continue
  138. try:
  139. raw = zlib.decompress(f.body)
  140. except zlib.error:
  141. continue
  142. for sub in FrameSplitter().feed(raw):
  143. yield from _sub_texts(sub)
  144. def _sub_texts(f: Frame) -> Iterator[str]:
  145. if f.msg_id == -1:
  146. try:
  147. yield zlib.decompress(f.body).decode("utf-8", "replace")
  148. except (zlib.error, UnicodeDecodeError):
  149. pass
  150. return
  151. try:
  152. yield decode6(f.body).decode("utf-8", "replace")
  153. except Exception: # noqa: BLE001 - decode6 对任意输入可能抛多种异常
  154. pass
  155. def claim_hoe(conn: GatewayConnection, sid: int, world: WorldMirror,
  156. updater: WorldUpdater,
  157. role: str) -> tuple[str, int]:
  158. """T04 领锄头一轮: 点击 NPC → @func38_get_hzc → 扫三态回执。
  159. 返回 (state, makeindex): got=入包(带物品实例号) / wait5=开区<5min
  160. (点 Btn2 关闭) / over=活动已结束 / fail=无定论信号。
  161. makeindex 是穿装备帧 CS1003.recog 的必需参数(TakeOnEquipCommand 定案)。
  162. """
  163. world.npc_dialog = None
  164. body1010 = json.dumps({"UserID": HOE_NPC_CFG, "index": HOE_NPC_MENU_INDEX},
  165. separators=(",", ":")).encode()
  166. _ = conn.send_frame(1010, 0, 0, 0, sid, body1010)
  167. time.sleep(1.2)
  168. pre = pump(conn, world, updater, 0.6)
  169. _LOG.info("[T04·诊断] CS1010 后收帧 %s", [f.msg_id for f in pre] or "零")
  170. if world.npc_dialog is not None:
  171. _LOG.info("[T04·诊断] SC643 sMsg<<%s>>",
  172. str(world.npc_dialog.get("sMsg", ""))[:160].replace("\n", "␤"))
  173. body1011 = json.dumps({"UserID": "999999999", "Act": HOE_ACT,
  174. "index": 999999999},
  175. separators=(",", ":")).encode()
  176. _ = conn.send_frame(1011, 0, 0, 0, sid, body1011)
  177. deadline = time.time() + 4.0
  178. seen_ids: set[int] = set()
  179. while time.time() < deadline:
  180. frames = pump(conn, world, updater, 0.3)
  181. for fr in frames:
  182. if fr.msg_id not in (60000, 5037):
  183. seen_ids.add(fr.msg_id)
  184. for text in _iter_batch_texts(frames):
  185. if f'"Index":{HOE_ITEM_INDEX}' in text and "makeindex" in text:
  186. mk = 0
  187. try:
  188. j = json.loads(text)
  189. mk = int(j.get("makeindex", 0))
  190. except (ValueError, TypeError):
  191. pass
  192. return "got", mk
  193. if "5分钟后" in text:
  194. _send_qfunc_no(conn, sid)
  195. return "wait5", 0
  196. if "活动已结束" in text or "挖矿活动已结束" in text:
  197. return "over", 0
  198. dialog = world.npc_dialog
  199. if dialog is not None:
  200. smsg = str(dialog.get("sMsg", ""))
  201. if "5分钟后" in smsg:
  202. _send_qfunc_no(conn, sid)
  203. return "wait5", 0
  204. if "活动已结束" in smsg:
  205. return "over", 0
  206. if seen_ids:
  207. _LOG.info("[T04·诊断] 本轮非心跳帧: %s", sorted(seen_ids))
  208. return "fail", 0
  209. def _send_qfunc_no(conn: GatewayConnection, sid: int) -> None:
  210. """弹窗 Btn2(@func_no) — 关闭"5分钟后"弹窗, 绝不点 Btn1(首充)。"""
  211. body = json.dumps({"UserID": "999999999", "Act": "@func_no",
  212. "index": 999999999},
  213. separators=(",", ":")).encode()
  214. _ = conn.send_frame(1011, 0, 0, 0, sid, body)
  215. def _send_exit_panel(conn: GatewayConnection, sid: int) -> None:
  216. """CS1011 @exit — 关闭领取面板(真客户端 t=143.54 实证, 穿锄后必发)。
  217. 面板未关时后续挖矿/移动指令疑似被服务器吞 — j39 穿锄无效的主疑点。
  218. """
  219. body = json.dumps({"UserID": "999999999", "Act": "@exit",
  220. "index": 999999999},
  221. separators=(",", ":")).encode()
  222. _ = conn.send_frame(1011, 0, 0, 0, sid, body)
  223. def _wear_hoe(conn: GatewayConnection, sid: int,
  224. world: WorldMirror, updater: WorldUpdater, # noqa: ARG001 — 保持调用方签名
  225. makeindex: int) -> bool:
  226. """穿锄头(2026-10-02 pre=makeindex 定案, 见 equip.py)。
  227. 领锄入包后 2s 内直接 CS1003: pre=makeindex(关键!), recog=1, body=鹤嘴锄。
  228. 历史版本缺 pre → 服务器按 mk 查无物品 → 恒 616(已排除 30+ 项后的真根因)。
  229. """
  230. ok = take_on(conn, sid, "鹤嘴锄", makeindex)
  231. _LOG.info("[穿锄] CS1003 pre=%d → %s", makeindex,
  232. "SC615+203 ✓已穿上" if ok else "失败(616/超时)")
  233. if ok:
  234. _LOG.info("[穿锄✓] 关闭领取面板(@exit) — 真客户端穿锄后必发")
  235. _send_exit_panel(conn, sid)
  236. return ok
  237. def claim_hoe_with_retry(conn: GatewayConnection, sid: int,
  238. world: WorldMirror, updater: WorldUpdater,
  239. role: str) -> tuple[str, int]:
  240. """T04 主循环: wait5 → 等 65s 重试; got → 立刻穿锄。返回 (状态, makeindex)。"""
  241. for attempt in range(1, HOE_HOE_MAX_TRIES + 1):
  242. state, mk = claim_hoe(conn, sid, world, updater, role)
  243. _LOG.info("[领锄头] 第%d轮 状态=%s makeindex=%d", attempt, state, mk)
  244. if state == "got":
  245. _LOG.info("[领锄头✓] 鹤嘴锄已入包(Index:50001, makeindex=%d)", mk)
  246. if not _wear_hoe(conn, sid, world, updater, mk):
  247. _LOG.warning("[穿锄✗] 失败 — 挖矿继续尝试(可能已穿着)")
  248. return "got", mk
  249. if state == "over":
  250. _LOG.warning("[领锄头✗] 挖矿活动已结束(20 分钟窗口过)")
  251. return "over", 0
  252. if state == "wait5":
  253. _LOG.info("[领锄头] 开区<5min — 等 %.0fs 重试", HOE_WAIT5_RETRY_S)
  254. deadline = time.time() + HOE_WAIT5_RETRY_S
  255. while time.time() < deadline:
  256. _ = pump(conn, world, updater, 0.5)
  257. continue
  258. time.sleep(5.0)
  259. return "fail", 0
  260. def _boundary_walls(walls: set[tuple[int, int]]) -> set[tuple[int, int]]:
  261. """与地图边缘连通的墙(四面矿墙带) — 从图边洪泛, 排除内部孤墙。"""
  262. outer: set[tuple[int, int]] = set()
  263. dq = deque()
  264. for x in range(MINE_W):
  265. for y in (0, MINE_H - 1):
  266. c = (x, y)
  267. if c in walls and c not in outer:
  268. outer.add(c)
  269. dq.append(c)
  270. for y in range(MINE_H):
  271. for x in (0, MINE_W - 1):
  272. c = (x, y)
  273. if c in walls and c not in outer:
  274. outer.add(c)
  275. dq.append(c)
  276. while dq:
  277. x, y = dq.popleft()
  278. for dx, dy in DIR_VECS:
  279. nxt = (x + dx, y + dy)
  280. if nxt in walls and nxt not in outer:
  281. outer.add(nxt)
  282. dq.append(nxt)
  283. return outer
  284. def find_mining_spot(pos: tuple[int, int],
  285. walls: set[tuple[int, int]],
  286. outer_walls: set[tuple[int, int]] | None = None,
  287. ) -> tuple[tuple[int, int], int] | None:
  288. """挖矿站位: 四面边界墙带随机选一面(用户定案 2026-10-03: 随机分散,
  289. 多号不挤同面墙), 在该墙上就近取「可走 且 8邻含边界墙」格。
  290. 等待图 = 中央菱形开阔区 + 四周矿墙带(边界墙, 与图边洪泛连通)。
  291. NPC 附近的内部孤墙((17,21)/(18,21) 等)不是矿, 挖不出 — j37 实证。
  292. """
  293. ow = outer_walls if outer_walls is not None else _boundary_walls(walls)
  294. reach = reachable_cells(pos, walls)
  295. # 全部可面壁格按所在墙分组(上y小/下y大/左x小/右x大 — 以格到图中心
  296. # 的主轴判墙), 随机挑一组后再就近取格; 判不出主轴的格归入全部池
  297. cx, cy = MINE_W // 2, MINE_H // 2
  298. groups: dict[str, list[tuple[tuple[int, int], int]]] = {}
  299. for cell, depth in sorted(reach.items(), key=lambda kv: kv[1]):
  300. ds = [i for i, (dx, dy) in enumerate(DIR_VECS)
  301. if (cell[0] + dx, cell[1] + dy) in ow]
  302. if not ds:
  303. continue
  304. dx0, dy0 = cell[0] - cx, cell[1] - cy
  305. side = ("n" if dy0 < 0 else "s") if abs(dy0) >= abs(dx0) \
  306. else ("w" if dx0 < 0 else "e")
  307. groups.setdefault(side, []).append((cell, ds[0]))
  308. if not groups:
  309. return None
  310. side = random.choice(sorted(groups)) # noqa: S311 — 四面墙随机
  311. return groups[side][0]
  312. def mine_loop(conn: GatewayConnection, sid: int, world: WorldMirror,
  313. updater: WorldUpdater, role: str, seconds: float,
  314. spot: tuple[int, int], face_dir: int) -> dict[str, int]:
  315. """T05 挖矿循环: CS3014{999,dir,src,dst} @0.68s + 产物三形态统计。
  316. src 每锄实时取 world.me(j37 实证: 规划位与服务器实际位差 1 格时,
  317. 每锄被 SC28 锚点纠正=全部无效); dest=面前墙格。停止后发一帧 3013
  318. 走步(客户端语义: 走动即停挖)。
  319. """
  320. stats = {"swings": 0, "loot_broadcasts": 0, "gold": 0, "items": 0}
  321. deadline = time.time() + seconds
  322. over = False
  323. raw_dump_t = 0.0 # 观察回退节流: 格式漂移时 5s 一条保现场, 不刷屏
  324. while time.time() < deadline and not over and not world.me.is_dead:
  325. me = world.me
  326. src_x, src_y = me.x, me.y
  327. dst_x = src_x + DIR_VECS[face_dir][0]
  328. dst_y = src_y + DIR_VECS[face_dir][1]
  329. payload = json.dumps({"skillID": 999, "dir": face_dir,
  330. "destX": dst_x, "destY": dst_y,
  331. "srcX": src_x, "srcY": src_y},
  332. separators=(",", ":")).encode()
  333. _ = conn.send_frame(3014, 0, 0, 0, 0, payload)
  334. stats["swings"] += 1
  335. frames = pump(conn, world, updater, HOE_SWING_INTERVAL_S)
  336. for text in _iter_batch_texts(frames):
  337. if "挖矿获得" in text:
  338. m = _LOOT_RE.search(text)
  339. if m is None:
  340. # 观察回退(50区实证需要): 正则失配 = 播报格式漂移,
  341. # 静默会掩盖格式变化 — 节流打原始前 100 字保现场
  342. now = time.time()
  343. if now - raw_dump_t > 5.0:
  344. raw_dump_t = now
  345. _LOG.warning("🎁[格式漂移] %s", text[:100])
  346. elif m.group(1) == role:
  347. stats["loot_broadcasts"] += 1
  348. # 双格式: 富文本包裹取组2, 裸名取组3
  349. _LOG.info("🎁 [产物] %s", m.group(2) or m.group(3))
  350. if "活动已结束" in text:
  351. over = True
  352. m_gold = _GOLD_RE.search(text)
  353. if m_gold:
  354. stats["gold"] = max(stats["gold"], int(m_gold.group(1)))
  355. if '"Index":' in text and "makeindex" in text:
  356. stats["items"] += 1
  357. m_name = re.search(r'"Name":"([^"]+)"', text)
  358. if m_name:
  359. _LOG.info("📦 [入包] %s", m_name.group(1))
  360. else:
  361. now = time.time()
  362. if now - raw_dump_t > 5.0:
  363. raw_dump_t = now
  364. _LOG.warning("📦[键名漂移] %s", text[:100])
  365. me = world.me
  366. _LOG.info("[挖矿结束] %s — 挥锄 %d 产物播报 %d 金币 %d 入包 %d 终位(%d,%d)",
  367. "活动结束" if over else "时长到", stats["swings"],
  368. stats["loot_broadcasts"], stats["gold"], stats["items"],
  369. me.x, me.y)
  370. _ = conn.send_frame(3013, 0, face_dir, (me.y << 16) | me.x, sid,
  371. WAIT_MAP.encode(),
  372. pre=((me.y + DIR_VECS[face_dir][1]) << 16)
  373. | (me.x + DIR_VECS[face_dir][0]))
  374. return stats
  375. def _dir8(x: int, y: int, tx: int, ty: int) -> int:
  376. """8 向主方向(截断 dx/dy 到 -1..1)。"""
  377. dx = max(-1, min(1, tx - x))
  378. dy = max(-1, min(1, ty - y))
  379. return next((i for i, (ox, oy) in enumerate(DIR_VECS) if ox == dx and oy == dy), 4)
  380. def _face_wall_dir(pos: tuple[int, int],
  381. walls: set[tuple[int, int]]) -> int | None:
  382. """当前位置的 8 向里找面向墙/图边界的方向; 无返回 None。"""
  383. for d_idx, (dx, dy) in enumerate(DIR_VECS):
  384. nx, ny = pos[0] + dx, pos[1] + dy
  385. if not (0 <= nx < MINE_W and 0 <= ny < MINE_H) or (nx, ny) in walls:
  386. return d_idx
  387. return None
  388. def _step_to_mine_cell(conn: GatewayConnection, sid: int,
  389. world: WorldMirror, updater: WorldUpdater,
  390. spot: tuple[int, int]) -> None:
  391. """AutoMove 容差 2 格, 挖矿须精确踩格 — 差 1-2 格时单步 CS3011 补位。
  392. j37 实证: 站(18,22)按规划位(18,21)发帧, 每锄被 SC28 纠正=全无效。
  393. """
  394. for _ in range(4):
  395. me = world.me
  396. dx, dy = spot[0] - me.x, spot[1] - me.y
  397. if dx == 0 and dy == 0:
  398. return
  399. d = _dir8(me.x, me.y, spot[0], spot[1])
  400. from pbot.game.messages import protocol_map_name
  401. mn = protocol_map_name(me.map_id, me.map_name)
  402. _ = conn.send_frame(3011, 0, d, (me.y << 16) | me.x, sid, mn.encode(),
  403. pre=((me.y + DIR_VECS[d][1]) << 16)
  404. | (me.x + DIR_VECS[d][0]))
  405. deadline = time.time() + 1.5
  406. while time.time() < deadline:
  407. _ = pump(conn, world, updater, 0.3)
  408. if (world.me.x, world.me.y) == spot:
  409. return
  410. _LOG.warning("[T05] 补位 4 次未踩到 %s(在 %d,%d)", spot,
  411. world.me.x, world.me.y)
  412. def parse_args() -> argparse.Namespace:
  413. """命令行。"""
  414. ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
  415. _ = ap.add_argument("-u", "--user", default="longwu988")
  416. _ = ap.add_argument("-p", "--password", default="Longwu1988")
  417. _ = ap.add_argument("--zone", type=int, default=ZONE_DEFAULT,
  418. help="区服 serverid(默认 裁决33区=14605092, 16:00 开)")
  419. _ = ap.add_argument("--name", default="",
  420. help="角色名(默认随机纯中文); 必须全中文")
  421. _ = ap.add_argument("--hold-seconds", type=float, default=180.0,
  422. help="NPC 旁静止时长(秒, 默认 180)")
  423. _ = ap.add_argument("--mine-seconds", type=float, default=600.0,
  424. help="挖矿时长(秒, 默认 600)")
  425. _ = ap.add_argument("--skip-hoe", action="store_true",
  426. help="跳过领锄头(已领过), 直接挖矿")
  427. _ = ap.add_argument("--skip-heal", action="store_true",
  428. help="跳过喝药保护(挖矿无需)")
  429. _ = ap.add_argument("--roam-points", type=int, default=3,
  430. help="四处跑的随机远点数(默认 3)")
  431. _ = ap.add_argument("--login-port", type=int, default=0)
  432. return ap.parse_args()
  433. def _create_role_with_retry(zone: ZoneId, jwt: str, uid: int, want_name: str,
  434. ports: tuple[int, ...]) -> tuple[int, str, str, int]:
  435. """创角(重名换名重试; 槽满回退复用已有角色)。
  436. 返回 (sid, 角色名, 票据, 游戏端口)。新区(空角色表)必走创角;
  437. 旧区槽满(522)时 login_gate 回退 role_list[-1] — 接受复用并警告。
  438. """
  439. name = want_name
  440. for attempt in range(1, _NAME_ATTEMPTS + 1):
  441. if not _is_pure_cn(name):
  442. msg = f"角色名必须全中文: {name!r}"
  443. raise RuntimeError(msg)
  444. _, sid, real, ticket, gport = login_gate_with_retry(
  445. zone, jwt, uid, name, ports)
  446. if real == name:
  447. return sid, real, ticket, gport
  448. _LOG.warning("[创角] 第%d次 请求=%s 实际=%s(重名/槽满复用旧角)",
  449. attempt, name, real)
  450. if attempt == _NAME_ATTEMPTS:
  451. _LOG.warning("[创角] 重试耗尽 — 接受复用角色 %s", real)
  452. return sid, real, ticket, gport
  453. name = random_cn_name()
  454. raise RuntimeError("unreachable")
  455. def probe_login_port(zone: ZoneId, jwt: str, uid: int,
  456. ports: tuple[int, ...]) -> int:
  457. """端口自发现: 逐端口发 2001→104→100 探针, 收到 SC520 = 真端口。
  458. 新物理机的登录服端口与旧机不同(33区 8081 vs 32区 8041), 错端口的
  459. 服务握手应答 529/530 但 CS100 点名该区时裸断连(2026-10-01 定案)。
  460. 每端口 ~10s 上限; 返回 0 = 全部失败。
  461. """
  462. from pbot.codec.bitbuf import decode6, encode6
  463. from pbot.transport.gateway import CHANNEL, GAME_ID, GatewayConnection
  464. for port in ports:
  465. c: GatewayConnection | None = None
  466. try:
  467. c = GatewayConnection(zone, port)
  468. body2001 = f"{uid}/".encode() + encode6(jwt.encode()) + b"/" \
  469. + encode6(CHANNEL.encode()) + b"/1/0"
  470. _ = c.send_frame(2001, random.randint(1, 2**30), # noqa: S311
  471. int(zone), port, 0, body2001, tail=0)
  472. key32 = None
  473. for f in c.recv_frames(6):
  474. if f.msg_id == 529:
  475. key32 = decode6(f.body).split(b"/")[0]
  476. if not key32:
  477. _LOG.info("[探针] %d: 无529 — 跳过", port)
  478. continue
  479. _ = c.send_frame(104, 0, 0, 0, 0, encode6(key32), tail=1)
  480. sid = 0
  481. for f in c.recv_frames(4):
  482. if f.msg_id == 530:
  483. sid = f.pre
  484. body100 = f"{uid}/{sid}/{int(zone)}/{CHANNEL}/{GAME_ID}/".encode() + key32
  485. _ = c.send_frame(100, random.randint(1, 2**30), # noqa: S311
  486. int(zone), port, sid, body100, tail=2)
  487. got = [f.msg_id for f in c.recv_frames(4)]
  488. if 520 in got:
  489. _LOG.info("[探针✓] 端口 %d 收到 SC520 — 就用这个", port)
  490. return port
  491. _LOG.info("[探针] %d: CS100 应答 %s — 非本区端口", port, got or "断连")
  492. except Exception as e: # noqa: BLE001 - 探针逐端口容错
  493. _LOG.info("[探针] %d: %s — 跳过", port, str(e)[:60])
  494. finally:
  495. if c is not None:
  496. c.close()
  497. return 0
  498. def main() -> int:
  499. """登录 → 创角 → 进世界 → 四处寻路跑 → 跑到矿场总兵 → 静止 → 退出。"""
  500. a = parse_args()
  501. setup_logging()
  502. hold = max(0.0, a.hold_seconds)
  503. banner("[0] 启动: 区=%d 角色=%s 静止=%.0fs 漫游点=%d",
  504. a.zone, a.name or "(随机中文)", hold, a.roam_points)
  505. http = requests.Session()
  506. http.trust_env = False
  507. http.verify = False
  508. banner("[1] 盒子登录: %s", a.user)
  509. jwt, uid = login(Account(username=a.user, password=a.password))
  510. _LOG.info("[✓] uid=%d", uid)
  511. banner("[2] EnterGameClient zone=%d", a.zone)
  512. for attempt in range(_ENTER_RETRIES):
  513. try:
  514. _ = enter_game_client(http, jwt, ZoneId(a.zone))
  515. _LOG.info("[✓] 上报成功")
  516. break
  517. except Exception as e: # 选区上报偶发失败, 重试兜底
  518. if attempt == _ENTER_RETRIES - 1:
  519. raise
  520. _LOG.warning("[!] 第%d次失败(%s), %.0fs 后重试",
  521. attempt + 1, e, _ENTER_RETRY_SLEEP_S)
  522. time.sleep(_ENTER_RETRY_SLEEP_S)
  523. banner("[3] 登录服端口获取")
  524. ports = (a.login_port,) if a.login_port else PORTS
  525. if not a.login_port:
  526. try:
  527. info = fetch_zone_ports().get(a.zone)
  528. if info and info["port"]:
  529. _LOG.info("[✓] serverlist 解密: %s 端口=%d state=%d",
  530. info["name"], info["port"], info["state"])
  531. ports = (info["port"],)
  532. except Exception as e: # noqa: BLE001 - serverlist 失败走探针兜底
  533. _LOG.warning("[!] serverlist 端口获取失败(%s) — 探针兜底", e)
  534. if len(ports) > 1:
  535. found = probe_login_port(ZoneId(a.zone), jwt, uid, ports)
  536. if found:
  537. ports = (found,)
  538. else:
  539. _LOG.warning("[!] 端口探测全部失败 — 用 PORTS 原序硬试")
  540. banner("[3.5] 登录服创角(纯中文名)")
  541. sid, real_name, ticket_key, game_port = _create_role_with_retry(
  542. ZoneId(a.zone), jwt, uid, a.name or random_cn_name(), ports)
  543. _LOG.info("[✓] sid=%d 角色=%s 游戏端口=%d", sid, real_name, game_port)
  544. banner("[4] 进游戏")
  545. stop: list[bool] = [False]
  546. conn, world, updater = enter_world(ZoneId(a.zone), real_name, ticket_key,
  547. sid, game_port, uid, stop=stop,
  548. platform=1, with_guide=False)
  549. _LOG.info("[✓] 出生 map=%s @(%d,%d)", world.me.map_name, world.me.x,
  550. world.me.y)
  551. try:
  552. if world.me.map_name != WAIT_MAP:
  553. _LOG.warning("[!] 出生不在%s(当前 %s) — 仍按流程站立",
  554. WAIT_MAP, world.me.map_name)
  555. born_t = time.time()
  556. banner("[5] 落地静置 %.0fs(建视野)", _SETTLE_S)
  557. deadline = time.time() + _SETTLE_S
  558. while time.time() < deadline:
  559. _ = pump(conn, world, updater, 0.5)
  560. npc_pos = NPC_FALLBACK_POS
  561. for npc in world.npcs.values():
  562. if NPC_NAME in npc.name:
  563. npc_pos = (npc.x, npc.y)
  564. _LOG.info("[NPC] 视野内 %s aid=%s @(%d,%d)",
  565. npc.name, npc.aid, npc.x, npc.y)
  566. break
  567. else:
  568. _LOG.info("[NPC] 视野未见 %s — 用坐标 %s", NPC_NAME, NPC_FALLBACK_POS)
  569. banner("[5.5] 四处跑: %d 个随机远点(AutoMove 寻路)", a.roam_points)
  570. walls = load_walls_from_map(str(MAP_0139))
  571. for i, wp in enumerate(pick_roam_waypoints(
  572. (world.me.x, world.me.y), walls, a.roam_points), 1):
  573. _LOG.info("[漫游] 第%d点 → (%d,%d)", i, wp[0], wp[1])
  574. bh = AutoMoveBehavior(sid=sid, dest_x=wp[0], dest_y=wp[1])
  575. _ = run(conn, world, updater, bh, duration_s=_MOVE_BUDGET_S)
  576. if world.me.map_name != WAIT_MAP:
  577. _LOG.warning("[漫游✗] 换图 %s — 跳过后续漫游点",
  578. world.me.map_name)
  579. break
  580. _ = pump(conn, world, updater, 0.5)
  581. _LOG.info("[漫游结束] map=%s @(%d,%d) 距 %s %d 格",
  582. world.me.map_name, world.me.x, world.me.y, NPC_NAME,
  583. max(abs(npc_pos[0] - world.me.x),
  584. abs(npc_pos[1] - world.me.y)))
  585. # 挖矿窗口(开区5-20min)时间宝贵: 领锄/挖矿优先, 喝药挪到站立期
  586. banner("[6] 寻路跑向 %s(%d,%d)", NPC_NAME, *npc_pos)
  587. bh = AutoMoveBehavior(sid=sid, dest_x=npc_pos[0], dest_y=npc_pos[1])
  588. r = run(conn, world, updater, bh, duration_s=_MOVE_BUDGET_S)
  589. dist = max(abs(npc_pos[0] - world.me.x), abs(npc_pos[1] - world.me.y))
  590. _LOG.info("[寻路结束] result=%s 位置=(%d,%d) 距 %s %d 格",
  591. r.value, world.me.x, world.me.y, NPC_NAME, dist)
  592. if dist > _ARRIVE_DIST:
  593. _LOG.warning("[!] 未精确到位(距 %d 格) — 就地站立", dist)
  594. # ---- T04 领锄头 ----
  595. hoe, hoe_mk = "got", 0 if a.skip_hoe else (None, 0)
  596. if a.skip_hoe:
  597. # 跳过领取: 从背包定位锄头真实 makeindex(equip.py pre 定案必需)
  598. from pbot.game.equip import bag_items, find_makeindex
  599. bag = bag_items(conn, sid)
  600. hoe_mk = find_makeindex(bag, HOE_ITEM_INDEX)
  601. _LOG.info("[T04] skip-hoe 模式 — 背包锄头 makeindex=%d", hoe_mk)
  602. if not hoe_mk:
  603. _LOG.warning("[T04] 背包无锄头(可能已穿着) — 继续挖矿流程")
  604. else:
  605. hoe, hoe_mk = claim_hoe_with_retry(conn, sid, world, updater,
  606. real_name)
  607. if hoe != "got":
  608. _LOG.warning("[T04] 结果=%s — 跳过挖矿, 进入站立", hoe)
  609. else:
  610. # ---- T05 挖矿 ----
  611. banner("[6.8] T05 挖矿 %.0fs", a.mine_seconds)
  612. spot_res = find_mining_spot((world.me.x, world.me.y), walls)
  613. if spot_res is None:
  614. _LOG.warning("[T05] 可达域无面壁格 — 跳过挖矿")
  615. else:
  616. spot, face_dir = spot_res
  617. if spot != (world.me.x, world.me.y):
  618. _LOG.info("[T05] 跑到挖矿位 %s 面向 dir=%d", spot, face_dir)
  619. bh = AutoMoveBehavior(sid=sid, dest_x=spot[0], dest_y=spot[1])
  620. _ = run(conn, world, updater, bh, duration_s=_MOVE_BUDGET_S)
  621. _step_to_mine_cell(conn, sid, world, updater, spot)
  622. me = world.me
  623. d = face_dir
  624. if (me.x, me.y) != spot:
  625. _LOG.warning("[T05] 未精确踩到 %s(实际 %d,%d) — 就地重找面墙向",
  626. spot, me.x, me.y)
  627. d = _face_wall_dir((me.x, me.y), walls) or face_dir
  628. _LOG.info("[T05] 站(%d,%d) 挖(%d,%d) dir=%d", me.x, me.y,
  629. me.x + DIR_VECS[d][0], me.y + DIR_VECS[d][1], d)
  630. _ = mine_loop(conn, sid, world, updater, real_name,
  631. a.mine_seconds, (me.x, me.y), d)
  632. if not a.skip_heal:
  633. banner("[7] 开启喝药保护(进图已 ≥%.0fs)", time.time() - born_t)
  634. heal_state: str | None = None
  635. for i in range(_HEAL_ROUNDS):
  636. heal_state = enable_auto_heal(conn, sid, want_on=True,
  637. world=world)
  638. if heal_state == "on":
  639. break
  640. _LOG.warning("[T1] 第%d轮 state=%s — 重试", i + 1, heal_state)
  641. time.sleep(1.0)
  642. _LOG.info("[%s] 喝药状态=%s", "✓" if heal_state == "on" else "!",
  643. heal_state)
  644. banner("[7.5] 原地站立 %.0fs(仅心跳+收帧)", hold)
  645. deadline = time.time() + hold
  646. while time.time() < deadline:
  647. _ = pump(conn, world, updater, 0.5)
  648. _LOG.info("[站立结束] map=%s @(%d,%d) 距 %s %d 格",
  649. world.me.map_name, world.me.x, world.me.y, NPC_NAME,
  650. max(abs(npc_pos[0] - world.me.x),
  651. abs(npc_pos[1] - world.me.y)))
  652. top = sorted(world.frame_counts.items(), key=lambda kv: -kv[1])[:12]
  653. _LOG.info("收帧Top12: %s", top)
  654. banner("[8] 退出进程")
  655. return 0
  656. finally:
  657. stop[0] = True
  658. conn.close()
  659. _LOG.info("连接已关闭, 角色=%s", real_name)
  660. if __name__ == "__main__":
  661. try:
  662. sys.exit(main())
  663. except PbotError as e:
  664. _LOG.error("失败: %s", e.detail)
  665. sys.exit(1)
  666. except RuntimeError as e:
  667. _LOG.error("失败: %s", e)
  668. sys.exit(1)