瀏覽代碼

feat: enable high-level cq2 game login

wuzilong 1 月之前
父節點
當前提交
5a3c0e0b08
共有 6 個文件被更改,包括 362 次插入 和 74 次删除
  1. 15 0
      cq2_sdk/CHANGELOG.md
  2. 19 9
      cq2_sdk/README.md
  3. 17 21
      cq2_sdk/docs/runbook.md
  4. 105 33
      cq2_sdk/src/cq2_sdk/client.py
  5. 5 5
      cq2_sdk/src/cq2_sdk/tasks/context.py
  6. 201 6
      cq2_sdk/tests/unit/test_client.py

+ 15 - 0
cq2_sdk/CHANGELOG.md

@@ -2,6 +2,21 @@
 
 All notable changes to this project are documented here.
 
+## [Unreleased] - 2026-09-18
+
+### Added
+
+- Verified 48-byte Encode6 TCP framing with the real nine-word header order.
+- Detailed server-list Base64/XXTEA decoding and TCP endpoint resolution.
+- Complete new/old login, existing/create-role, and world-entry state machine.
+- High-level `run_until_enter_game()` orchestration through authoritative SC2.
+
+### Changed
+
+- `Encode6BitBuf` now uses the extracted client substitution table.
+- TCP reconnect resets sequence numbers while retaining `sdata`.
+- Role IDs remain strings so large server identifiers are not truncated.
+
 ## [0.1.0] - 2026-09-15
 
 ### Added

+ 19 - 9
cq2_sdk/README.md

@@ -8,30 +8,40 @@ Python SDK for driving the `cq2` (传奇) game through 13 new-player tasks via p
 
 ## Status
 
-Pre-alpha. Tasks 1-40 of the implementation plan in progress.
+Pre-alpha. 996box authentication, detailed server resolution, TCP role login,
+and entry through SC2 world initialization are implemented. The 13 gameplay
+task classes remain protocol stubs.
 
 ## Design documents
 
-- Spec: [`docs/superpowers/specs/2026-09-15-cq2-newbie-13-tasks-sdk-design.md`](docs/superpowers/specs/2026-09-15-cq2-newbie-13-tasks-sdk-design.md)
-- Plan: [`docs/superpowers/plans/2026-09-15-cq2-newbie-13-tasks-sdk.md`](docs/superpowers/plans/2026-09-15-cq2-newbie-13-tasks-sdk.md)
+- Login spec: `../docs/superpowers/specs/2026-09-18-real-game-login-design.md`
+- Login plan: `../docs/superpowers/plans/2026-09-18-real-game-login.md`
 
 ## Installation
 
-_(TBD — populated by later tasks)_
+```bash
+uv sync --group dev
+```
 
 ## Usage
 
-_(TBD — populated by later tasks)_
-
 ```python
-import cq2_sdk
+from cq2_sdk import Cq2Client
 
-async with cq2_sdk.client(phone="13800138000", sms_code="1234") as cli:
-    await cli.launcher.run_until_enter_game(server_name="玖扬复古176", zone_index=0)
+async with Cq2Client(account="your-account", password="...") as cli:
+    # Existing roles are selected automatically. If this zone has no role,
+    # pass char_name="角色名", job=0, sex=0 explicitly.
+    session = await cli.launcher.run_until_enter_game(zone_index=0)
+    print(session.char_name, session.world_ready)
+
+    # Gameplay tasks are still under protocol development.
     result = await cli.run_newbie_flow()
     print(f"完成 {len(result.completed_tasks)} 步, 用时 {result.elapsed}")
 ```
 
