combat_bot.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. # -*- coding: utf-8 -*-
  2. """
  3. combat_bot.py —— 无客户端杀怪: 进游戏 → 视野怪物表 → 寻路接近 → CS3014 近战击杀。
  4. 协议来源(游戏Lua源码):
  5. gamePlayerController.lua:93 CS3013 跑: recog=Y*65536+X, p2=dir(0-7), p3=lastPoint, body=mapID
  6. RequestLaunchCommand.lua:387 CS3014 砍: recog=skillID, body=JSON{skillID,dir,srcX,srcY,destX,destY,targetID}
  7. gameMapController.lua:114-130 SC10 进视野: recog=race|raceImg<<16, p1=actorID, p2=X|Y<<16, p3=type|Dir<<16, body=JSON
  8. mmoConstants.lua:141-148 方向: 0上 1右上 2右 3右下 4下 5左下 6左 7左上
  9. mmoConstants.lua:771 SKILL_INDEX_BASIC=0
  10. 用法:
  11. .venv/bin/python combat_bot.py --zone 14492162 --name 测C1742 [--kill N] [--minutes 5]
  12. """
  13. import argparse
  14. import json
  15. import math
  16. import random
  17. import socket
  18. import struct
  19. import sys
  20. import threading
  21. import time
  22. import zlib
  23. import requests as rq
  24. import urllib3
  25. urllib3.disable_warnings()
  26. HERE = __file__.rsplit("/", 1)[0]
  27. sys.path.insert(0, HERE)
  28. from encode6bitbuf_final import encode6, decode6 # noqa: E402
  29. import login_996pc as L # noqa: E402
  30. import cq2_pure_enter as P # noqa: E402
  31. RACE_HUMAN = {0, 1, 5, 11} # 网络玩家/英雄/宝宝/守卫 → 非怪
  32. DIRS = [(0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1)] # 0-7
  33. def enter_game_gate_v2(zone_id, name, ticket_key, sid, game_port, uid):
  34. """CS20 登录体对照 LoginProxy.lua:1076 的 11 字段格式:
  35. *#账号/角色名/角色ID/sdata/platform/channelId/whitelist/cert/adult/isboxlogin/isBatchMsg
  36. 旧版(enter_game_gate)多一个 /1 且 channelId 错位 → 服务器不下发 SC658(世界未初始化)。"""
  37. g = socket.create_connection((P.GW_HOST.format(zone=zone_id), game_port), timeout=10)
  38. body20 = f"*#{uid}/{name}/{ticket_key}/{sid}/2/{P.CHANNEL}/0/0/0/1/1".encode()
  39. head = struct.pack("<9i", 900000, 20, random.randint(1, 2**30),
  40. zone_id, game_port, sid, 0, len(body20), 0)
  41. g.sendall(encode6(head) + body20)
  42. return g
  43. def send_init_frames(bot, uid):
  44. """init 序列逐帧复刻 cap_unlock 真客户端(sid=159 会话 t=44.83-45.09):
  45. 1018(t2) 1046×4(t3-6) 2020(t7,uid) 5026(t8) 5028(t9)
  46. 5027(t10, body=当前图名!) 5260(t11) 1078(t12) 1079(t13) 5148(t14)
  47. 5277(t15) 1103(t16) 1151(t17) 6209(t18) 6208(t19)
  48. 关键: 5027 必须是【当前所在图名】(等待图='等待地图'), 在 5028 之后、
  49. 5260 之前 — 旧版发 b'3' 且位置错 → 服务器图会话绑定失败 → 移动全拒。"""
  50. tpls = P._load_ui_templates()
  51. cur_map = bot.me.get("mapname") or "等待地图"
  52. frames = [
  53. (1018, P.CFG_1018.encode()),
  54. (1046, b""), (1046, b""), (1046, b""), (1046, b""),
  55. (2020, f"{uid}/3.40.4087/3.40.9112/{uid}/0/1001/110585/0/0".encode()),
  56. (5026, P.DEV_FINGERPRINT_5026),
  57. (5028, P.CHANNEL_5028),
  58. (5027, cur_map.encode()),
  59. (5260, b""), (1078, b""), (1079, b""), (5148, b""),
  60. (5277, tpls["5277"].encode() if tpls else b""),
  61. (1103, b""), (1151, b""), (6209, b""), (6208, b""),
  62. ]
  63. for i, (mid, body) in enumerate(frames):
  64. p1, p2 = (41878640, 98395) if mid == 1018 else (0, 0)
  65. bot.send(mid, 0, p1, p2, bot.sid, body, tail=2 + i)
  66. bot.pump(0.1)
  67. def log(m):
  68. print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True)
  69. def lo16(v):
  70. return v & 0xFFFF
  71. def hi16(v):
  72. return (v >> 16) & 0xFFFF
  73. def pack_point(x, y):
  74. return (y & 0xFFFF) * 65536 + (x & 0xFFFF)
  75. def proto_map_name(me):
  76. """走路帧 body 的协议图名 = 服务器下发的 MapId(SC634/SC51 JSON)。
  77. 中文名→协议ID 反查兜底: 比奇='0' 盟重='3' 等待图='等待地图'(中文名即协议名)。
  78. 发错图名服务器直接拒帧(run5-11 站桩根因: me['map'] 残留出生图'3')。"""
  79. m = str(me.get("map") or me.get("mapname") or "0")
  80. return {"比奇大城": "0", "盟重土城": "3"}.get(m, m)
  81. def dir_to(x, y, tx, ty):
  82. dx, dy = max(-1, min(1, tx - x)), max(-1, min(1, ty - y))
  83. for i, (ox, oy) in enumerate(DIRS):
  84. if ox == dx and oy == dy:
  85. return i
  86. return 2
  87. class CombatBot:
  88. def __init__(self, sock, sid, name):
  89. self.g, self.sid, self.name = sock, sid, name
  90. self.me = {"id": 0, "x": 0, "y": 0, "dir": 4, "map": "0", "mapname": ""}
  91. self.monsters = {} # actorID -> {x,y,race,name,die}
  92. self.npcs = {} # actorID -> {cfg_id,name,index,x,y}
  93. self.frame_log = {} # msgId -> count (观测用)
  94. self.kills = 0
  95. self.exp0 = None
  96. self._tail = 100
  97. self._buf = b""
  98. self.alive = True
  99. self.lock = threading.Lock()
  100. # ---------- 发送 ----------
  101. def send(self, mid, recog, p1, p2, p3, body=b"", tail=None):
  102. self._tail += 1
  103. t = self._tail if tail is None else tail
  104. with self.lock:
  105. self.g.sendall(encode6(struct.pack(
  106. "<9i", 0, mid, recog, p1, p2, p3, 0, len(body), t)) + body)
  107. def send_run(self, x, y, last_x, last_y, d):
  108. """CS3013 跑2格 — 实测验证布局(combat_bot 早期等待图22步全通同源):
  109. Lua: LuaSendMsg(msgType, point, 0, dir, lastPoint, mapID)
  110. → recog=pack_point(x,y), p1=0, p2=dir, p3=pack_point(last), body=MapId。"""
  111. for v in (x, y, last_x, last_y):
  112. if not 0 <= v <= 65535:
  113. return
  114. self.send(3013, pack_point(x, y), 0, d, pack_point(last_x, last_y),
  115. proto_map_name(self.me).encode())
  116. def send_walk(self, x, y, last_x, last_y, d):
  117. """CS3011 走1格 — 布局同 send_run(走/跑共用 handleActionBegin 路径)。"""
  118. for v in (x, y, last_x, last_y):
  119. if not 0 <= v <= 65535:
  120. return
  121. self.send(3011, pack_point(x, y), 0, d, pack_point(last_x, last_y),
  122. proto_map_name(self.me).encode())
  123. def send_attack(self, target_id, tx, ty):
  124. # cap3 实证 targetID 为字符串("57659"), 且 body 键序与真实客户端一致
  125. d = dir_to(self.me["x"], self.me["y"], tx, ty)
  126. payload = {"skillID": 0, "dir": d, "srcX": self.me["x"], "srcY": self.me["y"],
  127. "destX": tx, "destY": ty, "targetID": str(target_id)}
  128. body = json.dumps(payload, separators=(",", ":")).encode()
  129. # 关键诊断: 打印首次攻击,确认攻击确实发出
  130. if not getattr(self, "_first_atk_logged", False):
  131. self._first_atk_logged = True
  132. log(f" [首次攻击] me=({self.me['x']},{self.me['y']}) → "
  133. f"target={target_id} @{tx},{ty} dir={d}")
  134. self.send(3014, 0, 0, 0, 0, body)
  135. # ---------- 收帧/状态 ----------
  136. def wait_msg(self, mid, timeout):
  137. deadline = time.time() + timeout
  138. while time.time() < deadline and self.alive:
  139. self.pump(0.3)
  140. if mid in self.frame_log:
  141. return True
  142. return mid in self.frame_log
  143. def pump(self, seconds=0.2):
  144. end = time.time() + seconds
  145. events = []
  146. while time.time() < end:
  147. self.g.settimeout(max(0.05, end - time.time()))
  148. try:
  149. d = self.g.recv(65536)
  150. if not d:
  151. self.alive = False
  152. return events
  153. except (socket.timeout, OSError):
  154. break
  155. events += self._parse(d)
  156. return events
  157. def _parse(self, d: bytes):
  158. """帧切分 + 按已知消息号更新状态。返回可见事件列表。"""
  159. ev = []
  160. buf = self._buf + d
  161. pos = 0
  162. while len(buf) - pos >= 48:
  163. try:
  164. h = struct.unpack("<9i", decode6(buf[pos:pos + 48]))
  165. except Exception:
  166. pos += 1
  167. continue
  168. blen = h[7]
  169. if blen < 0 or blen > 200000 or len(buf) - pos < 48 + blen:
  170. break
  171. mid, recog, p1, p2, p3, tail = h[1], h[2], h[3], h[4], h[5], h[8]
  172. body = buf[pos + 48: pos + 48 + blen]
  173. pos += 48 + blen
  174. self.frame_log[mid] = self.frame_log.get(mid, 0) + 1
  175. if mid == -1 and body[:2] == b"x\x9c":
  176. # SC-1 批量帧: body=zlib(子帧流), 子帧同顶层格式 → 递归
  177. try:
  178. ev += self._parse_sub(zlib.decompress(body))
  179. except zlib.error:
  180. pass
  181. continue
  182. ev += self._dispatch(mid, recog, p1, p2, p3, body)
  183. self._buf = buf[pos:]
  184. return ev
  185. def _parse_sub(self, raw: bytes):
  186. """批量帧内的子帧流拆包(不回写 _buf)。"""
  187. ev = []
  188. pos = 0
  189. while len(raw) - pos >= 48:
  190. try:
  191. h = struct.unpack("<9i", decode6(raw[pos:pos + 48]))
  192. except Exception:
  193. break
  194. blen = h[7]
  195. if blen < 0 or blen > 200000 or len(raw) - pos < 48 + blen:
  196. break
  197. self.frame_log[h[1]] = self.frame_log.get(h[1], 0) + 1
  198. ev += self._dispatch(h[1], h[2], h[3], h[4], h[5],
  199. raw[pos + 48: pos + 48 + blen])
  200. pos += 48 + blen
  201. return ev
  202. def _jbody(self, body):
  203. """zlib → encode6 → JSON 逐层剥。实测: SC11/13 body=encode6(JSON) 无 zlib;
  204. 部分帧 zlib(encode6(JSON)); 部分帧直接 JSON。"""
  205. if not body:
  206. return None
  207. for blob in CombatBot._body_variants(body):
  208. if not blob:
  209. continue
  210. try:
  211. return json.loads(blob)
  212. except Exception:
  213. continue
  214. return None
  215. @staticmethod
  216. def _body_variants(body: bytes):
  217. """产出所有可能的解码变体: 原文 / encode6解 / zlib解 / zlib(encode6解)。"""
  218. out = [body]
  219. out.append(CombatBot._try_decode6(body))
  220. if body[:2] == b"x\x9c":
  221. try:
  222. dec = zlib.decompress(body)
  223. out.append(dec)
  224. out.append(CombatBot._try_decode6(dec))
  225. except zlib.error:
  226. pass
  227. return out
  228. @staticmethod
  229. def _try_decode6(raw: bytes):
  230. """可打印区(0x3C..0x7B)开头才尝试 decode6(长度不必 8 倍数, 内部按 bit 流)。"""
  231. if not raw or not all(0x3C <= c <= 0x7B for c in raw[:16]):
  232. return b""
  233. try:
  234. return decode6(raw)
  235. except Exception:
  236. return b""
  237. def _dispatch(self, mid, recog, p1, p2, p3, body):
  238. ev = []
  239. # 旁路钩子: 外部脚本监听全部已拆包子帧(文本类解码由钩子自理)
  240. hook = getattr(self, "raw_frame_hook", None)
  241. if hook:
  242. try:
  243. hook(mid, recog, p1, p2, p3, body)
  244. except Exception:
  245. pass
  246. # SC50 创建主玩家 / SC10 进视野(对照 converActorData: X=lo16(p2) Y=hi16(p2),
  247. # race=lo16(recog), type=lo16(p3), nActorId=p1)
  248. if mid in (50, 10):
  249. x, y = lo16(p2), hi16(p2)
  250. typ = lo16(p3)
  251. if mid == 50:
  252. self.me["id"] = p1
  253. j = self._jbody(body)
  254. if j and j.get("info"):
  255. self.me["uid"] = str(j["info"][0]) # 完整UserID字符串(SC11/13自识别用)
  256. if p2 != 0: # SC50 头 p2=0 时坐标以 SC51 为准
  257. self.me["x"], self.me["y"] = x, y
  258. ev.append(("me_born", self.me["x"], self.me["y"]))
  259. elif typ == 50: # ACTOR_MONSTER(mmoConstants:76)
  260. j = self._jbody(body) or {}
  261. cs = j.get("cs", 0)
  262. dead = (cs & 0b100) or (cs & 0b10) # die/skeleton 位
  263. info = j.get("info")
  264. self.monsters[p1] = {
  265. "x": x, "y": y, "race": lo16(recog),
  266. "name": info[2] if info else str(p1),
  267. "die": bool(dead), "t": time.time()}
  268. ev.append(("mon_in", p1, x, y))
  269. else: # NPC/玩家: info=[actorID字符串, 名称]
  270. j = self._jbody(body) or {}
  271. info = j.get("info")
  272. if info and len(info) >= 2:
  273. self.npcs[p1] = {"aid": str(info[0]), "name": info[1],
  274. "index": p1, "x": x, "y": y}
  275. ev.append(("npc_in", str(info[0]), info[1], p1, x, y))
  276. elif mid == 643: # SC643 NPC对话树
  277. j = self._jbody(body)
  278. if j:
  279. ev.append(("npc_talk", j))
  280. elif mid == 20: # SC20 怪物出生(复活)
  281. try:
  282. aid = int(body.decode("utf-8", "replace").strip())
  283. except ValueError:
  284. aid = 0
  285. if aid in self.monsters:
  286. self.monsters[aid]["die"] = False
  287. self.monsters[aid]["t"] = time.time()
  288. elif mid == 30: # SC30 出视野
  289. try:
  290. aid = int(body.decode("utf-8", "replace").strip())
  291. except ValueError:
  292. aid = 0
  293. if aid in self.monsters:
  294. del self.monsters[aid]
  295. ev.append(("mon_out", aid))
  296. elif mid == 32: # SC32 死亡倒下
  297. try:
  298. aid = int(body.decode("utf-8", "replace").strip())
  299. except ValueError:
  300. aid = 0
  301. if aid in self.monsters:
  302. self.monsters[aid]["die"] = True
  303. self.kills += 1
  304. ev.append(("mon_die", aid))
  305. elif mid == 28: # SC28 移动失败拉回(p1=X,p2=Y, p3=dir)
  306. self.me["x"], self.me["y"] = p1, p2
  307. self.me["dir"] = p3 & 0xFF
  308. ev.append(("pull_back", p1, p2))
  309. elif mid in (11, 13): # SC11 走/SC13 跑 广播(recog=dir,p1=X,p2=Y)
  310. if recog and 0 <= (recog & 0xFF) < 8: # 疑似自己移动的确认(简化: 无aid时跳过)
  311. pass
  312. ev.append(("actor_move", lo16(p1) if p1 > 65535 else p1,
  313. hi16(p1) if p1 > 65535 else 0, mid))
  314. elif mid == 51: # SC51 地图初始化(p1=X,p2=Y 整数)
  315. j = self._jbody(body) or {}
  316. mapname = j.get("MapName") or str(j.get("MapId", "?"))
  317. self.me["map"] = j.get("MapId", mapname)
  318. self.me["mapname"] = mapname
  319. if mid == 51:
  320. # p1/p2 bit11 是标志位非坐标(README 定案: 2048 类值即此)
  321. self.me["x"], self.me["y"] = p1 & 0x7FF, p2 & 0x7FF
  322. ev.append(("map", mapname, p1, p2))
  323. elif mid == 634: # SC634 场景切换(等待图→比奇)
  324. ev.append(("map_change",))
  325. elif mid == 44: # SC44 经验
  326. j = self._jbody(body)
  327. if j:
  328. ev.append(("exp", j))
  329. return ev
  330. # ---------- 行为 ----------
  331. def nearest_monster(self):
  332. cands = [(math.dist((m["x"], m["y"]), (self.me["x"], self.me["y"])), aid)
  333. for aid, m in self.monsters.items() if not m["die"]]
  334. return min(cands)[1] if cands else None
  335. def step_toward(self, tx, ty):
  336. """一格一步: 候选方向按接近度排序, 走路(CS3011)。"""
  337. x, y = self.me["x"], self.me["y"]
  338. dx = max(-1, min(1, tx - x))
  339. dy = max(-1, min(1, ty - y))
  340. if dx == 0 and dy == 0:
  341. return True
  342. occupied = {(m["x"], m["y"]) for m in self.monsters.values() if not m["die"]}
  343. cands = []
  344. for i, (ox, oy) in enumerate(DIRS):
  345. nx, ny = x + ox, y + oy
  346. if (nx, ny) in occupied:
  347. continue
  348. cands.append((math.dist((nx, ny), (tx, ty)), i, nx, ny))
  349. if not cands:
  350. return False
  351. cands.sort()
  352. _, d, nx, ny = cands[0]
  353. self.send_walk(nx, ny, x, y, d)
  354. self.me["x"], self.me["y"], self.me["dir"] = nx, ny, d
  355. return False
  356. def fight(self, kill_target=None, minutes=5):
  357. log(f"战斗循环启动: 目标数={kill_target or '∞'} 时长={minutes}min "
  358. f"玩家=({self.me['x']},{self.me['y']}) map={self.me.get('mapname', self.me['map'])}")
  359. deadline = time.time() + minutes * 60
  360. target = None
  361. no_target_t0 = None
  362. last_map_log = ""
  363. while time.time() < deadline and self.alive:
  364. self.pump(0.3)
  365. if kill_target and self.kills >= kill_target:
  366. log(f"已达击杀数 {self.kills}, 停止")
  367. break
  368. mname = self.me.get("mapname", "")
  369. if mname != last_map_log:
  370. last_map_log = mname
  371. log(f"当前地图: {mname} 玩家@({self.me['x']},{self.me['y']}) 怪物数={len(self.monsters)}")
  372. self.monsters.clear() # 换图后视野重建, 防旧坐标污染
  373. target = None
  374. m = None
  375. if target is not None and target in self.monsters:
  376. m = self.monsters[target]
  377. if m["die"]:
  378. target, m = None, None
  379. if m is None:
  380. target = self.nearest_monster()
  381. m = self.monsters.get(target)
  382. if target is not None:
  383. log(f"选定目标 #{target} {m['name']} @({m['x']},{m['y']})")
  384. if m is None:
  385. if no_target_t0 is None:
  386. no_target_t0 = time.time()
  387. log(f"视野内无怪物({mname}), 开始探索走动...")
  388. for _ in range(5):
  389. self.pump(0.05)
  390. d = random.randrange(8)
  391. nx = self.me["x"] + DIRS[d][0]
  392. ny = self.me["y"] + DIRS[d][1]
  393. self.send_walk(nx, ny, self.me["x"], self.me["y"], d)
  394. self.me["x"], self.me["y"], self.me["dir"] = nx, ny, d
  395. time.sleep(0.35)
  396. continue
  397. no_target_t0 = None
  398. dist = max(abs(m["x"] - self.me["x"]), abs(m["y"] - self.me["y"]))
  399. if dist <= 1:
  400. self.send_attack(target, m["x"], m["y"])
  401. time.sleep(0.65) # HitTime=650ms(见SC2 gameoptions)
  402. else:
  403. self.step_toward(m["x"], m["y"])
  404. time.sleep(0.35)
  405. log(f"循环结束: kills={self.kills} 收帧统计={sorted(self.frame_log.items(), key=lambda kv: -kv[1])[:12]}")
  406. return self.kills
  407. def main():
  408. ap = argparse.ArgumentParser()
  409. ap.add_argument("-u", "--user", default="longwu988")
  410. ap.add_argument("-p", "--password", default="Longwu1988")
  411. ap.add_argument("--zone", type=int, required=True)
  412. ap.add_argument("--name", default="测C1742")
  413. ap.add_argument("--port", type=int, default=0)
  414. ap.add_argument("--kill", type=int, default=0, help="击杀N只后停, 0=不限")
  415. ap.add_argument("--minutes", type=float, default=5)
  416. args = ap.parse_args()
  417. log("[1/5] HTTP登录...")
  418. j = None
  419. for attempt in range(5):
  420. try:
  421. s = rq.Session()
  422. j, _ = L.do_login(s, args.user, args.password)
  423. break
  424. except Exception as e:
  425. log(f" 登录失败(第{attempt+1}次): {str(e)[:70]}, 15s后重试")
  426. time.sleep(15)
  427. if not j or j.get("code") != 200:
  428. raise SystemExit(f"[!] 登录失败: {(j or {}).get('msg', '网络异常')}")
  429. jwt = j["data"]["token"]
  430. uid = str((j["data"].get("userInfo") or {}).get("id") or P.UID)
  431. log("[2/5] 选区...")
  432. j2 = None
  433. for attempt in range(5):
  434. try:
  435. j2 = P.enter_game(s, jwt, args.zone)
  436. break
  437. except Exception as e:
  438. log(f" enter_game 失败(第{attempt+1}次): {str(e)[:60]}, 15s后重试")
  439. time.sleep(15)
  440. if not j2 or j2.get("code") != 200:
  441. raise SystemExit(f"[!] enter_game 失败: {(j2 or {}).get('msg', '网络异常')}")
  442. log("[3/5] 登录服握手(复用角色)...")
  443. ports = [args.port] if args.port else [8041, 8021, 8061, 8001]
  444. ok = None
  445. for pt in ports:
  446. try:
  447. key32, sid, rname, tkey, game_port = P.login_gate(
  448. args.zone, jwt, args.name, pt, uid)
  449. if key32:
  450. ok = (key32, sid, rname, tkey, game_port, pt)
  451. break
  452. except SystemExit as e:
  453. log(f" 端口{pt}: {e}")
  454. except Exception as e:
  455. log(f" 端口{pt}: {str(e)[:60]}")
  456. if not ok:
  457. raise SystemExit("[!] 所有端口失败")
  458. key32, sid, rname, tkey, game_port, pt = ok
  459. log(f" 角色={rname} 游戏端口={game_port}")
  460. log("[4/5] 连游戏服+进世界...")
  461. g = enter_game_gate_v2(args.zone, rname, tkey, sid, game_port, uid)
  462. bot = CombatBot(g, sid, rname)
  463. P.heartbeat_loop(g, sid, bot.lock) # CS20后立即心跳, 防超时踢
  464. ok = False
  465. t0 = time.time()
  466. while time.time() - t0 < 10:
  467. bot.pump(0.3)
  468. if 658 in bot.frame_log:
  469. ok = True
  470. break
  471. log(f" SC658 {'✓' if ok else '✗ 超时'} ({time.time()-t0:.1f}s)")
  472. send_init_frames(bot, uid) # 不用 P.send_init_sequence: 其收尾 recv 清空会吃掉 SC50
  473. for _ in range(20): # 收出生/地图/视野帧
  474. evs = bot.pump(0.5)
  475. for e in evs:
  476. log(f" 事件: {e}")
  477. if bot.me["id"] and bot.me["map"] not in ("0", ""):
  478. break
  479. if not bot.alive:
  480. break
  481. log(f" 收帧统计: {sorted(bot.frame_log.items(), key=lambda kv: -kv[1])[:15]}")
  482. if not bot.me["id"]:
  483. g.close()
  484. raise SystemExit("[!] 未收到 SC50 主玩家创建, 中止")
  485. log(f" 主玩家 #{bot.me['id']} @({bot.me['x']},{bot.me['y']}) map={bot.me['map']}")
  486. log("[5/5] 开喝药保护 + 心跳...")
  487. state = P.enable_auto_heal(g, sid, want_on=True)
  488. log(f" 喝药={state}")
  489. P.heartbeat_loop(g, sid, bot.lock)
  490. try:
  491. kills = bot.fight(kill_target=args.kill or None, minutes=args.minutes)
  492. log(f"[完成] 击杀={kills}")
  493. finally:
  494. g.close()
  495. if __name__ == "__main__":
  496. main()