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

+215
View File
@@ -0,0 +1,215 @@
## 2026-09-19 代码结构审查(只读,未改代码)
- 全量读了 engine/models/actions/app/session/hub/client/storage/bot,跑通 ruff/format/pyright/97 测试,全绿。
- 结论:分层健康,无需返工。发现 5 个可优化点:
1. engine.Scheduler 与 server.FarmClock 重复实现同一套 tick 决策(暂停顺延+deadline);
2. actions.py 表内注册的 save/reset 在服务端被 execute_action 拦截成死代码,未来绕过即触发默认路径覆盖存档的隐患;
3. app.brief() 手工挑 26 个字段,建议改 BRIEF_KEYS 常量派生;
4. hub.Publisher 的 except Exception: pass 静默吞异常(违反项目 contextlib.suppress 约定且无痕迹);
5. client.pause() 手工 merge paused 疑似冗余(回执 brief 已带 paused 且 _action 已 apply)。
- 未动手改,等用户决定优先级。
## 2026-09-19 结构审查 5 项优化已全部落地(+88/-76,7 个文件)
1. engine.py 新增 tick_decision() 纯函数,Scheduler 与 FarmClock 共用(原两处重复的暂停顺延+deadline 逻辑合并)。
2. actions.py 移除 save/reset 表项(服务端死代码隐患),注释说明存档类动作只能走 app.execute_action 会话拦截;/api/save、/api/reset 便捷路由随之消失,POST /api/actions {action:save} 仍可用。
3. app.py brief() 改为 BRIEF_KEYS 常量派生。
4. hub.Publisher 吞异常改为 errors 计数,/api/health 新增 publish_errors 字段。
5. client.pause() 删除冗余手工 merge,收敛为纯转发。
- README 动作清单与便捷入口数(13→11)同步更新。
- 门禁:ruff format/check、pyright、97 项测试全绿。
- 教训:对同一文件连续 Edit 时 old_string/new_string 写反了两次(一次添加了重复定义、一次误删 def 行),靠重读文件恢复。批量改动应先用幂等补丁脚本。
## 2026-09-19 实时通道从 WebSocket 整体迁移到 SSE
- 决策过程:用户先问「HTTP 能双向吗」「HTTP/2 也不行吗」,讲清 Server Push 已废弃、
H2 双向流等于 gRPC(本项目已否决)后,用户拍板「换 SSE,删掉 WebSocket」。
- 服务端:hub.py 改为 SSE 订阅池(新增 sse_frame / sse_heartbeat / CLOSE_MESSAGE /
close_all);app.py 用 GET /api/stream(StreamingResponse)替换 /ws,删掉全部 WS 分支;
生成器每秒空转一次以便发现停机、按 15s 发心跳注释。
- 客户端:client.py 删掉 asyncio/websockets/Future 回执机制,改为 urllib 流式读 SSE
(mode: http/sse/poll),写操作统一 HTTP,新增 _underlying_socket() 用于立即断流。
- 网页版 webui.py 改 EventSource + fetch POST;tools/ws_demo.py 换成 tools/sse_demo.py。
- 测试:WS 用例改写为 SSE,新增 test_sse_stream_speaks_the_protocol(裸流验协议)
与 test_writes_go_over_http,98 项全绿,耗时回到 5.2s。
- 踩坑(重要):SSE 长连接会拖死优雅停机,三处一起修才干净:
1) 停机路径 hub.close_all() 发 bye 让生成器自己 break;
2) uvicorn Config 加 timeout_graceful_shutdown=5 兜底;
3) 客户端 stop() 必须 shutdown 底层 socket——直接 response.close() 会等读线程的锁,
最长卡到下一次心跳(15s)。实测:带长连接停机 0.2s 完成。
- 文档:README 第 6 节重写为 SSE(含 curl/urllib/EventSource 示例)、架构图与结构表同步,
pyproject 描述与关键词更新,main.py/ai_player.py/__main__.py 帮助文案更新。
- 新增 env 依赖说明:uvicorn[standard] 仍会捎带 websockets,但项目已不再使用。
## 2026-09-19 ASGI 服务器从 uvicorn 换成 Hypercorn(拿到 HTTP/2)
- 依赖:`uv add hypercorn` / `uv remove uvicorn`(hypercorn 0.18 自带 h11/h2/wsproto),
requirements.txt 重新导出,pyproject 依赖注释改写。
- `__main__.py`:`hypercorn.asyncio.serve` + Config(bind/loglevel/accesslog/
graceful_timeout=5,语义等同 uvicorn 的 timeout_graceful_shutdown);shutdown_trigger
用 asyncio.Event,由 `handle.on_stop` 置位 —— 于是 POST /api/admin/shutdown 与
Ctrl+C/SIGTERM/SIGBREAK 走完全相同的收尾路径;第二次信号强制退出(os._exit(130))。
- `app.py` 新增 `as_asgi()`:Hypercorn 的 `hypercorn.typing.Scope` 与
`starlette.types.Scope` 两个 TypedDict 互不兼容,pyright 会在 serve() 处报错,用返回
Any 的桥函数收口(一处注释说明)。
- **踩坑 1(真 bug,早于本次改动)**:`clear_pid_file` 里"文件还开着就 os.remove",
Windows 抛 PermissionError(WinError 32),又被 `except OSError` 吞掉 →
每次正常退出都留下 stale pid 文件。改为先读完关句柄再删,并补 3 条回归测试。
- **踩坑 2**:Windows 上 SO_REUSEADDR 让第二个实例能绑上已监听端口(还会抢走新连接),
Hypercorn 因此不会报"端口占用"。新增 `daemon.port_busy(host, port)` 公用函数
("能不能连上"而不是"能不能 bind"),入口启动前自检,占用则退 3 并提示换端口;
`DaemonManager.port_busy()` 改为委托它,并补了测试。
- 测试:HAS_UVICORN→HAS_HYPERCORN;TestLiveServer 用 hypercorn serve 起后台线程、
靠 shutdown_trigger 收尾;新增 TestServerEntry(4 项)与 test_port_conflict_is_refused,
共 103 项 / 5.2s。
- 文档:README 第 6 节补 HTTP/2 说明、第 8 节补"lifespan 早于 bind"的 pid 细节、
第 12 节加"为什么是 Hypercorn(而不是 uvicorn)";测试清单按实际类重新计数。
顺手补掉上一轮 SSE 迁移漏掉的两处文案(tui.py 帮助页、farm/__init__.py 分层说明)。
- 实测(真实子进程):h2c 用 h2 库裸说协议 200;h2c 上读 SSE 正常;写操作→推送
0.08~0.15s;挂着长连接停机 0.08s 且收到 bye;SIGBREAK 停机 0.07s;
daemon start/status/stop 全通(stop 1.12s);端口冲突退 3 并给出提示。
## 2026-09-19 ASGI 服务器换回 uvicorn(HTTP/2 由 zttp 提供)
- 决策:用户要求「换回 uvicorn 并且安装 h2 支持并启用」。关键发现:uvicorn 0.53 自己不再
内置 h2,它的 HTTP/2 实现建立在 `zttp` 包上(`uvicorn/protocols/http/zttp_h2_impl.py`;
另有 `auto_zttp_impl.AutoZttpProtocol` 按 ALPN / h2c 前导字节自动分流 HTTP/1.1 与 h2)。
启用方式 `Config(app, http="zttp", http2=True)`;没装 zttp 时 `Config.load()` 直接 raise。
- 依赖:`uv remove hypercorn`(连带 h2/hpack/hyperframe/wsproto/priority);
`uv add "uvicorn[standard]" zttp`——HTTP/2 能力保留,[standard] 仍捎带 websockets(未使用)。
- `__main__.py`:uvicorn.Server + `Config(http="zttp", http2=True, timeout_graceful_shutdown=5)`;
信号接管改为**子类覆盖 `Server.handle_exit`**(0.53 没有 install_signal_handlers),
不调 super()、不写 `_captured_signals`(避免退出时重放信号),第二次信号 `os._exit(130)`。
服务器子类写在 `main()` 内部,因为 uvicorn 是延迟导入的(离线玩法不该被迫装它)。
- 修正一处历史错误注释:uvicorn 0.53 的启动顺序**也是**先 lifespan 再 bind 端口。
- 测试:`HAS_HYPERCORN` → `HAS_UVICORN`;TestLiveServer 用 `uvicorn.Server.run()` 起后台线程
(非主线程 → capture_signals 自动跳过,不会劫持测试进程的信号),停机走 `request_stop()`。
顺带删掉测试里已无用的 `import asyncio`。103 项全绿(6.0s)。
- 实测(真子进程,非 mock):h2c `GET /api/state` → `:status 200`(用 h2 库裸说协议,
本机 curl 没编进 HTTP/2);同一条 h2c 连接上读 SSE 收到 welcome;HTTP/1.1 写操作 →
推送到达 0.10s;**挂着 2 条长连接停机 0.52s**(长连接收到 bye);SIGBREAK 停机 0.49s;
端口冲突退 3 并提示换端口;守护进程 start/status/stop 全通;pid 文件每次都清理干净。
- 文档:README 第 6 节与第 12 节重写(uvicorn + zttp 的启用法,附配置片段)、结构表、
测试清单同步;pyproject 依赖注释说明为什么需要 zttp;requirements.txt 重新导出。
## 2026-09-19 修复 TUI 键盘监听(方向键/空格/Tab/ESC 全失效)
- 症状:只有字母键和数字键有反应,方向键、空格(暂停)、Tab(切牲畜)、ESC(关菜单)全都没用。
- 根因:`farm/keys.py` 的具名键返回**大写**(`"UP"`/`"SPACE"`/`"TAB"`/`"ESC"`),
而 `farm/tui.py` 的处理表里写的是**小写**(`"up"`/`" "`/`"tab"`/`"esc"`),
`handler.get(key)` 永远取不到 → 静默无动作。字母键因为 `char.lower()` 恰好对得上才没事。
- 修法(不是改一处字符串,而是把约定固化下来):
1. `keys.py` 定一套小写具名键常量 + `NAMED_KEYS`,两条平台路径都返回这套名字;
2. `tui.py` 全部改用 `keys.XXX` 引用,pyright 能直接查出拼写错误;删掉 `"tab\t"` 这个历史 hack;
3. 顺手修掉 POSIX 路径的老毛病:一次 `os.read` 读到多个字节时只取第一个、其余静默丢弃
(快打/粘贴会丢键);现在带缓冲区逐个返回,并能正确处理被拆成两次读到的转义序列与半个汉字;
补上 `ESC [ 3 ~`(Delete)等序列;单按 ESC 与序列开头用 20ms 等待区分。
4. 新增 `tests/test_tui_keys.py`(19 项):逐个具名键驱动真实 TuiApp 做**行为**校验,
外加"两端映射表必须落在同一套名字里"的约定校验——正是它抓出了 POSIX 缺 Delete 的不一致。
5. 读不到键盘时(管道/非 Win32 控制台,如 Git Bash 的 mintty)TUI 底部直接显示原因,
不再让玩家对着界面干等;预览工具用 `show_keyboard_notice=False` 保持按键提示。
- 门禁:ruff / pyright 零报错,测试 103 → **122 项**全过。
## 2026-09-19 一天改成 40 分钟 + 移除「休息」动作(纯放置)
- 用户反馈两条:① 键盘监听不对(方向键/空格/Tab/ESC 全失效);② 15 秒一天太快。
问清目标后拍板:**一天 40 分钟**、**「休息」直接去掉**(只剩时间自然流动)。
- 节奏:`DEFAULT_TICK_SECONDS` 15 → 2400;`MAX_TICK_SECONDS` 600 → 86400
(旧上限会把 40 分钟夹回 10 分钟,这是必须先拆的硬门槛)。
- 移除 sleep 的全部触点:`engine.sleep()`、`config.SLEEP_COOLDOWN`、`_last_sleep`、
`client.RemoteFarm.sleep()`、`server/actions.py` 的注册、TUI `[N]休息` 与帮助文案、
网页版按钮与文本清单、`app.rules_text()` 给 AI 的说明、README、`main.py` 提示、
`tools/sse_demo.py`(改成挑 welcome 里的第一个合法动作,不再写死动作名)。
- 机器人:`rest()` → `wait_for_next_day(timeout)`,`run()` 新增 `wait_seconds`(默认 0 = 不干等,
直接收工);顺带删掉再也不会出现的 cooldown 特例。CLI 加 `bot --wait`。
- 显示:新增 `farm.models.format_duration()`,40 分钟显示成 `40:00` 而不是 `2399.6s`
(TUI 头部 / `main.py state` / 网页版 JS 同名函数)。
- 顺手修:`FarmSession.__init__` 立刻设 `next_tick_at`,消除新建牧场后 ≤0.2 秒内
倒计时为 None(客户端会误显示"已暂停/手动模式")的空窗。
- 测试:删 `test_sleep_cooldown`,加 tick 夹取/默认值/`sleep` 已移除、`format_duration`
分档、`tests/test_bot.py`(等待策略)。103 → **131 项**全绿,ruff/pyright 零报错。
- 端到端实测(真服务器 + 真 CLI):动作表只剩 10 个(无 sleep)、`POST sleep` 返回
`unknown_action`、`seconds_to_tick=2400.0`、`main.py state` 显示「39:59 后进入下一天」。
## 2026-09-19 删除全部测试套件(tests/ 与所有引用一并清理)
- 用户要求「删除全部 test」;确认后选:删目录 + 清理全部引用、**不留备份**。
- 删除 `tests/`:test_engine / test_server / test_tui_keys / test_bot(共 131 项)+ `tests/_tmp/`。
- 引用清理(一次性幂等补丁脚本,跑完即删):
pyproject 的 ruff `src`、per-file-ignores `"tests/*"`、pyright `include`/`exclude`;
`.gitignore` 的 `tests/_tmp/`;`main.py` 帮助里的测试命令;`farm/keys.py` docstring 的引用;
README 目录树的三行 + 整节「10. 测试」(后续章节 11/12 顺延为 10/11)。
- 依赖:`uv remove --dev httpx`(连带 httpcore / certifi)——它只是 FastAPI TestClient 的底座,
没有测试就没人用它;uv.lock 同步。requirements.txt 不动(本来就只导出运行依赖)。
- 门禁:ruff check / format 通过,pyright 0 错误(25 个文件)。
- 代价与风险:未跟踪的 test_tui_keys.py / test_bot.py 就此永久丢失(未提交,用户明确选择不备份);
键盘约定的保障从"行为测试"退化为"改 keys.py 时人工核对 tui.py 的引用"。
README 第 307 行提到 httpx 是 HTTP/2 客户端写法示例,与依赖无关,保留。
## 2026-09-19 重建测试套件(500 项)+ 修两处 client 隐患
- 用户要求「完成测试代码」;确认后选:**完整重建**覆盖,但**只写测试文件**
(不恢复上一轮剥掉的工程配置:tests 仍不进 ruff `src` / pyright `include` / per-file-ignores,
`httpx` 保持移除)。
- 新增 6 个文件、**500 项**:test_engine 110 / test_actions 35 / test_server 131(skip 35) /
test_tui_keys 75 / test_bot 36 / test_client 113。
- skip-guard 模式:`try: import fastapi/httpx/uvicorn ... HAS_X = True except: False` +
`@unittest.skipUnless`。没装 httpx → `TestClient` 与真机 `LiveServer` 那 35 项自动跳过,
其余 465 项照跑;所以 `uv run python -m unittest discover -s tests` 是**真绿**而不是靠跳过伪装。
- `test_server.LiveServerTestCase` 起真 in-process uvicorn(`http="zttp", http2=True`,绑自由端口),
用 `RemoteFarm` + 裸 SSE 读取做端到端;`test_client.HttpTransportTestCase` 起**纯标准库**
`http.server` 回环桩(不依赖第三方),顺带证明 `net.open_url` 的回环直连真的生效。
- 顺手修 `farm/client.py` 两处(是测试逼出来的真隐患,不是测试写错):
1. `_normalize()` 原来**先 `rstrip("/")` 再补 scheme**,于是 `"http://"` → `"http:"` →
再拼成 `"http://http:"`;地址没有主机名 → `is_loopback(None) == False` → 请求走系统代理 →
报一个和真实原因无关的 502。改成**先补 scheme 再削尾部斜杠**,畸形端点
(`''` / `' '` / `'//'` / `'http://'`)统一回落 `DEFAULT_ENDPOINT`。
2. `StateView.__init__` 预置 `plots` / `animals` 为空列表:原先 `apply({})` 提前返回,
空视图**根本没有这两个属性**,`autostart=False` 或服务器连不上时读 `state.plots`
直接 AttributeError(本该显示一片空农田)。
- 写测试踩到的坑(记下来省下次):
- **bytes 字面量不能含非 ASCII**:`b"<html>不是 JSON</html>"` 直接 SyntaxError,
必须 `"...".encode()`(ruff 还会顺手提示 UP012 去掉多余的 `"utf-8"`)。
- `OpenerDirector.add_handler` **只在"至少挂上一个钩子"时才把 handler 记进 `handlers`**;
`ProxyHandler({})` 一个 `*_open` 都不注册 → 它自己反而不在 `handlers` 里。
这**正是**回环直连生效的机制,不能想当然断言"handlers 里应该有个 ProxyHandler"。
- 门禁:`ruff format .` 只动了 6 个测试文件(源码本来就干净)→ 但 `ruff check .` 一开始报
9 条(全在 `tests/`,因为没有 per-file-ignores)。既然不加配置,就把**测试文件本身改干净**
(SIM117 合并 with / RUF012 用 `ClassVar` / RUF100 删无效 noqa / UP012 / F401),
现在 `ruff check .` **All checks passed** + pyright **0 errors** + format 31 文件 unchanged。
- 收尾:6 个测试文件全部 LF(CRLF=0)。
## 2026-09-19 代码优化:结构拆分 + 热路径 + 消重(benchmark-first)
- 用户要求「优化代码」,勾选三项:结构拆分 / 性能热路径 / 消除重复逻辑(明确不要"只出报告")。
- **先量后改**:新增 tools/bench.py(ASGI 直调,不走 httpx;warmup + best-of-K),
并给 to_dict / snapshot / brief_source / net_worth 装调用计数器。它同时是**守卫**:
以后谁再把 brief 路径改回 snapshot(),计数会立刻变红。
- 热路径根因:brief()/summary()/动作回执本来只要 ~17 个标量,却调 session.snapshot()
做**整局序列化**;GameState.to_dict 内部还重复算 3 次 net_worth(每次访问都重算属性)。
- 改法:
1. engine.brief_source() —— 只读标量的新方法(不碰 snapshot);session.summary()、
app.brief() 改用它,BRIEF_KEYS + 新增 BRIEF_TODO_KEYS 白名单派生。
2. models.next_goal_after(index) 提为模块级;to_dict 里 net_worth 只算 1 次,复用于
net_worth / goal_index / next_goal(删掉 next_goal property)。
3. 结构:create_app 475 行 → 32 行(抽出 _Services dataclass 持 8 件接线 + 8 个模块级
_register_* 函数,内嵌 lifespan 生成器变成 _Services.lifespan 方法);
main.build_parser 拆 server/client 两组;engine.available_actions 拆
_plot_actions/_animal_actions/_investment_actions;__main__.main 160 → 7 行
(_resolve_save_dir / _start_daemon / _print_startup_banner / _ensure_port_free /
_run_foreground,uvicorn 那段含内嵌 FarmServer 逐字平移)。
4. 消重:engine 的"等级门槛"规则重复 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;app.py SSE 的
notice(text, exclude) 让"来了/走了"共用一条路径。
- 结果(16 地 + 8 牲畜终局,5 轮最快):brief 63.6→15.6µs(-76%)、summary 60.2→14.0µs(-77%)、
leaderboard(32) 1981.7→454.8µs(-78%)、snapshot 57.3→45.0µs(-21%)、
GET /api/world 193.6→132.3µs(-31%)、POST full=1 464.7→392.9µs(-16%)、
GET /api/actions 398.3→335.1µs(-16%)。动作回执的序列化次数 to_dict 2→1、
snapshot 2→1、net_worth 6→1。>60 行的函数 15 个 → 8 个。
- 门禁:ruff format/check 全绿、pyright 0 错误、**500 项测试 OK(skip 35)**。
bench 的 ASGI 直调**不跑 FastAPI lifespan**,所以 lifespan 的活改造用
test_server 里的真 in-process uvicorn(LiveServerTestCase)单独验。
- 临时补丁脚本一律 read_bytes/write_bytes 保 LF,跑完即删(本次 4 个:_splice_app /
_dedup_engine / _dedup_main / _split_entry)。
+87 -11
View File
@@ -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`