snowy-yang a9bc8238ab 序列化热路径去重:to_dict 里重复求值的属性改局部复用
为什么:snapshot() 同时压在 SSE 每 250ms 推送和 full=1 动作回执两条
高频路径上,而它的成本大头是重复求值——profile 显示单次快照
1,128,001 次函数调用里近半是属性链重复计算同一份配置数据。

- Plot.to_dict:grow_days 原来经 4 个属性各求值一遍(本体 + days_left
  + is_ripe + stage),16 块地就是 64 次配置表查询。改为算一次局部
  复用;stage 的实现体拆到 _stage(grow),规则本体仍只有一份。
- Animal.to_dict:info 配置表原来查 4 次、cycle int() 两遍,改取一次。
- GameState.to_dict:weather_info 3 键 2 次查询;granary_free 原来把
  granary_capacity 和 storage_used 整个重算,改算一轮复用。

行为零变化(规则本体全在原属性里,532 项测试全绿)。
基准(tools/bench.py --repeat 5,最坏形态 16 地 + 8 牲畜):

    场景                       优化前      优化后
    engine.snapshot()          50.1 µs     44.2 µs      -12%
    session.snapshot()         51.0 µs     41.9 µs      -18%
    build_state_message()      50.9 µs     43.3 µs      -15%
    engine.available_actions   52.7 µs     48.7 µs       -8%

单次 snapshot 函数调用数 1,128,001 → 588,001(-48%),本次数字已记入
bench.py docstring 作长期参考。重复计数器全 OK(to_dict/snapshot/
net_worth 每请求仍各 1 次,无回归)。
2026-09-26 11:08:14 +08:00

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/ 就是网页版客户端(可多开几个标签页,用不同牧场名, 能实时看到对方的操作)。页面顶部是 Canvas 二维农场地图:农田方阵、牧场围栏、 建筑带画在一张俯视图上,作物随生长进度变大、成熟发光,雨/旱/暴风雨有对应天气效果; 点地块直接播种/浇水/收获,点牲畜喂食/收取(下方的卡片面板保留,作精确操作用)。

不想装任何第三方库、只想单机玩玩:

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 <动作> '<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
共 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 请求示例

# 看现在能做什么
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' ...

响应示例:

{
  "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/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。

局域网联机:

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;
  • 牧场名会被用作文件名 → 白名单校验,挡掉 ../、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 这个包上,所以启用方式就是在配置里 多写两项:

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。)

S
Description
终端农场经营游戏 + 小型多人游戏服务器:TUI / 网页双客户端,HTTP + SSE 接口给 AI 与脚本调用
Readme
1.3 MiB
0 Stars 1 Watchers 0 Forks
Languages
Python 100%