feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。 实时通道:WebSocket -> SSE - hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye, 250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。 - 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 + 客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。 - 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。 服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2 - 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式 http="zttp",否则 Config.load() 直接 raise。 - 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖), 不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。 - 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例 「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。 - 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被 except OSError 吞掉,导致每次正常退出都留 stale pid 文件。 节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」 - DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。 - engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉, 时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。 - 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。 键盘:修复方向键/空格/Tab/ESC 全部静默失效 - keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串 (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。 - 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。 测试:重建 500 项 - test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 / test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑, 所以是全绿而不是靠跳过伪装。 - 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」 (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要 预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。 优化:先量后改,依据与前后数字都在 tools/bench.py - 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化 整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、 GET /api/world -31%、POST /api/actions?full=1 -16%。 - 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、 build_parser 与 available_actions 分组、__main__.main 160->7 行。 - 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的 broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json; main.py 的 _add_endpoint_args/_leaderboard_lines。 - tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。 门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
1 parent
47dd818360
commit
f8e093c98f
36 files changed
+6865
-2204
No files matched your search
+106
-188
@@ -15,20 +15,21 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程
|
||||
|
||||
字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。
|
||||
|
||||
双通道
|
||||
------
|
||||
读写分工
|
||||
--------
|
||||
* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。
|
||||
* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。
|
||||
* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
|
||||
* **读数走 SSE**(GET /api/stream):服务器在状态变化时顺着一条 HTTP 长连接推过来,
|
||||
界面不用轮询;上行下行都是普通 HTTP,不需要任何第三方库。
|
||||
* SSE 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
|
||||
|
||||
请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -36,8 +37,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from typing import Any
|
||||
|
||||
from . import config, net
|
||||
@@ -45,7 +44,19 @@ from .engine import GameError
|
||||
|
||||
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
|
||||
LOG_KEEP = 200
|
||||
WS_RECONNECT_DELAY = 3.0
|
||||
STREAM_RECONNECT_DELAY = 3.0 # SSE 断开后重连的间隔(秒)
|
||||
STREAM_READ_TIMEOUT = 40.0 # 读 SSE 的 socket 超时,要比服务器心跳(15s)宽裕
|
||||
|
||||
|
||||
def _underlying_socket(response: Any) -> Any:
|
||||
"""从 ``HTTPResponse`` 里掏出底层 socket。
|
||||
|
||||
用途只有一个:断开时能立刻中断正在阻塞的读取(``close()`` 会等读线程的锁,
|
||||
最长要等到下一次心跳,不可接受)。掏不到就返回 None,调用方会退化成 close()。
|
||||
"""
|
||||
fp = getattr(response, "fp", None)
|
||||
raw = getattr(fp, "raw", None) # http.client 把 socket 包成 SocketIO
|
||||
return getattr(raw, "_sock", None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 状态视图
|
||||
@@ -166,6 +177,10 @@ class StateView:
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self._d: dict[str, Any] = {}
|
||||
# 空视图也要长得像"空农场":还没收到第一份快照时(或服务器连不上时),
|
||||
# 上层会直接读 state.plots 渲染,不能甩出 AttributeError。
|
||||
self.plots: list[PlotView] = []
|
||||
self.animals: list[AnimalView] = []
|
||||
self.apply(data or {})
|
||||
|
||||
def apply(self, data: dict[str, Any]) -> None:
|
||||
@@ -248,19 +263,17 @@ class RemoteFarm:
|
||||
|
||||
self.state = StateView()
|
||||
self.connected = False
|
||||
self.mode = "http" # http / ws / poll
|
||||
self.mode = "http" # http(只有写请求)/ sse(长连接推送)/ poll(轮询兜底)
|
||||
self.last_error = ""
|
||||
self.last_message = ""
|
||||
self.server_info: dict[str, Any] = {}
|
||||
self.on_change: Callable[[], None] | None = None
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._pending: dict[int, Future] = {}
|
||||
self._req_id = 0
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ws_socket: Any = None
|
||||
self._ws_loop: Any = None
|
||||
self._stream_response: Any = None
|
||||
self._stream_socket: Any = None
|
||||
self._catalog: dict[str, Any] = {
|
||||
"crops": [],
|
||||
"animals": [],
|
||||
@@ -270,7 +283,7 @@ class RemoteFarm:
|
||||
}
|
||||
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
|
||||
self._log_seq = 0
|
||||
self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0}
|
||||
self.stats = {"http": 0, "pushes": 0, "errors": 0}
|
||||
|
||||
if autostart:
|
||||
self.refresh(raise_on_error=True)
|
||||
@@ -280,12 +293,18 @@ class RemoteFarm:
|
||||
# ------------------------------------------------------------ 基础设施
|
||||
@staticmethod
|
||||
def _normalize(endpoint: str) -> str:
|
||||
endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
|
||||
endpoint = (endpoint or "").strip()
|
||||
if endpoint.startswith("tcp://"):
|
||||
endpoint = "http://" + endpoint[len("tcp://") :]
|
||||
if not endpoint.startswith(("http://", "https://")):
|
||||
elif not endpoint.startswith(("http://", "https://")):
|
||||
endpoint = "http://" + endpoint
|
||||
return endpoint
|
||||
# 补完 scheme 再削尾部斜杠:顺序反了的话 "http://" 会被削成 "http:",
|
||||
# 拼出来的地址没有主机名,请求会悄悄落到系统代理上(表现为莫名其妙的 502)。
|
||||
endpoint = endpoint.rstrip("/")
|
||||
has_host = urllib.parse.urlsplit(endpoint).netloc
|
||||
if endpoint.startswith(("http://", "https://")) and has_host:
|
||||
return endpoint
|
||||
return DEFAULT_ENDPOINT
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
@@ -346,7 +365,7 @@ class RemoteFarm:
|
||||
|
||||
# ------------------------------------------------------------ 读接口
|
||||
def refresh(self, raise_on_error: bool = False) -> bool:
|
||||
"""主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。"""
|
||||
"""主动拉一次全量数据(首次连接、以及 SSE 断开后的兜底)。"""
|
||||
try:
|
||||
data = self._http("GET", "/api/state")
|
||||
self._apply_online(data.get("state") or {}, data.get("online"))
|
||||
@@ -417,38 +436,15 @@ class RemoteFarm:
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ 写接口
|
||||
def _action(
|
||||
self, action: str, params: dict[str, Any] | None = None, transport: str = "auto"
|
||||
) -> dict[str, Any]:
|
||||
"""执行一个动作。
|
||||
def _action(self, action: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""执行一个动作:写操作统一走 HTTP POST /api/actions。
|
||||
|
||||
优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
|
||||
(连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家,
|
||||
否则"金币不足"会被悄悄重试成两次操作。
|
||||
实时通道只负责下行推送,所以这里不需要"优先哪条通道"的判断;
|
||||
``full=1`` 让服务器顺手把完整快照带回来,省掉"操作后再查一次"。
|
||||
"""
|
||||
params = params or {}
|
||||
use_ws = (transport == "ws") or (
|
||||
transport == "auto" and self.mode == "ws" and self.connected
|
||||
data = self._http(
|
||||
"POST", "/api/actions", {"action": action, "params": params or {}}, full=1
|
||||
)
|
||||
if use_ws:
|
||||
try:
|
||||
result = self._ws_action(action, params)
|
||||
except GameError as exc:
|
||||
if exc.code in ("ws_closed", "timeout", "network"):
|
||||
self.last_error = f"WebSocket 不可用({exc.message}),已改走 HTTP"
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
self.stats["ws_actions"] += 1
|
||||
if isinstance(result.get("snapshot"), dict):
|
||||
self._apply(result["snapshot"])
|
||||
elif isinstance(result.get("state"), dict):
|
||||
self._apply(result["state"])
|
||||
self._merge_log(result.get("log") or [])
|
||||
return result
|
||||
|
||||
# full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次"
|
||||
data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1)
|
||||
self._apply(data.get("snapshot") or data.get("state") or {})
|
||||
self._merge_log(data.get("log") or [])
|
||||
return data
|
||||
@@ -484,22 +480,13 @@ class RemoteFarm:
|
||||
def buy_plot(self) -> dict[str, Any]:
|
||||
return self._action("buy_plot")
|
||||
|
||||
def sleep(self) -> dict[str, Any]:
|
||||
return self._action("sleep")
|
||||
|
||||
def pause(self, paused: bool | None = None) -> dict[str, Any]:
|
||||
# 动作回执里的 state(brief)已带 paused 与 seconds_to_tick,
|
||||
# _action 内部会合并进本地视图,这里只需转发参数
|
||||
params: dict[str, Any] = {}
|
||||
if paused is not None:
|
||||
params["paused"] = paused
|
||||
data = self._action("pause", params)
|
||||
self._apply(data.get("state") or {})
|
||||
# 本地视图也要反映暂停态,TUI 的倒计时依赖它
|
||||
if "paused" in data:
|
||||
merged = self.state.to_dict()
|
||||
merged["paused"] = data["paused"]
|
||||
merged["seconds_to_tick"] = None if data["paused"] else merged.get("seconds_to_tick")
|
||||
self.state.apply(merged)
|
||||
return data
|
||||
return self._action("pause", params)
|
||||
|
||||
def reset(self) -> dict[str, Any]:
|
||||
data = self._action("reset")
|
||||
@@ -520,60 +507,64 @@ class RemoteFarm:
|
||||
self._merge_log(data.get("log") or [])
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------ WebSocket
|
||||
# ------------------------------------------------------------ 实时通道(SSE)
|
||||
def start(self) -> None:
|
||||
if not self.realtime_enabled or self._thread is not None:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True)
|
||||
self._thread = threading.Thread(target=self._realtime_loop, name="farm-sse", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
with self._lock:
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(GameError("closed", "连接已关闭"))
|
||||
self._pending.clear()
|
||||
# 主动掐断长连接,否则服务端那条流式响应会一直挂着,拖慢它的优雅停机。
|
||||
# 注意不能直接 response.close():读线程正持有那把锁,close 会一直等它,
|
||||
# 而它要等下一次心跳(15 秒)才醒。直接 shutdown 底层 socket 才能立刻唤醒。
|
||||
sock = self._stream_socket
|
||||
if sock is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
response = self._stream_response
|
||||
if response is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
response.close()
|
||||
|
||||
def transport_label(self) -> str:
|
||||
if self.mode == "ws":
|
||||
return "WebSocket 实时"
|
||||
if self.mode == "sse":
|
||||
return "SSE 实时"
|
||||
if self.mode == "poll":
|
||||
return "HTTP 轮询"
|
||||
return "HTTP"
|
||||
|
||||
@property
|
||||
def ws_url(self) -> str:
|
||||
parts = urllib.parse.urlsplit(self.endpoint)
|
||||
scheme = "wss" if parts.scheme == "https" else "ws"
|
||||
query = {"farm": self.farm}
|
||||
def stream_url(self) -> str:
|
||||
"""SSE 长连接地址。
|
||||
|
||||
令牌只能走查询参数:``EventSource`` 与 ``urllib`` 都没法给长连接加自定义头。
|
||||
"""
|
||||
query: dict[str, Any] = {"farm": self.farm}
|
||||
if self.token:
|
||||
query["token"] = self.token
|
||||
if self.name:
|
||||
query["name"] = self.name
|
||||
return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}"
|
||||
return f"{self.endpoint}/api/stream?{urllib.parse.urlencode(query)}"
|
||||
|
||||
def _realtime_loop(self) -> None:
|
||||
"""实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。"""
|
||||
"""实时线程:读 SSE 长连接;断了就一边轮询一边重连,永不放弃。"""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._ws_session()
|
||||
self._stream_once()
|
||||
except Exception as exc:
|
||||
self.connected = False
|
||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||
if self.mode != "ws":
|
||||
self.mode = "poll"
|
||||
self._fail_pending(self.last_error)
|
||||
self.mode = "poll"
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._poll_until_reconnect()
|
||||
|
||||
def _poll_until_reconnect(self) -> None:
|
||||
"""WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。"""
|
||||
if self.mode == "ws":
|
||||
self.mode = "poll"
|
||||
deadline = time.time() + WS_RECONNECT_DELAY
|
||||
"""SSE 断开期间的降级通道:轻量轮询 + 到点再试一次长连接。"""
|
||||
deadline = time.time() + STREAM_RECONNECT_DELAY
|
||||
while not self._stop.is_set() and time.time() < deadline:
|
||||
try:
|
||||
data = self._http("GET", "/api/state")
|
||||
@@ -582,64 +573,46 @@ class RemoteFarm:
|
||||
pass
|
||||
self._stop.wait(0.5)
|
||||
|
||||
def _ws_session(self) -> None:
|
||||
import asyncio
|
||||
def _stream_once(self) -> None:
|
||||
"""读一次 SSE 流,直到断开或主动停止。
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
# 没装 websockets:直接走轮询模式,功能不减
|
||||
self.mode = "poll"
|
||||
self._stop.wait(WS_RECONNECT_DELAY)
|
||||
return
|
||||
|
||||
asyncio.run(self._ws_main(websockets))
|
||||
|
||||
async def _ws_main(self, websockets) -> None:
|
||||
async with websockets.connect(
|
||||
self.ws_url,
|
||||
open_timeout=self.timeout,
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
max_size=4 * 1024 * 1024,
|
||||
) as socket:
|
||||
self.mode = "ws"
|
||||
直接按行迭代响应体:一行一行拿,不把响应收完,
|
||||
所以这是真正的流式读取,而且只用标准库。
|
||||
"""
|
||||
request = urllib.request.Request(self.stream_url, headers={"Accept": "text/event-stream"})
|
||||
# 服务器每 HEARTBEAT_SECONDS 秒发一次心跳,读超时要比它宽裕
|
||||
with net.open_url(request, timeout=max(self.timeout, STREAM_READ_TIMEOUT)) as response:
|
||||
self._stream_response = response
|
||||
self._stream_socket = _underlying_socket(response)
|
||||
self.mode = "sse"
|
||||
self.connected = True
|
||||
self.last_error = ""
|
||||
self._ws_socket = socket
|
||||
self._ws_loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
raw = await asyncio.wait_for(socket.recv(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
await socket.send(json.dumps({"type": "ping"}))
|
||||
for raw_line in response:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or line.startswith(":"):
|
||||
# 空行是帧分隔符;冒号开头是心跳注释,都无需处理
|
||||
continue
|
||||
self._handle_ws_message(raw)
|
||||
if line.startswith("data:"):
|
||||
self._handle_stream_message(line[len("data:") :].strip())
|
||||
finally:
|
||||
self._ws_socket = None
|
||||
self._ws_loop = None
|
||||
self._fail_pending("WebSocket 连接已断开")
|
||||
self._stream_response = None
|
||||
self._stream_socket = None
|
||||
|
||||
def _fail_pending(self, reason: str) -> None:
|
||||
"""连接断了以后,把还在等回执的动作请求全部失败掉。"""
|
||||
with self._lock:
|
||||
pending, self._pending = self._pending, {}
|
||||
for future in pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(GameError("ws_closed", reason))
|
||||
|
||||
def _handle_ws_message(self, raw: Any) -> None:
|
||||
def _handle_stream_message(self, payload: str) -> None:
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
message = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
kind = message.get("type")
|
||||
|
||||
if kind == "welcome":
|
||||
self.server_info = message.get("server") or {}
|
||||
if kind in ("welcome", "state", "sync"):
|
||||
if message.get("server"):
|
||||
self.server_info = message["server"]
|
||||
self.farm = message.get("farm", self.farm)
|
||||
if message.get("catalog"):
|
||||
self._catalog = message["catalog"]
|
||||
@@ -648,38 +621,6 @@ class RemoteFarm:
|
||||
self.stats["pushes"] += 1
|
||||
return
|
||||
|
||||
if kind in ("state", "sync"):
|
||||
if message.get("catalog"):
|
||||
self._catalog = message["catalog"]
|
||||
self._apply_online(message.get("state") or {}, message.get("online"))
|
||||
self._merge_log(message.get("log") or [])
|
||||
self.stats["pushes"] += 1
|
||||
return
|
||||
|
||||
if kind == "result":
|
||||
rid = message.get("id")
|
||||
with self._lock:
|
||||
future = self._pending.pop(rid, None) if rid is not None else None
|
||||
if future is None or future.done():
|
||||
return
|
||||
if message.get("ok"):
|
||||
self._merge_log(message.get("log") or [])
|
||||
future.set_result(
|
||||
{
|
||||
"ok": True,
|
||||
"message": message.get("message", "完成"),
|
||||
**(message.get("result") or {}),
|
||||
"state": message.get("state"),
|
||||
"snapshot": message.get("snapshot"),
|
||||
"log": message.get("log"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
future.set_exception(
|
||||
GameError(message.get("error", "error"), message.get("message", "操作失败"))
|
||||
)
|
||||
return
|
||||
|
||||
if kind == "day":
|
||||
self._merge_log(
|
||||
[
|
||||
@@ -704,36 +645,13 @@ class RemoteFarm:
|
||||
|
||||
if kind == "error":
|
||||
self.last_error = message.get("message", "")
|
||||
self.stats["errors"] += 1
|
||||
return
|
||||
|
||||
def _ws_send(self, payload: dict[str, Any]) -> None:
|
||||
"""把消息发到 WS 线程的事件循环里(跨线程投递)。"""
|
||||
socket = getattr(self, "_ws_socket", None)
|
||||
loop = getattr(self, "_ws_loop", None)
|
||||
if socket is None or loop is None:
|
||||
raise GameError("ws_closed", "WebSocket 尚未连接")
|
||||
asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop)
|
||||
|
||||
def _ws_action(
|
||||
self, action: str, params: dict[str, Any], timeout: float | None = None
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._req_id += 1
|
||||
rid = self._req_id
|
||||
future: Future = Future()
|
||||
self._pending[rid] = future
|
||||
try:
|
||||
self._ws_send({"type": "action", "id": rid, "action": action, "params": params})
|
||||
except GameError:
|
||||
with self._lock:
|
||||
self._pending.pop(rid, None)
|
||||
raise
|
||||
try:
|
||||
return future.result(timeout=timeout or self.timeout)
|
||||
except FutureTimeout:
|
||||
with self._lock:
|
||||
self._pending.pop(rid, None)
|
||||
# 超时是正常控制流:FutureTimeout 是内部实现细节,不必挂到异常链上
|
||||
raise GameError("timeout", "服务器没有在超时时间内回应") from None
|
||||
if kind == "bye":
|
||||
# 服务器主动收尾(停机):记下原因,让上层提示"服务器已停止"
|
||||
self.connected = False
|
||||
self.last_error = message.get("message", "服务器已停机")
|
||||
|
||||
|
||||
class RemoteBotClient(RemoteFarm):
|
||||
|
||||
Reference in new issue
Block a user