+`run_until_enter_game()` returns only after the world server sends SC2. A live
+smoke run does not create a role unless `char_name` is supplied.
+
 ## Development
 
 ```bash

+ 17 - 21
cq2_sdk/docs/runbook.md

@@ -1,17 +1,19 @@
 # 13-Task Runbook
 
-> **⚡ 2026-09-16 晚 状态快照**(从此处恢复工作)
+> **2026-09-18 状态快照**
 >
 > **已打通(真实数据)**:
 > - ✅ **真 JWT 在 MySQL**:`longwu988`(users.id=5),从 `docs/手机号短信验证登录过程.saz` 提取,`phoneOptionInfo` 活体验证通过,**有效期至 2026-10-16**
 > - ✅ **A2 SMS 登录协议已破解**(发码 `user-api.../get_phone_code` + 极验 `_captcha_` base64 重发 + 登录 `api.../byPhoneCode`),`a2_sms_login.py` 可跑(token 过期时用)
 > - ✅ **B1→B2→B3 真链**:玖扬复古176(servers.id=8)→ 9 个今日新区(zone_id 14426762-770)→ B3 选最新可玩区 14426767(16:00 区)
-> - ✅ **C1→C2 也真跑通了**:C1(run_id=6,start_info 落盘 `env_dumps/run_6_env.json`,含 signkey/game token/uid);C2 TCP 连通 `gfgame.dhsf.xqhuyu.com:12000`(0.1s)。注意:`.env` 老 `jycq-...:8001` 是死域名已换;8001 直连全关,真入口只有 12000
-> - ✅ Store 层 5 个类全齐 + 228/228 单测
+> - ✅ 48 字节 Encode6 TCP 头已由二进制和真实心跳帧交叉验证
+> - ✅ 详细服务器列表 Base64/XXTEA 解码与 `s{mainId}-{srvRoot}` 地址解析
+> - ✅ CS2001→SC529→CS104→SC530→CS100→SC520→CS103/CS101→SC525→CS20→SC658→CS1018→SC2
+> - ✅ 新/旧登录、已有/新建角色、角色服/世界服重连和错误响应均有离线回环测试
 > - 工具:`scripts/parse_saz.py`(抓包解析)、`seed_token_from_saz.py`(token 提取落库)、`probe_today_open.py`(端点活探)
 > - 修复记录:`getTodayOpen` host pc-api→api;`update_token` 曾错放 RunStore;996box 域名需 `verify=False`
 >
-> **下一步**:① cq2play 会话跑 C1/C2 验证启动链;② T19 消息注册表(Lua);③ T17 TCP 登录(Frida)。
+> **下一步**:在不创建新角色的前提下做真实账号烟测,然后继续实现 13 个游戏任务协议。
 
 ## What this is
 
@@ -20,12 +22,8 @@ The 13 in-game tasks (T01-T13) are implemented as **stubs** in the current codeb
 To run the full flow against the real 996box / cq2 servers, you need:
 
 1. **Real protocol logic** for the 13 game tasks (Tasks 24-35 deep implementation)
-2. **cq2 TCP 3-step login** (Task 17)
-3. **Message registry from client Lua** (Task 19)
-4. **Cq2Client** orchestrator (Task 37)
-5. **Live credentials**: phone number + SMS code
-6. **Geetest solver**: browser-based or captcha-service (currently only `ManualGeetestSolver` pauses for stdin input)
-7. **Client Lua files**: decrypted `.lua` files from `D:\ProgramFiles\996box\GameBoxDownload\1\cq\assets\` (or override with `CQ2_LUA_PATH`)
+2. **Live credentials**: 996box account and password
+3. **Geetest solver**: browser-based or captcha-service (the default manual solver may pause for input)
 
 ## Quick start (once above is in place)
 
@@ -41,8 +39,8 @@ import asyncio
 from cq2_sdk import Cq2Client
 
 async def main():
-    async with Cq2Client(phone='13800138000', sms_code='123456') as cli:
-        await cli.launcher.run_until_enter_game(server_name='玖扬复古176', zone_index=0)
+    async with Cq2Client(account='your-account', password='...') as cli:
+        await cli.launcher.run_until_enter_game(zone_index=0)
         result = await cli.run_newbie_flow()
         print(f'done: {result.completed_tasks} in {result.elapsed:.1f}s')
 
@@ -61,6 +59,8 @@ All infrastructure is in place and unit-tested:
 | TCP frame codec + message constants | ✅ | 8 tests |
 | XXTEA encryption wrapper | ✅ | 4 tests |
 | Async TCP client with heartbeat | ✅ | 5 tests |
+| Detailed server-list decoder/resolver | ✅ | deterministic + public-list smoke |
+| Role/world login through SC2 | ✅ | loopback state-machine tests |
 | HTTP client + 4 signature algorithms | ✅ | 7 tests |
 | Geetest solver abstraction | ✅ | 5 tests |
 | 996box HTTP login (sms + geetest + session) | ✅ | 4 tests (mocked) |
@@ -72,17 +72,13 @@ All infrastructure is in place and unit-tested:
 | Task base + TaskRegistry + 13 task stubs | ✅ | 8 tests |
 | Atomic snapshot save/load | ✅ | 4 tests |
 
-**Total: 107/107 tests passing** (last verified at commit `5c37d16`).
-
-## What's deferred (needs live server)
+## What's deferred
 
 | Task | What it needs |
 |------|---------------|
-| 17 (cq2 TCP login) | Live game server + real protocol frame capture |
-| 19 (Message registry from Lua) | Decrypted client Lua files + running `cq2.exe` once to capture wire protocol |
-| 37 (Cq2Client) | Tasks 17 + 19 |
-| 38 (Recording) | Live server sessions |
-| 40 (Final integration) | Everything above |
+| 13 gameplay tasks | Verified per-message payloads and live task-state responses |
+| Recording | A consented live game session |
+| Final integration | Gameplay tasks plus a full live run |
 
 ## Resuming work
 
@@ -93,4 +89,4 @@ git log --oneline -15      # see commit history
 uv run pytest tests/unit   # verify all 107 tests pass
 ```
 
