feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径

三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
snowy-yang committed 2026-09-19 12:55:45 +08:00
1 parent 47dd818360
commit f8e093c98f
36 files changed
+6865 -2204

No files matched your search

-1
View File
@@ -23,7 +23,6 @@ venv/
# saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库)
run/
saves/
tests/_tmp/
# 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等),
# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。
/_*
+215
View File
@@ -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"<html>不是 JSON</html>"` 直接 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)。
+87 -11
View File
@@ -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`,网页 <http://127.0.0.1:8765/>,文档 /docs
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | 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`
+126 -102
View File
@@ -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`。)*
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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`
+45 -23
View File
@@ -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)
+106 -188
View File
@@ -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):
+6 -4
View File
@@ -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 # 上限给到一整天,想多慢都不会被夹住
# --------------------------------------------------------------------------
+212 -146
View File
@@ -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
+232 -50
View File
@@ -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""
+35 -8
View File
@@ -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,
+2 -1
View File
@@ -11,7 +11,8 @@
* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理;
* 目标是别的机器 → 尊重用户环境里的代理设置。
WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。
SSE 长连接同样经由这里:客户端读 ``GET /api/stream`` 用的是 urllib,
所以回环地址也必须直连,否则第一条状态推送就收不到。
"""
from __future__ import annotations
+2 -2
View File
@@ -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` —— 内嵌网页客户端
+196 -63
View File
@@ -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__":
+6 -16
View File
@@ -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)
+357 -355
View File
@@ -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
+40 -22
View File
@@ -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]:
+71 -28
View File
@@ -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
+25 -21
View File
@@ -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:
# 单个牧场出错不影响其它牧场的时间
+25 -30
View File
@@ -3,7 +3,7 @@
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
@@ -132,7 +132,6 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<button class="primary" data-act="harvest_all">一键收获</button>
<button data-act="buy_plot">买地</button>
<button data-act="buy_feed">买饲料x10</button>
<button data-act="sleep">休息一天</button>
<button data-act="pause">暂停/继续</button>
<button data-act="save">存档</button>
<button data-act="reset">重置</button>
@@ -159,7 +158,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<script>
const $ = (id) => document.getElementById(id);
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
let es = null, state = null, catalog = null, pollTimer = null;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
@@ -218,40 +217,34 @@ function refreshFromBrief(brief) {
}
function syncNow() {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: "sync" }));
return;
}
api("/api/state?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
.then((d) => { if (d) { state = d.state; render(); } });
}
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
function connect() {
if (ws) { try { ws.close(); } catch (e) {} }
clearTimeout(reconnectTimer);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/ws?farm=" + encodeURIComponent(farmName()) +
if (es) { try { es.close(); } catch (e) {} }
clearInterval(pollTimer); pollTimer = null;
const url = "/api/stream?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
setStatus("连接中…", "");
ws = new WebSocket(url);
ws.onopen = () => {
setStatus("已连接(WebSocket)", "on");
es = new EventSource(url);
es.onopen = () => {
setStatus("已连接(SSE 实时)", "on");
clearInterval(pollTimer); pollTimer = null;
api("/api/farms", { headers: headers() });
};
ws.onmessage = (ev) => {
es.onmessage = (ev) => {
let msg = null;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handle(msg);
};
ws.onclose = (ev) => {
if (ev.code === 4401) { setStatus("令牌不正确", "bad"); }
else setStatus("已断开,3 秒后重连", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500); // 降级为轮询
reconnectTimer = setTimeout(connect, 3000);
es.onerror = () => {
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
setStatus("推送断开,轮询中…", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
};
ws.onerror = () => setStatus("连接异常", "bad");
}
function handle(msg) {
@@ -263,11 +256,6 @@ function handle(msg) {
state = msg.state; if (msg.catalog) catalog = msg.catalog;
renderLog(msg.log);
render();
} else if (msg.type === "result") {
toast(msg.message);
if (msg.snapshot) { state = msg.snapshot; render(); }
else if (msg.state) { refreshFromBrief(msg.state); }
renderLog(msg.log);
} else if (msg.type === "day") {
renderLog(null);
} else if (msg.type === "notice") {
@@ -277,6 +265,14 @@ function handle(msg) {
}
}
/* 一天 40 分钟,直接显示秒数是一串大数,换算成 m:ss */
function fmtLeft(sec) {
if (sec === null || sec === undefined) return "已暂停";
const t = Math.max(0, Math.round(sec));
if (t < 60) return t + "s";
return Math.floor(t / 60) + ":" + String(t % 60).padStart(2, "0");
}
function render() {
if (!state) return;
const s = state;
@@ -284,7 +280,7 @@ function render() {
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", s.seconds_to_tick === null ? "已暂停" : s.seconds_to_tick + "s"]
["下一天", fmtLeft(s.seconds_to_tick)]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
/* 农田 */
@@ -409,7 +405,6 @@ $("farm").value = localStorage.getItem("sf.farm") || "default";
$("token").value = localStorage.getItem("sf.token") || "";
connect();
setInterval(renderBoard, 5000);
setInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
</script>
</body>
</html>
@@ -424,7 +419,7 @@ def dashboard_text(host: str, port: int) -> str:
f"网页客户端:http://{host}:{port}/\n"
f"接口文档 :http://{host}:{port}/docs\n"
f"健康检查 :http://{host}:{port}/api/health\n"
f"实时通道 :ws://{host}:{port}/ws?farm=<牧场名>\n"
f"实时通道 :http://{host}:{port}/api/stream?farm=<牧场名>(SSE 单向推送)\n"
"\n"
"给 AI / 脚本的最短路径\n"
"----------------------\n"
@@ -448,7 +443,7 @@ def dashboard_text(host: str, port: int) -> str:
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n"
" collect|buy_feed|buy_plot|sleep|pause|save|reset 便捷入口\n"
" collect|buy_feed|buy_plot|pause|save|reset 便捷入口\n"
'POST /api/farms {"farm":"名字"} 创建/进入牧场\n'
"POST /api/admin/save 全部存档\n"
"POST /api/admin/shutdown 优雅停机\n"
+37 -21
View File
@@ -22,9 +22,10 @@ import time
import unicodedata
from typing import Any
from . import config
from . import config, keys
from .engine import GameError
from .keys import KeyReader
from .models import format_duration
from .storage import default_save_path
# ---------------------------------------------------------------- 颜色
@@ -181,6 +182,8 @@ class TuiApp:
self.autosave = autosave
self.server_label = server_label
self.reader = KeyReader()
# 读不到键盘时是否在界面上提示:预览工具画的是定格画面,会关掉它
self.show_keyboard_notice = True
self.running = True
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
@@ -256,7 +259,7 @@ class TuiApp:
# ================================================================ 输入
def _handle_key(self, key: str) -> None:
if key in ("CTRL_C",):
if key == keys.CTRL_C:
self.running = False
return
@@ -278,13 +281,15 @@ class TuiApp:
return
# ---- 主模式
# 键名一律取 farm.keys 的规范化常量:写成 keys.XXX 后 pyright 能直接
# 查出拼写错误,不会再出现"两端命名不一致、按键静默失效"这种事。
handler = {
"q": self._quit,
"?": self._open_help,
"up": lambda: self._move_plot(-self.grid_cols),
"down": lambda: self._move_plot(self.grid_cols),
"left": lambda: self._move_plot(-1),
"right": lambda: self._move_plot(1),
keys.UP: lambda: self._move_plot(-self.grid_cols),
keys.DOWN: lambda: self._move_plot(self.grid_cols),
keys.LEFT: lambda: self._move_plot(-1),
keys.RIGHT: lambda: self._move_plot(1),
"w": lambda: self._act(self.engine.water, self.plot_cursor + 1),
"h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1),
"e": self._harvest_all,
@@ -294,12 +299,10 @@ class TuiApp:
"c": self._collect,
"l": lambda: self._act(self.engine.buy_plot),
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
"n": lambda: self._act(self.engine.sleep),
"s": self._save,
"r": self._ask_reset,
" ": self._toggle_pause,
"tab": self._toggle_focus,
"tab\t": self._toggle_focus,
keys.SPACE: self._toggle_pause,
keys.TAB: self._toggle_focus,
}
action = handler.get(key)
if action:
@@ -406,7 +409,7 @@ class TuiApp:
self.mode = "select_animal"
def _handle_menu_key(self, key: str) -> None:
if key in ("esc", "q"):
if key in (keys.ESC, "q"):
self.mode = "main"
self._flash("已取消", C_GRAY)
return
@@ -516,7 +519,9 @@ class TuiApp:
right = tag + color("手动模式 ", C_GRAY, self.use_color)
else:
right = tag + color(
f"第 {snap['day']} 天 · 下一日 {countdown:.1f}s ", C_CYAN, self.use_color
f"第 {snap['day']} 天 · 下一日 {format_duration(countdown)} ",
C_CYAN,
self.use_color,
)
gap = max(1, width - display_width(left) - display_width(right) - 1)
@@ -749,14 +754,24 @@ class TuiApp:
log_box = draw_box("农事日志", rows, width, accent=C_PURPLE, use_color=self.use_color)
keys = " [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获"
keys2 = " [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料"
keys3 = " [N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出"
hint = [
color(keys, C_GRAY, self.use_color),
color(keys2, C_GRAY, self.use_color),
color(keys3, C_GRAY, self.use_color),
color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color),
color(
" [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料",
C_GRAY,
self.use_color,
),
color(" [空格]暂停 [S]存档 [R]重置 [Q]退出", C_GRAY, self.use_color),
]
if self.show_keyboard_notice and not self.reader.available:
# 读不到键盘时把原因摆在最显眼处,别让玩家对着界面干等
hint = [
color(
" ⚠ 当前终端读不到按键,只能观看:" + (self.reader.reason or "非交互式环境"),
C_ORANGE,
self.use_color,
)
]
return log_box + [pad(h, width) for h in hint]
def _render_menu(self, width: int) -> list[str]:
@@ -784,7 +799,8 @@ class TuiApp:
"",
color(" 时间", C_BOLD + C_GOLD, self.use_color)
+ color(
" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。",
f" 每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟自动推进一天,"
"作物成长、牲畜产奶、市场价都在这一天结算;只能等,没有快进。",
C_GRAY,
self.use_color,
),
@@ -813,14 +829,14 @@ class TuiApp:
),
color(" ", C_GRAY, self.use_color)
+ color(
" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置",
" B 买动物 / L 开垦土地 / K 买饲料 / 空格 暂停 / S 存档 / R 重置",
C_GRAY,
self.use_color,
),
"",
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
+ color(
" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,",
" 游戏跑在服务器进程里,HTTP 接口给 AI、SSE 长连接给实时界面,",
C_GRAY,
self.use_color,
),
+56 -36
View File
@@ -33,8 +33,10 @@ import json
import os
import sys
import time
from typing import Any
from farm import config
from farm.models import format_duration
DEFAULT_URL = "http://127.0.0.1:8765"
@@ -87,15 +89,21 @@ def die(message: str, hint: str = "", code: int = 1) -> int:
# ---------------------------------------------------------------------- 参数
def build_parser() -> argparse.ArgumentParser:
"""命令行总表:服务器那几个子命令 + 客户端那几个子命令。"""
parser = argparse.ArgumentParser(
prog="uv run main.py",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP/WebSocket 供 AI 调用)",
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP + SSE 供 AI 调用)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=BANNER,
)
sub = parser.add_subparsers(dest="command", metavar="<命令>")
_add_server_parsers(sub)
_add_client_parsers(sub)
return parser
# ---- 服务器
def _add_server_parsers(sub) -> None:
"""serve / stop / status / logs —— 管服务器本身的那几个。"""
p_serve = sub.add_parser("serve", help="启动游戏服务器(可后台运行)")
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
p_serve.add_argument(
@@ -127,29 +135,25 @@ def build_parser() -> argparse.ArgumentParser:
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)")
p_stop.add_argument("--port", type=int, default=8765)
p_stop.add_argument("--host", default="127.0.0.1")
p_stop.add_argument("--token", default=None)
_add_endpoint_args(p_stop)
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
p_status = sub.add_parser("status", help="查看服务器状态")
p_status.add_argument("--port", type=int, default=8765)
p_status.add_argument("--host", default="127.0.0.1")
p_status.add_argument("--token", default=None)
_add_endpoint_args(p_status)
p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部")
p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
p_logs = sub.add_parser("logs", help="查看服务器日志尾部")
p_logs.add_argument("-n", "--lines", type=int, default=40)
# ---- 客户端
def _add_client_parsers(sub) -> None:
"""play / solo / do / batch / actions / state / log / watch / bot / farms / help。"""
p_play = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩")
p_play.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
p_play.add_argument("--farm", default="default", help="牧场名,默认 default")
p_play.add_argument("--token", default=None)
_add_client_args(p_play)
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
p_play.add_argument("--no-color", action="store_true")
p_play.add_argument("--no-realtime", action="store_true", help="不用 WebSocket,改走 HTTP 轮询")
p_play.add_argument("--no-realtime", action="store_true", help="不用 SSE 推送,改走 HTTP 轮询")
p_solo = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)")
p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS)
@@ -188,6 +192,9 @@ def build_parser() -> argparse.ArgumentParser:
p_bot.add_argument("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限")
p_bot.add_argument("--minutes", type=float, default=0.0, help="最多跑多少分钟,0 表示不限")
p_bot.add_argument("--pause", type=float, default=0.25, help="每个动作之间的间隔秒数")
p_bot.add_argument(
"--wait", type=float, default=0.0, help="没事可做时最多等多少秒(等下一天),0 表示直接收工"
)
p_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜")
_add_client_args(p_farms)
@@ -195,15 +202,35 @@ def build_parser() -> argparse.ArgumentParser:
p_help = sub.add_parser("help", help="玩法与接口说明")
_add_client_args(p_help)
return parser
def _add_client_args(parser: argparse.ArgumentParser) -> None:
"""连服务器要用的三件套:地址、牧场名、令牌。"""
parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
parser.add_argument("--farm", default="default", help="牧场名,默认 default")
parser.add_argument("--token", default=None, help="访问令牌")
def _add_endpoint_args(parser: argparse.ArgumentParser) -> None:
"""只用来"找到那个服务器"的参数,stop / status 共用。"""
parser.add_argument("--host", default="127.0.0.1", help="服务器地址,默认 127.0.0.1")
parser.add_argument("--port", type=int, default=8765, help="服务器端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌")
def _leaderboard_lines(rows: list[dict[str, Any]], limit: int = 0) -> list[str]:
"""排行榜表格行,``cmd_farms`` 与 ``cmd_status`` 共用。
列宽只在这里定义一次,加一列时不必去两个地方各调一次格式。
"""
header = f"{'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}"
selected = rows[:limit] if limit else rows
return [header] + [
f"{row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
for row in selected
]
# ---------------------------------------------------------------------- 客户端
def make_client(args, realtime: bool = False):
"""构造远程客户端;连不上时给出可执行的下一步建议。"""
@@ -304,11 +331,8 @@ def cmd_status(args) -> int:
world = manager.world()
if world and world.get("leaderboard"):
print(" 排行榜")
for row in world["leaderboard"][:5]:
print(
f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}"
)
for line in _leaderboard_lines(world["leaderboard"], limit=5):
print(" " + line)
if args.logs:
print(dim(" ---- 日志尾部 ----"))
@@ -458,7 +482,8 @@ def cmd_actions(args) -> int:
)
if not items:
print(warn("• 当前没有可执行的动作"))
print(dim(" 试试:uv run main.py do sleep (推进一天)"))
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
return 0
print(f"共 {len(items)} 个候选动作:\n")
for index, item in enumerate(items, start=1):
@@ -481,7 +506,7 @@ def cmd_state(args) -> int:
print(
f"第 {snap.get('day')} 天"
+ (
f" · {snap.get('seconds_to_tick')}s 后进入下一天"
f" · {format_duration(snap.get('seconds_to_tick'))} 后进入下一天"
if snap.get("seconds_to_tick") is not None
else " · 已暂停"
)
@@ -555,7 +580,7 @@ def cmd_bot(args) -> int:
client = make_client(args, realtime=False)
bot = RuleBot(client, pause=args.pause, name=args.farm)
try:
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes)
summary = bot.run(rounds=args.rounds, max_minutes=args.minutes, wait_seconds=args.wait)
except KeyboardInterrupt:
bot.stop()
print("\n已中断。")
@@ -573,12 +598,8 @@ def cmd_farms(args) -> int:
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
)
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}")
for row in world.get("leaderboard", []):
print(
f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} "
f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}"
)
for line in _leaderboard_lines(world.get("leaderboard", [])):
print(" " + line)
return 0
@@ -604,7 +625,6 @@ QUICKSTART = """
uv run ruff check . # 静态检查(未用导入、可疑写法……)
uv run ruff format . # 统一格式
uv run pyright # 类型检查
uv run python -m unittest discover -s tests # 全部测试
直接调接口(AI / 脚本)
-----------------------
@@ -618,12 +638,12 @@ QUICKSTART = """
uv run main.py state --json # 机器可读的完整状态
uv run main.py watch # 实时看别人/机器人玩
WebSocket(实时推送)
---------------------
ws://127.0.0.1:8765/ws?farm=default
服务器在状态变化时推送 {"type":"state", ...};
也可以在 WS 上直接发动作:{"type":"action","action":"plant",
"params":{"plot":1,"crop":"wheat"},"id":1},会收到 {"type":"result",...} 回执。
SSE 实时推送(只读)
--------------------
curl -N 'http://127.0.0.1:8765/api/stream?farm=default'
服务器在状态变化时推 {"type":"state", ...},每帧一行 data: JSON;
没有消息时每 15 秒发一行 ": keep-alive" 心跳。
写操作走 HTTP:POST /api/actions,两条路改的是同一个引擎。
"""
+14 -10
View File
@@ -16,11 +16,11 @@
[project]
name = "simplefarm"
version = "2.0.0"
description = "终端农场经营游戏 + 小型多人游戏服务器:人玩 TUI,AI 走 HTTP/WebSocket 接口"
description = "终端农场经营游戏 + 小型多人游戏服务器:人玩 TUI,AI 走 HTTP + SSE 接口"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
keywords = ["game", "tui", "cli", "fastapi", "websocket", "llm", "agent"]
keywords = ["game", "tui", "cli", "fastapi", "sse", "llm", "agent"]
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Topic :: Games/Entertainment :: Simulation",
@@ -31,16 +31,22 @@ classifiers = [
# 所以 `uv run main.py solo` 这类离线玩法不需要装下面这两个。
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27", # [standard] 带上 websockets,实时推送靠它
# ASGI 服务器本体。[standard] 带 httptools(更快的 HTTP/1.1 解析)、watchfiles 等;
# 它还会捎带一个 websockets——本项目只用 SSE、不用 WebSocket,那是 uvicorn 的
# 可选依赖,别误会成我们在用。
"uvicorn[standard]>=0.53.0",
# HTTP/2 的开关。uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在 zttp 上
# (uvicorn/protocols/http/zttp_h2_impl.py):配 http="zttp" + http2=True 之后,
# AutoZttpProtocol 会在**同一个端口**按 ALPN(TLS)或 h2c 前导字节(明文)
# 自动分流 HTTP/1.1 与 HTTP/2,应用代码一行都不用改。
"zttp>=0.0.33",
]
# 开发依赖(`uv sync` 默认就会装,用 `uv sync --no-dev` 可以跳过)。
# httpx 不是可选项:FastAPI 的 TestClient 建立在它之上,测试必需。
[dependency-groups]
dev = [
"ruff>=0.8.0",
"pyright>=1.1.390",
"httpx>=0.27",
]
[tool.uv]
@@ -53,7 +59,7 @@ package = false
[tool.ruff]
line-length = 100 # 贴着现有代码定:只有 18 行超 100,用默认的 88 会重排 122 行
target-version = "py310" # 与 requires-python 对齐
src = ["farm", "tests", "tools"]
src = ["farm", "tools"]
# 文档里的示例代码是"给人看的排版",不交给格式化器管
extend-exclude = ["README.md"]
@@ -92,8 +98,6 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
# 内嵌的 HTML/JS 模板:整块写在一个字符串里,按 100 列硬拆反而更难读也更难改
"farm/server/webui.py" = ["E501"]
# 测试里注释是"解释为什么这么断言",紧跟断言更清楚;含长中文行属正常
"tests/*" = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["farm"]
@@ -109,8 +113,8 @@ line-ending = "auto" # Windows/Linux 混用时不要强改行尾,避免
# - basic 模式抓得住 None 误用、参数个数/类型不符、属性不存在这类真 bug;
# - 不去追 Unknown 类型(本项目的快照是 dict[str, Any] 结构,追下去全是噪音)。
[tool.pyright]
include = ["farm", "tests", "tools", "main.py", "ai_player.py"]
exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"]
include = ["farm", "tools", "main.py", "ai_player.py"]
exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run"]
pythonVersion = "3.10"
# 代码里有 termios / msvcrt 这类平台专属分支。只按当前平台分析,
# 会把另一侧的调用误报成"模块没有这个属性",所以让 pyright 同时考虑所有平台。
+3
View File
@@ -40,6 +40,7 @@ typing-extensions==4.16.0
# starlette
# typing-inspection
# uvicorn
# zttp
typing-inspection==0.4.4
# via
# fastapi
@@ -54,3 +55,5 @@ websockets==16.1.1 ; python_full_version < '3.11'
# via uvicorn
websockets==17.1 ; python_full_version >= '3.11'
# via uvicorn
zttp==0.0.33
# via simplefarm
+332
View File
@@ -0,0 +1,332 @@
"""动作表测试:所有入口共用的那唯一一条执行通道。
动作表(``farm/server/actions.py``)是 HTTP、命令行、网页、机器人共同的分发点,
所以"参数校验只在这里做一次"这件事必须被钉住——校验漏一处,就等于每个入口都漏。
uv run python -m unittest tests.test_actions -v
"""
from __future__ import annotations
import unittest
from farm import config
from farm.engine import FarmEngine, GameError
from farm.server.actions import (
ACTIONS,
Param,
action_names,
describe_actions,
run_action,
)
EXPECTED_ACTIONS = {
"plant",
"water",
"harvest",
"harvest_all",
"buy_animal",
"feed",
"collect",
"buy_feed",
"buy_plot",
"pause",
}
def make_engine() -> FarmEngine:
engine = FarmEngine()
engine.state.gold = 5000
return engine
class ParamTestCase(unittest.TestCase):
"""参数规格的取值转换——外部 JSON 什么类型都可能传进来。"""
def test_int_accepts_the_obvious_spellings(self):
spec = Param("plot", "int")
self.assertEqual(spec.coerce(3), 3)
self.assertEqual(spec.coerce("3"), 3)
self.assertEqual(spec.coerce(" 12 "), 12)
self.assertEqual(spec.coerce("-4"), -4)
self.assertEqual(spec.coerce("+7"), 7)
self.assertEqual(spec.coerce(3.0), 3)
def test_int_rejects_booleans_floats_and_words(self):
spec = Param("plot", "int")
for bad in (True, 3.5, "3.5", "三", "", None, [], {}):
with self.subTest(raw=bad):
with self.assertRaises(GameError) as ctx:
spec.coerce(bad)
self.assertEqual(ctx.exception.code, "bad_param")
def test_str_accepts_scalars(self):
spec = Param("crop", "str")
self.assertEqual(spec.coerce("wheat"), "wheat")
self.assertEqual(spec.coerce(123), "123")
self.assertEqual(spec.coerce(1.5), "1.5")
def test_str_rejects_containers(self):
spec = Param("crop", "str")
for bad in (["wheat"], {"a": 1}, None):
with self.subTest(raw=bad), self.assertRaises(GameError):
spec.coerce(bad)
def test_bool_accepts_common_spellings(self):
spec = Param("paused", "bool")
self.assertIs(spec.coerce(True), True)
self.assertIs(spec.coerce(False), False)
self.assertIs(spec.coerce("true"), True)
self.assertIs(spec.coerce("FALSE"), False)
self.assertIs(spec.coerce("1"), True)
self.assertIs(spec.coerce("0"), False)
self.assertIs(spec.coerce(1), True)
self.assertIs(spec.coerce(0), False)
def test_bool_rejects_ambiguous_words(self):
spec = Param("paused", "bool")
for bad in ("yes", "no", "暂停", "", [], 1.5):
with self.subTest(raw=bad):
with self.assertRaises(GameError) as ctx:
spec.coerce(bad)
self.assertEqual(ctx.exception.code, "bad_param")
def test_describe_matches_the_spec(self):
spec = Param("quantity", "int", required=False, default=10, desc="份数")
self.assertEqual(
spec.to_dict(),
{"name": "quantity", "type": "int", "required": False, "default": 10, "desc": "份数"},
)
class ActionTableTestCase(unittest.TestCase):
def test_the_table_holds_exactly_the_expected_actions(self):
self.assertEqual(set(action_names()), EXPECTED_ACTIONS)
def test_action_names_are_sorted(self):
names = action_names()
self.assertEqual(names, sorted(names))
def test_save_and_reset_are_deliberately_absent(self):
"""存档类动作必须由牧场会话处理,否则会写默认路径、覆盖别人的档。"""
self.assertNotIn("save", ACTIONS)
self.assertNotIn("reset", ACTIONS)
for name, spec in ACTIONS.items():
with self.subTest(action=name):
self.assertNotIn(name, ("save", "reset"))
self.assertNotEqual(spec.summary, "")
def test_removed_sleep_action_stays_removed(self):
"""「休息 / 手动推进一天」已经整体移除,别悄悄回来。"""
self.assertNotIn("sleep", ACTIONS)
self.assertNotIn("rest", ACTIONS)
self.assertNotIn("next_day", ACTIONS)
def test_describe_actions_is_json_shaped(self):
described = describe_actions()
self.assertEqual(len(described), len(ACTIONS))
for item in described:
with self.subTest(action=item["action"]):
self.assertEqual(set(item), {"action", "summary", "params"})
for param in item["params"]:
self.assertEqual(set(param), {"name", "type", "required", "default", "desc"})
def test_every_action_handler_returns_a_dict(self):
"""返回类型约定是 dict:HTTP 层直接把它铺进 JSON 响应。"""
engine = make_engine()
engine.state.level = 5
results = [
run_action(engine, "pause", {"paused": True}),
run_action(engine, "buy_feed", {"quantity": 1}),
run_action(engine, "plant", {"plot": 1, "crop": "wheat"}),
]
for result in results:
with self.subTest(result=result):
self.assertIsInstance(result, dict)
class RunActionValidationTestCase(unittest.TestCase):
"""统一执行通道的输入校验。"""
def setUp(self):
self.engine = make_engine()
def assert_code(self, code: str, name, params=None) -> GameError:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, name, params)
self.assertEqual(ctx.exception.code, code)
return ctx.exception
def test_missing_or_blank_action_name(self):
for bad in (None, "", " ", 123, ["plant"]):
with self.subTest(name=bad):
self.assert_code("bad_param", bad)
def test_unknown_action_lists_the_valid_ones(self):
exc = self.assert_code("unknown_action", "fly_to_moon")
for name in EXPECTED_ACTIONS:
self.assertIn(name, exc.message)
def test_action_name_is_normalized(self):
result = run_action(self.engine, " PLANT ", {"plot": 1, "crop": "wheat"})
self.assertEqual(result["action"], "plant")
self.assertTrue(result["ok"])
def test_params_must_be_an_object(self):
for bad in ([1, 2], "plot=1", 3):
with self.subTest(params=bad):
self.assert_code("bad_param", "plant", bad)
def test_none_params_becomes_an_empty_mapping(self):
self.assert_code("missing_param", "plant", None)
def test_missing_required_parameter(self):
exc = self.assert_code("missing_param", "plant", {"plot": 1})
self.assertIn("crop", exc.message)
def test_unknown_parameter_is_rejected_not_ignored(self):
"""静默忽略拼错的参数,会让 AI 以为动作生效了。"""
exc = self.assert_code("bad_param", "plant", {"plot": 1, "crop": "wheat", "pot": 2})
self.assertIn("pot", exc.message)
def test_wrong_parameter_type(self):
self.assert_code("bad_param", "plant", {"plot": "第一块", "crop": "wheat"})
def test_null_parameter_counts_as_missing(self):
self.assert_code("missing_param", "plant", {"plot": None, "crop": "wheat"})
def test_optional_parameter_falls_back_to_its_default(self):
before = self.engine.state.feed
result = run_action(self.engine, "buy_feed", {})
self.assertEqual(self.engine.state.feed, before + config.FEED_BULK)
self.assertEqual(result["action"], "buy_feed")
def test_error_dict_is_machine_readable(self):
exc = self.assert_code("unknown_action", "nope")
payload = exc.to_dict()
self.assertEqual(payload, {"ok": False, "error": "unknown_action", "message": exc.message})
def test_result_always_carries_ok_and_action(self):
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
self.engine.state.plots[0].growth = 2
result = run_action(self.engine, "harvest_all", {})
self.assertIs(result["ok"], True)
self.assertEqual(result["action"], "harvest_all")
class ActionBehaviourTestCase(unittest.TestCase):
"""经过动作表之后的玩法行为——确认分发没有接错线。"""
def setUp(self):
self.engine = make_engine()
self.engine.state.level = 5
def test_plant_then_water_then_harvest(self):
run_action(self.engine, "plant", {"plot": 2, "crop": "carrot"})
plot = self.engine.state.plots[1]
self.assertEqual(plot.crop, "carrot")
self.assertEqual(plot.index, 1)
plot.moisture = 0
run_action(self.engine, "water", {"plot": 2})
self.assertEqual(plot.moisture, config.WATER_CAPACITY)
plot.growth = plot.grow_days
result = run_action(self.engine, "harvest", {"plot": 2})
self.assertEqual(result["action"], "harvest")
self.assertTrue(plot.is_empty)
def test_buy_and_feed_and_collect_an_animal(self):
bought = run_action(self.engine, "buy_animal", {"kind": "chicken"})
aid = bought["animal"]["id"]
fed = run_action(self.engine, "feed", {"animal_id": aid})
self.assertEqual(fed["animal"]["fed"], True)
self.engine.state.animals[0].pending = 2
collected = run_action(self.engine, "collect", {"animal_id": aid})
self.assertEqual(collected["quantity"], 2)
def test_feed_accepts_an_explicit_quantity(self):
bought = run_action(self.engine, "buy_animal", {"kind": "cow"})
aid = bought["animal"]["id"]
self.engine.state.feed = 20
run_action(self.engine, "feed", {"animal_id": aid, "quantity": "2"})
self.assertEqual(self.engine.state.feed, 18, "字符串数字要能转换")
def test_buy_plot_returns_the_next_price(self):
result = run_action(self.engine, "buy_plot", {})
self.assertEqual(result["next_plot_price"], self.engine.state.next_plot_price)
def test_pause_through_the_table(self):
self.assertEqual(run_action(self.engine, "pause", {"paused": "true"})["paused"], True)
self.assertEqual(run_action(self.engine, "pause", {"paused": False})["paused"], False)
self.assertEqual(
run_action(self.engine, "pause", {})["paused"],
True,
"省略参数表示反转,此时是 False -> True",
)
def test_pause_rejects_values_that_are_not_boolean(self):
"""参数校验在前:含糊的字符串会被挡在门口,而不是当成 true。"""
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "pause", {"paused": "不确定算不算"})
self.assertEqual(ctx.exception.code, "bad_param")
class HarvestAllTestCase(unittest.TestCase):
def setUp(self):
self.engine = make_engine()
self.engine.state.level = 5
def test_nothing_ripe_is_an_error(self):
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "harvest_all", {})
self.assertEqual(ctx.exception.code, "nothing_to_harvest")
def test_harvests_every_ripe_plot_and_skips_the_rest(self):
for number in (1, 2, 3):
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
for index in (0, 1):
self.engine.state.plots[index].growth = 2 # 1、2 号成熟
result = run_action(self.engine, "harvest_all", {})
self.assertEqual(result["plots"], [1, 2])
self.assertEqual(
result["quantity"], result["earned"] // config.CROPS["wheat"]["base_price"]
)
self.assertEqual(result["errors"], [])
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的 3 号地要保持原样")
def test_partial_failure_does_not_stop_the_others(self):
"""一块地失败不能带走其它地块的收成。"""
for number in (1, 2):
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
for index in (0, 1):
self.engine.state.plots[index].growth = 2
self.engine.state.ap = 10
# 把第一块地做成"看起来熟了、实际收不了"(作物键被改坏)
self.engine.state.plots[0].crop = "wheat"
original = self.engine.harvest
def flaky(number):
if int(number) == 1:
raise GameError("plot_empty", "1 号地是空地")
return original(number)
self.engine.harvest = flaky # type: ignore[method-assign]
result = run_action(self.engine, "harvest_all", {})
self.assertEqual(result["plots"], [2])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("1号地", result["errors"][0])
def test_message_summarizes_the_haul(self):
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
self.engine.state.plots[0].growth = 2
result = run_action(self.engine, "harvest_all", {})
self.assertIn("1 块地", result["message"])
if __name__ == "__main__":
unittest.main(verbosity=2)
+394
View File
@@ -0,0 +1,394 @@
"""规则机器人测试:打分策略、单步执行、以及"没事可做时怎么办"。
机器人是「服务器把状态和合法动作摊开后,外面是谁在决策都不重要」这个设计的
活证明,所以它走的每一步都应当是**普通客户端**能走的。
全程使用鸭子类型的假客户端,不碰网络,也不依赖服务器。
uv run python -m unittest tests.test_bot -v
"""
from __future__ import annotations
import contextlib
import io
import unittest
from farm.bot import RuleBot, random_bot
from farm.engine import GameError
from farm.models import format_duration
class FakeClient:
"""只实现机器人真正会用的那几个方法。"""
def __init__(self, actions=None, state=None, catalog=None) -> None:
self.actions = list(actions or [])
self.state = dict(state or {})
self.catalog_data = catalog or {"crops": [], "animals": []}
self.calls: list[tuple[str, dict]] = []
self.fail_with: Exception | None = None
self.label = "假通道"
def available_actions(self):
return [dict(item) for item in self.actions]
def snapshot(self):
return dict(self.state)
def catalog(self):
return self.catalog_data
def transport_label(self) -> str:
return self.label
def _action(self, name, params=None):
self.calls.append((name, dict(params or {})))
if self.fail_with is not None:
raise self.fail_with
return {"ok": True, "message": f"完成 {name}"}
CATALOG = {
"crops": [
{"key": "wheat", "name": "小麦", "profit_per_day": 4.5},
{"key": "carrot", "name": "胡萝卜", "profit_per_day": 6.0},
],
"animals": [
{"key": "chicken", "name": "鸡", "cost": 90, "profit_per_day": 9.0},
{"key": "cow", "name": "牛", "cost": 480, "profit_per_day": 60.0},
],
}
BASE_STATE = {
"day": 1,
"gold": 1000,
"feed": 20,
"ap": 10,
"next_plot_price": 200,
"paused": False,
"seconds_to_tick": 120.0,
}
def make_bot(client=None, **kwargs) -> RuleBot:
options = {"verbose": False, "pause": 0.0, "name": "tester"}
options.update(kwargs)
return RuleBot(client or FakeClient(state=BASE_STATE), **options)
class ScoreTestCase(unittest.TestCase):
"""打分表就是整个"策略"——顺序错了机器人就会先乱花钱。"""
def setUp(self):
self.bot = make_bot()
def score(self, action: dict, **state_overrides) -> float:
state = dict(BASE_STATE)
state.update(state_overrides)
return self.bot.score(action, state, CATALOG)
def test_harvest_comes_first(self):
self.assertEqual(self.score({"action": "harvest"}), 100.0)
def test_free_money_actions_rank_above_others(self):
order = [
self.score({"action": "harvest"}),
self.score({"action": "collect"}),
self.score({"action": "feed"}),
self.score({"action": "water"}),
]
self.assertEqual(order, sorted(order, reverse=True))
self.assertEqual(len(set(order)), len(order), "分数要有区分度")
def test_plant_prefers_the_best_profit_per_day(self):
wheat = self.score({"action": "plant", "params": {"crop": "wheat"}})
carrot = self.score({"action": "plant", "params": {"crop": "carrot"}})
self.assertGreater(carrot, wheat)
def test_plant_of_an_unknown_crop_still_gets_a_base_score(self):
value = self.score({"action": "plant", "params": {"crop": "榴莲"}})
self.assertEqual(value, 60.0)
def test_feed_is_bought_only_when_short_and_affordable(self):
action = {"action": "buy_feed"}
self.assertEqual(self.score(action, feed=1, gold=500), 40.0)
self.assertEqual(self.score(action, feed=0, gold=500), 40.0)
self.assertEqual(self.score(action, feed=5, gold=500), 5.0, "库存够就别花钱")
self.assertEqual(self.score(action, feed=1, gold=50), 5.0, "钱少也别花")
def test_land_is_bought_only_with_a_cash_buffer(self):
action = {"action": "buy_plot"}
self.assertEqual(self.score(action, gold=600, next_plot_price=200), 34.0)
self.assertEqual(self.score(action, gold=599, next_plot_price=200), 4.0)
self.assertEqual(self.score(action, gold=9999, next_plot_price=-1), 4.0, "没地可买")
def test_animals_need_a_cash_buffer_and_a_free_action_point(self):
action = {"action": "buy_animal", "params": {"kind": "chicken"}}
self.assertEqual(self.score(action, gold=270, ap=1), 39.0)
self.assertEqual(self.score(action, gold=269, ap=1), 3.0)
self.assertEqual(self.score(action, gold=1000, ap=0), 3.0, "没行动点就别想")
def test_unknown_actions_have_the_lowest_score(self):
self.assertEqual(self.score({"action": "fly_to_moon"}), 1.0)
self.assertEqual(self.score({}), 1.0)
def test_score_survives_a_missing_payload(self):
"""服务器偶尔给的是精简状态,机器人不能因此崩掉。"""
self.assertEqual(self.bot.score({"action": "harvest"}, {}, {}), 100.0)
class ChooseTestCase(unittest.TestCase):
def test_picks_the_highest_scoring_action(self):
client = FakeClient(
actions=[
{"action": "water", "params": {"plot": 1}},
{"action": "harvest", "params": {"plot": 2}},
{"action": "plant", "params": {"plot": 3, "crop": "carrot"}},
],
state=BASE_STATE,
catalog=CATALOG,
)
choice = make_bot(client).choose()
assert choice is not None
self.assertEqual(choice["action"], "harvest")
def test_no_actions_means_no_choice(self):
self.assertIsNone(make_bot(FakeClient(actions=[], state=BASE_STATE)).choose())
def test_nothing_worth_doing_means_no_choice(self):
"""分数都 <= 1 时应当"什么都不做",而不是硬挑一个。"""
client = FakeClient(actions=[{"action": "fly"}], state=BASE_STATE, catalog=CATALOG)
self.assertIsNone(make_bot(client).choose())
def test_choice_is_json_shaped(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
choice = make_bot(client).choose()
assert choice is not None
self.assertEqual(choice["params"], {"plot": 1})
class StepTestCase(unittest.TestCase):
def test_successful_step_is_recorded(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
bot = make_bot(client)
self.assertEqual(bot.step(), "harvest")
self.assertEqual(bot.actions_executed, 1)
self.assertEqual(bot.done, ["harvest"])
self.assertEqual(client.calls, [("harvest", {"plot": 1})])
def test_nothing_to_do_returns_none(self):
bot = make_bot(FakeClient(actions=[], state=BASE_STATE))
self.assertIsNone(bot.step())
self.assertEqual(bot.actions_executed, 0)
def test_failure_is_counted_not_raised(self):
client = FakeClient(
actions=[{"action": "plant", "params": {"plot": 1, "crop": "wheat"}}],
state=BASE_STATE,
catalog=CATALOG,
)
client.fail_with = GameError("no_ap", "行动点不足")
bot = make_bot(client)
self.assertIsNone(bot.step())
self.assertEqual(bot.failures, 1)
self.assertEqual(bot.actions_executed, 0)
def test_a_failed_step_does_not_stop_the_bot(self):
client = FakeClient(
actions=[{"action": "plant", "params": {"plot": 1, "crop": "wheat"}}],
state=BASE_STATE,
catalog=CATALOG,
)
client.fail_with = GameError("no_ap", "行动点不足")
bot = make_bot(client)
bot.step()
client.fail_with = None
self.assertEqual(bot.step(), "plant")
class WaitForNextDayTestCase(unittest.TestCase):
"""没有「手动推进一天」之后,等待策略就是机器人的全部节奏感。"""
def test_paused_time_is_hopeless(self):
client = FakeClient(state={**BASE_STATE, "paused": True})
bot = make_bot(client)
self.assertFalse(bot.wait_for_next_day(10.0))
def test_zero_timeout_means_clock_out_immediately(self):
client = FakeClient(state=BASE_STATE)
bot = make_bot(client)
self.assertFalse(bot.wait_for_next_day(0))
self.assertEqual(client.calls, [], "收工不等于发请求")
def test_negative_timeout_also_clocks_out(self):
self.assertFalse(make_bot().wait_for_next_day(-5))
def test_waits_until_the_day_actually_changes(self):
class TickingClient(FakeClient):
"""第三次被问到时,"时间"已经跨天了。"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.reads = 0
def snapshot(self):
self.reads += 1
data = super().snapshot()
if self.reads >= 3:
data["day"] = data.get("day", 1) + 1
return data
bot = make_bot(TickingClient(state=BASE_STATE))
self.assertTrue(bot.wait_for_next_day(3.0))
def test_gives_up_when_the_day_never_comes(self):
bot = make_bot(FakeClient(state=BASE_STATE))
self.assertFalse(bot.wait_for_next_day(0.4))
def test_the_message_uses_the_human_duration_format(self):
client = FakeClient(state={**BASE_STATE, "seconds_to_tick": 1234.5})
bot = make_bot(client)
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
bot.verbose = True
bot.wait_for_next_day(0)
self.assertIn(format_duration(1234.5), buffer.getvalue())
def test_stop_interrupts_the_wait(self):
bot = make_bot(FakeClient(state=BASE_STATE))
bot.stop()
self.assertTrue(bot.stopped)
self.assertFalse(bot.wait_for_next_day(5.0), "已经叫停了就不该干等")
class RunTestCase(unittest.TestCase):
def test_round_limit_is_respected(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
summary = make_bot(client).run(rounds=3)
self.assertEqual(summary["actions"], 3)
self.assertEqual(summary["name"], "tester")
def test_summary_counts_the_days_that_passed(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
summary = make_bot(client).run(rounds=1)
self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True)
def test_no_action_points_means_clocking_out(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state={**BASE_STATE, "ap": 0},
catalog=CATALOG,
)
bot = make_bot(client)
summary = bot.run(rounds=5)
self.assertEqual(summary["actions"], 0)
self.assertEqual(client.calls, [], "没行动点就不该发动作请求")
def test_idle_limit_triggers_the_waiting_path(self):
client = FakeClient(actions=[], state=BASE_STATE)
bot = make_bot(client)
summary = bot.run(rounds=5, idle_limit=1, wait_seconds=0)
self.assertEqual(summary["actions"], 0)
self.assertEqual(bot.failures, 0)
def test_stopped_bot_exits_the_loop(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
bot = make_bot(client)
bot.stop()
summary = bot.run(rounds=10)
self.assertEqual(summary["actions"], 0)
def test_verbose_mode_prints_a_greeting(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
make_bot(client, verbose=True).run(rounds=1)
self.assertIn("上线", buffer.getvalue())
def test_quiet_mode_prints_nothing(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
make_bot(client).run(rounds=1)
self.assertEqual(buffer.getvalue(), "")
def test_transport_label_is_mentioned_on_startup(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
client.label = "SSE 实时"
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
make_bot(client, verbose=True).run(rounds=1)
self.assertIn("SSE 实时", buffer.getvalue())
def test_max_minutes_zero_means_no_deadline(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
summary = make_bot(client).run(rounds=2, max_minutes=0)
self.assertEqual(summary["actions"], 2)
class RandomBotTestCase(unittest.TestCase):
def test_random_bot_picks_from_the_legal_list(self):
actions = [
{"action": "water", "params": {"plot": 1}},
{"action": "harvest", "params": {"plot": 2}},
]
client = FakeClient(actions=actions, state=BASE_STATE, catalog=CATALOG)
bot = random_bot(client, verbose=False, pause=0.0)
choice = bot.choose()
assert choice is not None
self.assertIn(choice["action"], {"water", "harvest"})
self.assertEqual(bot.name, "random")
def test_random_bot_returns_none_without_options(self):
bot = random_bot(FakeClient(actions=[], state=BASE_STATE), verbose=False, pause=0.0)
self.assertIsNone(bot.choose())
def test_random_bot_still_executes_steps(self):
client = FakeClient(
actions=[{"action": "harvest", "params": {"plot": 1}}],
state=BASE_STATE,
catalog=CATALOG,
)
bot = random_bot(client, verbose=False, pause=0.0)
self.assertEqual(bot.step(), "harvest")
if __name__ == "__main__":
unittest.main(verbosity=2)
+990
View File
@@ -0,0 +1,990 @@
"""远程客户端测试:视图语义、地址归一化、SSE 消息分发、以及 HTTP 传输层。
这里是「TUI 不需要知道自己在跟谁说话」这句承诺的兑现处:
``StateView`` 必须把服务器发来的两种东西区分开——**完整快照**(带 plots/animals)
要整体替换,**精简状态**(动作回执里那几个标量)只能做字段合并。搞反了的后果
很具体:播一次种,整片农田就从界面上消失了。
除 ``HttpTransportTestCase`` 会起一个纯标准库的回环桩服务外,其余全部离线;
桩服务只绑 127.0.0.1,不依赖 fastapi / httpx / uvicorn。
uv run python -m unittest tests.test_client -v
"""
from __future__ import annotations
import http.server
import socket
import threading
import unittest
import urllib.parse
import urllib.request
from typing import ClassVar
from farm import config, net
from farm.client import (
DEFAULT_ENDPOINT,
LOG_KEEP,
AnimalView,
PlotView,
RemoteBotClient,
RemoteFarm,
StateView,
_underlying_socket,
)
from farm.engine import GameError
# --------------------------------------------------------------------- 构造假数据
def plot_dict(**overrides):
"""一份「服务器会发过来的」地块 JSON。"""
data = {
"index": 0,
"position": 1,
"crop": "carrot",
"crop_name": "胡萝卜",
"growth": 1.0,
"grow_days": 3,
"days_left": 2,
"moisture": 3,
"is_ripe": False,
"is_empty": False,
"stage": "生长中",
}
data.update(overrides)
return data
def animal_dict(**overrides):
data = {
"id": 7,
"kind": "cow",
"name": "奶牛",
"product": "milk",
"fed": True,
"pending": 0,
"cycle": 3,
"progress": 1,
"starve": 0,
"days_to_product": 2,
"is_sick": False,
}
data.update(overrides)
return data
def offline_client(**kwargs):
"""不连网络的 RemoteFarm(autostart=False 是关键)。"""
kwargs.setdefault("autostart", False)
kwargs.setdefault("realtime", False)
return RemoteFarm(**kwargs)
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class _StubHandler(http.server.BaseHTTPRequestHandler):
"""按路径吐预设响应的小桩,够本文件用。**
``payloads`` 由测试在 ``setUp`` 里填:path -> (status, body_bytes, content_type)。
"""
protocol_version = "HTTP/1.1"
payloads: ClassVar[dict[str, tuple[int, bytes, str]]] = {}
def do_GET(self) -> None:
self._reply()
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
if length:
self.rfile.read(length)
self._reply()
def _reply(self) -> None:
path = urllib.parse.urlsplit(self.path).path
status, body, content_type = self.payloads.get(
path, (404, b'{"ok": false, "error": "not_found"}', "application/json")
)
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args) -> None:
"""别把访问日志刷到测试输出里。"""
# ===================================================================== 视图字段映射
class PlotViewTestCase(unittest.TestCase):
def test_fields_follow_the_server_payload(self):
view = PlotView(plot_dict())
self.assertEqual(view.index, 0)
self.assertEqual(view.position, 1)
self.assertEqual(view.crop, "carrot")
self.assertEqual(view.crop_name, "胡萝卜")
self.assertEqual(view.growth, 1.0)
self.assertEqual(view.grow_days, 3)
self.assertEqual(view.days_left, 2)
self.assertEqual(view.moisture, 3)
self.assertFalse(view.is_ripe)
self.assertFalse(view.is_empty)
self.assertEqual(view.stage, "生长中")
def test_missing_fields_fall_back_to_defaults(self):
view = PlotView({})
self.assertEqual(view.index, 0)
# position 缺省时由 index 推出(地块编号是给人看的,从 1 起)
self.assertEqual(view.position, 1)
self.assertIsNone(view.crop)
self.assertEqual(view.crop_name, "")
self.assertEqual(view.growth, 0.0)
self.assertEqual(view.moisture, 0)
self.assertEqual(view.stage, "")
def test_position_defaults_to_index_plus_one(self):
self.assertEqual(PlotView({"index": 4}).position, 5)
def test_empty_defaults_to_crop_is_none(self):
self.assertTrue(PlotView({"crop": None}).is_empty)
self.assertFalse(PlotView({"crop": "carrot"}).is_empty)
def test_explicit_is_empty_wins_over_crop(self):
self.assertTrue(PlotView({"crop": "carrot", "is_empty": True}).is_empty)
def test_numeric_strings_are_coerced(self):
# JSON 里数字有可能是字符串,视图不该把它漏给渲染层
view = PlotView({"growth": "2.5", "moisture": "3", "is_ripe": 1})
self.assertEqual(view.growth, 2.5)
self.assertEqual(view.moisture, 3)
self.assertIs(view.is_ripe, True)
def test_to_dict_is_a_copy(self):
view = PlotView(plot_dict())
dumped = view.to_dict()
dumped["crop"] = "corn"
self.assertEqual(view.crop, "carrot")
def test_view_is_read_only(self):
view = PlotView(plot_dict())
with self.assertRaises(AttributeError):
view.index = 9 # type: ignore[misc]
class AnimalViewTestCase(unittest.TestCase):
def test_fields_follow_the_server_payload(self):
view = AnimalView(animal_dict())
self.assertEqual(view.aid, 7)
self.assertEqual(view.kind, "cow")
self.assertEqual(view.name, "奶牛")
self.assertEqual(view.product, "milk")
self.assertTrue(view.fed)
self.assertEqual(view.pending, 0)
self.assertEqual(view.cycle, 3)
self.assertEqual(view.progress, 1)
self.assertEqual(view.days_to_product, 2)
self.assertFalse(view.is_sick)
def test_id_maps_to_aid(self):
# 服务器用 "id",本地模型叫 .aid —— 同构靠的就是这一层改名
self.assertEqual(AnimalView({"id": 12}).aid, 12)
def test_missing_fields_fall_back_to_defaults(self):
view = AnimalView({})
self.assertEqual(view.aid, 0)
self.assertEqual(view.kind, "")
self.assertEqual(view.product, "")
self.assertFalse(view.fed)
self.assertEqual(view.cycle, 1) # 牲畜至少处在第 1 个周期
self.assertEqual(view.starve, 0)
self.assertFalse(view.is_sick)
def test_to_dict_is_a_copy(self):
view = AnimalView(animal_dict())
dumped = view.to_dict()
dumped["kind"] = "sheep"
self.assertEqual(view.kind, "cow")
# ===================================================================== 状态视图语义
class StateViewTestCase(unittest.TestCase):
def test_full_snapshot_replaces_everything(self):
view = StateView({"day": 1, "plots": [plot_dict()], "animals": [animal_dict()]})
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
view.apply({"day": 2, "plots": [], "animals": []})
self.assertEqual(view.day, 2)
self.assertEqual(view.plots, [])
self.assertEqual(view.animals, [])
def test_brief_state_merges_and_keeps_the_farm(self):
"""回归:动作回执里的精简 state 绝不能清空农田。"""
view = StateView(
{"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []}
)
view.apply({"gold": 60, "ap": 5})
self.assertEqual(view.gold, 60)
self.assertEqual(view.ap, 5)
self.assertEqual(view.day, 1, "没提到的字段要保留")
self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失")
def test_brief_state_after_a_full_snapshot_keeps_views_usable(self):
view = StateView({"plots": [plot_dict(crop="corn")], "animals": [animal_dict()]})
view.apply({"gold": 1})
self.assertEqual(view.plots[0].crop, "corn")
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
def test_empty_data_is_ignored(self):
view = StateView({"day": 3, "gold": 50})
view.apply({})
self.assertEqual(view.day, 3)
self.assertEqual(view.gold, 50)
def test_initial_construction_is_tolerant(self):
view = StateView()
self.assertEqual(view.plots, [])
self.assertEqual(view.animals, [])
self.assertEqual(view.day, 1)
self.assertEqual(view.gold, 0)
self.assertEqual(view.level, 1)
self.assertEqual(view.market, {})
def test_an_empty_view_looks_like_an_empty_farm(self):
"""没收到任何快照时也必须能安全渲染。
服务器连不上、或客户端是 ``autostart=False`` 起的,界面拿到的是空视图;
这时读 ``.plots`` 必须是"空农田",不能是 AttributeError。
"""
view = StateView()
self.assertTrue(hasattr(view, "plots"))
self.assertTrue(hasattr(view, "animals"))
self.assertEqual(len(view.plots), 0)
self.assertEqual(len(view.animals), 0)
self.assertEqual(view.to_dict(), {})
def test_offline_client_exposes_a_renderable_empty_state(self):
client = offline_client(endpoint="http://127.0.0.1:1/")
self.assertEqual(client.state.plots, [])
self.assertEqual(client.snapshot(), {})
def test_non_dict_entries_are_filtered_out(self):
view = StateView({"plots": [plot_dict(), "垃圾", 3], "animals": [None, animal_dict()]})
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
def test_scalar_accessors_read_through(self):
view = StateView(
{
"day": 5,
"gold": 320,
"level": 4,
"exp": 11,
"ap": 2,
"feed": 9,
"market": {"corn": 12},
}
)
self.assertEqual((view.day, view.gold, view.level), (5, 320, 4))
self.assertEqual((view.exp, view.ap, view.feed), (11, 2, 9))
self.assertEqual(view.market["corn"], 12)
def test_get_and_item_access(self):
view = StateView({"online": 3, "paused": False})
self.assertEqual(view.get("online"), 3)
self.assertEqual(view.get("nope", "兜底"), "兜底")
self.assertEqual(view["online"], 3)
with self.assertRaises(KeyError):
_ = view["nope"]
def test_to_dict_round_trips_the_raw_payload(self):
payload = {"day": 2, "gold": 5, "plots": [plot_dict()]}
self.assertEqual(StateView(payload).to_dict(), payload)
def test_to_dict_is_a_copy(self):
view = StateView({"gold": 5})
dumped = view.to_dict()
dumped["gold"] = 999
self.assertEqual(view.gold, 5)
# ===================================================================== 地址与 URL
class AddressTestCase(unittest.TestCase):
def test_normalize_adds_the_scheme(self):
self.assertEqual(RemoteFarm._normalize("127.0.0.1:8765"), "http://127.0.0.1:8765")
def test_normalize_maps_the_legacy_tcp_scheme(self):
self.assertEqual(RemoteFarm._normalize("tcp://127.0.0.1:8765"), "http://127.0.0.1:8765")
def test_normalize_strips_whitespace_and_trailing_slashes(self):
self.assertEqual(RemoteFarm._normalize(" http://host:9000/// "), "http://host:9000")
def test_normalize_keeps_https(self):
self.assertEqual(RemoteFarm._normalize("https://farm.example"), "https://farm.example")
def test_normalize_falls_back_when_empty(self):
self.assertEqual(RemoteFarm._normalize(""), DEFAULT_ENDPOINT)
self.assertEqual(RemoteFarm._normalize(" "), DEFAULT_ENDPOINT)
def test_normalize_falls_back_for_degenerate_endpoints(self):
"""畸形端点不能变成"没有主机名"的地址。
否则 host 是空串,``net.is_loopback`` 判成 False,请求会溜到系统代理上,
报回来一个和真实原因毫无关系的 502。回落到本机默认才是对的。
"""
for endpoint in ("", " ", "//", "http://", "https://", "tcp://"):
with self.subTest(endpoint=endpoint):
self.assertEqual(RemoteFarm._normalize(endpoint), DEFAULT_ENDPOINT)
def test_a_normalized_endpoint_always_has_a_host(self):
candidates = ("", " ", "http://", "127.0.0.1:1", "http://host:9000/", "tcp://h:1")
for endpoint in candidates:
with self.subTest(endpoint=endpoint):
normalized = RemoteFarm._normalize(endpoint)
self.assertTrue(urllib.parse.urlsplit(normalized).netloc, normalized)
def test_init_stores_a_normalized_endpoint(self):
client = offline_client(endpoint=" tcp://127.0.0.1:9999/ ")
self.assertEqual(client.endpoint, "http://127.0.0.1:9999")
self.assertEqual(client.host, "127.0.0.1:9999")
def test_farm_name_defaults_to_default(self):
self.assertEqual(offline_client(farm="").farm, "default")
self.assertEqual(offline_client(farm=None).farm, "default") # type: ignore[arg-type]
def test_host_falls_back_when_the_endpoint_has_none(self):
self.assertEqual(offline_client(endpoint="http://").host, "127.0.0.1:8765")
def test_url_always_carries_the_farm(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="阿黄")
parts = urllib.parse.urlsplit(client.url("/api/state"))
self.assertEqual(parts.path, "/api/state")
self.assertEqual(urllib.parse.parse_qs(parts.query), {"farm": ["阿黄"]})
def test_url_includes_the_token_when_present(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="s3cret")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
self.assertEqual(query["token"], ["s3cret"])
def test_url_omits_the_token_when_absent(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
self.assertNotIn("token", query)
def test_url_carries_extra_params(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
url = client.url("/api/log", limit=30)
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
self.assertEqual(query["limit"], ["30"])
def test_url_drops_params_that_are_none(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
url = client.url("/api/log", limit=None, since=0)
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
self.assertNotIn("limit", query)
self.assertEqual(query["since"], ["0"], "0 是有效值,不能当 None 丢掉")
def test_url_is_fully_qualified(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
self.assertTrue(client.url("/api/state").startswith("http://127.0.0.1:1/api/state?"))
def test_stream_url_carries_farm_and_name(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="阿黄")
parts = urllib.parse.urlsplit(client.stream_url)
self.assertEqual(parts.path, "/api/stream")
query = urllib.parse.parse_qs(parts.query)
self.assertEqual(query["farm"], ["a"])
# 令牌只能走查询参数:EventSource / urllib 都没法给长连接加自定义头
self.assertEqual(query["name"], ["阿黄"])
def test_stream_url_skips_the_empty_name(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
self.assertNotIn("name", query)
def test_stream_url_includes_the_token(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="t")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
self.assertEqual(query["token"], ["t"])
# ===================================================================== SSE 消息分发
class StreamMessageTestCase(unittest.TestCase):
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
def push(self, message):
import json
self.client._handle_stream_message(json.dumps(message, ensure_ascii=False))
def test_welcome_installs_state_catalog_and_server_info(self):
self.push(
{
"type": "welcome",
"farm": "b",
"online": 4,
"server": {"version": "2.3"},
"catalog": {"crops": [{"key": "carrot"}]},
"state": {"day": 6, "gold": 88, "plots": [plot_dict()], "animals": []},
"log": [],
}
)
self.assertEqual(self.client.farm, "b")
self.assertEqual(self.client.server_info, {"version": "2.3"})
self.assertEqual(self.client.catalog()["crops"], [{"key": "carrot"}])
self.assertEqual(self.client.state.day, 6)
self.assertEqual(self.client.state.gold, 88)
self.assertEqual(self.client.state.get("online"), 4, "在线人数要合进状态视图")
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(self.client.stats["pushes"], 1)
def test_state_and_sync_share_the_welcome_branch(self):
for kind in ("state", "sync"):
with self.subTest(kind=kind):
self.push({"type": kind, "state": {"gold": 7}, "plots": None})
self.assertEqual(self.client.state.gold, 7)
self.assertEqual(self.client.stats["pushes"], 2)
def test_brief_state_push_does_not_wipe_the_farm(self):
self.push({"type": "state", "state": {"plots": [plot_dict()], "animals": []}})
self.push({"type": "state", "state": {"gold": 3}})
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(self.client.state.gold, 3)
def test_push_without_state_still_counts_and_keeps_old_data(self):
self.push({"type": "state", "state": {"day": 9}})
self.push({"type": "state"})
self.assertEqual(self.client.state.day, 9)
self.assertEqual(self.client.stats["pushes"], 2)
def test_push_ignores_a_bad_online_value(self):
self.push({"type": "state", "online": "很多", "state": {"day": 1}})
self.assertIsNone(self.client.state.get("online"))
def test_online_is_dropped_when_the_state_is_empty(self):
self.push({"type": "state", "online": 3})
self.assertIsNone(self.client.state.get("online"))
def test_push_merges_the_log(self):
self.push(
{
"type": "state",
"state": {"day": 1},
"log": [{"seq": 2, "text": "b"}, {"seq": 1, "text": "a"}],
}
)
entries = self.client.recent_log()
self.assertEqual([e["seq"] for e in entries], [1, 2], "日志要按 seq 排序")
self.assertEqual([e["text"] for e in entries], ["a", "b"])
def test_day_message_appends_a_header_and_its_events(self):
self.push({"type": "day", "day": 4, "events": ["奶牛生病了", "一块地旱了"]})
entries = self.client.recent_log()
self.assertEqual(len(entries), 3)
self.assertEqual(entries[0]["text"], "第 4 天开始")
self.assertEqual(entries[0]["kind"], "info")
self.assertEqual([e["text"] for e in entries[1:]], ["奶牛生病了", "一块地旱了"])
self.assertTrue(all(e["kind"] == "warn" for e in entries[1:]))
self.assertTrue(all(e["day"] == 4 for e in entries))
def test_day_message_continues_the_sequence(self):
self.push({"type": "state", "state": {"day": 1}, "log": [{"seq": 5, "text": "旧"}]})
self.push({"type": "day", "day": 2, "events": ["一"]})
self.assertEqual([e["seq"] for e in self.client.recent_log()], [5, 6, 7])
def test_day_message_survives_missing_events(self):
self.push({"type": "day", "day": 2})
entries = self.client.recent_log()
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["text"], "第 2 天开始")
def test_error_message_is_recorded(self):
self.push({"type": "error", "message": "牧场不存在"})
self.assertEqual(self.client.last_error, "牧场不存在")
self.assertEqual(self.client.stats["errors"], 1)
def test_bye_disconnects_with_a_reason(self):
self.client.connected = True
self.push({"type": "bye", "message": "服务器正在停机"})
self.assertFalse(self.client.connected)
self.assertEqual(self.client.last_error, "服务器正在停机")
def test_bye_without_a_reason_uses_a_default(self):
self.client.connected = True
self.push({"type": "bye"})
self.assertFalse(self.client.connected)
self.assertEqual(self.client.last_error, "服务器已停机")
def test_malformed_payloads_are_ignored(self):
for payload in ("不是 JSON", "[1, 2, 3]", '"字符串"', "3", ""):
with self.subTest(payload=payload):
self.client._handle_stream_message(payload)
self.assertEqual(self.client.stats["pushes"], 0)
self.assertEqual(self.client.stats["errors"], 0)
self.assertEqual(self.client.last_error, "")
def test_unknown_message_types_are_ignored(self):
self.push({"type": "谁知道呢", "state": {"gold": 7}})
self.assertEqual(self.client.state.gold, 0)
self.assertEqual(self.client.stats["pushes"], 0)
# ===================================================================== 日志与回调
class LogAndCallbackTestCase(unittest.TestCase):
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/")
def test_merge_log_deduplicates_by_seq(self):
self.client._merge_log([{"seq": 1, "text": "a"}])
self.client._merge_log([{"seq": 1, "text": "a"}, {"seq": 2, "text": "b"}])
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1, 2])
def test_merge_log_skips_entries_without_a_sequence(self):
self.client._merge_log([{"text": "无号"}, {"seq": 0, "text": "零号"}, {"seq": 1}])
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
def test_merge_log_keeps_only_the_most_recent_entries(self):
self.client._merge_log([{"seq": n} for n in range(1, LOG_KEEP + 51)])
entries = self.client.recent_log(limit=0)
self.assertEqual(len(entries), LOG_KEEP)
self.assertEqual(entries[-1]["seq"], LOG_KEEP + 50)
def test_recent_log_filters_by_since(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual([e["seq"] for e in self.client.recent_log(since=3)], [4, 5])
def test_recent_log_limit_takes_the_tail(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual([e["seq"] for e in self.client.recent_log(limit=2)], [4, 5])
def test_recent_log_limit_zero_returns_everything(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual(len(self.client.recent_log(limit=0)), 5)
def test_apply_fires_the_change_callback(self):
hits = []
self.client.on_change = lambda: hits.append(1)
self.client._apply({"gold": 1})
self.client._apply({"gold": 2})
self.assertEqual(len(hits), 2)
def test_apply_does_not_fire_for_empty_state(self):
hits = []
self.client.on_change = lambda: hits.append(1)
self.client._apply({})
self.assertEqual(hits, [])
def test_a_broken_callback_cannot_break_data_sync(self):
def boom():
raise RuntimeError("界面炸了")
self.client.on_change = boom
self.client._apply({"gold": 42})
self.assertEqual(self.client.state.gold, 42)
def test_transport_label_reports_the_active_channel(self):
self.client.mode = "sse"
self.assertEqual(self.client.transport_label(), "SSE 实时")
self.client.mode = "poll"
self.assertEqual(self.client.transport_label(), "HTTP 轮询")
self.client.mode = "http"
self.assertEqual(self.client.transport_label(), "HTTP")
def test_catalog_starts_with_local_config_defaults(self):
catalog = self.client.catalog()
self.assertEqual(catalog["feed_price"], config.FEED_UNIT_PRICE)
self.assertEqual(catalog["water_capacity"], config.WATER_CAPACITY)
self.assertEqual(catalog["crops"], [])
# ===================================================================== 动作转发
class ActionForwardingTestCase(unittest.TestCase):
"""写操作统一走 POST /api/actions —— 用假的 _http 把请求原样抓下来核对。"""
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/")
self.requests: list[tuple[str, str, dict, dict]] = []
self.reply: dict = {}
self.client._http = self._fake_http # type: ignore[method-assign]
def _fake_http(self, method, path, payload=None, **params):
self.requests.append((method, path, payload or {}, params))
return self.reply
def last(self):
return self.requests[-1]
def test_plant_posts_the_right_payload(self):
self.client.plant(3, "carrot")
method, path, payload, params = self.last()
self.assertEqual(method, "POST")
self.assertEqual(path, "/api/actions")
self.assertEqual(payload, {"action": "plant", "params": {"plot": 3, "crop": "carrot"}})
self.assertEqual(params, {"full": 1}, "顺手要一份完整快照,省掉操作后再查一次")
def test_simple_actions_carry_their_arguments(self):
cases = [
(lambda: self.client.water(2), "water", {"plot": 2}),
(lambda: self.client.harvest(1), "harvest", {"plot": 1}),
(lambda: self.client.harvest_all(), "harvest_all", {}),
(lambda: self.client.buy_animal("cow"), "buy_animal", {"kind": "cow"}),
(lambda: self.client.collect(7), "collect", {"animal_id": 7}),
(lambda: self.client.buy_plot(), "buy_plot", {}),
]
for call, action, params in cases:
with self.subTest(action=action):
call()
self.assertEqual(self.last()[2], {"action": action, "params": params})
def test_feed_omits_quantity_when_not_given(self):
self.client.feed(7)
self.assertEqual(self.last()[2]["params"], {"animal_id": 7})
def test_feed_forwards_quantity(self):
self.client.feed(7, 3)
self.assertEqual(self.last()[2]["params"], {"animal_id": 7, "quantity": 3})
def test_buy_feed_defaults_to_bulk(self):
self.client.buy_feed()
self.assertEqual(self.last()[2]["params"], {"quantity": config.FEED_BULK})
def test_pause_without_argument_is_a_toggle(self):
self.client.pause()
self.assertEqual(self.last()[2], {"action": "pause", "params": {}})
def test_pause_forwards_an_explicit_value(self):
self.client.pause(True)
self.assertEqual(self.last()[2]["params"], {"paused": True})
def test_pause_forwards_an_explicit_false(self):
# False 与"没传"必须区分开,否则永远只能切不能停
self.client.pause(False)
self.assertEqual(self.last()[2]["params"], {"paused": False})
def test_save_forwards_to_the_server(self):
self.client.save()
self.assertEqual(self.last()[2], {"action": "save", "params": {}})
def test_batch_posts_a_single_request(self):
items = [{"action": "water", "params": {"plot": 1}}, {"action": "harvest", "params": {}}]
self.client.batch(items)
method, path, payload, params = self.last()
self.assertEqual(method, "POST")
self.assertEqual(path, "/api/batch")
self.assertEqual(payload, {"actions": items})
self.assertEqual(params, {"full": 1})
self.assertEqual(len(self.requests), 1, "批量就该是一个请求")
def test_action_applies_the_returned_snapshot(self):
self.reply = {"snapshot": {"plots": [plot_dict()], "gold": 10}, "log": [{"seq": 1}]}
self.client.water(1)
self.assertEqual(self.client.state.gold, 10)
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(len(self.client.recent_log()), 1)
def test_action_falls_back_to_the_brief_state(self):
self.reply = {"state": {"gold": 5}}
self.client.water(1)
self.assertEqual(self.client.state.gold, 5)
def test_snapshot_wins_over_brief_state(self):
self.reply = {"snapshot": {"gold": 10}, "state": {"gold": 5}}
self.client.water(1)
self.assertEqual(self.client.state.gold, 10)
def test_reset_clears_the_log_and_refreshes(self):
self.client._merge_log([{"seq": 1, "text": "旧"}])
refreshed = []
original = self.client.refresh
self.client.refresh = lambda *a, **k: refreshed.append(1) or True # type: ignore[method-assign]
self.client.reset()
self.client.refresh = original # type: ignore[method-assign]
self.assertEqual(self.client.recent_log(), [])
self.assertEqual(self.last()[2], {"action": "reset", "params": {}})
self.assertEqual(refreshed, [1], "重置后要重新拉全量")
# ===================================================================== 实时通道
class RealtimeTestCase(unittest.TestCase):
def test_start_is_a_noop_when_realtime_is_off(self):
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=False)
client.start()
self.assertIsNone(client._thread)
def test_start_spawns_only_one_thread(self):
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=True)
client._realtime_loop = lambda: None # type: ignore[method-assign]
try:
client.start()
first = client._thread
client.start()
self.assertIs(client._thread, first, "重复 start 不该开第二条连接")
finally:
client.stop()
def test_stop_is_safe_without_a_connection(self):
client = offline_client(endpoint="http://127.0.0.1:1/")
client.stop() # 不该抛
self.assertTrue(client._stop.is_set())
def test_underlying_socket_walks_the_attribute_chain(self):
class _Raw:
_sock = "套接字"
class _Fp:
raw = _Raw()
class _Response:
fp = _Fp()
self.assertEqual(_underlying_socket(_Response()), "套接字")
def test_underlying_socket_degrades_to_none(self):
"""掏不到就返回 None,调用方会退化成 close() —— 允许变慢,不允许崩。"""
for fake in (object(), type("R", (), {"fp": None})(), type("R", (), {"fp": object()})()):
with self.subTest(fake=type(fake).__name__):
self.assertIsNone(_underlying_socket(fake))
def test_stop_shuts_down_the_stream_socket(self):
events = []
class _Sock:
def shutdown(self, how):
events.append(("shutdown", how))
class _Resp:
def close(self):
events.append(("close", None))
client = offline_client(endpoint="http://127.0.0.1:1/")
client._stream_socket = _Sock()
client._stream_response = _Resp()
client.stop()
self.assertIn(("shutdown", socket.SHUT_RDWR), events)
self.assertIn(("close", None), events)
# shutdown 必须在 close 之前:close 会等读线程的锁,最长拖到下一次心跳
self.assertLess(events.index(("shutdown", socket.SHUT_RDWR)), events.index(("close", None)))
def test_stop_tolerates_broken_stream_objects(self):
class _Sock:
def shutdown(self, how):
raise OSError("已经断了")
class _Resp:
def close(self):
raise RuntimeError("也炸了")
client = offline_client(endpoint="http://127.0.0.1:1/")
client._stream_socket = _Sock()
client._stream_response = _Resp()
client.stop() # 停机路径必须稳
class BotClientTestCase(unittest.TestCase):
def test_wait_online_returns_as_soon_as_refresh_works(self):
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
client.refresh = lambda *a, **k: True # type: ignore[method-assign]
self.assertTrue(client.wait_online(seconds=5))
def test_wait_online_gives_up_after_the_deadline(self):
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
calls = []
client.refresh = lambda *a, **k: calls.append(1) or False # type: ignore[method-assign]
self.assertFalse(client.wait_online(seconds=0))
self.assertEqual(calls, [], "时限已过就不该再去连")
# ===================================================================== HTTP 传输层
class HttpTransportTestCase(unittest.TestCase):
"""用纯标准库的回环桩服务验证 _http 的成功与各类失败分支。
这不是"外部服务器":桩只绑 127.0.0.1,起不来就整类跳过。
顺带验证了 net.open_url 的回环直连——本机设了 HTTP_PROXY,
走代理的话这里会拿到 502,而不是桩真正返回的内容。
"""
server: http.server.ThreadingHTTPServer
@classmethod
def setUpClass(cls):
payload = b'{"ok": true, "state": {"day": 3, "gold": 42}, "online": 2}'
_StubHandler.payloads = {
"/api/state": (200, payload, "application/json"),
"/api/catalog": (
200,
b'{"ok": true, "catalog": {"feed_price": 6}}',
"application/json",
),
"/api/log": (
200,
b'{"ok": true, "entries": [{"seq": 1, "text": "a"}]}',
"application/json",
),
"/api/bad": (200, "<html>不是 JSON</html>".encode(), "text/html"),
"/api/list": (200, b"[1, 2, 3]", "application/json"),
"/api/empty": (204, b"", "application/json"),
"/api/rejected": (
400,
'{"ok": false, "error": "locked", "message": "这块地还没解锁"}'.encode(),
"application/json",
),
"/api/explode": (500, "内部错误,没有 JSON".encode(), "text/plain"),
}
try:
cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler)
except OSError as exc: # pragma: no cover - 环境不允许绑端口
raise unittest.SkipTest(f"起不了回环桩服务:{exc}") from exc
cls.port = int(cls.server.server_address[1])
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=5)
def setUp(self):
self.client = offline_client(endpoint=f"127.0.0.1:{self.port}", farm="测试场")
def test_get_returns_the_parsed_body(self):
data = self.client._http("GET", "/api/state")
self.assertEqual(data["state"]["gold"], 42)
self.assertTrue(self.client.connected)
self.assertEqual(self.client.last_error, "")
self.assertEqual(self.client.stats["http"], 1)
def test_loopback_requests_do_not_go_through_the_proxy(self):
# 桩返回的是我们自己的 JSON;被系统代理截走的话这里会变成 502 页面
self.assertEqual(self.client._http("GET", "/api/catalog")["catalog"]["feed_price"], 6)
def test_open_url_accepts_a_plain_string(self):
url = f"http://127.0.0.1:{self.port}/api/state"
with net.open_url(url, timeout=5) as response:
self.assertEqual(response.status, 200)
self.assertIn("gold", response.read().decode("utf-8"))
def test_refresh_pulls_state_catalog_and_log(self):
self.assertTrue(self.client.refresh())
self.assertEqual(self.client.state.day, 3)
self.assertEqual(self.client.state.gold, 42)
self.assertEqual(self.client.state.get("online"), 2)
self.assertEqual(self.client.catalog()["feed_price"], 6)
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
self.assertEqual(self.client.stats["http"], 3)
def test_refresh_swallows_errors_by_default(self):
client = offline_client(endpoint="127.0.0.1:1", farm="测试场") # 没人监听
self.assertFalse(client.refresh())
self.assertFalse(client.connected)
self.assertTrue(client.last_error)
def test_refresh_can_raise_on_error(self):
client = offline_client(endpoint="127.0.0.1:1", farm="测试场")
with self.assertRaises(GameError) as ctx:
client.refresh(raise_on_error=True)
self.assertEqual(ctx.exception.code, "network")
def test_empty_body_is_treated_as_an_empty_object(self):
self.assertEqual(self.client._http("GET", "/api/empty"), {})
def test_non_json_body_is_reported_as_a_bad_response(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/bad")
self.assertEqual(ctx.exception.code, "bad_response")
self.assertEqual(ctx.exception.status, 502)
def test_json_that_is_not_an_object_is_rejected(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/list")
self.assertEqual(ctx.exception.code, "bad_response")
self.assertIn("不是对象", ctx.exception.message)
def test_server_rejection_becomes_a_game_error(self):
with self.assertRaises(GameError) as ctx:
self.client._http("POST", "/api/rejected", {"action": "plant"})
self.assertEqual(ctx.exception.code, "locked")
self.assertEqual(ctx.exception.message, "这块地还没解锁")
self.assertEqual(ctx.exception.status, 400)
self.assertEqual(self.client.stats["errors"], 1)
def test_error_responses_that_are_not_json_say_so(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/explode")
self.assertEqual(ctx.exception.code, "bad_response")
def test_a_dead_server_becomes_a_network_error_with_a_hint(self):
dead = offline_client(endpoint=f"127.0.0.1:{free_port()}", farm="测试场")
with self.assertRaises(GameError) as ctx:
dead._http("GET", "/api/state")
self.assertEqual(ctx.exception.code, "network")
self.assertEqual(ctx.exception.status, 503)
self.assertIn("main.py serve", ctx.exception.message, "错误信息要告诉用户怎么补救")
self.assertFalse(dead.connected)
self.assertEqual(dead.stats["errors"], 1)
def test_action_response_is_applied_end_to_end(self):
_StubHandler.payloads["/api/actions"] = (
200,
b'{"ok": true, "snapshot": {"gold": 7, "plots": []}, "log": [{"seq": 9}]}',
"application/json",
)
try:
self.client._action("harvest_all")
finally:
_StubHandler.payloads.pop("/api/actions", None)
self.assertEqual(self.client.state.gold, 7)
self.assertEqual([e["seq"] for e in self.client.recent_log()], [9])
# ===================================================================== 代理绕行
class NetTestCase(unittest.TestCase):
def test_loopback_hosts_are_recognized(self):
for host in ("127.0.0.1", "127.0.0.5", "localhost", "LocalHost", "::1", "[::1]", ""):
with self.subTest(host=host):
self.assertTrue(net.is_loopback(host))
def test_loopback_check_tolerates_surrounding_space(self):
self.assertTrue(net.is_loopback(" 127.0.0.1 "))
def test_remote_hosts_are_not_loopback(self):
for host in ("farm.example.com", "10.0.0.1", "192.168.1.9", "128.0.0.1", None):
with self.subTest(host=host):
self.assertFalse(net.is_loopback(host))
def test_host_of_extracts_from_a_string_or_a_request(self):
self.assertEqual(net.host_of("http://127.0.0.1:8765/api/state"), "127.0.0.1")
request = urllib.request.Request("http://example.com:9000/x")
self.assertEqual(net.host_of(request), "example.com")
def test_host_of_returns_none_when_unparseable(self):
self.assertIsNone(net.host_of("没有协议也没有主机"))
def test_endpoint_host_accepts_a_bare_host_port(self):
self.assertEqual(net.endpoint_host("127.0.0.1:8765"), "127.0.0.1")
def test_endpoint_host_falls_back_to_the_default(self):
self.assertEqual(net.endpoint_host("http://"), "127.0.0.1")
self.assertEqual(net.endpoint_host("", default="10.0.0.1"), "10.0.0.1")
def test_the_loopback_opener_registers_no_proxy_handler_at_all(self):
"""回环直连的机制:这个 opener 里压根没有代理处理器。
``ProxyHandler({})`` 不会注册任何 ``*_open`` 钩子,而 ``add_handler``
只在"至少挂上一个钩子"时才把 handler 记进 ``handlers``,所以它自己
反而没被收进去——结果是 http 请求直接进 ``HTTPHandler``,必然直连。
"""
handlers = net._no_proxy_opener.handlers
self.assertFalse([h for h in handlers if isinstance(h, urllib.request.ProxyHandler)])
if __name__ == "__main__": # pragma: no cover
unittest.main()
+887 -433
View File
File diff suppressed because it is too large. Load diff
+1130 -483
View File
File diff suppressed because it is too large. Load diff
+655
View File
@@ -0,0 +1,655 @@
"""键盘与 TUI 测试:这里曾经漏掉一个只有真按下去才会发现的 bug。
历史事故
--------
``keys.py`` 在 Windows 上返回**大写** ``"UP"``,而 ``tui.py`` 的处理表里写的是
**小写** ``"up"``,于是方向键、空格、Tab、ESC 全部**静默失效**——字母键因为
``char.lower()`` 恰好对得上而正常,所以肉眼很难察觉。
因此这里的测试分两层:
* 约定层:两套平台映射表必须落在**同一批小写键名**里;
* 行为层:逐个具名键驱动**真实的** :class:`farm.tui.TuiApp`,确认真的有人处理。
uv run python -m unittest tests.test_tui_keys -v
"""
from __future__ import annotations
import contextlib
import io
import json
import os
import sys
import tempfile
import time
import unittest
from unittest import mock
from farm import config, keys
from farm.engine import FarmEngine
from farm.keys import KeyReader
from farm.tui import (
ANSI_RE,
TuiApp,
bar,
display_width,
draw_box,
hstack,
pad,
strip_ansi,
truncate,
)
# 主模式下"按下去必须有反应"的具名键。ENTER / HOME / END / DELETE 等在主界面
# 没有绑定,静默忽略是设计如此,所以不在此列(ESC 同理:它只在菜单里取消)。
NAMED_KEYS_WITH_MAIN_BINDINGS = (
keys.UP,
keys.DOWN,
keys.LEFT,
keys.RIGHT,
keys.SPACE,
keys.TAB,
keys.CTRL_C,
)
def fake_reader(data: bytes) -> KeyReader:
"""造一个只带解析能力的 KeyReader:绕过 tty 初始化,直接喂字节。"""
reader = KeyReader.__new__(KeyReader)
reader._fd = None
reader._saved_term = None
reader._raw = data
reader.reason = ""
reader.available = True
return reader
def make_app(**kwargs) -> TuiApp:
"""真实引擎 + 真实 TuiApp,只是不写盘、不上色,方便断言。"""
engine = FarmEngine()
engine.state.gold = 3000
directory = tempfile.mkdtemp(prefix="simplefarm-tui-")
options = {
"save_path": os.path.join(directory, "save.json"),
"use_color": False,
"autosave": False,
}
options.update(kwargs)
return TuiApp(engine, **options)
class KeyNameConventionTestCase(unittest.TestCase):
"""约定层:全小写、集中定义、两端一致。"""
def test_every_named_key_constant_is_lowercase(self):
for name in keys.NAMED_KEYS:
with self.subTest(key=name):
self.assertIsInstance(name, str)
self.assertEqual(name, name.lower(), f"{name!r} 必须全小写")
self.assertNotEqual(name, "", "空字符串不能当键名")
def test_named_keys_is_exactly_the_set_of_constants(self):
constants = {
getattr(keys, attr)
for attr in dir(keys)
if attr.isupper() and isinstance(getattr(keys, attr), str)
}
self.assertEqual(set(keys.NAMED_KEYS), constants)
def test_named_keys_do_not_collide_with_typeable_characters(self):
"""具名键不能是单个字符,否则会和普通按键混在一起分不清。"""
for name in keys.NAMED_KEYS:
with self.subTest(key=name):
self.assertGreater(len(name), 1)
def test_constant_values_are_the_lowercase_form_of_their_names(self):
self.assertEqual(keys.UP, "up")
self.assertEqual(keys.DOWN, "down")
self.assertEqual(keys.LEFT, "left")
self.assertEqual(keys.RIGHT, "right")
self.assertEqual(keys.SPACE, "space")
self.assertEqual(keys.TAB, "tab")
self.assertEqual(keys.ESC, "esc")
self.assertEqual(keys.ENTER, "enter")
self.assertEqual(keys.CTRL_C, "ctrl_c")
self.assertEqual(keys.BACKSPACE, "backspace")
self.assertEqual(keys.DELETE, "delete")
self.assertEqual(keys.HOME, "home")
self.assertEqual(keys.END, "end")
def test_platform_tables_only_produce_known_names(self):
tables = {
"windows 扩展键": keys._WIN_EXTENDED,
"windows 控制键": keys._WIN_CTRL,
"posix 控制键": keys._POSIX_CTRL,
"posix 转义序列": keys._POSIX_CSI,
}
for label, table in tables.items():
for raw, name in table.items():
with self.subTest(table=label, raw=raw):
self.assertIn(name, keys.NAMED_KEYS, f"{label} 产出了未登记的键名 {name!r}")
self.assertEqual(name, name.lower())
def test_both_platforms_produce_the_same_function_keys(self):
"""这条断言就是那个 bug 的护栏:两端必须产出同一批方向键/功能键。"""
windows = set(keys._WIN_EXTENDED.values())
posix = set(keys._POSIX_CSI.values())
self.assertEqual(windows, posix)
self.assertEqual(
windows, {keys.UP, keys.DOWN, keys.LEFT, keys.RIGHT, keys.HOME, keys.END, keys.DELETE}
)
def test_both_platforms_produce_the_same_control_keys(self):
windows = set(keys._WIN_CTRL.values())
posix = set(keys._POSIX_CTRL.values())
self.assertEqual(posix, windows - {keys.ESC}, "POSIX 的 ESC 走单独的转义序列分支")
class KeyReaderAvailabilityTestCase(unittest.TestCase):
def test_non_tty_stdin_degrades_with_a_reason(self):
with mock.patch.object(sys, "stdin", None):
reader = KeyReader()
self.assertFalse(reader.available)
self.assertIn("标准输入", reader.reason)
def test_piped_stdin_degrades_with_a_reason(self):
class NotATty:
def isatty(self) -> bool:
return False
with mock.patch.object(sys, "stdin", NotATty()):
reader = KeyReader()
self.assertFalse(reader.available)
self.assertNotEqual(reader.reason, "")
def test_unavailable_reader_returns_nothing(self):
reader = KeyReader.__new__(KeyReader)
reader._fd = None
reader._saved_term = None
reader._raw = b""
reader.reason = "测试"
reader.available = False
self.assertIsNone(reader.poll())
def test_close_is_safe_without_a_terminal(self):
reader = KeyReader.__new__(KeyReader)
reader._fd = None
reader._saved_term = None
reader._raw = b"leftover"
reader.reason = ""
reader.available = False
reader.close()
self.assertEqual(reader._raw, b"")
class PosixByteParsingTestCase(unittest.TestCase):
"""POSIX 侧把原始字节流翻译成按键名——纯函数,不碰真终端。"""
def parse(self, data: bytes) -> str | None:
return fake_reader(data)._parse_posix()
def drain(self, data: bytes, times: int = 8) -> list[str]:
reader = fake_reader(data)
out: list[str] = []
for _ in range(times):
key = reader._parse_posix()
if key is None:
break
out.append(key)
return out
def test_plain_characters_are_lowercased(self):
self.assertEqual(self.parse(b"a"), "a")
self.assertEqual(self.parse(b"A"), "a")
self.assertEqual(self.parse(b"7"), "7")
def test_control_characters(self):
self.assertEqual(self.parse(b"\x03"), keys.CTRL_C)
self.assertEqual(self.parse(b"\r"), keys.ENTER)
self.assertEqual(self.parse(b"\n"), keys.ENTER)
self.assertEqual(self.parse(b"\t"), keys.TAB)
self.assertEqual(self.parse(b"\x7f"), keys.BACKSPACE)
self.assertEqual(self.parse(b" "), keys.SPACE)
def test_arrow_keys(self):
self.assertEqual(self.parse(b"\x1b[A"), keys.UP)
self.assertEqual(self.parse(b"\x1b[B"), keys.DOWN)
self.assertEqual(self.parse(b"\x1b[C"), keys.RIGHT)
self.assertEqual(self.parse(b"\x1b[D"), keys.LEFT)
def test_home_end_and_delete(self):
self.assertEqual(self.parse(b"\x1b[H"), keys.HOME)
self.assertEqual(self.parse(b"\x1b[F"), keys.END)
self.assertEqual(self.parse(b"\x1b[1~"), keys.HOME)
self.assertEqual(self.parse(b"\x1b[3~"), keys.DELETE)
self.assertEqual(self.parse(b"\x1b[4~"), keys.END)
def test_application_mode_arrows(self):
"""有些终端把方向键发成 ESC O A 而不是 ESC [ A。"""
self.assertEqual(self.parse(b"\x1bOA"), keys.UP)
self.assertEqual(self.parse(b"\x1bOD"), keys.LEFT)
def test_lone_escape_is_the_escape_key(self):
self.assertEqual(self.parse(b"\x1b"), keys.ESC)
def test_escape_followed_by_a_plain_character(self):
reader = fake_reader(b"\x1bX")
self.assertEqual(reader._parse_posix(), keys.ESC)
self.assertEqual(reader._raw, b"X", "多出来的字符要留给下一轮,不能吞掉")
def test_unknown_sequence_is_dropped_not_guessed(self):
"""认不出来的功能键宁可丢掉,也不能退化成一个错误的动作。"""
self.assertIsNone(self.parse(b"\x1b[99~"))
def test_utf8_character_is_decoded_as_a_whole(self):
self.assertEqual(self.parse("中".encode()), "中")
def test_half_a_character_waits_for_the_rest(self):
reader = fake_reader("中".encode()[:2])
self.assertIsNone(reader._parse_posix())
self.assertEqual(len(reader._raw), 2, "半个字符要留在缓冲区里等下一轮")
reader._raw += "中".encode()[2:]
self.assertEqual(reader._parse_posix(), "中")
def test_several_keys_arriving_at_once_are_returned_one_by_one(self):
"""老实现只取第一个字节、把其余静默丢掉,快打或粘贴就会丢键。"""
self.assertEqual(self.drain(b"abc"), ["a", "b", "c"])
def test_mixed_burst_keeps_order(self):
self.assertEqual(self.drain(b"a\x1b[B"), ["a", keys.DOWN])
def test_backspace_then_letter(self):
self.assertEqual(self.drain(b"\x7fp"), [keys.BACKSPACE, "p"])
def test_incomplete_sequence_falls_back_to_escape(self):
"""序列读不全又不能干等:当作单按 ESC,总比把按键吞掉好。"""
reader = fake_reader(b"\x1b[")
self.assertEqual(reader._parse_posix(), keys.ESC)
reader._raw = b"\x1b[A"
self.assertEqual(reader._parse_posix(), keys.UP)
class TextLayoutTestCase(unittest.TestCase):
"""渲染用的纯函数:宽度算错的话,边框在中文环境下就会歪。"""
def test_ansi_codes_do_not_count_as_width(self):
self.assertEqual(display_width("\x1b[1mhi\x1b[0m"), 2)
def test_cjk_characters_are_double_width(self):
self.assertEqual(display_width("中文"), 4)
self.assertEqual(display_width("a中"), 3)
def test_strip_ansi_removes_color_codes(self):
self.assertEqual(strip_ansi("\x1b[31m红\x1b[0m"), "红")
self.assertIsNone(ANSI_RE.search(strip_ansi("\x1b[31m红\x1b[0m")))
def test_pad_aligns_by_display_width(self):
for align in ("left", "right", "center"):
with self.subTest(align=align):
self.assertEqual(display_width(pad("中文", 10, align)), 10)
def test_pad_truncates_overlong_content(self):
self.assertEqual(display_width(pad("非常长的一段中文内容", 6)), 6)
def test_truncate_adds_an_ellipsis(self):
text = truncate("非常长的一段中文内容", 7)
self.assertLessEqual(display_width(text), 7)
self.assertTrue(text.endswith("…"))
def test_truncate_keeps_short_text_intact(self):
self.assertEqual(truncate("短的", 10), "短的")
def test_bar_renders_filled_and_empty_cells(self):
self.assertEqual(bar(0, 10, 10), "░" * 10)
self.assertEqual(bar(10, 10, 10), "█" * 10)
self.assertEqual(len(bar(5, 10, 10)), 10)
self.assertEqual(len(bar(1, 0, 4)), 4, "分母为 0 也不能崩")
def test_box_lines_are_exactly_the_requested_width(self):
lines = draw_box("标题", ["内容", "另一行"], 30, use_color=False)
for line in lines:
with self.subTest(line=line):
self.assertEqual(display_width(line), 30)
def test_hstack_pads_the_shorter_column(self):
merged = hstack(["a", "b"], ["1"])
self.assertEqual(len(merged), 2)
self.assertTrue(merged[1].startswith("b"))
class TuiNamedKeyBehaviourTestCase(unittest.TestCase):
"""行为层:真的把键交给 TuiApp,看它有没有动。"""
def setUp(self):
self.app = make_app()
def test_arrow_keys_move_the_plot_cursor(self):
self.app.grid_cols = 3
self.assertEqual(self.app.plot_cursor, 0)
self.app._handle_key(keys.RIGHT)
self.assertEqual(self.app.plot_cursor, 1)
self.app._handle_key(keys.DOWN)
self.assertEqual(self.app.plot_cursor, 4)
self.app._handle_key(keys.UP)
self.assertEqual(self.app.plot_cursor, 1)
self.app._handle_key(keys.LEFT)
self.assertEqual(self.app.plot_cursor, 0)
def test_cursor_cannot_leave_the_grid(self):
self.app.grid_cols = 3
self.app._handle_key(keys.LEFT)
self.assertEqual(self.app.plot_cursor, 0)
self.app._handle_key(keys.UP)
self.assertEqual(self.app.plot_cursor, 0)
def test_space_toggles_time(self):
self.assertFalse(self.app.engine.paused)
self.app._handle_key(keys.SPACE)
self.assertTrue(self.app.engine.paused)
self.app._handle_key(keys.SPACE)
self.assertFalse(self.app.engine.paused)
def test_tab_cycles_through_animals(self):
self.app._handle_key(keys.TAB)
self.assertIn("还没有动物", self.app.message)
for _ in range(2):
self.app.engine.buy_animal("chicken")
self.app._handle_key(keys.TAB)
self.assertEqual(self.app.animal_cursor, 1)
self.app._handle_key(keys.TAB)
self.assertEqual(self.app.animal_cursor, 0, "应当循环回第一只")
def test_ctrl_c_quits(self):
self.assertTrue(self.app.running)
self.app._handle_key(keys.CTRL_C)
self.assertFalse(self.app.running)
def test_every_bound_named_key_does_something(self):
"""这是那个 bug 的正面护栏:逐个具名键驱动真实 TuiApp。"""
for key in NAMED_KEYS_WITH_MAIN_BINDINGS:
with self.subTest(key=key):
app = make_app()
app.engine.buy_animal("chicken")
app.engine.buy_animal("chicken")
app.grid_cols = 3
app.plot_cursor = 4 # 摆在中间,上下左右才都动得了
before = (
app.running,
app.plot_cursor,
app.animal_cursor,
app.mode,
app.engine.paused,
)
app._handle_key(key)
after = (
app.running,
app.plot_cursor,
app.animal_cursor,
app.mode,
app.engine.paused,
)
self.assertNotEqual(before, after, f"按键 {key!r} 没有任何反应")
def test_unbound_named_keys_are_ignored_without_crashing(self):
for key in (keys.ENTER, keys.HOME, keys.END, keys.DELETE, keys.BACKSPACE):
with self.subTest(key=key):
app = make_app()
app._handle_key(key)
self.assertEqual(app.plot_cursor, 0)
self.assertTrue(app.running)
def test_unknown_key_is_ignored(self):
self.app._handle_key("zzz")
self.assertEqual(self.app.plot_cursor, 0)
self.assertTrue(self.app.running)
class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
def setUp(self):
self.app = make_app()
self.engine = self.app.engine
def test_p_opens_the_crop_menu_and_a_digit_plants(self):
self.app._handle_key("p")
self.assertEqual(self.app.mode, "select_crop")
self.assertTrue(self.app.menu)
self.app._handle_key("1")
self.assertEqual(self.app.mode, "main")
self.assertIsNotNone(self.engine.state.plots[0].crop)
def test_escape_closes_the_menu(self):
self.app._handle_key("p")
self.app._handle_key(keys.ESC)
self.assertEqual(self.app.mode, "main")
self.assertIsNone(self.engine.state.plots[0].crop)
def test_menu_rejects_a_locked_choice(self):
self.engine.state.level = 1
self.app._handle_key("p")
locked_index = next(
i for i, item in enumerate(self.app.menu, start=1) if item["key"] == "strawberry"
)
self.app._handle_key(str(locked_index))
self.assertIsNone(self.engine.state.plots[0].crop)
self.assertIn("尚未解锁", self.app.message)
def test_menu_ignores_out_of_range_choices(self):
self.app._handle_key("p")
self.app._handle_key("0")
self.assertIn("没有这个选项", self.app.message)
self.app._handle_key("9")
self.assertIn("没有这个选项", self.app.message)
def test_b_buys_an_animal_through_the_menu(self):
self.app._handle_key("b")
self.assertEqual(self.app.mode, "select_animal")
self.app._handle_key("1")
self.assertEqual(len(self.engine.state.animals), 1)
def test_w_waters_and_h_harvests_the_selected_plot(self):
self.engine.plant(1, "wheat")
self.engine.state.plots[0].moisture = 0
self.app._handle_key("w")
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
gold = self.engine.state.gold
self.app._handle_key("h")
self.assertGreater(self.engine.state.gold, gold)
def test_e_harvests_everything_ripe(self):
for number in (1, 2, 3):
self.engine.plant(number, "wheat")
for index in (0, 1):
self.engine.state.plots[index].growth = 2
self.app._handle_key("e")
self.assertTrue(self.engine.state.plots[0].is_empty)
self.assertTrue(self.engine.state.plots[1].is_empty)
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的留着")
def test_e_without_anything_ripe_says_so(self):
self.app._handle_key("e")
self.assertIn("没有成熟", self.app.message)
def test_f_feeds_the_focused_animal(self):
self.engine.buy_animal("chicken")
self.app._handle_key("f")
self.assertTrue(self.engine.state.animals[0].fed)
def test_c_collects_the_focused_animal(self):
self.engine.buy_animal("chicken")
self.engine.state.animals[0].pending = 2
gold = self.engine.state.gold
self.app._handle_key("c")
self.assertGreater(self.engine.state.gold, gold)
def test_l_buys_land_and_k_buys_feed(self):
self.engine.state.level = 2
plots = len(self.engine.state.plots)
self.app._handle_key("l")
self.assertEqual(len(self.engine.state.plots), plots + 1)
feed = self.engine.state.feed
self.app._handle_key("k")
self.assertEqual(self.engine.state.feed, feed + config.FEED_BULK)
def test_s_saves_the_game(self):
self.app.autosave = False
self.app._handle_key("s")
self.assertTrue(os.path.exists(self.app.save_path))
def test_r_asks_before_resetting(self):
self.engine.state.day = 5
self.app._handle_key("r")
self.assertEqual(self.app.mode, "confirm_reset")
self.app._handle_key("n")
self.assertEqual(self.app.mode, "main")
self.assertEqual(self.engine.state.day, 5, "没确认就不该重置")
def test_r_then_y_resets(self):
self.engine.state.day = 5
self.app._handle_key("r")
self.app._handle_key("y")
self.assertEqual(self.engine.state.day, 1)
def test_question_mark_opens_help_and_any_key_closes_it(self):
self.app._handle_key("?")
self.assertEqual(self.app.mode, "help")
self.app._handle_key("x")
self.assertEqual(self.app.mode, "main")
def test_q_quits(self):
self.app._handle_key("q")
self.assertFalse(self.app.running)
def test_failed_action_shows_the_reason_instead_of_crashing(self):
self.app._handle_key("w") # 空地浇水
self.assertIn("空地", self.app.message)
self.assertTrue(self.app.running)
class TuiRenderTestCase(unittest.TestCase):
def setUp(self):
self.app = make_app()
# TuiApp 自己不驱动时钟,倒计时要靠 next_tick_at 才有值
self.app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
def capture(self) -> str:
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
self.app._render()
return buffer.getvalue()
def test_first_frame_contains_every_panel(self):
output = self.render_plain()
for label in ("SimpleFarm", "农田", "牧场", "市场行情", "农事日志"):
with self.subTest(panel=label):
self.assertIn(label, output)
def render_plain(self) -> str:
return strip_ansi(self.capture())
def test_frame_fits_the_terminal_width(self):
output = strip_ansi(self.capture())
for line in output.splitlines():
with self.subTest(line=line[:20]):
self.assertLessEqual(display_width(line.rstrip("\r")), 200)
def test_countdown_uses_the_human_format(self):
output = self.render_plain()
self.assertNotIn("s \n", output)
self.assertRegex(output, r"\d+:\d{2}")
def test_paused_state_is_visible(self):
self.app.engine.pause(True)
self.assertIn("已暂停", self.render_plain())
def test_crop_state_is_visible(self):
self.app.engine.plant(1, "wheat")
self.assertIn("小麦", self.render_plain())
def test_market_panel_uses_the_china_convention(self):
"""涨红跌绿是中国习惯,别跟着欧美反过来。"""
tui_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "farm", "tui.py"
)
with open(tui_path, encoding="utf-8") as handle:
source = handle.read()
self.assertIn('trend, tone = "涨", C_RED', source)
self.assertIn('trend, tone = "跌", C_GREEN', source)
def test_help_page_is_current(self):
self.app._handle_key("?")
output = self.render_plain()
self.assertIn("分钟", output)
self.assertNotIn("[N]休息", output)
self.assertNotIn("15 秒", output)
def test_keyboard_notice_appears_when_input_is_unavailable(self):
self.app.reader.available = False
self.app.reader.reason = "标准输入不是终端(管道或重定向)"
self.app.show_keyboard_notice = True
self.assertIn("读不到按键", self.render_plain())
def test_keyboard_notice_can_be_suppressed_for_previews(self):
self.app.reader.available = False
self.app.show_keyboard_notice = False
output = self.render_plain()
self.assertNotIn("读不到按键", output)
self.assertIn("↑↓←→", output)
def test_menu_frame_renders_choices(self):
self.app._handle_key("p")
output = self.render_plain()
self.assertIn("选择要种在", output)
self.assertIn("小麦", output)
def test_auto_save_only_runs_when_enabled(self):
app = make_app(autosave=True)
app._exit()
self.assertTrue(os.path.exists(app.save_path))
class TuiSnapshotCompatibilityTestCase(unittest.TestCase):
"""TUI 要能同时吃本地引擎和远程客户端——两者的快照字段必须对齐。"""
def test_render_works_with_a_remote_shaped_snapshot(self):
from farm.client import StateView
app = make_app()
view = StateView(app.engine.snapshot())
app.engine = view # 用一个"长得像引擎"的对象
lines = app._render_status(view.to_dict(), 100)
rendered = strip_ansi("\n".join(lines))
self.assertIn("金币", rendered)
def test_log_entries_are_rendered(self):
app = make_app()
app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
app.engine.add_log("测试事件", "good")
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
app._render()
self.assertIn("测试事件", strip_ansi(buffer.getvalue()))
def test_recent_messages_expire(self):
app = make_app()
app._flash("一闪而过", seconds=0.0)
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
app._render()
self.assertNotIn("一闪而过", strip_ansi(buffer.getvalue()))
def test_json_round_trip_of_the_snapshot_is_renderable(self):
"""远程客户端的快照是过了一遍 JSON 的,字段类型可能变。"""
self.app = make_app()
raw = json.loads(json.dumps(self.app.engine.snapshot(), ensure_ascii=False))
self.app._render_status(raw, 100)
self.app._render_farm(raw, 60)
if __name__ == "__main__":
unittest.main(verbosity=2)
+345
View File
@@ -0,0 +1,345 @@
"""热路径基准:给「优化前后」提供可对比的数字。
为什么需要它
------------
这个服务同时扛着三条高频路径,而它们**都在重复序列化整局状态**:
* 每次 ``POST /api/actions``(TUI 客户端固定带 ``full=1``)
* ``Publisher`` 每 250ms 一次的 SSE 推送
* ``GET /api/world`` / ``/api/farms`` 的排行榜(遍历所有牧场)
改任何一处序列化代码,代价都会乘以上面这些倍数。所以先有数字,再动手。
跑法::
uv run python tools/bench.py
uv run python tools/bench.py --repeat 5 # 每项取最快一轮,抗系统噪声
输出两段:
1. **重复计数**——一次请求到底触发了几次 ``GameState.to_dict`` /
``net_worth`` / ``available_actions``。这是优化最直接的证据。
2. **耗时**——各场景的 µs/op 与 ops/s。
参考数字(16 块地 + 8 只牲畜的最坏形态,本机实测)
--------------------------------------------------
2026-09-19 那次优化(把 brief/summary 改成读标量、net_worth 只算一次)前后::
场景 优化前 优化后
brief(session, hub) 63.6 µs 15.4 µs -76%
session.summary() 60.2 µs 13.8 µs -77%
leaderboard(32 farms) 1981.7 µs 445.8 µs -78%
engine.snapshot() 57.3 µs 45.0 µs -21%
POST /api/actions full=1 464.7 µs 390.2 µs -16%
GET /api/actions 398.3 µs 333.2 µs -16%
GET /api/world 193.6 µs 133.5 µs -31%
一次 ``POST /api/actions?full=1`` 的重复工作量:``to_dict`` 2 → 1 次、
``snapshot`` 2 → 1 次、``net_worth`` 6 → 2 次。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import tempfile
import time
from collections.abc import Callable
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm import config, models
from farm.engine import FarmEngine
from farm.models import Animal, Plot
from farm.server.hub import ConnectionHub, build_state_message
from farm.server.session import SessionManager
try:
from farm.server.app import ServerConfig, brief, create_app
except ImportError as exc: # pragma: no cover - 环境没装服务器依赖时
raise SystemExit(f"本基准需要服务器依赖(fastapi 等):{exc}") from None
SPEED_FARM = "bench"
ACTION_BODY = b'{"action":"pause","params":{}}'
# --------------------------------------------------------------------- 造数据
def fill_endgame(engine: FarmEngine) -> None:
"""把一个引擎填成"后期农场":地种满一半、牲畜拉满。
挑后期形态是因为它才是序列化的最坏情况——16 块地 + 8 只牲畜。
"""
state = engine.state
state.level = 6 # 6 + 2*(6-1) = 16,正好顶到 MAX_PLOTS
state.gold = 50000
state.ap = config.START_AP
state.feed = 40
state.plots = [Plot(i) for i in range(config.MAX_PLOTS)]
for i, plot in enumerate(state.plots):
if i % 2 == 0: # 一半种着、一半空着,两条分支都走到
plot.crop = "corn"
plot.moisture = config.WATER_CAPACITY
plot.growth = 2.0
state.animals = [Animal("cow", aid=i) for i in range(config.MAX_ANIMALS)]
for i, animal in enumerate(state.animals):
animal.fed = i % 2 == 0
animal.pending = 1 if i % 3 == 0 else 0
def make_session(save_dir: str) -> Any:
manager = SessionManager(save_dir=save_dir, tick_seconds=config.DEFAULT_TICK_SECONDS)
session = manager.get(SPEED_FARM, create=True)
fill_endgame(session.engine)
return session
# --------------------------------------------------------------------- 计时
def measure(func: Callable[[], Any], repeat: int = 5, target: float = 0.15) -> float:
"""返回单次调用耗时(秒)。先估一次,再决定循环几轮让总时长接近 target。"""
start = time.perf_counter()
func()
single = max(time.perf_counter() - start, 1e-9)
number = max(1, min(20000, int(target / single)))
best = float("inf")
for _ in range(repeat):
start = time.perf_counter()
for _ in range(number):
func()
best = min(best, time.perf_counter() - start)
return best / number
class Scenario:
"""一条被测路径。"""
def __init__(self, name: str, note: str, func: Callable[[], Any]) -> None:
self.name = name
self.note = note
self.func = func
self.per_op = 0.0
def run(self, repeat: int) -> None:
self.per_op = measure(self.func, repeat=repeat)
# --------------------------------------------------------------------- ASGI 直连
async def asgi_call(
app: Any, method: str, path: str, query: str = "", body: bytes = b""
) -> tuple[int, dict[str, Any]]:
"""不经过任何 HTTP 客户端,直接把请求喂给 ASGI 应用。
这样基准里既能测到真实路由/中间件开销,又不需要 httpx 之类的依赖。
注意:这条路径**不会跑 lifespan**,所以它测不到时钟与推送线程的启动;
那部分由 ``tests/test_server.py`` 的真机用例覆盖。
"""
scope: dict[str, Any] = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": method,
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": query.encode(),
"root_path": "",
"headers": [
(b"host", b"127.0.0.1"),
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
"client": ("127.0.0.1", 41234),
"server": ("127.0.0.1", 8765),
}
sent: list[dict[str, Any]] = []
async def receive() -> dict[str, Any]:
return {"type": "http.request", "body": body, "more_body": False}
async def send(message: dict[str, Any]) -> None:
sent.append(message)
await app(scope, receive, send)
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
raw = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
return status, json.loads(raw) if raw else {}
def call_once(app: Any, method: str, path: str, query: str, body: bytes) -> None:
"""同步跑一次 ASGI 请求(给"只跑一次"的计数场景用)。"""
loop = asyncio.new_event_loop()
try:
status, _ = loop.run_until_complete(asgi_call(app, method, path, query, body))
if status >= 400:
raise RuntimeError(f"{method} {path} 返回 {status}")
finally:
loop.close()
def measure_http(
app: Any, method: str, path: str, query: str, body: bytes, repeat: int = 5, number: int = 300
) -> float:
"""批量跑 HTTP 调用;循环放在事件循环内部,避免把建循环的开销算进去。"""
async def burst() -> None:
for _ in range(number):
status, _ = await asgi_call(app, method, path, query, body)
if status >= 400:
raise RuntimeError(f"{method} {path} 返回 {status}")
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(burst()) # 预热,顺带把牧场建出来
best = float("inf")
for _ in range(repeat):
start = time.perf_counter()
loop.run_until_complete(burst())
best = min(best, time.perf_counter() - start)
return best / number
finally:
loop.close()
# --------------------------------------------------------------------- 重复计数
def count_calls(func: Callable[[], Any], patches: list[tuple[Any, str, str]]) -> dict[str, int]:
"""给若干方法/属性装计数器,跑一次 ``func()``,返回各标签的命中次数。
``patches`` 每项是 ``(类, 属性名, 标签)``;属性会自动识破 property 与普通方法。
"""
counters: dict[str, int] = {label: 0 for _, _, label in patches}
originals: list[tuple[Any, str, Any]] = []
for cls, attr, label in patches:
original = getattr(cls, attr)
if isinstance(original, property):
getter = original.fget
def count_property(self: Any, _getter: Any = getter, _label: str = label) -> Any:
counters[_label] += 1
return _getter(self)
replacement: Any = property(count_property)
else:
def count_method(
self: Any, *args: Any, _orig: Any = original, _label: str = label, **kwargs: Any
) -> Any:
counters[_label] += 1
return _orig(self, *args, **kwargs)
replacement = count_method
originals.append((cls, attr, original))
setattr(cls, attr, replacement)
try:
func()
finally:
for cls, attr, original in originals:
setattr(cls, attr, original)
return counters
# --------------------------------------------------------------------- 主流程
def main() -> int:
parser = argparse.ArgumentParser(description="SimpleFarm 热路径基准")
parser.add_argument("--repeat", type=int, default=5, help="每个场景取最快的一轮(默认 5)")
parser.add_argument("--farms", type=int, default=32, help="排行榜场景的牧场数(默认 32)")
args = parser.parse_args()
save_dir = tempfile.mkdtemp(prefix="farm-bench-")
session = make_session(save_dir)
hub = ConnectionHub()
engine = session.engine
cfg = ServerConfig(save_dir=save_dir, tick_seconds=config.DEFAULT_TICK_SECONDS)
app = create_app(cfg)
fill_endgame(app.state.manager.get(SPEED_FARM, create=True).engine)
# ---------------------------------------------------------- 重复计数
query = f"farm={SPEED_FARM}&full=1"
print("=" * 74)
print("重复工作量")
print("=" * 74)
per_action = count_calls(
lambda: call_once(app, "POST", "/api/actions", query, ACTION_BODY),
[
(models.GameState, "to_dict", "to_dict"),
(FarmEngine, "snapshot", "snapshot"),
(FarmEngine, "brief_source", "brief_source"),
],
)
expected_per_action = {"to_dict": 1, "snapshot": 1, "brief_source": 1}
print(" 一次 POST /api/actions?full=1:")
for label, value in per_action.items():
want = expected_per_action[label]
mark = "OK" if value == want else f"!! 期望 {want}"
print(f" {label:16} {value:3d} 次 {mark}")
# 动作回执与完整快照各需要一次 net_worth,所以单独量"一次 to_dict 里算几次"
per_to_dict = count_calls(engine.state.to_dict, [(models.GameState, "net_worth", "net_worth")])
count = per_to_dict["net_worth"]
print(
f" 一次 GameState.to_dict(): net_worth {count} 次 {'OK' if count == 1 else '!! 应为 1'}"
)
print()
# ---------------------------------------------------------- 耗时
scenarios = [
Scenario("engine.snapshot()", "完整序列化整局(16 地 + 8 牲畜)", engine.snapshot),
Scenario("state.net_worth", "净资产属性(每次访问都重算)", lambda: engine.state.net_worth),
Scenario("engine.available_actions()", "GET /api/actions 的主体", engine.available_actions),
Scenario(
"brief(session, hub)", "动作回执的精简状态(只读标量)", lambda: brief(session, hub)
),
Scenario("session.snapshot()", "full=1 时回执附带的完整快照", session.snapshot),
Scenario(
"build_state_message()",
"SSE 每 250ms 一次的推送",
lambda: build_state_message(session, 1),
),
Scenario("session.summary()", "排行榜单行摘要(只读标量)", session.summary),
]
# 排行榜:N 个牧场,每个都走一遍 summary()
league_dir = tempfile.mkdtemp(prefix="farm-league-")
league = SessionManager(save_dir=league_dir, tick_seconds=config.DEFAULT_TICK_SECONDS)
for i in range(args.farms):
fill_endgame(league.get(f"farm-{i:03d}", create=True).engine)
scenarios.append(
Scenario(f"leaderboard({args.farms} farms)", "GET /api/world 的排行榜", league.leaderboard)
)
print("=" * 74)
print(f"耗时(每项取 {args.repeat} 轮最快,越大越慢)")
print("=" * 74)
print(f" {'场景':<32} {'µs/op':>10} {'ops/s':>10} 说明")
for scenario in scenarios:
scenario.run(args.repeat)
us = scenario.per_op * 1e6
print(f" {scenario.name:<32} {us:10.1f} {1 / scenario.per_op:10.0f} {scenario.note}")
# ---------------------------------------------------------- HTTP 端到端
print()
print("=" * 74)
print("HTTP 端到端(真实路由 + 响应装配;不含 lifespan)")
print("=" * 74)
http_cases = [
("GET /api/actions", "GET", "/api/actions", f"farm={SPEED_FARM}", b""),
("POST /api/actions full=1", "POST", "/api/actions", query, ACTION_BODY),
("GET /api/state", "GET", "/api/state", f"farm={SPEED_FARM}", b""),
("GET /api/world", "GET", "/api/world", f"farm={SPEED_FARM}", b""),
]
print(f" {'场景':<32} {'µs/op':>10} {'ops/s':>10}")
for label, method, path, case_query, body in http_cases:
per_op = measure_http(app, method, path, case_query, body, repeat=args.repeat)
print(f" {label:<32} {per_op * 1e6:10.1f} {1 / per_op:10.0f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -2
View File
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm import config
from farm.engine import FarmEngine
from farm.tui import TuiApp
@@ -44,13 +45,15 @@ def build_remote(url: str, farm: str, token: str | None) -> tuple[RemoteFarm, Tu
app = TuiApp(
client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}"
)
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
return client, app
def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]:
random.seed(42)
engine = FarmEngine(tick_seconds=15)
engine = FarmEngine(tick_seconds=config.DEFAULT_TICK_SECONDS)
app = TuiApp(engine, use_color=True, autosave=False)
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
if scene == "empty":
return engine, app
@@ -128,7 +131,8 @@ def main() -> int:
if engine.next_tick_at is None and not engine.paused:
import time as _time
engine.next_tick_at = _time.time() + 8.4
# 假装这一天已经过去了 12 秒,倒计时才会显示成 39:48 这种真实样子
engine.next_tick_at = _time.time() + config.DEFAULT_TICK_SECONDS - 12
app.use_color = args.color
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""SSE 通道演示脚本:证明"服务器主动推、客户端用 HTTP 发"这一对方向都通。
它同时是一个"外部客户端"的样板——完全不知道游戏内部结构,只知道协议:
连上 /api/stream → 收 welcome(含状态)→ 用 HTTP 发一个动作 → 收 state 推送
只用标准库(urllib 流式读长连接),所以任何有 Python 的环境都能跑。
用法:
python tools/sse_demo.py --url http://127.0.0.1:8765 --farm demo --seconds 8
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.parse
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm import net
def stream_url(endpoint: str, farm: str, token: str | None) -> str:
base = endpoint if "://" in endpoint else "http://" + endpoint
query = {"farm": farm, "name": "sse-demo"}
if token:
query["token"] = token
return f"{base.rstrip('/')}/api/stream?{urllib.parse.urlencode(query)}"
def action_url(endpoint: str, farm: str, token: str | None) -> str:
base = endpoint if "://" in endpoint else "http://" + endpoint
query = {"farm": farm}
if token:
query["token"] = token
return f"{base.rstrip('/')}/api/actions?{urllib.parse.urlencode(query)}"
def post_action(url: str, action: str, params: dict | None = None) -> dict:
"""用普通 HTTP POST 提交动作——上行不占用实时通道。"""
body = json.dumps({"action": action, "params": params or {}}).encode("utf-8")
request = urllib.request.Request(
url, data=body, method="POST", headers={"Content-Type": "application/json"}
)
with net.open_url(request, timeout=8.0) as response:
return json.loads(response.read().decode("utf-8"))
def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int:
url = stream_url(endpoint, farm, token)
print(f"连接 {url}")
request = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
counts: dict[str, int] = {}
acted = False
deadline = time.time() + seconds
# 读超时必须比服务器心跳(15 秒)宽裕,否则空闲时会被自己的超时打断
with net.open_url(request, timeout=45.0) as response:
while True:
try:
raw_line = response.readline()
except (TimeoutError, OSError) as exc:
print(f"(读超时:{exc})")
break
if not raw_line:
print("(服务器关闭了长连接)")
break
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if not line.startswith("data:"):
continue
message = json.loads(line[len("data:") :].strip())
kind = message.get("type", "?")
counts[kind] = counts.get(kind, 0) + 1
if kind == "welcome":
state = message.get("state", {})
print(
f"← welcome:牧场 {message.get('farm')} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 候选动作 {len(message.get('actions', []))} 个"
)
# welcome 里就带着当前合法动作,挑第一个执行,
# 验证"两条通道改的是同一个引擎"
candidates = message.get("actions") or []
if candidates:
pick = candidates[0]
reply = post_action(
action_url(endpoint, farm, token),
pick["action"],
pick.get("params") or {},
)
acted = True
print(
f"→ POST /api/actions {pick['action']} "
f"{pick.get('params') or {}}:{reply.get('message')}"
)
elif kind == "state":
snapshot = message.get("state", {})
print(
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · "
f"在线 {message.get('online')}"
)
if acted:
# 收到了动作触发的推送,两条路都验证完了
break
elif kind == "day":
print(f"← day:进入第 {message.get('day')} 天,事件 {message.get('events')}")
elif kind == "notice":
print(f"← notice:{message.get('text')}")
else:
print(f"← {kind}")
if time.time() >= deadline:
break
print(f"\n消息统计:{counts}")
if counts.get("welcome", 0) < 1:
print("✗ 没有收到 welcome,SSE 长连接可能没建立")
return 1
if counts.get("state", 0) < 1:
print("✗ 动作之后没有收到 state 推送(引擎与推送线程之间可能断了)")
return 1
print("✓ SSE 通道正常:状态会主动下行,动作经由 HTTP 上行")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="SSE 通道演示")
parser.add_argument("--url", default="http://127.0.0.1:8765")
parser.add_argument("--farm", default="sse-demo")
parser.add_argument("--token", default=None)
parser.add_argument("--seconds", type=float, default=8.0)
args = parser.parse_args()
return run(args.url, args.farm, args.token, args.seconds)
if __name__ == "__main__":
sys.exit(main())
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""WebSocket 通道演示脚本:证明"服务器推、客户端拉/发"这一对方向都通。
它同时是一个"外部客户端"的样板——完全不知道游戏内部结构,只知道协议:
连接 → 收 welcome(含状态)→ 发一个动作 → 收 result 回执 → 收 state 推送
用法:
python tools/ws_demo.py --url http://127.0.0.1:8765 --farm demo --seconds 8
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import urllib.parse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def ws_url(endpoint: str, farm: str, token: str | None) -> str:
parts = urllib.parse.urlsplit(endpoint if "://" in endpoint else "http://" + endpoint)
scheme = "wss" if parts.scheme == "https" else "ws"
query = {"farm": farm, "name": "ws-demo"}
if token:
query["token"] = token
return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}"
async def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int:
import websockets
url = ws_url(endpoint, farm, token)
print(f"连接 {url}")
async with websockets.connect(url, open_timeout=8) as socket:
welcome = json.loads(await socket.recv())
state = welcome.get("state", {})
print(
f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · "
f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个"
)
async def sender() -> None:
"""通过 WebSocket 发一个动作,等回执。"""
await asyncio.sleep(0.3)
request = {"type": "action", "id": 1, "action": "sleep", "params": {}}
print(f"→ action:{json.dumps(request, ensure_ascii=False)}")
await socket.send(json.dumps(request))
deadline = asyncio.get_event_loop().time() + seconds
send_task = asyncio.create_task(sender())
counts: dict[str, int] = {}
try:
while asyncio.get_event_loop().time() < deadline:
try:
raw = await asyncio.wait_for(socket.recv(), timeout=seconds)
except asyncio.TimeoutError:
break
message = json.loads(raw)
kind = message.get("type", "?")
counts[kind] = counts.get(kind, 0) + 1
if kind == "result":
print(
f"← result(id={message.get('id')}):{message.get('message')} "
f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]"
)
elif kind == "state":
snapshot = message.get("state", {})
print(
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · "
f"在线 {message.get('online')}"
)
elif kind == "day":
print(f"← day:进入第 {message.get('day')} 天,事件 {message.get('events')}")
elif kind == "notice":
print(f"← notice:{message.get('text')}")
else:
print(f"← {kind}")
finally:
send_task.cancel()
print(f"\n消息统计:{counts}")
if counts.get("result", 0) < 1 or counts.get("state", 0) < 1:
print("✗ WebSocket 通道没有按预期工作(result / state 消息缺失)")
return 1
print("✓ WebSocket 双向通道正常:动作可上行,状态会主动下行")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="WebSocket 通道演示")
parser.add_argument("--url", default="http://127.0.0.1:8765")
parser.add_argument("--farm", default="ws-demo")
parser.add_argument("--token", default=None)
parser.add_argument("--seconds", type=float, default=8.0)
args = parser.parse_args()
return asyncio.run(run(args.url, args.farm, args.token, args.seconds))
if __name__ == "__main__":
sys.exit(main())
Generated
+78 -40
View File
@@ -38,15 +38,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -93,19 +84,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
@@ -156,21 +134,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
@@ -438,11 +401,11 @@ source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "zttp" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pyright" },
{ name = "ruff" },
]
@@ -450,12 +413,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.110" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" },
{ name = "zttp", specifier = ">=0.0.33" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "pyright", specifier = ">=1.1.390" },
{ name = "ruff", specifier = ">=0.8.0" },
]
@@ -956,3 +919,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload-time = "2026-08-26T17:25:29.745Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
]
[[package]]
name = "zttp"
version = "0.0.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/33/4006bf40e8b41d594cb600501092c879c2570a0d2183d3718049c6eec4ac/zttp-0.0.33.tar.gz", hash = "sha256:055bc7a656df43a3ff70a40e6ef16357ebdcedac4b42b7a6330f09838d671d23", size = 353809, upload-time = "2026-09-16T13:11:15.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/57/98b5b6cea312f8c6aacec18e2561aaaff7ec2a05dfec947fe6ca119f191e/zttp-0.0.33-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e177fe8f8e3a1ef28deb3f76f44c175ae93c0325b6996a42a5c9701325a1e2d4", size = 382214, upload-time = "2026-09-16T13:10:04.105Z" },
{ url = "https://files.pythonhosted.org/packages/2f/1f/52d34a6c2553d28d7b4772349bc8c933f52cec2dd768c159ddb75b4c149d/zttp-0.0.33-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:02295487b8df41808c1a1847d5963641e023f23e11dbe4584ab74bf15e409d61", size = 428227, upload-time = "2026-09-16T13:10:05.475Z" },
{ url = "https://files.pythonhosted.org/packages/10/a9/3b399d9508179153c216b631364d5448fe924a8f4f346326375dd756cf60/zttp-0.0.33-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a428141a4c13b75c5487135b6c4b5bcfb4d36c9827a55d4135b5d0589035ce66", size = 423064, upload-time = "2026-09-16T13:10:06.495Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f5/cd987d4244c693960103fa68e0311bcf4155504304275ebce1a55d34fc53/zttp-0.0.33-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d1485b7133c4154f1bda53dc1faef2fefc1054e58b9ee42a9907a3bc505a564a", size = 453367, upload-time = "2026-09-16T13:10:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b8/07c3ace80803f84b842283bfafe07563c5c6cc4123710fb1a184337aed96/zttp-0.0.33-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:444ce7ff72c92d5c3ae7eddf4712cf54ec629bc9e761fadec858339acf00f149", size = 422028, upload-time = "2026-09-16T13:10:08.584Z" },
{ url = "https://files.pythonhosted.org/packages/63/fe/6fb31f10c6c1f5bee4fde978267bff0d09ab0c4499372488365f078a0394/zttp-0.0.33-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:198817af0f9c156842383e32d0036b9f79a9dc5941efb92c6f6a0087f0c00c16", size = 451800, upload-time = "2026-09-16T13:10:09.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e8/a08099d82e94c76b69759164a61aa6396443ab7731c62b0358fc425984b6/zttp-0.0.33-cp310-cp310-win_amd64.whl", hash = "sha256:7cfe1e2ff0a722b41f974c286fbfc5452d9e591991258591773373a3a3dce298", size = 496684, upload-time = "2026-09-16T13:10:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2a/85/051fdb708c2bb87e56f5cd2a0e80955c30614bf67d7063c6495238b97eea/zttp-0.0.33-cp310-cp310-win_arm64.whl", hash = "sha256:37e4884656a67a0410a0fd2ac0b32dbe10bca7de01e175d0aa5a752776134984", size = 434452, upload-time = "2026-09-16T13:10:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/03/85/60ccca5077f71b48a2d4792fdeda1dcfdb000c8a8d8892cdaedbd177276b/zttp-0.0.33-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:893c67a38e43954892f84e76f66fc9bbc9739efc33194e05c757484dd2c45967", size = 382172, upload-time = "2026-09-16T13:10:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/6e/97/7b07305a752e0cbd29c8ad65214ac61a6b7fd170a387f0e3f05e7919f407/zttp-0.0.33-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:65d3d87f49c08b65e4b558a3541d41293de5f6e1b2efca72b411e987e485c2fd", size = 428166, upload-time = "2026-09-16T13:10:14.223Z" },
{ url = "https://files.pythonhosted.org/packages/68/48/acb7a67a190e6877f0dd560d26216a3cfc518e30539c5a74b26d1a55b263/zttp-0.0.33-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f831a34257ff1ab08760c0b3cdf687e1fe13d8a2208a977502ac91bf7a3b5740", size = 422980, upload-time = "2026-09-16T13:10:15.33Z" },
{ url = "https://files.pythonhosted.org/packages/1f/29/f5faba04a4071c38ce9ddfa8730f15ed76796434502b6ebf51f532b02c3d/zttp-0.0.33-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:07d059766e727a27b38135378667212a94702d4421461ec74badfd6d6cffec57", size = 453290, upload-time = "2026-09-16T13:10:16.285Z" },
{ url = "https://files.pythonhosted.org/packages/9d/4d/af2b0cc3c3dbab3cfd6a4dba7651129ae8eaf77f69e7c92b88c53338d26d/zttp-0.0.33-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d27eb512867f26ecd0dce6601251beb84e9faaefe9c9cd1df21b744a83f2070", size = 421898, upload-time = "2026-09-16T13:10:17.366Z" },
{ url = "https://files.pythonhosted.org/packages/02/51/70ffce18b5fb4c6be8249dedb0e72870751dc4c928b4e3f64025297d2bd1/zttp-0.0.33-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac5d69937e862a3568da6d702ae39fff9872f0796f3bc2f3245cf4be1c30466e", size = 451724, upload-time = "2026-09-16T13:10:18.399Z" },
{ url = "https://files.pythonhosted.org/packages/7a/81/c5a20bb4e1134aca35d1238ef9d8d7d571a6c52944349f54a18c7bf65316/zttp-0.0.33-cp311-cp311-win_amd64.whl", hash = "sha256:2c5da7f3c6434517c05f78110bf94601c2cb2cbd1ec5e5a212e63ee006bcc2b0", size = 496585, upload-time = "2026-09-16T13:10:19.544Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cc/79611a85104b9c5d1f64b759a9a279f2a6cae1ee5bd40e5644ff322f0fd1/zttp-0.0.33-cp311-cp311-win_arm64.whl", hash = "sha256:898b8e61c2e74a46e8c85f90364765c17ba8216bccef1e1848ff2ad39737510d", size = 434397, upload-time = "2026-09-16T13:10:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/92/6f/706056c965ade400f03c5c07fc1bcfba942190932012dfad7ed7dc3c047e/zttp-0.0.33-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:418ea283f320406db60312b0ccdb0cc4509427bd63c244ae9a801a3a6fa92656", size = 382171, upload-time = "2026-09-16T13:10:21.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fc/c3ddf19c4e58cd34020ede4d5898139c81b7ba5031a0bf60ceb051d73cdc/zttp-0.0.33-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:c46fdd9d4cf2cbcecad349e97e7bcc9d7922beb293ecb4a36ebfb3e0aa47f247", size = 428168, upload-time = "2026-09-16T13:10:23.157Z" },
{ url = "https://files.pythonhosted.org/packages/ea/5d/78c2904f29b355b80b0a4c4aec0c47eb415908c06eb13e58bbf51fd728b5/zttp-0.0.33-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3653313f72615b4e16cf9cd0977ac473b1685d06612572848fb598f831f15e0b", size = 422984, upload-time = "2026-09-16T13:10:24.217Z" },
{ url = "https://files.pythonhosted.org/packages/8b/88/c8623e75a4e980c45c7bf9011edad175aebdd8624df79448cc90c6c2a0ac/zttp-0.0.33-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6bdc3030c37023c9096e8a4f2383fb5943d5106706ea687ee110570c676d12d5", size = 453291, upload-time = "2026-09-16T13:10:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/77/e0/44c9b7fd60382deada23d630896328c1a1a474f1b5e5efc720c5e173db05/zttp-0.0.33-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d3288ba6b4827558a116b8cd44be2da9a620f9e7bb18bb9271655b849c9ce2e3", size = 421897, upload-time = "2026-09-16T13:10:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/b0/5f/56d2e5e1664e83d881d7b89ebd7da2da1d0476bf404d5289611f1fae2331/zttp-0.0.33-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a4877580be5ecac9ad48a70931271b1afd593b4bbc8dd884b17de58e34fdb20", size = 451725, upload-time = "2026-09-16T13:10:27.526Z" },
{ url = "https://files.pythonhosted.org/packages/43/7f/f6a6eb6b8baea5ea753e3d9d93ef06cf171d6aac37f1b0ef10d24fabe0e5/zttp-0.0.33-cp312-cp312-win_amd64.whl", hash = "sha256:fc2e35d2d26896f885906774c1f94c6dd1795dd46921cccb73b581a80631cc47", size = 496590, upload-time = "2026-09-16T13:10:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/56/33/6760714a293e5844a278288ec477cfa86e30b315d3d92f1f6b8bbe301249/zttp-0.0.33-cp312-cp312-win_arm64.whl", hash = "sha256:298a77bd2068e30c2afd0779c1de2f296751b1208ea05814a52c8d0c8e125e6b", size = 434400, upload-time = "2026-09-16T13:10:29.809Z" },
{ url = "https://files.pythonhosted.org/packages/57/ad/3a875a146a4ff2d853e7d0b68570948ac13c7e2725645a2c62493c2e5b8c/zttp-0.0.33-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0b6f90b1fb27215dac799c94101a12501a902b6456f7b7051d2d6d9dbe772b", size = 382166, upload-time = "2026-09-16T13:10:30.938Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/9de73ab3a3a0942ced32090031eeaa4f256c77a5bb71855076e1facb6e29/zttp-0.0.33-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:696657c34e48ccefab0b41dfccd5943a9398f1217822b6b6c6e329c95bed8341", size = 428151, upload-time = "2026-09-16T13:10:31.889Z" },
{ url = "https://files.pythonhosted.org/packages/75/33/3d1fe322bb7835b1ae81270fa9ea3de65d01ce0e328655177cc7c91348c1/zttp-0.0.33-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:81478a5e6ff4ee6d8e9c26698641678d5914c66f9bf79d244b4947edff9a5307", size = 422976, upload-time = "2026-09-16T13:10:32.925Z" },
{ url = "https://files.pythonhosted.org/packages/fc/0b/844405414bbf4545fd3537560bf58aa4adde9ab1c62c5e3a9dd498c57307/zttp-0.0.33-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:08a508383f27c761d952d3a29bb4d5d7ed9e42be456eb5f4048c7883a600b757", size = 453276, upload-time = "2026-09-16T13:10:33.852Z" },
{ url = "https://files.pythonhosted.org/packages/3c/c9/8b9edd7eb1b07b04df1a2216b66015af2381fc6553ac396a3aed5a2d1c8a/zttp-0.0.33-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff7c76012edaef6a6214f546884ed6ae3dd90ed5c0cbaae748c3706589d63cee", size = 421897, upload-time = "2026-09-16T13:10:35.05Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c2/a36774fa67743d8c2838f5aad965d89a83c94430083b3f94fec44c2871d4/zttp-0.0.33-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:309807d9ca8671f5b903248855ffcfce2b6c47473f94d102357017f7472f3f9a", size = 451715, upload-time = "2026-09-16T13:10:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/87/39/c169f1eb723cd89c77710c5a278bb713065f624ada2f1dc4d39c8dda5e61/zttp-0.0.33-cp313-cp313-win_amd64.whl", hash = "sha256:4dd61679a8c082743ae342751936759140320dca9f6a0a219ccd5aa414cbf272", size = 496596, upload-time = "2026-09-16T13:10:37.158Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/70374b2c3d0df65228f42ad85cb0443b155e5dc7b61482cff3e1be081c8e/zttp-0.0.33-cp313-cp313-win_arm64.whl", hash = "sha256:e6ddb380a911af2b143e84917ec6c0df27a8f917d992ada1199538314b70113e", size = 434398, upload-time = "2026-09-16T13:10:38.335Z" },
{ url = "https://files.pythonhosted.org/packages/02/bb/63844f794213f4cf2321b28298191c70b712d3eec200f717786d76aa14a0/zttp-0.0.33-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4f12dcc288ae6d5eaa09e33748c31324a1ebe265a29deccca026cdcc44f2af4", size = 384950, upload-time = "2026-09-16T13:10:39.368Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f7/5f668596bfea45d40bf456d51a139c3da5608e79d5f03f96f9c9d18590c4/zttp-0.0.33-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e4749278225b0f46ec907af24efe6d55c84519b43e9d54cca87b5b8c6c88262e", size = 429972, upload-time = "2026-09-16T13:10:40.435Z" },
{ url = "https://files.pythonhosted.org/packages/bd/79/e3647b0c4ad3688872664ba83616affc7bd731f886e177f9a03682d56b98/zttp-0.0.33-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b4ceb105af67bd9c262cbb1c2752eb817dad7ec6dc2958511ef788fc9f4d7e76", size = 425649, upload-time = "2026-09-16T13:10:41.492Z" },
{ url = "https://files.pythonhosted.org/packages/26/f0/16edb7c472dd53b0a0181898e3744e2baa5f2636ef6809c4b2660d47a58f/zttp-0.0.33-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a630b668fcd5510776cb25084a835194cf861c7f69c55541f2648a11f5ce794f", size = 455659, upload-time = "2026-09-16T13:10:42.588Z" },
{ url = "https://files.pythonhosted.org/packages/77/35/3d952ba192bec83d55290ffd8b425a35ff53a49a2699b3458d0333400762/zttp-0.0.33-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d82b94e9abf4fa97d5e258939e5e5bb3f5e0de622b098161ff035bb4f3d6221e", size = 424817, upload-time = "2026-09-16T13:10:43.608Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fb/242400de983f24b8ad72def811dca7bc775fe3309439710796ad54544799/zttp-0.0.33-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44b411fb145d52723f8fc6ed805f6d02fa599d83c8474efae075068167e63f36", size = 453938, upload-time = "2026-09-16T13:10:44.686Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ab/e01e17a2c3ef0facd9600896010c7da74fc3936080157c316dc0f69ab622/zttp-0.0.33-cp314-cp314-win_amd64.whl", hash = "sha256:b9abd292277d6f7e8da70386b5aedb9d958fd69bddb2f8aee65986d71d1c97b6", size = 513610, upload-time = "2026-09-16T13:10:45.821Z" },
{ url = "https://files.pythonhosted.org/packages/10/a7/eddefe43b62470567c2ab3408ddc4678edb3208a9c21635a5b27f95863ce/zttp-0.0.33-cp314-cp314-win_arm64.whl", hash = "sha256:b8371cf472b1e282fe2654cbc925baf67a41119b9d1f8073c6912bafc5f4a2fb", size = 453142, upload-time = "2026-09-16T13:10:46.99Z" },
{ url = "https://files.pythonhosted.org/packages/40/43/893d2b7d3177becd71e8e39570f61196ad0d56549a350856e5cb66e22376/zttp-0.0.33-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:53b4b84cf265aabf3710e24c3c8f8a02e21e361b6976df035bea715b8555c896", size = 384265, upload-time = "2026-09-16T13:10:48.244Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ed/d35ae032e3c1e80ad751504aaa773359bd5d20a183d3c882084bdd6ad8ac/zttp-0.0.33-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:d26ac86c2224e8cee654214906d3113f8e25fab2282696e2e994b3feea747214", size = 430614, upload-time = "2026-09-16T13:10:49.291Z" },
{ url = "https://files.pythonhosted.org/packages/57/d2/b384b168cf74073e069d4d7a8050788c8f06f72cb361d007a2e919625cc7/zttp-0.0.33-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c10f0aea936ef98dc15f776d9060b9ed27da93f37efdb26f734fc190cd147ef5", size = 424495, upload-time = "2026-09-16T13:10:50.468Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8b/3db7879f64298d183f96ef377786d321ea8e83a1121b87a4546e757eb016/zttp-0.0.33-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:383372fdc1fa25355dc60edf63263c2b87aa0d83d74fe7c05e0c856d2e6691ae", size = 455475, upload-time = "2026-09-16T13:10:51.619Z" },
{ url = "https://files.pythonhosted.org/packages/bf/32/47eeddc3d4b84696e8359c9126d8dd5e94982b6e6c22f0f099c71e253871/zttp-0.0.33-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:27eb9c19f00de252f74a24ce94b239db542e57e8bbd6b7151bf8c9be964eef18", size = 423649, upload-time = "2026-09-16T13:10:52.867Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b1/d1e3e712a737099c4ab4e63fd6b09055bb35a7cb6b4f438873cf2a24d964/zttp-0.0.33-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a96f4e3f4b5e64e1b14e3166b56a6acfc89044c5c524bda7c6b42c621b1ef16", size = 453805, upload-time = "2026-09-16T13:10:53.92Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e6/afa61663102ac0abfe41953d78daff9718e0162b3d19cb2521a7047dadc8/zttp-0.0.33-cp314-cp314t-win_amd64.whl", hash = "sha256:4ca2a371e815b4b4ad88e6df912eda0a5fd42963a9e1f29d5c2830dd46430b95", size = 513668, upload-time = "2026-09-16T13:10:54.953Z" },
{ url = "https://files.pythonhosted.org/packages/32/dc/7322ab5d2b426d1fbca9a828f77b7ac1c213fca2d1d944f42035ebb36847/zttp-0.0.33-cp314-cp314t-win_arm64.whl", hash = "sha256:3a218191142bf851a939060f97b9723c985621f55310c0eb01e7886c0adaac8a", size = 452809, upload-time = "2026-09-16T13:10:56.135Z" },
{ url = "https://files.pythonhosted.org/packages/c7/fc/e8a0e60ded25c8f5394fb24aa53bbf6fe6267f65862c3d9e20a1e955dc7e/zttp-0.0.33-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5c38b4b7391a98d00be990b43d5b3ef8462942f3bac0ae74d380372ebe595053", size = 384952, upload-time = "2026-09-16T13:10:57.165Z" },
{ url = "https://files.pythonhosted.org/packages/13/79/a9922b52efdb65d149aa3b54090fb86473c2edd316cc1a8d03babfc89b15/zttp-0.0.33-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:d5810ff7f6b4937ef479d9b17c9a77e64339ff0e8c4ef2580c9d741696a91018", size = 429972, upload-time = "2026-09-16T13:10:58.315Z" },
{ url = "https://files.pythonhosted.org/packages/d1/87/04924852f37ac770955e98ece0d0d25e256d86518c0bceb25b250f863ae5/zttp-0.0.33-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:9aeb21637c42054a2ecddb294c2926ba9e0a00da4e9f34412c21d79c7210b03b", size = 425648, upload-time = "2026-09-16T13:10:59.439Z" },
{ url = "https://files.pythonhosted.org/packages/0b/65/c04f7b979c81abce288660cc06fe1d6bdf33ece900d41b4e0035c1d62c49/zttp-0.0.33-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:87c9d5aee05da47b165610373690b16c9da0413c8e922cf87a719388b9fc297d", size = 455660, upload-time = "2026-09-16T13:11:00.613Z" },
{ url = "https://files.pythonhosted.org/packages/d5/84/d553e1945139d70ece11f7df5759aa8a32e790ada5905f1f16c537361bb0/zttp-0.0.33-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:06e0ab676ef82ee5deaa38ecd8847bc6f52911799f64f35cbeed3e3e1b806bf1", size = 424818, upload-time = "2026-09-16T13:11:02.017Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d4/349820409735b8228682d81ca241357b024cc77b80b4a5c9600509fa455d/zttp-0.0.33-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5710ec638c7a359057814283001f5332ed203aed820a51bbea35633cdeac8ce6", size = 453937, upload-time = "2026-09-16T13:11:03.153Z" },
{ url = "https://files.pythonhosted.org/packages/51/9f/2214000fc9db07f76cac03c2924945ee0df5793503eb89feceb8c1e506c6/zttp-0.0.33-cp315-cp315-win_amd64.whl", hash = "sha256:ec1e911547c3140c75fc945074ae3a10436565eca4d4e4367282cd55248072c4", size = 513612, upload-time = "2026-09-16T13:11:04.292Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c1/2f0b202a2bcf3e9e69501ff580eb5726bfe9f014ac6a27902b003bdb9872/zttp-0.0.33-cp315-cp315-win_arm64.whl", hash = "sha256:6698e4cc65a9d2e04e7915fc4ae3e3129b53d97d049927df208350df809c7bf6", size = 453141, upload-time = "2026-09-16T13:11:05.476Z" },
{ url = "https://files.pythonhosted.org/packages/36/e3/d0045cfffcb274317020b209b95938b5e6bb306a1d3a4dcbd9a90ba9d5bd/zttp-0.0.33-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:67fe74e5a676443090a0d5d2263d3718d68e0fef73870f56cf3f35af4a32a26f", size = 384268, upload-time = "2026-09-16T13:11:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/69/e9/6243ca041eeb31f2ed8e10d7c99b5baefa8e423e53f69ade167a018a138a/zttp-0.0.33-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:81a6685bd90dbbaef226f54261a4ba4122fe7e4b585283edaf6b01c40d79594c", size = 430613, upload-time = "2026-09-16T13:11:07.719Z" },
{ url = "https://files.pythonhosted.org/packages/a5/c1/770b794d7048aad9bf3598aeffdda94ee2ed5c45cf716a3a09cb64876d8b/zttp-0.0.33-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:7fb31c87efe2a1454407197742cdfa9e33c666d366e04594d968a7954e276aee", size = 424494, upload-time = "2026-09-16T13:11:08.844Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/d0cc5b8fae45b5c4af89be55cf81d0dd62a9acf79db423eb995e3686c3f8/zttp-0.0.33-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:3f29decfd7b59b619006c8d164fe65bdad76a80f113874d6f7c2403e9599bfad", size = 455473, upload-time = "2026-09-16T13:11:09.976Z" },
{ url = "https://files.pythonhosted.org/packages/2d/21/d62d04ff9e59ac29c931e7044b901fe81d6cc0f61fa999b214f0b1ea3708/zttp-0.0.33-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e6750d7234acf16eec617d8ade2ad1c2fc423b4d066f84885fa8e652b6fb771b", size = 423649, upload-time = "2026-09-16T13:11:11.18Z" },
{ url = "https://files.pythonhosted.org/packages/58/cf/f6cabe4399c03c30d1091b938b30a87dd2e8ae95f003fe1549a13ba5ddd6/zttp-0.0.33-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ff5920ec4b888780159a6ecad1d608621c203c41a38e1c14a88439e0b45f7bff", size = 453807, upload-time = "2026-09-16T13:11:12.311Z" },
{ url = "https://files.pythonhosted.org/packages/40/e0/e92e0d0d3d77d8c58112a48931acacf730cd73886ff7d7a3cfd5d179ff06/zttp-0.0.33-cp315-cp315t-win_amd64.whl", hash = "sha256:22fdbe666ba2c1b66f0c2dc55d048ddadf7f40abca2430eac90972508b473363", size = 513666, upload-time = "2026-09-16T13:11:13.509Z" },
{ url = "https://files.pythonhosted.org/packages/cd/80/7c549bf773c71f8bc27946b35ed7cfe8c64ce635cae672766583db44bd53/zttp-0.0.33-cp315-cp315t-win_arm64.whl", hash = "sha256:99fb6c2fd680c45597ebceb4bd435d0994e593adae8fe52ac032452689bed891", size = 452808, upload-time = "2026-09-16T13:11:14.775Z" },
]