Files
SimpleFarm/README.md
T
snowy-yang f8e093c98f feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
2026-09-19 12:55:45 +08:00

512 lines
25 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 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 |
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
| **市场** | 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚 |
| **土地** | 初始 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]买饲料
[空格]暂停 [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`。
牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。
### 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` 做增量拉取) |
| GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) |
| POST | `/api/actions` | **通用动作入口** |
| POST | `/api/batch` | 批量动作(一次最多 50 个) |
| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 10 个 |
| 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?)
```
> `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"}}]}'
# 要完整快照(默认只回精简状态)
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/<牧场名>.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` 传给子进程,子进程启动时写入自己的 `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 存档读写(临时文件 + 原子替换)
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`,客户端据此丢弃乱序帧;
* 引擎调用统一丢进线程池,不阻塞事件循环;
* 单条连接/单个牧场出错不影响其它牧场;
* 存档用"临时文件 + `os.replace`"原子替换,崩溃不会写坏存档;存档读取失败会降级为开新档而不是崩溃。
**本地请求不走系统代理**(`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`。)*