Files
SimpleFarm/.workbuddy/memory/MEMORY.md
T
snowy-yang cf24cb43f1 feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出
动手前先修掉了两处「死机制」,新增的三个系统各自激活一个:

* 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。
  现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而
  `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。
  5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。
* 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与
  收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。
  仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。
* 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、
  喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。
  每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。

存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段
一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的
确定性入口。

顺带修两个真 bug:
* `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt`
  这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出
  `ensure_parent_dir()` 兜底。
* `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据,
  看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。

质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、
bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。
另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
2026-09-19 17:02:33 +08:00

191 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 不进 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`
里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。
- **存档**:`SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`——旧档缺的新字段一律走默认值,
**不作废玩家手里的档**。加字段时两处版本号都要看一遍。
- **测试钩子**:`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` 在底部明示原因。
- **多牧场**:一个 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`、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`** 缺失),**不要用管道**,会整条命令失败;
查看/删除文件一律写 Python 脚本。跑测试也一样:`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 <path> -l gitea.4i.hk [-X POST -d '<json>']`。
限制:**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`