From f8e093c98f81c5d2cbaa7e269124fae3f3097a10 Mon Sep 17 00:00:00 2001
From: snowy-yang <102860110+snowy-yang@users.noreply.github.com>
Date: Sat, 19 Sep 2026 12:55:45 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E6=97=B6=E9=80=9A=E9=81=93?=
=?UTF-8?q?=E6=94=B9=20SSE=E3=80=81=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=8D=A2=20?=
=?UTF-8?q?uvicorn+zttp=E3=80=81=E8=8A=82=E5=A5=8F=E6=94=B9=2040=20?=
=?UTF-8?q?=E5=88=86=E9=92=9F=E4=B8=80=E5=A4=A9=EF=BC=8C=E5=B9=B6=E9=87=8D?=
=?UTF-8?q?=E5=BB=BA=E6=B5=8B=E8=AF=95=E4=B8=8E=E4=BC=98=E5=8C=96=E7=83=AD?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
三件事凑成一次不兼容变更: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)。
---
.gitignore | 1 -
.workbuddy/memory/2026-09-19.md | 215 ++++
.workbuddy/memory/MEMORY.md | 98 +-
README.md | 228 +++--
ai_player.py | 4 +-
farm/__init__.py | 2 +-
farm/bot.py | 68 +-
farm/client.py | 294 ++----
farm/config.py | 10 +-
farm/engine.py | 358 ++++---
farm/keys.py | 282 +++++-
farm/models.py | 43 +-
farm/net.py | 3 +-
farm/server/__init__.py | 4 +-
farm/server/__main__.py | 259 +++--
farm/server/actions.py | 22 +-
farm/server/app.py | 712 +++++++-------
farm/server/daemon.py | 62 +-
farm/server/hub.py | 99 +-
farm/server/session.py | 46 +-
farm/server/webui.py | 55 +-
farm/tui.py | 58 +-
main.py | 92 +-
pyproject.toml | 24 +-
requirements.txt | 3 +
tests/test_actions.py | 332 +++++++
tests/test_bot.py | 394 ++++++++
tests/test_client.py | 990 +++++++++++++++++++
tests/test_engine.py | 1320 ++++++++++++++++---------
tests/test_server.py | 1613 ++++++++++++++++++++++---------
tests/test_tui_keys.py | 655 +++++++++++++
tools/bench.py | 345 +++++++
tools/preview_tui.py | 8 +-
tools/sse_demo.py | 147 +++
tools/ws_demo.py | 105 --
uv.lock | 118 ++-
36 files changed, 6865 insertions(+), 2204 deletions(-)
create mode 100644 .workbuddy/memory/2026-09-19.md
create mode 100644 tests/test_actions.py
create mode 100644 tests/test_bot.py
create mode 100644 tests/test_client.py
create mode 100644 tests/test_tui_keys.py
create mode 100644 tools/bench.py
create mode 100644 tools/sse_demo.py
delete mode 100644 tools/ws_demo.py
diff --git a/.gitignore b/.gitignore
index 25399cb..f33a6a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,7 +23,6 @@ venv/
# saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库)
run/
saves/
-tests/_tmp/
# 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等),
# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。
/_*
diff --git a/.workbuddy/memory/2026-09-19.md b/.workbuddy/memory/2026-09-19.md
new file mode 100644
index 0000000..e7a6dd8
--- /dev/null
+++ b/.workbuddy/memory/2026-09-19.md
@@ -0,0 +1,215 @@
+## 2026-09-19 代码结构审查(只读,未改代码)
+
+- 全量读了 engine/models/actions/app/session/hub/client/storage/bot,跑通 ruff/format/pyright/97 测试,全绿。
+- 结论:分层健康,无需返工。发现 5 个可优化点:
+ 1. engine.Scheduler 与 server.FarmClock 重复实现同一套 tick 决策(暂停顺延+deadline);
+ 2. actions.py 表内注册的 save/reset 在服务端被 execute_action 拦截成死代码,未来绕过即触发默认路径覆盖存档的隐患;
+ 3. app.brief() 手工挑 26 个字段,建议改 BRIEF_KEYS 常量派生;
+ 4. hub.Publisher 的 except Exception: pass 静默吞异常(违反项目 contextlib.suppress 约定且无痕迹);
+ 5. client.pause() 手工 merge paused 疑似冗余(回执 brief 已带 paused 且 _action 已 apply)。
+- 未动手改,等用户决定优先级。
+
+## 2026-09-19 结构审查 5 项优化已全部落地(+88/-76,7 个文件)
+
+1. engine.py 新增 tick_decision() 纯函数,Scheduler 与 FarmClock 共用(原两处重复的暂停顺延+deadline 逻辑合并)。
+2. actions.py 移除 save/reset 表项(服务端死代码隐患),注释说明存档类动作只能走 app.execute_action 会话拦截;/api/save、/api/reset 便捷路由随之消失,POST /api/actions {action:save} 仍可用。
+3. app.py brief() 改为 BRIEF_KEYS 常量派生。
+4. hub.Publisher 吞异常改为 errors 计数,/api/health 新增 publish_errors 字段。
+5. client.pause() 删除冗余手工 merge,收敛为纯转发。
+- README 动作清单与便捷入口数(13→11)同步更新。
+- 门禁:ruff format/check、pyright、97 项测试全绿。
+- 教训:对同一文件连续 Edit 时 old_string/new_string 写反了两次(一次添加了重复定义、一次误删 def 行),靠重读文件恢复。批量改动应先用幂等补丁脚本。
+
+## 2026-09-19 实时通道从 WebSocket 整体迁移到 SSE
+
+- 决策过程:用户先问「HTTP 能双向吗」「HTTP/2 也不行吗」,讲清 Server Push 已废弃、
+ H2 双向流等于 gRPC(本项目已否决)后,用户拍板「换 SSE,删掉 WebSocket」。
+- 服务端:hub.py 改为 SSE 订阅池(新增 sse_frame / sse_heartbeat / CLOSE_MESSAGE /
+ close_all);app.py 用 GET /api/stream(StreamingResponse)替换 /ws,删掉全部 WS 分支;
+ 生成器每秒空转一次以便发现停机、按 15s 发心跳注释。
+- 客户端:client.py 删掉 asyncio/websockets/Future 回执机制,改为 urllib 流式读 SSE
+ (mode: http/sse/poll),写操作统一 HTTP,新增 _underlying_socket() 用于立即断流。
+- 网页版 webui.py 改 EventSource + fetch POST;tools/ws_demo.py 换成 tools/sse_demo.py。
+- 测试:WS 用例改写为 SSE,新增 test_sse_stream_speaks_the_protocol(裸流验协议)
+ 与 test_writes_go_over_http,98 项全绿,耗时回到 5.2s。
+- 踩坑(重要):SSE 长连接会拖死优雅停机,三处一起修才干净:
+ 1) 停机路径 hub.close_all() 发 bye 让生成器自己 break;
+ 2) uvicorn Config 加 timeout_graceful_shutdown=5 兜底;
+ 3) 客户端 stop() 必须 shutdown 底层 socket——直接 response.close() 会等读线程的锁,
+ 最长卡到下一次心跳(15s)。实测:带长连接停机 0.2s 完成。
+- 文档:README 第 6 节重写为 SSE(含 curl/urllib/EventSource 示例)、架构图与结构表同步,
+ pyproject 描述与关键词更新,main.py/ai_player.py/__main__.py 帮助文案更新。
+- 新增 env 依赖说明:uvicorn[standard] 仍会捎带 websockets,但项目已不再使用。
+
+
+## 2026-09-19 ASGI 服务器从 uvicorn 换成 Hypercorn(拿到 HTTP/2)
+
+- 依赖:`uv add hypercorn` / `uv remove uvicorn`(hypercorn 0.18 自带 h11/h2/wsproto),
+ requirements.txt 重新导出,pyproject 依赖注释改写。
+- `__main__.py`:`hypercorn.asyncio.serve` + Config(bind/loglevel/accesslog/
+ graceful_timeout=5,语义等同 uvicorn 的 timeout_graceful_shutdown);shutdown_trigger
+ 用 asyncio.Event,由 `handle.on_stop` 置位 —— 于是 POST /api/admin/shutdown 与
+ Ctrl+C/SIGTERM/SIGBREAK 走完全相同的收尾路径;第二次信号强制退出(os._exit(130))。
+- `app.py` 新增 `as_asgi()`:Hypercorn 的 `hypercorn.typing.Scope` 与
+ `starlette.types.Scope` 两个 TypedDict 互不兼容,pyright 会在 serve() 处报错,用返回
+ Any 的桥函数收口(一处注释说明)。
+- **踩坑 1(真 bug,早于本次改动)**:`clear_pid_file` 里"文件还开着就 os.remove",
+ Windows 抛 PermissionError(WinError 32),又被 `except OSError` 吞掉 →
+ 每次正常退出都留下 stale pid 文件。改为先读完关句柄再删,并补 3 条回归测试。
+- **踩坑 2**:Windows 上 SO_REUSEADDR 让第二个实例能绑上已监听端口(还会抢走新连接),
+ Hypercorn 因此不会报"端口占用"。新增 `daemon.port_busy(host, port)` 公用函数
+ ("能不能连上"而不是"能不能 bind"),入口启动前自检,占用则退 3 并提示换端口;
+ `DaemonManager.port_busy()` 改为委托它,并补了测试。
+- 测试:HAS_UVICORN→HAS_HYPERCORN;TestLiveServer 用 hypercorn serve 起后台线程、
+ 靠 shutdown_trigger 收尾;新增 TestServerEntry(4 项)与 test_port_conflict_is_refused,
+ 共 103 项 / 5.2s。
+- 文档:README 第 6 节补 HTTP/2 说明、第 8 节补"lifespan 早于 bind"的 pid 细节、
+ 第 12 节加"为什么是 Hypercorn(而不是 uvicorn)";测试清单按实际类重新计数。
+ 顺手补掉上一轮 SSE 迁移漏掉的两处文案(tui.py 帮助页、farm/__init__.py 分层说明)。
+- 实测(真实子进程):h2c 用 h2 库裸说协议 200;h2c 上读 SSE 正常;写操作→推送
+ 0.08~0.15s;挂着长连接停机 0.08s 且收到 bye;SIGBREAK 停机 0.07s;
+ daemon start/status/stop 全通(stop 1.12s);端口冲突退 3 并给出提示。
+
+## 2026-09-19 ASGI 服务器换回 uvicorn(HTTP/2 由 zttp 提供)
+
+- 决策:用户要求「换回 uvicorn 并且安装 h2 支持并启用」。关键发现:uvicorn 0.53 自己不再
+ 内置 h2,它的 HTTP/2 实现建立在 `zttp` 包上(`uvicorn/protocols/http/zttp_h2_impl.py`;
+ 另有 `auto_zttp_impl.AutoZttpProtocol` 按 ALPN / h2c 前导字节自动分流 HTTP/1.1 与 h2)。
+ 启用方式 `Config(app, http="zttp", http2=True)`;没装 zttp 时 `Config.load()` 直接 raise。
+- 依赖:`uv remove hypercorn`(连带 h2/hpack/hyperframe/wsproto/priority);
+ `uv add "uvicorn[standard]" zttp`——HTTP/2 能力保留,[standard] 仍捎带 websockets(未使用)。
+- `__main__.py`:uvicorn.Server + `Config(http="zttp", http2=True, timeout_graceful_shutdown=5)`;
+ 信号接管改为**子类覆盖 `Server.handle_exit`**(0.53 没有 install_signal_handlers),
+ 不调 super()、不写 `_captured_signals`(避免退出时重放信号),第二次信号 `os._exit(130)`。
+ 服务器子类写在 `main()` 内部,因为 uvicorn 是延迟导入的(离线玩法不该被迫装它)。
+- 修正一处历史错误注释:uvicorn 0.53 的启动顺序**也是**先 lifespan 再 bind 端口。
+- 测试:`HAS_HYPERCORN` → `HAS_UVICORN`;TestLiveServer 用 `uvicorn.Server.run()` 起后台线程
+ (非主线程 → capture_signals 自动跳过,不会劫持测试进程的信号),停机走 `request_stop()`。
+ 顺带删掉测试里已无用的 `import asyncio`。103 项全绿(6.0s)。
+- 实测(真子进程,非 mock):h2c `GET /api/state` → `:status 200`(用 h2 库裸说协议,
+ 本机 curl 没编进 HTTP/2);同一条 h2c 连接上读 SSE 收到 welcome;HTTP/1.1 写操作 →
+ 推送到达 0.10s;**挂着 2 条长连接停机 0.52s**(长连接收到 bye);SIGBREAK 停机 0.49s;
+ 端口冲突退 3 并提示换端口;守护进程 start/status/stop 全通;pid 文件每次都清理干净。
+- 文档:README 第 6 节与第 12 节重写(uvicorn + zttp 的启用法,附配置片段)、结构表、
+ 测试清单同步;pyproject 依赖注释说明为什么需要 zttp;requirements.txt 重新导出。
+## 2026-09-19 修复 TUI 键盘监听(方向键/空格/Tab/ESC 全失效)
+
+- 症状:只有字母键和数字键有反应,方向键、空格(暂停)、Tab(切牲畜)、ESC(关菜单)全都没用。
+- 根因:`farm/keys.py` 的具名键返回**大写**(`"UP"`/`"SPACE"`/`"TAB"`/`"ESC"`),
+ 而 `farm/tui.py` 的处理表里写的是**小写**(`"up"`/`" "`/`"tab"`/`"esc"`),
+ `handler.get(key)` 永远取不到 → 静默无动作。字母键因为 `char.lower()` 恰好对得上才没事。
+- 修法(不是改一处字符串,而是把约定固化下来):
+ 1. `keys.py` 定一套小写具名键常量 + `NAMED_KEYS`,两条平台路径都返回这套名字;
+ 2. `tui.py` 全部改用 `keys.XXX` 引用,pyright 能直接查出拼写错误;删掉 `"tab\t"` 这个历史 hack;
+ 3. 顺手修掉 POSIX 路径的老毛病:一次 `os.read` 读到多个字节时只取第一个、其余静默丢弃
+ (快打/粘贴会丢键);现在带缓冲区逐个返回,并能正确处理被拆成两次读到的转义序列与半个汉字;
+ 补上 `ESC [ 3 ~`(Delete)等序列;单按 ESC 与序列开头用 20ms 等待区分。
+ 4. 新增 `tests/test_tui_keys.py`(19 项):逐个具名键驱动真实 TuiApp 做**行为**校验,
+ 外加"两端映射表必须落在同一套名字里"的约定校验——正是它抓出了 POSIX 缺 Delete 的不一致。
+ 5. 读不到键盘时(管道/非 Win32 控制台,如 Git Bash 的 mintty)TUI 底部直接显示原因,
+ 不再让玩家对着界面干等;预览工具用 `show_keyboard_notice=False` 保持按键提示。
+- 门禁:ruff / pyright 零报错,测试 103 → **122 项**全过。
+
+## 2026-09-19 一天改成 40 分钟 + 移除「休息」动作(纯放置)
+
+- 用户反馈两条:① 键盘监听不对(方向键/空格/Tab/ESC 全失效);② 15 秒一天太快。
+ 问清目标后拍板:**一天 40 分钟**、**「休息」直接去掉**(只剩时间自然流动)。
+- 节奏:`DEFAULT_TICK_SECONDS` 15 → 2400;`MAX_TICK_SECONDS` 600 → 86400
+ (旧上限会把 40 分钟夹回 10 分钟,这是必须先拆的硬门槛)。
+- 移除 sleep 的全部触点:`engine.sleep()`、`config.SLEEP_COOLDOWN`、`_last_sleep`、
+ `client.RemoteFarm.sleep()`、`server/actions.py` 的注册、TUI `[N]休息` 与帮助文案、
+ 网页版按钮与文本清单、`app.rules_text()` 给 AI 的说明、README、`main.py` 提示、
+ `tools/sse_demo.py`(改成挑 welcome 里的第一个合法动作,不再写死动作名)。
+- 机器人:`rest()` → `wait_for_next_day(timeout)`,`run()` 新增 `wait_seconds`(默认 0 = 不干等,
+ 直接收工);顺带删掉再也不会出现的 cooldown 特例。CLI 加 `bot --wait`。
+- 显示:新增 `farm.models.format_duration()`,40 分钟显示成 `40:00` 而不是 `2399.6s`
+ (TUI 头部 / `main.py state` / 网页版 JS 同名函数)。
+- 顺手修:`FarmSession.__init__` 立刻设 `next_tick_at`,消除新建牧场后 ≤0.2 秒内
+ 倒计时为 None(客户端会误显示"已暂停/手动模式")的空窗。
+- 测试:删 `test_sleep_cooldown`,加 tick 夹取/默认值/`sleep` 已移除、`format_duration`
+ 分档、`tests/test_bot.py`(等待策略)。103 → **131 项**全绿,ruff/pyright 零报错。
+- 端到端实测(真服务器 + 真 CLI):动作表只剩 10 个(无 sleep)、`POST sleep` 返回
+ `unknown_action`、`seconds_to_tick=2400.0`、`main.py state` 显示「39:59 后进入下一天」。
+
+
+## 2026-09-19 删除全部测试套件(tests/ 与所有引用一并清理)
+
+- 用户要求「删除全部 test」;确认后选:删目录 + 清理全部引用、**不留备份**。
+- 删除 `tests/`:test_engine / test_server / test_tui_keys / test_bot(共 131 项)+ `tests/_tmp/`。
+- 引用清理(一次性幂等补丁脚本,跑完即删):
+ pyproject 的 ruff `src`、per-file-ignores `"tests/*"`、pyright `include`/`exclude`;
+ `.gitignore` 的 `tests/_tmp/`;`main.py` 帮助里的测试命令;`farm/keys.py` docstring 的引用;
+ README 目录树的三行 + 整节「10. 测试」(后续章节 11/12 顺延为 10/11)。
+- 依赖:`uv remove --dev httpx`(连带 httpcore / certifi)——它只是 FastAPI TestClient 的底座,
+ 没有测试就没人用它;uv.lock 同步。requirements.txt 不动(本来就只导出运行依赖)。
+- 门禁:ruff check / format 通过,pyright 0 错误(25 个文件)。
+- 代价与风险:未跟踪的 test_tui_keys.py / test_bot.py 就此永久丢失(未提交,用户明确选择不备份);
+ 键盘约定的保障从"行为测试"退化为"改 keys.py 时人工核对 tui.py 的引用"。
+ README 第 307 行提到 httpx 是 HTTP/2 客户端写法示例,与依赖无关,保留。
+
+## 2026-09-19 重建测试套件(500 项)+ 修两处 client 隐患
+
+- 用户要求「完成测试代码」;确认后选:**完整重建**覆盖,但**只写测试文件**
+ (不恢复上一轮剥掉的工程配置:tests 仍不进 ruff `src` / pyright `include` / per-file-ignores,
+ `httpx` 保持移除)。
+- 新增 6 个文件、**500 项**:test_engine 110 / test_actions 35 / test_server 131(skip 35) /
+ test_tui_keys 75 / test_bot 36 / test_client 113。
+- skip-guard 模式:`try: import fastapi/httpx/uvicorn ... HAS_X = True except: False` +
+ `@unittest.skipUnless`。没装 httpx → `TestClient` 与真机 `LiveServer` 那 35 项自动跳过,
+ 其余 465 项照跑;所以 `uv run python -m unittest discover -s tests` 是**真绿**而不是靠跳过伪装。
+- `test_server.LiveServerTestCase` 起真 in-process uvicorn(`http="zttp", http2=True`,绑自由端口),
+ 用 `RemoteFarm` + 裸 SSE 读取做端到端;`test_client.HttpTransportTestCase` 起**纯标准库**
+ `http.server` 回环桩(不依赖第三方),顺带证明 `net.open_url` 的回环直连真的生效。
+- 顺手修 `farm/client.py` 两处(是测试逼出来的真隐患,不是测试写错):
+ 1. `_normalize()` 原来**先 `rstrip("/")` 再补 scheme**,于是 `"http://"` → `"http:"` →
+ 再拼成 `"http://http:"`;地址没有主机名 → `is_loopback(None) == False` → 请求走系统代理 →
+ 报一个和真实原因无关的 502。改成**先补 scheme 再削尾部斜杠**,畸形端点
+ (`''` / `' '` / `'//'` / `'http://'`)统一回落 `DEFAULT_ENDPOINT`。
+ 2. `StateView.__init__` 预置 `plots` / `animals` 为空列表:原先 `apply({})` 提前返回,
+ 空视图**根本没有这两个属性**,`autostart=False` 或服务器连不上时读 `state.plots`
+ 直接 AttributeError(本该显示一片空农田)。
+- 写测试踩到的坑(记下来省下次):
+ - **bytes 字面量不能含非 ASCII**:`b"不是 JSON"` 直接 SyntaxError,
+ 必须 `"...".encode()`(ruff 还会顺手提示 UP012 去掉多余的 `"utf-8"`)。
+ - `OpenerDirector.add_handler` **只在"至少挂上一个钩子"时才把 handler 记进 `handlers`**;
+ `ProxyHandler({})` 一个 `*_open` 都不注册 → 它自己反而不在 `handlers` 里。
+ 这**正是**回环直连生效的机制,不能想当然断言"handlers 里应该有个 ProxyHandler"。
+- 门禁:`ruff format .` 只动了 6 个测试文件(源码本来就干净)→ 但 `ruff check .` 一开始报
+ 9 条(全在 `tests/`,因为没有 per-file-ignores)。既然不加配置,就把**测试文件本身改干净**
+ (SIM117 合并 with / RUF012 用 `ClassVar` / RUF100 删无效 noqa / UP012 / F401),
+ 现在 `ruff check .` **All checks passed** + pyright **0 errors** + format 31 文件 unchanged。
+- 收尾:6 个测试文件全部 LF(CRLF=0)。
+
+## 2026-09-19 代码优化:结构拆分 + 热路径 + 消重(benchmark-first)
+
+- 用户要求「优化代码」,勾选三项:结构拆分 / 性能热路径 / 消除重复逻辑(明确不要"只出报告")。
+- **先量后改**:新增 tools/bench.py(ASGI 直调,不走 httpx;warmup + best-of-K),
+ 并给 to_dict / snapshot / brief_source / net_worth 装调用计数器。它同时是**守卫**:
+ 以后谁再把 brief 路径改回 snapshot(),计数会立刻变红。
+- 热路径根因:brief()/summary()/动作回执本来只要 ~17 个标量,却调 session.snapshot()
+ 做**整局序列化**;GameState.to_dict 内部还重复算 3 次 net_worth(每次访问都重算属性)。
+- 改法:
+ 1. engine.brief_source() —— 只读标量的新方法(不碰 snapshot);session.summary()、
+ app.brief() 改用它,BRIEF_KEYS + 新增 BRIEF_TODO_KEYS 白名单派生。
+ 2. models.next_goal_after(index) 提为模块级;to_dict 里 net_worth 只算 1 次,复用于
+ net_worth / goal_index / next_goal(删掉 next_goal property)。
+ 3. 结构:create_app 475 行 → 32 行(抽出 _Services dataclass 持 8 件接线 + 8 个模块级
+ _register_* 函数,内嵌 lifespan 生成器变成 _Services.lifespan 方法);
+ main.build_parser 拆 server/client 两组;engine.available_actions 拆
+ _plot_actions/_animal_actions/_investment_actions;__main__.main 160 → 7 行
+ (_resolve_save_dir / _start_daemon / _print_startup_banner / _ensure_port_free /
+ _run_foreground,uvicorn 那段含内嵌 FarmServer 逐字平移)。
+ 4. 消重:engine 的"等级门槛"规则重复 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;app.py SSE 的
+ notice(text, exclude) 让"来了/走了"共用一条路径。
+- 结果(16 地 + 8 牲畜终局,5 轮最快):brief 63.6→15.6µs(-76%)、summary 60.2→14.0µs(-77%)、
+ leaderboard(32) 1981.7→454.8µs(-78%)、snapshot 57.3→45.0µs(-21%)、
+ GET /api/world 193.6→132.3µs(-31%)、POST full=1 464.7→392.9µs(-16%)、
+ GET /api/actions 398.3→335.1µs(-16%)。动作回执的序列化次数 to_dict 2→1、
+ snapshot 2→1、net_worth 6→1。>60 行的函数 15 个 → 8 个。
+- 门禁:ruff format/check 全绿、pyright 0 错误、**500 项测试 OK(skip 35)**。
+ bench 的 ASGI 直调**不跑 FastAPI lifespan**,所以 lifespan 的活改造用
+ test_server 里的真 in-process uvicorn(LiveServerTestCase)单独验。
+- 临时补丁脚本一律 read_bytes/write_bytes 保 LF,跑完即删(本次 4 个:_splice_app /
+ _dedup_engine / _dedup_main / _split_entry)。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
index 82560c5..7478b76 100644
--- a/.workbuddy/memory/MEMORY.md
+++ b/.workbuddy/memory/MEMORY.md
@@ -2,7 +2,7 @@
## 是什么
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
-TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(服务端不内置 AI)。
+TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
## 运行方式(v2.0 起由 uv 统一管理)
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
@@ -10,28 +10,104 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 ,文档 /docs
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
- AI/脚本入口:`main.py actions | do <动作> '' | batch | state --json | watch | bot`
-- 测试:`uv run python -m unittest discover -s tests`(97 项)
+- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。
+ 但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、
+ 不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 ——
+ 于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。
+ 代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。
+ 覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) /
+ `test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。
- 质量门禁(改动后都应跑一遍):
- `uv run ruff check .` / `uv run ruff format .` / `uv run pyright`
+ `uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
+ (2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`)
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。
+## 玩法节奏(2026-09-19 起)
+- **一天 40 分钟**:`config.DEFAULT_TICK_SECONDS = 2400`,`MAX_TICK_SECONDS = 86400`
+ (旧上限 600 秒会把 40 分钟悄悄夹回去,已放宽)。`--tick` 可调,调试用 `--tick 3`。
+- **没有「手动推进一天」**:`engine.sleep()` / `SLEEP_COOLDOWN` / `/api/sleep` /
+ TUI 的 `[N]休息` / 网页版按钮**全部移除**,时间只能自然流动。
+ 机器人改为 `RuleBot.wait_for_next_day(timeout)`,CLI `bot --wait` 控制等多久(0 = 直接收工)。
+ 再加任何“快进一天”的入口前先想清楚:它会让慢时钟彻底失效。
+- 时长显示统一走 `farm.models.format_duration()`(2400 秒 → `40:00`),
+ TUI 头部倒计时 / `main.py state` / 网页版(JS 里同名函数)都用它,别再写 `{x}s`。
+- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s)
+ 之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。
+
## 架构约定(改动时必须遵守)
- **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义,
- 这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。
+ 这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。两条容易踩的:
+ `StateView.apply()` 里"带 `plots` = 完整快照(整体替换)、不带 = 精简状态(只合并字段)"——
+ 搞反了播一次种就清空整片农田;`RemoteFarm._normalize()` 必须**先补 scheme 再削尾部斜杠**,
+ 顺序反了 `"http://"` 会被削成 `"http:"`,地址没有主机名 → 请求溜到系统代理 → 报一个
+ 和真实原因无关的 502。空视图(`StateView()`)也要预置 `plots`/`animals` 为空列表,
+ 否则连不上服务器时读 `state.plots` 直接 AttributeError。
+- **按键名统一小写**:`farm/keys.py` 是按键名的唯一来源,具名键全小写
+ (`UP`/`SPACE`/`TAB`/`ESC`/`CTRL_C` … 常量值即小写字符串),Windows 与 POSIX
+ 两条路径必须返回**完全一样**的名字。`tui.py` 一律用 `keys.XXX` 引用,**不要手写
+ 字符串字面量**:历史 bug 就是 Windows 侧产大写 `"UP"`、TUI 里写小写 `"up"`,
+ 方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
+动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
+ 读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟;
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
-- **实时通道**:WebSocket(`/ws`)是唯一实时通道,推送节流 250ms,消息带自增 `seq`。
- 写操作优先走 WS(带 `id` 回执与 `snapshot`),只有 ws_closed/timeout/network
- 才退回 HTTP 重试;**业务错误必须原样抛出,不能重试**。
+- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送
+ (welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发
+ `: keep-alive` 注释心跳。**写操作一律走 HTTP POST /api/actions**,不再有 WS 回执。
+ 停机必须 `hub.close_all()`(否则长连接拖住 ASGI 服务器的优雅退出)+ 配置
+ `timeout_graceful_shutdown` 兜底;客户端 stop() 要 shutdown 底层 socket,
+ 光 close() 会等读线程的锁(最长等到下一次心跳)。
+- **ASGI 服务器(v2.3 起为 uvicorn + zttp 的 HTTP/2)**:`farm/server/__main__.py` 用
+ `uvicorn.Config(http="zttp", http2=True)` + `uvicorn.Server`。
+ **同一端口同时说 HTTP/1.1 与 HTTP/2**(TLS 走 ALPN;明文走 h2c prior-knowledge;
+ 分流由 `AutoZttpProtocol` 完成;浏览器只在 https 下用 h2)。
+ 关键:uvicorn 0.53 **自己不再内置 h2**,HTTP/2 必须装 `zttp` 并显式选 `http="zttp"`,
+ 否则 `Config.load()` 直接 raise(错误信息里就写着 pip install zttp)。
+ 两条停机路径汇到同一处:`POST /api/admin/shutdown` 与 Ctrl+C / SIGTERM / SIGBREAK
+ 都调 `handle.request_stop()`。信号接管靠**子类覆盖 `Server.handle_exit`**
+ (0.53 的机制是 `capture_signals()` + `handle_exit`,没有 install_signal_handlers 可覆盖);
+ 覆盖后不调 super()、也不写 `_captured_signals`,退出时就不会重放信号。第二次信号 = 强制退出。
+ `farm/server/app.py` 的 `as_asgi()` 是给 pyright 的类型桥:uvicorn 的
+ `uvicorn._types.Scope` 与 `starlette.types.Scope` 两个 TypedDict 互不兼容。
+ 另:uvicorn 的启动顺序**也是**先 lifespan、后 bind 端口(旧注释曾写成"相反",已更正)。
+- **端口自检**:uvicorn 会设 `SO_REUSEADDR`,Windows 上第二个实例能
+ "绑上"已被监听的端口并抢走新连接,所以入口启动前用 `daemon.port_busy()`
+ (判断"能不能连上",而不是"能不能 bind")自检,被占用就退 3 并提示换端口。
+
+## 序列化与性能约定(2026-09-19 优化后固化)
+- **轻量路径不许做完整序列化**:只要标量时(动作回执 / `session.summary()` / `app.brief()` /
+ 排行榜单行)一律走 `engine.brief_source()`,**不要**调 `engine.snapshot()` 或
+ `state.to_dict()` 再挑字段——那是整局序列化(16 地 + 8 牲畜约 45µs,轻量路径只需 15µs)。
+ 完整快照只在 `GET /api/state`、SSE `state` 消息、`POST /api/actions?full=1` 用。
+ `app.py` 的 `BRIEF_KEYS` / `BRIEF_TODO_KEYS` 是这两类字段的白名单,加字段要同步。
+- `GameState.to_dict()` 里 `net_worth` **只算一次**再复用(`net_worth` / `goal_index` /
+ `next_goal` 三处都靠它);`net_worth` 是每次访问都重算的属性(约 5µs),别在循环里反复读。
+ 「下一个目标」用模块级 `models.next_goal_after(index)`,不要再加 `next_goal` property。
+- **`tools/bench.py` 是性能守卫**,不只是一次性脚本:它给 `to_dict` / `snapshot` /
+ `brief_source` / `net_worth` 装了调用计数器,谁把轻量路径改回 `snapshot()` 会立刻计数变红。
+ 它用 ASGI 直调(不走 httpx),**不跑 FastAPI lifespan** —— 动了 lifespan 要用
+ `tests/test_server.py::LiveServerTestCase`(真 in-process uvicorn)复验。
+- **`farm/server/app.py` 的结构**:`create_app()` 只做「建 `_Services` + 依次调
+ `_register_*`」,所有路由/生命周期都住在模块级 `_register_*` 函数里(曾经是一个 475 行的
+ 内嵌闭包大函数)。加路由就加/改对应的 `_register_*`,别把逻辑塞回 `create_app`。
+- **函数长度参考**:>60 行的函数应视为信号(重构前 15 个,现在 8 个,最长
+ `__main__._run_foreground` 131 行,其中大半是"必须内嵌的 uvicorn 停机子类")。
+- **重复的等级门槛**统一走 `engine._is_locked/_require_level/_newly_unlocked`,
+ 不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。
## 环境禁忌(本机特有)
+- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
+ 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
+ 一律"先读完 → 关掉句柄 → 再删"。
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep` 缺失),**不要用管道**,会整条命令失败;
- 查看/删除文件一律写 Python 脚本。
+ 查看/删除文件一律写 Python 脚本。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1`
+ 必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。
+ PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。
- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
@@ -52,8 +128,8 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
- 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。
-- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
+- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright`
## 相关文档
-- 项目入口文档:`README.md`(接口表、WS 协议、边界处理、设计取舍全在里面)
-- 当天工作记录:`.workbuddy/memory/2026-09-18.md`
+- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面)
+- 当天工作记录:`.workbuddy/memory/2026-09-19.md`
diff --git a/README.md b/README.md
index 3dea5af..6ef2b15 100644
--- a/README.md
+++ b/README.md
@@ -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`。)*
diff --git a/ai_player.py b/ai_player.py
index bf7338f..e348a41 100644
--- a/ai_player.py
+++ b/ai_player.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""外挂式 AI/机器人玩家——完全通过 HTTP + WebSocket 接口参与游戏。
+"""外挂式 AI/机器人玩家——完全通过 HTTP + SSE 接口参与游戏。
这个脚本**不**导入任何游戏规则代码,只用接口。换句话说:
它对服务器来说和一个远程 LLM Agent 没有任何区别。
@@ -33,7 +33,7 @@ from farm.engine import GameError
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="uv run ai_player.py",
- description="通过 HTTP/WebSocket 接口参与 SimpleFarm 的机器人玩家",
+ description="通过 HTTP + SSE 接口参与 SimpleFarm 的机器人玩家",
)
parser.add_argument("--url", default="http://127.0.0.1:8765", help="服务器地址")
parser.add_argument("--farm", default="ai-1", help="经营哪个牧场,默认 ai-1")
diff --git a/farm/__init__.py b/farm/__init__.py
index 6d00253..f860d9a 100644
--- a/farm/__init__.py
+++ b/farm/__init__.py
@@ -5,7 +5,7 @@
* :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config`
纯游戏规则与数据,不依赖终端也不依赖网络。
* :mod:`farm.server`
- 服务器侧:FastAPI 应用、动作表、多牧场会话、WebSocket 推送、守护进程。
+ 服务器侧:FastAPI 应用、动作表、多牧场会话、SSE 推送、守护进程。
* :mod:`farm.client`
客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。
* :mod:`farm.tui`
diff --git a/farm/bot.py b/farm/bot.py
index efb9937..405586c 100644
--- a/farm/bot.py
+++ b/farm/bot.py
@@ -12,6 +12,10 @@
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
换个 ``--url`` 就是一个独立的外挂玩家。
+
+节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,所以行动点
+用完之后它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
+就是"最多等多久",默认 0——一天 40 分钟,干等不划算。
"""
from __future__ import annotations
@@ -22,6 +26,7 @@ import time
from typing import Any
from .engine import GameError
+from .models import format_duration
class RuleBot:
@@ -125,12 +130,6 @@ class RuleBot:
result = self.client._action(name, params) # 走通用动作入口
except GameError as exc:
self.failures += 1
- if exc.code == "cooldown":
- # 休息有冷却;这是正常节奏,不该记成失败
- self.failures -= 1
- self.say(f"冷却中… {exc.message}")
- time.sleep(1.0)
- return None
self.say(f"✗ {name} 失败({exc.code}):{exc.message}")
return None
self.actions_executed += 1
@@ -140,24 +139,45 @@ class RuleBot:
time.sleep(self.pause)
return name
- def rest(self) -> bool:
- """行动点用完时推进一天。"""
- try:
- result = self.client.sleep()
- except GameError as exc:
- if exc.code == "cooldown":
- time.sleep(1.0)
- return True
- self.say(f"✗ sleep 失败:{exc.message}")
+ def wait_for_next_day(self, timeout: float) -> bool:
+ """没得可做时等时间自己流动到下一天。
+
+ 返回 True 表示等到了新的一天;``timeout`` 为 0 表示不等待、直接收工
+ (机器人本来就该"把能做的做完就走",不像真人会挂机守着)。
+ """
+ state = self.client.snapshot()
+ if state.get("paused"):
+ self.say("— 时间被暂停了,再等也不会有下一天,先收工。")
return False
- self.say(f"— {result.get('message', '进入下一天')}")
- return True
+ if timeout <= 0:
+ left = format_duration(state.get("seconds_to_tick"))
+ self.say(f"— 暂时没事可做,距离下一天还有 {left},先收工。")
+ return False
+
+ start_day = state.get("day")
+ self.say(f"— 等时间流动(最多 {timeout:.0f} 秒)…")
+ deadline = time.time() + timeout
+ while not self._stop.is_set() and time.time() < deadline:
+ time.sleep(0.25)
+ day = self.client.snapshot().get("day")
+ if day != start_day:
+ self.say(f"— 天亮了:第 {day} 天")
+ return True
+ self.say("— 没等到下一天,先收工。")
+ return False
# ------------------------------------------------------------ 主循环
- def run(self, rounds: int = 0, max_minutes: float = 0.0, idle_limit: int = 8) -> dict[str, Any]:
+ def run(
+ self,
+ rounds: int = 0,
+ max_minutes: float = 0.0,
+ idle_limit: int = 8,
+ wait_seconds: float = 0.0,
+ ) -> dict[str, Any]:
"""跑机器人。
- ``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。
+ ``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间;
+ ``wait_seconds`` 是"没事可做时最多等多久"(等下一天),0 表示直接收工。
"""
deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None
start_day = self.client.snapshot().get("day", 1)
@@ -175,16 +195,18 @@ class RuleBot:
state = self.client.snapshot()
if state.get("ap", 0) <= 0:
- # 行动点用完:休息推进一天,顺便等冷却
- self.rest()
+ # 行动点用完:只能等这一天自己走完,没有手动快进
+ if not self.wait_for_next_day(wait_seconds):
+ break
+ idle = 0
continue
did = self.step()
if did is None:
idle += 1
if idle >= idle_limit:
- self.say("暂时没有可做的事,休息一天再看。")
- self.rest()
+ if not self.wait_for_next_day(wait_seconds):
+ break
idle = 0
else:
time.sleep(0.4)
diff --git a/farm/client.py b/farm/client.py
index e763a56..307295c 100644
--- a/farm/client.py
+++ b/farm/client.py
@@ -15,20 +15,21 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程
字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。
-双通道
-------
+读写分工
+--------
* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。
-* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。
-* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
+* **读数走 SSE**(GET /api/stream):服务器在状态变化时顺着一条 HTTP 长连接推过来,
+ 界面不用轮询;上行下行都是普通 HTTP,不需要任何第三方库。
+* SSE 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。
"""
from __future__ import annotations
-import asyncio
import contextlib
import json
+import socket
import threading
import time
import urllib.error
@@ -36,8 +37,6 @@ import urllib.parse
import urllib.request
from collections import deque
from collections.abc import Callable
-from concurrent.futures import Future
-from concurrent.futures import TimeoutError as FutureTimeout
from typing import Any
from . import config, net
@@ -45,7 +44,19 @@ from .engine import GameError
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
LOG_KEEP = 200
-WS_RECONNECT_DELAY = 3.0
+STREAM_RECONNECT_DELAY = 3.0 # SSE 断开后重连的间隔(秒)
+STREAM_READ_TIMEOUT = 40.0 # 读 SSE 的 socket 超时,要比服务器心跳(15s)宽裕
+
+
+def _underlying_socket(response: Any) -> Any:
+ """从 ``HTTPResponse`` 里掏出底层 socket。
+
+ 用途只有一个:断开时能立刻中断正在阻塞的读取(``close()`` 会等读线程的锁,
+ 最长要等到下一次心跳,不可接受)。掏不到就返回 None,调用方会退化成 close()。
+ """
+ fp = getattr(response, "fp", None)
+ raw = getattr(fp, "raw", None) # http.client 把 socket 包成 SocketIO
+ return getattr(raw, "_sock", None)
# --------------------------------------------------------------------- 状态视图
@@ -166,6 +177,10 @@ class StateView:
def __init__(self, data: dict[str, Any] | None = None) -> None:
self._d: dict[str, Any] = {}
+ # 空视图也要长得像"空农场":还没收到第一份快照时(或服务器连不上时),
+ # 上层会直接读 state.plots 渲染,不能甩出 AttributeError。
+ self.plots: list[PlotView] = []
+ self.animals: list[AnimalView] = []
self.apply(data or {})
def apply(self, data: dict[str, Any]) -> None:
@@ -248,19 +263,17 @@ class RemoteFarm:
self.state = StateView()
self.connected = False
- self.mode = "http" # http / ws / poll
+ self.mode = "http" # http(只有写请求)/ sse(长连接推送)/ poll(轮询兜底)
self.last_error = ""
self.last_message = ""
self.server_info: dict[str, Any] = {}
self.on_change: Callable[[], None] | None = None
self._lock = threading.RLock()
- self._pending: dict[int, Future] = {}
- self._req_id = 0
self._stop = threading.Event()
self._thread: threading.Thread | None = None
- self._ws_socket: Any = None
- self._ws_loop: Any = None
+ self._stream_response: Any = None
+ self._stream_socket: Any = None
self._catalog: dict[str, Any] = {
"crops": [],
"animals": [],
@@ -270,7 +283,7 @@ class RemoteFarm:
}
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._log_seq = 0
- self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0}
+ self.stats = {"http": 0, "pushes": 0, "errors": 0}
if autostart:
self.refresh(raise_on_error=True)
@@ -280,12 +293,18 @@ class RemoteFarm:
# ------------------------------------------------------------ 基础设施
@staticmethod
def _normalize(endpoint: str) -> str:
- endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
+ endpoint = (endpoint or "").strip()
if endpoint.startswith("tcp://"):
endpoint = "http://" + endpoint[len("tcp://") :]
- if not endpoint.startswith(("http://", "https://")):
+ elif not endpoint.startswith(("http://", "https://")):
endpoint = "http://" + endpoint
- return endpoint
+ # 补完 scheme 再削尾部斜杠:顺序反了的话 "http://" 会被削成 "http:",
+ # 拼出来的地址没有主机名,请求会悄悄落到系统代理上(表现为莫名其妙的 502)。
+ endpoint = endpoint.rstrip("/")
+ has_host = urllib.parse.urlsplit(endpoint).netloc
+ if endpoint.startswith(("http://", "https://")) and has_host:
+ return endpoint
+ return DEFAULT_ENDPOINT
@property
def host(self) -> str:
@@ -346,7 +365,7 @@ class RemoteFarm:
# ------------------------------------------------------------ 读接口
def refresh(self, raise_on_error: bool = False) -> bool:
- """主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。"""
+ """主动拉一次全量数据(首次连接、以及 SSE 断开后的兜底)。"""
try:
data = self._http("GET", "/api/state")
self._apply_online(data.get("state") or {}, data.get("online"))
@@ -417,38 +436,15 @@ class RemoteFarm:
)
# ------------------------------------------------------------ 写接口
- def _action(
- self, action: str, params: dict[str, Any] | None = None, transport: str = "auto"
- ) -> dict[str, Any]:
- """执行一个动作。
+ def _action(self, action: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
+ """执行一个动作:写操作统一走 HTTP POST /api/actions。
- 优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
- (连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家,
- 否则"金币不足"会被悄悄重试成两次操作。
+ 实时通道只负责下行推送,所以这里不需要"优先哪条通道"的判断;
+ ``full=1`` 让服务器顺手把完整快照带回来,省掉"操作后再查一次"。
"""
- params = params or {}
- use_ws = (transport == "ws") or (
- transport == "auto" and self.mode == "ws" and self.connected
+ data = self._http(
+ "POST", "/api/actions", {"action": action, "params": params or {}}, full=1
)
- if use_ws:
- try:
- result = self._ws_action(action, params)
- except GameError as exc:
- if exc.code in ("ws_closed", "timeout", "network"):
- self.last_error = f"WebSocket 不可用({exc.message}),已改走 HTTP"
- else:
- raise
- else:
- self.stats["ws_actions"] += 1
- if isinstance(result.get("snapshot"), dict):
- self._apply(result["snapshot"])
- elif isinstance(result.get("state"), dict):
- self._apply(result["state"])
- self._merge_log(result.get("log") or [])
- return result
-
- # full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次"
- data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1)
self._apply(data.get("snapshot") or data.get("state") or {})
self._merge_log(data.get("log") or [])
return data
@@ -484,22 +480,13 @@ class RemoteFarm:
def buy_plot(self) -> dict[str, Any]:
return self._action("buy_plot")
- def sleep(self) -> dict[str, Any]:
- return self._action("sleep")
-
def pause(self, paused: bool | None = None) -> dict[str, Any]:
+ # 动作回执里的 state(brief)已带 paused 与 seconds_to_tick,
+ # _action 内部会合并进本地视图,这里只需转发参数
params: dict[str, Any] = {}
if paused is not None:
params["paused"] = paused
- data = self._action("pause", params)
- self._apply(data.get("state") or {})
- # 本地视图也要反映暂停态,TUI 的倒计时依赖它
- if "paused" in data:
- merged = self.state.to_dict()
- merged["paused"] = data["paused"]
- merged["seconds_to_tick"] = None if data["paused"] else merged.get("seconds_to_tick")
- self.state.apply(merged)
- return data
+ return self._action("pause", params)
def reset(self) -> dict[str, Any]:
data = self._action("reset")
@@ -520,60 +507,64 @@ class RemoteFarm:
self._merge_log(data.get("log") or [])
return data
- # ------------------------------------------------------------ WebSocket
+ # ------------------------------------------------------------ 实时通道(SSE)
def start(self) -> None:
if not self.realtime_enabled or self._thread is not None:
return
self._stop.clear()
- self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True)
+ self._thread = threading.Thread(target=self._realtime_loop, name="farm-sse", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
- with self._lock:
- for future in self._pending.values():
- if not future.done():
- future.set_exception(GameError("closed", "连接已关闭"))
- self._pending.clear()
+ # 主动掐断长连接,否则服务端那条流式响应会一直挂着,拖慢它的优雅停机。
+ # 注意不能直接 response.close():读线程正持有那把锁,close 会一直等它,
+ # 而它要等下一次心跳(15 秒)才醒。直接 shutdown 底层 socket 才能立刻唤醒。
+ sock = self._stream_socket
+ if sock is not None:
+ with contextlib.suppress(OSError):
+ sock.shutdown(socket.SHUT_RDWR)
+ response = self._stream_response
+ if response is not None:
+ with contextlib.suppress(Exception):
+ response.close()
def transport_label(self) -> str:
- if self.mode == "ws":
- return "WebSocket 实时"
+ if self.mode == "sse":
+ return "SSE 实时"
if self.mode == "poll":
return "HTTP 轮询"
return "HTTP"
@property
- def ws_url(self) -> str:
- parts = urllib.parse.urlsplit(self.endpoint)
- scheme = "wss" if parts.scheme == "https" else "ws"
- query = {"farm": self.farm}
+ def stream_url(self) -> str:
+ """SSE 长连接地址。
+
+ 令牌只能走查询参数:``EventSource`` 与 ``urllib`` 都没法给长连接加自定义头。
+ """
+ query: dict[str, Any] = {"farm": self.farm}
if self.token:
query["token"] = self.token
if self.name:
query["name"] = self.name
- return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}"
+ return f"{self.endpoint}/api/stream?{urllib.parse.urlencode(query)}"
def _realtime_loop(self) -> None:
- """实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。"""
+ """实时线程:读 SSE 长连接;断了就一边轮询一边重连,永不放弃。"""
while not self._stop.is_set():
try:
- self._ws_session()
+ self._stream_once()
except Exception as exc:
self.connected = False
self.last_error = f"{type(exc).__name__}: {exc}"
- if self.mode != "ws":
- self.mode = "poll"
- self._fail_pending(self.last_error)
+ self.mode = "poll"
if self._stop.is_set():
break
self._poll_until_reconnect()
def _poll_until_reconnect(self) -> None:
- """WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。"""
- if self.mode == "ws":
- self.mode = "poll"
- deadline = time.time() + WS_RECONNECT_DELAY
+ """SSE 断开期间的降级通道:轻量轮询 + 到点再试一次长连接。"""
+ deadline = time.time() + STREAM_RECONNECT_DELAY
while not self._stop.is_set() and time.time() < deadline:
try:
data = self._http("GET", "/api/state")
@@ -582,64 +573,46 @@ class RemoteFarm:
pass
self._stop.wait(0.5)
- def _ws_session(self) -> None:
- import asyncio
+ def _stream_once(self) -> None:
+ """读一次 SSE 流,直到断开或主动停止。
- try:
- import websockets
- except ImportError:
- # 没装 websockets:直接走轮询模式,功能不减
- self.mode = "poll"
- self._stop.wait(WS_RECONNECT_DELAY)
- return
-
- asyncio.run(self._ws_main(websockets))
-
- async def _ws_main(self, websockets) -> None:
- async with websockets.connect(
- self.ws_url,
- open_timeout=self.timeout,
- ping_interval=20,
- ping_timeout=20,
- max_size=4 * 1024 * 1024,
- ) as socket:
- self.mode = "ws"
+ 直接按行迭代响应体:一行一行拿,不把响应收完,
+ 所以这是真正的流式读取,而且只用标准库。
+ """
+ request = urllib.request.Request(self.stream_url, headers={"Accept": "text/event-stream"})
+ # 服务器每 HEARTBEAT_SECONDS 秒发一次心跳,读超时要比它宽裕
+ with net.open_url(request, timeout=max(self.timeout, STREAM_READ_TIMEOUT)) as response:
+ self._stream_response = response
+ self._stream_socket = _underlying_socket(response)
+ self.mode = "sse"
self.connected = True
self.last_error = ""
- self._ws_socket = socket
- self._ws_loop = asyncio.get_running_loop()
try:
- while not self._stop.is_set():
- try:
- raw = await asyncio.wait_for(socket.recv(), timeout=30.0)
- except asyncio.TimeoutError:
- await socket.send(json.dumps({"type": "ping"}))
+ for raw_line in response:
+ if self._stop.is_set():
+ return
+ line = raw_line.decode("utf-8", errors="replace").strip()
+ if not line or line.startswith(":"):
+ # 空行是帧分隔符;冒号开头是心跳注释,都无需处理
continue
- self._handle_ws_message(raw)
+ if line.startswith("data:"):
+ self._handle_stream_message(line[len("data:") :].strip())
finally:
- self._ws_socket = None
- self._ws_loop = None
- self._fail_pending("WebSocket 连接已断开")
+ self._stream_response = None
+ self._stream_socket = None
- def _fail_pending(self, reason: str) -> None:
- """连接断了以后,把还在等回执的动作请求全部失败掉。"""
- with self._lock:
- pending, self._pending = self._pending, {}
- for future in pending.values():
- if not future.done():
- future.set_exception(GameError("ws_closed", reason))
-
- def _handle_ws_message(self, raw: Any) -> None:
+ def _handle_stream_message(self, payload: str) -> None:
try:
- message = json.loads(raw)
- except (TypeError, json.JSONDecodeError):
+ message = json.loads(payload)
+ except json.JSONDecodeError:
return
if not isinstance(message, dict):
return
kind = message.get("type")
- if kind == "welcome":
- self.server_info = message.get("server") or {}
+ if kind in ("welcome", "state", "sync"):
+ if message.get("server"):
+ self.server_info = message["server"]
self.farm = message.get("farm", self.farm)
if message.get("catalog"):
self._catalog = message["catalog"]
@@ -648,38 +621,6 @@ class RemoteFarm:
self.stats["pushes"] += 1
return
- if kind in ("state", "sync"):
- if message.get("catalog"):
- self._catalog = message["catalog"]
- self._apply_online(message.get("state") or {}, message.get("online"))
- self._merge_log(message.get("log") or [])
- self.stats["pushes"] += 1
- return
-
- if kind == "result":
- rid = message.get("id")
- with self._lock:
- future = self._pending.pop(rid, None) if rid is not None else None
- if future is None or future.done():
- return
- if message.get("ok"):
- self._merge_log(message.get("log") or [])
- future.set_result(
- {
- "ok": True,
- "message": message.get("message", "完成"),
- **(message.get("result") or {}),
- "state": message.get("state"),
- "snapshot": message.get("snapshot"),
- "log": message.get("log"),
- }
- )
- else:
- future.set_exception(
- GameError(message.get("error", "error"), message.get("message", "操作失败"))
- )
- return
-
if kind == "day":
self._merge_log(
[
@@ -704,36 +645,13 @@ class RemoteFarm:
if kind == "error":
self.last_error = message.get("message", "")
+ self.stats["errors"] += 1
+ return
- def _ws_send(self, payload: dict[str, Any]) -> None:
- """把消息发到 WS 线程的事件循环里(跨线程投递)。"""
- socket = getattr(self, "_ws_socket", None)
- loop = getattr(self, "_ws_loop", None)
- if socket is None or loop is None:
- raise GameError("ws_closed", "WebSocket 尚未连接")
- asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop)
-
- def _ws_action(
- self, action: str, params: dict[str, Any], timeout: float | None = None
- ) -> dict[str, Any]:
- with self._lock:
- self._req_id += 1
- rid = self._req_id
- future: Future = Future()
- self._pending[rid] = future
- try:
- self._ws_send({"type": "action", "id": rid, "action": action, "params": params})
- except GameError:
- with self._lock:
- self._pending.pop(rid, None)
- raise
- try:
- return future.result(timeout=timeout or self.timeout)
- except FutureTimeout:
- with self._lock:
- self._pending.pop(rid, None)
- # 超时是正常控制流:FutureTimeout 是内部实现细节,不必挂到异常链上
- raise GameError("timeout", "服务器没有在超时时间内回应") from None
+ if kind == "bye":
+ # 服务器主动收尾(停机):记下原因,让上层提示"服务器已停止"
+ self.connected = False
+ self.last_error = message.get("message", "服务器已停机")
class RemoteBotClient(RemoteFarm):
diff --git a/farm/config.py b/farm/config.py
index 4061328..e059f55 100644
--- a/farm/config.py
+++ b/farm/config.py
@@ -185,10 +185,12 @@ MAX_ANIMALS = 8 # 牧场总容量
# 时间
# --------------------------------------------------------------------------
-DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数
-MIN_TICK_SECONDS = 3.0
-MAX_TICK_SECONDS = 600.0
-SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒)
+# 一天有多长(真实秒数)。慢是刻意的:这是“放置农场”——
+# 一天 40 分钟,作物 2~10 天成熟(小麦 80 分钟、草莓约 6.7 小时)。
+# 没有任何“手动推进一天”的接口,时间只能自然流动。
+DEFAULT_TICK_SECONDS = 2400.0
+MIN_TICK_SECONDS = 3.0 # 调试用:把一天缩到几秒
+MAX_TICK_SECONDS = 86400.0 # 上限给到一整天,想多慢都不会被夹住
# --------------------------------------------------------------------------
diff --git a/farm/engine.py b/farm/engine.py
index 50c6968..f6d3c2c 100644
--- a/farm/engine.py
+++ b/farm/engine.py
@@ -51,7 +51,6 @@ class FarmEngine:
)
self._log_seq = 0
- self._last_sleep = 0.0
self.paused = False
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
self.next_tick_at: float | None = None
@@ -106,6 +105,44 @@ class FarmEngine:
return None
return round(max(0.0, self.next_tick_at - time.time()), 1)
+ def brief_source(self) -> dict[str, Any]:
+ """精简状态(动作回执、排行榜、`status`)要用的字段。
+
+ 刻意**不走** :meth:`snapshot`:那些场景只要十几个标量,而完整快照会把
+ 每块地、每只牲畜都序列化成 dict,再被调用方整批丢掉。实测每个请求因此
+ 白花 ~60µs;排行榜更夸张——N 个牧场就是 N 次全量序列化。
+
+ 这里的键是 ``farm.server.app.BRIEF_KEYS`` 的超集:加字段时两边一起改。
+ """
+ with self._lock:
+ state = self.state
+ # net_worth 是这里最贵的一项(要遍历地块与牲畜),算一次给两处用
+ worth = state.net_worth
+ return {
+ "day": state.day,
+ "gold": state.gold,
+ "level": state.level,
+ "exp": state.exp,
+ "exp_needed": state.exp_needed,
+ "ap": state.ap,
+ "max_ap": state.max_ap,
+ "feed": state.feed,
+ "plot_count": len(state.plots),
+ "max_plots": state.max_plots,
+ "next_plot_price": state.next_plot_price,
+ "animal_count": len(state.animals),
+ "max_animals": config.MAX_ANIMALS,
+ "net_worth": round(worth, 2),
+ "goal_index": config.goal_index(worth),
+ "seconds_to_tick": self.seconds_to_tick,
+ "paused": self.paused,
+ "tick_seconds": self.tick_seconds,
+ # 待办清单是特意为 AI 准备的:直接读模型对象,不再过一遍序列化
+ "ripe_plots": [p.index + 1 for p in state.plots if p.is_ripe],
+ "hungry_animals": [a.aid for a in state.animals if not a.fed],
+ "collectable_animals": [a.aid for a in state.animals if a.pending > 0],
+ }
+
def market_price(self, key: str) -> float:
with self._lock:
return round(self.state.market.get(key, 0.0), 2)
@@ -127,7 +164,7 @@ class FarmEngine:
"price": price,
"exp": info["exp"],
"level_req": info["level_req"],
- "locked": self.state.level < info["level_req"],
+ "locked": self._is_locked(info),
"profit": round(gross - info["seed_cost"], 2),
"profit_per_day": round((gross - info["seed_cost"]) / days, 2),
}
@@ -147,7 +184,7 @@ class FarmEngine:
"feed_per_day": info["feed_per_day"],
"exp": info["exp"],
"level_req": info["level_req"],
- "locked": self.state.level < info["level_req"],
+ "locked": self._is_locked(info),
"profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
}
)
@@ -166,109 +203,151 @@ class FarmEngine:
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
"""
with self._lock:
- actions: list[dict[str, Any]] = []
state = self.state
-
- # ---- 收获 / 浇水 / 播种
- for plot in state.plots:
- if plot.crop is None:
- for key, info in config.CROPS.items():
- if state.level < info["level_req"]:
- continue
- if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]:
- continue
- actions.append(
- {
- "action": "plant",
- "params": {"plot": plot.index + 1, "crop": key},
- "cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]},
- "reason": f"{plot.index + 1} 号地空闲,可种{info['name']}",
- }
- )
- elif plot.is_ripe:
- actions.append(
- {
- "action": "harvest",
- "params": {"plot": plot.index + 1},
- "cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
- "reason": (
- f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
- ),
- }
- )
- elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
- actions.append(
- {
- "action": "water",
- "params": {"plot": plot.index + 1},
- "cost": {"gold": 0, "ap": config.AP_COST["water"]},
- "reason": f"{plot.index + 1} 号地缺水,作物已停止生长",
- }
- )
-
- # ---- 动物
- for animal in state.animals:
- if animal.pending > 0:
- actions.append(
- {
- "action": "collect",
- "params": {"animal_id": animal.aid},
- "cost": {"gold": 0, "ap": config.AP_COST["collect"]},
- "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
- }
- )
- elif not animal.fed:
- need = animal.info["feed_per_day"]
- cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
- if state.gold >= cost and state.ap >= config.AP_COST["feed"]:
- actions.append(
- {
- "action": "feed",
- "params": {"animal_id": animal.aid},
- "cost": {"gold": cost, "ap": config.AP_COST["feed"]},
- "reason": f"{animal.name}(#{animal.aid})今天还没喂食",
- }
- )
-
- # ---- 投资类
- price = state.next_plot_price
- if price > 0 and state.gold >= price:
- actions.append(
- {
- "action": "buy_plot",
- "params": {},
- "cost": {"gold": price, "ap": config.AP_COST["buy_plot"]},
- "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
- }
- )
- if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK:
- actions.append(
- {
- "action": "buy_feed",
- "params": {"quantity": config.FEED_BULK},
- "cost": {
- "gold": config.FEED_UNIT_PRICE * config.FEED_BULK,
- "ap": config.AP_COST["buy_feed"],
- },
- "reason": "饲料库存偏低,建议补货",
- }
- )
- for key, info in config.ANIMALS.items():
- if state.level < info["level_req"]:
- continue
- if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
- continue
- actions.append(
- {
- "action": "buy_animal",
- "params": {"kind": key},
- "cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]},
- "reason": f"可购买{info['name']}({info['cost']} 金币)",
- }
- )
+ actions: list[dict[str, Any]] = []
+ actions.extend(self._plot_actions(state))
+ actions.extend(self._animal_actions(state))
+ actions.extend(self._investment_actions(state))
return actions
+ # ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
+ def _plot_actions(self, state: GameState) -> list[dict[str, Any]]:
+ """每块地当前能做的事:播种 / 收获 / 浇水。"""
+ # 能种什么只取决于等级、金币和行动点,与**具体是哪块地**无关,
+ # 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。
+ plant_ap = config.AP_COST["plant"]
+ plantable = [
+ (key, info)
+ for key, info in config.CROPS.items()
+ if not self._is_locked(info)
+ and state.gold >= info["seed_cost"]
+ and state.ap >= plant_ap
+ ]
+ actions: list[dict[str, Any]] = []
+ for plot in state.plots:
+ position = plot.index + 1
+ if plot.crop is None:
+ for key, info in plantable:
+ actions.append(
+ {
+ "action": "plant",
+ "params": {"plot": position, "crop": key},
+ "cost": {"gold": info["seed_cost"], "ap": plant_ap},
+ "reason": f"{position} 号地空闲,可种{info['name']}",
+ }
+ )
+ elif plot.is_ripe:
+ actions.append(
+ {
+ "action": "harvest",
+ "params": {"plot": position},
+ "cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
+ "reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
+ }
+ )
+ elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
+ actions.append(
+ {
+ "action": "water",
+ "params": {"plot": position},
+ "cost": {"gold": 0, "ap": config.AP_COST["water"]},
+ "reason": f"{position} 号地缺水,作物已停止生长",
+ }
+ )
+ return actions
+
+ def _animal_actions(self, state: GameState) -> list[dict[str, Any]]:
+ """每只牲畜当前能做的事:收取产出 / 喂食。"""
+ actions: list[dict[str, Any]] = []
+ for animal in state.animals:
+ if animal.pending > 0:
+ actions.append(
+ {
+ "action": "collect",
+ "params": {"animal_id": animal.aid},
+ "cost": {"gold": 0, "ap": config.AP_COST["collect"]},
+ "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
+ }
+ )
+ elif not animal.fed:
+ need = animal.info["feed_per_day"]
+ cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
+ if state.gold >= cost and state.ap >= config.AP_COST["feed"]:
+ actions.append(
+ {
+ "action": "feed",
+ "params": {"animal_id": animal.aid},
+ "cost": {"gold": cost, "ap": config.AP_COST["feed"]},
+ "reason": f"{animal.name}(#{animal.aid})今天还没喂食",
+ }
+ )
+ return actions
+
+ def _investment_actions(self, state: GameState) -> list[dict[str, Any]]:
+ """与具体地块/牲畜无关的长期投入:开垦土地、补饲料、买牲畜。"""
+ actions: list[dict[str, Any]] = []
+ price = state.next_plot_price
+ if price > 0 and state.gold >= price:
+ actions.append(
+ {
+ "action": "buy_plot",
+ "params": {},
+ "cost": {"gold": price, "ap": config.AP_COST["buy_plot"]},
+ "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
+ }
+ )
+ bulk_cost = config.FEED_UNIT_PRICE * config.FEED_BULK
+ if state.feed < 5 and state.gold >= bulk_cost:
+ actions.append(
+ {
+ "action": "buy_feed",
+ "params": {"quantity": config.FEED_BULK},
+ "cost": {"gold": bulk_cost, "ap": config.AP_COST["buy_feed"]},
+ "reason": "饲料库存偏低,建议补货",
+ }
+ )
+ for key, info in config.ANIMALS.items():
+ if self._is_locked(info):
+ continue
+ if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
+ continue
+ actions.append(
+ {
+ "action": "buy_animal",
+ "params": {"kind": key},
+ "cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]},
+ "reason": f"可购买{info['name']}({info['cost']} 金币)",
+ }
+ )
+ return actions
+
# ================================================================ 内部工具
+ def _is_locked(self, info: dict[str, Any]) -> bool:
+ """这件作物/牲畜在当前等级是否还没解锁。
+
+ "等级门槛"这一条规则原本在 catalog / available_actions / plant /
+ buy_animal / _gain_exp 里各写了一遍,改文案或改判定就得追五处。
+ 现在只在这里定义,别处一律调它。
+ """
+ return self.state.level < info["level_req"]
+
+ def _require_level(self, info: dict[str, Any]) -> None:
+ """等级不够就抛 locked。播种与买牲畜都要过这一关。"""
+ if self._is_locked(info):
+ raise GameError(
+ "locked",
+ f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
+ )
+
+ def _newly_unlocked(self, level: int) -> list[str]:
+ """正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。"""
+ return [
+ info["name"]
+ for table in (config.CROPS, config.ANIMALS)
+ for info in table.values()
+ if info["level_req"] == level
+ ]
+
def _get_plot(self, plot_no: Any) -> Plot:
"""把 1-based 的地块编号转成 Plot,越界即报错。"""
try:
@@ -299,7 +378,7 @@ class FarmEngine:
raise GameError(
"no_ap",
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
- f"按 N 休息或等待下一天。",
+ f"等一天的时间自然走完就会恢复。",
)
if cost:
self.state.ap -= cost
@@ -333,15 +412,7 @@ class FarmEngine:
self.state.level += 1
bonus = config.level_up_bonus(self.state.level)
self.state.gold += bonus
- unlocked = [
- info["name"]
- for info in config.CROPS.values()
- if info["level_req"] == self.state.level
- ] + [
- info["name"]
- for info in config.ANIMALS.values()
- if info["level_req"] == self.state.level
- ]
+ unlocked = self._newly_unlocked(self.state.level)
text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币"
if unlocked:
text += f",解锁:{'、'.join(unlocked)}"
@@ -362,11 +433,7 @@ class FarmEngine:
f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}",
)
info = config.CROPS[crop]
- if self.state.level < info["level_req"]:
- raise GameError(
- "locked",
- f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
- )
+ self._require_level(info)
plot = self._get_plot(plot_no)
if plot.crop is not None:
raise GameError(
@@ -451,11 +518,7 @@ class FarmEngine:
f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}",
)
info = config.ANIMALS[kind]
- if self.state.level < info["level_req"]:
- raise GameError(
- "locked",
- f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
- )
+ self._require_level(info)
if len(self.state.animals) >= config.MAX_ANIMALS:
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
@@ -587,17 +650,6 @@ class FarmEngine:
"next_plot_price": self.state.next_plot_price,
}
- def sleep(self) -> dict[str, Any]:
- """手动推进一天,带冷却避免刷时间。"""
- with self._lock:
- elapsed = time.time() - self._last_sleep
- if elapsed < config.SLEEP_COOLDOWN:
- wait = config.SLEEP_COOLDOWN - elapsed
- raise GameError("cooldown", f"刚休息过,请等待 {wait:.1f} 秒", status=429)
- self._last_sleep = time.time()
- events = self.advance_day(reason="休息")
- return {"ok": True, "message": f"进入第 {self.state.day} 天", "events": events}
-
def pause(self, paused: bool | None = None) -> dict[str, Any]:
with self._lock:
self.paused = bool(paused) if paused is not None else not self.paused
@@ -610,7 +662,6 @@ class FarmEngine:
tick = self.tick_seconds
self.state = GameState()
self._log_seq = 0
- self._last_sleep = 0.0
self.paused = False
if keep_tick:
self.tick_seconds = tick
@@ -709,8 +760,30 @@ class FarmEngine:
)
+def tick_decision(
+ paused: bool, next_tick_at: float | None, tick_seconds: float, now: float
+) -> tuple[bool, float]:
+ """根据当前时刻决定"是否该推进一天",并给出新的调度时间点。
+
+ 这是 :class:`Scheduler`(单机)与 :class:`farm.server.session.FarmClock`
+ (服务器)共用的唯一 tick 决策,两处不再各写一遍:
+
+ * 暂停中 → 顺延,保证恢复后仍有完整的一个 tick;
+ * 没有调度点(从未启动/被清空)→ 从现在起重新计时;
+ * 到点 → 推进,并把下一次调度设为 now + tick_seconds;
+ * 未到点 → 维持原调度时间。
+
+ 返回 ``(should_advance, next_tick_at)``。
+ """
+ if paused or next_tick_at is None:
+ return False, now + tick_seconds
+ if now >= next_tick_at:
+ return True, now + tick_seconds
+ return False, next_tick_at
+
+
class Scheduler:
- """自动推进游戏时间的后台线程。"""
+ """自动推进游戏时间的后台线程(单机模式;服务器版见 farm.server.session.FarmClock)。"""
def __init__(self, engine: FarmEngine, enabled: bool = True) -> None:
self.engine = engine
@@ -732,16 +805,9 @@ class Scheduler:
def _run(self) -> None:
while not self._stop.wait(0.2):
engine = self.engine
- if engine.paused:
- # 暂停时顺延,保证恢复后仍有完整的一个 tick
- engine.next_tick_at = time.time() + engine.tick_seconds
- continue
- deadline = engine.next_tick_at
- if deadline is None:
- deadline = time.time() + engine.tick_seconds
- if time.time() >= deadline:
+ advance, engine.next_tick_at = tick_decision(
+ engine.paused, engine.next_tick_at, engine.tick_seconds, time.time()
+ )
+ if advance:
with contextlib.suppress(Exception):
engine.advance_day(reason="时间流逝")
- engine.next_tick_at = time.time() + engine.tick_seconds
- else:
- engine.next_tick_at = deadline
diff --git a/farm/keys.py b/farm/keys.py
index bd89404..bb3f07b 100644
--- a/farm/keys.py
+++ b/farm/keys.py
@@ -1,10 +1,21 @@
"""跨平台非阻塞键盘读取。
Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``,
-两端统一返回规范化的按键名,TUI 层无需关心平台差异。
+两端统一返回**同一套小写**的按键名,TUI 层无需关心平台差异。
+
+按键名约定(重要)
+------------------
+具名键一律**小写**,见 :data:`NAMED_KEYS`:``up`` / ``down`` / ``left`` /
+``right`` / ``space`` / ``tab`` / ``esc`` / ``enter`` / ``backspace`` /
+``home`` / ``end`` / ``delete`` / ``ctrl_c``;普通可打印字符返回其自身的
+小写形式(例如 ``"p"``)。
+
+这条约定是硬性的:两条平台路径必须返回**完全一样**的名字。历史上这里
+Windows 返回大写(``"UP"``)、TUI 里写的是小写(``"up"``),于是方向键、
+空格、Tab、ESC 全都静默失效,而且只在某个平台上才看得出来。
在非交互式环境(管道、重定向)下 ``available`` 为 False,
-TUI 会退化为无输入的只读模式。
+TUI 会退化为无输入的只读模式,并把 :attr:`KeyReader.reason` 显示给玩家。
"""
from __future__ import annotations
@@ -12,30 +23,95 @@ from __future__ import annotations
import importlib.util
import os
import sys
+import time
IS_WINDOWS = os.name == "nt"
-# Windows 下的扩展键(以 \x00 / \xe0 开头)
+# ---------------------------------------------------------------------- 具名键
+# 两端共用的规范化名字。全小写,避免"大写产出、小写消费"这类靠肉眼发现的错配。
+CTRL_C = "ctrl_c"
+ENTER = "enter"
+TAB = "tab"
+BACKSPACE = "backspace"
+ESC = "esc"
+SPACE = "space"
+UP = "up"
+DOWN = "down"
+LEFT = "left"
+RIGHT = "right"
+HOME = "home"
+END = "end"
+DELETE = "delete"
+
+NAMED_KEYS = frozenset(
+ {
+ CTRL_C,
+ ENTER,
+ TAB,
+ BACKSPACE,
+ ESC,
+ SPACE,
+ UP,
+ DOWN,
+ LEFT,
+ RIGHT,
+ HOME,
+ END,
+ DELETE,
+ }
+)
+
+# Windows 扩展键:第一次 getwch() 拿到 \x00 / \xe0,第二次才是真正的键码
_WIN_EXTENDED = {
- "H": "UP",
- "P": "DOWN",
- "K": "LEFT",
- "M": "RIGHT",
- "G": "HOME",
- "O": "END",
- "S": "DELETE",
+ "H": UP,
+ "P": DOWN,
+ "K": LEFT,
+ "M": RIGHT,
+ "G": HOME,
+ "O": END,
+ "S": DELETE,
}
+# Windows 控制字符
_WIN_CTRL = {
- "\x03": "CTRL_C",
- "\r": "ENTER",
- "\n": "ENTER",
- "\t": "TAB",
- "\x08": "BACKSPACE",
- "\x1b": "ESC",
- " ": "SPACE",
+ "\x03": CTRL_C,
+ "\r": ENTER,
+ "\n": ENTER,
+ "\t": TAB,
+ "\x08": BACKSPACE,
+ "\x7f": BACKSPACE,
+ "\x1b": ESC,
+ " ": SPACE,
}
+# POSIX 控制字符(ESC 单独处理,因为方向键等功能键也是它打头的)
+_POSIX_CTRL = {
+ "\x03": CTRL_C,
+ "\r": ENTER,
+ "\n": ENTER,
+ "\t": TAB,
+ "\x08": BACKSPACE,
+ "\x7f": BACKSPACE,
+ " ": SPACE,
+}
+
+# POSIX 转义序列的"参数+终结符"(去掉开头的 ESC [ 或 ESC O):
+# 方向键是 ESC [ A~D,Home/End 有两套写法,Delete 是 ESC [ 3 ~
+_POSIX_CSI = {
+ "A": UP,
+ "B": DOWN,
+ "C": RIGHT,
+ "D": LEFT,
+ "H": HOME,
+ "F": END,
+ "1~": HOME,
+ "3~": DELETE,
+ "4~": END,
+}
+
+EXTENDED_WAIT = 0.02 # 等功能键第二个字节的最长时间(秒)
+PENDING_WAIT = 0.02 # 判断"单按 ESC"还是"序列开头"、以及等半个字符的时间
+
class KeyReader:
"""把终端的原始字节流转换成按键名。"""
@@ -43,12 +119,16 @@ class KeyReader:
def __init__(self) -> None:
self._fd: int | None = None
self._saved_term = None
+ self._raw = b"" # POSIX:上一次没解析完的字节(半个转义序列、半个汉字)
+
+ self.reason = "" # 读不到键盘时的解释,用于提示玩家
+ self.available = False
# 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
stdin = sys.stdin
if stdin is None or not stdin.isatty():
- self.available = False
+ self.reason = "标准输入不是终端(管道或重定向)"
return
self.available = True
@@ -56,6 +136,17 @@ class KeyReader:
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
if importlib.util.find_spec("msvcrt") is None:
self.available = False
+ self.reason = "找不到 msvcrt 模块"
+ return
+ try:
+ import msvcrt
+
+ # 探一次:不是真的 Win32 控制台(例如 Git Bash 的 mintty)这里会报错。
+ # 与其让玩家对着"按键没反应"发呆,不如把原因摆出来。
+ msvcrt.kbhit()
+ except Exception:
+ self.available = False
+ self.reason = "当前终端不是 Win32 控制台,msvcrt 读不到按键"
return
try:
@@ -71,6 +162,7 @@ class KeyReader:
except Exception:
self.available = False
self._saved_term = None
+ self.reason = "无法把终端切到 cbreak 模式"
# ------------------------------------------------------------------ 读取
def poll(self) -> str | None:
@@ -79,6 +171,7 @@ class KeyReader:
return None
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
+ # ---------------------------------------------------------------- Windows
def _poll_windows(self) -> str | None:
import msvcrt
@@ -87,56 +180,144 @@ class KeyReader:
char = msvcrt.getwch()
if char in ("\x00", "\xe0"):
- if not msvcrt.kbhit():
- return None
- return _WIN_EXTENDED.get(msvcrt.getwch().upper())
- if char in _WIN_CTRL:
- return _WIN_CTRL[char]
- return char.lower()
+ code = self._win_extended_code()
+ # 认不出来的功能键就丢掉,不要退化成 ESC 之类的错误动作
+ return _WIN_EXTENDED.get(code) if code else None
+ named = _WIN_CTRL.get(char)
+ return named if named else char.lower()
+ @staticmethod
+ def _win_extended_code() -> str | None:
+ """取扩展键的第二个字节;它通常紧跟其后,但偶尔会差几毫秒。"""
+ import msvcrt
+
+ deadline = time.monotonic() + EXTENDED_WAIT
+ while True:
+ if msvcrt.kbhit():
+ return msvcrt.getwch().upper()
+ if time.monotonic() >= deadline:
+ return None
+ time.sleep(0.005)
+
+ # ---------------------------------------------------------------- POSIX 侧
def _poll_posix(self) -> str | None:
import select
if self._fd is None:
return None
- try:
- ready, _, _ = select.select([self._fd], [], [], 0)
- except (OSError, ValueError):
- return None
- if not ready:
+ if not self._raw:
+ try:
+ ready, _, _ = select.select([self._fd], [], [], 0)
+ except (OSError, ValueError):
+ return None
+ if not ready:
+ return None
+ if self._read_fd() is None:
+ return None
+ return self._parse_posix()
+
+ def _read_fd(self) -> bytes | None:
+ """把输入追加进 :attr:`_raw`。读不到返回 None。"""
+ if self._fd is None:
return None
try:
- data = os.read(self._fd, 8)
+ data = os.read(self._fd, 64)
except OSError:
return None
if not data:
return None
+ # 一次读到的可能是好几个按键:全部留在缓冲区里逐个返回,
+ # 不能像老实现那样只取第一个字符、把其余的悄悄丢掉。
+ self._raw += data
+ return data
- text = data.decode("utf-8", errors="ignore")
- if not text:
+ def _read_ready(self, timeout: float) -> bool:
+ """等一小会儿再读一次,用于判断不完整的转义序列与半个字符。"""
+ import select
+
+ if self._fd is None:
+ return False
+ try:
+ ready, _, _ = select.select([self._fd], [], [], timeout)
+ except (OSError, ValueError):
+ return False
+ return bool(ready) and self._read_fd() is not None
+
+ def _parse_posix(self) -> str | None:
+ raw = self._raw
+ if not raw:
return None
- # 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D
- if text.startswith("\x1b["):
- return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
- if text.startswith("\x1bO"):
- return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
+ if raw[0:1] == b"\x1b":
+ seq = self._read_escape_sequence()
+ if seq is None:
+ return None # 还没读全,等下一轮 poll
+ if seq == b"":
+ return ESC # 就是单按 ESC
+ return _POSIX_CSI.get(seq.decode("latin-1"))
- char = text[0]
- mapping = {
- "\x03": "CTRL_C",
- "\r": "ENTER",
- "\n": "ENTER",
- "\t": "TAB",
- "\x7f": "BACKSPACE",
- "\x08": "BACKSPACE",
- "\x1b": "ESC",
- " ": "SPACE",
- }
- if char in mapping:
- return mapping[char]
+ first = raw[0]
+ if first < 0x80:
+ self._raw = raw[1:]
+ char = chr(first)
+ named = _POSIX_CTRL.get(char)
+ return named if named else char.lower()
+
+ # 多字节 UTF-8:先凑齐整个字符再解码,免得半个汉字被解成乱码
+ length = 2 if first < 0xE0 else 3 if first < 0xF0 else 4
+ if len(raw) < length:
+ self._read_ready(PENDING_WAIT)
+ raw = self._raw
+ if len(raw) < length:
+ return None # 等下一轮 poll 再来
+ char = raw[:length].decode("utf-8", errors="replace")
+ self._raw = raw[length:]
return char.lower()
+ def _read_escape_sequence(self) -> bytes | None:
+ """解析 ESC 打头的序列。
+
+ 单按 ESC 返回 ``b""``,还没读全返回 ``None``,否则返回去掉
+ ``ESC [`` / ``ESC O`` 之后的"参数+终结符"(例如 ``b"A"``、``b"3~"``)。
+ """
+ # 最多试两次:第一次先看看能不能直接判完,不行就等一小会儿再试。
+ for attempt in (0, 1):
+ raw = self._raw
+ if len(raw) == 1:
+ # 单按 ESC 与序列的第一个字节长得一样,有歧义
+ if attempt:
+ self._raw = b""
+ return b""
+ self._read_ready(PENDING_WAIT)
+ continue
+
+ if raw[1:2] not in (b"[", b"O"):
+ # 不是标准转义序列:吃掉 ESC,剩下的字节留给下一轮
+ self._raw = raw[1:]
+ return b""
+
+ end = self._csi_end(raw)
+ if end is None:
+ if attempt:
+ self._raw = b""
+ return b"" # 等不到了,当单按 ESC 处理,别把按键吞掉
+ self._read_ready(PENDING_WAIT)
+ continue
+
+ self._raw = raw[end + 1 :]
+ return raw[2 : end + 1]
+ return None
+
+ @staticmethod
+ def _csi_end(raw: bytes) -> int | None:
+ """CSI 序列的终结符下标(0x40~0x7E);还没读到就返回 None。"""
+ index = 2
+ while index < len(raw):
+ if 0x40 <= raw[index] <= 0x7E:
+ return index
+ index += 1
+ return None
+
# ------------------------------------------------------------------ 清理
def close(self) -> None:
if self._fd is not None and self._saved_term is not None:
@@ -148,3 +329,4 @@ class KeyReader:
pass
self._fd = None
self._saved_term = None
+ self._raw = b""
diff --git a/farm/models.py b/farm/models.py
index fedfb62..5f67848 100644
--- a/farm/models.py
+++ b/farm/models.py
@@ -17,6 +17,33 @@ def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
+def format_duration(seconds: float | None) -> str:
+ """把秒数写成人看的时长:``45s`` / ``39:58`` / ``1:05:00``。
+
+ 一天默认 40 分钟(2400 秒),直接显示秒数是一串难读的大数,
+ 所以 TUI 头部、``main.py state`` 这类地方都走这个函数。
+ """
+ if seconds is None:
+ return "—"
+ total = int(max(0.0, seconds))
+ if total < 60:
+ return f"{total}s"
+ hours, rest = divmod(total, 3600)
+ minutes, secs = divmod(rest, 60)
+ if hours:
+ return f"{hours}:{minutes:02d}:{secs:02d}"
+ return f"{minutes}:{secs:02d}"
+
+
+def next_goal_after(index: int) -> dict[str, Any] | None:
+ """给定目标序号,返回"下一个目标"。已到最后一个则返回 None。"""
+ nxt = index + 1
+ if nxt >= len(config.GOALS):
+ return None
+ amount, text = config.GOALS[nxt]
+ return {"amount": amount, "text": text}
+
+
class Plot:
"""一块土地。"""
@@ -243,14 +270,14 @@ class GameState:
@property
def next_goal(self) -> dict[str, Any] | None:
- nxt = self.goal_index + 1
- if nxt >= len(config.GOALS):
- return None
- amount, text = config.GOALS[nxt]
- return {"amount": amount, "text": text}
+ return next_goal_after(self.goal_index)
# ---------------------------------------------------------------- 序列化
def to_dict(self) -> dict[str, Any]:
+ # net_worth 要遍历地块与牲畜,是这里最贵的一项;下面三处都要用它,
+ # 所以只算一次再分发给 net_worth / goal_index / next_goal。
+ worth = self.net_worth
+ goal = config.goal_index(worth)
return {
"day": self.day,
"gold": self.gold,
@@ -268,9 +295,9 @@ class GameState:
"animal_count": len(self.animals),
"max_animals": config.MAX_ANIMALS,
"market": {k: round(v, 2) for k, v in self.market.items()},
- "net_worth": round(self.net_worth, 2),
- "goal_index": self.goal_index,
- "next_goal": self.next_goal,
+ "net_worth": round(worth, 2),
+ "goal_index": goal,
+ "next_goal": next_goal_after(goal),
"stats": {
"harvested": self.total_harvested,
"collected": self.total_collected,
diff --git a/farm/net.py b/farm/net.py
index c5276f1..d15e3a8 100644
--- a/farm/net.py
+++ b/farm/net.py
@@ -11,7 +11,8 @@
* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理;
* 目标是别的机器 → 尊重用户环境里的代理设置。
-WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。
+SSE 长连接同样经由这里:客户端读 ``GET /api/stream`` 用的是 urllib,
+所以回环地址也必须直连,否则第一条状态推送就收不到。
"""
from __future__ import annotations
diff --git a/farm/server/__init__.py b/farm/server/__init__.py
index cffb9a1..0a7ea72 100644
--- a/farm/server/__init__.py
+++ b/farm/server/__init__.py
@@ -5,10 +5,10 @@
模块划分
--------
-* :mod:`farm.server.app` —— FastAPI 应用(REST + WebSocket)
+* :mod:`farm.server.app` —— FastAPI 应用(REST + SSE)
* :mod:`farm.server.actions` —— 动作表:所有入口共用的唯一执行通道
* :mod:`farm.server.session` —— 多牧场会话与统一时钟
-* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送
+* :mod:`farm.server.hub` —— SSE 订阅池与状态推送
* :mod:`farm.server.daemon` —— 守护进程(start/stop/status)
* :mod:`farm.server.webui` —— 内嵌网页客户端
diff --git a/farm/server/__main__.py b/farm/server/__main__.py
index 138ea5b..0719570 100644
--- a/farm/server/__main__.py
+++ b/farm/server/__main__.py
@@ -1,5 +1,14 @@
"""``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。
+ASGI 服务器用 uvicorn,并打开它的 **HTTP/2** 支持(``http="zttp"`` +
+``http2=True``):同一个端口既说 HTTP/1.1 也说 HTTP/2(TLS 走 ALPN,明文走
+h2c prior-knowledge),`curl`、浏览器、AI 脚本各取所需,应用代码一行都不用改。
+
+为什么 HTTP/2 要选 ``zttp``:uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在
+``zttp`` 这个包上(见 ``uvicorn/protocols/http/zttp_h2_impl.py``)。装上之后
+``AutoZttpProtocol`` 会在一条监听上自动分流两种协议,所以选它**不会**丢掉
+HTTP/1.1 能力。
+
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
再拉起一个本模块的实例,只是不带 ``--daemon``。
"""
@@ -7,18 +16,29 @@
from __future__ import annotations
import argparse
-import importlib.util
import os
import sys
+from types import FrameType
from .. import config
-from .daemon import DEFAULT_LOG_FILE, DEFAULT_PID_FILE, PROJECT_ROOT, DaemonManager
+from .daemon import (
+ DEFAULT_LOG_FILE,
+ DEFAULT_PID_FILE,
+ PROJECT_ROOT,
+ DaemonManager,
+ port_busy,
+)
+
+# 优雅停机的上限(秒)。SSE 是**永不自己结束**的长连接,停机时我们会先用
+# hub.close_all() 让每个流自己收尾;这个值只是兜底:万一有客户端赖着不走
+# (比如浏览器标签页没关),到点就取消剩下的连接任务,绝不让 stop 卡死。
+GRACE_TIMEOUT = 5.0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="uv run python -m farm.server",
- description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。",
+ description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
@@ -68,40 +88,98 @@ def dependency_hint() -> str:
"缺少运行服务器所需的依赖。请先安装:\n"
" uv sync\n"
"如果不想用 uv,也可以用 pip:\n"
- " pip install fastapi 'uvicorn[standard]'\n"
+ " pip install 'uvicorn[standard]' fastapi zttp\n"
f"\n当前解释器:{sys.executable}"
)
-def main(argv=None) -> int:
- args = build_parser().parse_args(argv)
+def clear_pid_file(path: str | None) -> None:
+ """删掉自己写的 pid 文件,避免下次 status 看到"幽灵进程"。
+
+ 只删内容确实是本进程 pid 的那个:万一文件已经被别的实例接管,
+ 删掉它等于把别人的记录抹了。
+
+ 顺序有讲究:必须**先读完、关掉句柄,再 os.remove**。Windows 上删一个还开着的
+ 文件会抛 ``PermissionError``(WinError 32),而它会被下面的 ``except OSError``
+ 吞掉——结果就是 pid 文件永远留在磁盘上(这个坑在本项目里真实存在过,
+ 做端到端停机验证时才被发现)。
+ """
+ if not path:
+ return
+ try:
+ if not os.path.exists(path):
+ return
+ with open(path, encoding="utf-8") as handle:
+ content = handle.read().strip()
+ if content == str(os.getpid()):
+ os.remove(path)
+ except OSError:
+ pass
+
+
+def _resolve_save_dir(args) -> str:
+ """存档目录:显式 --save-dir 优先,其次老参数 --save 所在目录,最后项目根/saves。"""
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
if args.save_file:
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
+ return save_dir
- if args.daemon:
- manager = DaemonManager(
- host=args.host,
- port=args.port,
- token=args.token,
- tick=clamp_tick(args.tick),
- save_dir=save_dir,
- pid_file=args.pid_file or DEFAULT_PID_FILE,
- log_file=args.log_file or DEFAULT_LOG_FILE,
- )
- ok, message = manager.start()
- print(message)
- return 0 if ok else 1
+def _start_daemon(args, save_dir: str) -> int:
+ """把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。"""
+ manager = DaemonManager(
+ host=args.host,
+ port=args.port,
+ token=args.token,
+ tick=clamp_tick(args.tick),
+ save_dir=save_dir,
+ pid_file=args.pid_file or DEFAULT_PID_FILE,
+ log_file=args.log_file or DEFAULT_LOG_FILE,
+ )
+ ok, message = manager.start()
+ print(message)
+ return 0 if ok else 1
+
+
+def _print_startup_banner(cfg, save_dir: str) -> None:
+ print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
+ print(f" 监听 http://{cfg.host}:{cfg.port}/")
+ print(f" 存档目录 {save_dir}")
+ print(f" 实时通道 http://{cfg.host}:{cfg.port}/api/stream?farm=<牧场名>(SSE)")
+ print(" ASGI uvicorn(同一端口协商 HTTP/1.1 与 HTTP/2,HTTP/2 由 zttp 提供)")
+ if cfg.token:
+ print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
+
+
+def _ensure_port_free(host: str, port: int) -> int | None:
+ """端口自检:可用返回 ``None``,被别人占着则打印提示并返回退出码 3。
+
+ 必须用"能不能连上"来判断,而不是"能不能 bind":Windows 上只要两边都设了
+ SO_REUSEADDR(uvicorn 会设),第二个进程也能在已被监听的端口上"绑定成功"
+ (然后抢走新连接),于是静默把第一个实例顶掉——这种事故比启动失败难查得多。
+ """
+ if not port_busy(host, port):
+ return None
+ print(
+ f"启动失败:{host}:{port} 已经有程序在监听(可能是另一个 SimpleFarm 实例)。\n"
+ f" 换一个端口试试:--port {port + 1}",
+ file=sys.stderr,
+ )
+ return 3
+
+
+def _run_foreground(args, save_dir: str) -> int:
+ """在本进程里把 ASGI 服务器跑到退出,返回进程退出码。"""
+ # uvicorn 只在真的要跑服务时才需要——`main.py solo` 这类离线玩法不该被迫装它,
+ # 所以延迟到这一步才导入,失败时给出可照抄的安装命令。
try:
- import uvicorn
+ from uvicorn import Config as UvicornConfig
+ from uvicorn import Server as UvicornServer
except ImportError:
print(dependency_hint(), file=sys.stderr)
return 2
- # 只探测"在不在",不真的导入——uvicorn 需要它才能完成 WebSocket 协议升级
- ws_ok = importlib.util.find_spec("websockets") is not None
- from .app import ServerConfig, create_app
+ from .app import ServerConfig, ServerHandle, as_asgi, create_app
cfg = ServerConfig(
host=args.host,
@@ -114,67 +192,122 @@ def main(argv=None) -> int:
world_name=args.world,
allow_remote_control=not args.no_remote_control,
)
- app = create_app(cfg)
+ # 先看端口有没有人占着。
+ busy_code = _ensure_port_free(cfg.host, cfg.port)
+ if busy_code is not None:
+ return busy_code
- server_config = uvicorn.Config(
- app,
+ app = create_app(cfg)
+ handle = app.state.handle
+
+ uv_config = UvicornConfig(
+ as_asgi(app),
host=cfg.host,
port=cfg.port,
log_level=args.log_level,
access_log=args.access_log,
- loop="asyncio",
- lifespan="on",
+ # HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol,
+ # http2=True 才让它真的启用 h2(uvicorn 只认 zttp 这个后端)。
+ http="zttp",
+ http2=True,
+ # 与 graceful shutdown 的语义一致:等连接自己收尾的最长时间
+ timeout_graceful_shutdown=int(GRACE_TIMEOUT),
)
- server = uvicorn.Server(server_config)
- # 让 POST /api/admin/shutdown 能触发优雅退出(lifespan 里会把存档写掉)
- app.state.handle.on_stop(lambda: setattr(server, "should_exit", True))
+
+ # 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是
+ # 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。
+ class FarmServer(UvicornServer):
+ """uvicorn 服务器 + SimpleFarm 的停机语义。
+
+ 唯一改动是 :meth:`handle_exit`(uvicorn 通过 ``capture_signals`` 把它接到
+ SIGINT / SIGTERM / SIGBREAK 上)。默认实现只置 ``should_exit``,那样 SSE
+ 长连接收不到 ``bye``,只能干等 ``timeout_graceful_shutdown`` 到点被强杀——
+ 实测白等满 5 秒。这里改成先 ``handle.request_stop()``:每个流立刻自己收尾,
+ 再由 ``on_stop`` 回调置位 ``should_exit``。于是「接口停机」与「信号停机」
+ 走的是同一条路径。
+ """
+
+ # 由下面在实例化之后注入(放在这里是为了不动基类 __init__ 的签名)
+ handle: ServerHandle
+
+ def __init__(self, config: UvicornConfig) -> None:
+ super().__init__(config)
+ self._interrupts = 0
+
+ def handle_exit(self, sig: int, frame: FrameType | None) -> None:
+ self._interrupts += 1
+ if self._interrupts > 1:
+ # 第二次信号:用户不想再等了,放弃优雅收尾,直接退
+ print("\n再次收到中断信号,强制退出。", file=sys.stderr, flush=True)
+ os._exit(130)
+ # 故意不调 super():uvicorn 的版本只置 should_exit,而我们的停机钩子
+ # 会做同样的事,还要额外让 SSE 流主动收尾。另外保持 _captured_signals
+ # 为空,退出时就不会把信号重放一遍。
+ self.handle.request_stop()
+
+ server = FarmServer(uv_config)
+ server.handle = handle
+
+ # 两条停机路径汇到同一个地方:
+ # • POST /api/admin/shutdown → handle.request_stop()
+ # • Ctrl+C / SIGTERM / SIGBREAK → FarmServer.handle_exit → handle.request_stop()
+ # request_stop() 既让 SSE 流自己收尾(生成器看 handle.stopping),
+ # 也经由这个回调把 should_exit 置位,uvicorn 于是开始优雅退出。
+ handle.on_stop(lambda: setattr(server, "should_exit", True))
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
+ # uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口",
+ # 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败,
+ # 下面的 finally 会把 pid 文件删掉,不留幽灵。
def write_pid_file() -> None:
if not args.pid_file:
return
try:
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
- with open(args.pid_file, "w", encoding="utf-8") as handle:
- handle.write(str(os.getpid()))
+ with open(args.pid_file, "w", encoding="utf-8") as file_handle:
+ file_handle.write(str(os.getpid()))
except OSError as exc:
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
- app.state.handle.on_start(write_pid_file)
+ handle.on_start(write_pid_file)
- print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
- print(f" 监听 http://{cfg.host}:{cfg.port}/")
- print(f" 存档目录 {save_dir}")
- print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>")
- if not ws_ok:
+ _print_startup_banner(cfg, save_dir)
+
+ status = 0
+ try:
+ server.run()
+ except OSError as exc:
print(
- " ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
- "网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'"
- )
- if cfg.token:
- print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
-
- server.run()
-
- # 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程"
- if args.pid_file:
- try:
- if os.path.exists(args.pid_file):
- with open(args.pid_file, encoding="utf-8") as handle:
- if handle.read().strip() == str(os.getpid()):
- os.remove(args.pid_file)
- except OSError:
- pass
-
- if not getattr(server, "started", False):
- print(
- f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
+ f"启动失败:{exc}\n"
+ f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
- return 3
- print("服务器已停止。")
- return 0
+ status = 3
+ except SystemExit as exc:
+ # uvicorn 的 lifespan 跑在 bind 之前,监听失败时它自己 sys.exit(3)
+ code = exc.code
+ status = code if isinstance(code, int) and code else 3
+ print(
+ f"启动失败:无法监听 {cfg.host}:{cfg.port}(细节见上面的日志)。\n"
+ f" 换一个端口试试:--port {cfg.port + 1}",
+ file=sys.stderr,
+ )
+ finally:
+ clear_pid_file(args.pid_file)
+
+ if status == 0:
+ print("服务器已停止。")
+ return status
+
+
+def main(argv=None) -> int:
+ """入口:要么转后台(父进程立刻返回),要么就在本进程里把服务跑起来。"""
+ args = build_parser().parse_args(argv)
+ save_dir = _resolve_save_dir(args)
+ if args.daemon:
+ return _start_daemon(args, save_dir)
+ return _run_foreground(args, save_dir)
if __name__ == "__main__":
diff --git a/farm/server/actions.py b/farm/server/actions.py
index 8f9f27c..26228f6 100644
--- a/farm/server/actions.py
+++ b/farm/server/actions.py
@@ -1,11 +1,11 @@
-"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。
+"""统一的动作表:HTTP、命令行、机器人都走这一份映射。
为什么要有这一层
----------------
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
-"HTTP 能做、WS 不能做" 这种不一致。
+"HTTP 能做、命令行不能做" 这种不一致。
所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。
新增一个玩法动作,只需要在这张表里加一行。
@@ -128,14 +128,6 @@ def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
}
-def _save(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
- return engine.save()
-
-
-def _reset(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]:
- return engine.reset()
-
-
def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]:
value = params.get("paused")
return engine.pause(value if isinstance(value, bool) else None)
@@ -232,8 +224,6 @@ _register(
_register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot()))
-_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep()))
-
_register(
Action(
"pause",
@@ -245,11 +235,11 @@ _register(
)
)
-_register(Action("save", "把当前农场写入存档文件", [], _save))
-
-_register(Action("reset", "重置农场,从头开始经营", [], _reset))
-
+# 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下
+# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径,
+# 任何一个新入口直接 run_action("save") 都会覆盖别人的存档。
+# 见 farm.server.app.execute_action 的拦截逻辑。
def action_names() -> list[str]:
return sorted(ACTIONS)
diff --git a/farm/server/app.py b/farm/server/app.py
index 9fc7d48..9ab2e43 100644
--- a/farm/server/app.py
+++ b/farm/server/app.py
@@ -1,4 +1,4 @@
-"""FastAPI 应用:REST + WebSocket + 网页客户端。
+"""FastAPI 应用:REST + SSE + 网页客户端。
接口设计原则
------------
@@ -8,8 +8,9 @@
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
每个牧场有自己的引擎、时钟与存档;互不影响。
-4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是
- 同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
+4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
+ 的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
+ 两者改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
"""
from __future__ import annotations
@@ -22,13 +23,23 @@ from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from typing import Any
-from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
from .. import config
from ..engine import GameError
from . import actions as actions_mod
-from .hub import Client, ConnectionHub, Publisher, next_seq
+from .hub import (
+ CLOSE_MESSAGE,
+ HEARTBEAT_SECONDS,
+ IDLE_TICK,
+ Client,
+ ConnectionHub,
+ Publisher,
+ next_seq,
+ sse_frame,
+ sse_heartbeat,
+)
from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id
from .webui import DASHBOARD_HTML, dashboard_text
@@ -56,8 +67,8 @@ class ServerConfig:
class ServerHandle:
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
- * ``on_start``:监听端口成功后触发(用于写 pid 文件等);
- * ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。
+ * ``on_start``:ASGI 生命周期启动时触发(用于写 pid 文件等);
+ * ``on_stop``:请求关闭时触发(uvicorn 用它把 ``should_exit`` 置位,开始优雅退出)。
"""
def __init__(self) -> None:
@@ -89,43 +100,56 @@ class ServerHandle:
# ---------------------------------------------------------------------- 工具
+# brief() 从精简状态里挑选的字段白名单:够 AI 做下一步决策,又不至于太吵。
+# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。
+BRIEF_KEYS = (
+ "day",
+ "gold",
+ "level",
+ "exp",
+ "exp_needed",
+ "ap",
+ "max_ap",
+ "feed",
+ "plot_count",
+ "max_plots",
+ "next_plot_price",
+ "animal_count",
+ "max_animals",
+ "net_worth",
+ "goal_index",
+ "seconds_to_tick",
+ "paused",
+)
+
+# 特意为 AI 准备的待办清单:不用遍历整个快照就知道该干什么
+BRIEF_TODO_KEYS = ("ripe_plots", "hungry_animals", "collectable_animals")
+
+
def brief(session: FarmSession, hub: ConnectionHub) -> dict[str, Any]:
- """操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。"""
- snap = session.snapshot()
- return {
- "farm": session.farm_id,
- "day": snap["day"],
- "gold": snap["gold"],
- "level": snap["level"],
- "exp": snap["exp"],
- "exp_needed": snap["exp_needed"],
- "ap": snap["ap"],
- "max_ap": snap["max_ap"],
- "feed": snap["feed"],
- "plot_count": snap["plot_count"],
- "max_plots": snap["max_plots"],
- "next_plot_price": snap["next_plot_price"],
- "animal_count": snap["animal_count"],
- "max_animals": snap["max_animals"],
- "ripe_plots": [p["position"] for p in snap["plots"] if p["is_ripe"]],
- "hungry_animals": [a["id"] for a in snap["animals"] if not a["fed"]],
- "collectable_animals": [a["id"] for a in snap["animals"] if a["pending"] > 0],
- "net_worth": snap["net_worth"],
- "goal_index": snap["goal_index"],
- "seconds_to_tick": snap["seconds_to_tick"],
- "paused": snap["paused"],
- "online": hub.online(session.farm_id),
- }
+ """操作后回给调用者的精简状态。
+
+ 只读引擎直接给出的那十几个标量,**不做完整快照**。以前这里是
+ ``session.snapshot()`` 之后再挑字段——每块地、每只牲畜都白白序列化一遍,
+ 实测每次动作浪费约 60µs;而 ``full=1`` 时后面还要再序列化一次给快照用。
+ """
+ source = session.engine.brief_source()
+ data: dict[str, Any] = {"farm": session.farm_id}
+ data.update({key: source[key] for key in BRIEF_KEYS})
+ data.update({key: source[key] for key in BRIEF_TODO_KEYS})
+ data["online"] = hub.online(session.farm_id)
+ return data
def rules_text() -> str:
return (
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
"==========================================\n"
- f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。"
+ f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟"
+ f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。"
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
- f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
- '(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n'
+ f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
+ "时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
@@ -142,94 +166,111 @@ def rules_text() -> str:
' POST /api/actions → {"action":"<上一步里的 action>",'
'"params":{...}} 直接执行\n'
" 响应里的 state 就是执行后的最新状态,无需再查一次。\n"
- "\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;"
- '也可以直接在 WS 上发送 {"type":"action","action":"plant",'
- '"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n'
+ "\n实时通道(单向推送,服务器 → 客户端):GET /api/stream?farm=me 是一条 SSE "
+ '长连接,服务器在状态变化时推 {"type":"state",...};\n'
+ "想主动拉一次用 GET /api/state;写操作一律 POST /api/actions。\n"
)
-# ---------------------------------------------------------------------- 应用
-def create_app(cfg: ServerConfig | None = None) -> FastAPI:
- cfg = cfg or ServerConfig()
- manager = SessionManager(
- save_dir=cfg.save_dir,
- tick_seconds=cfg.tick_seconds,
- max_sessions=cfg.max_sessions,
- idle_timeout=cfg.idle_timeout,
- )
- hub = ConnectionHub()
- publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval)
- clock = FarmClock(manager)
- handle = ServerHandle()
- started_at = time.time()
- counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0}
+def as_asgi(app: FastAPI) -> Any:
+ """把应用交给 ASGI 服务器时的类型桥。
+
+ uvicorn 用它自己那份 ``uvicorn._types.Scope`` 描述 ASGI,starlette(FastAPI)
+ 用的是 ``starlette.types.Scope``。两者运行期逐字一致,但都是 TypedDict、
+ 彼此没有子类型关系,所以 pyright 会拦。返回值声明成 Any 就是明说:
+ 这个转换由我们担保,问题只在两份类型声明的重复上。
+ """
+ return app
+
+
+# ---------------------------------------------------------------------- 运行时
+@dataclass
+class _Services:
+ """create_app 装配出来的运行时依赖。
+
+ 以前这些是 ``create_app`` 的局部变量,于是每个路由都必须是嵌套闭包才能看见
+ 它们——一个函数膨胀到 475 行、8 层嵌套。收进一个对象后,路由可以拆成模块级
+ 的注册函数,各自只依赖传进来的 ``svc``,读起来是平的。
+ """
+
+ cfg: ServerConfig
+ manager: SessionManager
+ hub: ConnectionHub
+ publisher: Publisher
+ clock: FarmClock
+ handle: ServerHandle
+ started_at: float
+ counters: dict[str, int]
+
+ @classmethod
+ def create(cls, cfg: ServerConfig) -> _Services:
+ manager = SessionManager(
+ save_dir=cfg.save_dir,
+ tick_seconds=cfg.tick_seconds,
+ max_sessions=cfg.max_sessions,
+ idle_timeout=cfg.idle_timeout,
+ )
+ hub = ConnectionHub()
+ return cls(
+ cfg=cfg,
+ manager=manager,
+ hub=hub,
+ publisher=Publisher(manager, hub, autosave_interval=cfg.autosave_interval),
+ clock=FarmClock(manager),
+ handle=ServerHandle(),
+ started_at=time.time(),
+ # 这里没有 ws_messages:那是 WebSocket 时代的遗留计数,
+ # 改成 SSE 之后再也没有人加过它,留着只会让人以为它在被用。
+ counters={"requests": 0, "actions": 0, "errors": 0},
+ )
- # 标注成 asynccontextmanager:FastAPI 的 lifespan 参数要的就是
- # "返回异步上下文管理器",裸 async generator 在类型上并不匹配
@contextlib.asynccontextmanager
- async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
- clock.start()
- publisher.start()
- # 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
- handle.notify_start()
+ async def lifespan(self, _app: FastAPI) -> AsyncIterator[None]:
+ """应用生命周期:起时钟与推送线程,退出时按相反顺序收干净。
+
+ 装饰成 ``asynccontextmanager`` 是因为 FastAPI 要的就是"能当异步上下文
+ 管理器用"的东西,裸 async generator 在类型上并不匹配。
+ """
+ self.clock.start()
+ self.publisher.start()
+ # 生命周期启动完成,可以对外宣告"我在了"。uvicorn 的顺序是"先跑 lifespan、
+ # 后 bind 端口",所以这里代表"服务已上线",不代表"端口已监听";
+ # bind 失败时 pid 文件由 __main__ 的 finally 负责清掉。
+ self.handle.notify_start()
try:
yield
finally:
- publisher.stop()
- clock.stop()
- manager.shutdown()
-
- app = FastAPI(
- title=f"{SERVER_NAME} 游戏服务器",
- version=API_VERSION,
- description="命令行农场游戏的服务端:HTTP 给 AI 用,WebSocket 给实时界面用。",
- lifespan=lifespan,
- )
- app.state.cfg = cfg
- app.state.manager = manager
- app.state.hub = hub
- app.state.publisher = publisher
- app.state.clock = clock
- app.state.handle = handle
- app.state.started_at = started_at
- app.state.counters = counters
-
- # ------------------------------------------------------------ 异常处理
- @app.exception_handler(GameError)
- async def _game_error(_request: Request, exc: GameError) -> JSONResponse:
- counters["errors"] += 1
- return JSONResponse(exc.to_dict(), status_code=exc.status)
-
- @app.exception_handler(json.JSONDecodeError)
- async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse:
- counters["errors"] += 1
- return JSONResponse(
- {"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"},
- status_code=400,
- )
+ self.publisher.stop()
+ # 兜底:万一是信号触发退出(没走 /api/admin/shutdown),也把长连接收掉
+ self.hub.close_all()
+ self.clock.stop()
+ self.manager.shutdown()
# ------------------------------------------------------------ 鉴权与取牧场
- def authorized(supplied: str | None) -> bool:
- if not cfg.token:
- return True
- return supplied == cfg.token
+ def authorized(self, supplied: str | None) -> bool:
+ # 没配 token 就是谁都能进(本机自娱自乐的默认形态)
+ return not self.cfg.token or supplied == self.cfg.token
+ @staticmethod
def token_of(request: Request) -> str | None:
return request.headers.get("x-api-token") or request.query_params.get("token")
- def guard(request: Request) -> None:
- counters["requests"] += 1
- if not authorized(token_of(request)):
+ def guard(self, request: Request) -> None:
+ """计数 + 鉴权。所有对外路由的第一句都调它。"""
+ self.counters["requests"] += 1
+ if not self.authorized(self.token_of(request)):
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
- def session_of(request: Request, owner: str = "") -> FarmSession:
+ def session_of(self, request: Request, owner: str = "") -> FarmSession:
farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
- return manager.get(farm, create=True, owner=owner)
+ return self.manager.get(farm, create=True, owner=owner)
+ @staticmethod
def full_requested(request: Request) -> bool:
raw = (request.query_params.get("full") or "").lower()
return raw in ("1", "true", "yes", "on")
+ @staticmethod
async def body_of(request: Request) -> dict[str, Any]:
raw = await request.body()
if not raw:
@@ -242,12 +283,14 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
raise GameError("bad_json", "请求体必须是 JSON 对象")
return data
- def execute_action(session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
+ # ------------------------------------------------------------ 动作执行
+ def execute_action(self, session: FarmSession, name: Any, params: Any) -> dict[str, Any]:
"""执行一个动作,返回引擎/会话级的结果字典。
- ``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎:
- ``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。
- 这两个动作单独拦截,其余全部走统一的动作表。
+ ``save`` 与 ``reset`` 是**存档类动作**,刻意不在 farm.server.actions
+ 的通用动作表里(那张表只认识引擎方法,而 ``engine.save()`` 只会写到
+ 默认存档路径,多牧场环境下会把别人的存档覆盖掉)。它们只能走这里、
+ 由牧场会话处理;其余全部走统一的动作表。
"""
key = name.strip().lower() if isinstance(name, str) else ""
if key == "save":
@@ -264,48 +307,70 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
return actions_mod.run_action(session.engine, name, params)
def run_and_wrap(
- session: FarmSession, name: Any, params: Any, full: bool = False
+ self, session: FarmSession, name: Any, params: Any, full: bool = False
) -> dict[str, Any]:
- """执行动作并拼装统一响应。HTTP 与 WS 共用。"""
- result = execute_action(session, name, params)
- counters["actions"] += 1
+ """执行动作并拼装统一响应。HTTP 各入口共用。"""
+ result = self.execute_action(session, name, params)
+ self.counters["actions"] += 1
session.touch(count_action=True)
payload: dict[str, Any] = dict(result)
payload["ok"] = True
payload["farm"] = session.farm_id
payload["seq"] = next_seq()
- payload["state"] = brief(session, hub)
+ payload["state"] = brief(session, self.hub)
payload["log"] = session.engine.recent_log(limit=3)
if full:
payload["snapshot"] = session.snapshot()
return payload
- # ================================================================ 页面
+
+# ---------------------------------------------------------------------- 路由分组
+# 每个 _register_* 负责一组关系紧密的接口。分组边界照 OpenAPI 的 tags 来切,
+# 所以"接口文档里在哪组"和"代码里在哪段"是对得上的。
+def _register_error_handlers(app: FastAPI, svc: _Services) -> None:
+ @app.exception_handler(GameError)
+ async def _game_error(_request: Request, exc: GameError) -> JSONResponse:
+ svc.counters["errors"] += 1
+ return JSONResponse(exc.to_dict(), status_code=exc.status)
+
+ @app.exception_handler(json.JSONDecodeError)
+ async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse:
+ svc.counters["errors"] += 1
+ return JSONResponse(
+ {"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"},
+ status_code=400,
+ )
+
+
+def _register_pages(app: FastAPI, svc: _Services) -> None:
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def index() -> HTMLResponse:
return HTMLResponse(DASHBOARD_HTML)
@app.get("/text", response_class=PlainTextResponse, include_in_schema=False)
async def text_index() -> PlainTextResponse:
- return PlainTextResponse(dashboard_text(cfg.host, cfg.port))
+ return PlainTextResponse(dashboard_text(svc.cfg.host, svc.cfg.port))
- # ================================================================ 元信息
+
+def _register_meta_routes(app: FastAPI, svc: _Services) -> None:
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
async def health() -> dict[str, Any]:
+ # 刻意不鉴权:探针要能在没有 token 的情况下判断"这个服务还活着吗"
return {
"ok": True,
"service": SERVER_NAME,
"version": API_VERSION,
- "uptime": round(time.time() - started_at, 1),
- "farms": manager.count(),
- "clients": hub.total(),
- "clock_ticks": clock.ticks,
- "saves_since_start": publisher.pushes,
+ "uptime": round(time.time() - svc.started_at, 1),
+ "farms": svc.manager.count(),
+ "clients": svc.hub.total(),
+ "clock_ticks": svc.clock.ticks,
+ "saves_since_start": svc.publisher.pushes,
+ "publish_errors": svc.publisher.errors,
}
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
async def help_page(request: Request) -> dict[str, Any]:
- guard(request)
+ svc.guard(request)
return {
"ok": True,
"service": SERVER_NAME,
@@ -323,46 +388,48 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}'
),
"farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}',
- "realtime": "WS /ws?farm=NAME",
+ "realtime": "GET /api/stream?farm=NAME(SSE 单向推送)",
},
}
+
+def _register_world_routes(app: FastAPI, svc: _Services) -> None:
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
async def world(request: Request) -> dict[str, Any]:
- guard(request)
+ svc.guard(request)
return {
"ok": True,
- "world": cfg.world_name,
- "uptime": round(time.time() - started_at, 1),
- "farms": manager.count(),
- "clients": hub.total(),
- "connections": hub.snapshot(),
- "leaderboard": manager.leaderboard(hub.online_map()),
- "tick_seconds": cfg.tick_seconds,
- "started_at": started_at,
+ "world": svc.cfg.world_name,
+ "uptime": round(time.time() - svc.started_at, 1),
+ "farms": svc.manager.count(),
+ "clients": svc.hub.total(),
+ "connections": svc.hub.snapshot(),
+ "leaderboard": svc.manager.leaderboard(svc.hub.online_map()),
+ "tick_seconds": svc.cfg.tick_seconds,
+ "started_at": svc.started_at,
}
- # ================================================================ 牧场
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
async def list_farms(request: Request) -> dict[str, Any]:
- guard(request)
- rows = manager.leaderboard(hub.online_map())
+ svc.guard(request)
+ rows = svc.manager.leaderboard(svc.hub.online_map())
return {"ok": True, "count": len(rows), "farms": rows}
@app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场")
async def create_farm(request: Request) -> dict[str, Any]:
- guard(request)
- body = await body_of(request)
+ svc.guard(request)
+ body = await svc.body_of(request)
farm = validate_farm_id(body.get("farm") or body.get("name"))
owner = str(body.get("owner") or "")
- existed = manager.find(farm) is not None
- session = manager.get(farm, create=True, owner=owner)
+ existed = svc.manager.find(farm) is not None
+ session = svc.manager.get(farm, create=True, owner=owner)
tick = body.get("tick")
if tick is not None:
try:
tick_value = float(tick)
except (TypeError, ValueError):
raise GameError("bad_param", "tick 必须是数字") from None
+ # 直接赋值绕过了引擎构造时的夹取,所以这里得自己夹一次
session.engine.tick_seconds = max(
config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value)
)
@@ -372,45 +439,46 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
"created": not existed,
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
"farm": farm,
- "summary": session.summary(hub.online(farm)),
- "state": brief(session, hub),
+ "summary": session.summary(svc.hub.online(farm)),
+ "state": brief(session, svc.hub),
}
- # ================================================================ 单牧场只读
+
+def _register_farm_routes(app: FastAPI, svc: _Services) -> None:
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
async def get_state(request: Request) -> dict[str, Any]:
- guard(request)
- session = session_of(request)
+ svc.guard(request)
+ session = svc.session_of(request)
return {
"ok": True,
"farm": session.farm_id,
"state": session.snapshot(),
- "online": hub.online(session.farm_id),
+ "online": svc.hub.online(session.farm_id),
}
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
async def get_catalog(request: Request) -> dict[str, Any]:
- guard(request)
- session = session_of(request)
+ svc.guard(request)
+ session = svc.session_of(request)
return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()}
@app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)")
async def get_actions(request: Request) -> dict[str, Any]:
- guard(request)
- session = session_of(request)
+ svc.guard(request)
+ session = svc.session_of(request)
items = session.engine.available_actions()
return {
"ok": True,
"farm": session.farm_id,
"count": len(items),
"actions": items,
- "state": brief(session, hub),
+ "state": brief(session, svc.hub),
}
@app.get("/api/log", tags=["牧场"], summary="最近事件")
async def get_log(request: Request) -> dict[str, Any]:
- guard(request)
- session = session_of(request)
+ svc.guard(request)
+ session = svc.session_of(request)
limit = _int(request.query_params.get("limit"), 20, 1, 200)
since = _int(request.query_params.get("since"), 0, 0, 10**9)
entries = session.engine.recent_log(limit=limit, since=since)
@@ -422,18 +490,19 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
"last_seq": entries[-1]["seq"] if entries else since,
}
- # ================================================================ 写操作
+
+def _register_action_routes(app: FastAPI, svc: _Services) -> None:
@app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)")
async def post_action(request: Request) -> dict[str, Any]:
- guard(request)
- body = await body_of(request)
- session = session_of(request)
+ svc.guard(request)
+ body = await svc.body_of(request)
+ session = svc.session_of(request)
name = body.get("action") or body.get("name")
params = body.get("params")
if params is None:
# 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"}
params = {k: v for k, v in body.items() if k not in ("action", "name", "params")}
- return run_and_wrap(session, name, params, full_requested(request))
+ return svc.run_and_wrap(session, name, params, svc.full_requested(request))
@app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)")
async def post_batch(request: Request) -> dict[str, Any]:
@@ -441,9 +510,9 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
每个动作独立成败,失败的只记错误不中断后续动作。
"""
- guard(request)
- body = await body_of(request)
- session = session_of(request)
+ svc.guard(request)
+ body = await svc.body_of(request)
+ session = svc.session_of(request)
items = body.get("actions")
if not isinstance(items, list) or not items:
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
@@ -455,12 +524,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
continue
try:
- payload = run_and_wrap(
+ payload = svc.run_and_wrap(
session, item.get("action"), item.get("params") or {}, full=False
)
+ # 逐个动作不必重复回精简状态:末尾统一给一份就够了
results.append({k: v for k, v in payload.items() if k != "state"})
except GameError as exc:
- counters["errors"] += 1
+ svc.counters["errors"] += 1
results.append({**exc.to_dict(), "action": item.get("action")})
ok_count = sum(1 for r in results if r.get("ok"))
payload: dict[str, Any] = {
@@ -469,22 +539,24 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
"done": ok_count,
"failed": len(results) - ok_count,
"results": results,
- "state": brief(session, hub),
+ "state": brief(session, svc.hub),
}
- if full_requested(request):
+ if svc.full_requested(request):
payload["snapshot"] = session.snapshot()
return payload
def make_action_route(action_name: str) -> Callable[[Request], Any]:
async def route(request: Request) -> dict[str, Any]:
- guard(request)
- body = await body_of(request)
- session = session_of(request)
- return run_and_wrap(session, action_name, body, full_requested(request))
+ svc.guard(request)
+ body = await svc.body_of(request)
+ session = svc.session_of(request)
+ return svc.run_and_wrap(session, action_name, body, svc.full_requested(request))
route.__name__ = f"post_{action_name}"
return route
+ # 每个动作都额外挂一条自己的路径(POST /api/water 之类),方便直接 curl;
+ # 语义与 POST /api/actions 完全等价。
for _name, _spec in actions_mod.ACTIONS.items():
app.add_api_route(
f"/api/{_name}",
@@ -495,12 +567,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
summary=f"{_spec.summary}(等价于 POST /api/actions)",
)
- # ================================================================ 管理
+
+def _register_admin_routes(app: FastAPI, svc: _Services) -> None:
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
async def admin_save(request: Request) -> dict[str, Any]:
- guard(request)
+ svc.guard(request)
saved: list[str] = []
- for session in manager.all():
+ for session in svc.manager.all():
try:
session.save()
saved.append(session.farm_id)
@@ -512,210 +585,139 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
@app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)")
async def admin_shutdown(request: Request) -> dict[str, Any]:
- guard(request)
- if not cfg.allow_remote_control:
+ svc.guard(request)
+ if not svc.cfg.allow_remote_control:
raise GameError("forbidden", "服务器未开放远程停机", status=403)
- manager.shutdown()
- accepted = handle.request_stop()
+ svc.manager.shutdown()
+ # 先让所有 SSE 长连接收尾:否则服务器的优雅停机要一直等它们,变成卡死
+ svc.hub.close_all()
+ accepted = svc.handle.request_stop()
return {
"ok": True,
"message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)",
}
- # ================================================================ WebSocket
- @app.websocket("/ws")
- async def ws_endpoint(websocket: WebSocket) -> None:
- params = websocket.query_params
- supplied = websocket.headers.get("x-api-token") or params.get("token")
- if not authorized(supplied):
- # 4401 是自定义关闭码,客户端据此提示"令牌不对"而不是"网络断了"
- await websocket.close(code=4401, reason="unauthorized")
- return
- try:
- farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
- except GameError as exc:
- await websocket.close(code=4400, reason=exc.message[:120])
- return
+def _register_stream_route(app: FastAPI, svc: _Services) -> None:
+ @app.get("/api/stream", tags=["实时"], summary="SSE 实时推送(服务器 → 客户端单向)")
+ async def stream(request: Request) -> StreamingResponse:
+ """一条 HTTP 长连接持续推送状态;写操作请走 POST /api/actions。
+
+ 消息类型:``welcome``(首帧全量)/ ``state``(状态变化)/ ``day``(跨天)/
+ ``notice``(有人来去)/ ``error``,每条都是一行 JSON 放在 ``data:`` 后面。
+ 客户端断开(关页面、退出 TUI、进程被杀)都会走到生成器的 finally。
+ """
+ svc.guard(request)
+ params = request.query_params
+ farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
+ # 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
+ session = svc.manager.get(farm, create=True)
- await websocket.accept()
loop = asyncio.get_running_loop()
client = Client(farm, loop, name=(params.get("name") or "")[:32])
- try:
- session = manager.get(farm, create=True)
- except GameError as exc:
- # 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题
- await websocket.send_json({"type": "error", "error": exc.code, "message": exc.message})
- await websocket.close(code=1013, reason=exc.message[:100])
- return
- hub.register(client)
+ svc.hub.register(client)
session.touch()
- await websocket.send_json(
- {
- "type": "welcome",
- "client_id": client.cid,
- "farm": farm,
- "server": {
- "name": SERVER_NAME,
- "version": API_VERSION,
- "world": cfg.world_name,
- "tick_seconds": session.engine.tick_seconds,
- },
- "state": session.snapshot(),
- "catalog": session.engine.catalog(),
- "actions": session.engine.available_actions(),
- "log": session.engine.recent_log(limit=12),
- "online": hub.online(farm),
- }
- )
- hub.broadcast(
- farm,
- {
- "type": "notice",
- "farm": farm,
- "text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})",
- },
- exclude=client,
- )
+ # "来了/走了"是同一件事的两个方向,差别只有动词,所以共用一个信封
+ who = client.name or client.cid
- async def writer() -> None:
- while True:
- message = await client.next_message()
- await websocket.send_json(message)
+ def notice(text: str, exclude: Client | None = None) -> None:
+ svc.hub.broadcast(farm, {"type": "notice", "farm": farm, "text": text}, exclude=exclude)
- async def reader() -> None:
- while True:
- raw = await websocket.receive_text()
- counters["ws_messages"] += 1
- await handle_ws_message(raw)
-
- async def handle_ws_message(raw: str) -> None:
- """处理一条客户端消息。
-
- 注意:这里只做「解析 + 派发」,真正的引擎调用丢给线程池,
- 因此一条慢查询不会让同一个连接的心跳/推送卡住。
- """
- try:
- data = json.loads(raw)
- except json.JSONDecodeError as exc:
- await websocket.send_json(
- {"type": "error", "error": "bad_json", "message": f"不是合法 JSON:{exc}"}
- )
- return
- if not isinstance(data, dict):
- await websocket.send_json(
- {"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"}
- )
- return
-
- kind = str(data.get("type") or "").lower()
- rid = data.get("id")
-
- if kind == "ping":
- await websocket.send_json({"type": "pong", "id": rid, "t": time.time()})
- return
-
- if kind in ("sync", "refresh", "state"):
- await websocket.send_json(
- {
- "type": "sync",
- "id": rid,
- "seq": next_seq(),
- "farm": session.farm_id,
- "state": session.snapshot(),
- "actions": session.engine.available_actions(),
- "log": session.engine.recent_log(limit=20),
- }
- )
- return
-
- if kind == "help":
- await websocket.send_json(
- {
- "type": "help",
- "id": rid,
- "rules": rules_text(),
- "actions": actions_mod.describe_actions(),
- }
- )
- return
-
- if kind == "action":
- try:
- # 引擎是同步的,丢到线程池里跑,别堵住事件循环
- result = await asyncio.to_thread(
- execute_action,
- session,
- data.get("action"),
- data.get("params") or {},
- )
- except GameError as exc:
- counters["errors"] += 1
- await websocket.send_json(
- {
- "type": "result",
- "id": rid,
- "ok": False,
- "error": exc.code,
- "message": exc.message,
- "farm": session.farm_id,
- }
- )
- return
- counters["actions"] += 1
- session.touch(count_action=True)
- await websocket.send_json(
- {
- "type": "result",
- "id": rid,
- "ok": True,
- "farm": session.farm_id,
- "message": result.get("message", "完成"),
- "result": result,
- "state": brief(session, hub),
- # 回执里带上完整快照:客户端可以立刻刷新画面,不必等下一次推送
- "snapshot": session.snapshot(),
- "log": session.engine.recent_log(limit=3),
- "seq": next_seq(),
- }
- )
- return
-
- await websocket.send_json(
+ async def events() -> AsyncIterator[str]:
+ # 首帧等价于原来的 welcome:一次把状态、目录、合法动作、日志给全
+ yield sse_frame(
{
- "type": "error",
- "error": "unknown_message",
- "message": f"不认识的消息类型 {kind!r}",
- "hint": "可用:action / ping / sync / help",
+ "type": "welcome",
+ "client_id": client.cid,
+ "farm": farm,
+ "server": {
+ "name": SERVER_NAME,
+ "version": API_VERSION,
+ "world": svc.cfg.world_name,
+ "tick_seconds": session.engine.tick_seconds,
+ },
+ "state": session.snapshot(),
+ "catalog": session.engine.catalog(),
+ "actions": session.engine.available_actions(),
+ "log": session.engine.recent_log(limit=12),
+ "online": svc.hub.online(farm),
}
)
-
- writer_task = asyncio.create_task(writer())
- reader_task = asyncio.create_task(reader())
- try:
- await asyncio.wait({writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED)
- except WebSocketDisconnect:
- pass
- except Exception:
- counters["errors"] += 1
- finally:
- for task in (writer_task, reader_task):
- task.cancel()
- hub.unregister(client)
- session.touch()
- hub.broadcast(
- session.farm_id,
- {
- "type": "notice",
- "farm": session.farm_id,
- "text": (
- f"{client.name or client.cid} 离开了牧场"
- f"(在线 {hub.online(session.farm_id)})"
- ),
- },
+ notice(
+ f"{who} 来到了牧场(在线 {svc.hub.online(farm)})",
+ exclude=client,
)
+ try:
+ last_beat = time.time()
+ while True:
+ if svc.handle.stopping:
+ # 服务器要退了:主动收尾,别让长连接拖住优雅停机
+ yield sse_frame(dict(CLOSE_MESSAGE))
+ break
+ try:
+ message = await asyncio.wait_for(client.next_message(), timeout=IDLE_TICK)
+ except asyncio.TimeoutError:
+ # 空转一秒只是为了让上面的停机检查有机会跑;
+ # 心跳按自己的间隔发,别刷屏
+ if time.time() - last_beat >= HEARTBEAT_SECONDS:
+ last_beat = time.time()
+ yield sse_heartbeat()
+ continue
+ if message.get("type") == "bye":
+ yield sse_frame(message)
+ break
+ yield sse_frame(message)
+ finally:
+ svc.hub.unregister(client)
+ session.touch()
+ notice(f"{who} 离开了牧场(在线 {svc.hub.online(farm)})")
+ return StreamingResponse(
+ events(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ # 让 nginx 之类的反代不要缓冲,否则推送会攒着一次性发出来
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+# ---------------------------------------------------------------------- 应用
+def create_app(cfg: ServerConfig | None = None) -> FastAPI:
+ """装配应用:依赖 → 生命周期 → 各路由分组。
+
+ 路由各自藏在 ``_register_*`` 里,所以这里读起来就是一份目录:
+ 想找哪个接口,按分组名翻就行,不必在几百行里爬。
+ """
+ cfg = cfg or ServerConfig()
+ svc = _Services.create(cfg)
+
+ app = FastAPI(
+ title=f"{SERVER_NAME} 游戏服务器",
+ version=API_VERSION,
+ description="命令行农场游戏的服务端:HTTP 给 AI 用,SSE 给实时界面用。",
+ lifespan=svc.lifespan,
+ )
+ app.state.cfg = cfg
+ app.state.manager = svc.manager
+ app.state.hub = svc.hub
+ app.state.publisher = svc.publisher
+ app.state.clock = svc.clock
+ app.state.handle = svc.handle
+ app.state.started_at = svc.started_at
+ app.state.counters = svc.counters
+
+ _register_error_handlers(app, svc)
+ _register_pages(app, svc)
+ _register_meta_routes(app, svc)
+ _register_world_routes(app, svc)
+ _register_farm_routes(app, svc)
+ _register_action_routes(app, svc)
+ _register_admin_routes(app, svc)
+ _register_stream_route(app, svc)
return app
diff --git a/farm/server/daemon.py b/farm/server/daemon.py
index b145b4d..2bf215f 100644
--- a/farm/server/daemon.py
+++ b/farm/server/daemon.py
@@ -19,6 +19,7 @@ import contextlib
import json
import os
import signal
+import socket
import subprocess
import sys
import time
@@ -66,6 +67,27 @@ def pid_alive(pid: int) -> bool:
return True
+def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
+ """端口上已经有程序在监听了吗。
+
+ 用"能不能连上"判断,而不是"能不能 bind":Windows 上只要两边都设了
+ ``SO_REUSEADDR``(uvicorn 会设),第二个进程也能绑上同一个
+ 端口、然后抢走新连接——于是 bind 探测会给出一个自信的错误答案。
+ """
+
+ if host in ("0.0.0.0", ""):
+ target = "127.0.0.1"
+ elif host == "::":
+ target = "::1"
+ else:
+ target = host
+ try:
+ with socket.create_connection((target, port), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
@@ -118,39 +140,35 @@ class DaemonManager:
return None
# ------------------------------------------------------------ 探测
- def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
- """访问 /api/health,成功说明服务真的在监听。
+ def _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None:
+ """访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。
- 用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
+ 一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
- """
- url = f"http://{self.host}:{self.port}/api/health"
- try:
- with net.open_url(url, timeout=timeout) as response:
- return json.loads(response.read().decode("utf-8"))
- except (urllib.error.URLError, OSError, ValueError):
- return None
- def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
- url = f"http://{self.host}:{self.port}/api/world"
- if self.token:
+ 末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组,
+ 调用方的 ``.get(...)`` 会直接崩在属性访问上。
+ """
+ url = f"http://{self.host}:{self.port}{path}"
+ if token and self.token:
url += "?token=" + urllib.parse.quote(self.token)
try:
with net.open_url(url, timeout=timeout) as response:
- return json.loads(response.read().decode("utf-8"))
+ payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
+ return payload if isinstance(payload, dict) else None
+
+ def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
+ """访问 /api/health,成功说明服务真的在监听。"""
+ return self._get_json("/api/health", timeout)
+
+ def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
+ return self._get_json("/api/world", timeout, token=True)
def port_busy(self) -> bool:
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
- import socket
-
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(0.6)
- return (
- sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port))
- == 0
- )
+ return port_busy(self.host, self.port)
# ------------------------------------------------------------ 启动
def command(self) -> list[str]:
diff --git a/farm/server/hub.py b/farm/server/hub.py
index e147ba9..6bf3739 100644
--- a/farm/server/hub.py
+++ b/farm/server/hub.py
@@ -1,20 +1,23 @@
-"""实时通道:WebSocket 连接池 + 状态推送线程。
+"""实时通道:SSE 订阅池 + 状态推送线程。
-为什么选 WebSocket 而不是 SSE
------------------------------
-这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门),
-客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个
-HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。
-WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。
+为什么是 SSE
+------------
+这个游戏的下行(作物成熟、别人来串门)需要服务器**主动**推给客户端,
+而上行(播种、喂食)本来就是普通的 HTTP 写请求。SSE(Server-Sent Events)
+正好是"一条 HTTP 长连接单向推流",上行复用 REST,两边各用最自然的协议:
+
+* 纯 HTTP 语义:`curl`、浏览器 `EventSource`、任何 HTTP 客户端都能读;
+* 不用额外协议栈:客户端只需 urllib 流式读,连第三方库都不用装;
+* 天然支持重连(浏览器 EventSource 自动重连,本项目的客户端自己重连)。
线程模型(重点)
----------------
uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。
两者之间不能直接调用对方的 API,所以:
-* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
+* 每条订阅持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列;
-* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。
+* 每条订阅有一个生成器协程,只负责「从队列取出 → yield 成 SSE 帧」。
这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。
"""
@@ -24,13 +27,19 @@ from __future__ import annotations
import asyncio
import contextlib
import itertools
+import json
import threading
import time
from collections import defaultdict
from typing import Any
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
-QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢)
+QUEUE_SIZE = 32 # 单订阅出口缓冲区;满了就丢最旧的(状态类消息可丢)
+HEARTBEAT_SECONDS = 15.0 # 无消息时的 SSE 心跳间隔,防中间代理掐断空闲连接
+IDLE_TICK = 1.0 # 生成器空转的唤醒间隔:用于发现"服务器要停机了"
+
+# 停机时推给每个订阅者的收尾消息:客户端据此知道是服务器主动断开的
+CLOSE_MESSAGE: dict[str, Any] = {"type": "bye", "message": "服务器正在停机"}
_seq_counter = itertools.count(1)
@@ -44,8 +53,22 @@ def next_seq() -> int:
return next(_seq_counter)
+def sse_frame(message: dict[str, Any]) -> str:
+ """把一条消息编码成 SSE 帧。
+
+ SSE 的格式很简单:``data: <一行文本>`` 加一个空行。
+ JSON 本身不含裸换行(``json.dumps`` 会把换行转义),所以一帧一行就够。
+ """
+ return f"data: {json.dumps(message, ensure_ascii=False)}\n\n"
+
+
+def sse_heartbeat() -> str:
+ """SSE 注释行心跳:客户端会忽略,但能让代理/防火墙看到连接还活着。"""
+ return ": keep-alive\n\n"
+
+
class Client:
- """一条 WebSocket 连接。"""
+ """一条 SSE 订阅(一个浏览器标签页 / 一个 TUI / 一个脚本)。"""
_ids = itertools.count(1)
@@ -74,7 +97,7 @@ class Client:
# ------------------------------------------------------------ 线程安全投递
def push(self, message: dict[str, Any]) -> None:
- """从任意线程把消息排队到该连接的写协程。"""
+ """从任意线程把消息排队到该订阅的生成器协程。"""
if not self.alive:
return
try:
@@ -110,7 +133,7 @@ class Client:
class ConnectionHub:
- """按牧场分组的连接登记处。"""
+ """按牧场分组的 SSE 订阅登记处。"""
def __init__(self) -> None:
self._lock = threading.RLock()
@@ -154,21 +177,27 @@ class ConnectionHub:
with self._lock:
return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)]
- # ------------------------------------------------------------ 广播
- def broadcast(
- self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
- ) -> int:
- sent = 0
- for client in self.clients(farm_id):
- if client is exclude:
- continue
- client.push(message)
- sent += 1
- return sent
+ # ------------------------------------------------------------ 收尾
+ def close_all(self) -> int:
+ """让所有 SSE 流主动收尾,返回受影响的订阅数。
- def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
+ 停机时必须做这件事:SSE 是**永不自己结束**的长连接,只要还有一条挂着,
+ uvicorn 的优雅停机就要一直等它,`stop` 会卡到超时。发一条 ``bye``
+ 让每个流的生成器自己 break,连接随之正常关闭。
+ """
with self._lock:
targets = list(self._all)
+ return self._push_many(targets, dict(CLOSE_MESSAGE), None)
+
+ # ------------------------------------------------------------ 广播
+ def _push_many(
+ self, targets: list[Client], message: dict[str, Any], exclude: Client | None
+ ) -> int:
+ """给一批订阅者推送同一条消息,返回送出的条数。
+
+ ``Client.push`` 只是把消息排进队列、不改动它,所以这里可以放心让所有
+ 订阅者共用同一个 dict(不必每人拷一份)。
+ """
sent = 0
for client in targets:
if client is exclude:
@@ -177,9 +206,21 @@ class ConnectionHub:
sent += 1
return sent
+ def broadcast(
+ self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
+ ) -> int:
+ """推给某个牧场里的所有订阅者。"""
+ return self._push_many(self.clients(farm_id), message, exclude)
+
+ def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
+ """推给所有牧场的所有订阅者。"""
+ with self._lock:
+ targets = list(self._all)
+ return self._push_many(targets, message, exclude)
+
class Publisher(threading.Thread):
- """后台推送线程:把「状态变了」这件事节流后送给在线的客户端。
+ """后台推送线程:把「状态变了」这件事节流后送给在线的订阅者。
引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里
按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧,
@@ -193,6 +234,7 @@ class Publisher(threading.Thread):
self.autosave_interval = autosave_interval
self._stop = threading.Event()
self.pushes = 0
+ self.errors = 0
self._last_autosave = time.time()
self._started_at = time.time()
@@ -213,8 +255,9 @@ class Publisher(threading.Thread):
session.dirty = False
self._push_state(session, now)
except Exception:
- # 推送线程绝不能因为单个牧场出错而退出
- pass
+ # 推送线程绝不能因为单个牧场出错而退出;
+ # 但也不能完全无声——计数进 self.errors,/api/health 可观测
+ self.errors += 1
if now - self._last_autosave >= self.autosave_interval:
self._last_autosave = now
diff --git a/farm/server/session.py b/farm/server/session.py
index fc8b192..e822d25 100644
--- a/farm/server/session.py
+++ b/farm/server/session.py
@@ -23,7 +23,7 @@ import time
from typing import Any
from .. import config
-from ..engine import FarmEngine, GameError
+from ..engine import FarmEngine, GameError, tick_decision
from ..storage import load_state, save_state
DEFAULT_FARM = "default"
@@ -72,6 +72,10 @@ class FarmSession:
self._lock = threading.RLock()
self.engine = FarmEngine(tick_seconds=tick_seconds)
+ # 立刻排好第一次推进:否则新建牧场要等时钟线程下一轮(≤ accuracy 秒)
+ # 才有 deadline,这段时间 snapshot 里的倒计时是 None,
+ # 客户端会误以为"已暂停 / 手动模式"。
+ self.engine.next_tick_at = time.time() + self.engine.tick_seconds
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
self.dirty = True
self.published_day = 0
@@ -153,19 +157,24 @@ class FarmSession:
return data
def summary(self, online: int = 0) -> dict[str, Any]:
- """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。"""
- snap = self.engine.snapshot()
+ """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。
+
+ 走 :meth:`FarmEngine.brief_source` 而**不是** ``snapshot()``:
+ 排行榜会对每个牧场调一次,而这里只用得上十几个标量——为它序列化全量
+ 地块与牲畜,成本会随牧场数线性放大(32 个牧场实测 ~1.9ms)。
+ """
+ brief = self.engine.brief_source()
return {
"farm": self.farm_id,
"owner": self.owner,
- "day": snap["day"],
- "gold": snap["gold"],
- "level": snap["level"],
- "net_worth": snap["net_worth"],
- "plots": f"{snap['plot_count']}/{snap['max_plots']}",
- "animals": snap["animal_count"],
- "goal_index": snap["goal_index"],
- "paused": snap["paused"],
+ "day": brief["day"],
+ "gold": brief["gold"],
+ "level": brief["level"],
+ "net_worth": brief["net_worth"],
+ "plots": f"{brief['plot_count']}/{brief['max_plots']}",
+ "animals": brief["animal_count"],
+ "goal_index": brief["goal_index"],
+ "paused": brief["paused"],
"online": online,
"actions": self.actions_taken,
"idle_seconds": round(time.time() - self.last_active, 1),
@@ -292,17 +301,12 @@ class FarmClock(threading.Thread):
for session in self.manager.all():
engine = session.engine
try:
- if engine.paused:
- # 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick
- engine.next_tick_at = now + engine.tick_seconds
- continue
- deadline = engine.next_tick_at
- if deadline is None:
- engine.next_tick_at = now + engine.tick_seconds
- continue
- if now >= deadline:
+ # tick 决策与单机 Scheduler 共用同一份纯函数,避免两处逻辑漂移
+ advance, engine.next_tick_at = tick_decision(
+ engine.paused, engine.next_tick_at, engine.tick_seconds, now
+ )
+ if advance:
session.advance_day("时间流逝")
- engine.next_tick_at = now + engine.tick_seconds
self.ticks += 1
except Exception:
# 单个牧场出错不影响其它牧场的时间
diff --git a/farm/server/webui.py b/farm/server/webui.py
index 590f436..ff91557 100644
--- a/farm/server/webui.py
+++ b/farm/server/webui.py
@@ -3,7 +3,7 @@
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
-静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
+静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
@@ -132,7 +132,6 @@ DASHBOARD_HTML = r"""
-
@@ -159,7 +158,7 @@ DASHBOARD_HTML = r"""