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:
snowy-yang committed 2026-09-19 12:55:45 +08:00
1 parent 47dd818360
commit f8e093c98f
36 files changed
+6865 -2204

No files matched your search

+106 -188
View File
@@ -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):