Files
SimpleFarm/README.md
T
snowy-yang a7725f8812 chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
2026-09-18 21:12:40 +08:00

425 lines
20 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 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏——
服务端**不内置任何 AI**,它只提供协议。
```
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
│ AI / 脚本 │ ───────────────────────▶ │ │
│ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │
└──────────────┘ JSON 响应 │ (可后台常驻) │
┌──────────────┐ WebSocket /ws │ │
│ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │
└──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │
┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │
│ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │
└──────────────┘ └────────────────────────┘
│
farm.engine 纯游戏规则
```
---
## 1. 快速开始
```bash
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/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
能实时看到对方的操作)。
不想装任何第三方库、只想单机玩玩:
```bash
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` | 玩法与接口说明 |
实例:
```bash
$ 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 请求示例
```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": 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 直接玩(不依赖本项目代码):
```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`。
局域网联机:
```bash
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. 测试
```bash
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`。)*