Преглед изворни кода

feat(apilot): M1 e2e - headless create-role on live gateway (zone 14481607) [round 0: prelive scaffolding]

wuzilong пре 1 месец
родитељ
комит
b4e007897f
4 измењених фајлова са 86 додато и 11 уклоњено
  1. 38 11
      apilot/host.py
  2. 19 0
      apilot/main.py
  3. 5 0
      pytest.ini
  4. 24 0
      tests/test_live_t01.py

+ 38 - 11
apilot/host.py

@@ -27,6 +27,11 @@ from apilot.wire import Frame  # noqa: E402
 GW_HOST = "s{zone}-jycq-206923.dhsf.xqhuyu.com"
 CHANNEL = "110585"
 GAME_ID = 206923
+LOGIN_PORTS = (8041, 8021, 8061, 8001)  # 端口轮询顺序(README: 8041 优先)
+
+
+class _GateError(RuntimeError):
+    """单端口登录链失败(超时/断连)→ 换下一端口重试。"""
 
 
 class Host:
@@ -148,9 +153,13 @@ class Host:
             wire._buf = wire._buf + data
         return None, None
 
-    def run_create_role(self, name: str | None = None, timeout: float = 30) -> dict:
+    def run_create_role(self, name: str | None = None, timeout: float = 30,
+                        ports: tuple[int, ...] = LOGIN_PORTS) -> dict:
         """登录链逐帧驱动(对照 cq2_pure_enter.login_gate 唯一真源,逐帧注释)。
 
+        内部: 逐端口 connect(8041/8021/8061/8001 轮询)→ 2001 链 → 101 → 521。
+        返回 {"role_name", "ticket", "sid", "key32", "port"}。
+
         2001(tail=0,recog=random) → 等 529 取 key32(decode6 split /) →
         104(body=encode6(key32),tail=1) → 等 530 取 sid(m[0]/pre 字段,
         cq2_pure_enter:138) →
@@ -159,14 +168,31 @@ class Host:
         角色列表含 name→复用;否则 101(tail=3) → 等 521(decode6 后 split /)
         /522(换名重试 ≤5 次)。
         """
-        port = self._login_port
+        last_err: Exception | None = None
+        for port in ports:
+            if not self.nc.is_connected():
+                try:
+                    self.connect_login_gate(port)
+                except OSError as e:
+                    last_err = e
+                    continue
+            try:
+                return self._create_role_once(name, timeout, port)
+            except _GateError as e:
+                last_err = e
+                self.nc.disconnect()
+                continue
+        raise RuntimeError(f"[!] 所有候选端口失败: {last_err}")
+
+    def _create_role_once(self, name: str | None, timeout: float,
+                          port: int) -> dict:
         # CS2001 ─ 登录服首帧,带 uid/encode6(jwt)/encode6(channel)/1/0
         self._send(2001, random.randint(1, 2**30), self.zone, port, 0, 0,
                    self.build_2001_body(self.uid, self.jwt))
         # 等 SC529 → 会话密钥 key32(body 形如 "key32/...")
         f529, _ = self._wait_for(529, timeout)
         if f529 is None:
-            raise RuntimeError("[!] 登录服未下发 529 key")
+            raise _GateError("[!] 登录服未下发 529 key")
         key32 = decode6(f529.body).split(b"/")[0]
         # CS104 ─ 回传 key32 协商
         self._send(104, 0, 0, 0, 0, 1, encode6(key32))
@@ -175,7 +201,7 @@ class Host:
         # 这里 _recv_with_pre 直读 raw 字节补回。未命中超时即报错。
         _, sid = self._recv_with_pre(530, timeout)
         if sid is None:
-            raise RuntimeError("[!] 登录服未下发 530 sid")
+            raise _GateError("[!] 登录服未下发 530 sid")
         self.nc.set_sdata(sid)
         # CS100 ─ 拉角色列表(body 形如 "uid/sid/zone/channel/gameid/"+key32)
         body100 = (f"{self.uid}/{sid}/{self.zone}/{CHANNEL}/{GAME_ID}/"
@@ -238,19 +264,20 @@ class Host:
             if lst:
                 chr_info = lst[-1]
         if chr_info is None:
-            raise RuntimeError("[!] 无角色且创角失败")
-        return {"name": chr_info["chr"], "sid": sid,
-                "key32": key32, "user_id": chr_info["UserID"]}
+            raise _GateError("[!] 无角色且创角失败")
+        ticket = chr_info["UserID"]
+        return {"role_name": chr_info["chr"], "ticket": ticket,
+                "sid": sid, "key32": key32, "port": port}
 
 
 def run_zone(zone_id: int, user: str, password: str, name: str | None = None,
-             port: int = 8041) -> dict:
-    """一站式: HTTP 登录 → enter_game → 网关连接 → 创角 → 返回票据。"""
+             port: int | None = None) -> dict:
+    """一站式: HTTP 登录 → enter_game → 网关连接(端口轮询) → 创角 → 返回票据。"""
     h = Host(zone_id, user, password)
     session = h.http_login()
     j2 = P.enter_game(session, h.jwt, zone_id)
     if j2.get("code") != 200:
         raise SystemExit(f"[!] enter_game 失败: {j2.get('msg') or j2}")
-    h.connect_login_gate(port=port)
-    info = h.run_create_role(name)
+    ports = (port,) if port else LOGIN_PORTS
+    info = h.run_create_role(name, ports=ports)
     return {"uid": h.uid, "jwt": h.jwt, "zone": h.zone, **info}

+ 19 - 0
apilot/main.py

@@ -0,0 +1,19 @@
+"""APilot CLI。M1: 无客户端创角。"""
+import argparse
+
+from apilot.host import run_zone
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--zone", type=int, required=True)
+    ap.add_argument("-u", "--user", default="longwu988")
+    ap.add_argument("-p", "--password", default="Longwu1988")
+    ap.add_argument("--name", default=None)
+    a = ap.parse_args()
+    r = run_zone(a.zone, a.user, a.password, a.name)
+    print(f"[M1完成] 区={a.zone} 角色={r['role_name']} 票据={r['ticket'][:24]}...")
+
+
+if __name__ == "__main__":
+    main()

+ 5 - 0
pytest.ini

@@ -0,0 +1,5 @@
+[pytest]
+testpaths = tests
+markers =
+    live: real-gateway e2e (deselected by default; run with -m live)
+addopts = -m "not live"

+ 24 - 0
tests/test_live_t01.py

@@ -0,0 +1,24 @@
+# tests/test_live_t01.py
+"""Task 8: [live] T01 端到端 — 无客户端创角(逐鹿05区 14481607)。
+
+默认跳过(pytest.ini addopts -m "not live");真实网关显式:
+    protocol_bot/.venv/bin/python -m pytest tests/test_live_t01.py -m live -v -s
+"""
+import random
+
+import pytest
+
+from apilot.host import Host
+
+pytestmark = pytest.mark.live
+
+
+def test_t01_create_role_headless():
+    name = "萌新" + str(random.randint(1000, 9999))
+    h = Host(14481607, "longwu988", "Longwu1988", state_dir="/tmp/apilot_t01")
+    h.http_login()
+    h.boot_lua()
+    result = h.run_create_role(name=name)   # 内部: connect→2001链→101→521
+    assert result["role_name"] == name
+    assert result["ticket"]                 # 521 票据非空
+    assert h.nc.get_sdata() != 0            # 530 sid 已注入