c2_wait_for_ready.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. """C2: 等游戏就绪 — 纯 TCP poll, 不调 HTTP。
  2. 数据流:
  3. MySQL 拿 latest run (status="running") → 拿 zone_id → 拿 zone.host:port
  4. ↓
  5. wait_for_game_ready(host, port) → asyncio.open_connection 轮询
  6. ↓
  7. 成功 → RunStore.update_status(run_id, "ready")
  8. 超时 → RunStore.update_status(run_id, "failed", error="timeout")
  9. ↓
  10. DebugLogger 留痕
  11. 跑法:
  12. uv run python scripts/business/c2_wait_for_ready.py
  13. uv run python scripts/business/c2_wait_for_ready.py --user longwu988
  14. uv run python scripts/business/c2_wait_for_ready.py --run-id 5
  15. uv run python scripts/business/c2_wait_for_ready.py --timeout 90
  16. uv run python scripts/business/c2_wait_for_ready.py --check
  17. 前置依赖:
  18. 1. A1 跑过 (user.token)
  19. 2. C1 跑过 (runs 记录 + zone.host/port)
  20. """
  21. from __future__ import annotations
  22. import argparse
  23. import asyncio
  24. import os
  25. import sys
  26. import time
  27. from pathlib import Path
  28. from typing import Any
  29. _HERE = Path(__file__).resolve().parent
  30. _PROJECT = _HERE.parent.parent
  31. sys.path.insert(0, str(_PROJECT / "src"))
  32. from cq2_sdk.env_loader import load_env # noqa: E402
  33. load_env(_PROJECT / ".env")
  34. from cq2_sdk.debug_log import DebugLogger # noqa: E402
  35. from cq2_sdk.launcher.navigate import ( # noqa: E402
  36. GameLaunchTimeout,
  37. wait_for_game_ready,
  38. )
  39. from cq2_sdk.store import RunStore, UserStore, ZoneStore, connection # noqa: E402
  40. DEFAULT_USERNAME = "longwu988"
  41. DEFAULT_TIMEOUT = 60.0 # 跟 SDK 内置默认一致
  42. DEFAULT_INTERVAL = 2.0
  43. async def wait_and_persist(
  44. run_id: int | None, # None = 自动选 latest
  45. user_id: int | None, # run_id=None 时用 user 找 latest
  46. timeout: float,
  47. interval: float,
  48. log: DebugLogger,
  49. ) -> dict[str, Any]:
  50. """Run C2: 等 TCP 通 + 跟新 run 状态。
  51. Returns: {run_id, host, port, status, elapsed_s, attempts}
  52. """
  53. log.step("c2_wait_for_ready_start",
  54. run_id=run_id, user_id=user_id, timeout=timeout)
  55. # 1. 找 run
  56. with connection() as conn:
  57. runs = RunStore(conn)
  58. if run_id is None:
  59. # 自动: 取该 user 的 latest run
  60. if user_id is None:
  61. raise RuntimeError(
  62. "需要 --run-id 或 --user 来定位 run"
  63. )
  64. recent = runs.list_recent(user_id, limit=1)
  65. if not recent:
  66. raise RuntimeError(
  67. f"user_id={user_id} 在 MySQL 没 run 记录。先跑 C1 创一个。"
  68. )
  69. run = recent[0]
  70. else:
  71. run = runs.get(run_id)
  72. if run is None:
  73. raise RuntimeError(f"run_id={run_id} 不在 MySQL")
  74. run_db_id = run.id
  75. run_status = run.status
  76. zone_db_id = run.zone_id
  77. # 2. 拿 zone.host:port
  78. zone = ZoneStore(conn).get_by_id(zone_db_id)
  79. if zone is None:
  80. raise RuntimeError(
  81. f"run_id={run_db_id} 指向 zone_db_id={zone_db_id}, 但 zone 不存在"
  82. )
  83. host = zone.host
  84. port = zone.port
  85. log.info("loaded_run_and_zone",
  86. run_id=run_db_id, run_status=run_status,
  87. zone_db_id=zone_db_id, host=host, port=port)
  88. if not host or not port:
  89. log.error("zone_missing_host_or_port", run_id=run_db_id,
  90. zone_db_id=zone_db_id, host=host, port=port,
  91. note="C1 跑成功的话 zone.host/port 应该有值; 重跑 C1")
  92. raise RuntimeError(
  93. f"zone (id={zone_db_id}) 缺 host/port, 没法轮询。"
  94. f" 重新跑 C1 把它填上。"
  95. )
  96. if run_status not in ("running", "ready"):
  97. log.warn("run_status_unusual_for_c2",
  98. run_id=run_db_id, run_status=run_status,
  99. note="C2 通常跟 running → ready 转换; 当前 status 看起来已经走过了")
  100. # 3. TCP poll (用 SDK 自带 wait_for_game_ready)
  101. log.step("c2_polling_tcp", host=host, port=int(port),
  102. timeout_s=timeout, interval_s=interval)
  103. t0 = time.time()
  104. attempts = 0
  105. last_err: str | None = None
  106. try:
  107. await wait_for_game_ready(
  108. host=host, port=int(port),
  109. timeout=timeout, interval=interval,
  110. )
  111. except GameLaunchTimeout as e:
  112. elapsed = time.time() - t0
  113. log.error("tcp_poll_timeout",
  114. run_id=run_db_id, host=host, port=int(port),
  115. elapsed_s=round(elapsed, 1), last_err=last_err)
  116. # 跟新 run 状态为 failed
  117. with connection() as conn:
  118. RunStore(conn).update_status(
  119. run_db_id, "failed", current_task=None,
  120. )
  121. raise RuntimeError(
  122. f"游戏没在 {timeout}s 内起来: {e}"
  123. )
  124. elapsed = time.time() - t0
  125. log.info("tcp_port_reachable",
  126. run_id=run_db_id, host=host, port=int(port),
  127. elapsed_s=round(elapsed, 1), attempts=attempts)
  128. # 4. 跟新 run 状态为 ready
  129. log.step("c2_persist_run_status", run_id=run_db_id, new_status="ready")
  130. with connection() as conn:
  131. updated = RunStore(conn).update_status(run_db_id, "ready")
  132. log.info("run_status_updated",
  133. run_id=updated.id, new_status=updated.status)
  134. return {
  135. "run_id": run_db_id,
  136. "host": host,
  137. "port": int(port),
  138. "status": "ready",
  139. "elapsed_s": round(elapsed, 1),
  140. }
  141. def check_config(user: str | None, run_id: int | None) -> bool:
  142. print("[check] 配置检查:")
  143. if run_id is not None:
  144. print(f" run_id = {run_id} [OK, 显式指定]")
  145. elif user:
  146. print(f" user = {user!r} [OK, 会取该 user 的 latest run]")
  147. else:
  148. print(" user = (未设置) [FAIL: --user 或 --run-id 至少一个]")
  149. print(" (实际跑还需要 C1 跑过, MySQL 有 runs 记录 + zone.host/port)")
  150. return run_id is not None or user is not None
  151. def parse_args() -> argparse.Namespace:
  152. p = argparse.ArgumentParser(
  153. description="C2: 轮询 game server host:port 等游戏就绪, 跟新 run status",
  154. formatter_class=argparse.RawDescriptionHelpFormatter,
  155. )
  156. p.add_argument(
  157. "--user",
  158. default=os.environ.get("CQ2_ACCOUNT", DEFAULT_USERNAME),
  159. help="自动选 latest run 时, 找哪个 user 的 run (默认 .env CQ2_ACCOUNT)",
  160. )
  161. p.add_argument(
  162. "--run-id",
  163. type=int,
  164. default=None,
  165. help="显式指定 run_id, 跳过自动选",
  166. )
  167. p.add_argument(
  168. "--timeout",
  169. type=float,
  170. default=DEFAULT_TIMEOUT,
  171. help=f"TCP poll 超时秒数 (默认 {DEFAULT_TIMEOUT})",
  172. )
  173. p.add_argument(
  174. "--interval",
  175. type=float,
  176. default=DEFAULT_INTERVAL,
  177. help=f"TCP poll 间隔秒数 (默认 {DEFAULT_INTERVAL})",
  178. )
  179. p.add_argument(
  180. "--check",
  181. action="store_true",
  182. help="只检查 .env / CLI 配置, 不查 DB 不连 TCP",
  183. )
  184. return p.parse_args()
  185. def main() -> int:
  186. args = parse_args()
  187. if args.check:
  188. ok = check_config(args.user, args.run_id)
  189. print(f"\n result: {'READY' if ok else 'NOT READY'}")
  190. return 0 if ok else 1
  191. if args.run_id is None and not args.user:
  192. check_config(args.user, args.run_id)
  193. return 1
  194. with DebugLogger(
  195. extra={"task": "c2", "user": args.user, "run_id": args.run_id,
  196. "timeout": args.timeout}
  197. ) as log:
  198. log.info("c2_start", user=args.user, run_id=args.run_id,
  199. timeout=args.timeout)
  200. try:
  201. # user_id 反查 (明确报错, 比传 None 进去好)
  202. user_id = None
  203. if args.user and args.run_id is None:
  204. with connection() as conn:
  205. u = UserStore(conn).get_by_username(args.user)
  206. if u is None:
  207. raise RuntimeError(
  208. f"user {args.user!r} 不在 MySQL。先跑 A1 登录。"
  209. )
  210. user_id = u.id
  211. result = asyncio.run(wait_and_persist(
  212. args.run_id, user_id, args.timeout, args.interval, log,
  213. ))
  214. except Exception as e:
  215. log.error(e, step="c2_unexpected")
  216. print(f"\n[FAIL] {type(e).__name__}: {e}")
  217. print(f" log file: {log.path}")
  218. return 1
  219. print("\n[OK] C2 游戏就绪:")
  220. for k, v in result.items():
  221. print(f" {k:<10} = {v}")
  222. print(f" log file = {log.path}")
  223. print(" 下一步: 进入 D1-D13 (13 个新号流程任务)")
  224. return 0
  225. if __name__ == "__main__":
  226. sys.exit(main())