-To implement the real protocol for the 13 game tasks, see the per-task stubs in `src/cq2_sdk/tasks/stubs.py` and the plan at `docs/superpowers/plans/2026-09-15-cq2-newbie-13-tasks-sdk.md` §5.1.
+To implement the real protocol for the 13 game tasks, see the per-task stubs in `src/cq2_sdk/tasks/stubs.py` and the root plan at `../docs/superpowers/plans/2026-09-15-cq2-newbie-13-tasks-sdk.md` §5.1.

+ 105 - 33
cq2_sdk/src/cq2_sdk/client.py

@@ -4,45 +4,44 @@ Real protocol (2026-09-15 reverse-engineered):
   1. Box996Login (api.jiyuhz.cn) — RSA loginInfo + HMAC X-BOX-AUTHORIZATION
   2. get_today_open (pc-api.996hezi.com) — today's servers
   3. enter_game_client — the 启动 button
-  4. TCP game login (gfgame:12000) — frame layout implemented; game login body/state remains unverified
+  4. Detailed server-list resolution + TCP role/world login through SC2
 
-Usage (HTTP part works today):
+Usage:
     async with Cq2Client(account="longwu988", password="...") as cli:
-        servers = await cli.launcher.box.get_today_open()     # 今天开了几个区
-        await cli.launcher.enter_game(14414738)               # 启动
+        await cli.launcher.run_until_enter_game(zone_index=0)
 
-The TCP part (run_until_enter_game) remains unavailable until a matching live
-session validates the CS2001 body, initial sequence, body codec, and state
-machine.
+When the selected zone has no role, pass char_name explicitly. The SDK never
+creates a role implicitly.
 """
 from __future__ import annotations
 
 import time
 from dataclasses import dataclass, field
 from pathlib import Path
+from collections.abc import Awaitable, Callable
 
-from cq2_sdk.lua_bridge import get_runtime, LuaLoader, Cq2Lua
+from cq2_sdk.lua_bridge import get_runtime
 from cq2_sdk.lua_bridge.runtime import DEFAULT_LUA_PATH
 from cq2_sdk.transport import TcpClient
 from cq2_sdk.login import (
     Box996Login,
-    Box996Error,
     CaptchaRequiredError,
     GeetestSolver,
     ManualGeetestSolver,
     TodayOpenServer,
+    GameLogin,
+    GameSession,
+    ServerInfo,
+    fetch_server_info,
 )
 from cq2_sdk.login.models import BoxSession
-from cq2_sdk.messages.registry import MessageRegistry
 from cq2_sdk.messages import constants as C
 from cq2_sdk.movement import Movement
 from cq2_sdk.combat import Combat
 from cq2_sdk.missions import Missions
 from cq2_sdk.tasks.registry import TaskRegistry
 from cq2_sdk.tasks.context import TaskContext
-from cq2_sdk.launcher import find_server, launch_game, wait_for_game_ready
-from cq2_sdk.launcher import ServerGroup, GameLaunchInfo
-from cq2_sdk.utils.snapshot import save as snapshot_save, load as snapshot_load
+from cq2_sdk.utils.snapshot import load as snapshot_load
 
 
 @dataclass
@@ -55,13 +54,25 @@ class FlowResult:
 class LauncherFacade:
     """High-level pre-game flow using the REAL 996box protocol.
 
