host.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. """宿主: HTTP 登录 + 网关连接 + 创角驱动。复用 protocol_bot 已验证资产。"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import random
  6. import socket
  7. import sys
  8. import time
  9. from typing import Any
  10. ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  11. sys.path.insert(0, os.path.join(ROOT, "protocol_bot"))
  12. import requests # noqa: E402
  13. import login_996pc as L # type: ignore # noqa: E402
  14. import cq2_pure_enter as P # type: ignore # noqa: E402
  15. from encode6bitbuf_final import encode6, decode6 # type: ignore # noqa: E402
  16. from apilot.bridge import bootstrap_runtime # noqa: E402
  17. from apilot.stubs.env import EnvData # noqa: E402
  18. from apilot.stubs.netclient import NetClientStub # noqa: E402
  19. from apilot.stubs.networkctl import NetworkCtlStub, dispatch_frame # noqa: E402
  20. from apilot.stubs.scheduler import Scheduler # noqa: E402
  21. from apilot.wire import Frame # noqa: E402
  22. GW_HOST = "s{zone}-jycq-206923.dhsf.xqhuyu.com"
  23. CHANNEL = "110585"
  24. GAME_ID = 206923
  25. LOGIN_PORTS = (8041, 8021, 8061, 8001) # 端口轮询顺序(README: 8041 优先)
  26. class _GateError(RuntimeError):
  27. """单端口登录链失败(超时/断连)→ 换下一端口重试。"""
  28. class Host:
  29. def __init__(self, zone_id: int, user: str, password: str,
  30. state_dir: str = ".apilot_state") -> None:
  31. self.zone = zone_id
  32. self.user, self.password = user, password
  33. self.state_dir = state_dir
  34. self.nc = NetClientStub()
  35. self.ctl = NetworkCtlStub(self.nc)
  36. self.sched = Scheduler()
  37. self.env = EnvData({})
  38. self.lua: Any = None
  39. self.jwt = ""
  40. self.uid = ""
  41. self._login_port = 0
  42. self._tail = 0
  43. def build_2001_body(self, uid: str, jwt: str) -> bytes:
  44. return (f"{uid}/".encode() + encode6(jwt.encode()) + b"/" +
  45. encode6(CHANNEL.encode()) + b"/1/0")
  46. def build_101_body(self, name: str, uid: str, job: int = 0, sex: int = 0) -> bytes:
  47. return json.dumps({"server_name": self.zone, "chr": name, "secsdk": "0",
  48. "job": job, "sex": sex, "account": uid,
  49. "sdkid": CHANNEL}, ensure_ascii=False).encode()
  50. def http_login(self) -> requests.Session:
  51. s = requests.Session()
  52. j, _ = L.do_login(s, self.user, self.password)
  53. if j.get("code") != 200:
  54. raise SystemExit(f"[!] 登录失败: {j.get('msg')}")
  55. self.jwt = j["data"]["token"]
  56. self.uid = str((j["data"].get("userInfo") or {}).get("id") or "")
  57. self.env.data.update({"uid": self.uid, "token": self.jwt,
  58. "new_boxtoken": self.jwt, "isBoxLogin": 1})
  59. return s
  60. def boot_lua(self) -> None:
  61. self.lua = bootstrap_runtime(self.nc, self.ctl, self.sched, self.env, self.state_dir)
  62. def connect_login_gate(self, port: int = 8041) -> None:
  63. self._login_port = port
  64. self.nc.connect(GW_HOST.format(zone=self.zone), port)
  65. def pump(self, seconds: float, on_frame=None) -> list:
  66. """收帧→分发→调度, 驱动 seconds 墙钟。"""
  67. end = time.time() + seconds
  68. got: list = []
  69. while time.time() < end:
  70. for f in self.nc.tick(0.2):
  71. if self.lua is not None:
  72. dispatch_frame(self.lua, self.ctl, f)
  73. got.append(f)
  74. if on_frame:
  75. on_frame(f)
  76. self.sched.tick(0.05)
  77. return got
  78. def tick(self, dt: float = 0.05) -> None:
  79. for f in self.nc.tick(dt):
  80. if self.lua is not None:
  81. dispatch_frame(self.lua, self.ctl, f)
  82. self.sched.tick(dt)
  83. def _send(self, mid: int, recog: int, p1: int, p2: int, p3: int,
  84. tail: int, body: bytes) -> None:
  85. """登录链统一发包: body 字节由 host 构造,帧头走 pack_frame(wire.py)唯一真源。"""
  86. self.nc.send_frame(Frame(recog=recog, msg_id=mid, p1=p1, p2=p2,
  87. p3=p3, tail=tail, body=body))
  88. def _wait_for(self, mid: int, timeout: float):
  89. """收帧直到命中 mid 或超时。返回 (帧, 已收列表)。"""
  90. deadline = time.time() + timeout
  91. got: list = []
  92. while time.time() < deadline:
  93. remain = max(0.05, deadline - time.time())
  94. for f in self.pump(min(remain, 0.2)):
  95. got.append(f)
  96. if f.msg_id == mid:
  97. return f, got
  98. return None, got
  99. def _recv_with_pre(self, target_mid: int, timeout: float):
  100. """等帧时同时拿回 m[0]("pre" 字段,apilot Frame 抽象丢弃)。
  101. 仅 SC530 用 — cq2_pure_enter.login_gate:138 sid=m[0]。"""
  102. import struct
  103. wire = self.nc.wire
  104. if wire is None:
  105. return None, None
  106. deadline = time.time() + timeout
  107. while time.time() < deadline:
  108. remain = max(0.05, deadline - time.time())
  109. wire.sock.settimeout(min(0.2, remain))
  110. try:
  111. d = wire.sock.recv(65536)
  112. if not d:
  113. break
  114. except (socket.timeout, ConnectionResetError, OSError):
  115. d = b""
  116. # 半包处理: 每轮把 wire._buf 前置到 data 再扫描, 扫剩的写回 _buf。
  117. data = wire._buf + d
  118. wire._buf = b""
  119. while len(data) >= 48:
  120. try:
  121. m = struct.unpack("<9i", decode6(data[:48]))
  122. except Exception:
  123. break
  124. blen = m[7]
  125. if len(data) < 48 + blen: # 帧体不完整, 整帧留待下轮
  126. break
  127. if m[1] == target_mid:
  128. wire._buf = data[48 + blen:]
  129. return Frame(recog=m[2], msg_id=m[1], p1=m[3], p2=m[4],
  130. p3=m[5], tail=m[8],
  131. body=data[48:48 + blen]), m[0]
  132. # 未命中帧 → 丢弃并继续扫下一帧(一次收到多帧的场景)
  133. data = data[48 + blen:]
  134. wire._buf = wire._buf + data
  135. return None, None
  136. def run_create_role(self, name: str | None = None, timeout: float = 30,
  137. ports: tuple[int, ...] = LOGIN_PORTS) -> dict:
  138. """登录链逐帧驱动(对照 cq2_pure_enter.login_gate 唯一真源,逐帧注释)。
  139. 内部: 逐端口 connect(8041/8021/8061/8001 轮询)→ 2001 链 → 101 → 521。
  140. 返回 {"role_name", "ticket", "sid", "key32", "port"}。
  141. 2001(tail=0,recog=random) → 等 529 取 key32(decode6 split /) →
  142. 104(body=encode6(key32),tail=1) → 等 530 取 sid(m[0]/pre 字段,
  143. cq2_pure_enter:138) →
  144. nc.set_sdata(sid) → 100(uid/sid/zone/channel/gameid/key32,tail=2) →
  145. 等 520 角色 JSON(decode6 后 json.loads) →
  146. 角色列表含 name→复用;否则 101(tail=3) → 等 521(decode6 后 split /)
  147. /522(换名重试 ≤5 次)。
  148. """
  149. last_err: Exception | None = None
  150. for port in ports:
  151. if not self.nc.is_connected():
  152. try:
  153. self.connect_login_gate(port)
  154. except OSError as e:
  155. last_err = e
  156. continue
  157. try:
  158. return self._create_role_once(name, timeout, port)
  159. except _GateError as e:
  160. last_err = e
  161. self.nc.disconnect()
  162. continue
  163. raise RuntimeError(f"[!] 所有候选端口失败: {last_err}")
  164. def _delete_role(self, role_uid: str, timeout: float) -> bool:
  165. """CS102 删角色(LoginProxy:RequestDeleteRole, body=UserID, p3=0, tail 严格递增)。
  166. 返回是否收到 523(成功)。"""
  167. # 522 后立即发 102 服务器静默丢弃(实测); 停顿 ~2s 才被受理。
  168. time.sleep(1.5)
  169. self.pump(0.5)
  170. self._tail += 1
  171. self._send(102, 0, 0, 0, 0, self._tail, role_uid.encode())
  172. deadline = time.time() + timeout
  173. while time.time() < deadline:
  174. remain = max(0.05, deadline - time.time())
  175. for f in self.pump(min(remain, 0.2)):
  176. if f.msg_id == 523:
  177. return True
  178. if f.msg_id == 524:
  179. return False
  180. return False
  181. def _create_role_once(self, name: str | None, timeout: float,
  182. port: int) -> dict:
  183. # CS2001 ─ 登录服首帧,带 uid/encode6(jwt)/encode6(channel)/1/0
  184. self._send(2001, random.randint(1, 2**30), self.zone, port, 0, 0,
  185. self.build_2001_body(self.uid, self.jwt))
  186. # 等 SC529 → 会话密钥 key32(body 形如 "key32/...")
  187. f529, _ = self._wait_for(529, timeout)
  188. if f529 is None:
  189. raise _GateError("[!] 登录服未下发 529 key")
  190. key32 = decode6(f529.body).split(b"/")[0]
  191. # CS104 ─ 回传 key32 协商
  192. self._send(104, 0, 0, 0, 0, 1, encode6(key32))
  193. # 等 SC530 → sid 真实位置 m[0](首 int,"pre" 字段),不是 Frame.recog(m[2])。
  194. # cq2_pure_enter.login_gate:138 sid=m[0] 唯一真源;apilot Frame 抽象丢弃 m[0],
  195. # 这里 _recv_with_pre 直读 raw 字节补回。未命中超时即报错。
  196. _, sid = self._recv_with_pre(530, timeout)
  197. if sid is None:
  198. raise _GateError("[!] 登录服未下发 530 sid")
  199. self.nc.set_sdata(sid)
  200. # CS100 ─ 拉角色列表(body 形如 "uid/sid/zone/channel/gameid/"+key32)
  201. body100 = (f"{self.uid}/{sid}/{self.zone}/{CHANNEL}/{GAME_ID}/"
  202. .encode() + key32)
  203. self._send(100, random.randint(1, 2**30), self.zone, port, sid, 2, body100)
  204. # 等 SC520 → 角色列表 JSON(cq2_pure_enter 兼容: encode6 包裹时先 decode6)
  205. roles: dict | None = None
  206. deadline = time.time() + timeout
  207. while time.time() < deadline:
  208. remain = max(0.05, deadline - time.time())
  209. for f in self.pump(min(remain, 0.2)):
  210. if f.msg_id == 520 and f.body:
  211. try:
  212. b = f.body
  213. if all(0x3C <= c <= 0x7B for c in b[:200]):
  214. b = decode6(b)
  215. roles = json.loads(b)
  216. except Exception:
  217. roles = None
  218. if roles is not None:
  219. break
  220. if roles is not None:
  221. break
  222. # 选/创角
  223. if name is None:
  224. name = "萌新" + str(random.randint(1000, 9999))
  225. chr_info = next((c for c in (roles or {}).get("list", [])
  226. if c.get("chr") == name), None)
  227. self._tail = 2
  228. attempts = 0
  229. while chr_info is None and attempts < 6:
  230. self._tail += 1
  231. self._send(101, 0, 0, 0, sid, self._tail,
  232. self.build_101_body(name, self.uid))
  233. ticket: str | None = None
  234. got_522 = False
  235. sub_deadline = time.time() + timeout
  236. while time.time() < sub_deadline:
  237. remain = max(0.05, sub_deadline - time.time())
  238. for f in self.pump(min(remain, 0.2)):
  239. if f.msg_id == 521 and f.body:
  240. try:
  241. ticket = decode6(f.body).decode("utf-8", "replace")
  242. except Exception:
  243. ticket = f.body.decode("utf-8", "replace")
  244. if ticket is None and f.msg_id == 522:
  245. got_522 = True
  246. if ticket or got_522:
  247. break
  248. if ticket:
  249. chr_info = {"chr": name, "UserID": ticket.split("/", 1)[1]}
  250. break
  251. if got_522:
  252. attempts += 1
  253. # 槽满(客户端 LoginProxy:648 i>2 break → 每区 2 角色上限):
  254. # 删一个非目标角色腾槽(CS102 tail 严格递增, p3=0), 再原名重试;
  255. # 删无可删才换名。523/524 等待在 _delete_role 内完成。
  256. lst = (roles or {}).get("list") or []
  257. # 服务器只允许删 last=1(当前选中)角色 — 删其它静默无响应(实测)。
  258. # LoginProxy:916 删除后 DelRoleByID(GetSelectedRoleID()) 同一语义。
  259. victim = next((c for c in lst
  260. if c.get("chr") != name and c.get("last") == 1),
  261. None)
  262. if victim is not None and self._delete_role(victim["UserID"],
  263. timeout):
  264. lst.remove(victim) # 防下轮重复删同一已删角色(重复删静默)
  265. else:
  266. name = "萌新" + str(random.randint(1000, 9999))
  267. continue
  268. break
  269. if chr_info is None:
  270. lst = (roles or {}).get("list") or []
  271. if lst:
  272. chr_info = lst[-1]
  273. if chr_info is None:
  274. raise _GateError("[!] 无角色且创角失败")
  275. ticket = chr_info["UserID"]
  276. return {"role_name": chr_info["chr"], "ticket": ticket,
  277. "sid": sid, "key32": key32, "port": port}
  278. def run_zone(zone_id: int, user: str, password: str, name: str | None = None,
  279. port: int | None = None) -> dict:
  280. """一站式: HTTP 登录 → enter_game → 网关连接(端口轮询) → 创角 → 返回票据。"""
  281. h = Host(zone_id, user, password)
  282. session = h.http_login()
  283. j2 = P.enter_game(session, h.jwt, zone_id)
  284. if j2.get("code") != 200:
  285. raise SystemExit(f"[!] enter_game 失败: {j2.get('msg') or j2}")
  286. ports = (port,) if port else LOGIN_PORTS
  287. info = h.run_create_role(name, ports=ports)
  288. return {"uid": h.uid, "jwt": h.jwt, "zone": h.zone, **info}