三件事凑成一次不兼容变更: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)。
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. 快速开始
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/ 就是网页版客户端(可多开几个标签页,用不同牧场名, 能实时看到对方的操作)。
不想装任何第三方库、只想单机玩玩:
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 |
玩法与接口说明 |
实例:
$ 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 请求示例
# 看现在能做什么
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' ...
响应示例:
{
"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 直接读(只用标准库,不依赖本项目代码):
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"], "天")
浏览器里就是一行:
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。
局域网联机:
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 这个包上,所以启用方式就是在配置里
多写两项:
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。)