# 2026-09-18 · SimpleFarm 从单机 TUI 改造为小型多人游戏服务器 ## 需求 用户先要求"CLI/TUI 农场游戏 + HTTP/gRPC 接口让 AI 参与";中途改方向: **放弃 gRPC,改用 FastAPI/aiohttp**,要求:守护进程可后台运行、SSE 或 WS 二选一、 TUI 只在用户玩时作为客户端、用户也能用 HTTP 调用、支持多用户(小型游戏服务器)。 ## 最终架构(v2.0) - 服务端 `farm/server/`:`app.py`(FastAPI REST+WS) / `actions.py`(动作表) / `session.py`(多牧场会话 + 单一 FarmClock 统一时钟) / `hub.py`(WS 连接池 + 250ms 节流推送) / `daemon.py`(守护进程) / `webui.py`(内嵌网页客户端)。 - 客户端 `farm/client.py` 的 `RemoteFarm`:与 `FarmEngine` **接口同构**, 所以 `tui.py` 不用改渲染逻辑就能从"本地引擎"切到"远程服务器"。 写操作优先走 WS(带 id 回执),通道类错误自动退回 HTTP;读数靠 WS 推送。 - `farm/bot.py` 规则机器人 + `ai_player.py` 外挂机器人:只调接口,不 import 游戏规则。 - 统一入口 `main.py`:serve/stop/status/logs/play/solo/do/batch/actions/state/log/watch/bot/farms/help。 ## 关键决策 - **选 WebSocket 而非 SSE**:这个游戏动作上行和状态下行是同一件业务的两半, SSE 只能下行,等于要维护两套鉴权/重连。 - **删掉 gRPC**(`farm/api.py` 旧 stdlib HTTP 层、`farm/grpc_server.py`、`proto/`、`tools/gen_proto.py`): 接口主要喂 LLM/脚本,它们只会捏 HTTP+JSON。 - 多牧场模型:一个牧场 = 一个玩家 = 一份存档 + 一个引擎;空闲 30 分钟先落盘再移出内存。 - 动作表 `server/actions.py` 是所有入口(HTTP / WS / CLI / 机器人)的唯一执行通道,参数校验只写一次。 ## 踩过的坑(重要,可复用) 1. **本地请求被系统代理截走**:机器上设了 `HTTP_PROXY` 但没有 `no_proxy`, `urllib` 把发给 127.0.0.1 的请求也交给代理,本机服务器报 `502 upstream connect failed`。 解决:`farm/net.py` 的 `open_url()`,回环地址走 `ProxyHandler({})` 直连。 2. **pid 归属**:起初父进程把 `Popen().pid` 写进 pid 文件,但 Windows 上 venv 的 `python.exe` 可能是转发器,`os.getpid()`(40564)≠ 父进程看到的 pid(20244),`stop` 会杀错进程。 解决:`--pid-file` 传给子进程,子进程在 lifespan 启动(端口已绑定)后写自己的 pid,退出时删除; `stop` 优先走 HTTP `/api/admin/shutdown` 优雅停机(能落盘),拿不到 pid 也能停。 3. **精简状态会清空视图**:动作回执里的 `state` 是精简版(无 `plots`/`animals`), 客户端若整体替换就会把农田格子清空。解决:`StateView.apply()` 按"含 plots 即整体替换, 否则合并字段",并且 HTTP 动作带 `full=1` / WS 回执带 `snapshot` 让客户端立刻刷新。 4. **`save` 动作必须由会话处理**:`engine.save()` 会写默认路径,多牧场下会互相覆盖; 在 `app.execute_action()` 里把 save/reset 拦截为会话级操作。 5. 本机 Bash 的 coreutils 坏了(`ls`/`head`/`tail`/`grep` 都不存在), 所有查看/清理动作改用 Python 脚本完成;管道会直接让命令失败,**不要用管道**。 ## 验证 - 97 项测试全绿(引擎 45 + 服务器 52),含真 uvicorn + 真客户端 + 真 WebSocket 端到端。 - 实测:daemon 启动 → status 正确 pid → actions/do/batch → 两牧场隔离与排行榜 → WS 收到 welcome/state/result/day → 机器人 120→197 金币 → stop 优雅停机落盘。 - TUI 远程渲染验证:`tools/preview_tui.py --url http://127.0.0.1:8793 --farm alice` (112 列 / 76 列都正常,头部显示 `牧场名 @ 服务器`)。 - 依赖装在隔离 venv:`C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe` (fastapi 0.141.1 / uvicorn 0.53 / websockets 17.1 / httpx 0.28)。 --- # 2026-09-18(下半场)· 工程化改造:uv + ruff + pyright ## 需求 "完成 uv 来管理,ruff pyright 来完善"。目标是让项目达到可维护的工程标准: 依赖有单一可信来源、锁文件可复现、lint 零告警、类型检查零错误,且测试仍全绿。 ## 落地内容 - **uv 作为单一口径**:新增 `pyproject.toml`(唯一真实来源)+ `uv.lock`(提交,保证可复现) + `.python-version`(3.13)。`[tool.uv] package = false`(这是应用不是库)。 `requirements.txt` 改为 `uv export --no-dev --no-hashes` 的产物,仅供不用 uv 的人参考。 - **ruff**:474 项告警 → 0。规则集 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF。 line-length 定为 **100**(实测 >88 有 122 行、>100 只有 18 行,88 会造成大面积无意义重排)。 **刻意不开 PTH**:路径在本项目是"字符串身份"(要喂给 subprocess / 塞进 JSON / 打印), 不是文件系统操作对象,转成 Path 反而要在边界反复 str()。 **忽略 PERF203**:剩下 2 处 try/except 在循环里是刻意的"单项容错",不是性能疏忽。 - **pyright**:15 errors / 10 warnings → 0。basic 模式。 `pythonPlatform = "All"`(代码里有 termios/msvcrt 双平台分支,只看单平台会把另一侧误报成 "模块没有该属性");`reportUnsupportedDunderAll = false`(`__init__.py` 惰性导出属正常写法)。 真实修掉的类型问题:`keys.py` 的 `sys.stdin.fileno()` None 收窄、`app.py` lifespan 需 `@asynccontextmanager` + `AsyncIterator[None]`、`actions.py` 的 `isinstance` 冗余告警。 ## 关键决策 - **先建 git 仓库再动手**:400+ 处自动改动前先做基线快照,分阶段提交以便逐段回滚。 - 提交节奏:`a7725f8` 基线 → `c7c8a63` 统一 LF → `fcd4cb4` ruff 清零 → `d7a7107` pyright 清零 + README 工程化章节 → `c414db4` 提示语统一。 - **`uv.lock` 必须提交**,它是可复现构建的依据,不是缓存(已在 `.gitignore` 里显式说明)。 - 用户可见提示统一为 `uv run ...`。这不只是措辞:依赖装在 `.venv`, 用户照抄 `python main.py serve` 会因缺 fastapi 直接失败。 但 `main.py:26` 与 `README:49` 的两处"没装 uv?"说明**刻意保留** `python main.py` 写法。 ## 踩过的坑(重要,可复用) 6. **同一文件多次 Edit 会被静默覆盖**:一条消息里对同一文件发多个 Edit,只有部分落地, 表现为随后 ruff 报出 `F821 Undefined name contextlib`(import 那处没写上)等"幽灵错误"。 解决:改用**幂等 Python 补丁脚本**——整文件读入、精确匹配替换、逐条报告 `ALREADY/APPLIED/MISS`, 跑完即删。本轮 34 + 6 + 15 + 23 + 4 处替换全部这样完成。 注意脚本里判"已应用"要用 `if new in text` 而不是比对两行文本,否则会出现 "new 是 old 子串"导致的**假 ALREADY**(F841 那次就中过招)。 7. **`pathlib.write_text()` 在 Windows 上会把 `\n` 转成 `\r\n`**,等于偷偷破坏"统一 LF"的约定。 症状:`git commit` 冒出一串 `CRLF will be replaced by LF` 警告。 解决:改行尾类操作一律用 `read_bytes()/write_bytes()`(二进制,不经转换), 或 `write_text(..., newline="\n")`。**这是本项目写文件的默认姿势。** 8. **`git status` 报 M 但 `git diff` 为空 = 索引 stat 缓存过期**(文件被重写导致 mtime 变了), 不是真差异。用 `git cat-file -p HEAD:` 与工作区做字节比对可证伪; `git update-index --refresh` 或 `git add -A`(内容相同时等于无操作)即恢复干净。 ## 验证 - `uv run ruff check .` → All checks passed;`uv run ruff format --check .` → 26 files already formatted。 - `uv run pyright` → 0 errors, 0 warnings, 0 informations。 - `uv run python -m unittest discover -s tests` → Ran 97 tests, OK。 - `uv lock --check` → 一致(28 包);`uv run main.py --help` 正常。 - 端到端复验:`uv run main.py serve --daemon --port 8796` 起服 → status / actions / do(plant, buy_animal, feed) / farms / stop 全通,优雅停机并落盘。 - 行尾复核:全部 `.py` 与 `README.md` 均 0 个 CRLF,blob 与工作区逐字节相同。 ## 复核:ruff / pyright 规则是否真的生效(用探针反证) 用户追问"规则你加了吗",做了**正向反证**而不是只回答"写了": - 先排除覆盖源:不存在 `ruff.toml` / `.ruff.toml` / `pyrightconfig.json` / `setup.cfg` / `tox.ini` / `.flake8` / `mypy.ini` —— **唯一配置来源就是 pyproject.toml**。 - 写故意违规的探针文件 `tools/_rule_probe.py`(用完即删),实测命中: ruff → F401 / RUF012 / UP045 / E741 / UP031 / E501(145>100) / SIM105 / SIM102 / B018; pyright → reportUnusedImport / reportReturnType ×2 / reportArgumentType。 说明 `select`、`line-length=100`、`reportUnusedImport=true` 等**全部真在生效**。 - `ruff check --show-settings` 复核:`linter.line_length = 100`、 `pycodestyle.max_line_length = 100`、`formatter.line_ending = auto`、 `analyze.target_version = 3.10`;启用规则 **263 条**,前缀 [B,C,E,F,I,PERF,RET,RUF,SIM,UP,W] 与 select 的 11 项一一对应。 - 新知识:`X | None` 的规则码在新版 ruff 里是 **UP045**(不是 UP007); **SIM105 对 try 体里含 `return` 的情况有意不报**(换个朴素用例才命中), 所以"某条规则没报出来"≠"规则没启用",别急着改配置。 - 该手法已补进技能 `python-uv-ruff-pyright-hardening` 的「反证:确认配置真的被加载了」一节。