Files
SimpleFarm/README.md
T
snowy-yang cb4c1e7800 feat(engine): 四座自动化设施——喷灌 / 收割机 / 售货机 / 磨坊
为什么:自动化建筑只有水井和喂食器,中期以后每天的操作只剩浇水喂食
的重复劳动。新设施把"替代行动点动作"的思路推到完整流水线:
浇水(井/喷灌)→ 收割机收熟地 → 磨坊把陈粮变饲料 → 喂食器喂牲口
→ 售货机趁高价清库存,人不在也能持续产出。

- config.BUILDINGS 注册 4 座:喷灌系统 2800 金(全量浇水)、
  自动收割机 900 金 ×3(每天各收 2 块熟地,刻意不给经验——机器收的
  不算劳动,给手动玩法留价值)、自动售货机 1200 金(市价 > 基准价才
  出手、每天上限 5 份、越贵越先卖)、饲料磨坊 500 金(1 份最便宜的
  作物磨 3 份饲料)。
- engine._run_buildings 扩成六段流水线,顺序即依赖:磨坊排在喂食器前,
  早上磨的饲料当天就能喂上;收割机遇满仓停摆、作物留地(同喂食器
  缺料停摆哲学——自动系统绝不硬来,更绝不自动花钱)。
- webui / build 动作 / catalog 全部由 config.BUILDINGS 驱动,零改动。
- 测试 +6(喷灌全量浇水、收割入库且无经验、满仓停摆留作物、磨坊挑
  最便宜、磨坊→喂食器同日链路、售货机阈值与配额)。修复断言时确认
  两个既有行为:引擎自带 START_FEED=5;advance_day 末尾会把动物 fed
  复位("喂到了"的证据是 pending 累计而非 fed 本身)。
- 门禁:534 项 OK(skip 39)、ruff/pyright 全绿。
2026-09-25 21:05:27 +08:00

