enable_heal.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """一次性: 登录指定角色, 读喝药开关状态, 为 off 时发 kaiqi 翻开, 再读确认。
  2. 协议定案(2026-09-26 v2, cap_round2 实证):
  3. - @openmianban,23 → 服务器回 SC3213(encode6 面板): id=yd_cy1 按钮
  4. but_1.png=on / but_0.png=off
  5. - @chiyao_kaiqi = toggle, 每发一次翻转一次, 面板即时刷新
  6. - 故必须先读状态, off 才发, 发后再读确认
  7. 用法: uv run python scripts/enable_heal.py -u <账号> -p <密码> --zone <区> --name <角色>
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import json
  12. import logging
  13. import re
  14. import struct
  15. import sys
  16. import time
  17. import zlib
  18. import requests
  19. from pbot.codec.bitbuf import decode6
  20. from pbot.config import Account
  21. from pbot.errors import PbotError
  22. from pbot.game.session import enter_world
  23. from pbot.login.box_client import enter_game_client, login
  24. from pbot.transport.gateway import GatewayConnection, login_gate_with_retry
  25. from pbot.types import ZoneId
  26. _LOG = logging.getLogger("enable_heal")
  27. _PORTS: tuple[int, ...] = (8021, 8061, 8001, 8041)
  28. _BUT_PAT: re.Pattern[bytes] = re.compile(rb"id=yd_cy1[^>]*?(but_[01])\.png", re.DOTALL)
  29. def _send_3199(conn: GatewayConnection, sid: int, body: dict[str, object],
  30. tail: int) -> None:
  31. """发一条 CS3199 UI 动作。"""
  32. wire = json.dumps(body, separators=(",", ":")).encode()
  33. _ = conn.send_frame(3199, 0, 0, 0, sid, wire, tail=tail)
  34. def _read_heal_state(conn: GatewayConnection, wait_s: float) -> str | None:
  35. """收帧并解析 SC3213 面板的 yd_cy1 状态('on'/'off'/None)。
  36. 面板藏在 SC-1(zlib) 子帧 SC3213, body 为 encode6 编码的 JSON。
  37. """
  38. deadline = time.time() + wait_s
  39. seen: list[int] = []
  40. while time.time() < deadline:
  41. for f in conn.recv_frames(max(0.1, deadline - time.time())):
  42. seen.append(f.msg_id)
  43. if f.msg_id != -1 or f.body[:2] != b"x\x9c":
  44. continue
  45. try:
  46. raw = zlib.decompress(f.body)
  47. except zlib.error:
  48. continue
  49. pos = 0
  50. sub_msgs: list[int] = []
  51. while len(raw) - pos >= 48:
  52. try:
  53. h = struct.unpack("<9i", decode6(raw[pos:pos + 48]))
  54. except Exception: # noqa: BLE001 子帧错位即停
  55. break
  56. blen = h[7]
  57. if blen < 0 or blen > 30000 or len(raw) - pos < 48 + blen:
  58. break
  59. sub_msgs.append(h[1])
  60. if h[1] == 3213:
  61. _LOG.info("发现 SC3213 子帧!")
  62. txt = decode6(raw[pos + 48:pos + 48 + blen])
  63. m = _BUT_PAT.search(txt)
  64. if m:
  65. _LOG.info("SC3213 面板到达!")
  66. return "on" if b"but_1" in m.group(0) else "off"
  67. else:
  68. _LOG.info("SC3213 无 yd_cy1 匹配: %s", decode6(raw[pos + 48:pos + 48 + blen])[:200])
  69. pos += 48 + blen
  70. _LOG.info("SC-1 子帧: %s", sub_msgs)
  71. _LOG.info("状态窗口内收帧: %s", seen[-30:])
  72. return None
  73. def ensure_heal_on(conn: GatewayConnection, sid: int) -> str | None:
  74. """kaiqi(toggle) → 读面板确认 → 未 on 再 toggle, 最多 3 轮。
  75. 实测定案: openmianban 不触发面板下发(cap_round2 + 实测双重确认),
  76. 但每次 kaiqi 后服务器必推 SC3213 刷新面板(68114/68120/68125 实证)。
  77. 故以 kaiqi 驱动面板, 读到状态即可判定并决定是否再 toggle。
  78. """
  79. state: str | None = None
  80. for i, tail in enumerate((901, 902, 903)):
  81. _send_3199(conn, sid, {"index": 303, "act": "@chiyao_kaiqi", "subid": 1},
  82. tail=tail)
  83. _LOG.info("已发 kaiqi #%d(tail=%d), 等面板...", i + 1, tail)
  84. state = _read_heal_state(conn, 10.0)
  85. _LOG.info("kaiqi #%d 后状态: %s", i + 1, state)
  86. if state == "on":
  87. return "on"
  88. if state is None:
  89. _LOG.warning("面板未到, 不再盲发(防翻转)")
  90. return None
  91. return state
  92. def main() -> int:
  93. """登录目标角色, 确保自动喝药为 on, 退出码 0=on。"""
  94. logging.basicConfig(level=logging.INFO,
  95. format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
  96. stream=sys.stdout)
  97. ap = argparse.ArgumentParser()
  98. ap.add_argument("-u", "--user", required=True)
  99. ap.add_argument("-p", "--password", required=True)
  100. ap.add_argument("--zone", type=int, required=True)
  101. ap.add_argument("--name", required=True)
  102. a = ap.parse_args()
  103. http = requests.Session()
  104. http.trust_env = False
  105. http.verify = False
  106. jwt, uid = login(Account(username=a.user, password=a.password))
  107. _LOG.info("登录成功 uid=%d", uid)
  108. _ = enter_game_client(http, jwt, ZoneId(a.zone))
  109. _key32, sid, real_name, ticket_key, game_port = login_gate_with_retry(
  110. ZoneId(a.zone), jwt, uid, a.name, _PORTS)
  111. _LOG.info("票据 OK 角色=%s 游戏端口=%d", real_name, game_port)
  112. stop: list[bool] = [False]
  113. conn, _world, _updater = enter_world(ZoneId(a.zone), real_name, ticket_key,
  114. sid, game_port, uid, stop=stop)
  115. try:
  116. # ★ 时序门槛(cap_round2 全部 24 次 openmianban 实证: 均在进图 54~60s 后,
  117. # 更早发服务器直接忽略不回面板) — 等满 62s 再发
  118. import time as _t
  119. _LOG.info("等待 62s(面板请求时序门槛: 进图 54~60s 内服务器忽略)...")
  120. _t.sleep(62)
  121. final = ensure_heal_on(conn, sid)
  122. if final == "on":
  123. _LOG.info("[✓] 自动喝药已确认开启")
  124. return 0
  125. _LOG.error("[✗] 未能确认开启(最终状态: %s)", final)
  126. return 1
  127. finally:
  128. stop[0] = True
  129. conn.close()
  130. if __name__ == "__main__":
  131. try:
  132. sys.exit(main())
  133. except PbotError as e:
  134. _LOG.error("失败: %s", e.detail)
  135. sys.exit(1)