# 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 # ⑤ 优雅停机(会把所有牧场存档) ``` 浏览器打开 就是网页版客户端(可多开几个标签页,用不同牧场名, 能实时看到对方的操作)。页面顶部是 **Canvas 二维农场地图**:农田方阵、牧场围栏、 建筑带画在一张俯视图上,作物随生长进度变大、成熟发光,雨/旱/暴风雨有对应天气效果; 点地块直接播种/浇水/收获,点牲畜喂食/收取(下方的卡片面板保留,作精确操作用)。 不想装任何第三方库、只想单机玩玩: ```bash uv run main.py solo # 本地引擎 + 终端界面,不开服务器 ``` > **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`; > 依赖用 `pip install 'uvicorn[standard]' fastapi zttp` 装。两种方式行为完全一致。 --- ## 2. 玩法 | 系统 | 规则 | | --- | --- | | **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | | **节奏** | 动作**没有**行动点/体力限制,想种就种——稀缺的只有时间本身:一天就是 40 分钟,只能自然流动,没有手动快进 | | **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 | | **水分** | 每天按天气消耗:晴 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 分钟是刻意设计的,不是笔误。 > 动作没有体力限制,但作物、牲畜、市场的进展都随换天推进——所以一天的玩法是“上来把事干完,然后离开”。 > 小麦 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 <动作> ''` | 执行一个动作 | | `batch ''` | 一个请求执行多个动作 | | `state [--json]` | 查看状态(`--json` 为机器可读) | | `log -n 20` | 最近事件 | | `watch` | 实时滚动状态与日志(适合盯着 AI 玩) | | `bot --farm ai-1` | 规则机器人替你经营一个牧场 | | `farms` | 牧场排行榜 | | `help` | 玩法与接口说明 | 实例: ```bash $ uv run main.py actions 牧场 default · 第 3 天 · 金币 96 共 9 个候选动作: [ 1] harvest {"plot": 2} (免费) 2 号地的胡萝卜已成熟 [ 2] plant {"plot": 4, "crop": "wheat"} (gold:5) 4 号地空闲,可种小麦 ... $ uv run main.py do harvest '{"plot":2}' ✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s) 第 3 天 · 金币 200 · 净资产 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) ← 谷仓变现与建造 ``` > `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' \ -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=`。 没带令牌返回 `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"` 可直接查) | | 接口文档 | | 实现上不依赖 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`; * 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`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`。)*