c1_launch_game.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """C1: 调启动接口 — 拿 host/port/game_token, 创建 runs 记录, 更新 zone 的 host/port。
  2. 数据流:
  3. MySQL user (token from A1) + server (B1) + zone (B2/B3, zone_id=996box serverid)
  4. ↓
  5. Box996Login.enter_game_client(game_id, serverid)
  6. ↓
  7. 响应: {data: {host, port, game_token, env}}
  8. ↓
  9. RunStore.create(user_id, server_db_id, zone_db_id, status="running")
  10. ↓
  11. ZoneStore 跟新 host/port (下次 B3 能看到)
  12. ↓
  13. DebugLogger 留痕
  14. 跑法:
  15. uv run python scripts/business/c1_launch_game.py
  16. uv run python scripts/business/c1_launch_game.py --user longwu988
  17. uv run python scripts/business/c1_launch_game.py --zone 14414742
  18. uv run python scripts/business/c1_launch_game.py --check
  19. 前置依赖:
  20. 1. A1 跑过 (user.token 在 MySQL)
  21. 2. B1 跑过 (server 在 MySQL)
  22. 3. B2 跑过 (zones 在 MySQL)
  23. """
  24. from __future__ import annotations
  25. import argparse
  26. import asyncio
  27. import os
  28. import sys
  29. from pathlib import Path
  30. from typing import Any
  31. _HERE = Path(__file__).resolve().parent
  32. _PROJECT = _HERE.parent.parent
  33. sys.path.insert(0, str(_PROJECT / "src"))
  34. from cq2_sdk.env_loader import load_env # noqa: E402
  35. load_env(_PROJECT / ".env")
  36. from cq2_sdk.debug_log import DebugLogger # noqa: E402
  37. from cq2_sdk.login import Box996Login, ManualGeetestSolver # noqa: E402
  38. from cq2_sdk.store import ( # noqa: E402
  39. RunStore,
  40. ServerStore,
  41. UserStore,
  42. ZoneStore,
  43. connection,
  44. )
  45. DEFAULT_USERNAME = "longwu988"
  46. DEFAULT_SERVER_NAME = "玖扬复古176"
  47. DEFAULT_DEVICE_ID = "00:15:5d:0a:04:00"
  48. async def launch_and_persist(
  49. username: str,
  50. server_name: str,
  51. zone_id_996box: str | None, # 显式传某个 zone, None 走 B3 自动选
  52. log: DebugLogger,
  53. ) -> dict[str, Any]:
  54. """Run C1: 启动游戏 + 落 runs 记录 + 跟新 zone host/port。
  55. Returns: {
  56. run_id, user_id, server_db_id, zone_db_id, zone_id_996box,
  57. host, port, game_token
  58. }
  59. """
  60. log.step("c1_launch_game_start",
  61. username=username, server_name=server_name,
  62. zone_id_996box=zone_id_996box)
  63. # 1. 从 MySQL 拿 user / server / zone
  64. with connection() as conn:
  65. user = UserStore(conn).get_by_username(username)
  66. if user is None or not user.token:
  67. raise RuntimeError(
  68. f"user {username!r} 在 MySQL 没 token。先跑 A1。"
  69. )
  70. server = ServerStore(conn).get_by_name(server_name)
  71. if server is None:
  72. raise RuntimeError(
  73. f"server {server_name!r} 不在 MySQL。先跑 B1。"
  74. )
  75. zones = ZoneStore(conn)
  76. if zone_id_996box is None:
  77. # 走 B3: 选最新 openable
  78. zone = zones.get_latest_openable(server.id)
  79. if zone is None:
  80. raise RuntimeError(
  81. f"没可用的 openable zone (server={server_name})。先跑 B2 拉新区列表。"
  82. )
  83. zone_id_996box = zone.zone_id
  84. log.info("zone_auto_selected_via_b3",
  85. zone_db_id=zone.id, zone_id_996box=zone_id_996box,
  86. zone_name=zone.zone_name)
  87. else:
  88. # 用户显式指定了 zone_id (e.g. "14414742"), 按这个去查
  89. zone = zones.get_latest_openable(server.id) # 用 latest_openable 拿 zone object
  90. # 简化: 不管 zone_id 怎么传, 都从 latest 拿 zone db_id
  91. # 实际应该是 get_by_zone_id(zone_id_996box), 但 ZoneStore 没这个方法, 临时用 latest
  92. # TODO: 加 ZoneStore.get_by_996box_id
  93. if zone is None or zone.zone_id != zone_id_996box:
  94. raise RuntimeError(
  95. f"zone_id_996box={zone_id_996box!r} 不在 MySQL (或不是 openable)。"
  96. f" 实际 latest openable: {zone.zone_id if zone else 'none'}"
  97. )
  98. log.info("zone_explicitly_specified",
  99. zone_db_id=zone.id, zone_id_996box=zone_id_996box,
  100. zone_name=zone.zone_name)
  101. zone_db_id = zone.id
  102. game_id = server.game_id
  103. server_db_id = server.id
  104. user_id = user.id
  105. log.info("loaded_from_mysql",
  106. user_id=user_id, server_db_id=server_db_id, zone_db_id=zone_db_id,
  107. game_id=game_id, zone_id_996box=zone_id_996box)
  108. # 2. 调 996box 启动接口
  109. log.step("c1_call_enter_game_client",
  110. game_id=game_id, serverid=int(zone_id_996box))
  111. box = Box996Login(geetest=ManualGeetestSolver(), device_id=DEFAULT_DEVICE_ID)
  112. box.token = user.token
  113. resp = await box.enter_game_client(game_id=game_id, serverid=int(zone_id_996box))
  114. log.info("enter_game_client_response", resp_keys=list(resp.keys()) if isinstance(resp, dict) else type(resp).__name__)
  115. # 3. 解析响应 — 真实结构 (2026-09-16 实测):
  116. # data = { download_info, download_info_client, start_info, config_name }
  117. # start_info = 写给 cq.exe 的 env.json 内容 (signkey/token/uid/...)
  118. # host/port 不在响应里 — 游戏网关走 .env CQ2_GATE_HOST/PORT
  119. data = resp if isinstance(resp, dict) else {}
  120. start_info = data.get("start_info") or {}
  121. if not start_info:
  122. log.error("launch_response_missing_start_info", resp_keys=list(data.keys()))
  123. raise RuntimeError(
  124. f"启动接口返回没有 start_info: keys={list(data.keys())}"
  125. )
  126. game_token = str(start_info.get("token", ""))
  127. # host/port 不在响应里 — 游戏网关走 .env CQ2_GATE_HOST/PORT。
  128. # 默认值 2026-09-16 实测: gfgame.dhsf.xqhuyu.com 上只有 12000 开着
  129. # (8001 直连端口全关, 逐区后端端口藏在 12000 入口代理后面, 见
  130. # scripts/probe_gateway.py / port_matrix.py 的老逆向结论)。
  131. host = os.environ.get("CQ2_GATE_HOST", "gfgame.dhsf.xqhuyu.com")
  132. port = int(os.environ.get("CQ2_GATE_PORT", "12000"))
  133. log.info("launch_response_parsed",
  134. host=host, port=port,
  135. game_account=start_info.get("gameAccount"),
  136. uid=start_info.get("uid"),
  137. signkey_prefix=str(start_info.get("signkey", ""))[:12] + "...",
  138. game_token_prefix=(game_token[:12] + "...") if game_token else "")
  139. # 4. 创建 run 记录 + 存 start_info (env.json 原料) + 更新 zone host/port
  140. log.step("c1_persist_run_record")
  141. import json as _json
  142. env_dir = _PROJECT / "env_dumps"
  143. env_dir.mkdir(exist_ok=True)
  144. with connection() as conn:
  145. run = RunStore(conn).create(
  146. user_id=user_id,
  147. server_id=server_db_id,
  148. zone_id=zone_db_id,
  149. status="running",
  150. )
  151. log.info("run_created", run_id=run.id, status=run.status)
  152. # start_info 落盘为 env.json 样式的文件 (真客户端就是这么给 cq.exe 的)
  153. env_path = env_dir / f"run_{run.id}_env.json"
  154. env_path.write_text(
  155. _json.dumps(start_info, ensure_ascii=False, indent=2),
  156. encoding="utf-8",
  157. )
  158. with conn.cursor() as cur:
  159. cur.execute(
  160. "UPDATE runs SET snapshot_path = %s WHERE id = %s",
  161. (str(env_path), run.id),
  162. )
  163. # 5. 更新 zone 的 host/port (C2 直接拿来 TCP poll)
  164. with conn.cursor() as cur:
  165. cur.execute(
  166. "UPDATE zones SET host = %s, port = %s WHERE id = %s",
  167. (host, int(port), zone_db_id),
  168. )
  169. log.info("zone_host_port_updated", host=host, port=port,
  170. env_file=str(env_path))
  171. return {
  172. "run_id": run.id,
  173. "user_id": user_id,
  174. "server_db_id": server_db_id,
  175. "zone_db_id": zone_db_id,
  176. "zone_id_996box": zone_id_996box,
  177. "host": host,
  178. "port": int(port),
  179. "game_token_prefix": game_token[:16] + "..." if game_token else "",
  180. "start_info_keys": sorted(start_info.keys())[:12],
  181. "env_file": str(env_path),
  182. }
  183. def check_config(username: str | None, server_name: str | None,
  184. zone_id: str | None) -> bool:
  185. print("[check] 配置检查:")
  186. print(f" username = {username!r} [OK]" if username
  187. else " username = (未设置) [FAIL]")
  188. print(f" server_name = {server_name!r} [OK]" if server_name
  189. else " server_name = (未设置) [FAIL]")
  190. print(f" zone_id = {zone_id!r} (可选, 不填走 B3 自动选)"
  191. if zone_id or True # always show, just note it's optional
  192. else "")
  193. print(" (实际跑还需要 A1 + B1 + B2 都跑过, MySQL 有 user.token + server + zones)")
  194. return bool(username and server_name)
  195. def parse_args() -> argparse.Namespace:
  196. p = argparse.ArgumentParser(
  197. description="C1: 调 996box 启动接口, 拿 host/port, 创 runs 记录",
  198. formatter_class=argparse.RawDescriptionHelpFormatter,
  199. )
  200. p.add_argument(
  201. "--user",
  202. default=os.environ.get("CQ2_ACCOUNT", DEFAULT_USERNAME),
  203. help="用哪个 user 的 token (默认 .env CQ2_ACCOUNT)",
  204. )
  205. p.add_argument(
  206. "--server",
  207. default=os.environ.get("CQ2_DEFAULT_SERVER", DEFAULT_SERVER_NAME),
  208. help=f"区服名 (默认 {DEFAULT_SERVER_NAME})",
  209. )
  210. p.add_argument(
  211. "--zone",
  212. default=None,
  213. help="996box zone serverid (e.g. 14414742)。不填走 B3 自动选 latest openable。",
  214. )
  215. p.add_argument(
  216. "--check",
  217. action="store_true",
  218. help="只检查 .env / CLI 配置, 不实际调 API",
  219. )
  220. return p.parse_args()
  221. def main() -> int:
  222. args = parse_args()
  223. if args.check:
  224. ok = check_config(args.user, args.server, args.zone)
  225. print(f"\n result: {'READY' if ok else 'NOT READY'}")
  226. return 0 if ok else 1
  227. if not args.user or not args.server:
  228. check_config(args.user, args.server, args.zone)
  229. return 1
  230. with DebugLogger(
  231. extra={"task": "c1", "username": args.user,
  232. "server_name": args.server, "zone": args.zone}
  233. ) as log:
  234. log.info("c1_start", username=args.user,
  235. server_name=args.server, zone=args.zone)
  236. try:
  237. result = asyncio.run(launch_and_persist(
  238. args.user, args.server, args.zone, log,
  239. ))
  240. except Exception as e:
  241. log.error(e, step="c1_unexpected")
  242. print(f"\n[FAIL] {type(e).__name__}: {e}")
  243. print(f" log file: {log.path}")
  244. return 1
  245. print("\n[OK] C1 启动 + 落库成功:")
  246. for k, v in result.items():
  247. print(f" {k:<16} = {v}")
  248. print(f" log file = {log.path}")
  249. print(" 下一步: 跑 C2 等游戏真起来, 然后可以开始 D1-D13")
  250. return 0
  251. if __name__ == "__main__":
  252. sys.exit(main())