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

+232 -50
View File
@@ -1,10 +1,21 @@
"""跨平台非阻塞键盘读取。
Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``,
两端统一返回规范化的按键名,TUI 层无需关心平台差异。
两端统一返回**同一套小写**的按键名,TUI 层无需关心平台差异。
按键名约定(重要)
------------------
具名键一律**小写**,见 :data:`NAMED_KEYS`:``up`` / ``down`` / ``left`` /
``right`` / ``space`` / ``tab`` / ``esc`` / ``enter`` / ``backspace`` /
``home`` / ``end`` / ``delete`` / ``ctrl_c``;普通可打印字符返回其自身的
小写形式(例如 ``"p"``)。
这条约定是硬性的:两条平台路径必须返回**完全一样**的名字。历史上这里
Windows 返回大写(``"UP"``)、TUI 里写的是小写(``"up"``),于是方向键、
空格、Tab、ESC 全都静默失效,而且只在某个平台上才看得出来。
在非交互式环境(管道、重定向)下 ``available`` 为 False,
TUI 会退化为无输入的只读模式。
TUI 会退化为无输入的只读模式,并把 :attr:`KeyReader.reason` 显示给玩家。
"""
from __future__ import annotations
@@ -12,30 +23,95 @@ from __future__ import annotations
import importlib.util
import os
import sys
import time
IS_WINDOWS = os.name == "nt"
# Windows 下的扩展键(以 \x00 / \xe0 开头)
# ---------------------------------------------------------------------- 具名键
# 两端共用的规范化名字。全小写,避免"大写产出、小写消费"这类靠肉眼发现的错配。
CTRL_C = "ctrl_c"
ENTER = "enter"
TAB = "tab"
BACKSPACE = "backspace"
ESC = "esc"
SPACE = "space"
UP = "up"
DOWN = "down"
LEFT = "left"
RIGHT = "right"
HOME = "home"
END = "end"
DELETE = "delete"
NAMED_KEYS = frozenset(
{
CTRL_C,
ENTER,
TAB,
BACKSPACE,
ESC,
SPACE,
UP,
DOWN,
LEFT,
RIGHT,
HOME,
END,
DELETE,
}
)
# Windows 扩展键:第一次 getwch() 拿到 \x00 / \xe0,第二次才是真正的键码
_WIN_EXTENDED = {
"H": "UP",
"P": "DOWN",
"K": "LEFT",
"M": "RIGHT",
"G": "HOME",
"O": "END",
"S": "DELETE",
"H": UP,
"P": DOWN,
"K": LEFT,
"M": RIGHT,
"G": HOME,
"O": END,
"S": DELETE,
}
# Windows 控制字符
_WIN_CTRL = {
"\x03": "CTRL_C",
"\r": "ENTER",
"\n": "ENTER",
"\t": "TAB",
"\x08": "BACKSPACE",
"\x1b": "ESC",
" ": "SPACE",
"\x03": CTRL_C,
"\r": ENTER,
"\n": ENTER,
"\t": TAB,
"\x08": BACKSPACE,
"\x7f": BACKSPACE,
"\x1b": ESC,
" ": SPACE,
}
# POSIX 控制字符(ESC 单独处理,因为方向键等功能键也是它打头的)
_POSIX_CTRL = {
"\x03": CTRL_C,
"\r": ENTER,
"\n": ENTER,
"\t": TAB,
"\x08": BACKSPACE,
"\x7f": BACKSPACE,
" ": SPACE,
}
# POSIX 转义序列的"参数+终结符"(去掉开头的 ESC [ 或 ESC O):
# 方向键是 ESC [ A~D,Home/End 有两套写法,Delete 是 ESC [ 3 ~
_POSIX_CSI = {
"A": UP,
"B": DOWN,
"C": RIGHT,
"D": LEFT,
"H": HOME,
"F": END,
"1~": HOME,
"3~": DELETE,
"4~": END,
}
EXTENDED_WAIT = 0.02 # 等功能键第二个字节的最长时间(秒)
PENDING_WAIT = 0.02 # 判断"单按 ESC"还是"序列开头"、以及等半个字符的时间
class KeyReader:
"""把终端的原始字节流转换成按键名。"""
@@ -43,12 +119,16 @@ class KeyReader:
def __init__(self) -> None:
self._fd: int | None = None
self._saved_term = None
self._raw = b"" # POSIX:上一次没解析完的字节(半个转义序列、半个汉字)
self.reason = "" # 读不到键盘时的解释,用于提示玩家
self.available = False
# 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
stdin = sys.stdin
if stdin is None or not stdin.isatty():
self.available = False
self.reason = "标准输入不是终端(管道或重定向)"
return
self.available = True
@@ -56,6 +136,17 @@ class KeyReader:
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
if importlib.util.find_spec("msvcrt") is None:
self.available = False
self.reason = "找不到 msvcrt 模块"
return
try:
import msvcrt
# 探一次:不是真的 Win32 控制台(例如 Git Bash 的 mintty)这里会报错。
# 与其让玩家对着"按键没反应"发呆,不如把原因摆出来。
msvcrt.kbhit()
except Exception:
self.available = False
self.reason = "当前终端不是 Win32 控制台,msvcrt 读不到按键"
return
try:
@@ -71,6 +162,7 @@ class KeyReader:
except Exception:
self.available = False
self._saved_term = None
self.reason = "无法把终端切到 cbreak 模式"
# ------------------------------------------------------------------ 读取
def poll(self) -> str | None:
@@ -79,6 +171,7 @@ class KeyReader:
return None
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
# ---------------------------------------------------------------- Windows
def _poll_windows(self) -> str | None:
import msvcrt
@@ -87,56 +180,144 @@ class KeyReader:
char = msvcrt.getwch()
if char in ("\x00", "\xe0"):
if not msvcrt.kbhit():
return None
return _WIN_EXTENDED.get(msvcrt.getwch().upper())
if char in _WIN_CTRL:
return _WIN_CTRL[char]
return char.lower()
code = self._win_extended_code()
# 认不出来的功能键就丢掉,不要退化成 ESC 之类的错误动作
return _WIN_EXTENDED.get(code) if code else None
named = _WIN_CTRL.get(char)
return named if named else char.lower()
@staticmethod
def _win_extended_code() -> str | None:
"""取扩展键的第二个字节;它通常紧跟其后,但偶尔会差几毫秒。"""
import msvcrt
deadline = time.monotonic() + EXTENDED_WAIT
while True:
if msvcrt.kbhit():
return msvcrt.getwch().upper()
if time.monotonic() >= deadline:
return None
time.sleep(0.005)
# ---------------------------------------------------------------- POSIX 侧
def _poll_posix(self) -> str | None:
import select
if self._fd is None:
return None
try:
ready, _, _ = select.select([self._fd], [], [], 0)
except (OSError, ValueError):
return None
if not ready:
if not self._raw:
try:
ready, _, _ = select.select([self._fd], [], [], 0)
except (OSError, ValueError):
return None
if not ready:
return None
if self._read_fd() is None:
return None
return self._parse_posix()
def _read_fd(self) -> bytes | None:
"""把输入追加进 :attr:`_raw`。读不到返回 None。"""
if self._fd is None:
return None
try:
data = os.read(self._fd, 8)
data = os.read(self._fd, 64)
except OSError:
return None
if not data:
return None
# 一次读到的可能是好几个按键:全部留在缓冲区里逐个返回,
# 不能像老实现那样只取第一个字符、把其余的悄悄丢掉。
self._raw += data
return data
text = data.decode("utf-8", errors="ignore")
if not text:
def _read_ready(self, timeout: float) -> bool:
"""等一小会儿再读一次,用于判断不完整的转义序列与半个字符。"""
import select
if self._fd is None:
return False
try:
ready, _, _ = select.select([self._fd], [], [], timeout)
except (OSError, ValueError):
return False
return bool(ready) and self._read_fd() is not None
def _parse_posix(self) -> str | None:
raw = self._raw
if not raw:
return None
# 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D
if text.startswith("\x1b["):
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
if text.startswith("\x1bO"):
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
if raw[0:1] == b"\x1b":
seq = self._read_escape_sequence()
if seq is None:
return None # 还没读全,等下一轮 poll
if seq == b"":
return ESC # 就是单按 ESC
return _POSIX_CSI.get(seq.decode("latin-1"))
char = text[0]
mapping = {
"\x03": "CTRL_C",
"\r": "ENTER",
"\n": "ENTER",
"\t": "TAB",
"\x7f": "BACKSPACE",
"\x08": "BACKSPACE",
"\x1b": "ESC",
" ": "SPACE",
}
if char in mapping:
return mapping[char]
first = raw[0]
if first < 0x80:
self._raw = raw[1:]
char = chr(first)
named = _POSIX_CTRL.get(char)
return named if named else char.lower()
# 多字节 UTF-8:先凑齐整个字符再解码,免得半个汉字被解成乱码
length = 2 if first < 0xE0 else 3 if first < 0xF0 else 4
if len(raw) < length:
self._read_ready(PENDING_WAIT)
raw = self._raw
if len(raw) < length:
return None # 等下一轮 poll 再来
char = raw[:length].decode("utf-8", errors="replace")
self._raw = raw[length:]
return char.lower()
def _read_escape_sequence(self) -> bytes | None:
"""解析 ESC 打头的序列。
单按 ESC 返回 ``b""``,还没读全返回 ``None``,否则返回去掉
``ESC [`` / ``ESC O`` 之后的"参数+终结符"(例如 ``b"A"``、``b"3~"``)。
"""
# 最多试两次:第一次先看看能不能直接判完,不行就等一小会儿再试。
for attempt in (0, 1):
raw = self._raw
if len(raw) == 1:
# 单按 ESC 与序列的第一个字节长得一样,有歧义
if attempt:
self._raw = b""
return b""
self._read_ready(PENDING_WAIT)
continue
if raw[1:2] not in (b"[", b"O"):
# 不是标准转义序列:吃掉 ESC,剩下的字节留给下一轮
self._raw = raw[1:]
return b""
end = self._csi_end(raw)
if end is None:
if attempt:
self._raw = b""
return b"" # 等不到了,当单按 ESC 处理,别把按键吞掉
self._read_ready(PENDING_WAIT)
continue
self._raw = raw[end + 1 :]
return raw[2 : end + 1]
return None
@staticmethod
def _csi_end(raw: bytes) -> int | None:
"""CSI 序列的终结符下标(0x40~0x7E);还没读到就返回 None。"""
index = 2
while index < len(raw):
if 0x40 <= raw[index] <= 0x7E:
return index
index += 1
return None
# ------------------------------------------------------------------ 清理
def close(self) -> None:
if self._fd is not None and self._saved_term is not None:
@@ -148,3 +329,4 @@ class KeyReader:
pass
self._fd = None
self._saved_term = None
self._raw = b""