535 lines
27 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 接口 + SSE 实时推送**,AI / 脚本 / 机器人可以和人玩同一局游戏——
服务端**不内置任何 AI**,它只提供协议。
```
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
│ AI / 脚本 │ ───────────────────────▶ │ │
│ 机器人 │ ◀── JSON 响应 + SSE ──── │ FastAPI 服务器 │
└──────────────┘ │ (可后台常驻) │
┌──────────────┐ HTTP /api/* │ │
│ 终端界面 TUI │ ───────────────────────▶ │ FarmClock 统一时钟 │
└──────────────┘ ◀── SSE /api/stream ──── │ SessionManager 多牧场 │
┌──────────────┐ HTTP /api/* │ ConnectionHub 推送 │
│ 浏览器客户端 │ ───────────────────────▶ │ 存档(每牧场一份) │
└──────────────┘ ◀── SSE /api/stream ──── └────────────────────────┘
│
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 'uvicorn[standard]' fastapi zttp` 装。两种方式行为完全一致。
---
## 2. 玩法
| 系统 | 规则 |
| --- | --- |
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获、收取、卖货、建造都免费。用完只能等第二天——时间只能自然流动,没有手动快进 |
| **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 |
| **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 |
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
| **谷仓** | 收获和收取**只入库、不当场给钱**(容量 40 起,随等级与谷仓建筑增长);仓满时收不进去,作物原样留在地里,卖了再来 |
| **市场** | 价格每天在基础价 ±18% 内波动;**靠 `sell` 择时变现**,高位卖才真的赚 |
| **建筑** | 谷仓(+25 仓容,最多 5 座)/ 水井(每天自动浇一块干透的地)/ 饲料磨坊(每天 1 份作物磨 3 份饲料,挑最便宜的)/ 自动喂食器(每天自动喂一只,只吃库存饲料)/ 自动收割机(每天自动收 2 块熟地入库,不给经验,可叠 3 座)/ 自动售货机(市价高于基准价时自动卖出,每天上限 5 份)/ 喷灌系统(所有缺水地块一次浇满)/ 温室(免疫暴风雨、水分蒸发减半);每多一座涨价 60%。自动化的共同底线:**只替你干活的动作,绝不自动花钱** |
| **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 |
| **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 |
| **丰收** | 收获有 12% 概率触发双倍产出 |
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭——
> 好在预报提前一天就给你,来得及浇。想调试就把节奏调快:
> `uv run main.py play --tick 3`(一天 3 秒)。
---
## 3. 终端界面
`uv run main.py play` 打开。核心交互:
```
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
[M]卖货 [G]建造 [空格]暂停 [S]存档 [R]重置 [Q]退出
```
界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。
**TUI 是纯客户端**:它通过 SSE 长连接订阅状态、通过 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`。
牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。
**网页端用用户名登录**(`POST /api/login`,Cookie 绑定同名牧场):登录后 `?farm=`
被忽略、只能操作自己的牧场;脚本/AI 不带 Cookie 时保持旧语义,不受影响。
### 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}` 创建/进入 |
| POST | `/api/login` | `{"username":"名字"}` 用户名登录,设置 HttpOnly Cookie |
| POST | `/api/logout` | 退出登录(清除 Cookie) |
| GET | `/api/me` | 当前登录用户;未登录为 `null`(无需令牌) |
| GET | `/api/state` | 完整状态快照 |
| GET | `/api/catalog` | 作物 / 牲畜 / 商品与实时价格 / 建筑 / 天气表 / 仓容 |
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) |
| GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) |
| POST | `/api/actions` | **通用动作入口** |
| POST | `/api/batch` | 批量动作(一次最多 50 个) |
| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 12 个 |
| 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() pause(paused?)
sell(key?,quantity?) build(key) ← 谷仓变现与建造,0 行动点
```
> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理,
> 否则 `engine.save()` 会把存档写进默认路径、覆盖别人的档。仍然可以用
> `POST /api/actions {"action":"save"}` 调用(服务端会话层拦截执行),
> 只是没有 `/api/save` 便捷路由、也不出现在动作表文档里。
### 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"}}]}'
# 收成变现 / 造建筑:都是 0 行动点,行动点用完了照样能做
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
-H 'Content-Type: application/json' -d '{"action":"sell"}'
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
-H 'Content-Type: application/json' \
-d '{"action":"build","params":{"key":"granary"}}'
# 要完整快照(默认只回精简状态)
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": 2387.6, "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. SSE 实时推送
> 为什么是 SSE?客户端要读的只有"状态变了",这是**服务器 → 客户端单向**的下行;
> 而写操作(播种、喂食)本来就是普通 HTTP 请求。SSE 正好是"一条 HTTP 长连接单向推流",
> 于是上下行各用最自然的协议,不必为了一条推送引入双向握手、消息帧与心跳管理。
> 顺带换来的好处:`curl` 就能读、浏览器 `EventSource` 自带断线重连、
> 客户端只靠标准库 `urllib` 就能订阅(一个第三方包都不用装)。
连接:
```
http://127.0.0.1:8765/api/stream?farm=alice&token=<可选>&name=<昵称>
```
响应是标准的 `text/event-stream`,每帧就是一行 JSON 加一个空行:
```
data: {"type":"welcome","farm":"alice","state":{…},"catalog":{…},"actions":[…],"online":1}
data: {"type":"state","seq":42,"farm":"alice","state":{…},"log":[…],"online":2}
: keep-alive
```
### 服务器 → 客户端
| 类型 | 触发时机 |
| --- | --- |
| `welcome` | 连接建立:一次给全状态、目录、合法动作、日志、在线人数 |
| `state` | 状态变化时推送(**节流合并到 4 次/秒**),带自增 `seq` |
| `day` | 进入新的一天,附当日事件 |
| `notice` | 有人加入 / 离开牧场 |
| `bye` | 服务器正在停机(收到就可以收摊了) |
| `error` | 通道层错误 |
心跳:无消息时每 15 秒发一行 `: keep-alive`(SSE 注释行),既防中间代理掐掉空闲连接,
也让客户端能区分"只是没消息"和"连接真的断了"。
### 客户端 → 服务器
写操作**不占用**这条通道,直接 `POST /api/actions` 即可(见上一节)。
两条路改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
用 Python 直接读(只用标准库,不依赖本项目代码):
```python
import json, urllib.request
request = urllib.request.Request(
"http://127.0.0.1:8765/api/stream?farm=demo",
headers={"Accept": "text/event-stream"},
)
with urllib.request.urlopen(request, timeout=45) as response:
for raw in response: # 一行一行流式读,不等响应结束
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
message = json.loads(line[5:])
if message["type"] == "welcome":
print("第", message["state"]["day"], "天")
```
浏览器里就是一行:
```js
const es = new EventSource("/api/stream?farm=demo");
es.onmessage = (ev) => console.log(JSON.parse(ev.data));
```
服务端跑在 uvicorn 上并打开了 HTTP/2(`http="zttp"` + `http2=True`),**同一个端口**
同时说 HTTP/1.1 与 HTTP/2:支持 h2c 的客户端(`curl --http2-prior-knowledge`、
`httpx(http2=True)` 之类)可以直接走 HTTP/2,其余客户端继续走 HTTP/1.1,不需要任何配置,
SSE 长连接在两种协议下都照常工作。
浏览器只在 **https** 下才会协商 h2(详见第 12 节)。
---
## 7. 多人模型
* **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。
* **用户名登录**:网页首屏先登录(输入用户名即可,第一次用会自动开同名新牧场)。
Cookie 是 HMAC 签名的 HttpOnly,30 天有效,密钥在 `saves/secret.key`——重启不掉线。
登录后改 URL 也进不了别人的牧场;`curl`/脚本不带 Cookie 时保持 `?farm=` 旧语义
(互信小圈子模型:用户名即身份,不设密码,防的是"随手改 URL"而不是蓄意攻击)。
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`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/farm.db`(SQLite;`sqlite3 saves/farm.db "SELECT farm_id,day,gold,net_worth FROM farms"` 可直接查) |
| 接口文档 | <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` 传给子进程,子进程启动时写入自己的 `os.getpid()`,
> 退出时再删掉。`stop` 则优先走 HTTP 优雅停机,拿不到 pid 也能停下来。
>
> 细节:uvicorn 的 lifespan 启动**早于**端口绑定,所以 pid 文件会比"端口可连"早半步出现。
> 万一端口被占用、进程随即退出,收尾的 `finally` 会把这个 pid 文件删掉,
> 不会留下幽灵进程让 `stop` 误判。
---
## 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 存档读写(SQLite,一牧场一行;老 JSON 档启动时自动导入)
keys.py 跨平台按键读取
net.py 回环请求绕过系统代理的小工具
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP 写 + SSE 读)
bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略)
server/
app.py FastAPI 应用:REST + SSE + 多牧场调度
actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验
session.py 牧场会话与统一时钟
hub.py SSE 订阅池与状态推送(线程 → 事件循环的桥)
daemon.py 守护进程:start / stop / status / 日志 / pid
webui.py 内嵌网页客户端(单页,原生 JS + EventSource)
__main__.py uv run python -m farm.server(uvicorn 前台运行 + HTTP/2)
tools/
preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器)
sse_demo.py 最小 SSE 客户端(只用标准库),验证协议
```
分层原则:**规则不认识网络,网络不认识终端。**
`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。
所以三端共享完全一致的裁判逻辑。
---
## 10. 工程化: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+ 独有语法,会立刻报错。
---
## 11. 边界处理与设计取舍
**输入校验**(唯一入口 `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` 串行化同一牧场,多牧场并行;
* SSE 每条订阅一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
* 引擎调用统一丢进线程池,不阻塞事件循环;
* 单条连接/单个牧场出错不影响其它牧场;
* 存档是 SQLite(`saves/farm.db`,一牧场一行):写入包在事务里,掉电也不会写坏档;
老版本 JSON 存档启动时自动导入(原文件改名 `*.json.migrated` 留底);
存档读取失败会降级为开新档而不是崩溃。
**本地请求不走系统代理**(`farm/net.py`):
机器上设了 `HTTP_PROXY` 却没有 `no_proxy` 时(公司网络、容器、CI 沙箱很常见),
`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上",
报的还是 `502 upstream connect failed` 这种看不出根因的错误。
所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连,
目标是别的机器才尊重环境里的代理设置。(SSE 长连接也走这条路,所以本机连本机不会被代理截走。)
**测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器),
`tools/sse_demo.py` 是一个最小 SSE 客户端(只用标准库),用来确认"状态主动下行、动作走 HTTP 上行"两条路都通。
**服务器是 uvicorn,HTTP/2 由 `zttp` 提供**:HTTP/2 的收益在多路复用——网页版开局会并发拉
`state` / `catalog` / `world` / `log`,SSE 长连接又长期占着一条连接;HTTP/2 下这些请求复用
同一条 TCP、头部还会压缩,互不排队。
uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在 `zttp` 这个包上,所以启用方式就是在配置里
多写两项:
```python
UvicornConfig(app, http="zttp", http2=True)
```
`http="zttp"` 选出 `AutoZttpProtocol`,它会在**同一个端口**上按 ALPN(TLS)或 h2c 前导字节
(明文 prior-knowledge)自动分流 HTTP/1.1 与 HTTP/2——老客户端照旧走 HTTP/1.1,
应用代码一行都不用改。
两点需要留意:
* 浏览器只在 **https** 下才会用 h2,所以网页版想真正吃到 HTTP/2 得配上 TLS 证书
(明文端口上的 h2c 是 `curl` / 脚本客户端的事);
* `timeout_graceful_shutdown=5` 只是兜底:停机仍然先靠 `hub.close_all()` 让每条 SSE 流
自己收尾,这个值只管"客户端赖着不走"的情况。
**为什么放弃 gRPC**:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON;
gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。
HTTP + SSE 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)*