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
+232
-50
@@ -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""
|
||||
Reference in new issue
Block a user