Files
SimpleFarm/.workbuddy/memory/MEMORY.md
T
snowy-yang d1692d7b10 docs: 记忆与验收技能跟上——存档改动必须真机验自动落盘
单元测试抓不住 autosave 失效(dirty 被高频清零这件事测试里复现
不了真机时序),所以技能里加一条:动了存档/会话层,必须 0 客户端
在线做一个动作、等 60s、直接查库那一行有没有更新。
2026-09-23 20:54:13 +08:00

18 KiB
Raw Blame History

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 里先于生长执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。
  • 存档: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 <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