Files
SimpleFarm/README.md
T
snowy-yang c414db4217 fix: 用户可见的操作提示统一为 uv run 前缀
项目由 uv 管理,依赖装在 .venv。此前各处的提示语仍写作 `python main.py ...`,用户若在未激活虚拟环境时照抄,会因缺少 fastapi 而失败——这是正确性问题,不只是措辞。

涉及:main.py(12 行,含 prog 与各条 hint)、ai_player.py(5 行)、farm/client.py、farm/server/__main__.py(3 行,含 prog)、farm/server/__init__.py、farm/server/daemon.py、README(4 处示例与目录树注释)。

刻意保留 main.py:26 与 README:49 的「没装 uv?」兜底说明,那里 `python main.py` 是正确写法。

验收:ruff 全通过 / format 26 文件无改动 / pyright 0 错误 / 97 项测试通过。
2026-09-18 21:25:52 +08:00

488 lines
23 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 · 小型农场游戏服务器
一个可以在终端里玩的农场经营游戏,同时也是一个**小型多人游戏服务器**:
* 游戏逻辑跑在**服务端进程**里,可以后台常驻;
* 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩;
* 每个玩家一个独立牧场(存档隔离),互相能看到排行榜;
* 同时开放 **HTTP 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏——
服务端**不内置任何 AI**,它只提供协议。
```
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
│ AI / 脚本 │ ───────────────────────▶ │ │
│ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │
└──────────────┘ JSON 响应 │ (可后台常驻) │
┌──────────────┐ WebSocket /ws │ │
│ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │
└──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │
┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │
│ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │
└──────────────┘ └────────────────────────┘
│
farm.engine 纯游戏规则
```
---
## 1. 快速开始
```bash
uv sync # 建好环境(只有服务端需要第三方依赖)
uv run main.py serve --daemon # ① 后台启动服务器
uv run main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口)
uv run main.py bot --farm ai-1 # ③ 让机器人玩家一起经营
uv run main.py status # ④ 看服务器状态
uv run main.py stop # ⑤ 优雅停机(会把所有牧场存档)
```
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
能实时看到对方的操作)。
不想装任何第三方库、只想单机玩玩:
```bash
uv run main.py solo # 本地引擎 + 终端界面,不开服务器
```
> **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`;
> 依赖用 `pip install fastapi 'uvicorn[standard]'` 装。两种方式行为完全一致。
---
## 2. 玩法
| 系统 | 规则 |
| --- | --- |
| **时间** | 每个牧场每 15 秒自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 `sleep`(2 秒冷却) |
| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 |
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
| **市场** | 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚 |
| **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 |
| **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 |
| **丰收** | 收获有 12% 概率触发双倍产出 |
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
---
## 3. 终端界面
`uv run main.py play` 打开。核心交互:
```
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
[N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出
```
界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。
**TUI 是纯客户端**:它通过 WebSocket 订阅状态、通过 WebSocket/HTTP 提交动作,
自己不持有任何游戏状态。所以:
* 服务端可以在另一台机器上,`uv run main.py play --url http://10.0.0.5:8765 --farm alice`;
* 关掉 TUI 不会影响服务器,你的牧场继续按时间生长;
* 你在 TUI 里操作的同时,AI 可以对同一个牧场操作,双方立刻能看到对方的结果。
---
## 4. 命令行
| 命令 | 作用 |
| --- | --- |
| `serve [--daemon]` | 启动服务器(前台 / 后台) |
| `stop [--force]` | 优雅停机:先让服务器落盘,再退出 |
| `status [--logs] [--json]` | 服务器状态 + 排行榜 + 日志尾部 |
| `logs -n 40` | 查看后台日志 |
| `play --farm NAME` | 终端界面(TUI) |
| `solo` | 离线单机(本地引擎,不开服务器) |
| `actions` | **列出当前所有合法动作**(AI 推荐入口) |
| `do <动作> '<JSON参数>'` | 执行一个动作 |
| `batch '<JSON数组>'` | 一个请求执行多个动作 |
| `state [--json]` | 查看状态(`--json` 为机器可读) |
| `log -n 20` | 最近事件 |
| `watch` | 实时滚动状态与日志(适合盯着 AI 玩) |
| `bot --farm ai-1` | 规则机器人替你经营一个牧场 |
| `farms` | 牧场排行榜 |
| `help` | 玩法与接口说明 |
实例:
```bash
$ uv run main.py actions
牧场 default · 第 3 天 · 金币 96 · 行动点 7/10
共 9 个候选动作:
[ 1] harvest {"plot": 2} (免费)
2 号地的胡萝卜已成熟
[ 2] plant {"plot": 4, "crop": "wheat"} (gold:5 ap:1)
4 号地空闲,可种小麦
...
$ uv run main.py do harvest '{"plot":2}'
✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s)
第 3 天 · 金币 200 · 行动点 7/10 · 净资产 512.0
```
---
## 5. 给 AI 的 HTTP 接口
所有接口都在 `/api/*`,响应统一是 `{"ok": bool, "message": str, ...}`;
失败时带机器可读的 `error`(如 `not_enough_gold` / `no_ap` / `not_ripe`)与 HTTP 状态码。
**多牧场**:用 `?farm=名字` 或请求头 `X-Farm-Id` 指定;不指定就是 `default`。
牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。
### 5.1 最短决策闭环
```
GET /api/actions?farm=me → 当前所有**合法**动作(含开销与 why)
POST /api/actions → {"action":"<上一步的 action>","params":{...}}
← 回执里直接带执行后的状态,无需再查一次
```
### 5.2 接口一览
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/health` | 健康检查(唯一不需要令牌的接口) |
| GET | `/api/help` | 规则 + 动作清单 + 参数类型 |
| GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 |
| GET | `/api/farms` | 牧场列表(按净资产排名) |
| POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 |
| GET | `/api/state` | 完整状态快照 |
| GET | `/api/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 |
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) |
| POST | `/api/actions` | **通用动作入口** |
| POST | `/api/batch` | 批量动作(一次最多 50 个) |
| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 13 个 |
| POST | `/api/admin/save` | 全部存档 |
| POST | `/api/admin/shutdown` | 优雅停机 |
| GET | `/docs` | 自动生成的 Swagger 文档 |
动作清单(`GET /api/help` 里有完整参数表):
```
plant(plot,crop) water(plot) harvest(plot) harvest_all()
buy_animal(kind) feed(animal_id,quantity?) collect(animal_id)
buy_feed(quantity?) buy_plot() sleep() pause(paused?) save() reset()
```
### 5.3 请求示例
```bash
# 看现在能做什么
curl 'http://127.0.0.1:8765/api/actions?farm=alice'
# 播种
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
-H 'Content-Type: application/json' \
-d '{"action":"plant","params":{"plot":1,"crop":"wheat"}}'
# 一次干三件事(AI 省流)
curl -X POST 'http://127.0.0.1:8765/api/batch?farm=alice' \
-H 'Content-Type: application/json' \
-d '{"actions":[{"action":"harvest_all"},
{"action":"buy_feed","params":{"quantity":10}},
{"action":"plant","params":{"plot":1,"crop":"carrot"}}]}'
# 要完整快照(默认只回精简状态)
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ...
```
响应示例:
```json
{
"ok": true,
"action": "plant",
"message": "1 号地已种下小麦,2 天后成熟",
"farm": "alice",
"seq": 42,
"state": {
"day": 3, "gold": 91, "level": 1, "ap": 6, "feed": 5,
"ripe_plots": [], "hungry_animals": [], "collectable_animals": [],
"net_worth": 401.0, "seconds_to_tick": 11.4, "paused": false, "online": 2
},
"log": [{"seq": 41, "day": 3, "kind": "action", "text": "在 1 号地播下小麦种子"}]
}
```
> `state.ripe_plots` / `hungry_animals` / `collectable_animals` 是特意为 AI 准备的
> "待办清单"字段:不用遍历整个快照就知道该干什么。
**鉴权**:服务器用 `--token` 启动后,所有接口都要带 `X-API-Token: <token>` 头或 `?token=<token>`。
没带令牌返回 `401 {"ok":false,"error":"unauthorized"}`。
---
## 6. WebSocket 实时通道
> 为什么是 WebSocket 而不是 SSE?SSE 只能服务端 → 客户端单向推流,
> 而这个游戏的上行(播种、喂食)和下行(状态、事件)是同一件业务的两半:
> 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。
> WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。
连接:
```
ws://127.0.0.1:8765/ws?farm=alice&token=<可选>&name=<昵称>
```
### 客户端 → 服务器
| 消息 | 说明 |
| --- | --- |
| `{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}}` | 执行动作,回执带同一个 `id` |
| `{"type":"sync","id":2}` | 主动拉一次完整状态 + 合法动作 |
| `{"type":"help","id":3}` | 拉规则与动作说明 |
| `{"type":"ping"}` | 心跳,回 `pong` |
### 服务器 → 客户端
| 消息 | 触发时机 |
| --- | --- |
| `{"type":"welcome", "state":…, "catalog":…, "actions":…, "log":…, "online":1}` | 连接建立 |
| `{"type":"state", "seq":…, "state":…, "log":[…]}` | 状态变化时推送(**节流合并到 4 次/秒**) |
| `{"type":"day", "day":5, "events":[…]}` | 进入新的一天 |
| `{"type":"result", "id":1, "ok":true, "message":…, "snapshot":…}` | 动作回执 |
| `{"type":"notice", "text":"web 来到了牧场(在线 2)"}` | 有人加入 / 离开 |
| `{"type":"error", "error":"bad_json", "message":…}` | 协议错误 |
用 Python 直接玩(不依赖本项目代码):
```python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8765/ws?farm=demo") as ws:
welcome = json.loads(await ws.recv())
print("第", welcome["state"]["day"], "天")
await ws.send(json.dumps({"type": "action", "id": 1, "action": "harvest_all"}))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "result":
print(msg["message"])
elif msg["type"] == "state":
print("金币", msg["state"]["gold"])
asyncio.run(main())
```
---
## 7. 多人模型
* **一个牧场 = 一个玩家**:独立引擎、独立时钟、独立存档(`saves/<牧场名>.json`)。
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。
* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。
* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。
* 无人在线的牧场在 `--idle-timeout`(默认 30 分钟)后被移出内存,**先落盘再释放**;
玩家回来时自动从存档恢复。
* 上限 `--max-sessions`(默认 64),超出返回 `503 too_many_farms`。
局域网联机:
```bash
uv run main.py serve --daemon --host 0.0.0.0 --port 8765 --token 找个密码
uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个密码
```
---
## 8. 守护进程
`serve --daemon` 之后:
| 项目 | 位置 / 命令 |
| --- | --- |
| 进程号 | `run/server.pid`(**由服务进程自己写**,见下方说明) |
| 日志 | `run/server.log`(`uv run main.py logs -n 50`) |
| 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) |
| 状态 | `uv run main.py status` |
| 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) |
| 接口文档 | <http://127.0.0.1:8765/docs> |
实现上不依赖 systemd / nohup / 任何第三方库:POSIX 用新会话、Windows 用 `DETACHED_PROCESS`,
父进程退出后服务继续活着。自动存档每 60 秒一次,停机时再存一次。
> **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件,
> 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**,
> 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。
> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程在端口绑定成功(lifespan 启动)后
> 才写入自己的 `os.getpid()`,退出时再删掉。`stop` 则优先走 HTTP 优雅停机,
> 拿不到 pid 也能停下来。
---
## 9. 代码结构
```
pyproject.toml 唯一真源:依赖、Python 版本、ruff 与 pyright 规则
uv.lock 锁定的依赖版本(可复现构建,应当提交)
.python-version 钉住解释器版本
.gitattributes 行尾统一为 LF,避免 Windows 上 CRLF 反复抖动
main.py 统一入口:serve / play / do / bot / status ...
ai_player.py 外挂式机器人玩家(只用接口,不 import 任何游戏规则)
farm/
config.py 数值与静态表(作物、牲畜、经济、时间)
models.py Plot / Animal / GameState —— 只持有数据
engine.py FarmEngine / Scheduler —— 纯游戏规则(不依赖网络与终端)
storage.py 存档读写(临时文件 + 原子替换)
keys.py 跨平台按键读取
net.py 回环请求绕过系统代理的小工具
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP + WS)
bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略)
server/
app.py FastAPI 应用:REST + WebSocket + 多牧场调度
actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验
session.py 牧场会话与统一时钟
hub.py WebSocket 连接池与状态推送(线程 → 事件循环的桥)
daemon.py 守护进程:start / stop / status / 日志 / pid
webui.py 内嵌网页客户端(单页,原生 JS + WebSocket)
__main__.py uv run python -m farm.server(前台运行)
tools/
preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器)
ws_demo.py 最小 WebSocket 客户端,验证协议
tests/
test_engine.py 引擎规则与存档(单机层)
test_server.py 动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端
```
分层原则:**规则不认识网络,网络不认识终端。**
`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。
所以三端共享完全一致的裁判逻辑,也能各自单独测试。
---
## 10. 测试
```bash
uv run python -m unittest discover -s tests -v # 全部(97 项)
uv run python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI
uv run python -m unittest tests.test_server.TestLiveServer # 真起服务器 + 真 WebSocket
```
覆盖范围:
* 引擎规则、边界与存档兼容(45 项)
* 动作表参数校验:缺参 / 多参 / 类型错 / 未知动作(10 项)
* 牧场 ID 校验:路径穿越、超长、非法字符(3 项)
* 多牧场隔离、排行榜、空闲回收、暂停保护、容量上限(6 项)
* REST 语义:状态、动作、批量、日志增量、鉴权 401、坏 JSON、`X-Farm-Id` 头(20 项)
* **真网络端到端**:真 uvicorn + 真客户端,验证 HTTP 读写、两个客户端共享一个牧场、
动作确实走了 WebSocket、WS 断开后自动退回 HTTP、机器人只靠接口也能经营(7 项)
另外做过一轮真实环境实测(`--daemon` 起服务,再用命令行/脚本连上去):
```
serve --daemon → status 显示正确 pid → actions 列出 13 个候选动作
→ do plant / buy_animal / feed → batch 部分失败不影响其它动作
→ 排行榜出现两个互相隔离的牧场(alice 25 金币 / bob 120 金币)
→ WebSocket 客户端依次收到 welcome / state / result / day 四类消息
→ 机器人从 120 金币经营到 197 金币、净资产 276(0 次失败)
→ stop 优雅停机,所有牧场写入 saves/*.json
```
---
## 11. 工程化:uv + ruff + pyright
依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里——
单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果,
提交进版本库,换台机器 `uv sync` 得到的是同一个环境。
| 目的 | 命令 |
| --- | --- |
| 同步环境(含开发依赖) | `uv sync` |
| 只装运行依赖 | `uv sync --no-dev` |
| 添加依赖 | `uv add <包>` / `uv add --dev <包>` |
| 升级锁定版本 | `uv lock --upgrade` |
| 导出给 pip 用户 | `uv export --no-dev --no-hashes -o requirements.txt` |
| 静态检查 | `uv run ruff check .` |
| 自动修复 | `uv run ruff check . --fix` |
| 格式化 | `uv run ruff format .` |
| 类型检查 | `uv run pyright` |
### 为什么是 uv
* **一个工具顶四个**:解释器管理、依赖解析、虚拟环境、命令运行都在里面,
不需要 pyenv + pip + venv + pip-tools 拼装;
* **可复现**:`uv.lock` 精确到哈希,跨机器一致;
* **不用激活环境**:`uv run <命令>` 自动使用项目环境,少一类"我明明装了"的事故。
### ruff 的取舍
`ruff check` 管正确性,`ruff format` 管排版,一个工具替代
Black + isort + flake8 三件套(避免"格式化完 lint 又报"的拉锯)。
启用 `E W F I UP B C4 SIM RET PERF RUF`。两个刻意的**不**:
* **不启用 PTH**(`os.path` → `pathlib`)。本项目的路径大多是"字符串身份":
要拼进 `subprocess` 命令行、写进 JSON 存档、打印给用户看。全换成 `Path`
只是把字符串包一层再拆一层,换来 70 处纯风格改动,反而淹没真正的问题。
* **忽略 RUF001/002/003**。它们会把中文全角标点当成"含糊的 Unicode 字符",
但这里的注释与界面文案本来就是中文,全角括号是正确写法。
另外忽略 `PERF203`(循环里的 `try`):保留下来的两处都是**逐项容错**语义
(逐个牧场存档要立刻上报失败、收获全部要跳过失败地块)。把 `try` 提到循环外
会让"一项失败不影响其它项"这个行为消失——那是错的优化。
`line-length = 100` 是**贴着现有代码**定的:全项目只有 18 行超过 100 列;
若采用默认的 88,需要额外重排 122 行,diff 噪音会淹没真实改动。
### pyright 的取舍
用 `basic` 模式。目标不是"消灭所有 Unknown",而是**能在 CI 里拦住真 bug**:
None 误用、属性不存在、参数类型不符。本项目的状态快照是 `dict[str, Any]` 结构,
开 `strict` 去追每个 `Unknown` 只会得到一堆噪音。
配合 `pythonVersion = "3.10"`,守住 `requires-python` 的下限——
哪天不小心用上 3.11+ 独有语法,会立刻报错。
---
## 12. 边界处理与设计取舍
**输入校验**(唯一入口 `server/actions.py`):
* 未知动作 → `400 unknown_action`,并列出可用动作;
* 缺必需参数 → `missing_param`;多传参数 → `bad_param`(明确拒绝而不是静默忽略);
* 类型错误 → `bad_param`,但宽容地把 `"3"` 这类数字字符串转成整数(命令行/表单常见);
* 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币/行动点不足 → `not_enough_gold` / `no_ap`;
* 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`a/b` 之类的路径穿越;
* 请求体上限 64KB、批量动作上限 50、`limit` 参数自动夹取到合法区间。
**并发与容错**:
* 引擎内 `RLock` 串行化同一牧场,多牧场并行;
* WebSocket 每条连接一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
* 引擎调用统一丢进线程池,不阻塞事件循环;
* 单条连接/单个牧场出错不影响其它牧场;
* 存档用"临时文件 + `os.replace`"原子替换,崩溃不会写坏存档;存档读取失败会降级为开新档而不是崩溃。
**本地请求不走系统代理**(`farm/net.py`):
机器上设了 `HTTP_PROXY` 却没有 `no_proxy` 时(公司网络、容器、CI 沙箱很常见),
`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上",
报的还是 `502 upstream connect failed` 这种看不出根因的错误。
所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连,
目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,`websockets` 默认不读代理变量。)
**测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器),
`tools/ws_demo.py` 是一个最小 WebSocket 客户端,用来确认"动作上行、状态下行"两条路都通。
**为什么放弃 gRPC**:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON;
gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。
HTTP + WebSocket 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)*