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
+87
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
## 是什么
|
||||
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
|
||||
TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(服务端不内置 AI)。
|
||||
TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
|
||||
|
||||
## 运行方式(v2.0 起由 uv 统一管理)
|
||||
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
|
||||
@@ -10,28 +10,104 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
|
||||
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 <http://127.0.0.1:8765/>,文档 /docs
|
||||
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
|
||||
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | batch | state --json | watch | bot`
|
||||
- 测试:`uv run python -m unittest discover -s tests`(97 项)
|
||||
- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。
|
||||
但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、
|
||||
不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 ——
|
||||
于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。
|
||||
代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。
|
||||
覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) /
|
||||
`test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。
|
||||
- 质量门禁(改动后都应跑一遍):
|
||||
`uv run ruff check .` / `uv run ruff format .` / `uv run pyright`
|
||||
`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
|
||||
(2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`)
|
||||
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
|
||||
README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。
|
||||
|
||||
## 玩法节奏(2026-09-19 起)
|
||||
- **一天 40 分钟**:`config.DEFAULT_TICK_SECONDS = 2400`,`MAX_TICK_SECONDS = 86400`
|
||||
(旧上限 600 秒会把 40 分钟悄悄夹回去,已放宽)。`--tick` 可调,调试用 `--tick 3`。
|
||||
- **没有「手动推进一天」**:`engine.sleep()` / `SLEEP_COOLDOWN` / `/api/sleep` /
|
||||
TUI 的 `[N]休息` / 网页版按钮**全部移除**,时间只能自然流动。
|
||||
机器人改为 `RuleBot.wait_for_next_day(timeout)`,CLI `bot --wait` 控制等多久(0 = 直接收工)。
|
||||
再加任何“快进一天”的入口前先想清楚:它会让慢时钟彻底失效。
|
||||
- 时长显示统一走 `farm.models.format_duration()`(2400 秒 → `40:00`),
|
||||
TUI 头部倒计时 / `main.py state` / 网页版(JS 里同名函数)都用它,别再写 `{x}s`。
|
||||
- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s)
|
||||
之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。
|
||||
|
||||
## 架构约定(改动时必须遵守)
|
||||
- **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
|
||||
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
|
||||
- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义,
|
||||
这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。
|
||||
这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。两条容易踩的:
|
||||
`StateView.apply()` 里"带 `plots` = 完整快照(整体替换)、不带 = 精简状态(只合并字段)"——
|
||||
搞反了播一次种就清空整片农田;`RemoteFarm._normalize()` 必须**先补 scheme 再削尾部斜杠**,
|
||||
顺序反了 `"http://"` 会被削成 `"http:"`,地址没有主机名 → 请求溜到系统代理 → 报一个
|
||||
和真实原因无关的 502。空视图(`StateView()`)也要预置 `plots`/`animals` 为空列表,
|
||||
否则连不上服务器时读 `state.plots` 直接 AttributeError。
|
||||
- **按键名统一小写**:`farm/keys.py` 是按键名的唯一来源,具名键全小写
|
||||
(`UP`/`SPACE`/`TAB`/`ESC`/`CTRL_C` … 常量值即小写字符串),Windows 与 POSIX
|
||||
两条路径必须返回**完全一样**的名字。`tui.py` 一律用 `keys.XXX` 引用,**不要手写
|
||||
字符串字面量**:历史 bug 就是 Windows 侧产大写 `"UP"`、TUI 里写小写 `"up"`,
|
||||
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
|
||||
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
|
||||
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
|
||||
- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟;
|
||||
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
|
||||
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
|
||||
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
|
||||
- **实时通道**:WebSocket(`/ws`)是唯一实时通道,推送节流 250ms,消息带自增 `seq`。
|
||||
写操作优先走 WS(带 `id` 回执与 `snapshot`),只有 ws_closed/timeout/network
|
||||
才退回 HTTP 重试;**业务错误必须原样抛出,不能重试**。
|
||||
- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送
|
||||
(welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发
|
||||
`: keep-alive` 注释心跳。**写操作一律走 HTTP POST /api/actions**,不再有 WS 回执。
|
||||
停机必须 `hub.close_all()`(否则长连接拖住 ASGI 服务器的优雅退出)+ 配置
|
||||
`timeout_graceful_shutdown` 兜底;客户端 stop() 要 shutdown 底层 socket,
|
||||
光 close() 会等读线程的锁(最长等到下一次心跳)。
|
||||
- **ASGI 服务器(v2.3 起为 uvicorn + zttp 的 HTTP/2)**:`farm/server/__main__.py` 用
|
||||
`uvicorn.Config(http="zttp", http2=True)` + `uvicorn.Server`。
|
||||
**同一端口同时说 HTTP/1.1 与 HTTP/2**(TLS 走 ALPN;明文走 h2c prior-knowledge;
|
||||
分流由 `AutoZttpProtocol` 完成;浏览器只在 https 下用 h2)。
|
||||
关键:uvicorn 0.53 **自己不再内置 h2**,HTTP/2 必须装 `zttp` 并显式选 `http="zttp"`,
|
||||
否则 `Config.load()` 直接 raise(错误信息里就写着 pip install zttp)。
|
||||
两条停机路径汇到同一处:`POST /api/admin/shutdown` 与 Ctrl+C / SIGTERM / SIGBREAK
|
||||
都调 `handle.request_stop()`。信号接管靠**子类覆盖 `Server.handle_exit`**
|
||||
(0.53 的机制是 `capture_signals()` + `handle_exit`,没有 install_signal_handlers 可覆盖);
|
||||
覆盖后不调 super()、也不写 `_captured_signals`,退出时就不会重放信号。第二次信号 = 强制退出。
|
||||
`farm/server/app.py` 的 `as_asgi()` 是给 pyright 的类型桥:uvicorn 的
|
||||
`uvicorn._types.Scope` 与 `starlette.types.Scope` 两个 TypedDict 互不兼容。
|
||||
另:uvicorn 的启动顺序**也是**先 lifespan、后 bind 端口(旧注释曾写成"相反",已更正)。
|
||||
- **端口自检**:uvicorn 会设 `SO_REUSEADDR`,Windows 上第二个实例能
|
||||
"绑上"已被监听的端口并抢走新连接,所以入口启动前用 `daemon.port_busy()`
|
||||
(判断"能不能连上",而不是"能不能 bind")自检,被占用就退 3 并提示换端口。
|
||||
|
||||
## 序列化与性能约定(2026-09-19 优化后固化)
|
||||
- **轻量路径不许做完整序列化**:只要标量时(动作回执 / `session.summary()` / `app.brief()` /
|
||||
排行榜单行)一律走 `engine.brief_source()`,**不要**调 `engine.snapshot()` 或
|
||||
`state.to_dict()` 再挑字段——那是整局序列化(16 地 + 8 牲畜约 45µs,轻量路径只需 15µs)。
|
||||
完整快照只在 `GET /api/state`、SSE `state` 消息、`POST /api/actions?full=1` 用。
|
||||
`app.py` 的 `BRIEF_KEYS` / `BRIEF_TODO_KEYS` 是这两类字段的白名单,加字段要同步。
|
||||
- `GameState.to_dict()` 里 `net_worth` **只算一次**再复用(`net_worth` / `goal_index` /
|
||||
`next_goal` 三处都靠它);`net_worth` 是每次访问都重算的属性(约 5µs),别在循环里反复读。
|
||||
「下一个目标」用模块级 `models.next_goal_after(index)`,不要再加 `next_goal` property。
|
||||
- **`tools/bench.py` 是性能守卫**,不只是一次性脚本:它给 `to_dict` / `snapshot` /
|
||||
`brief_source` / `net_worth` 装了调用计数器,谁把轻量路径改回 `snapshot()` 会立刻计数变红。
|
||||
它用 ASGI 直调(不走 httpx),**不跑 FastAPI lifespan** —— 动了 lifespan 要用
|
||||
`tests/test_server.py::LiveServerTestCase`(真 in-process uvicorn)复验。
|
||||
- **`farm/server/app.py` 的结构**:`create_app()` 只做「建 `_Services` + 依次调
|
||||
`_register_*`」,所有路由/生命周期都住在模块级 `_register_*` 函数里(曾经是一个 475 行的
|
||||
内嵌闭包大函数)。加路由就加/改对应的 `_register_*`,别把逻辑塞回 `create_app`。
|
||||
- **函数长度参考**:>60 行的函数应视为信号(重构前 15 个,现在 8 个,最长
|
||||
`__main__._run_foreground` 131 行,其中大半是"必须内嵌的 uvicorn 停机子类")。
|
||||
- **重复的等级门槛**统一走 `engine._is_locked/_require_level/_newly_unlocked`,
|
||||
不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。
|
||||
|
||||
## 环境禁忌(本机特有)
|
||||
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
|
||||
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
|
||||
一律"先读完 → 关掉句柄 → 再删"。
|
||||
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep` 缺失),**不要用管道**,会整条命令失败;
|
||||
查看/删除文件一律写 Python 脚本。
|
||||
查看/删除文件一律写 Python 脚本。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1`
|
||||
必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。
|
||||
PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。
|
||||
- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的
|
||||
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
|
||||
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
|
||||
@@ -52,8 +128,8 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
|
||||
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
|
||||
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
|
||||
- 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。
|
||||
- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
|
||||
- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright`
|
||||
|
||||
## 相关文档
|
||||
- 项目入口文档:`README.md`(接口表、WS 协议、边界处理、设计取舍全在里面)
|
||||
- 当天工作记录:`.workbuddy/memory/2026-09-18.md`
|
||||
- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面)
|
||||
- 当天工作记录:`.workbuddy/memory/2026-09-19.md`
|
||||
Reference in new issue
Block a user