Files
SimpleFarm/.workbuddy/memory/2026-09-18.md
T
snowy-yang 52004571c8 docs: 记录工程化改造(uv + ruff + pyright)的决策与踩过的坑
日志补记下半场:三件套的落地细节、行宽为何定 100、PTH 为何不开、pyright 两个关键开关;新增三条可复用环境坑(write_text 会把 LF 变 CRLF、同文件多次 Edit 互相覆盖、git status 与 git diff 不一致的判别)。

项目长期约定同步更新:运行方式改为 uv 单一可信来源,新增代码风格约定与质量门禁命令。
2026-09-18 21:28:20 +08:00

8.1 KiB
Raw Blame History

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 写法。

踩过的坑(重要,可复用)

  1. 同一文件多次 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 那次就中过招)。
  2. pathlib.write_text() 在 Windows 上会把 \n 转成 \r\n,等于偷偷破坏"统一 LF"的约定。 症状:git commit 冒出一串 CRLF will be replaced by LF 警告。 解决:改行尾类操作一律用 read_bytes()/write_bytes()(二进制,不经转换), 或 write_text(..., newline="\n")。这是本项目写文件的默认姿势。
  3. git status 报 M 但 git diff 为空 = 索引 stat 缓存过期(文件被重写导致 mtime 变了), 不是真差异。用 git cat-file -p HEAD:<file> 与工作区做字节比对可证伪; 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 与工作区逐字节相同。