main.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. """主入口: 登录 → 选区 → 创角/复用 → 进游戏 → 开喝药 → 等待图走动 10 秒。
  2. 用法:
  3. uv run python main.py -u <账号> -p <密码> [--zone <区ID>] [--name <角色名>]
  4. [--wander-seconds 10] [--login-port 0]
  5. 区 ID=0 时自动选服务器列表最新已开区; 登录端口 0 时按 8021/8061/8001/8041 轮询
  6. (backup 实测: 38区=8041, 52区=8001, 端口随区变化)。
  7. """
  8. import argparse
  9. import base64
  10. import json
  11. import logging
  12. import os
  13. import re
  14. import sys
  15. import time
  16. import requests
  17. from pbot.bot.automove import AutoMoveBehavior
  18. from pbot.bot.behavior import TickResult
  19. from pbot.bot.kill import KillBehavior
  20. from pbot.bot.runner import pump, run
  21. from pbot.bot.unlock import UnlockBehavior
  22. from pbot.bot.wander import WanderBehavior
  23. from pbot.config import Account, RoleSpec
  24. from pbot.errors import PbotError
  25. from pbot.game.heal import enable_auto_heal
  26. from pbot.game.session import enter_world
  27. from pbot.login.box_client import enter_game_client, login
  28. from pbot.transport.gateway import (
  29. GatewayConnection,
  30. create_role_needed,
  31. delete_role,
  32. login_gate_with_retry,
  33. )
  34. from pbot.types import ZoneId
  35. from pbot.world.mirror import WorldMirror
  36. from pbot.world.update import WorldUpdater, as_dict, to_bool, to_float, to_int, typed_json
  37. SERVERLIST_URL = "https://list-box.dhsf.xqhuyu.com/box_serverlist/206923.txt"
  38. DEFAULT_PORTS: tuple[int, ...] = (8021, 8061, 8001, 8041)
  39. def setup_logging() -> None:
  40. """根日志: 时间+级别+模块, UTF-8 输出。"""
  41. logging.basicConfig(
  42. level=logging.INFO,
  43. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  44. stream=sys.stdout,
  45. )
  46. def pick_latest_zone() -> int:
  47. """服务器列表里 state=1 且已开服的最新区(backup 实测逻辑)。"""
  48. r = requests.get(SERVERLIST_URL, timeout=20)
  49. m = re.search(r"\{.*\}", r.text, re.DOTALL)
  50. if not m:
  51. raise PbotError(detail="区列表拉取失败")
  52. data = typed_json(json.loads(m.group(0))) # pyright: ignore[reportAny]
  53. best: tuple[float, str, int] | None = None
  54. def walk(o: object) -> None:
  55. nonlocal best
  56. if isinstance(o, dict):
  57. d = as_dict(typed_json(o)) # pyright: ignore[reportUnknownArgumentType]
  58. sid = to_int(d.get("serverid"))
  59. state = to_int(d.get("state"))
  60. ot = to_float(d.get("open_time"))
  61. if sid > 0 and state == 1 and 0 < ot <= time.time():
  62. name = str(d.get("list_name") or d.get("server_name") or "")
  63. if best is None or ot > best[0]:
  64. best = (ot, name, sid)
  65. for v in d.values():
  66. walk(v)
  67. elif isinstance(o, list):
  68. items: list[object] = list(o) # pyright: ignore[reportUnknownArgumentType]
  69. for v in items:
  70. walk(v)
  71. walk(data)
  72. result = best
  73. if result is None:
  74. raise PbotError(detail="无已开新区")
  75. logging.getLogger("main").info("自动选区: %s (id=%d, 开服=%s)",
  76. result[1], result[2], result[0])
  77. return result[2]
  78. def parse_args() -> dict[str, str | int | float]:
  79. """命令行参数(强类型化 dict)。"""
  80. ap = argparse.ArgumentParser()
  81. _ = ap.add_argument("-u", "--user", required=True)
  82. _ = ap.add_argument("-p", "--password", required=True)
  83. _ = ap.add_argument("--zone", type=int, default=0, help="0=自动选最新已开区")
  84. _ = ap.add_argument("--name", default="", help="角色名(空=随机萌新XXXX)")
  85. _ = ap.add_argument("--login-port", type=int, default=0, help="0=自动轮询")
  86. _ = ap.add_argument("--wander-seconds", type=float, default=10.0)
  87. _ = ap.add_argument("--kill", type=int, default=0,
  88. help="杀怪数达标线(>0 时进入打怪流程: 传比奇→杀N怪)")
  89. _ = ap.add_argument("--run", action="store_true",
  90. help="走动用 CS3013 跑(跳2格/0.55s)替代 CS3011 走")
  91. _ = ap.add_argument("--reset", action="store_true", help="先删角重创(新手任务重置)")
  92. _ = ap.add_argument("--jwt", default="",
  93. help="外部注入 JWT(pwd 型 token); 跳过盒子登录与 EnterGameClient")
  94. _ = ap.add_argument("--wait-duration", action="store_true",
  95. help="走动前等待 game_duration 首跳(≤5min, §八.11 打分假设)")
  96. _ = ap.add_argument("--idle", action="store_true",
  97. help="创角+喝药后原地不动(只心跳+收帧, 不走动不退出)")
  98. _ = ap.add_argument("--goto", default="",
  99. help="自动寻路目标 x,y(当前图内; 复刻客户端自动寻路 5013 协议)")
  100. a: object = ap.parse_args()
  101. user = str(a.user) # pyright: ignore[reportAny]
  102. password = str(a.password) # pyright: ignore[reportAny]
  103. zone_v = to_int(a.zone) # pyright: ignore[reportAny]
  104. name = str(a.name) # pyright: ignore[reportAny]
  105. login_port = to_int(a.login_port) # pyright: ignore[reportAny]
  106. wander_seconds = to_float(a.wander_seconds) # pyright: ignore[reportAny]
  107. kill_target = to_int(a.kill) # pyright: ignore[reportAny]
  108. ext_jwt = str(a.jwt) # pyright: ignore[reportAny]
  109. return {
  110. "user": user, "password": password, "zone": zone_v, "name": name,
  111. "login_port": login_port, "wander_seconds": wander_seconds,
  112. "kill": kill_target,
  113. "reset": bool(a.reset), # pyright: ignore[reportAny]
  114. "jwt": ext_jwt,
  115. "wait_duration": bool(a.wait_duration), # pyright: ignore[reportAny]
  116. "run": bool(a.run), # pyright: ignore[reportAny]
  117. "idle": bool(a.idle), # pyright: ignore[reportAny]
  118. "goto": str(a.goto), # pyright: ignore[reportAny]
  119. }
  120. def _enter_game(http: requests.Session, zone: ZoneId, jwt: str, uid: int,
  121. role_name: str, ports: tuple[int, ...], stop: list[bool],
  122. ext_mode: bool = False, wait_duration: bool = False,
  123. ) -> tuple[GatewayConnection, WorldMirror, WorldUpdater, int]:
  124. """阶段 2-4: 选区上报 → 登录服票据 → 游戏服就绪。"""
  125. log = logging.getLogger("main")
  126. if ext_mode:
  127. log.info("======== [2/5] 跳过 EnterGameClient (pwd 型 token 不走 PC 网关) ========")
  128. else:
  129. log.info("======== [2/5] EnterGameClient zone=%d ========", zone)
  130. for attempt in range(4):
  131. try:
  132. _ = enter_game_client(http, jwt, zone)
  133. break
  134. except Exception:
  135. if attempt == 3:
  136. raise
  137. time.sleep(12)
  138. log.info("======== [3/5] 登录服选角/创角 角色=%s ========", role_name)
  139. key32, sid, real_name, ticket_key, game_port = login_gate_with_retry(
  140. zone, jwt, uid, role_name, ports)
  141. log.info("[票据] key32=%s sid=%d 角色=%s ticket=%s 游戏端口=%d",
  142. key32.decode(errors="replace"), sid, real_name, ticket_key, game_port)
  143. log.info("======== [4/5] 进游戏(角色=%s) ========", real_name)
  144. conn, world, updater = enter_world(zone, real_name, ticket_key, sid,
  145. game_port, uid, stop=stop)
  146. # 真客户端在角色属性初始化后立即上报 game_login(PlayerPropertyProxy:620
  147. # → DataTrackProxy:OnEnterGame → SDK) — bot 补上这条腿, 否则服务器视
  148. # 本会话为"无壳层幽灵会话"(§八.4 假设), 走动帧全拒。
  149. if ext_mode:
  150. try:
  151. from pbot.login.sdk_report import SdkReporter
  152. sdk = SdkReporter()
  153. if sdk.up_device_login(box_game_id=206923, channel_id="110585"):
  154. sdk.send_game_login(
  155. uid=uid, zone_id=zone, zone_name=str(zone),
  156. role_id=ticket_key.split("/", 1)[-1], role_name=real_name,
  157. role_level=1, job_name="战士", job_id=0)
  158. globals()["_sdk_reporter"] = sdk
  159. if wait_duration:
  160. log.info("======== [4.5/5] 等待 game_duration 首跳(≤5min) ========")
  161. _ = sdk.wait_first_duration(timeout=360.0)
  162. except Exception as e: # noqa: BLE001 - 上报失败不阻断走动实验
  163. log.warning("[SDK] 上报链失败(实验继续): %s", e)
  164. return conn, world, updater, sid
  165. def _jwt_uid(jwt: str) -> int:
  166. """从 JWT payload 的 sub 提取 uid(pwd 型 token 的 sub 即 CS2001 uid)。"""
  167. try:
  168. payload_b64 = jwt.split(".")[1]
  169. payload = as_dict(typed_json(json.loads( # pyright: ignore[reportAny]
  170. base64.urlsafe_b64decode(payload_b64 + "=="))))
  171. return to_int(payload.get("sub"))
  172. except (IndexError, ValueError):
  173. raise PbotError(detail="--jwt 无效: payload 不是合法 JSON(base64)") from None
  174. def main() -> int:
  175. """全链路: 每阶段失败即退出并输出原因日志。"""
  176. cfg = parse_args()
  177. user = str(cfg["user"])
  178. password = str(cfg["password"])
  179. zone_arg = to_int(cfg["zone"])
  180. name_arg = str(cfg["name"])
  181. login_port = to_int(cfg["login_port"])
  182. wander_seconds = to_float(cfg["wander_seconds"])
  183. kill_target = to_int(cfg.get("kill"))
  184. do_reset = to_bool(cfg.get("reset"))
  185. ext_jwt = str(cfg.get("jwt") or "")
  186. wait_duration_flag = bool(cfg.get("wait_duration"))
  187. use_run = bool(cfg.get("run"))
  188. idle_mode = bool(cfg.get("idle"))
  189. goto_raw = str(cfg.get("goto") or "")
  190. goto: tuple[int, int] | None = None
  191. if "," in goto_raw:
  192. xs, ys = goto_raw.split(",", 1)
  193. if xs.strip().lstrip("+-").isdigit() and ys.strip().lstrip("+-").isdigit():
  194. goto = (int(xs), int(ys))
  195. else:
  196. raise PbotError(detail=f"--goto 无效: {goto_raw!r} (格式 x,y)")
  197. elif goto_raw:
  198. raise PbotError(detail=f"--goto 无效: {goto_raw!r} (格式 x,y)")
  199. setup_logging()
  200. log = logging.getLogger("main")
  201. account = Account(username=user, password=password)
  202. zone = ZoneId(pick_latest_zone() if zone_arg == 0 else zone_arg)
  203. if name_arg:
  204. role_name = create_role_needed(RoleSpec(name=name_arg))
  205. else:
  206. # 用户没传 → 随机纯中文名(无数字)
  207. import random as _rnd
  208. cn_pool = ("玖扬测试", "新角色", "复古玩家", "江湖游侠", "风云再起",
  209. "独行侠客", "剑指天下", "无名英雄", "传奇新篇", "傲世狂刀",
  210. "凌风踏雪", "落日长河", "踏雪无痕", "一剑封喉", "笑傲江湖")
  211. role_name = _rnd.choice(cn_pool) # noqa: S311
  212. log.info("[随机纯中文名] 选用: %s", role_name)
  213. ports = (login_port,) if login_port else DEFAULT_PORTS
  214. ext_mode = bool(ext_jwt)
  215. if ext_mode:
  216. # 优先实时提取盒子最新 token(消除签发时间差), 失败再用显式 --jwt
  217. try:
  218. from pbot.login.sdk_report import fetch_fresh_box_token
  219. jwt, uid = fetch_fresh_box_token()
  220. log.info("======== [1/5] 盒子实时 token 注入 uid=%d ========", uid)
  221. except (OSError, ValueError, KeyError) as e:
  222. jwt = ext_jwt
  223. uid = _jwt_uid(jwt)
  224. log.info("======== [1/5] 外部 JWT 注入 uid=%d (盒子token不可用: %s) ========",
  225. uid, e)
  226. else:
  227. # 1. 盒子登录
  228. log.info("======== [1/5] 盒子登录 %s ========", account.username)
  229. jwt, uid = login(account)
  230. if do_reset:
  231. log.info("======== [1.5/5] 删角重创 %s ========", role_name)
  232. deleted = delete_role(zone, jwt, uid, role_name, ports)
  233. log.info("[删角] %s", "成功" if deleted else "失败(继续)")
  234. time.sleep(10) # 服务器落库延迟(实测: 立即重创会拿不到票据)
  235. http = requests.Session()
  236. http.trust_env = False
  237. http.verify = False
  238. # 心跳: enter_world 内部在 CS20 后立即启动(backup 实测: 不跑心跳不下发出生帧)
  239. stop: list[bool] = [False]
  240. conn, world, updater, sid = _enter_game(http, zone, jwt, uid, role_name,
  241. ports, stop, ext_mode=ext_mode,
  242. wait_duration=wait_duration_flag)
  243. try:
  244. # 5. 喝药保护
  245. log.info("======== [5/5] 喝药保护 ========")
  246. state = enable_auto_heal(conn, sid, want_on=True, world=world)
  247. if state == "on":
  248. log.info("[✓] 喝药保护已开启(服务端 but_1 确认)")
  249. elif state == "off":
  250. log.warning("[!] 喝药保护为关(切换未生效?)")
  251. else:
  252. log.warning("[!] 未捕获到开关状态面板(zlib 面板未到达)")
  253. # 6. 打怪(--kill>0) / 自动寻路(--goto x,y) / 解锁+wander / idle(原地不动)
  254. if goto is not None:
  255. log.info("======== 自动寻路: 目标(%d,%d) 当前 %s @(%d,%d) ========",
  256. goto[0], goto[1], world.me.map_name, world.me.x, world.me.y)
  257. auto_move = AutoMoveBehavior(sid=sid, dest_x=goto[0], dest_y=goto[1])
  258. goto_duration = float(os.environ.get("PBOT_GOTO_DURATION_S", "0")) or 900.0
  259. goto_result = run(conn, world, updater, auto_move,
  260. duration_s=goto_duration)
  261. log.info("[寻路结束] result=%s 位置=(%d,%d) 地图=%s",
  262. goto_result.value, world.me.x, world.me.y, world.me.map_name)
  263. elif idle_mode:
  264. # idle: 创角+喝药后原地不动, 只心跳+收帧, 不发任何行动帧
  265. log.info("======== idle: 原地不动(创角+喝药已完成) ========")
  266. log.info(" 当前 %s @(%d,%d) actor=%s",
  267. world.me.map_name, world.me.x, world.me.y, world.me.actor_key)
  268. idle_until = float(os.environ.get("PBOT_IDLE_DURATION_S", "0")) or 1e18
  269. log.info(" idle 持续 %.0fs (0=永久); Ctrl-C 或 pkill -f 'uv run python main.py' 退出",
  270. min(idle_until, 86400 * 365))
  271. idle_deadline = time.time() + idle_until
  272. while time.time() < idle_deadline:
  273. _ = pump(conn, world, updater, 0.5)
  274. elif kill_target > 0:
  275. renwu_act = os.environ.get("PBOT_RENWU_ACT", "act_move,1")
  276. no_reroll = os.environ.get("PBOT_NO_REROLL") == "1"
  277. wp_raw = os.environ.get("PBOT_WAYPOINT", "")
  278. wp: tuple[int, int] | None = None
  279. if "," in wp_raw:
  280. xs, ys = wp_raw.split(",", 1)
  281. if xs.strip().lstrip("+-").isdigit() \
  282. and ys.strip().lstrip("+-").isdigit():
  283. wp = (int(xs), int(ys))
  284. la_raw = os.environ.get("PBOT_LAND_AT", "")
  285. la: tuple[int, int] | None = None
  286. if "," in la_raw:
  287. xs, ys = la_raw.split(",", 1)
  288. if xs.strip().lstrip("+-").isdigit() \
  289. and ys.strip().lstrip("+-").isdigit():
  290. la = (int(xs), int(ys))
  291. # PBOT_BOUND=x,y,r 限制 _search_anchor 范围(用户视野内)
  292. bd_raw = os.environ.get("PBOT_BOUND", "")
  293. bound: tuple[tuple[int, int], int] | None = None
  294. if "," in bd_raw:
  295. parts = bd_raw.split(",")
  296. if len(parts) == 3 \
  297. and all(p.strip().lstrip("+-").isdigit() for p in parts):
  298. bound = ((int(parts[0]), int(parts[1])), int(parts[2]))
  299. hdr = ("======== 打怪: 目标 %d 只 @%s, land_at=%s, waypoint=%s, "
  300. "bound=%s, 当前 %s @(%d,%d) ========")
  301. log.info(hdr, kill_target, renwu_act, la, wp, bound,
  302. world.me.map_name, world.me.x, world.me.y)
  303. kill = KillBehavior(sid=sid, kill_target=kill_target,
  304. renwu_act=renwu_act,
  305. allow_reroll=not no_reroll, waypoint=wp,
  306. land_at=la, bound=bound,
  307. force_act=os.environ.get("PBOT_FORCE_ACT") == "1",
  308. land_only=os.environ.get("PBOT_LAND_ONLY") == "1",
  309. roam=os.environ.get("PBOT_ROAM") == "1")
  310. kill_duration_s = float(
  311. os.environ.get("PBOT_KILL_DURATION_S", "0")) or \
  312. max(600.0, kill_target * 120.0)
  313. kill_result = run(conn, world, updater, kill,
  314. duration_s=kill_duration_s)
  315. log.info("[打怪结束] result=%s kills=%d 位置=(%d,%d) 地图=%s",
  316. kill_result.value, world.kills, world.me.x, world.me.y,
  317. world.me.map_name)
  318. if kill_result is TickResult.DONE and world.kills >= kill_target:
  319. # 达标后保持原地: 仅心跳(线程已有) + 收帧, 不发任何行动帧
  320. idle_s = float(os.environ.get("PBOT_KILL_IDLE_S", "0")) or 1e18
  321. log.info("======== 达标待机(原地不动, 仅心跳) %.0fs; Ctrl-C 或 pkill 退出 ========",
  322. min(idle_s, 86400 * 365))
  323. idle_until = time.time() + idle_s
  324. while time.time() < idle_until:
  325. _ = pump(conn, world, updater, 0.5)
  326. log.info("[待机结束] 最终位置=(%d,%d) 地图=%s",
  327. world.me.x, world.me.y, world.me.map_name)
  328. else:
  329. # 解锁(滞留带/异图 → renwu_mov/NPC链推进到等待图可走区)
  330. log.info("======== 解锁: 当前 %s @(%d,%d) ========",
  331. world.me.map_name, world.me.x, world.me.y)
  332. unlock = UnlockBehavior(sid=sid)
  333. unlock_result = run(conn, world, updater, unlock,
  334. duration_s=max(60.0, wander_seconds * 6))
  335. log.info("[解锁] result=%s 位置=(%d,%d) 地图=%s",
  336. unlock_result.value, world.me.x, world.me.y, world.me.map_name)
  337. # 7. 等待图走动/跑动
  338. mode = "跑动" if use_run else "走动"
  339. log.info("======== 等待图%s %.0f 秒 @(%d,%d) map=%s ========",
  340. mode, wander_seconds, world.me.x, world.me.y, world.me.map_name)
  341. behavior = WanderBehavior(sid=sid, use_run=use_run, random_roam=True)
  342. result = run(conn, world, updater, behavior, duration_s=wander_seconds)
  343. log.info("[结束] result=%s pos=(%d,%d) 地图=%s",
  344. result.value, world.me.x, world.me.y, world.me.map_name)
  345. top = sorted(world.frame_counts.items(), key=lambda kv: -kv[1])[:10]
  346. log.info("收帧Top10: %s", top)
  347. return 0
  348. finally:
  349. stop[0] = True
  350. conn.close()
  351. if __name__ == "__main__":
  352. sys.exit(main())