feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更: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)。
This commit is contained in:
1 parent
47dd818360
commit
f8e093c98f
36 files changed
+6865
-2204
No files matched your search
@@ -5,20 +5,20 @@
|
||||
* 游戏逻辑跑在**服务端进程**里,可以后台常驻;
|
||||
* 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩;
|
||||
* 每个玩家一个独立牧场(存档隔离),互相能看到排行榜;
|
||||
* 同时开放 **HTTP 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏——
|
||||
* 同时开放 **HTTP 接口 + SSE 实时推送**,AI / 脚本 / 机器人可以和人玩同一局游戏——
|
||||
服务端**不内置任何 AI**,它只提供协议。
|
||||
|
||||
```
|
||||
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
|
||||
│ AI / 脚本 │ ───────────────────────▶ │ │
|
||||
│ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │
|
||||
└──────────────┘ JSON 响应 │ (可后台常驻) │
|
||||
┌──────────────┐ WebSocket /ws │ │
|
||||
│ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │
|
||||
└──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │
|
||||
┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │
|
||||
│ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │
|
||||
└──────────────┘ └────────────────────────┘
|
||||
│ 机器人 │ ◀── JSON 响应 + SSE ──── │ FastAPI 服务器 │
|
||||
└──────────────┘ │ (可后台常驻) │
|
||||
┌──────────────┐ HTTP /api/* │ │
|
||||
│ 终端界面 TUI │ ───────────────────────▶ │ FarmClock 统一时钟 │
|
||||
└──────────────┘ ◀── SSE /api/stream ──── │ SessionManager 多牧场 │
|
||||
┌──────────────┐ HTTP /api/* │ ConnectionHub 推送 │
|
||||
│ 浏览器客户端 │ ───────────────────────▶ │ 存档(每牧场一份) │
|
||||
└──────────────┘ ◀── SSE /api/stream ──── └────────────────────────┘
|
||||
│
|
||||
farm.engine 纯游戏规则
|
||||
```
|
||||
@@ -47,7 +47,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
```
|
||||
|
||||
> **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`;
|
||||
> 依赖用 `pip install fastapi 'uvicorn[standard]'` 装。两种方式行为完全一致。
|
||||
> 依赖用 `pip install 'uvicorn[standard]' fastapi zttp` 装。两种方式行为完全一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -55,8 +55,8 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
|
||||
| 系统 | 规则 |
|
||||
| --- | --- |
|
||||
| **时间** | 每个牧场每 15 秒自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
|
||||
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 `sleep`(2 秒冷却) |
|
||||
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
|
||||
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天——时间只能自然流动,没有手动快进 |
|
||||
| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 |
|
||||
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
|
||||
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
|
||||
@@ -67,6 +67,11 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
|
||||
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
|
||||
|
||||
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
|
||||
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。
|
||||
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时)。想调试就把节奏调快:
|
||||
> `uv run main.py play --tick 3`(一天 3 秒)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 终端界面
|
||||
@@ -76,12 +81,12 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
||||
```
|
||||
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
|
||||
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
|
||||
[N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出
|
||||
[空格]暂停 [S]存档 [R]重置 [Q]退出
|
||||
```
|
||||
|
||||
界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。
|
||||
|
||||
**TUI 是纯客户端**:它通过 WebSocket 订阅状态、通过 WebSocket/HTTP 提交动作,
|
||||
**TUI 是纯客户端**:它通过 SSE 长连接订阅状态、通过 HTTP 提交动作,
|
||||
自己不持有任何游戏状态。所以:
|
||||
|
||||
* 服务端可以在另一台机器上,`uv run main.py play --url http://10.0.0.5:8765 --farm alice`;
|
||||
@@ -159,9 +164,10 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
|
||||
| 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` … 等 13 个 |
|
||||
| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 10 个 |
|
||||
| POST | `/api/admin/save` | 全部存档 |
|
||||
| POST | `/api/admin/shutdown` | 优雅停机 |
|
||||
| GET | `/docs` | 自动生成的 Swagger 文档 |
|
||||
@@ -171,9 +177,14 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
|
||||
```
|
||||
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()
|
||||
buy_feed(quantity?) buy_plot() pause(paused?)
|
||||
```
|
||||
|
||||
> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理,
|
||||
> 否则 `engine.save()` 会把存档写进默认路径、覆盖别人的档。仍然可以用
|
||||
> `POST /api/actions {"action":"save"}` 调用(服务端会话层拦截执行),
|
||||
> 只是没有 `/api/save` 便捷路由、也不出现在动作表文档里。
|
||||
|
||||
### 5.3 请求示例
|
||||
|
||||
```bash
|
||||
@@ -208,7 +219,7 @@ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ...
|
||||
"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
|
||||
"net_worth": 401.0, "seconds_to_tick": 2387.6, "paused": false, "online": 2
|
||||
},
|
||||
"log": [{"seq": 41, "day": 3, "kind": "action", "text": "在 1 号地播下小麦种子"}]
|
||||
}
|
||||
@@ -222,59 +233,81 @@ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ...
|
||||
|
||||
---
|
||||
|
||||
## 6. WebSocket 实时通道
|
||||
## 6. SSE 实时推送
|
||||
|
||||
> 为什么是 WebSocket 而不是 SSE?SSE 只能服务端 → 客户端单向推流,
|
||||
> 而这个游戏的上行(播种、喂食)和下行(状态、事件)是同一件业务的两半:
|
||||
> 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。
|
||||
> WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。
|
||||
> 为什么是 SSE?客户端要读的只有"状态变了",这是**服务器 → 客户端单向**的下行;
|
||||
> 而写操作(播种、喂食)本来就是普通 HTTP 请求。SSE 正好是"一条 HTTP 长连接单向推流",
|
||||
> 于是上下行各用最自然的协议,不必为了一条推送引入双向握手、消息帧与心跳管理。
|
||||
> 顺带换来的好处:`curl` 就能读、浏览器 `EventSource` 自带断线重连、
|
||||
> 客户端只靠标准库 `urllib` 就能订阅(一个第三方包都不用装)。
|
||||
|
||||
连接:
|
||||
|
||||
```
|
||||
ws://127.0.0.1:8765/ws?farm=alice&token=<可选>&name=<昵称>
|
||||
http://127.0.0.1:8765/api/stream?farm=alice&token=<可选>&name=<昵称>
|
||||
```
|
||||
|
||||
### 客户端 → 服务器
|
||||
响应是标准的 `text/event-stream`,每帧就是一行 JSON 加一个空行:
|
||||
|
||||
| 消息 | 说明 |
|
||||
| --- | --- |
|
||||
| `{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}}` | 执行动作,回执带同一个 `id` |
|
||||
| `{"type":"sync","id":2}` | 主动拉一次完整状态 + 合法动作 |
|
||||
| `{"type":"help","id":3}` | 拉规则与动作说明 |
|
||||
| `{"type":"ping"}` | 心跳,回 `pong` |
|
||||
```
|
||||
data: {"type":"welcome","farm":"alice","state":{…},"catalog":{…},"actions":[…],"online":1}
|
||||
|
||||
data: {"type":"state","seq":42,"farm":"alice","state":{…},"log":[…],"online":2}
|
||||
|
||||
: keep-alive
|
||||
```
|
||||
|
||||
### 服务器 → 客户端
|
||||
|
||||
| 消息 | 触发时机 |
|
||||
| 类型 | 触发时机 |
|
||||
| --- | --- |
|
||||
| `{"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":…}` | 协议错误 |
|
||||
| `welcome` | 连接建立:一次给全状态、目录、合法动作、日志、在线人数 |
|
||||
| `state` | 状态变化时推送(**节流合并到 4 次/秒**),带自增 `seq` |
|
||||
| `day` | 进入新的一天,附当日事件 |
|
||||
| `notice` | 有人加入 / 离开牧场 |
|
||||
| `bye` | 服务器正在停机(收到就可以收摊了) |
|
||||
| `error` | 通道层错误 |
|
||||
|
||||
用 Python 直接玩(不依赖本项目代码):
|
||||
心跳:无消息时每 15 秒发一行 `: keep-alive`(SSE 注释行),既防中间代理掐掉空闲连接,
|
||||
也让客户端能区分"只是没消息"和"连接真的断了"。
|
||||
|
||||
### 客户端 → 服务器
|
||||
|
||||
写操作**不占用**这条通道,直接 `POST /api/actions` 即可(见上一节)。
|
||||
两条路改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
||||
|
||||
用 Python 直接读(只用标准库,不依赖本项目代码):
|
||||
|
||||
```python
|
||||
import asyncio, json, websockets
|
||||
import json, urllib.request
|
||||
|
||||
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())
|
||||
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. 多人模型
|
||||
@@ -315,9 +348,12 @@ uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个
|
||||
> **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件,
|
||||
> 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**,
|
||||
> 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。
|
||||
> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程在端口绑定成功(lifespan 启动)后
|
||||
> 才写入自己的 `os.getpid()`,退出时再删掉。`stop` 则优先走 HTTP 优雅停机,
|
||||
> 拿不到 pid 也能停下来。
|
||||
> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程启动时写入自己的 `os.getpid()`,
|
||||
> 退出时再删掉。`stop` 则优先走 HTTP 优雅停机,拿不到 pid 也能停下来。
|
||||
>
|
||||
> 细节:uvicorn 的 lifespan 启动**早于**端口绑定,所以 pid 文件会比"端口可连"早半步出现。
|
||||
> 万一端口被占用、进程随即退出,收尾的 `finally` 会把这个 pid 文件删掉,
|
||||
> 不会留下幽灵进程让 `stop` 误判。
|
||||
|
||||
---
|
||||
|
||||
@@ -338,62 +374,28 @@ farm/
|
||||
keys.py 跨平台按键读取
|
||||
net.py 回环请求绕过系统代理的小工具
|
||||
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
|
||||
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP + WS)
|
||||
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP 写 + SSE 读)
|
||||
bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略)
|
||||
server/
|
||||
app.py FastAPI 应用:REST + WebSocket + 多牧场调度
|
||||
app.py FastAPI 应用:REST + SSE + 多牧场调度
|
||||
actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验
|
||||
session.py 牧场会话与统一时钟
|
||||
hub.py WebSocket 连接池与状态推送(线程 → 事件循环的桥)
|
||||
hub.py SSE 订阅池与状态推送(线程 → 事件循环的桥)
|
||||
daemon.py 守护进程:start / stop / status / 日志 / pid
|
||||
webui.py 内嵌网页客户端(单页,原生 JS + WebSocket)
|
||||
__main__.py uv run python -m farm.server(前台运行)
|
||||
webui.py 内嵌网页客户端(单页,原生 JS + EventSource)
|
||||
__main__.py uv run python -m farm.server(uvicorn 前台运行 + HTTP/2)
|
||||
tools/
|
||||
preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器)
|
||||
ws_demo.py 最小 WebSocket 客户端,验证协议
|
||||
tests/
|
||||
test_engine.py 引擎规则与存档(单机层)
|
||||
test_server.py 动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端
|
||||
sse_demo.py 最小 SSE 客户端(只用标准库),验证协议
|
||||
```
|
||||
|
||||
分层原则:**规则不认识网络,网络不认识终端。**
|
||||
`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。
|
||||
所以三端共享完全一致的裁判逻辑,也能各自单独测试。
|
||||
所以三端共享完全一致的裁判逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试
|
||||
|
||||
```bash
|
||||
uv run python -m unittest discover -s tests -v # 全部(97 项)
|
||||
uv run python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI
|
||||
uv run 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. 工程化:uv + ruff + pyright
|
||||
## 10. 工程化:uv + ruff + pyright
|
||||
|
||||
依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里——
|
||||
单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果,
|
||||
@@ -449,7 +451,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
||||
|
||||
---
|
||||
|
||||
## 12. 边界处理与设计取舍
|
||||
## 11. 边界处理与设计取舍
|
||||
|
||||
**输入校验**(唯一入口 `server/actions.py`):
|
||||
|
||||
@@ -463,7 +465,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
||||
**并发与容错**:
|
||||
|
||||
* 引擎内 `RLock` 串行化同一牧场,多牧场并行;
|
||||
* WebSocket 每条连接一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
|
||||
* SSE 每条订阅一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
|
||||
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
|
||||
* 引擎调用统一丢进线程池,不阻塞事件循环;
|
||||
* 单条连接/单个牧场出错不影响其它牧场;
|
||||
@@ -475,13 +477,35 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
||||
`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上",
|
||||
报的还是 `502 upstream connect failed` 这种看不出根因的错误。
|
||||
所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连,
|
||||
目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,`websockets` 默认不读代理变量。)
|
||||
目标是别的机器才尊重环境里的代理设置。(SSE 长连接也走这条路,所以本机连本机不会被代理截走。)
|
||||
|
||||
**测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器),
|
||||
`tools/ws_demo.py` 是一个最小 WebSocket 客户端,用来确认"动作上行、状态下行"两条路都通。
|
||||
`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 + WebSocket 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
|
||||
HTTP + SSE 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
|
||||
|
||||
*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)*
|
||||
Reference in new issue
Block a user