Files
SimpleFarm/.workbuddy/memory/MEMORY.md
T
snowy-yang a92c29ead2 docs: 记录发布到 gitea.4i.hk 的仓库地址与推送认证方式
远端仓库:https://gitea.4i.hk/hhhge/SimpleFarm(public,main)。

关键坑:非交互 shell 里必须 GCM_INTERACTIVE=always,否则 GCM 会因为「用户交互被禁用」
直接拒绝,git 报 could not read Username / Failed to authenticate user,看起来像认证配错了。

另记:tea 的 OAuth token 能建仓、能调 API、能加 SSH 公钥,但不能签发访问令牌
(POST /users/{u}/tokens 需要 basic auth)。
2026-09-19 13:33:48 +08:00

167 lines
15 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,客户端会误显示成“已暂停/手动模式”。
## 架构约定(改动时必须遵守)
- **分层**:`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` 各写一份。
## 环境禁忌(本机特有)
- 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`