snowy-yang c7c8a6308a chore: 统一行尾为 LF,清理临时文件
新增 .gitattributes 把文本文件钉死为 LF:否则 git 在 Windows 的 autocrlf
会把仓库的 LF 转成工作区 CRLF,与 ruff format 的 line-ending=auto 互相打架,
每次格式化都在改行尾。
2026-09-18 21:13:01 +08:00

SimpleFarm · 小型农场游戏服务器

一个可以在终端里玩的农场经营游戏,同时也是一个小型多人游戏服务器:

  • 游戏逻辑跑在服务端进程里,可以后台常驻;
  • 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩;
  • 每个玩家一个独立牧场(存档隔离),互相能看到排行榜;
  • 同时开放 HTTP 接口 + WebSocket 实时通道,AI / 脚本 / 机器人可以和人玩同一局游戏—— 服务端不内置任何 AI,它只提供协议。
        ┌──────────────┐        HTTP /api/*        ┌────────────────────────┐
        │  AI / 脚本    │ ───────────────────────▶ │                        │
        │  机器人       │ ◀─────────────────────── │   FastAPI 服务器        │
        └──────────────┘        JSON 响应           │   (可后台常驻)         │
        ┌──────────────┐       WebSocket /ws        │                        │
        │  终端界面 TUI │ ◀───────────────────────▶ │  FarmClock 统一时钟     │
        └──────────────┘      状态推送 + 动作回执     │  SessionManager 多牧场  │
        ┌──────────────┐        WebSocket /ws        │  ConnectionHub 推送     │
        │  浏览器客户端 │ ◀───────────────────────▶ │  存档(每牧场一份)      │
        └──────────────┘                            └────────────────────────┘
                                                              │
                                                     farm.engine 纯游戏规则

1. 快速开始

pip install -r requirements.txt          # 只有服务端需要第三方依赖

python main.py serve --daemon            # ① 后台启动服务器
python main.py play --farm 我的农场       # ② 终端里玩(另开一个窗口)
python main.py bot --farm ai-1           # ③ 让机器人玩家一起经营
python main.py status                    # ④ 看服务器状态
python main.py stop                      # ⑤ 优雅停机(会把所有牧场存档)

浏览器打开 http://127.0.0.1:8765/ 就是网页版客户端(可多开几个标签页,用不同牧场名, 能实时看到对方的操作)。

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

python main.py solo                      # 本地引擎 + 终端界面,不开服务器

2. 玩法

系统 规则
时间 每个牧场每 15 秒自动推进一天(--tick 可调),作物生长、牲畜产出、市场波动都在此刻结算
行动点 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 sleep(2 秒冷却)
水分 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点
作物 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢
牲畜 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会永久离开
市场 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚
土地 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增
目标 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑
丰收 收获有 12% 概率触发双倍产出

净资产在 TUI 顶部实时显示,goal_index 表示已解锁的里程碑数。


3. 终端界面

python main.py play 打开。核心交互:

 [↑↓←→]选地  [P]播种  [W]浇水  [H]收获  [E]全部收获
 [F]喂食  [C]收取  [Tab]换牲畜  [B]买动物  [L]买地  [K]买饲料
 [N]休息  [空格]暂停  [S]存档  [R]重置  [Q]退出

界面用双宽字符感知的对齐算法(display_width),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。

TUI 是纯客户端:它通过 WebSocket 订阅状态、通过 WebSocket/HTTP 提交动作, 自己不持有任何游戏状态。所以:

  • 服务端可以在另一台机器上,python 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 玩法与接口说明

实例:

$ python 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 号地空闲,可种小麦
  ...

$ python 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 做增量拉取)
POST /api/actions 通用动作入口
POST /api/batch 批量动作(一次最多 50 个)
POST /api/{动作名} 便捷入口:/api/plant /api/harvest … 等 13 个
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()  sleep()  pause(paused?)  save()  reset()

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": 11.4, "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. WebSocket 实时通道

为什么是 WebSocket 而不是 SSE?SSE 只能服务端 → 客户端单向推流, 而这个游戏的上行(播种、喂食)和下行(状态、事件)是同一件业务的两半: 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。 WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。

连接:

ws://127.0.0.1:8765/ws?farm=alice&token=<可选>&name=<昵称>

客户端 → 服务器

消息 说明
{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}} 执行动作,回执带同一个 id
{"type":"sync","id":2} 主动拉一次完整状态 + 合法动作
{"type":"help","id":3} 拉规则与动作说明
{"type":"ping"} 心跳,回 pong

服务器 → 客户端

消息 触发时机
{"type":"welcome", "state":…, "catalog":…, "actions":…, "log":…, "online":1} 连接建立
{"type":"state", "seq":…, "state":…, "log":[…]} 状态变化时推送(节流合并到 4 次/秒)
{"type":"day", "day":5, "events":[…]} 进入新的一天
{"type":"result", "id":1, "ok":true, "message":…, "snapshot":…} 动作回执
{"type":"notice", "text":"web 来到了牧场(在线 2)"} 有人加入 / 离开
{"type":"error", "error":"bad_json", "message":…} 协议错误

用 Python 直接玩(不依赖本项目代码):

import asyncio, json, websockets

async def main():
    async with websockets.connect("ws://127.0.0.1:8765/ws?farm=demo") as ws:
        welcome = json.loads(await ws.recv())
        print("第", welcome["state"]["day"], "天")
        await ws.send(json.dumps({"type": "action", "id": 1, "action": "harvest_all"}))
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] == "result":
                print(msg["message"])
            elif msg["type"] == "state":
                print("金币", msg["state"]["gold"])

asyncio.run(main())

7. 多人模型

  • 一个牧场 = 一个玩家:独立引擎、独立时钟、独立存档(saves/<牧场名>.json)。
  • 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(GET /api/farms)。
  • 同牧场可以有多人/多程序同时操作,引擎内的 RLock 保证串行,不会出现"两个人同时种同一块地"这类竞态。
  • 所有牧场的时钟由一个 FarmClock 线程统一驱动(而不是每个牧场一条线程)。
  • 无人在线的牧场在 --idle-timeout(默认 30 分钟)后被移出内存,先落盘再释放; 玩家回来时自动从存档恢复。
  • 上限 --max-sessions(默认 64),超出返回 503 too_many_farms。

局域网联机:

python main.py serve --daemon --host 0.0.0.0 --port 8765 --token 找个密码
python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个密码

8. 守护进程

serve --daemon 之后:

项目 位置 / 命令
进程号 run/server.pid(由服务进程自己写,见下方说明)
日志 run/server.log(python main.py logs -n 50)
停止 python main.py stop(先走 HTTP 优雅停机,失败再发信号)
状态 python 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 传给子进程,子进程在端口绑定成功(lifespan 启动)后 才写入自己的 os.getpid(),退出时再删掉。stop 则优先走 HTTP 优雅停机, 拿不到 pid 也能停下来。


9. 代码结构

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 + WS)
  bot.py                规则机器人:给候选动作打分(可整体替换为 LLM 策略)
  server/
    app.py              FastAPI 应用:REST + WebSocket + 多牧场调度
    actions.py          动作表:所有入口共用的唯一执行通道 + 参数校验
    session.py          牧场会话与统一时钟
    hub.py              WebSocket 连接池与状态推送(线程 → 事件循环的桥)
    daemon.py           守护进程:start / stop / status / 日志 / pid
    webui.py            内嵌网页客户端(单页,原生 JS + WebSocket)
    __main__.py         python -m farm.server(前台运行)
tools/
  preview_tui.py        把 TUI 渲染成文本(--url 可连真实服务器)
  ws_demo.py            最小 WebSocket 客户端,验证协议
tests/
  test_engine.py        引擎规则与存档(单机层)
  test_server.py        动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端

分层原则:规则不认识网络,网络不认识终端。 farm.engine 不知道有 HTTP,tui.py 不知道有 socket,server/* 不知道有终端。 所以三端共享完全一致的裁判逻辑,也能各自单独测试。


10. 测试

python -m unittest discover -s tests -v        # 全部(97 项)
python -m unittest tests.test_engine           # 只测游戏规则,不需要 FastAPI
python -m unittest tests.test_server.TestLiveServer   # 真起服务器 + 真 WebSocket

覆盖范围:

  • 引擎规则、边界与存档兼容(45 项)
  • 动作表参数校验:缺参 / 多参 / 类型错 / 未知动作(10 项)
  • 牧场 ID 校验:路径穿越、超长、非法字符(3 项)
  • 多牧场隔离、排行榜、空闲回收、暂停保护、容量上限(6 项)
  • REST 语义:状态、动作、批量、日志增量、鉴权 401、坏 JSON、X-Farm-Id 头(20 项)
  • 真网络端到端:真 uvicorn + 真客户端,验证 HTTP 读写、两个客户端共享一个牧场、 动作确实走了 WebSocket、WS 断开后自动退回 HTTP、机器人只靠接口也能经营(7 项)

另外做过一轮真实环境实测(--daemon 起服务,再用命令行/脚本连上去):

serve --daemon → status 显示正确 pid → actions 列出 13 个候选动作
→ do plant / buy_animal / feed → batch 部分失败不影响其它动作
→ 排行榜出现两个互相隔离的牧场(alice 25 金币 / bob 120 金币)
→ WebSocket 客户端依次收到 welcome / state / result / day 四类消息
→ 机器人从 120 金币经营到 197 金币、净资产 276(0 次失败)
→ stop 优雅停机,所有牧场写入 saves/*.json

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 串行化同一牧场,多牧场并行;
  • WebSocket 每条连接一个出口队列(满 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():目标是回环地址就直连, 目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,websockets 默认不读代理变量。)

测试产物:tools/preview_tui.py 可以把任意时刻的界面渲染成文本(支持 --url 连真实服务器), tools/ws_demo.py 是一个最小 WebSocket 客户端,用来确认"动作上行、状态下行"两条路都通。

为什么放弃 gRPC:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON; gRPC 需要 .proto 编译产物、需要客户端装 grpcio,接入成本换来的性能在这个场景里毫无意义。 HTTP + WebSocket 用 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%