# 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`,网页 ,文档 /docs - 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo` - AI/脚本入口:`main.py actions | do <动作> '' | batch | state --json | watch | bot` - **测试套件已重建(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 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,客户端会误显示成“已暂停/手动模式”。 ## 三大系统:天气 / 谷仓 / 建筑(2026-09-19 起) - **天气**:`config.WEATHER` 5 种(晴 30 / 多云 27 / 雨 24 / 干旱 12 / 暴风雨 7,合计 100), 同种不连出(`WEATHER_REROLLS` 重掷)。`state.weather`=今天、`state.forecast`=明天且 **提前可见**,于是「今晚浇不浇」成了可计算的决策。水分消耗改由天气决定 (晴 1 / 多云 0 / 干旱 2),雨天反补 3;建成温室后蒸发减半。 - **谷仓**:收获 / 收取**不再直接给钱**,进 `state.inventory`(键与 `market` 同构: `wheat` / `animal:chicken`),靠 `sell` 换金币——市场波动因此从纯装饰变成真决策。 仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。 - **建筑**:`config.BUILDINGS`,第 n 座价 `int(cost * 1.6**owned)`;每天在 `advance_day` 里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。 - **存档**:**SQLite**(`saves/farm.db`,一张 `farms` 表,一牧场一行)。 标量列(day/gold/level/ap/weather/forecast/net_worth)管可查询, 完整状态整份存 `payload` JSON——**加字段不用改表结构**,改 `_raw_state()` 就行。 `SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`,旧档缺的新字段走默认值、不作废玩家档; 老 JSON 由 `migrate_json_saves()` 启动时自动导入(原文件改名 `*.json.migrated` 留底)。 `StorageError = sqlite3.Error`(不是 OSError 子类),吞异常别再写 `except OSError`。 WAL 是持久属性,建库时设一次就够;每次连接重设白花 2ms。 - **测试钩子**:`advance_day(weather="sunny")` 可钉死当天天气(不传就随机,测试会飘); 这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。 - 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`, 正是为了执行这些免费动作。 ## 架构约定(改动时必须遵守) - **分层**:`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` 在底部明示原因。 - **session 的 dirty / unsaved 必须分开**:`dirty` 只管推送节流(Publisher ~250ms 清一次), `unsaved` 才是自动存档依据(只有成功的 `save()` 清它)。别再合并——合回来 autosave 就瞎了。 网页前端禁用 `window.prompt`/`confirm`(嵌入预览里被静默屏蔽),交互用页面内弹层。 - **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟; 时钟由**一个** `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`、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` 各写一份。 ## 环境禁忌(本机特有) - `os.makedirs(os.path.dirname(path), exist_ok=True)` 在 path **只有文件名**时 (`--pid-file pid.txt`)`dirname` 是空串,会以 FileNotFoundError 崩掉。 已抽 `farm/server/daemon.py` 的 `ensure_parent_dir()` 兜底,新代码用它,别再手写。 - `RemoteFarm.snapshot()` 是**本地缓存、不发请求**。关掉实时通道(`realtime=False`)的 脚本必须自己调 `refresh()`,否则轮询永远读到同一份旧数据——服务器在推进却看起来像卡死。 TUI / 网页走 SSE 所以没这个问题;`bot` 走 `available_actions()`(会顺手带回最新状态)。 - Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32); 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。 一律"先读完 → 关掉句柄 → 再删"。 - Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`**/**`rm`** 缺失),**不要用管道**,会整条命令失败; 查看/删除文件一律写 Python 脚本(`pathlib.unlink()` / `shutil.rmtree()`)——连删临时脚本都别用 shell 的 `rm`(safe-bin shim 依赖 dirname,直接 127)。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1` 必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。 PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。 - **写多行 git commit message 不要用 `git commit -m "$(cat <<'EOF' ... EOF)"`**:本机没有 `cat`, 会报 `cat: command not found` 然后 git 以为消息为空直接 `Aborting commit due to empty commit message` (此时 `git add -A` 已经成功,暂存区是好的)。正确做法:把消息写进一个文件 (用 Write 工具,再 `read_bytes().replace(b"\r\n", b"\n")` 归一化成 LF,避免 CRLF 混进消息), 然后 `git commit -F <文件>`,最后删掉它。**注意顺序**:先 `git add -A` 再建消息文件, 这样消息文件不会进暂存区(而且见下条,它本来也会被忽略)。 - `.gitignore` 里有 **`/_*`**(根目录下划线开头的文件全部忽略):好处是临时补丁脚本 (`_splice_app.py`、`_commit_msg.txt` 之类)默认就不会污染 `git status`,跑完删不删都不碍事; 代价是**放在根目录、以 `_` 开头的正经文件也会被静默忽略**,要提交就换名字或放子目录。 - 环境设了 `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` ## 远端仓库与推送(gitea.4i.hk) - 远端:**https://gitea.4i.hk/hhhge/SimpleFarm**(public,默认分支 `main`,owner `hhhge`), 本地 `origin` 已指向 `https://gitea.4i.hk/hhhge/SimpleFarm.git` 并设好 upstream。 - **推送认证走 GCM 的 generic provider**(`credential.https://gitea.4i.hk.provider=generic` 写在 `~/.gitconfig` 里)。关键:**非交互 shell 里必须带 `GCM_INTERACTIVE=always`**, 否则 GCM 直接 `Cannot prompt because user interactivity has been disabled`, 接着 git 报 `could not read Username / Failed to authenticate user`,看起来像认证配置坏了。 加上这个环境变量后 GCM 会静默用已有的 OAuth 凭据续期,不需要弹窗也不需要手输: `GCM_INTERACTIVE=always git push -u origin main` - `tea` CLI(0.16.0)已用 OAuth 登录该实例,配置在 `%LOCALAPPDATA%\tea\config.yml` (token 存在系统 keyring,**不在配置文件里**)。可用来建仓与调 API: `tea repos create --name X --login gitea.4i.hk`(**没有 `--public` 参数,默认就是公开**)、 `tea api -l gitea.4i.hk [-X POST -d '']`。 限制:**OAuth token 不能签发访问令牌**(`POST /users/{u}/tokens` 需要 basic auth, 会返回 401 `auth required`,这是 Gitea 防提权的设计);但**可以用它加 SSH 公钥** (`POST /user/keys`),所以将来若要免密推送,加一把密钥是可行的。 - 本机 git 走 `http.proxy / https.proxy = http://127.0.0.1:7890`(写在 `~/.gitconfig`)。 - **沙箱怪癖**:`.git/refs/remotes/**` 里"新建目录"的写入跨命令调用会被丢弃, 于是 `git status -sb` 一直显示 `## main...origin/main [gone]`(远端其实好着呢)。 修法是**在同一个命令里**先 `mkdir -p .git/refs/remotes/origin` + 写入 ref 文件, 紧接着跑 `git status`——分开两条命令就又看不到。真实终端里直接 `git fetch` 即可。 ## 相关文档 - 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面) - 当天工作记录:`.workbuddy/memory/2026-09-19.md`