三件事凑成一次不兼容变更: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)。
12 KiB
12 KiB
SimpleFarm 项目长期约定
是什么
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里, TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
运行方式(v2.0 起由 uv 统一管理)
- 单一可信来源是
pyproject.toml,.venv/由 uv 创建;uv.lock要提交(可复现构建依据)。 加/删依赖走uv add/uv remove,不要手工编辑依赖表,也不要再列pip install。 - 所有命令带
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 - 测试套件已重建(2026-09-19):
uv run python -m unittest discover -s tests,500 项。 但 pyproject 里不认它(用户明确选择「只写测试文件」):tests不进 ruffsrc、 不进 pyrightinclude、没有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 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),CLIbot --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不需要区分本地/远程。加动作时两边都要补。两条容易踩的: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()(会写默认路径、覆盖别人的档)。 - 实时通道(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、SSEstate消息、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_goalproperty。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_foreground131 行,其中大半是"必须内嵌的 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 脚本。跑测试也一样: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), 父进程的Popen().pid不可信。 - 写文件必须指定行尾:Windows 上
pathlib.write_text()会把\n转成\r\n, 破坏本仓库"统一 LF"的约定(.gitattributes已声明eol=lf)。 用脚本改文件时一律read_bytes()/write_bytes(),或write_text(..., newline="\n")。 - 同一文件在一条消息里多次 Edit 会被静默覆盖(只落地一部分,随后报出"幽灵"错误)。
批量改同一文件时改用幂等 Python 补丁脚本:整文件读入 → 精确匹配替换 →
逐条打印
ALREADY/APPLIED/MISS,跑完即删。判断"已应用"要if new in text, 不要比对两行文本(会出现 new 是 old 子串的假阳性)。 git status报 M 但git diff为空,是索引 stat 缓存过期,不是真差异;git add -A(内容相同时等于无操作)即恢复干净。
代码风格约定(ruff / pyright 已固化在 pyproject.toml)
- 行宽 100;字符串统一双引号;
isort把farm视为第一方。 - 类型标注用 PEP 585/604 写法(
dict[str, int]/X | None),不要Dict/Optional。 - 吞异常要显式:
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
相关文档
- 项目入口文档:
README.md(接口表、SSE 协议、边界处理、设计取舍全在里面) - 当天工作记录:
.workbuddy/memory/2026-09-19.md