-    HTTP portion (login → today's servers → EnterGameClient) is complete.
-    TCP wire framing is implemented, but its login payload and session state
-    remain unverified.
+    Chains today's-server selection, EnterGameClient, detailed TCP endpoint
+    resolution, role login, and world initialization.
     """
 
-    def __init__(self, box: Box996Login) -> None:
+    def __init__(
+        self,
+        box: Box996Login,
+        session: BoxSession | None = None,
+        *,
+        on_ready: Callable[
+            [TcpClient, ServerInfo, GameSession], Awaitable[None]
+        ] | None = None,
+    ) -> None:
         self.box = box
+        self.session = session
+        self.on_ready = on_ready
+        self.tcp: TcpClient | None = None
+        self.server: ServerInfo | None = None
+        self.game_session: GameSession | None = None
 
     async def get_today_servers(self, game_id: int = 206923) -> list[TodayOpenServer]:
         """今天开了几个区 — the real /box/v1/getTodayOpen."""
@@ -79,23 +90,55 @@ class LauncherFacade:
         self,
         server_name: str = "玖扬复古176",
         zone_index: int = 0,
-    ) -> GameLaunchInfo:
-        """Full flow incl. TCP connect — blocked on unverified game login data.
-
-        HTTP parts (find server, EnterGameClient) work; the TCP game login
-        requires a validated CS2001 body, production initial sequence, body
-        codec, and state machine.
-        """
-        servers = await self.box.get_today_open()
+        *,
+        game_id: int = 206923,
+        channel_id: str = "110585",
+        char_name: str | None = None,
+        job: int = 0,
+        sex: int = 0,
+        timeout: float = 10.0,
+    ) -> GameSession:
+        """Launch the selected zone and complete CQ2 TCP world login."""
+        del server_name  # This facade currently targets one configured game.
+        if self.session is None:
+            raise RuntimeError("run_until_enter_game requires an authenticated BoxSession")
+
+        servers = await self.box.get_today_open(game_id)
         if not servers:
             raise RuntimeError("no servers open today")
+        if zone_index < 0:
+            raise ValueError("zone_index must be non-negative")
         target = servers[min(zone_index, len(servers) - 1)]
-        await self.box.enter_game_client(serverid=target.serverid)
-        raise NotImplementedError(
-            "TCP wire framing is implemented, but game login requires a validated "
-            "CS2001 body, initial sequence, body codec, and state machine. "
-            "HTTP parts completed: getTodayOpen + EnterGameClient."
+        await self.box.enter_game_client(game_id=game_id, serverid=target.serverid)
+        server = await fetch_server_info(
+            target.serverid,
+            channel_id=channel_id,
+            game_id=game_id,
         )
+        tcp = TcpClient(server.host, server.proxy_port or server.port)
+        try:
+            await tcp.connect()
+            game_session = await GameLogin(tcp).login(
+                self.session,
+                server,
+                char_name=char_name,
+                job=job,
+                sex=sex,
+                timeout=timeout,
+            )
+            await tcp.start_heartbeat()
+            self.tcp = tcp
+            self.server = server
+            self.game_session = game_session
+            if self.on_ready is not None:
+                await self.on_ready(tcp, server, game_session)
+        except BaseException:
+            self.tcp = None
+            self.server = None
+            self.game_session = None
+            await tcp.close()
+            raise
+        return game_session
 
 
 class Cq2Client:
@@ -124,6 +167,9 @@ class Cq2Client:
         self.launcher: LauncherFacade | None = None
         self.box_session: BoxSession | None = None
         self.box: Box996Login | None = None
+        self.tcp: TcpClient | None = None
+        self.server_info: ServerInfo | None = None
+        self.game_session: GameSession | None = None
 
     async def __aenter__(self) -> "Cq2Client":
         if not self.password and not self.sms_code:
@@ -153,15 +199,41 @@ class Cq2Client:
             rt._configure_path()
 
         # 3. Launcher facade (HTTP parts fully functional)
-        self.launcher = LauncherFacade(self.box)
+        self.launcher = LauncherFacade(
+            self.box,
+            self.box_session,
+            on_ready=self._bind_game_session,
+        )
         return self
 
+    async def _bind_game_session(
+        self,
+        tcp: TcpClient,
+        server: ServerInfo,
+        game_session: GameSession,
+    ) -> None:
+        """Retain the live connection and expose gameplay task primitives."""
+        self.tcp = tcp
+        self.server_info = server
+        self.game_session = game_session
+        context = TaskContext(
+            tcp=tcp,
+            movement=Movement(tcp),
+            combat=Combat(tcp),
+            missions=Missions(tcp),
+            box_session=self.box_session,
+            server=server,
+            game_session=game_session,
+            snapshot_path=self.snapshot_path,
+        )
+        self.task = TaskRegistry(context)
+
     async def __aexit__(self, *exc) -> None:
         if self.box:
             await self.box.close()
-        if self.task and getattr(self.task.ctx, "tcp", None):
+        if self.tcp is not None:
             try:
-                await self.task.ctx.tcp.close()
+                await self.tcp.close()
             except Exception:
                 pass
 

+ 5 - 5
cq2_sdk/src/cq2_sdk/tasks/context.py

@@ -16,22 +16,22 @@ if TYPE_CHECKING:
     from cq2_sdk.movement import Movement
     from cq2_sdk.combat import Combat
     from cq2_sdk.missions import Missions
-    from cq2_sdk.login import BoxSession
-    from cq2_sdk.launcher import ServerGroup, Zone
+    from cq2_sdk.login import BoxSession, GameSession, ServerInfo
+    from cq2_sdk.launcher import Zone
 
 
 @dataclass
 class TaskContext:
     tcp: "TcpClient"
-    http: "HttpClient"
     movement: "Movement"
     combat: "Combat"
     missions: "Missions"
+    http: "HttpClient | None" = None
     lua: "LuaRuntime | Cq2Lua | None" = None
     box_session: "BoxSession | None" = None
-    server: "ServerGroup | None" = None
+    server: "ServerInfo | None" = None
     zone: "Zone | None" = None
-    game_session: object | None = None  # populated by GameLogin in Task 17
+    game_session: "GameSession | None" = None
     snapshot_path: Path | None = None
 
     async def save_snapshot(self, completed: list[str], current: str, state: dict) -> None:

+ 201 - 6
cq2_sdk/tests/unit/test_client.py

@@ -3,7 +3,14 @@ import httpx
 import pytest
 
 from cq2_sdk.client import Cq2Client, FlowResult, LauncherFacade
-from cq2_sdk.login import Box996Login, TodayOpenServer, CaptchaRequiredError
+from cq2_sdk.login import (
+    Box996Login,
+    BoxSession,
+    CaptchaRequiredError,
+    GameSession,
+    ServerInfo,
+    TodayOpenServer,
+)
 
 LOGIN_200 = {
     "code": 200,
@@ -72,11 +79,162 @@ async def test_launcher_enter_game():
 
 
 @pytest.mark.asyncio
-async def test_run_until_enter_game_raises_step2_pending():
-    """TCP part blocked — must raise NotImplementedError AFTER HTTP parts run."""
-    facade = LauncherFacade(make_box())
-    with pytest.raises(NotImplementedError, match="Frida|Step 2|frame encryption"):
-        await facade.run_until_enter_game()
+async def test_run_until_enter_game_orchestrates_real_login(monkeypatch):
+    events = []
+    ready = []
+    resolved = ServerInfo(
+        server_id=14414736,
+        main_server_id=14414736,
+        name="霸业10服",
+        host="game.example",
+        port=8041,
+        channel_id="110585",
+        game_id=206923,
+    )
+    game_session = GameSession(
+        char_id="role-1",
+        char_name="Hero",
+        session_id=149,
+        user_id="user-1",
+        world_ready=True,
+    )
+
+    async def fake_resolve(server_id, *, channel_id, game_id):
+        events.append(("resolve", server_id, channel_id, game_id))
+        return resolved
+
+    class FakeTcp:
+        def __init__(self, host, port):
+            events.append(("tcp", host, port))
+
+        async def connect(self):
+            events.append("connect")
+
+        async def start_heartbeat(self):
+            events.append("heartbeat")
+
+        async def close(self):
+            events.append("close")
+
+    class FakeGameLogin:
+        def __init__(self, tcp):
+            self.tcp = tcp
+
+        async def login(self, session, server, **kwargs):
+            events.append(("login", session.uid, server.server_id, kwargs))
+            return game_session
+
+    async def on_ready(tcp, server, result):
+        ready.append((tcp, server, result))
+
+    monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
+    monkeypatch.setattr("cq2_sdk.client.TcpClient", FakeTcp)
+    monkeypatch.setattr("cq2_sdk.client.GameLogin", FakeGameLogin)
+    box = make_box()
+    facade = LauncherFacade(
+        box,
+        BoxSession(uid="user-1", token="token", sdkid="", signkey=""),
+        on_ready=on_ready,
+    )
+
+    result = await facade.run_until_enter_game(
+        zone_index=0,
+        channel_id="110585",
+        char_name="Hero",
+        job=1,
+        sex=0,
+        timeout=3.0,
+    )
+    await box.close()
+
+    assert result is game_session
+    assert events == [
+        ("resolve", 14414736, "110585", 206923),
+        ("tcp", "game.example", 8041),
+        "connect",
+        (
+            "login",
+            "user-1",
+            14414736,
+            {"char_name": "Hero", "job": 1, "sex": 0, "timeout": 3.0},
+        ),
+        "heartbeat",
+    ]
+    assert ready == [(facade.tcp, resolved, game_session)]
+    assert facade.game_session is game_session
+
+
+@pytest.mark.asyncio
+async def test_run_until_enter_game_requires_authenticated_session():
+    box = make_box()
+    facade = LauncherFacade(box)
+    try:
+        with pytest.raises(RuntimeError, match="authenticated BoxSession"):
+            await facade.run_until_enter_game()
+    finally:
+        await box.close()
+
+
+@pytest.mark.asyncio
+async def test_run_until_enter_game_closes_tcp_when_ready_binding_fails(monkeypatch):
+    events = []
+    server = ServerInfo(
+        server_id=1,
+        name="One",
+        host="game.example",
+        port=8041,
+    )
+    result = GameSession(
+        char_id="r1",
+        char_name="Hero",
+        session_id=1,
+        user_id="u1",
+        world_ready=True,
+    )
+
+    async def fake_resolve(*args, **kwargs):
+        return server
+
+    class FakeTcp:
+        def __init__(self, host, port):
+            pass
+
+        async def connect(self):
+            pass
+
+        async def start_heartbeat(self):
+            pass
+
+        async def close(self):
+            events.append("close")
+
+    class FakeGameLogin:
+        def __init__(self, tcp):
+            pass
+
+        async def login(self, *args, **kwargs):
+            return result
+
+    async def failing_ready(*args):
+        raise RuntimeError("bind failed")
+
+    monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
+    monkeypatch.setattr("cq2_sdk.client.TcpClient", FakeTcp)
+    monkeypatch.setattr("cq2_sdk.client.GameLogin", FakeGameLogin)
+    box = make_box()
+    facade = LauncherFacade(
+        box,
+        BoxSession(uid="u1", token="token", sdkid="", signkey=""),
+        on_ready=failing_ready,
+    )
+    try:
+        with pytest.raises(RuntimeError, match="bind failed"):
+            await facade.run_until_enter_game()
+    finally:
+        await box.close()
+
+    assert events == ["close"]
+    assert facade.tcp is None
 
 
 # ---- Cq2Client ----
@@ -92,10 +250,47 @@ async def test_client_password_login_full_flow(monkeypatch):
         assert cli.box_session is not None
         assert cli.box_session.uid == "1186115037"
         assert cli.launcher is not None
+        assert cli.launcher.session is cli.box_session
         servers = await cli.launcher.get_today_servers()
         assert len(servers) == 2
 
 
+@pytest.mark.asyncio
+async def test_client_ready_callback_builds_gameplay_context():
+    cli = Cq2Client(account="account", password="password")
+    tcp = object()
+    server = ServerInfo(
+        server_id=7,
+        name="Seven",
+        host="game.example",
+        port=8041,
+    )
+    game_session = GameSession(
+        char_id="role-1",
+        char_name="Hero",
+        session_id=149,
+        user_id="user-1",
+        world_ready=True,
+    )
+    cli.box_session = BoxSession(
+        uid="user-1", token="token", sdkid="", signkey=""
+    )
+
+    await cli._bind_game_session(tcp, server, game_session)
+
+    assert cli.tcp is tcp
+    assert cli.server_info is server
+    assert cli.game_session is game_session
+    assert cli.task is not None
+    assert cli.task.ctx.tcp is tcp
+    assert cli.task.ctx.box_session is cli.box_session
+    assert cli.task.ctx.server is server
+    assert cli.task.ctx.game_session is game_session
+    assert cli.task.ctx.movement.tcp is tcp
+    assert cli.task.ctx.combat.tcp is tcp
+    assert cli.task.ctx.missions.tcp is tcp
+
+
 def test_client_requires_credentials():
     with pytest.raises(RuntimeError, match="password"):
         import asyncio