From f8e093c98f81c5d2cbaa7e269124fae3f3097a10 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:55:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E6=97=B6=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=94=B9=20SSE=E3=80=81=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=8D=A2=20?= =?UTF-8?q?uvicorn+zttp=E3=80=81=E8=8A=82=E5=A5=8F=E6=94=B9=2040=20?= =?UTF-8?q?=E5=88=86=E9=92=9F=E4=B8=80=E5=A4=A9=EF=BC=8C=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E6=B5=8B=E8=AF=95=E4=B8=8E=E4=BC=98=E5=8C=96=E7=83=AD?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。 实时通道:WebSocket -> SSE - hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye, 250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。 - 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 + 客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。 - 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。 服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2 - 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式 http="zttp",否则 Config.load() 直接 raise。 - 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖), 不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。 - 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例 「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。 - 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被 except OSError 吞掉,导致每次正常退出都留 stale pid 文件。 节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」 - DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。 - engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉, 时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。 - 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。 键盘:修复方向键/空格/Tab/ESC 全部静默失效 - keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串 (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。 - 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。 测试:重建 500 项 - test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 / test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑, 所以是全绿而不是靠跳过伪装。 - 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」 (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要 预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。 优化:先量后改,依据与前后数字都在 tools/bench.py - 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化 整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、 GET /api/world -31%、POST /api/actions?full=1 -16%。 - 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、 build_parser 与 available_actions 分组、__main__.main 160->7 行。 - 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的 broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json; main.py 的 _add_endpoint_args/_leaderboard_lines。 - tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。 门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。 --- .gitignore | 1 - .workbuddy/memory/2026-09-19.md | 215 ++++ .workbuddy/memory/MEMORY.md | 98 +- README.md | 228 +++-- ai_player.py | 4 +- farm/__init__.py | 2 +- farm/bot.py | 68 +- farm/client.py | 294 ++---- farm/config.py | 10 +- farm/engine.py | 358 ++++--- farm/keys.py | 282 +++++- farm/models.py | 43 +- farm/net.py | 3 +- farm/server/__init__.py | 4 +- farm/server/__main__.py | 259 +++-- farm/server/actions.py | 22 +- farm/server/app.py | 712 +++++++------- farm/server/daemon.py | 62 +- farm/server/hub.py | 99 +- farm/server/session.py | 46 +- farm/server/webui.py | 55 +- farm/tui.py | 58 +- main.py | 92 +- pyproject.toml | 24 +- requirements.txt | 3 + tests/test_actions.py | 332 +++++++ tests/test_bot.py | 394 ++++++++ tests/test_client.py | 990 +++++++++++++++++++ tests/test_engine.py | 1320 ++++++++++++++++--------- tests/test_server.py | 1613 ++++++++++++++++++++++--------- tests/test_tui_keys.py | 655 +++++++++++++ tools/bench.py | 345 +++++++ tools/preview_tui.py | 8 +- tools/sse_demo.py | 147 +++ tools/ws_demo.py | 105 -- uv.lock | 118 ++- 36 files changed, 6865 insertions(+), 2204 deletions(-) create mode 100644 .workbuddy/memory/2026-09-19.md create mode 100644 tests/test_actions.py create mode 100644 tests/test_bot.py create mode 100644 tests/test_client.py create mode 100644 tests/test_tui_keys.py create mode 100644 tools/bench.py create mode 100644 tools/sse_demo.py delete mode 100644 tools/ws_demo.py diff --git a/.gitignore b/.gitignore index 25399cb..f33a6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ venv/ # saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库) run/ saves/ -tests/_tmp/ # 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等), # 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。 /_* diff --git a/.workbuddy/memory/2026-09-19.md b/.workbuddy/memory/2026-09-19.md new file mode 100644 index 0000000..e7a6dd8 --- /dev/null +++ b/.workbuddy/memory/2026-09-19.md @@ -0,0 +1,215 @@ +## 2026-09-19 代码结构审查(只读,未改代码) + +- 全量读了 engine/models/actions/app/session/hub/client/storage/bot,跑通 ruff/format/pyright/97 测试,全绿。 +- 结论:分层健康,无需返工。发现 5 个可优化点: + 1. engine.Scheduler 与 server.FarmClock 重复实现同一套 tick 决策(暂停顺延+deadline); + 2. actions.py 表内注册的 save/reset 在服务端被 execute_action 拦截成死代码,未来绕过即触发默认路径覆盖存档的隐患; + 3. app.brief() 手工挑 26 个字段,建议改 BRIEF_KEYS 常量派生; + 4. hub.Publisher 的 except Exception: pass 静默吞异常(违反项目 contextlib.suppress 约定且无痕迹); + 5. client.pause() 手工 merge paused 疑似冗余(回执 brief 已带 paused 且 _action 已 apply)。 +- 未动手改,等用户决定优先级。 + +## 2026-09-19 结构审查 5 项优化已全部落地(+88/-76,7 个文件) + +1. engine.py 新增 tick_decision() 纯函数,Scheduler 与 FarmClock 共用(原两处重复的暂停顺延+deadline 逻辑合并)。 +2. actions.py 移除 save/reset 表项(服务端死代码隐患),注释说明存档类动作只能走 app.execute_action 会话拦截;/api/save、/api/reset 便捷路由随之消失,POST /api/actions {action:save} 仍可用。 +3. app.py brief() 改为 BRIEF_KEYS 常量派生。 +4. hub.Publisher 吞异常改为 errors 计数,/api/health 新增 publish_errors 字段。 +5. client.pause() 删除冗余手工 merge,收敛为纯转发。 +- README 动作清单与便捷入口数(13→11)同步更新。 +- 门禁:ruff format/check、pyright、97 项测试全绿。 +- 教训:对同一文件连续 Edit 时 old_string/new_string 写反了两次(一次添加了重复定义、一次误删 def 行),靠重读文件恢复。批量改动应先用幂等补丁脚本。 + +## 2026-09-19 实时通道从 WebSocket 整体迁移到 SSE + +- 决策过程:用户先问「HTTP 能双向吗」「HTTP/2 也不行吗」,讲清 Server Push 已废弃、 + H2 双向流等于 gRPC(本项目已否决)后,用户拍板「换 SSE,删掉 WebSocket」。 +- 服务端:hub.py 改为 SSE 订阅池(新增 sse_frame / sse_heartbeat / CLOSE_MESSAGE / + close_all);app.py 用 GET /api/stream(StreamingResponse)替换 /ws,删掉全部 WS 分支; + 生成器每秒空转一次以便发现停机、按 15s 发心跳注释。 +- 客户端:client.py 删掉 asyncio/websockets/Future 回执机制,改为 urllib 流式读 SSE + (mode: http/sse/poll),写操作统一 HTTP,新增 _underlying_socket() 用于立即断流。 +- 网页版 webui.py 改 EventSource + fetch POST;tools/ws_demo.py 换成 tools/sse_demo.py。 +- 测试:WS 用例改写为 SSE,新增 test_sse_stream_speaks_the_protocol(裸流验协议) + 与 test_writes_go_over_http,98 项全绿,耗时回到 5.2s。 +- 踩坑(重要):SSE 长连接会拖死优雅停机,三处一起修才干净: + 1) 停机路径 hub.close_all() 发 bye 让生成器自己 break; + 2) uvicorn Config 加 timeout_graceful_shutdown=5 兜底; + 3) 客户端 stop() 必须 shutdown 底层 socket——直接 response.close() 会等读线程的锁, + 最长卡到下一次心跳(15s)。实测:带长连接停机 0.2s 完成。 +- 文档:README 第 6 节重写为 SSE(含 curl/urllib/EventSource 示例)、架构图与结构表同步, + pyproject 描述与关键词更新,main.py/ai_player.py/__main__.py 帮助文案更新。 +- 新增 env 依赖说明:uvicorn[standard] 仍会捎带 websockets,但项目已不再使用。 + + +## 2026-09-19 ASGI 服务器从 uvicorn 换成 Hypercorn(拿到 HTTP/2) + +- 依赖:`uv add hypercorn` / `uv remove uvicorn`(hypercorn 0.18 自带 h11/h2/wsproto), + requirements.txt 重新导出,pyproject 依赖注释改写。 +- `__main__.py`:`hypercorn.asyncio.serve` + Config(bind/loglevel/accesslog/ + graceful_timeout=5,语义等同 uvicorn 的 timeout_graceful_shutdown);shutdown_trigger + 用 asyncio.Event,由 `handle.on_stop` 置位 —— 于是 POST /api/admin/shutdown 与 + Ctrl+C/SIGTERM/SIGBREAK 走完全相同的收尾路径;第二次信号强制退出(os._exit(130))。 +- `app.py` 新增 `as_asgi()`:Hypercorn 的 `hypercorn.typing.Scope` 与 + `starlette.types.Scope` 两个 TypedDict 互不兼容,pyright 会在 serve() 处报错,用返回 + Any 的桥函数收口(一处注释说明)。 +- **踩坑 1(真 bug,早于本次改动)**:`clear_pid_file` 里"文件还开着就 os.remove", + Windows 抛 PermissionError(WinError 32),又被 `except OSError` 吞掉 → + 每次正常退出都留下 stale pid 文件。改为先读完关句柄再删,并补 3 条回归测试。 +- **踩坑 2**:Windows 上 SO_REUSEADDR 让第二个实例能绑上已监听端口(还会抢走新连接), + Hypercorn 因此不会报"端口占用"。新增 `daemon.port_busy(host, port)` 公用函数 + ("能不能连上"而不是"能不能 bind"),入口启动前自检,占用则退 3 并提示换端口; + `DaemonManager.port_busy()` 改为委托它,并补了测试。 +- 测试:HAS_UVICORN→HAS_HYPERCORN;TestLiveServer 用 hypercorn serve 起后台线程、 + 靠 shutdown_trigger 收尾;新增 TestServerEntry(4 项)与 test_port_conflict_is_refused, + 共 103 项 / 5.2s。 +- 文档:README 第 6 节补 HTTP/2 说明、第 8 节补"lifespan 早于 bind"的 pid 细节、 + 第 12 节加"为什么是 Hypercorn(而不是 uvicorn)";测试清单按实际类重新计数。 + 顺手补掉上一轮 SSE 迁移漏掉的两处文案(tui.py 帮助页、farm/__init__.py 分层说明)。 +- 实测(真实子进程):h2c 用 h2 库裸说协议 200;h2c 上读 SSE 正常;写操作→推送 + 0.08~0.15s;挂着长连接停机 0.08s 且收到 bye;SIGBREAK 停机 0.07s; + daemon start/status/stop 全通(stop 1.12s);端口冲突退 3 并给出提示。 + +## 2026-09-19 ASGI 服务器换回 uvicorn(HTTP/2 由 zttp 提供) + +- 决策:用户要求「换回 uvicorn 并且安装 h2 支持并启用」。关键发现:uvicorn 0.53 自己不再 + 内置 h2,它的 HTTP/2 实现建立在 `zttp` 包上(`uvicorn/protocols/http/zttp_h2_impl.py`; + 另有 `auto_zttp_impl.AutoZttpProtocol` 按 ALPN / h2c 前导字节自动分流 HTTP/1.1 与 h2)。 + 启用方式 `Config(app, http="zttp", http2=True)`;没装 zttp 时 `Config.load()` 直接 raise。 +- 依赖:`uv remove hypercorn`(连带 h2/hpack/hyperframe/wsproto/priority); + `uv add "uvicorn[standard]" zttp`——HTTP/2 能力保留,[standard] 仍捎带 websockets(未使用)。 +- `__main__.py`:uvicorn.Server + `Config(http="zttp", http2=True, timeout_graceful_shutdown=5)`; + 信号接管改为**子类覆盖 `Server.handle_exit`**(0.53 没有 install_signal_handlers), + 不调 super()、不写 `_captured_signals`(避免退出时重放信号),第二次信号 `os._exit(130)`。 + 服务器子类写在 `main()` 内部,因为 uvicorn 是延迟导入的(离线玩法不该被迫装它)。 +- 修正一处历史错误注释:uvicorn 0.53 的启动顺序**也是**先 lifespan 再 bind 端口。 +- 测试:`HAS_HYPERCORN` → `HAS_UVICORN`;TestLiveServer 用 `uvicorn.Server.run()` 起后台线程 + (非主线程 → capture_signals 自动跳过,不会劫持测试进程的信号),停机走 `request_stop()`。 + 顺带删掉测试里已无用的 `import asyncio`。103 项全绿(6.0s)。 +- 实测(真子进程,非 mock):h2c `GET /api/state` → `:status 200`(用 h2 库裸说协议, + 本机 curl 没编进 HTTP/2);同一条 h2c 连接上读 SSE 收到 welcome;HTTP/1.1 写操作 → + 推送到达 0.10s;**挂着 2 条长连接停机 0.52s**(长连接收到 bye);SIGBREAK 停机 0.49s; + 端口冲突退 3 并提示换端口;守护进程 start/status/stop 全通;pid 文件每次都清理干净。 +- 文档:README 第 6 节与第 12 节重写(uvicorn + zttp 的启用法,附配置片段)、结构表、 + 测试清单同步;pyproject 依赖注释说明为什么需要 zttp;requirements.txt 重新导出。 +## 2026-09-19 修复 TUI 键盘监听(方向键/空格/Tab/ESC 全失效) + +- 症状:只有字母键和数字键有反应,方向键、空格(暂停)、Tab(切牲畜)、ESC(关菜单)全都没用。 +- 根因:`farm/keys.py` 的具名键返回**大写**(`"UP"`/`"SPACE"`/`"TAB"`/`"ESC"`), + 而 `farm/tui.py` 的处理表里写的是**小写**(`"up"`/`" "`/`"tab"`/`"esc"`), + `handler.get(key)` 永远取不到 → 静默无动作。字母键因为 `char.lower()` 恰好对得上才没事。 +- 修法(不是改一处字符串,而是把约定固化下来): + 1. `keys.py` 定一套小写具名键常量 + `NAMED_KEYS`,两条平台路径都返回这套名字; + 2. `tui.py` 全部改用 `keys.XXX` 引用,pyright 能直接查出拼写错误;删掉 `"tab\t"` 这个历史 hack; + 3. 顺手修掉 POSIX 路径的老毛病:一次 `os.read` 读到多个字节时只取第一个、其余静默丢弃 + (快打/粘贴会丢键);现在带缓冲区逐个返回,并能正确处理被拆成两次读到的转义序列与半个汉字; + 补上 `ESC [ 3 ~`(Delete)等序列;单按 ESC 与序列开头用 20ms 等待区分。 + 4. 新增 `tests/test_tui_keys.py`(19 项):逐个具名键驱动真实 TuiApp 做**行为**校验, + 外加"两端映射表必须落在同一套名字里"的约定校验——正是它抓出了 POSIX 缺 Delete 的不一致。 + 5. 读不到键盘时(管道/非 Win32 控制台,如 Git Bash 的 mintty)TUI 底部直接显示原因, + 不再让玩家对着界面干等;预览工具用 `show_keyboard_notice=False` 保持按键提示。 +- 门禁:ruff / pyright 零报错,测试 103 → **122 项**全过。 + +## 2026-09-19 一天改成 40 分钟 + 移除「休息」动作(纯放置) + +- 用户反馈两条:① 键盘监听不对(方向键/空格/Tab/ESC 全失效);② 15 秒一天太快。 + 问清目标后拍板:**一天 40 分钟**、**「休息」直接去掉**(只剩时间自然流动)。 +- 节奏:`DEFAULT_TICK_SECONDS` 15 → 2400;`MAX_TICK_SECONDS` 600 → 86400 + (旧上限会把 40 分钟夹回 10 分钟,这是必须先拆的硬门槛)。 +- 移除 sleep 的全部触点:`engine.sleep()`、`config.SLEEP_COOLDOWN`、`_last_sleep`、 + `client.RemoteFarm.sleep()`、`server/actions.py` 的注册、TUI `[N]休息` 与帮助文案、 + 网页版按钮与文本清单、`app.rules_text()` 给 AI 的说明、README、`main.py` 提示、 + `tools/sse_demo.py`(改成挑 welcome 里的第一个合法动作,不再写死动作名)。 +- 机器人:`rest()` → `wait_for_next_day(timeout)`,`run()` 新增 `wait_seconds`(默认 0 = 不干等, + 直接收工);顺带删掉再也不会出现的 cooldown 特例。CLI 加 `bot --wait`。 +- 显示:新增 `farm.models.format_duration()`,40 分钟显示成 `40:00` 而不是 `2399.6s` + (TUI 头部 / `main.py state` / 网页版 JS 同名函数)。 +- 顺手修:`FarmSession.__init__` 立刻设 `next_tick_at`,消除新建牧场后 ≤0.2 秒内 + 倒计时为 None(客户端会误显示"已暂停/手动模式")的空窗。 +- 测试:删 `test_sleep_cooldown`,加 tick 夹取/默认值/`sleep` 已移除、`format_duration` + 分档、`tests/test_bot.py`(等待策略)。103 → **131 项**全绿,ruff/pyright 零报错。 +- 端到端实测(真服务器 + 真 CLI):动作表只剩 10 个(无 sleep)、`POST sleep` 返回 + `unknown_action`、`seconds_to_tick=2400.0`、`main.py state` 显示「39:59 后进入下一天」。 + + +## 2026-09-19 删除全部测试套件(tests/ 与所有引用一并清理) + +- 用户要求「删除全部 test」;确认后选:删目录 + 清理全部引用、**不留备份**。 +- 删除 `tests/`:test_engine / test_server / test_tui_keys / test_bot(共 131 项)+ `tests/_tmp/`。 +- 引用清理(一次性幂等补丁脚本,跑完即删): + pyproject 的 ruff `src`、per-file-ignores `"tests/*"`、pyright `include`/`exclude`; + `.gitignore` 的 `tests/_tmp/`;`main.py` 帮助里的测试命令;`farm/keys.py` docstring 的引用; + README 目录树的三行 + 整节「10. 测试」(后续章节 11/12 顺延为 10/11)。 +- 依赖:`uv remove --dev httpx`(连带 httpcore / certifi)——它只是 FastAPI TestClient 的底座, + 没有测试就没人用它;uv.lock 同步。requirements.txt 不动(本来就只导出运行依赖)。 +- 门禁:ruff check / format 通过,pyright 0 错误(25 个文件)。 +- 代价与风险:未跟踪的 test_tui_keys.py / test_bot.py 就此永久丢失(未提交,用户明确选择不备份); + 键盘约定的保障从"行为测试"退化为"改 keys.py 时人工核对 tui.py 的引用"。 + README 第 307 行提到 httpx 是 HTTP/2 客户端写法示例,与依赖无关,保留。 + +## 2026-09-19 重建测试套件(500 项)+ 修两处 client 隐患 + +- 用户要求「完成测试代码」;确认后选:**完整重建**覆盖,但**只写测试文件** + (不恢复上一轮剥掉的工程配置:tests 仍不进 ruff `src` / pyright `include` / per-file-ignores, + `httpx` 保持移除)。 +- 新增 6 个文件、**500 项**:test_engine 110 / test_actions 35 / test_server 131(skip 35) / + test_tui_keys 75 / test_bot 36 / test_client 113。 +- skip-guard 模式:`try: import fastapi/httpx/uvicorn ... HAS_X = True except: False` + + `@unittest.skipUnless`。没装 httpx → `TestClient` 与真机 `LiveServer` 那 35 项自动跳过, + 其余 465 项照跑;所以 `uv run python -m unittest discover -s tests` 是**真绿**而不是靠跳过伪装。 +- `test_server.LiveServerTestCase` 起真 in-process uvicorn(`http="zttp", http2=True`,绑自由端口), + 用 `RemoteFarm` + 裸 SSE 读取做端到端;`test_client.HttpTransportTestCase` 起**纯标准库** + `http.server` 回环桩(不依赖第三方),顺带证明 `net.open_url` 的回环直连真的生效。 +- 顺手修 `farm/client.py` 两处(是测试逼出来的真隐患,不是测试写错): + 1. `_normalize()` 原来**先 `rstrip("/")` 再补 scheme**,于是 `"http://"` → `"http:"` → + 再拼成 `"http://http:"`;地址没有主机名 → `is_loopback(None) == False` → 请求走系统代理 → + 报一个和真实原因无关的 502。改成**先补 scheme 再削尾部斜杠**,畸形端点 + (`''` / `' '` / `'//'` / `'http://'`)统一回落 `DEFAULT_ENDPOINT`。 + 2. `StateView.__init__` 预置 `plots` / `animals` 为空列表:原先 `apply({})` 提前返回, + 空视图**根本没有这两个属性**,`autostart=False` 或服务器连不上时读 `state.plots` + 直接 AttributeError(本该显示一片空农田)。 +- 写测试踩到的坑(记下来省下次): + - **bytes 字面量不能含非 ASCII**:`b"不是 JSON"` 直接 SyntaxError, + 必须 `"...".encode()`(ruff 还会顺手提示 UP012 去掉多余的 `"utf-8"`)。 + - `OpenerDirector.add_handler` **只在"至少挂上一个钩子"时才把 handler 记进 `handlers`**; + `ProxyHandler({})` 一个 `*_open` 都不注册 → 它自己反而不在 `handlers` 里。 + 这**正是**回环直连生效的机制,不能想当然断言"handlers 里应该有个 ProxyHandler"。 +- 门禁:`ruff format .` 只动了 6 个测试文件(源码本来就干净)→ 但 `ruff check .` 一开始报 + 9 条(全在 `tests/`,因为没有 per-file-ignores)。既然不加配置,就把**测试文件本身改干净** + (SIM117 合并 with / RUF012 用 `ClassVar` / RUF100 删无效 noqa / UP012 / F401), + 现在 `ruff check .` **All checks passed** + pyright **0 errors** + format 31 文件 unchanged。 +- 收尾:6 个测试文件全部 LF(CRLF=0)。 + +## 2026-09-19 代码优化:结构拆分 + 热路径 + 消重(benchmark-first) + +- 用户要求「优化代码」,勾选三项:结构拆分 / 性能热路径 / 消除重复逻辑(明确不要"只出报告")。 +- **先量后改**:新增 tools/bench.py(ASGI 直调,不走 httpx;warmup + best-of-K), + 并给 to_dict / snapshot / brief_source / net_worth 装调用计数器。它同时是**守卫**: + 以后谁再把 brief 路径改回 snapshot(),计数会立刻变红。 +- 热路径根因:brief()/summary()/动作回执本来只要 ~17 个标量,却调 session.snapshot() + 做**整局序列化**;GameState.to_dict 内部还重复算 3 次 net_worth(每次访问都重算属性)。 +- 改法: + 1. engine.brief_source() —— 只读标量的新方法(不碰 snapshot);session.summary()、 + app.brief() 改用它,BRIEF_KEYS + 新增 BRIEF_TODO_KEYS 白名单派生。 + 2. models.next_goal_after(index) 提为模块级;to_dict 里 net_worth 只算 1 次,复用于 + net_worth / goal_index / next_goal(删掉 next_goal property)。 + 3. 结构:create_app 475 行 → 32 行(抽出 _Services dataclass 持 8 件接线 + 8 个模块级 + _register_* 函数,内嵌 lifespan 生成器变成 _Services.lifespan 方法); + main.build_parser 拆 server/client 两组;engine.available_actions 拆 + _plot_actions/_animal_actions/_investment_actions;__main__.main 160 → 7 行 + (_resolve_save_dir / _start_daemon / _print_startup_banner / _ensure_port_free / + _run_foreground,uvicorn 那段含内嵌 FarmServer 逐字平移)。 + 4. 消重:engine 的"等级门槛"规则重复 5 处 → _is_locked/_require_level/_newly_unlocked; + hub.broadcast/broadcast_all/close_all 收敛到 _push_many;daemon.probe/world → + _get_json;main.py 的 _add_endpoint_args / _leaderboard_lines;app.py SSE 的 + notice(text, exclude) 让"来了/走了"共用一条路径。 +- 结果(16 地 + 8 牲畜终局,5 轮最快):brief 63.6→15.6µs(-76%)、summary 60.2→14.0µs(-77%)、 + leaderboard(32) 1981.7→454.8µs(-78%)、snapshot 57.3→45.0µs(-21%)、 + GET /api/world 193.6→132.3µs(-31%)、POST full=1 464.7→392.9µs(-16%)、 + GET /api/actions 398.3→335.1µs(-16%)。动作回执的序列化次数 to_dict 2→1、 + snapshot 2→1、net_worth 6→1。>60 行的函数 15 个 → 8 个。 +- 门禁:ruff format/check 全绿、pyright 0 错误、**500 项测试 OK(skip 35)**。 + bench 的 ASGI 直调**不跑 FastAPI lifespan**,所以 lifespan 的活改造用 + test_server 里的真 in-process uvicorn(LiveServerTestCase)单独验。 +- 临时补丁脚本一律 read_bytes/write_bytes 保 LF,跑完即删(本次 4 个:_splice_app / + _dedup_engine / _dedup_main / _split_entry)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 82560c5..7478b76 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -2,7 +2,7 @@ ## 是什么 终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里, -TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(服务端不内置 AI)。 +TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。 ## 运行方式(v2.0 起由 uv 统一管理) - **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。 @@ -10,28 +10,104 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用( - 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 ,文档 /docs - 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo` - AI/脚本入口:`main.py actions | do <动作> '' | batch | state --json | watch | bot` -- 测试:`uv run python -m unittest discover -s tests`(97 项) +- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。 + 但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、 + 不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 —— + 于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。 + 代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。 + 覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) / + `test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。 - 质量门禁(改动后都应跑一遍): - `uv run ruff check .` / `uv run ruff format .` / `uv run pyright` + `uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests` + (2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`) - 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。 README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。 +## 玩法节奏(2026-09-19 起) +- **一天 40 分钟**:`config.DEFAULT_TICK_SECONDS = 2400`,`MAX_TICK_SECONDS = 86400` + (旧上限 600 秒会把 40 分钟悄悄夹回去,已放宽)。`--tick` 可调,调试用 `--tick 3`。 +- **没有「手动推进一天」**:`engine.sleep()` / `SLEEP_COOLDOWN` / `/api/sleep` / + TUI 的 `[N]休息` / 网页版按钮**全部移除**,时间只能自然流动。 + 机器人改为 `RuleBot.wait_for_next_day(timeout)`,CLI `bot --wait` 控制等多久(0 = 直接收工)。 + 再加任何“快进一天”的入口前先想清楚:它会让慢时钟彻底失效。 +- 时长显示统一走 `farm.models.format_duration()`(2400 秒 → `40:00`), + TUI 头部倒计时 / `main.py state` / 网页版(JS 里同名函数)都用它,别再写 `{x}s`。 +- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s) + 之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。 + ## 架构约定(改动时必须遵守) - **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket; `farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。 - **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义, - 这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。 + 这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。两条容易踩的: + `StateView.apply()` 里"带 `plots` = 完整快照(整体替换)、不带 = 精简状态(只合并字段)"—— + 搞反了播一次种就清空整片农田;`RemoteFarm._normalize()` 必须**先补 scheme 再削尾部斜杠**, + 顺序反了 `"http://"` 会被削成 `"http:"`,地址没有主机名 → 请求溜到系统代理 → 报一个 + 和真实原因无关的 502。空视图(`StateView()`)也要预置 `plots`/`animals` 为空列表, + 否则连不上服务器时读 `state.plots` 直接 AttributeError。 +- **按键名统一小写**:`farm/keys.py` 是按键名的唯一来源,具名键全小写 + (`UP`/`SPACE`/`TAB`/`ESC`/`CTRL_C` … 常量值即小写字符串),Windows 与 POSIX + 两条路径必须返回**完全一样**的名字。`tui.py` 一律用 `keys.XXX` 引用,**不要手写 + 字符串字面量**:历史 bug 就是 Windows 侧产大写 `"UP"`、TUI 里写小写 `"up"`, + 方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。 +动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。 + 读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。 - **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟; 时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。 - **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理, 不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。 -- **实时通道**:WebSocket(`/ws`)是唯一实时通道,推送节流 250ms,消息带自增 `seq`。 - 写操作优先走 WS(带 `id` 回执与 `snapshot`),只有 ws_closed/timeout/network - 才退回 HTTP 重试;**业务错误必须原样抛出,不能重试**。 +- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送 + (welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发 + `: keep-alive` 注释心跳。**写操作一律走 HTTP POST /api/actions**,不再有 WS 回执。 + 停机必须 `hub.close_all()`(否则长连接拖住 ASGI 服务器的优雅退出)+ 配置 + `timeout_graceful_shutdown` 兜底;客户端 stop() 要 shutdown 底层 socket, + 光 close() 会等读线程的锁(最长等到下一次心跳)。 +- **ASGI 服务器(v2.3 起为 uvicorn + zttp 的 HTTP/2)**:`farm/server/__main__.py` 用 + `uvicorn.Config(http="zttp", http2=True)` + `uvicorn.Server`。 + **同一端口同时说 HTTP/1.1 与 HTTP/2**(TLS 走 ALPN;明文走 h2c prior-knowledge; + 分流由 `AutoZttpProtocol` 完成;浏览器只在 https 下用 h2)。 + 关键:uvicorn 0.53 **自己不再内置 h2**,HTTP/2 必须装 `zttp` 并显式选 `http="zttp"`, + 否则 `Config.load()` 直接 raise(错误信息里就写着 pip install zttp)。 + 两条停机路径汇到同一处:`POST /api/admin/shutdown` 与 Ctrl+C / SIGTERM / SIGBREAK + 都调 `handle.request_stop()`。信号接管靠**子类覆盖 `Server.handle_exit`** + (0.53 的机制是 `capture_signals()` + `handle_exit`,没有 install_signal_handlers 可覆盖); + 覆盖后不调 super()、也不写 `_captured_signals`,退出时就不会重放信号。第二次信号 = 强制退出。 + `farm/server/app.py` 的 `as_asgi()` 是给 pyright 的类型桥:uvicorn 的 + `uvicorn._types.Scope` 与 `starlette.types.Scope` 两个 TypedDict 互不兼容。 + 另:uvicorn 的启动顺序**也是**先 lifespan、后 bind 端口(旧注释曾写成"相反",已更正)。 +- **端口自检**:uvicorn 会设 `SO_REUSEADDR`,Windows 上第二个实例能 + "绑上"已被监听的端口并抢走新连接,所以入口启动前用 `daemon.port_busy()` + (判断"能不能连上",而不是"能不能 bind")自检,被占用就退 3 并提示换端口。 + +## 序列化与性能约定(2026-09-19 优化后固化) +- **轻量路径不许做完整序列化**:只要标量时(动作回执 / `session.summary()` / `app.brief()` / + 排行榜单行)一律走 `engine.brief_source()`,**不要**调 `engine.snapshot()` 或 + `state.to_dict()` 再挑字段——那是整局序列化(16 地 + 8 牲畜约 45µs,轻量路径只需 15µs)。 + 完整快照只在 `GET /api/state`、SSE `state` 消息、`POST /api/actions?full=1` 用。 + `app.py` 的 `BRIEF_KEYS` / `BRIEF_TODO_KEYS` 是这两类字段的白名单,加字段要同步。 +- `GameState.to_dict()` 里 `net_worth` **只算一次**再复用(`net_worth` / `goal_index` / + `next_goal` 三处都靠它);`net_worth` 是每次访问都重算的属性(约 5µs),别在循环里反复读。 + 「下一个目标」用模块级 `models.next_goal_after(index)`,不要再加 `next_goal` property。 +- **`tools/bench.py` 是性能守卫**,不只是一次性脚本:它给 `to_dict` / `snapshot` / + `brief_source` / `net_worth` 装了调用计数器,谁把轻量路径改回 `snapshot()` 会立刻计数变红。 + 它用 ASGI 直调(不走 httpx),**不跑 FastAPI lifespan** —— 动了 lifespan 要用 + `tests/test_server.py::LiveServerTestCase`(真 in-process uvicorn)复验。 +- **`farm/server/app.py` 的结构**:`create_app()` 只做「建 `_Services` + 依次调 + `_register_*`」,所有路由/生命周期都住在模块级 `_register_*` 函数里(曾经是一个 475 行的 + 内嵌闭包大函数)。加路由就加/改对应的 `_register_*`,别把逻辑塞回 `create_app`。 +- **函数长度参考**:>60 行的函数应视为信号(重构前 15 个,现在 8 个,最长 + `__main__._run_foreground` 131 行,其中大半是"必须内嵌的 uvicorn 停机子类")。 +- **重复的等级门槛**统一走 `engine._is_locked/_require_level/_newly_unlocked`, + 不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。 ## 环境禁忌(本机特有) +- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32); + 若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。 + 一律"先读完 → 关掉句柄 → 再删"。 - Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep` 缺失),**不要用管道**,会整条命令失败; - 查看/删除文件一律写 Python 脚本。 + 查看/删除文件一律写 Python 脚本。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1` + 必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。 + PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。 - 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的 `open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。 - Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`), @@ -52,8 +128,8 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用( - 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。 - 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。 - 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。 -- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests` +- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright` ## 相关文档 -- 项目入口文档:`README.md`(接口表、WS 协议、边界处理、设计取舍全在里面) -- 当天工作记录:`.workbuddy/memory/2026-09-18.md` +- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面) +- 当天工作记录:`.workbuddy/memory/2026-09-19.md` diff --git a/README.md b/README.md index 3dea5af..6ef2b15 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,20 @@ * 游戏逻辑跑在**服务端进程**里,可以后台常驻; * 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩; * 每个玩家一个独立牧场(存档隔离),互相能看到排行榜; -* 同时开放 **HTTP 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏—— +* 同时开放 **HTTP 接口 + SSE 实时推送**,AI / 脚本 / 机器人可以和人玩同一局游戏—— 服务端**不内置任何 AI**,它只提供协议。 ``` ┌──────────────┐ HTTP /api/* ┌────────────────────────┐ │ AI / 脚本 │ ───────────────────────▶ │ │ - │ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │ - └──────────────┘ JSON 响应 │ (可后台常驻) │ - ┌──────────────┐ WebSocket /ws │ │ - │ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │ - └──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │ - ┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │ - │ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │ - └──────────────┘ └────────────────────────┘ + │ 机器人 │ ◀── JSON 响应 + SSE ──── │ FastAPI 服务器 │ + └──────────────┘ │ (可后台常驻) │ + ┌──────────────┐ HTTP /api/* │ │ + │ 终端界面 TUI │ ───────────────────────▶ │ FarmClock 统一时钟 │ + └──────────────┘ ◀── SSE /api/stream ──── │ SessionManager 多牧场 │ + ┌──────────────┐ HTTP /api/* │ ConnectionHub 推送 │ + │ 浏览器客户端 │ ───────────────────────▶ │ 存档(每牧场一份) │ + └──────────────┘ ◀── SSE /api/stream ──── └────────────────────────┘ │ farm.engine 纯游戏规则 ``` @@ -47,7 +47,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 ``` > **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`; -> 依赖用 `pip install fastapi 'uvicorn[standard]'` 装。两种方式行为完全一致。 +> 依赖用 `pip install 'uvicorn[standard]' fastapi zttp` 装。两种方式行为完全一致。 --- @@ -55,8 +55,8 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 | 系统 | 规则 | | --- | --- | -| **时间** | 每个牧场每 15 秒自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | -| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 `sleep`(2 秒冷却) | +| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | +| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天——时间只能自然流动,没有手动快进 | | **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 | | **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 | | **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** | @@ -67,6 +67,11 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。 +> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。 +> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。 +> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时)。想调试就把节奏调快: +> `uv run main.py play --tick 3`(一天 3 秒)。 + --- ## 3. 终端界面 @@ -76,12 +81,12 @@ uv run main.py solo # 本地引擎 + 终端界面,不开 ``` [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料 - [N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出 + [空格]暂停 [S]存档 [R]重置 [Q]退出 ``` 界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。 -**TUI 是纯客户端**:它通过 WebSocket 订阅状态、通过 WebSocket/HTTP 提交动作, +**TUI 是纯客户端**:它通过 SSE 长连接订阅状态、通过 HTTP 提交动作, 自己不持有任何游戏状态。所以: * 服务端可以在另一台机器上,`uv run main.py play --url http://10.0.0.5:8765 --farm alice`; @@ -159,9 +164,10 @@ POST /api/actions → {"action":"<上一步的 action>","params":{. | GET | `/api/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 | | GET | `/api/actions` | 当前合法动作(AI 推荐入口) | | GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) | +| GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) | | POST | `/api/actions` | **通用动作入口** | | POST | `/api/batch` | 批量动作(一次最多 50 个) | -| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 13 个 | +| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 10 个 | | POST | `/api/admin/save` | 全部存档 | | POST | `/api/admin/shutdown` | 优雅停机 | | GET | `/docs` | 自动生成的 Swagger 文档 | @@ -171,9 +177,14 @@ POST /api/actions → {"action":"<上一步的 action>","params":{. ``` plant(plot,crop) water(plot) harvest(plot) harvest_all() buy_animal(kind) feed(animal_id,quantity?) collect(animal_id) -buy_feed(quantity?) buy_plot() sleep() pause(paused?) save() reset() +buy_feed(quantity?) buy_plot() pause(paused?) ``` +> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理, +> 否则 `engine.save()` 会把存档写进默认路径、覆盖别人的档。仍然可以用 +> `POST /api/actions {"action":"save"}` 调用(服务端会话层拦截执行), +> 只是没有 `/api/save` 便捷路由、也不出现在动作表文档里。 + ### 5.3 请求示例 ```bash @@ -208,7 +219,7 @@ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ... "state": { "day": 3, "gold": 91, "level": 1, "ap": 6, "feed": 5, "ripe_plots": [], "hungry_animals": [], "collectable_animals": [], - "net_worth": 401.0, "seconds_to_tick": 11.4, "paused": false, "online": 2 + "net_worth": 401.0, "seconds_to_tick": 2387.6, "paused": false, "online": 2 }, "log": [{"seq": 41, "day": 3, "kind": "action", "text": "在 1 号地播下小麦种子"}] } @@ -222,59 +233,81 @@ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ... --- -## 6. WebSocket 实时通道 +## 6. SSE 实时推送 -> 为什么是 WebSocket 而不是 SSE?SSE 只能服务端 → 客户端单向推流, -> 而这个游戏的上行(播种、喂食)和下行(状态、事件)是同一件业务的两半: -> 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。 -> WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。 +> 为什么是 SSE?客户端要读的只有"状态变了",这是**服务器 → 客户端单向**的下行; +> 而写操作(播种、喂食)本来就是普通 HTTP 请求。SSE 正好是"一条 HTTP 长连接单向推流", +> 于是上下行各用最自然的协议,不必为了一条推送引入双向握手、消息帧与心跳管理。 +> 顺带换来的好处:`curl` 就能读、浏览器 `EventSource` 自带断线重连、 +> 客户端只靠标准库 `urllib` 就能订阅(一个第三方包都不用装)。 连接: ``` -ws://127.0.0.1:8765/ws?farm=alice&token=<可选>&name=<昵称> +http://127.0.0.1:8765/api/stream?farm=alice&token=<可选>&name=<昵称> ``` -### 客户端 → 服务器 +响应是标准的 `text/event-stream`,每帧就是一行 JSON 加一个空行: -| 消息 | 说明 | -| --- | --- | -| `{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}}` | 执行动作,回执带同一个 `id` | -| `{"type":"sync","id":2}` | 主动拉一次完整状态 + 合法动作 | -| `{"type":"help","id":3}` | 拉规则与动作说明 | -| `{"type":"ping"}` | 心跳,回 `pong` | +``` +data: {"type":"welcome","farm":"alice","state":{…},"catalog":{…},"actions":[…],"online":1} + +data: {"type":"state","seq":42,"farm":"alice","state":{…},"log":[…],"online":2} + +: keep-alive +``` ### 服务器 → 客户端 -| 消息 | 触发时机 | +| 类型 | 触发时机 | | --- | --- | -| `{"type":"welcome", "state":…, "catalog":…, "actions":…, "log":…, "online":1}` | 连接建立 | -| `{"type":"state", "seq":…, "state":…, "log":[…]}` | 状态变化时推送(**节流合并到 4 次/秒**) | -| `{"type":"day", "day":5, "events":[…]}` | 进入新的一天 | -| `{"type":"result", "id":1, "ok":true, "message":…, "snapshot":…}` | 动作回执 | -| `{"type":"notice", "text":"web 来到了牧场(在线 2)"}` | 有人加入 / 离开 | -| `{"type":"error", "error":"bad_json", "message":…}` | 协议错误 | +| `welcome` | 连接建立:一次给全状态、目录、合法动作、日志、在线人数 | +| `state` | 状态变化时推送(**节流合并到 4 次/秒**),带自增 `seq` | +| `day` | 进入新的一天,附当日事件 | +| `notice` | 有人加入 / 离开牧场 | +| `bye` | 服务器正在停机(收到就可以收摊了) | +| `error` | 通道层错误 | -用 Python 直接玩(不依赖本项目代码): +心跳:无消息时每 15 秒发一行 `: keep-alive`(SSE 注释行),既防中间代理掐掉空闲连接, +也让客户端能区分"只是没消息"和"连接真的断了"。 + +### 客户端 → 服务器 + +写操作**不占用**这条通道,直接 `POST /api/actions` 即可(见上一节)。 +两条路改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。 + +用 Python 直接读(只用标准库,不依赖本项目代码): ```python -import asyncio, json, websockets +import json, urllib.request -async def main(): - async with websockets.connect("ws://127.0.0.1:8765/ws?farm=demo") as ws: - welcome = json.loads(await ws.recv()) - print("第", welcome["state"]["day"], "天") - await ws.send(json.dumps({"type": "action", "id": 1, "action": "harvest_all"})) - async for raw in ws: - msg = json.loads(raw) - if msg["type"] == "result": - print(msg["message"]) - elif msg["type"] == "state": - print("金币", msg["state"]["gold"]) - -asyncio.run(main()) +request = urllib.request.Request( + "http://127.0.0.1:8765/api/stream?farm=demo", + headers={"Accept": "text/event-stream"}, +) +with urllib.request.urlopen(request, timeout=45) as response: + for raw in response: # 一行一行流式读,不等响应结束 + line = raw.decode("utf-8").strip() + if not line.startswith("data:"): + continue + message = json.loads(line[5:]) + if message["type"] == "welcome": + print("第", message["state"]["day"], "天") ``` +浏览器里就是一行: + +```js +const es = new EventSource("/api/stream?farm=demo"); +es.onmessage = (ev) => console.log(JSON.parse(ev.data)); +``` + +服务端跑在 uvicorn 上并打开了 HTTP/2(`http="zttp"` + `http2=True`),**同一个端口** +同时说 HTTP/1.1 与 HTTP/2:支持 h2c 的客户端(`curl --http2-prior-knowledge`、 +`httpx(http2=True)` 之类)可以直接走 HTTP/2,其余客户端继续走 HTTP/1.1,不需要任何配置, +SSE 长连接在两种协议下都照常工作。 +浏览器只在 **https** 下才会协商 h2(详见第 12 节)。 + --- ## 7. 多人模型 @@ -315,9 +348,12 @@ uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个 > **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件, > 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**, > 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。 -> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程在端口绑定成功(lifespan 启动)后 -> 才写入自己的 `os.getpid()`,退出时再删掉。`stop` 则优先走 HTTP 优雅停机, -> 拿不到 pid 也能停下来。 +> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程启动时写入自己的 `os.getpid()`, +> 退出时再删掉。`stop` 则优先走 HTTP 优雅停机,拿不到 pid 也能停下来。 +> +> 细节:uvicorn 的 lifespan 启动**早于**端口绑定,所以 pid 文件会比"端口可连"早半步出现。 +> 万一端口被占用、进程随即退出,收尾的 `finally` 会把这个 pid 文件删掉, +> 不会留下幽灵进程让 `stop` 误判。 --- @@ -338,62 +374,28 @@ farm/ keys.py 跨平台按键读取 net.py 回环请求绕过系统代理的小工具 tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦) - client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP + WS) + client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP 写 + SSE 读) bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略) server/ - app.py FastAPI 应用:REST + WebSocket + 多牧场调度 + app.py FastAPI 应用:REST + SSE + 多牧场调度 actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验 session.py 牧场会话与统一时钟 - hub.py WebSocket 连接池与状态推送(线程 → 事件循环的桥) + hub.py SSE 订阅池与状态推送(线程 → 事件循环的桥) daemon.py 守护进程:start / stop / status / 日志 / pid - webui.py 内嵌网页客户端(单页,原生 JS + WebSocket) - __main__.py uv run python -m farm.server(前台运行) + webui.py 内嵌网页客户端(单页,原生 JS + EventSource) + __main__.py uv run python -m farm.server(uvicorn 前台运行 + HTTP/2) tools/ preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器) - ws_demo.py 最小 WebSocket 客户端,验证协议 -tests/ - test_engine.py 引擎规则与存档(单机层) - test_server.py 动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端 + sse_demo.py 最小 SSE 客户端(只用标准库),验证协议 ``` 分层原则:**规则不认识网络,网络不认识终端。** `farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。 -所以三端共享完全一致的裁判逻辑,也能各自单独测试。 +所以三端共享完全一致的裁判逻辑。 --- -## 10. 测试 - -```bash -uv run python -m unittest discover -s tests -v # 全部(97 项) -uv run python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI -uv run python -m unittest tests.test_server.TestLiveServer # 真起服务器 + 真 WebSocket -``` - -覆盖范围: - -* 引擎规则、边界与存档兼容(45 项) -* 动作表参数校验:缺参 / 多参 / 类型错 / 未知动作(10 项) -* 牧场 ID 校验:路径穿越、超长、非法字符(3 项) -* 多牧场隔离、排行榜、空闲回收、暂停保护、容量上限(6 项) -* REST 语义:状态、动作、批量、日志增量、鉴权 401、坏 JSON、`X-Farm-Id` 头(20 项) -* **真网络端到端**:真 uvicorn + 真客户端,验证 HTTP 读写、两个客户端共享一个牧场、 - 动作确实走了 WebSocket、WS 断开后自动退回 HTTP、机器人只靠接口也能经营(7 项) - -另外做过一轮真实环境实测(`--daemon` 起服务,再用命令行/脚本连上去): - -``` -serve --daemon → status 显示正确 pid → actions 列出 13 个候选动作 -→ do plant / buy_animal / feed → batch 部分失败不影响其它动作 -→ 排行榜出现两个互相隔离的牧场(alice 25 金币 / bob 120 金币) -→ WebSocket 客户端依次收到 welcome / state / result / day 四类消息 -→ 机器人从 120 金币经营到 197 金币、净资产 276(0 次失败) -→ stop 优雅停机,所有牧场写入 saves/*.json -``` - ---- - -## 11. 工程化:uv + ruff + pyright +## 10. 工程化:uv + ruff + pyright 依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里—— 单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果, @@ -449,7 +451,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是 --- -## 12. 边界处理与设计取舍 +## 11. 边界处理与设计取舍 **输入校验**(唯一入口 `server/actions.py`): @@ -463,7 +465,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是 **并发与容错**: * 引擎内 `RLock` 串行化同一牧场,多牧场并行; -* WebSocket 每条连接一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端; +* SSE 每条订阅一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端; * 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧; * 引擎调用统一丢进线程池,不阻塞事件循环; * 单条连接/单个牧场出错不影响其它牧场; @@ -475,13 +477,35 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是 `urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上", 报的还是 `502 upstream connect failed` 这种看不出根因的错误。 所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连, -目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,`websockets` 默认不读代理变量。) +目标是别的机器才尊重环境里的代理设置。(SSE 长连接也走这条路,所以本机连本机不会被代理截走。) **测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器), -`tools/ws_demo.py` 是一个最小 WebSocket 客户端,用来确认"动作上行、状态下行"两条路都通。 +`tools/sse_demo.py` 是一个最小 SSE 客户端(只用标准库),用来确认"状态主动下行、动作走 HTTP 上行"两条路都通。 + +**服务器是 uvicorn,HTTP/2 由 `zttp` 提供**:HTTP/2 的收益在多路复用——网页版开局会并发拉 +`state` / `catalog` / `world` / `log`,SSE 长连接又长期占着一条连接;HTTP/2 下这些请求复用 +同一条 TCP、头部还会压缩,互不排队。 + +uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在 `zttp` 这个包上,所以启用方式就是在配置里 +多写两项: + +```python +UvicornConfig(app, http="zttp", http2=True) +``` + +`http="zttp"` 选出 `AutoZttpProtocol`,它会在**同一个端口**上按 ALPN(TLS)或 h2c 前导字节 +(明文 prior-knowledge)自动分流 HTTP/1.1 与 HTTP/2——老客户端照旧走 HTTP/1.1, +应用代码一行都不用改。 + +两点需要留意: + +* 浏览器只在 **https** 下才会用 h2,所以网页版想真正吃到 HTTP/2 得配上 TLS 证书 + (明文端口上的 h2c 是 `curl` / 脚本客户端的事); +* `timeout_graceful_shutdown=5` 只是兜底:停机仍然先靠 `hub.close_all()` 让每条 SSE 流 + 自己收尾,这个值只管"客户端赖着不走"的情况。 **为什么放弃 gRPC**:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON; gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。 -HTTP + WebSocket 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。 +HTTP + SSE 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。 *(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)* diff --git a/ai_player.py b/ai_player.py index bf7338f..e348a41 100644 --- a/ai_player.py +++ b/ai_player.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""外挂式 AI/机器人玩家——完全通过 HTTP + WebSocket 接口参与游戏。 +"""外挂式 AI/机器人玩家——完全通过 HTTP + SSE 接口参与游戏。 这个脚本**不**导入任何游戏规则代码,只用接口。换句话说: 它对服务器来说和一个远程 LLM Agent 没有任何区别。 @@ -33,7 +33,7 @@ from farm.engine import GameError def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="uv run ai_player.py", - description="通过 HTTP/WebSocket 接口参与 SimpleFarm 的机器人玩家", + description="通过 HTTP + SSE 接口参与 SimpleFarm 的机器人玩家", ) parser.add_argument("--url", default="http://127.0.0.1:8765", help="服务器地址") parser.add_argument("--farm", default="ai-1", help="经营哪个牧场,默认 ai-1") diff --git a/farm/__init__.py b/farm/__init__.py index 6d00253..f860d9a 100644 --- a/farm/__init__.py +++ b/farm/__init__.py @@ -5,7 +5,7 @@ * :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config` 纯游戏规则与数据,不依赖终端也不依赖网络。 * :mod:`farm.server` - 服务器侧:FastAPI 应用、动作表、多牧场会话、WebSocket 推送、守护进程。 + 服务器侧:FastAPI 应用、动作表、多牧场会话、SSE 推送、守护进程。 * :mod:`farm.client` 客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。 * :mod:`farm.tui` diff --git a/farm/bot.py b/farm/bot.py index efb9937..405586c 100644 --- a/farm/bot.py +++ b/farm/bot.py @@ -12,6 +12,10 @@ 它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑—— 换个 ``--url`` 就是一个独立的外挂玩家。 + +节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,所以行动点 +用完之后它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds`` +就是"最多等多久",默认 0——一天 40 分钟,干等不划算。 """ from __future__ import annotations @@ -22,6 +26,7 @@ import time from typing import Any from .engine import GameError +from .models import format_duration class RuleBot: @@ -125,12 +130,6 @@ class RuleBot: result = self.client._action(name, params) # 走通用动作入口 except GameError as exc: self.failures += 1 - if exc.code == "cooldown": - # 休息有冷却;这是正常节奏,不该记成失败 - self.failures -= 1 - self.say(f"冷却中… {exc.message}") - time.sleep(1.0) - return None self.say(f"✗ {name} 失败({exc.code}):{exc.message}") return None self.actions_executed += 1 @@ -140,24 +139,45 @@ class RuleBot: time.sleep(self.pause) return name - def rest(self) -> bool: - """行动点用完时推进一天。""" - try: - result = self.client.sleep() - except GameError as exc: - if exc.code == "cooldown": - time.sleep(1.0) - return True - self.say(f"✗ sleep 失败:{exc.message}") + def wait_for_next_day(self, timeout: float) -> bool: + """没得可做时等时间自己流动到下一天。 + + 返回 True 表示等到了新的一天;``timeout`` 为 0 表示不等待、直接收工 + (机器人本来就该"把能做的做完就走",不像真人会挂机守着)。 + """ + state = self.client.snapshot() + if state.get("paused"): + self.say("— 时间被暂停了,再等也不会有下一天,先收工。") return False - self.say(f"— {result.get('message', '进入下一天')}") - return True + if timeout <= 0: + left = format_duration(state.get("seconds_to_tick")) + self.say(f"— 暂时没事可做,距离下一天还有 {left},先收工。") + return False + + start_day = state.get("day") + self.say(f"— 等时间流动(最多 {timeout:.0f} 秒)…") + deadline = time.time() + timeout + while not self._stop.is_set() and time.time() < deadline: + time.sleep(0.25) + day = self.client.snapshot().get("day") + if day != start_day: + self.say(f"— 天亮了:第 {day} 天") + return True + self.say("— 没等到下一天,先收工。") + return False # ------------------------------------------------------------ 主循环 - def run(self, rounds: int = 0, max_minutes: float = 0.0, idle_limit: int = 8) -> dict[str, Any]: + def run( + self, + rounds: int = 0, + max_minutes: float = 0.0, + idle_limit: int = 8, + wait_seconds: float = 0.0, + ) -> dict[str, Any]: """跑机器人。 - ``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。 + ``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间; + ``wait_seconds`` 是"没事可做时最多等多久"(等下一天),0 表示直接收工。 """ deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None start_day = self.client.snapshot().get("day", 1) @@ -175,16 +195,18 @@ class RuleBot: state = self.client.snapshot() if state.get("ap", 0) <= 0: - # 行动点用完:休息推进一天,顺便等冷却 - self.rest() + # 行动点用完:只能等这一天自己走完,没有手动快进 + if not self.wait_for_next_day(wait_seconds): + break + idle = 0 continue did = self.step() if did is None: idle += 1 if idle >= idle_limit: - self.say("暂时没有可做的事,休息一天再看。") - self.rest() + if not self.wait_for_next_day(wait_seconds): + break idle = 0 else: time.sleep(0.4) diff --git a/farm/client.py b/farm/client.py index e763a56..307295c 100644 --- a/farm/client.py +++ b/farm/client.py @@ -15,20 +15,21 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程 字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。 -双通道 ------- +读写分工 +-------- * **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。 -* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。 -* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。 +* **读数走 SSE**(GET /api/stream):服务器在状态变化时顺着一条 HTTP 长连接推过来, + 界面不用轮询;上行下行都是普通 HTTP,不需要任何第三方库。 +* SSE 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。 请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。 """ from __future__ import annotations -import asyncio import contextlib import json +import socket import threading import time import urllib.error @@ -36,8 +37,6 @@ import urllib.parse import urllib.request from collections import deque from collections.abc import Callable -from concurrent.futures import Future -from concurrent.futures import TimeoutError as FutureTimeout from typing import Any from . import config, net @@ -45,7 +44,19 @@ from .engine import GameError DEFAULT_ENDPOINT = "http://127.0.0.1:8765" LOG_KEEP = 200 -WS_RECONNECT_DELAY = 3.0 +STREAM_RECONNECT_DELAY = 3.0 # SSE 断开后重连的间隔(秒) +STREAM_READ_TIMEOUT = 40.0 # 读 SSE 的 socket 超时,要比服务器心跳(15s)宽裕 + + +def _underlying_socket(response: Any) -> Any: + """从 ``HTTPResponse`` 里掏出底层 socket。 + + 用途只有一个:断开时能立刻中断正在阻塞的读取(``close()`` 会等读线程的锁, + 最长要等到下一次心跳,不可接受)。掏不到就返回 None,调用方会退化成 close()。 + """ + fp = getattr(response, "fp", None) + raw = getattr(fp, "raw", None) # http.client 把 socket 包成 SocketIO + return getattr(raw, "_sock", None) # --------------------------------------------------------------------- 状态视图 @@ -166,6 +177,10 @@ class StateView: def __init__(self, data: dict[str, Any] | None = None) -> None: self._d: dict[str, Any] = {} + # 空视图也要长得像"空农场":还没收到第一份快照时(或服务器连不上时), + # 上层会直接读 state.plots 渲染,不能甩出 AttributeError。 + self.plots: list[PlotView] = [] + self.animals: list[AnimalView] = [] self.apply(data or {}) def apply(self, data: dict[str, Any]) -> None: @@ -248,19 +263,17 @@ class RemoteFarm: self.state = StateView() self.connected = False - self.mode = "http" # http / ws / poll + self.mode = "http" # http(只有写请求)/ sse(长连接推送)/ poll(轮询兜底) self.last_error = "" self.last_message = "" self.server_info: dict[str, Any] = {} self.on_change: Callable[[], None] | None = None self._lock = threading.RLock() - self._pending: dict[int, Future] = {} - self._req_id = 0 self._stop = threading.Event() self._thread: threading.Thread | None = None - self._ws_socket: Any = None - self._ws_loop: Any = None + self._stream_response: Any = None + self._stream_socket: Any = None self._catalog: dict[str, Any] = { "crops": [], "animals": [], @@ -270,7 +283,7 @@ class RemoteFarm: } self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP) self._log_seq = 0 - self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0} + self.stats = {"http": 0, "pushes": 0, "errors": 0} if autostart: self.refresh(raise_on_error=True) @@ -280,12 +293,18 @@ class RemoteFarm: # ------------------------------------------------------------ 基础设施 @staticmethod def _normalize(endpoint: str) -> str: - endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/") + endpoint = (endpoint or "").strip() if endpoint.startswith("tcp://"): endpoint = "http://" + endpoint[len("tcp://") :] - if not endpoint.startswith(("http://", "https://")): + elif not endpoint.startswith(("http://", "https://")): endpoint = "http://" + endpoint - return endpoint + # 补完 scheme 再削尾部斜杠:顺序反了的话 "http://" 会被削成 "http:", + # 拼出来的地址没有主机名,请求会悄悄落到系统代理上(表现为莫名其妙的 502)。 + endpoint = endpoint.rstrip("/") + has_host = urllib.parse.urlsplit(endpoint).netloc + if endpoint.startswith(("http://", "https://")) and has_host: + return endpoint + return DEFAULT_ENDPOINT @property def host(self) -> str: @@ -346,7 +365,7 @@ class RemoteFarm: # ------------------------------------------------------------ 读接口 def refresh(self, raise_on_error: bool = False) -> bool: - """主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。""" + """主动拉一次全量数据(首次连接、以及 SSE 断开后的兜底)。""" try: data = self._http("GET", "/api/state") self._apply_online(data.get("state") or {}, data.get("online")) @@ -417,38 +436,15 @@ class RemoteFarm: ) # ------------------------------------------------------------ 写接口 - def _action( - self, action: str, params: dict[str, Any] | None = None, transport: str = "auto" - ) -> dict[str, Any]: - """执行一个动作。 + def _action(self, action: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """执行一个动作:写操作统一走 HTTP POST /api/actions。 - 优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误 - (连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家, - 否则"金币不足"会被悄悄重试成两次操作。 + 实时通道只负责下行推送,所以这里不需要"优先哪条通道"的判断; + ``full=1`` 让服务器顺手把完整快照带回来,省掉"操作后再查一次"。 """ - params = params or {} - use_ws = (transport == "ws") or ( - transport == "auto" and self.mode == "ws" and self.connected + data = self._http( + "POST", "/api/actions", {"action": action, "params": params or {}}, full=1 ) - if use_ws: - try: - result = self._ws_action(action, params) - except GameError as exc: - if exc.code in ("ws_closed", "timeout", "network"): - self.last_error = f"WebSocket 不可用({exc.message}),已改走 HTTP" - else: - raise - else: - self.stats["ws_actions"] += 1 - if isinstance(result.get("snapshot"), dict): - self._apply(result["snapshot"]) - elif isinstance(result.get("state"), dict): - self._apply(result["state"]) - self._merge_log(result.get("log") or []) - return result - - # full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次" - data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1) self._apply(data.get("snapshot") or data.get("state") or {}) self._merge_log(data.get("log") or []) return data @@ -484,22 +480,13 @@ class RemoteFarm: def buy_plot(self) -> dict[str, Any]: return self._action("buy_plot") - def sleep(self) -> dict[str, Any]: - return self._action("sleep") - def pause(self, paused: bool | None = None) -> dict[str, Any]: + # 动作回执里的 state(brief)已带 paused 与 seconds_to_tick, + # _action 内部会合并进本地视图,这里只需转发参数 params: dict[str, Any] = {} if paused is not None: params["paused"] = paused - data = self._action("pause", params) - self._apply(data.get("state") or {}) - # 本地视图也要反映暂停态,TUI 的倒计时依赖它 - if "paused" in data: - merged = self.state.to_dict() - merged["paused"] = data["paused"] - merged["seconds_to_tick"] = None if data["paused"] else merged.get("seconds_to_tick") - self.state.apply(merged) - return data + return self._action("pause", params) def reset(self) -> dict[str, Any]: data = self._action("reset") @@ -520,60 +507,64 @@ class RemoteFarm: self._merge_log(data.get("log") or []) return data - # ------------------------------------------------------------ WebSocket + # ------------------------------------------------------------ 实时通道(SSE) def start(self) -> None: if not self.realtime_enabled or self._thread is not None: return self._stop.clear() - self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True) + self._thread = threading.Thread(target=self._realtime_loop, name="farm-sse", daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() - with self._lock: - for future in self._pending.values(): - if not future.done(): - future.set_exception(GameError("closed", "连接已关闭")) - self._pending.clear() + # 主动掐断长连接,否则服务端那条流式响应会一直挂着,拖慢它的优雅停机。 + # 注意不能直接 response.close():读线程正持有那把锁,close 会一直等它, + # 而它要等下一次心跳(15 秒)才醒。直接 shutdown 底层 socket 才能立刻唤醒。 + sock = self._stream_socket + if sock is not None: + with contextlib.suppress(OSError): + sock.shutdown(socket.SHUT_RDWR) + response = self._stream_response + if response is not None: + with contextlib.suppress(Exception): + response.close() def transport_label(self) -> str: - if self.mode == "ws": - return "WebSocket 实时" + if self.mode == "sse": + return "SSE 实时" if self.mode == "poll": return "HTTP 轮询" return "HTTP" @property - def ws_url(self) -> str: - parts = urllib.parse.urlsplit(self.endpoint) - scheme = "wss" if parts.scheme == "https" else "ws" - query = {"farm": self.farm} + def stream_url(self) -> str: + """SSE 长连接地址。 + + 令牌只能走查询参数:``EventSource`` 与 ``urllib`` 都没法给长连接加自定义头。 + """ + query: dict[str, Any] = {"farm": self.farm} if self.token: query["token"] = self.token if self.name: query["name"] = self.name - return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}" + return f"{self.endpoint}/api/stream?{urllib.parse.urlencode(query)}" def _realtime_loop(self) -> None: - """实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。""" + """实时线程:读 SSE 长连接;断了就一边轮询一边重连,永不放弃。""" while not self._stop.is_set(): try: - self._ws_session() + self._stream_once() except Exception as exc: self.connected = False self.last_error = f"{type(exc).__name__}: {exc}" - if self.mode != "ws": - self.mode = "poll" - self._fail_pending(self.last_error) + self.mode = "poll" if self._stop.is_set(): break self._poll_until_reconnect() def _poll_until_reconnect(self) -> None: - """WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。""" - if self.mode == "ws": - self.mode = "poll" - deadline = time.time() + WS_RECONNECT_DELAY + """SSE 断开期间的降级通道:轻量轮询 + 到点再试一次长连接。""" + deadline = time.time() + STREAM_RECONNECT_DELAY while not self._stop.is_set() and time.time() < deadline: try: data = self._http("GET", "/api/state") @@ -582,64 +573,46 @@ class RemoteFarm: pass self._stop.wait(0.5) - def _ws_session(self) -> None: - import asyncio + def _stream_once(self) -> None: + """读一次 SSE 流,直到断开或主动停止。 - try: - import websockets - except ImportError: - # 没装 websockets:直接走轮询模式,功能不减 - self.mode = "poll" - self._stop.wait(WS_RECONNECT_DELAY) - return - - asyncio.run(self._ws_main(websockets)) - - async def _ws_main(self, websockets) -> None: - async with websockets.connect( - self.ws_url, - open_timeout=self.timeout, - ping_interval=20, - ping_timeout=20, - max_size=4 * 1024 * 1024, - ) as socket: - self.mode = "ws" + 直接按行迭代响应体:一行一行拿,不把响应收完, + 所以这是真正的流式读取,而且只用标准库。 + """ + request = urllib.request.Request(self.stream_url, headers={"Accept": "text/event-stream"}) + # 服务器每 HEARTBEAT_SECONDS 秒发一次心跳,读超时要比它宽裕 + with net.open_url(request, timeout=max(self.timeout, STREAM_READ_TIMEOUT)) as response: + self._stream_response = response + self._stream_socket = _underlying_socket(response) + self.mode = "sse" self.connected = True self.last_error = "" - self._ws_socket = socket - self._ws_loop = asyncio.get_running_loop() try: - while not self._stop.is_set(): - try: - raw = await asyncio.wait_for(socket.recv(), timeout=30.0) - except asyncio.TimeoutError: - await socket.send(json.dumps({"type": "ping"})) + for raw_line in response: + if self._stop.is_set(): + return + line = raw_line.decode("utf-8", errors="replace").strip() + if not line or line.startswith(":"): + # 空行是帧分隔符;冒号开头是心跳注释,都无需处理 continue - self._handle_ws_message(raw) + if line.startswith("data:"): + self._handle_stream_message(line[len("data:") :].strip()) finally: - self._ws_socket = None - self._ws_loop = None - self._fail_pending("WebSocket 连接已断开") + self._stream_response = None + self._stream_socket = None - def _fail_pending(self, reason: str) -> None: - """连接断了以后,把还在等回执的动作请求全部失败掉。""" - with self._lock: - pending, self._pending = self._pending, {} - for future in pending.values(): - if not future.done(): - future.set_exception(GameError("ws_closed", reason)) - - def _handle_ws_message(self, raw: Any) -> None: + def _handle_stream_message(self, payload: str) -> None: try: - message = json.loads(raw) - except (TypeError, json.JSONDecodeError): + message = json.loads(payload) + except json.JSONDecodeError: return if not isinstance(message, dict): return kind = message.get("type") - if kind == "welcome": - self.server_info = message.get("server") or {} + if kind in ("welcome", "state", "sync"): + if message.get("server"): + self.server_info = message["server"] self.farm = message.get("farm", self.farm) if message.get("catalog"): self._catalog = message["catalog"] @@ -648,38 +621,6 @@ class RemoteFarm: self.stats["pushes"] += 1 return - if kind in ("state", "sync"): - if message.get("catalog"): - self._catalog = message["catalog"] - self._apply_online(message.get("state") or {}, message.get("online")) - self._merge_log(message.get("log") or []) - self.stats["pushes"] += 1 - return - - if kind == "result": - rid = message.get("id") - with self._lock: - future = self._pending.pop(rid, None) if rid is not None else None - if future is None or future.done(): - return - if message.get("ok"): - self._merge_log(message.get("log") or []) - future.set_result( - { - "ok": True, - "message": message.get("message", "完成"), - **(message.get("result") or {}), - "state": message.get("state"), - "snapshot": message.get("snapshot"), - "log": message.get("log"), - } - ) - else: - future.set_exception( - GameError(message.get("error", "error"), message.get("message", "操作失败")) - ) - return - if kind == "day": self._merge_log( [ @@ -704,36 +645,13 @@ class RemoteFarm: if kind == "error": self.last_error = message.get("message", "") + self.stats["errors"] += 1 + return - def _ws_send(self, payload: dict[str, Any]) -> None: - """把消息发到 WS 线程的事件循环里(跨线程投递)。""" - socket = getattr(self, "_ws_socket", None) - loop = getattr(self, "_ws_loop", None) - if socket is None or loop is None: - raise GameError("ws_closed", "WebSocket 尚未连接") - asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop) - - def _ws_action( - self, action: str, params: dict[str, Any], timeout: float | None = None - ) -> dict[str, Any]: - with self._lock: - self._req_id += 1 - rid = self._req_id - future: Future = Future() - self._pending[rid] = future - try: - self._ws_send({"type": "action", "id": rid, "action": action, "params": params}) - except GameError: - with self._lock: - self._pending.pop(rid, None) - raise - try: - return future.result(timeout=timeout or self.timeout) - except FutureTimeout: - with self._lock: - self._pending.pop(rid, None) - # 超时是正常控制流:FutureTimeout 是内部实现细节,不必挂到异常链上 - raise GameError("timeout", "服务器没有在超时时间内回应") from None + if kind == "bye": + # 服务器主动收尾(停机):记下原因,让上层提示"服务器已停止" + self.connected = False + self.last_error = message.get("message", "服务器已停机") class RemoteBotClient(RemoteFarm): diff --git a/farm/config.py b/farm/config.py index 4061328..e059f55 100644 --- a/farm/config.py +++ b/farm/config.py @@ -185,10 +185,12 @@ MAX_ANIMALS = 8 # 牧场总容量 # 时间 # -------------------------------------------------------------------------- -DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数 -MIN_TICK_SECONDS = 3.0 -MAX_TICK_SECONDS = 600.0 -SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒) +# 一天有多长(真实秒数)。慢是刻意的:这是“放置农场”—— +# 一天 40 分钟,作物 2~10 天成熟(小麦 80 分钟、草莓约 6.7 小时)。 +# 没有任何“手动推进一天”的接口,时间只能自然流动。 +DEFAULT_TICK_SECONDS = 2400.0 +MIN_TICK_SECONDS = 3.0 # 调试用:把一天缩到几秒 +MAX_TICK_SECONDS = 86400.0 # 上限给到一整天,想多慢都不会被夹住 # -------------------------------------------------------------------------- diff --git a/farm/engine.py b/farm/engine.py index 50c6968..f6d3c2c 100644 --- a/farm/engine.py +++ b/farm/engine.py @@ -51,7 +51,6 @@ class FarmEngine: ) self._log_seq = 0 - self._last_sleep = 0.0 self.paused = False # 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None) self.next_tick_at: float | None = None @@ -106,6 +105,44 @@ class FarmEngine: return None return round(max(0.0, self.next_tick_at - time.time()), 1) + def brief_source(self) -> dict[str, Any]: + """精简状态(动作回执、排行榜、`status`)要用的字段。 + + 刻意**不走** :meth:`snapshot`:那些场景只要十几个标量,而完整快照会把 + 每块地、每只牲畜都序列化成 dict,再被调用方整批丢掉。实测每个请求因此 + 白花 ~60µs;排行榜更夸张——N 个牧场就是 N 次全量序列化。 + + 这里的键是 ``farm.server.app.BRIEF_KEYS`` 的超集:加字段时两边一起改。 + """ + with self._lock: + state = self.state + # net_worth 是这里最贵的一项(要遍历地块与牲畜),算一次给两处用 + worth = state.net_worth + return { + "day": state.day, + "gold": state.gold, + "level": state.level, + "exp": state.exp, + "exp_needed": state.exp_needed, + "ap": state.ap, + "max_ap": state.max_ap, + "feed": state.feed, + "plot_count": len(state.plots), + "max_plots": state.max_plots, + "next_plot_price": state.next_plot_price, + "animal_count": len(state.animals), + "max_animals": config.MAX_ANIMALS, + "net_worth": round(worth, 2), + "goal_index": config.goal_index(worth), + "seconds_to_tick": self.seconds_to_tick, + "paused": self.paused, + "tick_seconds": self.tick_seconds, + # 待办清单是特意为 AI 准备的:直接读模型对象,不再过一遍序列化 + "ripe_plots": [p.index + 1 for p in state.plots if p.is_ripe], + "hungry_animals": [a.aid for a in state.animals if not a.fed], + "collectable_animals": [a.aid for a in state.animals if a.pending > 0], + } + def market_price(self, key: str) -> float: with self._lock: return round(self.state.market.get(key, 0.0), 2) @@ -127,7 +164,7 @@ class FarmEngine: "price": price, "exp": info["exp"], "level_req": info["level_req"], - "locked": self.state.level < info["level_req"], + "locked": self._is_locked(info), "profit": round(gross - info["seed_cost"], 2), "profit_per_day": round((gross - info["seed_cost"]) / days, 2), } @@ -147,7 +184,7 @@ class FarmEngine: "feed_per_day": info["feed_per_day"], "exp": info["exp"], "level_req": info["level_req"], - "locked": self.state.level < info["level_req"], + "locked": self._is_locked(info), "profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2), } ) @@ -166,109 +203,151 @@ class FarmEngine: ``cost``(金币/行动点开销)、``reason``(为什么值得做)。 """ with self._lock: - actions: list[dict[str, Any]] = [] state = self.state - - # ---- 收获 / 浇水 / 播种 - for plot in state.plots: - if plot.crop is None: - for key, info in config.CROPS.items(): - if state.level < info["level_req"]: - continue - if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]: - continue - actions.append( - { - "action": "plant", - "params": {"plot": plot.index + 1, "crop": key}, - "cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]}, - "reason": f"{plot.index + 1} 号地空闲,可种{info['name']}", - } - ) - elif plot.is_ripe: - actions.append( - { - "action": "harvest", - "params": {"plot": plot.index + 1}, - "cost": {"gold": 0, "ap": config.AP_COST["harvest"]}, - "reason": ( - f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟" - ), - } - ) - elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]: - actions.append( - { - "action": "water", - "params": {"plot": plot.index + 1}, - "cost": {"gold": 0, "ap": config.AP_COST["water"]}, - "reason": f"{plot.index + 1} 号地缺水,作物已停止生长", - } - ) - - # ---- 动物 - for animal in state.animals: - if animal.pending > 0: - actions.append( - { - "action": "collect", - "params": {"animal_id": animal.aid}, - "cost": {"gold": 0, "ap": config.AP_COST["collect"]}, - "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取", - } - ) - elif not animal.fed: - need = animal.info["feed_per_day"] - cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE - if state.gold >= cost and state.ap >= config.AP_COST["feed"]: - actions.append( - { - "action": "feed", - "params": {"animal_id": animal.aid}, - "cost": {"gold": cost, "ap": config.AP_COST["feed"]}, - "reason": f"{animal.name}(#{animal.aid})今天还没喂食", - } - ) - - # ---- 投资类 - price = state.next_plot_price - if price > 0 and state.gold >= price: - actions.append( - { - "action": "buy_plot", - "params": {}, - "cost": {"gold": price, "ap": config.AP_COST["buy_plot"]}, - "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地", - } - ) - if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK: - actions.append( - { - "action": "buy_feed", - "params": {"quantity": config.FEED_BULK}, - "cost": { - "gold": config.FEED_UNIT_PRICE * config.FEED_BULK, - "ap": config.AP_COST["buy_feed"], - }, - "reason": "饲料库存偏低,建议补货", - } - ) - for key, info in config.ANIMALS.items(): - if state.level < info["level_req"]: - continue - if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]: - continue - actions.append( - { - "action": "buy_animal", - "params": {"kind": key}, - "cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]}, - "reason": f"可购买{info['name']}({info['cost']} 金币)", - } - ) + actions: list[dict[str, Any]] = [] + actions.extend(self._plot_actions(state)) + actions.extend(self._animal_actions(state)) + actions.extend(self._investment_actions(state)) return actions + # ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试 + def _plot_actions(self, state: GameState) -> list[dict[str, Any]]: + """每块地当前能做的事:播种 / 收获 / 浇水。""" + # 能种什么只取决于等级、金币和行动点,与**具体是哪块地**无关, + # 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。 + plant_ap = config.AP_COST["plant"] + plantable = [ + (key, info) + for key, info in config.CROPS.items() + if not self._is_locked(info) + and state.gold >= info["seed_cost"] + and state.ap >= plant_ap + ] + actions: list[dict[str, Any]] = [] + for plot in state.plots: + position = plot.index + 1 + if plot.crop is None: + for key, info in plantable: + actions.append( + { + "action": "plant", + "params": {"plot": position, "crop": key}, + "cost": {"gold": info["seed_cost"], "ap": plant_ap}, + "reason": f"{position} 号地空闲,可种{info['name']}", + } + ) + elif plot.is_ripe: + actions.append( + { + "action": "harvest", + "params": {"plot": position}, + "cost": {"gold": 0, "ap": config.AP_COST["harvest"]}, + "reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟", + } + ) + elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]: + actions.append( + { + "action": "water", + "params": {"plot": position}, + "cost": {"gold": 0, "ap": config.AP_COST["water"]}, + "reason": f"{position} 号地缺水,作物已停止生长", + } + ) + return actions + + def _animal_actions(self, state: GameState) -> list[dict[str, Any]]: + """每只牲畜当前能做的事:收取产出 / 喂食。""" + actions: list[dict[str, Any]] = [] + for animal in state.animals: + if animal.pending > 0: + actions.append( + { + "action": "collect", + "params": {"animal_id": animal.aid}, + "cost": {"gold": 0, "ap": config.AP_COST["collect"]}, + "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取", + } + ) + elif not animal.fed: + need = animal.info["feed_per_day"] + cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE + if state.gold >= cost and state.ap >= config.AP_COST["feed"]: + actions.append( + { + "action": "feed", + "params": {"animal_id": animal.aid}, + "cost": {"gold": cost, "ap": config.AP_COST["feed"]}, + "reason": f"{animal.name}(#{animal.aid})今天还没喂食", + } + ) + return actions + + def _investment_actions(self, state: GameState) -> list[dict[str, Any]]: + """与具体地块/牲畜无关的长期投入:开垦土地、补饲料、买牲畜。""" + actions: list[dict[str, Any]] = [] + price = state.next_plot_price + if price > 0 and state.gold >= price: + actions.append( + { + "action": "buy_plot", + "params": {}, + "cost": {"gold": price, "ap": config.AP_COST["buy_plot"]}, + "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地", + } + ) + bulk_cost = config.FEED_UNIT_PRICE * config.FEED_BULK + if state.feed < 5 and state.gold >= bulk_cost: + actions.append( + { + "action": "buy_feed", + "params": {"quantity": config.FEED_BULK}, + "cost": {"gold": bulk_cost, "ap": config.AP_COST["buy_feed"]}, + "reason": "饲料库存偏低,建议补货", + } + ) + for key, info in config.ANIMALS.items(): + if self._is_locked(info): + continue + if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]: + continue + actions.append( + { + "action": "buy_animal", + "params": {"kind": key}, + "cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]}, + "reason": f"可购买{info['name']}({info['cost']} 金币)", + } + ) + return actions + # ================================================================ 内部工具 + def _is_locked(self, info: dict[str, Any]) -> bool: + """这件作物/牲畜在当前等级是否还没解锁。 + + "等级门槛"这一条规则原本在 catalog / available_actions / plant / + buy_animal / _gain_exp 里各写了一遍,改文案或改判定就得追五处。 + 现在只在这里定义,别处一律调它。 + """ + return self.state.level < info["level_req"] + + def _require_level(self, info: dict[str, Any]) -> None: + """等级不够就抛 locked。播种与买牲畜都要过这一关。""" + if self._is_locked(info): + raise GameError( + "locked", + f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}", + ) + + def _newly_unlocked(self, level: int) -> list[str]: + """正好在 level 这一级解锁的作物与牲畜名字(先作物后牲畜)。""" + return [ + info["name"] + for table in (config.CROPS, config.ANIMALS) + for info in table.values() + if info["level_req"] == level + ] + def _get_plot(self, plot_no: Any) -> Plot: """把 1-based 的地块编号转成 Plot,越界即报错。""" try: @@ -299,7 +378,7 @@ class FarmEngine: raise GameError( "no_ap", f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。" - f"按 N 休息或等待下一天。", + f"等一天的时间自然走完就会恢复。", ) if cost: self.state.ap -= cost @@ -333,15 +412,7 @@ class FarmEngine: self.state.level += 1 bonus = config.level_up_bonus(self.state.level) self.state.gold += bonus - unlocked = [ - info["name"] - for info in config.CROPS.values() - if info["level_req"] == self.state.level - ] + [ - info["name"] - for info in config.ANIMALS.values() - if info["level_req"] == self.state.level - ] + unlocked = self._newly_unlocked(self.state.level) text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币" if unlocked: text += f",解锁:{'、'.join(unlocked)}" @@ -362,11 +433,7 @@ class FarmEngine: f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}", ) info = config.CROPS[crop] - if self.state.level < info["level_req"]: - raise GameError( - "locked", - f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}", - ) + self._require_level(info) plot = self._get_plot(plot_no) if plot.crop is not None: raise GameError( @@ -451,11 +518,7 @@ class FarmEngine: f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}", ) info = config.ANIMALS[kind] - if self.state.level < info["level_req"]: - raise GameError( - "locked", - f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}", - ) + self._require_level(info) if len(self.state.animals) >= config.MAX_ANIMALS: raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)") @@ -587,17 +650,6 @@ class FarmEngine: "next_plot_price": self.state.next_plot_price, } - def sleep(self) -> dict[str, Any]: - """手动推进一天,带冷却避免刷时间。""" - with self._lock: - elapsed = time.time() - self._last_sleep - if elapsed < config.SLEEP_COOLDOWN: - wait = config.SLEEP_COOLDOWN - elapsed - raise GameError("cooldown", f"刚休息过,请等待 {wait:.1f} 秒", status=429) - self._last_sleep = time.time() - events = self.advance_day(reason="休息") - return {"ok": True, "message": f"进入第 {self.state.day} 天", "events": events} - def pause(self, paused: bool | None = None) -> dict[str, Any]: with self._lock: self.paused = bool(paused) if paused is not None else not self.paused @@ -610,7 +662,6 @@ class FarmEngine: tick = self.tick_seconds self.state = GameState() self._log_seq = 0 - self._last_sleep = 0.0 self.paused = False if keep_tick: self.tick_seconds = tick @@ -709,8 +760,30 @@ class FarmEngine: ) +def tick_decision( + paused: bool, next_tick_at: float | None, tick_seconds: float, now: float +) -> tuple[bool, float]: + """根据当前时刻决定"是否该推进一天",并给出新的调度时间点。 + + 这是 :class:`Scheduler`(单机)与 :class:`farm.server.session.FarmClock` + (服务器)共用的唯一 tick 决策,两处不再各写一遍: + + * 暂停中 → 顺延,保证恢复后仍有完整的一个 tick; + * 没有调度点(从未启动/被清空)→ 从现在起重新计时; + * 到点 → 推进,并把下一次调度设为 now + tick_seconds; + * 未到点 → 维持原调度时间。 + + 返回 ``(should_advance, next_tick_at)``。 + """ + if paused or next_tick_at is None: + return False, now + tick_seconds + if now >= next_tick_at: + return True, now + tick_seconds + return False, next_tick_at + + class Scheduler: - """自动推进游戏时间的后台线程。""" + """自动推进游戏时间的后台线程(单机模式;服务器版见 farm.server.session.FarmClock)。""" def __init__(self, engine: FarmEngine, enabled: bool = True) -> None: self.engine = engine @@ -732,16 +805,9 @@ class Scheduler: def _run(self) -> None: while not self._stop.wait(0.2): engine = self.engine - if engine.paused: - # 暂停时顺延,保证恢复后仍有完整的一个 tick - engine.next_tick_at = time.time() + engine.tick_seconds - continue - deadline = engine.next_tick_at - if deadline is None: - deadline = time.time() + engine.tick_seconds - if time.time() >= deadline: + advance, engine.next_tick_at = tick_decision( + engine.paused, engine.next_tick_at, engine.tick_seconds, time.time() + ) + if advance: with contextlib.suppress(Exception): engine.advance_day(reason="时间流逝") - engine.next_tick_at = time.time() + engine.tick_seconds - else: - engine.next_tick_at = deadline diff --git a/farm/keys.py b/farm/keys.py index bd89404..bb3f07b 100644 --- a/farm/keys.py +++ b/farm/keys.py @@ -1,10 +1,21 @@ """跨平台非阻塞键盘读取。 Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``, -两端统一返回规范化的按键名,TUI 层无需关心平台差异。 +两端统一返回**同一套小写**的按键名,TUI 层无需关心平台差异。 + +按键名约定(重要) +------------------ +具名键一律**小写**,见 :data:`NAMED_KEYS`:``up`` / ``down`` / ``left`` / +``right`` / ``space`` / ``tab`` / ``esc`` / ``enter`` / ``backspace`` / +``home`` / ``end`` / ``delete`` / ``ctrl_c``;普通可打印字符返回其自身的 +小写形式(例如 ``"p"``)。 + +这条约定是硬性的:两条平台路径必须返回**完全一样**的名字。历史上这里 +Windows 返回大写(``"UP"``)、TUI 里写的是小写(``"up"``),于是方向键、 +空格、Tab、ESC 全都静默失效,而且只在某个平台上才看得出来。 在非交互式环境(管道、重定向)下 ``available`` 为 False, -TUI 会退化为无输入的只读模式。 +TUI 会退化为无输入的只读模式,并把 :attr:`KeyReader.reason` 显示给玩家。 """ from __future__ import annotations @@ -12,30 +23,95 @@ from __future__ import annotations import importlib.util import os import sys +import time IS_WINDOWS = os.name == "nt" -# Windows 下的扩展键(以 \x00 / \xe0 开头) +# ---------------------------------------------------------------------- 具名键 +# 两端共用的规范化名字。全小写,避免"大写产出、小写消费"这类靠肉眼发现的错配。 +CTRL_C = "ctrl_c" +ENTER = "enter" +TAB = "tab" +BACKSPACE = "backspace" +ESC = "esc" +SPACE = "space" +UP = "up" +DOWN = "down" +LEFT = "left" +RIGHT = "right" +HOME = "home" +END = "end" +DELETE = "delete" + +NAMED_KEYS = frozenset( + { + CTRL_C, + ENTER, + TAB, + BACKSPACE, + ESC, + SPACE, + UP, + DOWN, + LEFT, + RIGHT, + HOME, + END, + DELETE, + } +) + +# Windows 扩展键:第一次 getwch() 拿到 \x00 / \xe0,第二次才是真正的键码 _WIN_EXTENDED = { - "H": "UP", - "P": "DOWN", - "K": "LEFT", - "M": "RIGHT", - "G": "HOME", - "O": "END", - "S": "DELETE", + "H": UP, + "P": DOWN, + "K": LEFT, + "M": RIGHT, + "G": HOME, + "O": END, + "S": DELETE, } +# Windows 控制字符 _WIN_CTRL = { - "\x03": "CTRL_C", - "\r": "ENTER", - "\n": "ENTER", - "\t": "TAB", - "\x08": "BACKSPACE", - "\x1b": "ESC", - " ": "SPACE", + "\x03": CTRL_C, + "\r": ENTER, + "\n": ENTER, + "\t": TAB, + "\x08": BACKSPACE, + "\x7f": BACKSPACE, + "\x1b": ESC, + " ": SPACE, } +# POSIX 控制字符(ESC 单独处理,因为方向键等功能键也是它打头的) +_POSIX_CTRL = { + "\x03": CTRL_C, + "\r": ENTER, + "\n": ENTER, + "\t": TAB, + "\x08": BACKSPACE, + "\x7f": BACKSPACE, + " ": SPACE, +} + +# POSIX 转义序列的"参数+终结符"(去掉开头的 ESC [ 或 ESC O): +# 方向键是 ESC [ A~D,Home/End 有两套写法,Delete 是 ESC [ 3 ~ +_POSIX_CSI = { + "A": UP, + "B": DOWN, + "C": RIGHT, + "D": LEFT, + "H": HOME, + "F": END, + "1~": HOME, + "3~": DELETE, + "4~": END, +} + +EXTENDED_WAIT = 0.02 # 等功能键第二个字节的最长时间(秒) +PENDING_WAIT = 0.02 # 判断"单按 ESC"还是"序列开头"、以及等半个字符的时间 + class KeyReader: """把终端的原始字节流转换成按键名。""" @@ -43,12 +119,16 @@ class KeyReader: def __init__(self) -> None: self._fd: int | None = None self._saved_term = None + self._raw = b"" # POSIX:上一次没解析完的字节(半个转义序列、半个汉字) + + self.reason = "" # 读不到键盘时的解释,用于提示玩家 + self.available = False # 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None, # 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。 stdin = sys.stdin if stdin is None or not stdin.isatty(): - self.available = False + self.reason = "标准输入不是终端(管道或重定向)" return self.available = True @@ -56,6 +136,17 @@ class KeyReader: # Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用 if importlib.util.find_spec("msvcrt") is None: self.available = False + self.reason = "找不到 msvcrt 模块" + return + try: + import msvcrt + + # 探一次:不是真的 Win32 控制台(例如 Git Bash 的 mintty)这里会报错。 + # 与其让玩家对着"按键没反应"发呆,不如把原因摆出来。 + msvcrt.kbhit() + except Exception: + self.available = False + self.reason = "当前终端不是 Win32 控制台,msvcrt 读不到按键" return try: @@ -71,6 +162,7 @@ class KeyReader: except Exception: self.available = False self._saved_term = None + self.reason = "无法把终端切到 cbreak 模式" # ------------------------------------------------------------------ 读取 def poll(self) -> str | None: @@ -79,6 +171,7 @@ class KeyReader: return None return self._poll_windows() if IS_WINDOWS else self._poll_posix() + # ---------------------------------------------------------------- Windows def _poll_windows(self) -> str | None: import msvcrt @@ -87,56 +180,144 @@ class KeyReader: char = msvcrt.getwch() if char in ("\x00", "\xe0"): - if not msvcrt.kbhit(): - return None - return _WIN_EXTENDED.get(msvcrt.getwch().upper()) - if char in _WIN_CTRL: - return _WIN_CTRL[char] - return char.lower() + code = self._win_extended_code() + # 认不出来的功能键就丢掉,不要退化成 ESC 之类的错误动作 + return _WIN_EXTENDED.get(code) if code else None + named = _WIN_CTRL.get(char) + return named if named else char.lower() + @staticmethod + def _win_extended_code() -> str | None: + """取扩展键的第二个字节;它通常紧跟其后,但偶尔会差几毫秒。""" + import msvcrt + + deadline = time.monotonic() + EXTENDED_WAIT + while True: + if msvcrt.kbhit(): + return msvcrt.getwch().upper() + if time.monotonic() >= deadline: + return None + time.sleep(0.005) + + # ---------------------------------------------------------------- POSIX 侧 def _poll_posix(self) -> str | None: import select if self._fd is None: return None - try: - ready, _, _ = select.select([self._fd], [], [], 0) - except (OSError, ValueError): - return None - if not ready: + if not self._raw: + try: + ready, _, _ = select.select([self._fd], [], [], 0) + except (OSError, ValueError): + return None + if not ready: + return None + if self._read_fd() is None: + return None + return self._parse_posix() + + def _read_fd(self) -> bytes | None: + """把输入追加进 :attr:`_raw`。读不到返回 None。""" + if self._fd is None: return None try: - data = os.read(self._fd, 8) + data = os.read(self._fd, 64) except OSError: return None if not data: return None + # 一次读到的可能是好几个按键:全部留在缓冲区里逐个返回, + # 不能像老实现那样只取第一个字符、把其余的悄悄丢掉。 + self._raw += data + return data - text = data.decode("utf-8", errors="ignore") - if not text: + def _read_ready(self, timeout: float) -> bool: + """等一小会儿再读一次,用于判断不完整的转义序列与半个字符。""" + import select + + if self._fd is None: + return False + try: + ready, _, _ = select.select([self._fd], [], [], timeout) + except (OSError, ValueError): + return False + return bool(ready) and self._read_fd() is not None + + def _parse_posix(self) -> str | None: + raw = self._raw + if not raw: return None - # 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D - if text.startswith("\x1b["): - return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) - if text.startswith("\x1bO"): - return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) + if raw[0:1] == b"\x1b": + seq = self._read_escape_sequence() + if seq is None: + return None # 还没读全,等下一轮 poll + if seq == b"": + return ESC # 就是单按 ESC + return _POSIX_CSI.get(seq.decode("latin-1")) - char = text[0] - mapping = { - "\x03": "CTRL_C", - "\r": "ENTER", - "\n": "ENTER", - "\t": "TAB", - "\x7f": "BACKSPACE", - "\x08": "BACKSPACE", - "\x1b": "ESC", - " ": "SPACE", - } - if char in mapping: - return mapping[char] + first = raw[0] + if first < 0x80: + self._raw = raw[1:] + char = chr(first) + named = _POSIX_CTRL.get(char) + return named if named else char.lower() + + # 多字节 UTF-8:先凑齐整个字符再解码,免得半个汉字被解成乱码 + length = 2 if first < 0xE0 else 3 if first < 0xF0 else 4 + if len(raw) < length: + self._read_ready(PENDING_WAIT) + raw = self._raw + if len(raw) < length: + return None # 等下一轮 poll 再来 + char = raw[:length].decode("utf-8", errors="replace") + self._raw = raw[length:] return char.lower() + def _read_escape_sequence(self) -> bytes | None: + """解析 ESC 打头的序列。 + + 单按 ESC 返回 ``b""``,还没读全返回 ``None``,否则返回去掉 + ``ESC [`` / ``ESC O`` 之后的"参数+终结符"(例如 ``b"A"``、``b"3~"``)。 + """ + # 最多试两次:第一次先看看能不能直接判完,不行就等一小会儿再试。 + for attempt in (0, 1): + raw = self._raw + if len(raw) == 1: + # 单按 ESC 与序列的第一个字节长得一样,有歧义 + if attempt: + self._raw = b"" + return b"" + self._read_ready(PENDING_WAIT) + continue + + if raw[1:2] not in (b"[", b"O"): + # 不是标准转义序列:吃掉 ESC,剩下的字节留给下一轮 + self._raw = raw[1:] + return b"" + + end = self._csi_end(raw) + if end is None: + if attempt: + self._raw = b"" + return b"" # 等不到了,当单按 ESC 处理,别把按键吞掉 + self._read_ready(PENDING_WAIT) + continue + + self._raw = raw[end + 1 :] + return raw[2 : end + 1] + return None + + @staticmethod + def _csi_end(raw: bytes) -> int | None: + """CSI 序列的终结符下标(0x40~0x7E);还没读到就返回 None。""" + index = 2 + while index < len(raw): + if 0x40 <= raw[index] <= 0x7E: + return index + index += 1 + return None + # ------------------------------------------------------------------ 清理 def close(self) -> None: if self._fd is not None and self._saved_term is not None: @@ -148,3 +329,4 @@ class KeyReader: pass self._fd = None self._saved_term = None + self._raw = b"" diff --git a/farm/models.py b/farm/models.py index fedfb62..5f67848 100644 --- a/farm/models.py +++ b/farm/models.py @@ -17,6 +17,33 @@ def clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) +def format_duration(seconds: float | None) -> str: + """把秒数写成人看的时长:``45s`` / ``39:58`` / ``1:05:00``。 + + 一天默认 40 分钟(2400 秒),直接显示秒数是一串难读的大数, + 所以 TUI 头部、``main.py state`` 这类地方都走这个函数。 + """ + if seconds is None: + return "—" + total = int(max(0.0, seconds)) + if total < 60: + return f"{total}s" + hours, rest = divmod(total, 3600) + minutes, secs = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def next_goal_after(index: int) -> dict[str, Any] | None: + """给定目标序号,返回"下一个目标"。已到最后一个则返回 None。""" + nxt = index + 1 + if nxt >= len(config.GOALS): + return None + amount, text = config.GOALS[nxt] + return {"amount": amount, "text": text} + + class Plot: """一块土地。""" @@ -243,14 +270,14 @@ class GameState: @property def next_goal(self) -> dict[str, Any] | None: - nxt = self.goal_index + 1 - if nxt >= len(config.GOALS): - return None - amount, text = config.GOALS[nxt] - return {"amount": amount, "text": text} + return next_goal_after(self.goal_index) # ---------------------------------------------------------------- 序列化 def to_dict(self) -> dict[str, Any]: + # net_worth 要遍历地块与牲畜,是这里最贵的一项;下面三处都要用它, + # 所以只算一次再分发给 net_worth / goal_index / next_goal。 + worth = self.net_worth + goal = config.goal_index(worth) return { "day": self.day, "gold": self.gold, @@ -268,9 +295,9 @@ class GameState: "animal_count": len(self.animals), "max_animals": config.MAX_ANIMALS, "market": {k: round(v, 2) for k, v in self.market.items()}, - "net_worth": round(self.net_worth, 2), - "goal_index": self.goal_index, - "next_goal": self.next_goal, + "net_worth": round(worth, 2), + "goal_index": goal, + "next_goal": next_goal_after(goal), "stats": { "harvested": self.total_harvested, "collected": self.total_collected, diff --git a/farm/net.py b/farm/net.py index c5276f1..d15e3a8 100644 --- a/farm/net.py +++ b/farm/net.py @@ -11,7 +11,8 @@ * 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理; * 目标是别的机器 → 尊重用户环境里的代理设置。 -WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。 +SSE 长连接同样经由这里:客户端读 ``GET /api/stream`` 用的是 urllib, +所以回环地址也必须直连,否则第一条状态推送就收不到。 """ from __future__ import annotations diff --git a/farm/server/__init__.py b/farm/server/__init__.py index cffb9a1..0a7ea72 100644 --- a/farm/server/__init__.py +++ b/farm/server/__init__.py @@ -5,10 +5,10 @@ 模块划分 -------- -* :mod:`farm.server.app` —— FastAPI 应用(REST + WebSocket) +* :mod:`farm.server.app` —— FastAPI 应用(REST + SSE) * :mod:`farm.server.actions` —— 动作表:所有入口共用的唯一执行通道 * :mod:`farm.server.session` —— 多牧场会话与统一时钟 -* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送 +* :mod:`farm.server.hub` —— SSE 订阅池与状态推送 * :mod:`farm.server.daemon` —— 守护进程(start/stop/status) * :mod:`farm.server.webui` —— 内嵌网页客户端 diff --git a/farm/server/__main__.py b/farm/server/__main__.py index 138ea5b..0719570 100644 --- a/farm/server/__main__.py +++ b/farm/server/__main__.py @@ -1,5 +1,14 @@ """``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。 +ASGI 服务器用 uvicorn,并打开它的 **HTTP/2** 支持(``http="zttp"`` + +``http2=True``):同一个端口既说 HTTP/1.1 也说 HTTP/2(TLS 走 ALPN,明文走 +h2c prior-knowledge),`curl`、浏览器、AI 脚本各取所需,应用代码一行都不用改。 + +为什么 HTTP/2 要选 ``zttp``:uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在 +``zttp`` 这个包上(见 ``uvicorn/protocols/http/zttp_h2_impl.py``)。装上之后 +``AutoZttpProtocol`` 会在一条监听上自动分流两种协议,所以选它**不会**丢掉 +HTTP/1.1 能力。 + 守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行 再拉起一个本模块的实例,只是不带 ``--daemon``。 """ @@ -7,18 +16,29 @@ from __future__ import annotations import argparse -import importlib.util import os import sys +from types import FrameType from .. import config -from .daemon import DEFAULT_LOG_FILE, DEFAULT_PID_FILE, PROJECT_ROOT, DaemonManager +from .daemon import ( + DEFAULT_LOG_FILE, + DEFAULT_PID_FILE, + PROJECT_ROOT, + DaemonManager, + port_busy, +) + +# 优雅停机的上限(秒)。SSE 是**永不自己结束**的长连接,停机时我们会先用 +# hub.close_all() 让每个流自己收尾;这个值只是兜底:万一有客户端赖着不走 +# (比如浏览器标签页没关),到点就取消剩下的连接任务,绝不让 stop 卡死。 +GRACE_TIMEOUT = 5.0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="uv run python -m farm.server", - description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。", + description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( @@ -68,40 +88,98 @@ def dependency_hint() -> str: "缺少运行服务器所需的依赖。请先安装:\n" " uv sync\n" "如果不想用 uv,也可以用 pip:\n" - " pip install fastapi 'uvicorn[standard]'\n" + " pip install 'uvicorn[standard]' fastapi zttp\n" f"\n当前解释器:{sys.executable}" ) -def main(argv=None) -> int: - args = build_parser().parse_args(argv) +def clear_pid_file(path: str | None) -> None: + """删掉自己写的 pid 文件,避免下次 status 看到"幽灵进程"。 + + 只删内容确实是本进程 pid 的那个:万一文件已经被别的实例接管, + 删掉它等于把别人的记录抹了。 + + 顺序有讲究:必须**先读完、关掉句柄,再 os.remove**。Windows 上删一个还开着的 + 文件会抛 ``PermissionError``(WinError 32),而它会被下面的 ``except OSError`` + 吞掉——结果就是 pid 文件永远留在磁盘上(这个坑在本项目里真实存在过, + 做端到端停机验证时才被发现)。 + """ + if not path: + return + try: + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as handle: + content = handle.read().strip() + if content == str(os.getpid()): + os.remove(path) + except OSError: + pass + + +def _resolve_save_dir(args) -> str: + """存档目录:显式 --save-dir 优先,其次老参数 --save 所在目录,最后项目根/saves。""" save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves") if args.save_file: save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir + return save_dir - if args.daemon: - manager = DaemonManager( - host=args.host, - port=args.port, - token=args.token, - tick=clamp_tick(args.tick), - save_dir=save_dir, - pid_file=args.pid_file or DEFAULT_PID_FILE, - log_file=args.log_file or DEFAULT_LOG_FILE, - ) - ok, message = manager.start() - print(message) - return 0 if ok else 1 +def _start_daemon(args, save_dir: str) -> int: + """把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。""" + manager = DaemonManager( + host=args.host, + port=args.port, + token=args.token, + tick=clamp_tick(args.tick), + save_dir=save_dir, + pid_file=args.pid_file or DEFAULT_PID_FILE, + log_file=args.log_file or DEFAULT_LOG_FILE, + ) + ok, message = manager.start() + print(message) + return 0 if ok else 1 + + +def _print_startup_banner(cfg, save_dir: str) -> None: + print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…") + print(f" 监听 http://{cfg.host}:{cfg.port}/") + print(f" 存档目录 {save_dir}") + print(f" 实时通道 http://{cfg.host}:{cfg.port}/api/stream?farm=<牧场名>(SSE)") + print(" ASGI uvicorn(同一端口协商 HTTP/1.1 与 HTTP/2,HTTP/2 由 zttp 提供)") + if cfg.token: + print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数") + + +def _ensure_port_free(host: str, port: int) -> int | None: + """端口自检:可用返回 ``None``,被别人占着则打印提示并返回退出码 3。 + + 必须用"能不能连上"来判断,而不是"能不能 bind":Windows 上只要两边都设了 + SO_REUSEADDR(uvicorn 会设),第二个进程也能在已被监听的端口上"绑定成功" + (然后抢走新连接),于是静默把第一个实例顶掉——这种事故比启动失败难查得多。 + """ + if not port_busy(host, port): + return None + print( + f"启动失败:{host}:{port} 已经有程序在监听(可能是另一个 SimpleFarm 实例)。\n" + f" 换一个端口试试:--port {port + 1}", + file=sys.stderr, + ) + return 3 + + +def _run_foreground(args, save_dir: str) -> int: + """在本进程里把 ASGI 服务器跑到退出,返回进程退出码。""" + # uvicorn 只在真的要跑服务时才需要——`main.py solo` 这类离线玩法不该被迫装它, + # 所以延迟到这一步才导入,失败时给出可照抄的安装命令。 try: - import uvicorn + from uvicorn import Config as UvicornConfig + from uvicorn import Server as UvicornServer except ImportError: print(dependency_hint(), file=sys.stderr) return 2 - # 只探测"在不在",不真的导入——uvicorn 需要它才能完成 WebSocket 协议升级 - ws_ok = importlib.util.find_spec("websockets") is not None - from .app import ServerConfig, create_app + from .app import ServerConfig, ServerHandle, as_asgi, create_app cfg = ServerConfig( host=args.host, @@ -114,67 +192,122 @@ def main(argv=None) -> int: world_name=args.world, allow_remote_control=not args.no_remote_control, ) - app = create_app(cfg) + # 先看端口有没有人占着。 + busy_code = _ensure_port_free(cfg.host, cfg.port) + if busy_code is not None: + return busy_code - server_config = uvicorn.Config( - app, + app = create_app(cfg) + handle = app.state.handle + + uv_config = UvicornConfig( + as_asgi(app), host=cfg.host, port=cfg.port, log_level=args.log_level, access_log=args.access_log, - loop="asyncio", - lifespan="on", + # HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol, + # http2=True 才让它真的启用 h2(uvicorn 只认 zttp 这个后端)。 + http="zttp", + http2=True, + # 与 graceful shutdown 的语义一致:等连接自己收尾的最长时间 + timeout_graceful_shutdown=int(GRACE_TIMEOUT), ) - server = uvicorn.Server(server_config) - # 让 POST /api/admin/shutdown 能触发优雅退出(lifespan 里会把存档写掉) - app.state.handle.on_stop(lambda: setattr(server, "should_exit", True)) + + # 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是 + # 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。 + class FarmServer(UvicornServer): + """uvicorn 服务器 + SimpleFarm 的停机语义。 + + 唯一改动是 :meth:`handle_exit`(uvicorn 通过 ``capture_signals`` 把它接到 + SIGINT / SIGTERM / SIGBREAK 上)。默认实现只置 ``should_exit``,那样 SSE + 长连接收不到 ``bye``,只能干等 ``timeout_graceful_shutdown`` 到点被强杀—— + 实测白等满 5 秒。这里改成先 ``handle.request_stop()``:每个流立刻自己收尾, + 再由 ``on_stop`` 回调置位 ``should_exit``。于是「接口停机」与「信号停机」 + 走的是同一条路径。 + """ + + # 由下面在实例化之后注入(放在这里是为了不动基类 __init__ 的签名) + handle: ServerHandle + + def __init__(self, config: UvicornConfig) -> None: + super().__init__(config) + self._interrupts = 0 + + def handle_exit(self, sig: int, frame: FrameType | None) -> None: + self._interrupts += 1 + if self._interrupts > 1: + # 第二次信号:用户不想再等了,放弃优雅收尾,直接退 + print("\n再次收到中断信号,强制退出。", file=sys.stderr, flush=True) + os._exit(130) + # 故意不调 super():uvicorn 的版本只置 should_exit,而我们的停机钩子 + # 会做同样的事,还要额外让 SSE 流主动收尾。另外保持 _captured_signals + # 为空,退出时就不会把信号重放一遍。 + self.handle.request_stop() + + server = FarmServer(uv_config) + server.handle = handle + + # 两条停机路径汇到同一个地方: + # • POST /api/admin/shutdown → handle.request_stop() + # • Ctrl+C / SIGTERM / SIGBREAK → FarmServer.handle_exit → handle.request_stop() + # request_stop() 既让 SSE 流自己收尾(生成器看 handle.stopping), + # 也经由这个回调把 should_exit 置位,uvicorn 于是开始优雅退出。 + handle.on_stop(lambda: setattr(server, "should_exit", True)) # pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正 # 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。 + # uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口", + # 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败, + # 下面的 finally 会把 pid 文件删掉,不留幽灵。 def write_pid_file() -> None: if not args.pid_file: return try: os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True) - with open(args.pid_file, "w", encoding="utf-8") as handle: - handle.write(str(os.getpid())) + with open(args.pid_file, "w", encoding="utf-8") as file_handle: + file_handle.write(str(os.getpid())) except OSError as exc: print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr) - app.state.handle.on_start(write_pid_file) + handle.on_start(write_pid_file) - print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…") - print(f" 监听 http://{cfg.host}:{cfg.port}/") - print(f" 存档目录 {save_dir}") - print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>") - if not ws_ok: + _print_startup_banner(cfg, save_dir) + + status = 0 + try: + server.run() + except OSError as exc: print( - " ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;" - "网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'" - ) - if cfg.token: - print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数") - - server.run() - - # 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程" - if args.pid_file: - try: - if os.path.exists(args.pid_file): - with open(args.pid_file, encoding="utf-8") as handle: - if handle.read().strip() == str(os.getpid()): - os.remove(args.pid_file) - except OSError: - pass - - if not getattr(server, "started", False): - print( - f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}", + f"启动失败:{exc}\n" + f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}", file=sys.stderr, ) - return 3 - print("服务器已停止。") - return 0 + status = 3 + except SystemExit as exc: + # uvicorn 的 lifespan 跑在 bind 之前,监听失败时它自己 sys.exit(3) + code = exc.code + status = code if isinstance(code, int) and code else 3 + print( + f"启动失败:无法监听 {cfg.host}:{cfg.port}(细节见上面的日志)。\n" + f" 换一个端口试试:--port {cfg.port + 1}", + file=sys.stderr, + ) + finally: + clear_pid_file(args.pid_file) + + if status == 0: + print("服务器已停止。") + return status + + +def main(argv=None) -> int: + """入口:要么转后台(父进程立刻返回),要么就在本进程里把服务跑起来。""" + args = build_parser().parse_args(argv) + save_dir = _resolve_save_dir(args) + if args.daemon: + return _start_daemon(args, save_dir) + return _run_foreground(args, save_dir) if __name__ == "__main__": diff --git a/farm/server/actions.py b/farm/server/actions.py index 8f9f27c..26228f6 100644 --- a/farm/server/actions.py +++ b/farm/server/actions.py @@ -1,11 +1,11 @@ -"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。 +"""统一的动作表:HTTP、命令行、机器人都走这一份映射。 为什么要有这一层 ---------------- 引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、 脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍 ``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现 -"HTTP 能做、WS 不能做" 这种不一致。 +"HTTP 能做、命令行不能做" 这种不一致。 所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。 新增一个玩法动作,只需要在这张表里加一行。 @@ -128,14 +128,6 @@ def _harvest_all(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: } -def _save(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: - return engine.save() - - -def _reset(engine: FarmEngine, _params: dict[str, Any]) -> dict[str, Any]: - return engine.reset() - - def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]: value = params.get("paused") return engine.pause(value if isinstance(value, bool) else None) @@ -232,8 +224,6 @@ _register( _register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot())) -_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep())) - _register( Action( "pause", @@ -245,11 +235,11 @@ _register( ) ) -_register(Action("save", "把当前农场写入存档文件", [], _save)) - -_register(Action("reset", "重置农场,从头开始经营", [], _reset)) - +# 注意:save / reset **刻意不进**这张表。它们是"存档类动作",多牧场环境下 +# 必须由牧场会话(FarmSession)处理——engine.save() 只会写默认路径, +# 任何一个新入口直接 run_action("save") 都会覆盖别人的存档。 +# 见 farm.server.app.execute_action 的拦截逻辑。 def action_names() -> list[str]: return sorted(ACTIONS) diff --git a/farm/server/app.py b/farm/server/app.py index 9fc7d48..9ab2e43 100644 --- a/farm/server/app.py +++ b/farm/server/app.py @@ -1,4 +1,4 @@ -"""FastAPI 应用:REST + WebSocket + 网页客户端。 +"""FastAPI 应用:REST + SSE + 网页客户端。 接口设计原则 ------------ @@ -8,8 +8,9 @@ 这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。 3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家, 每个牧场有自己的引擎、时钟与存档;互不影响。 -4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是 - 同一个引擎,所以「网页里点一下,AI 那边立刻看到」。 +4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端** + 的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。 + 两者改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。 """ from __future__ import annotations @@ -22,13 +23,23 @@ from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field from typing import Any -from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse from .. import config from ..engine import GameError from . import actions as actions_mod -from .hub import Client, ConnectionHub, Publisher, next_seq +from .hub import ( + CLOSE_MESSAGE, + HEARTBEAT_SECONDS, + IDLE_TICK, + Client, + ConnectionHub, + Publisher, + next_seq, + sse_frame, + sse_heartbeat, +) from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id from .webui import DASHBOARD_HTML, dashboard_text @@ -56,8 +67,8 @@ class ServerConfig: class ServerHandle: """让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。 - * ``on_start``:监听端口成功后触发(用于写 pid 文件等); - * ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。 + * ``on_start``:ASGI 生命周期启动时触发(用于写 pid 文件等); + * ``on_stop``:请求关闭时触发(uvicorn 用它把 ``should_exit`` 置位,开始优雅退出)。 """ def __init__(self) -> None: @@ -89,43 +100,56 @@ class ServerHandle: # ---------------------------------------------------------------------- 工具 +# brief() 从精简状态里挑选的字段白名单:够 AI 做下一步决策,又不至于太吵。 +# 数据源是 FarmEngine.brief_source()(**不是**完整快照),加字段时两处一起改。 +BRIEF_KEYS = ( + "day", + "gold", + "level", + "exp", + "exp_needed", + "ap", + "max_ap", + "feed", + "plot_count", + "max_plots", + "next_plot_price", + "animal_count", + "max_animals", + "net_worth", + "goal_index", + "seconds_to_tick", + "paused", +) + +# 特意为 AI 准备的待办清单:不用遍历整个快照就知道该干什么 +BRIEF_TODO_KEYS = ("ripe_plots", "hungry_animals", "collectable_animals") + + def brief(session: FarmSession, hub: ConnectionHub) -> dict[str, Any]: - """操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。""" - snap = session.snapshot() - return { - "farm": session.farm_id, - "day": snap["day"], - "gold": snap["gold"], - "level": snap["level"], - "exp": snap["exp"], - "exp_needed": snap["exp_needed"], - "ap": snap["ap"], - "max_ap": snap["max_ap"], - "feed": snap["feed"], - "plot_count": snap["plot_count"], - "max_plots": snap["max_plots"], - "next_plot_price": snap["next_plot_price"], - "animal_count": snap["animal_count"], - "max_animals": snap["max_animals"], - "ripe_plots": [p["position"] for p in snap["plots"] if p["is_ripe"]], - "hungry_animals": [a["id"] for a in snap["animals"] if not a["fed"]], - "collectable_animals": [a["id"] for a in snap["animals"] if a["pending"] > 0], - "net_worth": snap["net_worth"], - "goal_index": snap["goal_index"], - "seconds_to_tick": snap["seconds_to_tick"], - "paused": snap["paused"], - "online": hub.online(session.farm_id), - } + """操作后回给调用者的精简状态。 + + 只读引擎直接给出的那十几个标量,**不做完整快照**。以前这里是 + ``session.snapshot()`` 之后再挑字段——每块地、每只牲畜都白白序列化一遍, + 实测每次动作浪费约 60µs;而 ``full=1`` 时后面还要再序列化一次给快照用。 + """ + source = session.engine.brief_source() + data: dict[str, Any] = {"farm": session.farm_id} + data.update({key: source[key] for key in BRIEF_KEYS}) + data.update({key: source[key] for key in BRIEF_TODO_KEYS}) + data["online"] = hub.online(session.farm_id) + return data def rules_text() -> str: return ( f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n" "==========================================\n" - f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。" + f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟" + f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。" f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n" - f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天" - '(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n' + f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——" + "时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n" f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。" f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n" f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n" @@ -142,94 +166,111 @@ def rules_text() -> str: ' POST /api/actions → {"action":"<上一步里的 action>",' '"params":{...}} 直接执行\n' " 响应里的 state 就是执行后的最新状态,无需再查一次。\n" - "\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;" - '也可以直接在 WS 上发送 {"type":"action","action":"plant",' - '"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n' + "\n实时通道(单向推送,服务器 → 客户端):GET /api/stream?farm=me 是一条 SSE " + '长连接,服务器在状态变化时推 {"type":"state",...};\n' + "想主动拉一次用 GET /api/state;写操作一律 POST /api/actions。\n" ) -# ---------------------------------------------------------------------- 应用 -def create_app(cfg: ServerConfig | None = None) -> FastAPI: - cfg = cfg or ServerConfig() - manager = SessionManager( - save_dir=cfg.save_dir, - tick_seconds=cfg.tick_seconds, - max_sessions=cfg.max_sessions, - idle_timeout=cfg.idle_timeout, - ) - hub = ConnectionHub() - publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval) - clock = FarmClock(manager) - handle = ServerHandle() - started_at = time.time() - counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0} +def as_asgi(app: FastAPI) -> Any: + """把应用交给 ASGI 服务器时的类型桥。 + + uvicorn 用它自己那份 ``uvicorn._types.Scope`` 描述 ASGI,starlette(FastAPI) + 用的是 ``starlette.types.Scope``。两者运行期逐字一致,但都是 TypedDict、 + 彼此没有子类型关系,所以 pyright 会拦。返回值声明成 Any 就是明说: + 这个转换由我们担保,问题只在两份类型声明的重复上。 + """ + return app + + +# ---------------------------------------------------------------------- 运行时 +@dataclass +class _Services: + """create_app 装配出来的运行时依赖。 + + 以前这些是 ``create_app`` 的局部变量,于是每个路由都必须是嵌套闭包才能看见 + 它们——一个函数膨胀到 475 行、8 层嵌套。收进一个对象后,路由可以拆成模块级 + 的注册函数,各自只依赖传进来的 ``svc``,读起来是平的。 + """ + + cfg: ServerConfig + manager: SessionManager + hub: ConnectionHub + publisher: Publisher + clock: FarmClock + handle: ServerHandle + started_at: float + counters: dict[str, int] + + @classmethod + def create(cls, cfg: ServerConfig) -> _Services: + manager = SessionManager( + save_dir=cfg.save_dir, + tick_seconds=cfg.tick_seconds, + max_sessions=cfg.max_sessions, + idle_timeout=cfg.idle_timeout, + ) + hub = ConnectionHub() + return cls( + cfg=cfg, + manager=manager, + hub=hub, + publisher=Publisher(manager, hub, autosave_interval=cfg.autosave_interval), + clock=FarmClock(manager), + handle=ServerHandle(), + started_at=time.time(), + # 这里没有 ws_messages:那是 WebSocket 时代的遗留计数, + # 改成 SSE 之后再也没有人加过它,留着只会让人以为它在被用。 + counters={"requests": 0, "actions": 0, "errors": 0}, + ) - # 标注成 asynccontextmanager:FastAPI 的 lifespan 参数要的就是 - # "返回异步上下文管理器",裸 async generator 在类型上并不匹配 @contextlib.asynccontextmanager - async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - clock.start() - publisher.start() - # 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了" - handle.notify_start() + async def lifespan(self, _app: FastAPI) -> AsyncIterator[None]: + """应用生命周期:起时钟与推送线程,退出时按相反顺序收干净。 + + 装饰成 ``asynccontextmanager`` 是因为 FastAPI 要的就是"能当异步上下文 + 管理器用"的东西,裸 async generator 在类型上并不匹配。 + """ + self.clock.start() + self.publisher.start() + # 生命周期启动完成,可以对外宣告"我在了"。uvicorn 的顺序是"先跑 lifespan、 + # 后 bind 端口",所以这里代表"服务已上线",不代表"端口已监听"; + # bind 失败时 pid 文件由 __main__ 的 finally 负责清掉。 + self.handle.notify_start() try: yield finally: - publisher.stop() - clock.stop() - manager.shutdown() - - app = FastAPI( - title=f"{SERVER_NAME} 游戏服务器", - version=API_VERSION, - description="命令行农场游戏的服务端:HTTP 给 AI 用,WebSocket 给实时界面用。", - lifespan=lifespan, - ) - app.state.cfg = cfg - app.state.manager = manager - app.state.hub = hub - app.state.publisher = publisher - app.state.clock = clock - app.state.handle = handle - app.state.started_at = started_at - app.state.counters = counters - - # ------------------------------------------------------------ 异常处理 - @app.exception_handler(GameError) - async def _game_error(_request: Request, exc: GameError) -> JSONResponse: - counters["errors"] += 1 - return JSONResponse(exc.to_dict(), status_code=exc.status) - - @app.exception_handler(json.JSONDecodeError) - async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse: - counters["errors"] += 1 - return JSONResponse( - {"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"}, - status_code=400, - ) + self.publisher.stop() + # 兜底:万一是信号触发退出(没走 /api/admin/shutdown),也把长连接收掉 + self.hub.close_all() + self.clock.stop() + self.manager.shutdown() # ------------------------------------------------------------ 鉴权与取牧场 - def authorized(supplied: str | None) -> bool: - if not cfg.token: - return True - return supplied == cfg.token + def authorized(self, supplied: str | None) -> bool: + # 没配 token 就是谁都能进(本机自娱自乐的默认形态) + return not self.cfg.token or supplied == self.cfg.token + @staticmethod def token_of(request: Request) -> str | None: return request.headers.get("x-api-token") or request.query_params.get("token") - def guard(request: Request) -> None: - counters["requests"] += 1 - if not authorized(token_of(request)): + def guard(self, request: Request) -> None: + """计数 + 鉴权。所有对外路由的第一句都调它。""" + self.counters["requests"] += 1 + if not self.authorized(self.token_of(request)): raise GameError("unauthorized", "缺少或错误的 API Token", status=401) - def session_of(request: Request, owner: str = "") -> FarmSession: + def session_of(self, request: Request, owner: str = "") -> FarmSession: farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM - return manager.get(farm, create=True, owner=owner) + return self.manager.get(farm, create=True, owner=owner) + @staticmethod def full_requested(request: Request) -> bool: raw = (request.query_params.get("full") or "").lower() return raw in ("1", "true", "yes", "on") + @staticmethod async def body_of(request: Request) -> dict[str, Any]: raw = await request.body() if not raw: @@ -242,12 +283,14 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: raise GameError("bad_json", "请求体必须是 JSON 对象") return data - def execute_action(session: FarmSession, name: Any, params: Any) -> dict[str, Any]: + # ------------------------------------------------------------ 动作执行 + def execute_action(self, session: FarmSession, name: Any, params: Any) -> dict[str, Any]: """执行一个动作,返回引擎/会话级的结果字典。 - ``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎: - ``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。 - 这两个动作单独拦截,其余全部走统一的动作表。 + ``save`` 与 ``reset`` 是**存档类动作**,刻意不在 farm.server.actions + 的通用动作表里(那张表只认识引擎方法,而 ``engine.save()`` 只会写到 + 默认存档路径,多牧场环境下会把别人的存档覆盖掉)。它们只能走这里、 + 由牧场会话处理;其余全部走统一的动作表。 """ key = name.strip().lower() if isinstance(name, str) else "" if key == "save": @@ -264,48 +307,70 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: return actions_mod.run_action(session.engine, name, params) def run_and_wrap( - session: FarmSession, name: Any, params: Any, full: bool = False + self, session: FarmSession, name: Any, params: Any, full: bool = False ) -> dict[str, Any]: - """执行动作并拼装统一响应。HTTP 与 WS 共用。""" - result = execute_action(session, name, params) - counters["actions"] += 1 + """执行动作并拼装统一响应。HTTP 各入口共用。""" + result = self.execute_action(session, name, params) + self.counters["actions"] += 1 session.touch(count_action=True) payload: dict[str, Any] = dict(result) payload["ok"] = True payload["farm"] = session.farm_id payload["seq"] = next_seq() - payload["state"] = brief(session, hub) + payload["state"] = brief(session, self.hub) payload["log"] = session.engine.recent_log(limit=3) if full: payload["snapshot"] = session.snapshot() return payload - # ================================================================ 页面 + +# ---------------------------------------------------------------------- 路由分组 +# 每个 _register_* 负责一组关系紧密的接口。分组边界照 OpenAPI 的 tags 来切, +# 所以"接口文档里在哪组"和"代码里在哪段"是对得上的。 +def _register_error_handlers(app: FastAPI, svc: _Services) -> None: + @app.exception_handler(GameError) + async def _game_error(_request: Request, exc: GameError) -> JSONResponse: + svc.counters["errors"] += 1 + return JSONResponse(exc.to_dict(), status_code=exc.status) + + @app.exception_handler(json.JSONDecodeError) + async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse: + svc.counters["errors"] += 1 + return JSONResponse( + {"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"}, + status_code=400, + ) + + +def _register_pages(app: FastAPI, svc: _Services) -> None: @app.get("/", response_class=HTMLResponse, include_in_schema=False) async def index() -> HTMLResponse: return HTMLResponse(DASHBOARD_HTML) @app.get("/text", response_class=PlainTextResponse, include_in_schema=False) async def text_index() -> PlainTextResponse: - return PlainTextResponse(dashboard_text(cfg.host, cfg.port)) + return PlainTextResponse(dashboard_text(svc.cfg.host, svc.cfg.port)) - # ================================================================ 元信息 + +def _register_meta_routes(app: FastAPI, svc: _Services) -> None: @app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)") async def health() -> dict[str, Any]: + # 刻意不鉴权:探针要能在没有 token 的情况下判断"这个服务还活着吗" return { "ok": True, "service": SERVER_NAME, "version": API_VERSION, - "uptime": round(time.time() - started_at, 1), - "farms": manager.count(), - "clients": hub.total(), - "clock_ticks": clock.ticks, - "saves_since_start": publisher.pushes, + "uptime": round(time.time() - svc.started_at, 1), + "farms": svc.manager.count(), + "clients": svc.hub.total(), + "clock_ticks": svc.clock.ticks, + "saves_since_start": svc.publisher.pushes, + "publish_errors": svc.publisher.errors, } @app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)") async def help_page(request: Request) -> dict[str, Any]: - guard(request) + svc.guard(request) return { "ok": True, "service": SERVER_NAME, @@ -323,46 +388,48 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: 'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}' ), "farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}', - "realtime": "WS /ws?farm=NAME", + "realtime": "GET /api/stream?farm=NAME(SSE 单向推送)", }, } + +def _register_world_routes(app: FastAPI, svc: _Services) -> None: @app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟") async def world(request: Request) -> dict[str, Any]: - guard(request) + svc.guard(request) return { "ok": True, - "world": cfg.world_name, - "uptime": round(time.time() - started_at, 1), - "farms": manager.count(), - "clients": hub.total(), - "connections": hub.snapshot(), - "leaderboard": manager.leaderboard(hub.online_map()), - "tick_seconds": cfg.tick_seconds, - "started_at": started_at, + "world": svc.cfg.world_name, + "uptime": round(time.time() - svc.started_at, 1), + "farms": svc.manager.count(), + "clients": svc.hub.total(), + "connections": svc.hub.snapshot(), + "leaderboard": svc.manager.leaderboard(svc.hub.online_map()), + "tick_seconds": svc.cfg.tick_seconds, + "started_at": svc.started_at, } - # ================================================================ 牧场 @app.get("/api/farms", tags=["世界"], summary="牧场排行榜") async def list_farms(request: Request) -> dict[str, Any]: - guard(request) - rows = manager.leaderboard(hub.online_map()) + svc.guard(request) + rows = svc.manager.leaderboard(svc.hub.online_map()) return {"ok": True, "count": len(rows), "farms": rows} @app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场") async def create_farm(request: Request) -> dict[str, Any]: - guard(request) - body = await body_of(request) + svc.guard(request) + body = await svc.body_of(request) farm = validate_farm_id(body.get("farm") or body.get("name")) owner = str(body.get("owner") or "") - existed = manager.find(farm) is not None - session = manager.get(farm, create=True, owner=owner) + existed = svc.manager.find(farm) is not None + session = svc.manager.get(farm, create=True, owner=owner) tick = body.get("tick") if tick is not None: try: tick_value = float(tick) except (TypeError, ValueError): raise GameError("bad_param", "tick 必须是数字") from None + # 直接赋值绕过了引擎构造时的夹取,所以这里得自己夹一次 session.engine.tick_seconds = max( config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value) ) @@ -372,45 +439,46 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: "created": not existed, "message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}", "farm": farm, - "summary": session.summary(hub.online(farm)), - "state": brief(session, hub), + "summary": session.summary(svc.hub.online(farm)), + "state": brief(session, svc.hub), } - # ================================================================ 单牧场只读 + +def _register_farm_routes(app: FastAPI, svc: _Services) -> None: @app.get("/api/state", tags=["牧场"], summary="完整状态快照") async def get_state(request: Request) -> dict[str, Any]: - guard(request) - session = session_of(request) + svc.guard(request) + session = svc.session_of(request) return { "ok": True, "farm": session.farm_id, "state": session.snapshot(), - "online": hub.online(session.farm_id), + "online": svc.hub.online(session.farm_id), } @app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格") async def get_catalog(request: Request) -> dict[str, Any]: - guard(request) - session = session_of(request) + svc.guard(request) + session = svc.session_of(request) return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()} @app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)") async def get_actions(request: Request) -> dict[str, Any]: - guard(request) - session = session_of(request) + svc.guard(request) + session = svc.session_of(request) items = session.engine.available_actions() return { "ok": True, "farm": session.farm_id, "count": len(items), "actions": items, - "state": brief(session, hub), + "state": brief(session, svc.hub), } @app.get("/api/log", tags=["牧场"], summary="最近事件") async def get_log(request: Request) -> dict[str, Any]: - guard(request) - session = session_of(request) + svc.guard(request) + session = svc.session_of(request) limit = _int(request.query_params.get("limit"), 20, 1, 200) since = _int(request.query_params.get("since"), 0, 0, 10**9) entries = session.engine.recent_log(limit=limit, since=since) @@ -422,18 +490,19 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: "last_seq": entries[-1]["seq"] if entries else since, } - # ================================================================ 写操作 + +def _register_action_routes(app: FastAPI, svc: _Services) -> None: @app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)") async def post_action(request: Request) -> dict[str, Any]: - guard(request) - body = await body_of(request) - session = session_of(request) + svc.guard(request) + body = await svc.body_of(request) + session = svc.session_of(request) name = body.get("action") or body.get("name") params = body.get("params") if params is None: # 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"} params = {k: v for k, v in body.items() if k not in ("action", "name", "params")} - return run_and_wrap(session, name, params, full_requested(request)) + return svc.run_and_wrap(session, name, params, svc.full_requested(request)) @app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)") async def post_batch(request: Request) -> dict[str, Any]: @@ -441,9 +510,9 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: 每个动作独立成败,失败的只记错误不中断后续动作。 """ - guard(request) - body = await body_of(request) - session = session_of(request) + svc.guard(request) + body = await svc.body_of(request) + session = svc.session_of(request) items = body.get("actions") if not isinstance(items, list) or not items: raise GameError("bad_param", "actions 必须是至少含一个元素的数组") @@ -455,12 +524,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"}) continue try: - payload = run_and_wrap( + payload = svc.run_and_wrap( session, item.get("action"), item.get("params") or {}, full=False ) + # 逐个动作不必重复回精简状态:末尾统一给一份就够了 results.append({k: v for k, v in payload.items() if k != "state"}) except GameError as exc: - counters["errors"] += 1 + svc.counters["errors"] += 1 results.append({**exc.to_dict(), "action": item.get("action")}) ok_count = sum(1 for r in results if r.get("ok")) payload: dict[str, Any] = { @@ -469,22 +539,24 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: "done": ok_count, "failed": len(results) - ok_count, "results": results, - "state": brief(session, hub), + "state": brief(session, svc.hub), } - if full_requested(request): + if svc.full_requested(request): payload["snapshot"] = session.snapshot() return payload def make_action_route(action_name: str) -> Callable[[Request], Any]: async def route(request: Request) -> dict[str, Any]: - guard(request) - body = await body_of(request) - session = session_of(request) - return run_and_wrap(session, action_name, body, full_requested(request)) + svc.guard(request) + body = await svc.body_of(request) + session = svc.session_of(request) + return svc.run_and_wrap(session, action_name, body, svc.full_requested(request)) route.__name__ = f"post_{action_name}" return route + # 每个动作都额外挂一条自己的路径(POST /api/water 之类),方便直接 curl; + # 语义与 POST /api/actions 完全等价。 for _name, _spec in actions_mod.ACTIONS.items(): app.add_api_route( f"/api/{_name}", @@ -495,12 +567,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: summary=f"{_spec.summary}(等价于 POST /api/actions)", ) - # ================================================================ 管理 + +def _register_admin_routes(app: FastAPI, svc: _Services) -> None: @app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档") async def admin_save(request: Request) -> dict[str, Any]: - guard(request) + svc.guard(request) saved: list[str] = [] - for session in manager.all(): + for session in svc.manager.all(): try: session.save() saved.append(session.farm_id) @@ -512,210 +585,139 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI: @app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)") async def admin_shutdown(request: Request) -> dict[str, Any]: - guard(request) - if not cfg.allow_remote_control: + svc.guard(request) + if not svc.cfg.allow_remote_control: raise GameError("forbidden", "服务器未开放远程停机", status=403) - manager.shutdown() - accepted = handle.request_stop() + svc.manager.shutdown() + # 先让所有 SSE 长连接收尾:否则服务器的优雅停机要一直等它们,变成卡死 + svc.hub.close_all() + accepted = svc.handle.request_stop() return { "ok": True, "message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)", } - # ================================================================ WebSocket - @app.websocket("/ws") - async def ws_endpoint(websocket: WebSocket) -> None: - params = websocket.query_params - supplied = websocket.headers.get("x-api-token") or params.get("token") - if not authorized(supplied): - # 4401 是自定义关闭码,客户端据此提示"令牌不对"而不是"网络断了" - await websocket.close(code=4401, reason="unauthorized") - return - try: - farm = validate_farm_id(params.get("farm") or DEFAULT_FARM) - except GameError as exc: - await websocket.close(code=4400, reason=exc.message[:120]) - return +def _register_stream_route(app: FastAPI, svc: _Services) -> None: + @app.get("/api/stream", tags=["实时"], summary="SSE 实时推送(服务器 → 客户端单向)") + async def stream(request: Request) -> StreamingResponse: + """一条 HTTP 长连接持续推送状态;写操作请走 POST /api/actions。 + + 消息类型:``welcome``(首帧全量)/ ``state``(状态变化)/ ``day``(跨天)/ + ``notice``(有人来去)/ ``error``,每条都是一行 JSON 放在 ``data:`` 后面。 + 客户端断开(关页面、退出 TUI、进程被杀)都会走到生成器的 finally。 + """ + svc.guard(request) + params = request.query_params + farm = validate_farm_id(params.get("farm") or DEFAULT_FARM) + # 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON + session = svc.manager.get(farm, create=True) - await websocket.accept() loop = asyncio.get_running_loop() client = Client(farm, loop, name=(params.get("name") or "")[:32]) - try: - session = manager.get(farm, create=True) - except GameError as exc: - # 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题 - await websocket.send_json({"type": "error", "error": exc.code, "message": exc.message}) - await websocket.close(code=1013, reason=exc.message[:100]) - return - hub.register(client) + svc.hub.register(client) session.touch() - await websocket.send_json( - { - "type": "welcome", - "client_id": client.cid, - "farm": farm, - "server": { - "name": SERVER_NAME, - "version": API_VERSION, - "world": cfg.world_name, - "tick_seconds": session.engine.tick_seconds, - }, - "state": session.snapshot(), - "catalog": session.engine.catalog(), - "actions": session.engine.available_actions(), - "log": session.engine.recent_log(limit=12), - "online": hub.online(farm), - } - ) - hub.broadcast( - farm, - { - "type": "notice", - "farm": farm, - "text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})", - }, - exclude=client, - ) + # "来了/走了"是同一件事的两个方向,差别只有动词,所以共用一个信封 + who = client.name or client.cid - async def writer() -> None: - while True: - message = await client.next_message() - await websocket.send_json(message) + def notice(text: str, exclude: Client | None = None) -> None: + svc.hub.broadcast(farm, {"type": "notice", "farm": farm, "text": text}, exclude=exclude) - async def reader() -> None: - while True: - raw = await websocket.receive_text() - counters["ws_messages"] += 1 - await handle_ws_message(raw) - - async def handle_ws_message(raw: str) -> None: - """处理一条客户端消息。 - - 注意:这里只做「解析 + 派发」,真正的引擎调用丢给线程池, - 因此一条慢查询不会让同一个连接的心跳/推送卡住。 - """ - try: - data = json.loads(raw) - except json.JSONDecodeError as exc: - await websocket.send_json( - {"type": "error", "error": "bad_json", "message": f"不是合法 JSON:{exc}"} - ) - return - if not isinstance(data, dict): - await websocket.send_json( - {"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"} - ) - return - - kind = str(data.get("type") or "").lower() - rid = data.get("id") - - if kind == "ping": - await websocket.send_json({"type": "pong", "id": rid, "t": time.time()}) - return - - if kind in ("sync", "refresh", "state"): - await websocket.send_json( - { - "type": "sync", - "id": rid, - "seq": next_seq(), - "farm": session.farm_id, - "state": session.snapshot(), - "actions": session.engine.available_actions(), - "log": session.engine.recent_log(limit=20), - } - ) - return - - if kind == "help": - await websocket.send_json( - { - "type": "help", - "id": rid, - "rules": rules_text(), - "actions": actions_mod.describe_actions(), - } - ) - return - - if kind == "action": - try: - # 引擎是同步的,丢到线程池里跑,别堵住事件循环 - result = await asyncio.to_thread( - execute_action, - session, - data.get("action"), - data.get("params") or {}, - ) - except GameError as exc: - counters["errors"] += 1 - await websocket.send_json( - { - "type": "result", - "id": rid, - "ok": False, - "error": exc.code, - "message": exc.message, - "farm": session.farm_id, - } - ) - return - counters["actions"] += 1 - session.touch(count_action=True) - await websocket.send_json( - { - "type": "result", - "id": rid, - "ok": True, - "farm": session.farm_id, - "message": result.get("message", "完成"), - "result": result, - "state": brief(session, hub), - # 回执里带上完整快照:客户端可以立刻刷新画面,不必等下一次推送 - "snapshot": session.snapshot(), - "log": session.engine.recent_log(limit=3), - "seq": next_seq(), - } - ) - return - - await websocket.send_json( + async def events() -> AsyncIterator[str]: + # 首帧等价于原来的 welcome:一次把状态、目录、合法动作、日志给全 + yield sse_frame( { - "type": "error", - "error": "unknown_message", - "message": f"不认识的消息类型 {kind!r}", - "hint": "可用:action / ping / sync / help", + "type": "welcome", + "client_id": client.cid, + "farm": farm, + "server": { + "name": SERVER_NAME, + "version": API_VERSION, + "world": svc.cfg.world_name, + "tick_seconds": session.engine.tick_seconds, + }, + "state": session.snapshot(), + "catalog": session.engine.catalog(), + "actions": session.engine.available_actions(), + "log": session.engine.recent_log(limit=12), + "online": svc.hub.online(farm), } ) - - writer_task = asyncio.create_task(writer()) - reader_task = asyncio.create_task(reader()) - try: - await asyncio.wait({writer_task, reader_task}, return_when=asyncio.FIRST_COMPLETED) - except WebSocketDisconnect: - pass - except Exception: - counters["errors"] += 1 - finally: - for task in (writer_task, reader_task): - task.cancel() - hub.unregister(client) - session.touch() - hub.broadcast( - session.farm_id, - { - "type": "notice", - "farm": session.farm_id, - "text": ( - f"{client.name or client.cid} 离开了牧场" - f"(在线 {hub.online(session.farm_id)})" - ), - }, + notice( + f"{who} 来到了牧场(在线 {svc.hub.online(farm)})", + exclude=client, ) + try: + last_beat = time.time() + while True: + if svc.handle.stopping: + # 服务器要退了:主动收尾,别让长连接拖住优雅停机 + yield sse_frame(dict(CLOSE_MESSAGE)) + break + try: + message = await asyncio.wait_for(client.next_message(), timeout=IDLE_TICK) + except asyncio.TimeoutError: + # 空转一秒只是为了让上面的停机检查有机会跑; + # 心跳按自己的间隔发,别刷屏 + if time.time() - last_beat >= HEARTBEAT_SECONDS: + last_beat = time.time() + yield sse_heartbeat() + continue + if message.get("type") == "bye": + yield sse_frame(message) + break + yield sse_frame(message) + finally: + svc.hub.unregister(client) + session.touch() + notice(f"{who} 离开了牧场(在线 {svc.hub.online(farm)})") + return StreamingResponse( + events(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + # 让 nginx 之类的反代不要缓冲,否则推送会攒着一次性发出来 + "X-Accel-Buffering": "no", + }, + ) + + +# ---------------------------------------------------------------------- 应用 +def create_app(cfg: ServerConfig | None = None) -> FastAPI: + """装配应用:依赖 → 生命周期 → 各路由分组。 + + 路由各自藏在 ``_register_*`` 里,所以这里读起来就是一份目录: + 想找哪个接口,按分组名翻就行,不必在几百行里爬。 + """ + cfg = cfg or ServerConfig() + svc = _Services.create(cfg) + + app = FastAPI( + title=f"{SERVER_NAME} 游戏服务器", + version=API_VERSION, + description="命令行农场游戏的服务端:HTTP 给 AI 用,SSE 给实时界面用。", + lifespan=svc.lifespan, + ) + app.state.cfg = cfg + app.state.manager = svc.manager + app.state.hub = svc.hub + app.state.publisher = svc.publisher + app.state.clock = svc.clock + app.state.handle = svc.handle + app.state.started_at = svc.started_at + app.state.counters = svc.counters + + _register_error_handlers(app, svc) + _register_pages(app, svc) + _register_meta_routes(app, svc) + _register_world_routes(app, svc) + _register_farm_routes(app, svc) + _register_action_routes(app, svc) + _register_admin_routes(app, svc) + _register_stream_route(app, svc) return app diff --git a/farm/server/daemon.py b/farm/server/daemon.py index b145b4d..2bf215f 100644 --- a/farm/server/daemon.py +++ b/farm/server/daemon.py @@ -19,6 +19,7 @@ import contextlib import json import os import signal +import socket import subprocess import sys import time @@ -66,6 +67,27 @@ def pid_alive(pid: int) -> bool: return True +def port_busy(host: str, port: int, timeout: float = 0.6) -> bool: + """端口上已经有程序在监听了吗。 + + 用"能不能连上"判断,而不是"能不能 bind":Windows 上只要两边都设了 + ``SO_REUSEADDR``(uvicorn 会设),第二个进程也能绑上同一个 + 端口、然后抢走新连接——于是 bind 探测会给出一个自信的错误答案。 + """ + + if host in ("0.0.0.0", ""): + target = "127.0.0.1" + elif host == "::": + target = "::1" + else: + target = host + try: + with socket.create_connection((target, port), timeout=timeout): + return True + except OSError: + return False + + class DaemonManager: """负责在后台拉起/停止 ``farm.server`` 进程。""" @@ -118,39 +140,35 @@ class DaemonManager: return None # ------------------------------------------------------------ 探测 - def probe(self, timeout: float = 1.5) -> dict[str, Any] | None: - """访问 /api/health,成功说明服务真的在监听。 + def _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None: + """访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。 - 用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走, + 一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走, 否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。 - """ - url = f"http://{self.host}:{self.port}/api/health" - try: - with net.open_url(url, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) - except (urllib.error.URLError, OSError, ValueError): - return None - def world(self, timeout: float = 2.0) -> dict[str, Any] | None: - url = f"http://{self.host}:{self.port}/api/world" - if self.token: + 末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组, + 调用方的 ``.get(...)`` 会直接崩在属性访问上。 + """ + url = f"http://{self.host}:{self.port}{path}" + if token and self.token: url += "?token=" + urllib.parse.quote(self.token) try: with net.open_url(url, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) + payload = json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, OSError, ValueError): return None + return payload if isinstance(payload, dict) else None + + def probe(self, timeout: float = 1.5) -> dict[str, Any] | None: + """访问 /api/health,成功说明服务真的在监听。""" + return self._get_json("/api/health", timeout) + + def world(self, timeout: float = 2.0) -> dict[str, Any] | None: + return self._get_json("/api/world", timeout, token=True) def port_busy(self) -> bool: """端口被别的程序占了吗(用于给出"换端口"的提示)。""" - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(0.6) - return ( - sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port)) - == 0 - ) + return port_busy(self.host, self.port) # ------------------------------------------------------------ 启动 def command(self) -> list[str]: diff --git a/farm/server/hub.py b/farm/server/hub.py index e147ba9..6bf3739 100644 --- a/farm/server/hub.py +++ b/farm/server/hub.py @@ -1,20 +1,23 @@ -"""实时通道:WebSocket 连接池 + 状态推送线程。 +"""实时通道:SSE 订阅池 + 状态推送线程。 -为什么选 WebSocket 而不是 SSE ------------------------------ -这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门), -客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个 -HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。 -WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。 +为什么是 SSE +------------ +这个游戏的下行(作物成熟、别人来串门)需要服务器**主动**推给客户端, +而上行(播种、喂食)本来就是普通的 HTTP 写请求。SSE(Server-Sent Events) +正好是"一条 HTTP 长连接单向推流",上行复用 REST,两边各用最自然的协议: + +* 纯 HTTP 语义:`curl`、浏览器 `EventSource`、任何 HTTP 客户端都能读; +* 不用额外协议栈:客户端只需 urllib 流式读,连第三方库都不用装; +* 天然支持重连(浏览器 EventSource 自动重连,本项目的客户端自己重连)。 线程模型(重点) ---------------- uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。 两者之间不能直接调用对方的 API,所以: -* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**; +* 每条订阅持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**; * 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列; -* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。 +* 每条订阅有一个生成器协程,只负责「从队列取出 → yield 成 SSE 帧」。 这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。 """ @@ -24,13 +27,19 @@ from __future__ import annotations import asyncio import contextlib import itertools +import json import threading import time from collections import defaultdict from typing import Any STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次 -QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢) +QUEUE_SIZE = 32 # 单订阅出口缓冲区;满了就丢最旧的(状态类消息可丢) +HEARTBEAT_SECONDS = 15.0 # 无消息时的 SSE 心跳间隔,防中间代理掐断空闲连接 +IDLE_TICK = 1.0 # 生成器空转的唤醒间隔:用于发现"服务器要停机了" + +# 停机时推给每个订阅者的收尾消息:客户端据此知道是服务器主动断开的 +CLOSE_MESSAGE: dict[str, Any] = {"type": "bye", "message": "服务器正在停机"} _seq_counter = itertools.count(1) @@ -44,8 +53,22 @@ def next_seq() -> int: return next(_seq_counter) +def sse_frame(message: dict[str, Any]) -> str: + """把一条消息编码成 SSE 帧。 + + SSE 的格式很简单:``data: <一行文本>`` 加一个空行。 + JSON 本身不含裸换行(``json.dumps`` 会把换行转义),所以一帧一行就够。 + """ + return f"data: {json.dumps(message, ensure_ascii=False)}\n\n" + + +def sse_heartbeat() -> str: + """SSE 注释行心跳:客户端会忽略,但能让代理/防火墙看到连接还活着。""" + return ": keep-alive\n\n" + + class Client: - """一条 WebSocket 连接。""" + """一条 SSE 订阅(一个浏览器标签页 / 一个 TUI / 一个脚本)。""" _ids = itertools.count(1) @@ -74,7 +97,7 @@ class Client: # ------------------------------------------------------------ 线程安全投递 def push(self, message: dict[str, Any]) -> None: - """从任意线程把消息排队到该连接的写协程。""" + """从任意线程把消息排队到该订阅的生成器协程。""" if not self.alive: return try: @@ -110,7 +133,7 @@ class Client: class ConnectionHub: - """按牧场分组的连接登记处。""" + """按牧场分组的 SSE 订阅登记处。""" def __init__(self) -> None: self._lock = threading.RLock() @@ -154,21 +177,27 @@ class ConnectionHub: with self._lock: return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)] - # ------------------------------------------------------------ 广播 - def broadcast( - self, farm_id: str, message: dict[str, Any], exclude: Client | None = None - ) -> int: - sent = 0 - for client in self.clients(farm_id): - if client is exclude: - continue - client.push(message) - sent += 1 - return sent + # ------------------------------------------------------------ 收尾 + def close_all(self) -> int: + """让所有 SSE 流主动收尾,返回受影响的订阅数。 - def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int: + 停机时必须做这件事:SSE 是**永不自己结束**的长连接,只要还有一条挂着, + uvicorn 的优雅停机就要一直等它,`stop` 会卡到超时。发一条 ``bye`` + 让每个流的生成器自己 break,连接随之正常关闭。 + """ with self._lock: targets = list(self._all) + return self._push_many(targets, dict(CLOSE_MESSAGE), None) + + # ------------------------------------------------------------ 广播 + def _push_many( + self, targets: list[Client], message: dict[str, Any], exclude: Client | None + ) -> int: + """给一批订阅者推送同一条消息,返回送出的条数。 + + ``Client.push`` 只是把消息排进队列、不改动它,所以这里可以放心让所有 + 订阅者共用同一个 dict(不必每人拷一份)。 + """ sent = 0 for client in targets: if client is exclude: @@ -177,9 +206,21 @@ class ConnectionHub: sent += 1 return sent + def broadcast( + self, farm_id: str, message: dict[str, Any], exclude: Client | None = None + ) -> int: + """推给某个牧场里的所有订阅者。""" + return self._push_many(self.clients(farm_id), message, exclude) + + def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int: + """推给所有牧场的所有订阅者。""" + with self._lock: + targets = list(self._all) + return self._push_many(targets, message, exclude) + class Publisher(threading.Thread): - """后台推送线程:把「状态变了」这件事节流后送给在线的客户端。 + """后台推送线程:把「状态变了」这件事节流后送给在线的订阅者。 引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里 按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧, @@ -193,6 +234,7 @@ class Publisher(threading.Thread): self.autosave_interval = autosave_interval self._stop = threading.Event() self.pushes = 0 + self.errors = 0 self._last_autosave = time.time() self._started_at = time.time() @@ -213,8 +255,9 @@ class Publisher(threading.Thread): session.dirty = False self._push_state(session, now) except Exception: - # 推送线程绝不能因为单个牧场出错而退出 - pass + # 推送线程绝不能因为单个牧场出错而退出; + # 但也不能完全无声——计数进 self.errors,/api/health 可观测 + self.errors += 1 if now - self._last_autosave >= self.autosave_interval: self._last_autosave = now diff --git a/farm/server/session.py b/farm/server/session.py index fc8b192..e822d25 100644 --- a/farm/server/session.py +++ b/farm/server/session.py @@ -23,7 +23,7 @@ import time from typing import Any from .. import config -from ..engine import FarmEngine, GameError +from ..engine import FarmEngine, GameError, tick_decision from ..storage import load_state, save_state DEFAULT_FARM = "default" @@ -72,6 +72,10 @@ class FarmSession: self._lock = threading.RLock() self.engine = FarmEngine(tick_seconds=tick_seconds) + # 立刻排好第一次推进:否则新建牧场要等时钟线程下一轮(≤ accuracy 秒) + # 才有 deadline,这段时间 snapshot 里的倒计时是 None, + # 客户端会误以为"已暂停 / 手动模式"。 + self.engine.next_tick_at = time.time() + self.engine.tick_seconds # 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写 self.dirty = True self.published_day = 0 @@ -153,19 +157,24 @@ class FarmSession: return data def summary(self, online: int = 0) -> dict[str, Any]: - """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。""" - snap = self.engine.snapshot() + """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。 + + 走 :meth:`FarmEngine.brief_source` 而**不是** ``snapshot()``: + 排行榜会对每个牧场调一次,而这里只用得上十几个标量——为它序列化全量 + 地块与牲畜,成本会随牧场数线性放大(32 个牧场实测 ~1.9ms)。 + """ + brief = self.engine.brief_source() return { "farm": self.farm_id, "owner": self.owner, - "day": snap["day"], - "gold": snap["gold"], - "level": snap["level"], - "net_worth": snap["net_worth"], - "plots": f"{snap['plot_count']}/{snap['max_plots']}", - "animals": snap["animal_count"], - "goal_index": snap["goal_index"], - "paused": snap["paused"], + "day": brief["day"], + "gold": brief["gold"], + "level": brief["level"], + "net_worth": brief["net_worth"], + "plots": f"{brief['plot_count']}/{brief['max_plots']}", + "animals": brief["animal_count"], + "goal_index": brief["goal_index"], + "paused": brief["paused"], "online": online, "actions": self.actions_taken, "idle_seconds": round(time.time() - self.last_active, 1), @@ -292,17 +301,12 @@ class FarmClock(threading.Thread): for session in self.manager.all(): engine = session.engine try: - if engine.paused: - # 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick - engine.next_tick_at = now + engine.tick_seconds - continue - deadline = engine.next_tick_at - if deadline is None: - engine.next_tick_at = now + engine.tick_seconds - continue - if now >= deadline: + # tick 决策与单机 Scheduler 共用同一份纯函数,避免两处逻辑漂移 + advance, engine.next_tick_at = tick_decision( + engine.paused, engine.next_tick_at, engine.tick_seconds, now + ) + if advance: session.advance_day("时间流逝") - engine.next_tick_at = now + engine.tick_seconds self.ticks += 1 except Exception: # 单个牧场出错不影响其它牧场的时间 diff --git a/farm/server/webui.py b/farm/server/webui.py index 590f436..ff91557 100644 --- a/farm/server/webui.py +++ b/farm/server/webui.py @@ -3,7 +3,7 @@ 为什么要内嵌而不是写一堆静态文件 -------------------------------- 服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理 -静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket +静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE 协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。 注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string`` @@ -132,7 +132,6 @@ DASHBOARD_HTML = r""" - @@ -159,7 +158,7 @@ DASHBOARD_HTML = r""" @@ -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" diff --git a/farm/tui.py b/farm/tui.py index b80522a..e478680 100644 --- a/farm/tui.py +++ b/farm/tui.py @@ -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, ), diff --git a/main.py b/main.py index 52e4376..c1dd8f8 100644 --- a/main.py +++ b/main.py @@ -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,两条路改的是同一个引擎。 """ diff --git a/pyproject.toml b/pyproject.toml index bfd5bbe..dd8e5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 同时考虑所有平台。 diff --git a/requirements.txt b/requirements.txt index 2ec4615..96e8d0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..e21f27d --- /dev/null +++ b/tests/test_actions.py @@ -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) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..d811254 --- /dev/null +++ b/tests/test_bot.py @@ -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) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..4400883 --- /dev/null +++ b/tests/test_client.py @@ -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, "不是 JSON".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() diff --git a/tests/test_engine.py b/tests/test_engine.py index 041e938..6948af7 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,458 +1,912 @@ -"""核心引擎的单元测试。 +"""引擎层测试:游戏规则、边界与存档。 -运行方式: - python -m unittest discover -s tests -v -或: - python tests/test_engine.py +这一层是"纯规则",不依赖 FastAPI / uvicorn,所以能单独跑: + + uv run python -m unittest tests.test_engine -v + +设计原则:**只通过公开接口断言行为**。私有方法(``_gain_exp`` 之类)只在 +"没有公开路径能触达"时才碰,且要说明为什么。 """ from __future__ import annotations -import contextlib +import json import os -import random -import sys -import threading +import tempfile +import time import unittest - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from unittest import mock from farm import config -from farm.engine import FarmEngine, GameError -from farm.models import GameState, Plot -from farm.storage import load_state, save_state +from farm.engine import FarmEngine, GameError, Scheduler, tick_decision +from farm.models import Animal, GameState, Plot, clamp, format_duration +from farm.server.actions import run_action +from farm.storage import SAVE_VERSION, delete_save, load_state, save_state -class EngineTestCase(unittest.TestCase): - def setUp(self) -> None: - random.seed(20260918) - self.engine = FarmEngine(tick_seconds=5) - # 存档相关测试会写文件,统一放到临时目录 - self.tmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp") - os.makedirs(self.tmp, exist_ok=True) - - # ============================================================ 初始状态 - def test_initial_state(self) -> None: - state = self.engine.state - self.assertEqual(state.day, 1) - self.assertEqual(state.gold, config.START_GOLD) - self.assertEqual(state.level, 1) - self.assertEqual(state.ap, config.START_AP) - self.assertEqual(len(state.plots), config.BASE_PLOTS) - self.assertEqual(state.animals, []) - self.assertEqual(state.exp_needed, config.exp_to_next(1)) - - # ============================================================ 种植 - def test_plant_costs_gold_and_ap(self) -> None: - before_gold = self.engine.state.gold - before_ap = self.engine.state.ap - self.engine.plant(1, "wheat") - plot = self.engine.state.plots[0] - self.assertEqual(plot.crop, "wheat") - self.assertEqual(self.engine.state.gold, before_gold - config.CROPS["wheat"]["seed_cost"]) - self.assertEqual(self.engine.state.ap, before_ap - config.AP_COST["plant"]) - self.assertEqual(plot.moisture, config.PLANT_INITIAL_MOISTURE) - - def test_plant_twice_on_same_plot_fails(self) -> None: - self.engine.plant(1, "wheat") - with self.assertRaises(GameError) as ctx: - self.engine.plant(1, "carrot") - self.assertEqual(ctx.exception.code, "plot_busy") - - def test_plant_out_of_range_plot(self) -> None: - for bad in (0, -1, 99, "abc", None): - with self.assertRaises(GameError) as ctx: - self.engine.plant(bad, "wheat") - self.assertIn(ctx.exception.code, ("no_such_plot", "bad_param")) - - def test_plant_unknown_crop(self) -> None: - with self.assertRaises(GameError) as ctx: - self.engine.plant(1, "banana") - self.assertEqual(ctx.exception.code, "unknown_crop") - with self.assertRaises(GameError) as ctx2: - self.engine.plant(1, 123) - self.assertEqual(ctx2.exception.code, "unknown_crop") - - def test_plant_locked_crop(self) -> None: - with self.assertRaises(GameError) as ctx: - self.engine.plant(1, "pumpkin") - self.assertEqual(ctx.exception.code, "locked") - - def test_plant_without_gold(self) -> None: - self.engine.state.gold = 1 - with self.assertRaises(GameError) as ctx: - self.engine.plant(1, "wheat") - self.assertEqual(ctx.exception.code, "not_enough_gold") - - def test_plant_without_ap(self) -> None: - self.engine.state.ap = 1 - self.engine.plant(1, "wheat") - with self.assertRaises(GameError) as ctx: - self.engine.plant(2, "wheat") - self.assertEqual(ctx.exception.code, "no_ap") - - # ============================================================ 生长与水分 - def test_crop_needs_water_to_grow(self) -> None: - self.engine.plant(1, "wheat") - plot = self.engine.state.plots[0] - # 播种自带 2 点水分,推进 2 天刚好成熟 - self.engine.advance_day() - self.engine.advance_day() - self.assertTrue(plot.is_ripe) - - def test_crop_stops_growing_without_water(self) -> None: - self.engine.state.level = 5 - self.engine.plant(1, "corn") # 需要 6 天 - plot = self.engine.state.plots[0] - for _ in range(5): - self.engine.advance_day() - # 只有 2 点初始水分,所以最多长 2 天 - self.assertEqual(plot.growth, 2) - self.assertEqual(plot.moisture, 0) - self.assertFalse(plot.is_ripe) - - def test_water_restores_moisture(self) -> None: - self.engine.state.level = 5 - self.engine.plant(1, "corn") - plot = self.engine.state.plots[0] - self.engine.state.ap = 10 - self.engine.water(1) - self.assertEqual(plot.moisture, config.WATER_CAPACITY) - - def test_water_empty_plot_fails(self) -> None: - with self.assertRaises(GameError) as ctx: - self.engine.water(1) - self.assertEqual(ctx.exception.code, "plot_empty") - - def test_water_when_full_fails(self) -> None: - self.engine.plant(1, "wheat") - self.engine.water(1) - with self.assertRaises(GameError) as ctx: - self.engine.water(1) - self.assertEqual(ctx.exception.code, "already_watered") - - # ============================================================ 收获 - def test_harvest_not_ripe(self) -> None: - self.engine.plant(1, "wheat") - with self.assertRaises(GameError) as ctx: - self.engine.harvest(1) - self.assertEqual(ctx.exception.code, "not_ripe") - - def test_harvest_empty_plot(self) -> None: - with self.assertRaises(GameError) as ctx: - self.engine.harvest(1) - self.assertEqual(ctx.exception.code, "plot_empty") - - def test_harvest_gives_gold_and_exp(self) -> None: - self.engine.plant(1, "wheat") - self.engine.advance_day() - self.engine.advance_day() - gold = self.engine.state.gold - result = self.engine.harvest(1) - self.assertTrue(result["ok"]) - self.assertGreater(result["earned"], 0) - self.assertEqual(self.engine.state.gold, gold + result["earned"]) - self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"]) - self.assertIsNone(self.engine.state.plots[0].crop) - - def test_exp_accumulates(self) -> None: - self.engine.plant(1, "wheat") - self.engine.advance_day() - self.engine.advance_day() - self.engine.harvest(1) - self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"]) - - # ============================================================ 等级与土地 - def test_level_up_unlocks_and_rewards(self) -> None: - state = self.engine.state - state.exp = state.exp_needed - 1 - # 种一茬小麦拿经验,触发升级 - self.engine.plant(1, "wheat") - self.engine.advance_day() - self.engine.advance_day() - gold_before = state.gold - self.engine.harvest(1) - self.assertEqual(state.level, 2) - self.assertGreaterEqual(state.gold, gold_before + config.level_up_bonus(2)) - self.assertEqual(state.max_plots, config.BASE_PLOTS + config.PLOTS_PER_LEVEL) - - def test_buy_plot_and_limit(self) -> None: - self.engine.state.gold = 99999 - self.engine.state.level = 10 - before = len(self.engine.state.plots) - self.engine.buy_plot() - self.assertEqual(len(self.engine.state.plots), before + 1) - - while len(self.engine.state.plots) < config.MAX_PLOTS: - self.engine.buy_plot() - self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS) - with self.assertRaises(GameError) as ctx: - self.engine.buy_plot() - self.assertEqual(ctx.exception.code, "plot_limit") - - def test_buy_plot_without_gold(self) -> None: - self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步 - self.engine.state.gold = 0 - with self.assertRaises(GameError) as ctx: - self.engine.buy_plot() - self.assertEqual(ctx.exception.code, "not_enough_gold") - - def test_buy_plot_blocked_at_level_cap(self) -> None: - self.engine.state.gold = 10**6 - with self.assertRaises(GameError) as ctx: - self.engine.buy_plot() - self.assertEqual(ctx.exception.code, "plot_limit") - - # ============================================================ 牲畜 - def test_buy_animal_and_produce(self) -> None: - self.engine.state.gold = 1000 - result = self.engine.buy_animal("chicken") - self.assertEqual(result["animal"]["kind"], "chicken") - self.assertEqual(len(self.engine.state.animals), 1) - - animal = self.engine.state.animals[0] - self.engine.feed(animal.aid) - self.assertTrue(animal.fed) - self.engine.advance_day() - self.assertEqual(animal.pending, 1) - - gold = self.engine.state.gold - result = self.engine.collect(animal.aid) - self.assertEqual(result["quantity"], 1) - self.assertGreater(self.engine.state.gold, gold - 1) - self.assertEqual(animal.pending, 0) - - def test_feed_twice_fails(self) -> None: - self.engine.state.gold = 1000 - self.engine.buy_animal("chicken") - animal = self.engine.state.animals[0] - self.engine.feed(animal.aid) - with self.assertRaises(GameError) as ctx: - self.engine.feed(animal.aid) - self.assertEqual(ctx.exception.code, "already_fed") - - def test_collect_without_product(self) -> None: - self.engine.state.gold = 1000 - self.engine.buy_animal("chicken") - animal = self.engine.state.animals[0] - with self.assertRaises(GameError) as ctx: - self.engine.collect(animal.aid) - self.assertEqual(ctx.exception.code, "nothing_to_collect") - - def test_animal_starves_to_death(self) -> None: - self.engine.state.gold = 1000 - self.engine.buy_animal("chicken") - for _ in range(3): - self.engine.advance_day() - self.assertEqual(len(self.engine.state.animals), 0) - - def test_animal_survives_if_fed(self) -> None: - self.engine.state.gold = 1000 - self.engine.state.feed = 10 - self.engine.buy_animal("chicken") - animal = self.engine.state.animals[0] - for _ in range(6): - self.engine.feed(animal.aid) - self.engine.advance_day() - self.assertEqual(len(self.engine.state.animals), 1) - self.assertGreater(animal.pending, 0) - - def test_unknown_animal(self) -> None: - with self.assertRaises(GameError) as ctx: - self.engine.buy_animal("dragon") - self.assertEqual(ctx.exception.code, "unknown_animal") - - def test_pasture_limit(self) -> None: - self.engine.state.gold = 10**6 - for _ in range(config.MAX_ANIMALS): - self.engine.buy_animal("chicken") - with self.assertRaises(GameError) as ctx: - self.engine.buy_animal("chicken") - self.assertEqual(ctx.exception.code, "pasture_full") - - def test_emergency_feed_when_out_of_stock(self) -> None: - self.engine.state.gold = 1000 - self.engine.buy_animal("chicken") - self.engine.state.feed = 0 - animal = self.engine.state.animals[0] - gold = self.engine.state.gold - self.engine.feed(animal.aid) - self.assertTrue(animal.fed) - self.assertLess(self.engine.state.gold, gold) # 被扣了高价补货费 - - def test_buy_feed_validation(self) -> None: - self.engine.state.gold = 10**6 - with self.assertRaises(GameError): - self.engine.buy_feed(0) - with self.assertRaises(GameError): - self.engine.buy_feed(-5) - with self.assertRaises(GameError): - self.engine.buy_feed(config.MAX_FEED_PURCHASE + 1) - with self.assertRaises(GameError): - self.engine.buy_feed("十份") - self.engine.buy_feed(5) - self.assertEqual(self.engine.state.feed, config.START_FEED + 5) - - # ============================================================ 时间与市场 - def test_advance_day_resets_ap(self) -> None: - self.engine.state.ap = 0 - self.engine.advance_day() - self.assertEqual(self.engine.state.ap, config.START_AP) - self.assertEqual(self.engine.state.day, 2) - - def test_sleep_cooldown(self) -> None: - self.engine.sleep() - with self.assertRaises(GameError) as ctx: - self.engine.sleep() - self.assertEqual(ctx.exception.code, "cooldown") - - def test_market_prices_stay_in_range(self) -> None: - for _ in range(60): - self.engine.advance_day() - for key, info in config.CROPS.items(): - price = self.engine.market_price(key) - base = info["base_price"] - self.assertGreaterEqual(price, base * 0.5) - self.assertLessEqual(price, base * 1.6) - - def test_pause_toggle(self) -> None: - self.engine.pause(True) - self.assertTrue(self.engine.paused) - self.engine.pause(False) - self.assertFalse(self.engine.paused) - result = self.engine.pause() - self.assertTrue(result["paused"]) - self.engine.pause(False) - - # ============================================================ 查询接口 - def test_snapshot_is_serializable(self) -> None: - import json - - self.engine.plant(1, "wheat") - snap = self.engine.snapshot() - text = json.dumps(snap, ensure_ascii=False) - self.assertIn("plots", text) - - def test_available_actions_reference_valid_plots(self) -> None: - actions = self.engine.available_actions() - self.assertTrue(any(a["action"] == "plant" for a in actions)) - for action in actions: - if action["action"] in ("plant", "water", "harvest"): - plot = action["params"]["plot"] - self.assertTrue(1 <= plot <= len(self.engine.state.plots)) - - def test_catalog_marks_locked(self) -> None: - catalog = self.engine.catalog() - by_key = {c["key"]: c for c in catalog["crops"]} - self.assertFalse(by_key["wheat"]["locked"]) - self.assertTrue(by_key["pumpkin"]["locked"]) - - # ============================================================ 存档 - def test_save_load_roundtrip(self) -> None: - self.engine.plant(1, "carrot") - self.engine.buy_animal("chicken") - self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言 - self.engine.state.feed = 7 - self.engine.state.day = 13 - path = os.path.join(self.tmp, "roundtrip.json") - - save_state(self.engine.state, path) - loaded, message = load_state(path) - self.assertIsNotNone(loaded, message) - assert loaded is not None - self.assertEqual(loaded.gold, 500) - self.assertEqual(loaded.feed, 7) - self.assertEqual(loaded.day, 13) - self.assertEqual(loaded.plots[0].crop, "carrot") - self.assertEqual(loaded.animals[0].kind, "chicken") - - def test_load_missing_file(self) -> None: - state, message = load_state(os.path.join(self.tmp, "nope.json")) - self.assertIsNone(state) - self.assertIn("未找到", message) - - def test_load_corrupt_file(self) -> None: - path = os.path.join(self.tmp, "broken.json") - with open(path, "w", encoding="utf-8") as handle: - handle.write("{ not valid json") - state, message = load_state(path) - self.assertIsNone(state) - self.assertIn("解析", message) - - def test_load_wrong_version(self) -> None: - import json - - path = os.path.join(self.tmp, "old.json") - with open(path, "w", encoding="utf-8") as handle: - json.dump({"version": 99, "state": {}}, handle) - state, message = load_state(path) - self.assertIsNone(state) - self.assertIn("版本", message) - - def test_state_from_dict_repairs_bad_data(self) -> None: - state = GameState.from_dict( - { - "day": -5, - "gold": -100, - "level": 0, - "plots": [{"index": 0, "crop": "不存在", "growth": 999, "moisture": 99}], - "animals": [{"kind": "独角兽"}], - "market": {"wheat": "not a number"}, - } - ) - self.assertEqual(state.day, 1) - self.assertEqual(state.gold, 0) - self.assertEqual(state.level, 1) - self.assertIsNone(state.plots[0].crop) # 非法作物被清空 - self.assertEqual(state.animals, []) # 非法动物被丢弃 - self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值 - self.assertGreaterEqual(len(state.plots), config.BASE_PLOTS) - - # ============================================================ 并发 - def test_concurrent_operations_are_safe(self) -> None: - self.engine.state.gold = 10**6 - errors = [] - - def worker(index: int) -> None: - try: - for _ in range(30): - self.engine.snapshot() - self.engine.available_actions() - if index % 3 == 0: - # 金币可能已经不够了,这里只关心它不崩溃 - with contextlib.suppress(GameError): - self.engine.buy_feed(1) - except Exception as exc: # 记录任何非预期异常 - errors.append(exc) - - threads = [threading.Thread(target=worker, args=(i,)) for i in range(12)] - for thread in threads: - thread.start() - for thread in threads: - thread.join() - self.assertEqual(errors, []) +def make_engine(**kwargs) -> FarmEngine: + """造一个测试引擎:默认给足金币,省得每个用例都先攒钱。""" + engine = FarmEngine(**kwargs) + engine.state.gold = 2000 + return engine -class PlotModelTestCase(unittest.TestCase): - def test_stage_progression(self) -> None: +class FormatDurationTestCase(unittest.TestCase): + """时长格式化:界面与命令行都靠它,别让 2400 秒显示成 "2399.6s"。""" + + def test_none_means_no_schedule(self): + self.assertEqual(format_duration(None), "—") + + def test_seconds_below_a_minute(self): + self.assertEqual(format_duration(0), "0s") + self.assertEqual(format_duration(45), "45s") + self.assertEqual(format_duration(59.9), "59s") + + def test_minutes_and_seconds_are_zero_padded(self): + self.assertEqual(format_duration(60), "1:00") + self.assertEqual(format_duration(2400), "40:00") + self.assertEqual(format_duration(2399.6), "39:59") + self.assertEqual(format_duration(65), "1:05") + + def test_hours_are_shown_when_needed(self): + self.assertEqual(format_duration(3600), "1:00:00") + self.assertEqual(format_duration(86400), "24:00:00") + + def test_negative_is_clamped_to_zero(self): + self.assertEqual(format_duration(-5), "0s") + + +class ClampTestCase(unittest.TestCase): + def test_clamp_keeps_value_inside_range(self): + self.assertEqual(clamp(5, 0, 10), 5) + self.assertEqual(clamp(-1, 0, 10), 0) + self.assertEqual(clamp(99, 0, 10), 10) + self.assertEqual(clamp(3.5, 0, 10), 3.5) + + +class PlotTestCase(unittest.TestCase): + def test_empty_plot_defaults(self): plot = Plot(0) + self.assertTrue(plot.is_empty) + self.assertFalse(plot.is_ripe) + self.assertEqual(plot.days_left, 0) + self.assertEqual(plot.grow_days, 0) self.assertEqual(plot.stage, "空地") - plot.crop = "wheat" + + def test_growth_stages_follow_config_thresholds(self): + plot = Plot(0) + plot.crop = "corn" # 6 天成熟 + plot.growth = 0 self.assertEqual(plot.stage, "已播种") - plot.growth = 0.7 + plot.growth = 2 # 33% self.assertEqual(plot.stage, "幼苗") - plot.growth = 1.5 + plot.growth = 4 # 66% self.assertEqual(plot.stage, "生长中") - plot.growth = 2 - self.assertTrue(plot.is_ripe) + plot.growth = 6 self.assertEqual(plot.stage, "已成熟") - def test_days_left_never_negative(self) -> None: - plot = Plot(0) - plot.crop = "wheat" - plot.growth = 99 + def test_ripeness_and_days_left(self): + plot = Plot(2) + plot.crop = "carrot" # 3 天 + plot.growth = 1 + self.assertFalse(plot.is_ripe) + self.assertEqual(plot.days_left, 2) + plot.growth = 3 + self.assertTrue(plot.is_ripe) self.assertEqual(plot.days_left, 0) + def test_to_dict_exposes_position_which_is_one_based(self): + data = Plot(3).to_dict() + self.assertEqual(data["index"], 3) + self.assertEqual(data["position"], 4) + + def test_from_dict_drops_crop_names_that_no_longer_exist(self): + """旧存档里可能留着已经删掉的作物,读档不能 KeyError。""" + plot = Plot.from_dict({"index": 0, "crop": "古老的神秘作物", "growth": 3}) + self.assertIsNone(plot.crop) + self.assertTrue(plot.is_empty) + + def test_from_dict_survives_missing_fields(self): + plot = Plot.from_dict({}) + self.assertEqual(plot.index, 0) + self.assertEqual(plot.growth, 0.0) + self.assertEqual(plot.moisture, 0) + + +class AnimalTestCase(unittest.TestCase): + def test_info_comes_from_config(self): + animal = Animal("chicken") + self.assertEqual(animal.name, "鸡") + self.assertEqual(animal.product, "鸡蛋") + self.assertEqual(animal.cycle, config.ANIMALS["chicken"]["cycle"]) + + def test_days_to_product_counts_down(self): + animal = Animal("sheep") # cycle 2 + self.assertEqual(animal.days_to_product, 2) + animal.progress = 2 + self.assertEqual(animal.days_to_product, 0) + + def test_sick_after_two_days_without_food(self): + animal = Animal("chicken") + animal.starve = 1 + self.assertFalse(animal.is_sick) + animal.starve = 2 + self.assertTrue(animal.is_sick) + + def test_from_dict_rejects_unknown_kind(self): + self.assertIsNone(Animal.from_dict({"kind": "恐龙"})) + + def test_from_dict_bumps_id_counter_so_new_animals_do_not_collide(self): + restored = Animal.from_dict({"kind": "cow", "id": 500}) + assert restored is not None + self.assertEqual(restored.aid, 500) + fresh = Animal("chicken") + self.assertGreater(fresh.aid, 500) + + +class GameStateTestCase(unittest.TestCase): + def test_initial_state_matches_config(self): + state = GameState() + self.assertEqual(state.day, 1) + self.assertEqual(state.gold, config.START_GOLD) + self.assertEqual(state.ap, config.START_AP) + self.assertEqual(len(state.plots), config.BASE_PLOTS) + self.assertEqual(len(state.animals), 0) + + def test_market_is_seeded_with_every_crop_and_animal_product(self): + state = GameState() + for key in config.CROPS: + self.assertIn(key, state.market) + for key in config.ANIMALS: + self.assertIn(f"animal:{key}", state.market) + + def test_max_plots_grows_with_level_and_is_capped(self): + state = GameState() + self.assertEqual(state.max_plots, config.BASE_PLOTS) + state.level = 2 + self.assertEqual(state.max_plots, config.BASE_PLOTS + config.PLOTS_PER_LEVEL) + state.level = 99 + self.assertEqual(state.max_plots, config.MAX_PLOTS) + + def test_next_plot_price_grows_and_returns_minus_one_at_the_cap(self): + state = GameState() + state.level = 2 # 初始 6 块地刚好占满 Lv.1 的名额 + first = state.next_plot_price + self.assertEqual(first, config.PLOT_BASE_PRICE) + state.plots_bought = 1 + self.assertGreater(state.next_plot_price, first) + state.level = 99 + state.plots = [Plot(i) for i in range(config.MAX_PLOTS)] + self.assertEqual(state.next_plot_price, -1) + + def test_net_worth_counts_gold_land_livestock_and_inventory(self): + state = GameState() + state.gold = 100 + state.feed = 0 + self.assertEqual(state.net_worth, 100.0) + state.feed = 10 + self.assertEqual(state.net_worth, 100.0 + 10 * config.FEED_UNIT_PRICE) + + def test_goal_progress(self): + state = GameState() + state.gold = 0 + state.feed = 0 + self.assertEqual(state.goal_index, -1) + self.assertEqual(state.next_goal["amount"], config.GOALS[0][0]) + state.gold = config.GOALS[-1][0] + 1 + self.assertEqual(state.goal_index, len(config.GOALS) - 1) + self.assertIsNone(state.next_goal) + + def test_from_dict_clamps_hostile_values(self): + """存档是外部输入:负数金币、越界行动点都要被夹回来。""" + state = GameState.from_dict({"gold": -500, "ap": 9999, "level": 0, "day": -3}) + self.assertEqual(state.gold, 0) + self.assertEqual(state.ap, state.max_ap) + self.assertEqual(state.level, 1) + self.assertEqual(state.day, 1) + + def test_from_dict_pads_missing_plots_and_reindexes(self): + state = GameState.from_dict({"plots": [{"index": 7, "crop": "wheat"}]}) + self.assertEqual(len(state.plots), config.BASE_PLOTS) + self.assertEqual([p.index for p in state.plots], list(range(config.BASE_PLOTS))) + self.assertEqual(state.plots[0].crop, "wheat") + + def test_from_dict_drops_extra_animals_beyond_capacity(self): + raw = {"animals": [{"kind": "chicken", "id": i + 1} for i in range(config.MAX_ANIMALS + 5)]} + state = GameState.from_dict(raw) + self.assertEqual(len(state.animals), config.MAX_ANIMALS) + + def test_from_dict_ignores_unparsable_market_prices(self): + state = GameState.from_dict({"market": {"wheat": "not a number"}}) + self.assertEqual(state.market["wheat"], config.CROPS["wheat"]["base_price"]) + + +class StorageTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-test-") + self.path = os.path.join(self.dir, "save.json") + + def tearDown(self): + delete_save(self.path) + + def write_raw(self, payload) -> str: + with open(self.path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False) + return self.path + + def test_round_trip_keeps_the_important_fields(self): + engine = make_engine() + engine.plant(1, "wheat") + engine.buy_animal("chicken") + engine.state.day = 7 + engine.state.exp = 42 + + save_state(engine.state, self.path) + loaded, message = load_state(self.path) + + assert loaded is not None, message + self.assertEqual(loaded.day, 7) + self.assertEqual(loaded.exp, 42) + self.assertEqual(loaded.plots[0].crop, "wheat") + self.assertEqual([a.kind for a in loaded.animals], ["chicken"]) + self.assertIn("第 7 天", message) + + def test_saved_file_is_readable_json_with_version(self): + engine = make_engine() + save_state(engine.state, self.path) + with open(self.path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["version"], SAVE_VERSION) + self.assertIsInstance(payload["state"], dict) + + def test_missing_file_is_not_an_error(self): + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("未找到存档", message) + + def test_broken_json_degrades_instead_of_raising(self): + with open(self.path, "w", encoding="utf-8") as handle: + handle.write("{ this is not json") + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("无法解析", message) + + def test_wrong_version_is_refused(self): + self.write_raw({"version": SAVE_VERSION + 1, "state": {}}) + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("版本不兼容", message) + + def test_root_must_be_an_object(self): + self.write_raw([1, 2, 3]) + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("根节点不是对象", message) + + def test_state_field_is_required(self): + self.write_raw({"version": SAVE_VERSION}) + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("缺少 state", message) + + def test_unexpected_state_shape_degrades(self): + self.write_raw({"version": SAVE_VERSION, "state": {"plots": "nonsense", "animals": 7}}) + state, message = load_state(self.path) + self.assertIsNone(state) + self.assertIn("存档内容异常", message) + + def test_atomic_write_leaves_no_temp_file(self): + engine = make_engine() + save_state(engine.state, self.path) + self.assertFalse(os.path.exists(self.path + ".tmp")) + + def test_delete_save_reports_truthfully(self): + save_state(make_engine().state, self.path) + self.assertTrue(delete_save(self.path)) + self.assertFalse(os.path.exists(self.path)) + + +class ActionTestCase(unittest.TestCase): + """各玩法动作的成功路径与错误码。""" + + def setUp(self): + self.engine = make_engine() + + def assert_code(self, code: str, func, *args, **kwargs): + with self.assertRaises(GameError) as ctx: + func(*args, **kwargs) + self.assertEqual(ctx.exception.code, code) + self.assertEqual(ctx.exception.status, 400) + + # ------------------------------------------------------------------ 播种 + def test_plant_costs_gold_and_action_point(self): + gold, ap = self.engine.state.gold, self.engine.state.ap + result = self.engine.plant(1, "wheat") + self.assertTrue(result["ok"]) + self.assertEqual(self.engine.state.gold, gold - config.CROPS["wheat"]["seed_cost"]) + self.assertEqual(self.engine.state.ap, ap - config.AP_COST["plant"]) + + def test_plant_sets_initial_moisture_and_planted_day(self): + self.engine.state.day = 4 + self.engine.plant(1, "wheat") + plot = self.engine.state.plots[0] + self.assertEqual(plot.moisture, config.PLANT_INITIAL_MOISTURE) + self.assertEqual(plot.planted_day, 4) + self.assertEqual(plot.growth, 0.0) + + def test_plant_rejects_unknown_crop(self): + self.assert_code("unknown_crop", self.engine.plant, 1, "榴莲") + + def test_plant_rejects_level_locked_crop(self): + self.assert_code("locked", self.engine.plant, 1, "strawberry") + + def test_plant_rejects_busy_plot(self): + self.engine.plant(1, "wheat") + self.assert_code("plot_busy", self.engine.plant, 1, "carrot") + + def test_plot_number_is_one_based_and_bounded(self): + self.assert_code("no_such_plot", self.engine.plant, 0, "wheat") + self.assert_code("no_such_plot", self.engine.plant, 99, "wheat") + + def test_non_numeric_plot_number_is_a_param_error(self): + self.assert_code("bad_param", self.engine.plant, "第一块", "wheat") + + # ------------------------------------------------------------------ 浇水 + def test_water_fills_moisture_to_capacity(self): + self.engine.plant(1, "wheat") + self.engine.state.plots[0].moisture = 0 + self.engine.water(1) + self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY) + + def test_water_needs_ap(self): + self.engine.plant(1, "wheat") + self.engine.state.ap = 0 + self.assert_code("no_ap", self.engine.water, 1) + + def test_water_rejects_full_and_empty_plots(self): + self.assert_code("plot_empty", self.engine.water, 1) + self.engine.plant(2, "wheat") + self.engine.state.plots[1].moisture = config.WATER_CAPACITY + self.assert_code("already_watered", self.engine.water, 2) + + # ------------------------------------------------------------------ 收获 + def test_harvest_pays_the_market_price(self): + self.engine.plant(1, "wheat") + self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] + gold = self.engine.state.gold + with mock.patch("farm.engine.random.random", return_value=0.99): + result = self.engine.harvest(1) + self.assertEqual(result["quantity"], 1) + self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"]) + self.assertEqual(self.engine.state.gold, gold + result["earned"]) + self.assertTrue(self.engine.state.plots[0].is_empty) + + def test_lucky_harvest_doubles_the_yield(self): + self.engine.plant(1, "wheat") + self.engine.state.plots[0].growth = 2 + with mock.patch("farm.engine.random.random", return_value=0.0): + result = self.engine.harvest(1) + self.assertEqual(result["quantity"], 2) + self.assertEqual(result["earned"], config.CROPS["wheat"]["base_price"] * 2) + + def test_harvest_before_ripe_is_refused(self): + self.engine.plant(1, "carrot") + self.assert_code("not_ripe", self.engine.harvest, 1) + + def test_harvest_on_empty_plot_is_refused(self): + self.assert_code("plot_empty", self.engine.harvest, 3) + + def test_harvesting_clears_the_plot_completely(self): + self.engine.plant(1, "wheat") + self.engine.state.plots[0].growth = 2 + self.engine.harvest(1) + plot = self.engine.state.plots[0] + self.assertIsNone(plot.crop) + self.assertEqual(plot.growth, 0) + self.assertEqual(plot.moisture, 0) + + # ------------------------------------------------------------------ 牲畜 + def test_buy_animal_takes_gold_and_lands_in_the_pasture(self): + result = self.engine.buy_animal("chicken") + self.assertEqual(len(self.engine.state.animals), 1) + self.assertEqual(result["animal"]["kind"], "chicken") + + def test_buy_animal_checks_level_and_money_and_capacity(self): + self.assert_code("unknown_animal", self.engine.buy_animal, "独角兽") + self.assert_code("locked", self.engine.buy_animal, "cow") + self.engine.state.gold = 0 + self.assert_code("not_enough_gold", self.engine.buy_animal, "chicken") + + def test_pasture_has_a_capacity_limit(self): + self.engine.state.level = 5 + for _ in range(config.MAX_ANIMALS): + self.engine.buy_animal("chicken") + self.assert_code("pasture_full", self.engine.buy_animal, "chicken") + + def test_feed_consumes_inventory(self): + self.engine.buy_animal("chicken") + aid = self.engine.state.animals[0].aid + feed_before = self.engine.state.feed + self.engine.feed(aid) + self.assertEqual(self.engine.state.feed, feed_before - 1) + self.assertTrue(self.engine.state.animals[0].fed) + + def test_feeding_twice_a_day_is_refused(self): + self.engine.buy_animal("chicken") + aid = self.engine.state.animals[0].aid + self.engine.feed(aid) + self.assert_code("already_fed", self.engine.feed, aid) + + def test_short_feed_is_bought_at_the_emergency_price(self): + self.engine.buy_animal("chicken") + self.engine.state.feed = 0 + gold = self.engine.state.gold + self.engine.feed(self.engine.state.animals[0].aid) + self.assertEqual(gold - self.engine.state.gold, config.FEED_EMERGENCY_PRICE) + self.assertEqual(self.engine.state.feed, 0) + + def test_overfeeding_is_trimmed_to_the_actual_need(self): + self.engine.state.level = 3 + self.engine.buy_animal("cow") # 每天 2 份 + self.engine.state.feed = 10 + self.engine.feed(self.engine.state.animals[0].aid, quantity=99) + self.assertEqual(self.engine.state.feed, 8) + + def test_feed_rejects_bad_quantity(self): + self.engine.buy_animal("chicken") + aid = self.engine.state.animals[0].aid + self.assert_code("bad_param", self.engine.feed, aid, 0) + self.assert_code("bad_param", self.engine.feed, aid, "many") + + def test_collect_pays_for_every_pending_product(self): + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + animal.pending = 3 + gold = self.engine.state.gold + result = self.engine.collect(animal.aid) + self.assertEqual(result["quantity"], 3) + self.assertEqual(result["earned"], config.ANIMALS["chicken"]["base_price"] * 3) + self.assertEqual(self.engine.state.gold, gold + result["earned"]) + self.assertEqual(animal.pending, 0) + + def test_collect_without_output_is_refused(self): + self.engine.buy_animal("chicken") + self.assert_code( + "nothing_to_collect", self.engine.collect, self.engine.state.animals[0].aid + ) + + def test_unknown_animal_id(self): + self.assert_code("no_such_animal", self.engine.feed, 999) + + # ------------------------------------------------------------------ 投资 + def test_buy_plot_appends_land_and_raises_the_next_price(self): + self.engine.state.level = 2 # Lv.1 的 6 块地已经到手,升级后才有地可买 + before = len(self.engine.state.plots) + price = self.engine.state.next_plot_price + gold = self.engine.state.gold + self.engine.buy_plot() + self.assertEqual(len(self.engine.state.plots), before + 1) + self.assertEqual(gold - self.engine.state.gold, price) + self.assertEqual(self.engine.state.plots[-1].index, before) + + def test_buy_plot_respects_the_level_cap(self): + self.engine.state.level = 99 + self.engine.state.gold = 10**9 + while self.engine.state.next_plot_price > 0: + self.engine.state.ap = 10 + self.engine.buy_plot() + self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS) + self.assert_code("plot_limit", self.engine.buy_plot) + + def test_buy_feed_adds_inventory(self): + before = self.engine.state.feed + result = self.engine.buy_feed(20) + self.assertEqual(self.engine.state.feed, before + 20) + self.assertEqual(result["feed"], before + 20) + + def test_buy_feed_bounds(self): + self.assert_code("bad_param", self.engine.buy_feed, 0) + self.assert_code("bad_param", self.engine.buy_feed, "一袋") + self.assert_code("too_many", self.engine.buy_feed, config.MAX_FEED_PURCHASE + 1) + + def test_buy_feed_rejects_negative_amount_without_crediting_gold(self): + """负数是"倒收钱"的经典漏洞,必须被金额校验挡住。""" + gold = self.engine.state.gold + with self.assertRaises(GameError): + self.engine.buy_feed(-5) + self.assertEqual(self.engine.state.gold, gold) + + # ------------------------------------------------------------------ 杂项 + def test_pause_toggles_and_can_be_explicit(self): + self.assertFalse(self.engine.paused) + self.assertTrue(self.engine.pause()["paused"], "无参调用应当反转") + self.assertFalse(self.engine.pause()["paused"], "再调一次就翻回来") + self.assertFalse(self.engine.pause(False)["paused"]) + self.assertTrue(self.engine.pause(True)["paused"]) + + def test_reset_clears_progress_but_keeps_the_clock_setting(self): + engine = FarmEngine(tick_seconds=120) + engine.state.gold = 9999 + engine.pause(True) + engine.reset() + self.assertEqual(engine.state.gold, config.START_GOLD) + self.assertEqual(engine.state.day, 1) + self.assertFalse(engine.paused) + self.assertEqual(engine.tick_seconds, 120) + + def test_snapshot_carries_clock_fields(self): + snap = self.engine.snapshot() + self.assertIn("tick_seconds", snap) + self.assertIn("paused", snap) + self.assertIn("seconds_to_tick", snap) + + def test_seconds_to_tick_is_none_without_a_scheduler(self): + """没有调度器(单机暂停 / 尚未启动)时倒计时必须是 None,不是 0。""" + self.assertIsNone(self.engine.seconds_to_tick) + + def test_seconds_to_tick_counts_down(self): + + self.engine.next_tick_at = time.time() + 30 + value = self.engine.seconds_to_tick + assert value is not None + self.assertGreater(value, 25) + self.assertLessEqual(value, 30) + + +class GrowthTestCase(unittest.TestCase): + """水、生长、成熟这条链——放置游戏的核心节奏。""" + + def setUp(self): + self.engine = make_engine() + + def test_crops_grow_only_while_watered(self): + self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆 + self.assertEqual(self.engine.state.plots[0].moisture, config.PLANT_INITIAL_MOISTURE) + + self.engine.advance_day() + self.assertEqual(self.engine.state.plots[0].growth, 1) + + self.engine.advance_day() + self.assertEqual(self.engine.state.plots[0].moisture, 0) + self.assertEqual(self.engine.state.plots[0].growth, 2) + + events = self.engine.advance_day() + self.assertEqual(self.engine.state.plots[0].growth, 2, "没水就不该再长") + self.assertTrue(any("缺水" in text for text in events)) + + def test_watering_resumes_growth(self): + self.engine.plant(1, "carrot") + self.engine.advance_day() + self.engine.advance_day() + self.engine.advance_day() # 此刻缺水停摆 + self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长") + self.engine.water(1) # 浇到 3 点水 + self.engine.advance_day() + self.assertEqual(self.engine.state.plots[0].growth, 3, "浇水后又开始长了") + self.assertTrue(self.engine.state.plots[0].is_ripe) + + def test_crop_reportedly_ripens_on_the_last_day(self): + self.engine.plant(1, "wheat") # 2 天 + self.engine.advance_day() + self.engine.advance_day() + plot = self.engine.state.plots[0] + self.assertTrue(plot.is_ripe) + self.assertEqual(plot.growth, plot.grow_days) + + def test_growth_never_exceeds_grow_days(self): + self.engine.plant(1, "wheat") + for _ in range(10): + self.engine.state.plots[0].moisture = config.WATER_CAPACITY + self.engine.advance_day() + plot = self.engine.state.plots[0] + self.assertEqual(plot.growth, plot.grow_days) + + +class AdvanceDayTestCase(unittest.TestCase): + def setUp(self): + self.engine = make_engine() + + def test_day_increments_and_action_points_refill(self): + self.engine.state.ap = 0 + self.engine.advance_day() + self.assertEqual(self.engine.state.day, 2) + self.assertEqual(self.engine.state.ap, config.START_AP) + + def test_fed_animals_make_progress_and_get_hungry_again(self): + self.engine.buy_animal("chicken") # cycle 1 + animal = self.engine.state.animals[0] + self.engine.feed(animal.aid) + self.engine.advance_day() + self.assertFalse(animal.fed, "新的一天要重新喂") + self.assertEqual(animal.pending, 1, "鸡每天下蛋") + + def test_animal_dies_after_three_hungry_days(self): + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + aid = animal.aid + + self.engine.advance_day() + self.assertEqual(animal.starve, 1) + events = self.engine.advance_day() + self.assertEqual(animal.starve, 2) + self.assertTrue(any("两天没吃东西" in text for text in events)) + + events = self.engine.advance_day() + self.assertEqual(len(self.engine.state.animals), 0) + self.assertTrue(any("离你而去" in text for text in events)) + with self.assertRaises(GameError): + self.engine.feed(aid) + + def test_feeding_resets_the_hunger_counter(self): + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + self.engine.advance_day() + self.engine.advance_day() + self.assertEqual(animal.starve, 2) + self.engine.state.feed = 5 + self.engine.feed(animal.aid) + self.assertEqual(animal.starve, 0) + self.assertFalse(animal.is_sick) + + def test_market_prices_stay_within_the_configured_band(self): + for _ in range(30): + self.engine.advance_day() + for key, info in config.CROPS.items(): + base = info["base_price"] + price = self.engine.state.market[key] + self.assertGreaterEqual(price, round(base * 0.55, 2) - 0.01) + self.assertLessEqual(price, round(base * 1.55, 2) + 0.01) + for key, info in config.ANIMALS.items(): + base = info["base_price"] + price = self.engine.state.market[f"animal:{key}"] + self.assertGreaterEqual(price, round(base * 0.55, 2) - 0.01) + self.assertLessEqual(price, round(base * 1.55, 2) + 0.01) + + def test_progress_message_mentions_the_new_day(self): + self.engine.advance_day(reason="测试") + texts = [entry["text"] for entry in self.engine.state.log] + self.assertTrue(any("第 2 天" in text for text in texts)) + + +class LevelUpTestCase(unittest.TestCase): + def setUp(self): + self.engine = make_engine() + + def test_exp_accumulates_and_triggers_level_up(self): + needed = self.engine.state.exp_needed + self.engine.state.exp = needed - 1 + self.engine.plant(1, "wheat") + self.engine.state.plots[0].growth = 2 + self.engine.harvest(1) + + self.assertEqual(self.engine.state.level, 2) + self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"] - 1) + self.assertGreater(self.engine.state.gold, 2000 + config.CROPS["wheat"]["base_price"]) + + def test_big_exp_award_can_level_up_several_times(self): + self.engine.state.exp = 0 + messages = self.engine._gain_exp(10**5) + self.assertGreater(len(messages), 2) + self.assertGreater(self.engine.state.level, 3) + + def test_new_crop_unlocks_with_level(self): + with self.assertRaises(GameError): + self.engine.plant(1, "tomato") + self.engine.state.level = 2 + self.engine.plant(1, "tomato") + self.assertEqual(self.engine.state.plots[0].crop, "tomato") + + +class AvailableActionsTestCase(unittest.TestCase): + """给 AI 看的"当前合法动作"列表必须是**真的合法**。""" + + def setUp(self): + self.engine = make_engine() + + def names(self) -> set[str]: + return {item["action"] for item in self.engine.available_actions()} + + def test_every_listed_action_can_actually_run(self): + """这张表存在的意义:AI 照抄 params 就一定成功。 + + 牲畜类动作的 animal_id 只对产生它的那个引擎有意义, + 所以它们在别的用例里单独验证。 + """ + actions = self.engine.available_actions() + self.assertTrue(actions) + for item in actions: + name = item["action"] + params = item.get("params") or {} + if "animal_id" in params: + continue + with self.subTest(action=name): + fresh = make_engine() + fresh.state.level = 5 # 解锁全部作物与牲畜 + fresh.state.feed = 50 + if name == "water": + plot = fresh.state.plots[params["plot"] - 1] + plot.crop, plot.moisture = "wheat", 0 + elif name == "harvest": + plot = fresh.state.plots[params["plot"] - 1] + plot.crop, plot.growth = "wheat", 2 + result = run_action(fresh, name, dict(params)) + self.assertTrue(result["ok"]) + + def test_locked_crops_are_not_offered(self): + keys = { + item["params"].get("crop") + for item in self.engine.available_actions() + if item["action"] == "plant" + } + self.assertIn("wheat", keys) + self.assertNotIn("strawberry", keys, "Lv.5 的作物不该出现在 Lv.1") + + def test_ripe_crop_offers_harvest_instead_of_water(self): + self.engine.plant(1, "wheat") + self.engine.state.plots[0].growth = 2 + for_plot_1 = [ + item["action"] + for item in self.engine.available_actions() + if item["params"].get("plot") == 1 + ] + self.assertIn("harvest", for_plot_1) + self.assertNotIn("water", for_plot_1) + + def test_dry_plot_offers_water(self): + self.engine.plant(1, "carrot") + self.engine.state.plots[0].moisture = 0 + self.assertIn("water", self.names()) + + def test_poor_player_is_not_offered_things_they_cannot_pay_for(self): + self.engine.state.gold = 0 + self.assertNotIn("buy_plot", self.names()) + self.assertNotIn("buy_animal", self.names()) + self.assertNotIn("plant", self.names()) + + def test_feed_is_offered_only_while_inventory_is_low(self): + self.assertEqual(self.engine.state.feed, config.START_FEED) + self.assertNotIn("buy_feed", self.names(), "默认 5 份不算低") + self.engine.state.feed = 1 + self.assertIn("buy_feed", self.names()) + + def test_hungry_animal_offers_feed_and_pending_offers_collect(self): + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + self.assertIn("feed", self.names()) + animal.fed = True + animal.pending = 1 + actions = [ + item["action"] + for item in self.engine.available_actions() + if item["params"].get("animal_id") == animal.aid + ] + self.assertIn("collect", actions) + self.assertNotIn("feed", actions) + + def test_action_point_shortage_hides_action_point_actions(self): + self.engine.state.ap = 0 + names = self.names() + self.assertNotIn("plant", names) + self.assertNotIn("water", names) + self.assertNotIn("feed", names) + + def test_actions_explain_themselves(self): + for item in self.engine.available_actions(): + with self.subTest(action=item["action"]): + self.assertTrue(item["reason"]) + self.assertIn("gold", item["cost"]) + self.assertIn("ap", item["cost"]) + + +class LogTestCase(unittest.TestCase): + def setUp(self): + self.engine = make_engine() + + def test_log_entries_carry_increasing_sequence_numbers(self): + self.engine.add_log("第一件事") + self.engine.add_log("第二件事") + seqs = [entry["seq"] for entry in self.engine.state.log] + self.assertEqual(seqs, sorted(seqs)) + self.assertEqual(len(set(seqs)), len(seqs)) + + def test_recent_log_can_filter_by_sequence(self): + self.engine.add_log("老消息") + mark = self.engine.state.log[-1]["seq"] + self.engine.add_log("新消息") + entries = self.engine.recent_log(since=mark) + self.assertEqual([entry["text"] for entry in entries], ["新消息"]) + + def test_log_is_trimmed_to_the_configured_maximum(self): + for i in range(config.MAX_LOG_ENTRIES + 25): + self.engine.add_log(f"第 {i} 条") + self.assertEqual(len(self.engine.state.log), config.MAX_LOG_ENTRIES) + + def test_listener_errors_do_not_break_the_game(self): + """界面回调抛异常不能把玩法带走。""" + self.engine.subscribe(lambda: (_ for _ in ()).throw(RuntimeError("界面挂了"))) + self.assertTrue(self.engine.plant(1, "wheat")["ok"]) + + +class ClockTestCase(unittest.TestCase): + """tick 配置、纯函数决策、以及单机调度线程。""" + + def test_default_day_length_is_forty_minutes(self): + self.assertEqual(config.DEFAULT_TICK_SECONDS, 2400.0) + self.assertEqual(FarmEngine().tick_seconds, 2400.0) + + def test_tick_is_clamped_on_both_ends(self): + self.assertEqual(FarmEngine(tick_seconds=0).tick_seconds, config.MIN_TICK_SECONDS) + self.assertEqual(FarmEngine(tick_seconds=10**9).tick_seconds, config.MAX_TICK_SECONDS) + self.assertEqual(FarmEngine(tick_seconds=999).tick_seconds, 999.0) + + def test_the_old_ten_minute_ceiling_is_gone(self): + """历史坑:上限曾是 600 秒,会把 40 分钟悄悄夹回 10 分钟。""" + self.assertGreater(config.MAX_TICK_SECONDS, config.DEFAULT_TICK_SECONDS) + + def test_tick_decision_paused_defers(self): + advance, next_at = tick_decision(True, 100.0, 60.0, 500.0) + self.assertFalse(advance) + self.assertEqual(next_at, 560.0, "暂停时要顺延,保证恢复后有完整一轮") + + def test_tick_decision_without_schedule_starts_counting(self): + advance, next_at = tick_decision(False, None, 60.0, 500.0) + self.assertFalse(advance) + self.assertEqual(next_at, 560.0) + + def test_tick_decision_fires_when_due(self): + advance, next_at = tick_decision(False, 100.0, 60.0, 100.0) + self.assertTrue(advance) + self.assertEqual(next_at, 160.0) + + def test_tick_decision_keeps_the_deadline_while_waiting(self): + advance, next_at = tick_decision(False, 100.0, 60.0, 40.0) + self.assertFalse(advance) + self.assertEqual(next_at, 100.0, "没到点不要重排,否则永远到不了") + + def test_scheduler_sets_and_clears_the_deadline(self): + engine = make_engine() + scheduler = Scheduler(engine) + self.assertIsNone(engine.next_tick_at) + scheduler.start() + try: + self.assertIsNotNone(engine.next_tick_at) + finally: + scheduler.stop() + self.assertIsNone(engine.next_tick_at) + + def test_scheduler_advances_days_on_its_own(self): + """真的让它跑一小会儿:把 tick 手动压到 50ms。""" + engine = make_engine() + engine.tick_seconds = 0.05 + scheduler = Scheduler(engine) + scheduler.start() + try: + deadline = time.time() + 3.0 + while engine.state.day < 2 and time.time() < deadline: + time.sleep(0.05) + finally: + scheduler.stop() + self.assertGreaterEqual(engine.state.day, 2, "时间应当自己往前走") + + def test_disabled_scheduler_does_nothing(self): + engine = make_engine() + scheduler = Scheduler(engine, enabled=False) + scheduler.start() + self.assertIsNone(engine.next_tick_at) + self.assertIsNone(scheduler._thread) + + def test_paused_engine_does_not_advance(self): + engine = make_engine() + engine.tick_seconds = 0.05 + engine.pause(True) + scheduler = Scheduler(engine) + scheduler.start() + try: + time.sleep(0.4) + finally: + scheduler.stop() + self.assertEqual(engine.state.day, 1) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_server.py b/tests/test_server.py index 7c56494..bb9853f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,617 +1,1264 @@ -"""服务器层测试:动作表校验、REST 接口、多牧场隔离、鉴权、WebSocket 端到端。 +"""服务器层测试:牧场会话、调度时钟、SSE 订阅池、HTTP 接口、守护进程。 -分三层 ------- -1. :class:`TestActionTable` / :class:`TestFarmId` —— 纯函数,不需要服务器; -2. :class:`TestRestAPI` —— 用 FastAPI 的 TestClient 在进程内打接口; -3. :class:`TestLiveServer` —— 真的起一个 uvicorn,再用 :class:`farm.client.RemoteFarm` - 通过 HTTP + WebSocket 连上去。这一层才是"客户端到底行不行"的答案。 +分成三档,缺依赖时自动降级而不是报错: -如果机器上没装 FastAPI/uvicorn,第 2、3 层会跳过而不是失败—— -单机引擎的测试在 test_engine.py,那部分永远可跑。 +* 纯逻辑档(session / hub / daemon / 工具函数)——只需标准库; +* **TestClient 档**——用 FastAPI 的进程内客户端打接口,需要 ``httpx``; +* **真网络档**——真起 uvicorn、真开 socket、真读 SSE 流,只用标准库。 + + uv run python -m unittest tests.test_server -v """ from __future__ import annotations -import importlib.util import json import os -import shutil import socket +import subprocess +import sys import tempfile import threading import time import unittest +import urllib.request -from farm import config -from farm.engine import FarmEngine, GameError -from farm.server.actions import ACTIONS, describe_actions, run_action -from farm.server.session import SessionManager, validate_farm_id +from farm import config, net +from farm.engine import GameError +from farm.server.daemon import DaemonManager, pid_alive, port_busy +from farm.server.hub import ( + CLOSE_MESSAGE, + Client, + ConnectionHub, + Publisher, + build_state_message, + next_seq, + sse_frame, + sse_heartbeat, +) +from farm.server.session import ( + DEFAULT_FARM, + FarmClock, + FarmSession, + SessionManager, + validate_farm_id, + validate_owner, +) -try: - # 能导入 TestClient 就说明 fastapi 在,不需要再单独 import 一次 - from fastapi.testclient import TestClient +try: # pragma: no cover - 环境相关 + import fastapi # noqa: F401 + + from farm.server.app import ( + BRIEF_KEYS, + ServerConfig, + ServerHandle, + _int, + as_asgi, + brief, + create_app, + rules_text, + ) HAS_FASTAPI = True -except ImportError: # pragma: no cover - 取决于环境 +except ImportError: # pragma: no cover HAS_FASTAPI = False -# uvicorn 只需要知道"在不在",不必真的导入(导入它是 TestLiveServer 里的事) -HAS_UVICORN = importlib.util.find_spec("uvicorn") is not None +try: # pragma: no cover + from fastapi.testclient import TestClient + + HAS_TESTCLIENT = True +except Exception: # pragma: no cover - 缺 httpx 时走这里 + HAS_TESTCLIENT = False + +try: # pragma: no cover + import uvicorn + + HAS_UVICORN = True +except ImportError: # pragma: no cover + HAS_UVICORN = False def free_port() -> int: - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] + """向系统要一个当前空闲的端口。""" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) -# ======================================================================= 纯逻辑 -class TestActionTable(unittest.TestCase): - """动作表是所有入口的唯一执行通道,参数校验必须严格。""" +def wait_until(predicate, timeout: float = 6.0, interval: float = 0.05) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(interval) + return bool(predicate()) - def setUp(self) -> None: - self.engine = FarmEngine(tick_seconds=60) - def test_all_actions_documented(self) -> None: - described = describe_actions() - self.assertEqual(len(described), len(ACTIONS)) - for item in described: - self.assertTrue(item["summary"]) - self.assertIn(item["action"], ACTIONS) +class FakeLoop: + """够用的假事件循环:直接同步执行,省掉为每条推送起真循环。""" - def test_plant_and_harvest_via_action_table(self) -> None: - result = run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"}) - self.assertTrue(result["ok"]) - self.assertEqual(result["action"], "plant") - state = self.engine.snapshot() - self.assertEqual(state["plots"][0]["crop"], "wheat") + def __init__(self, broken: bool = False) -> None: + self.calls = 0 + self.broken = broken - self.engine.advance_day() - self.engine.advance_day() - harvested = run_action(self.engine, "harvest", {"plot": 1}) - self.assertGreater(harvested["earned"], 0) + def call_soon_threadsafe(self, func, *args) -> None: + if self.broken: + raise RuntimeError("event loop is closed") + self.calls += 1 + func(*args) - def test_string_number_is_coerced(self) -> None: - """命令行/表单常把数字传成字符串,这里必须能识别。""" - result = run_action(self.engine, "plant", {"plot": "1", "crop": "wheat"}) - self.assertTrue(result["ok"]) - def test_unknown_action_rejected(self) -> None: +class FarmIdTestCase(unittest.TestCase): + """牧场 ID 会变成存档文件名,所以它同时是安全边界。""" + + def test_empty_falls_back_to_default(self): + self.assertEqual(validate_farm_id(None), DEFAULT_FARM) + self.assertEqual(validate_farm_id(""), DEFAULT_FARM) + # 只有 None 与空串才算没给;纯空白会被 strip 成空串,从而被正则挡下 with self.assertRaises(GameError) as ctx: - run_action(self.engine, "fly_to_moon", {}) - self.assertEqual(ctx.exception.code, "unknown_action") - self.assertEqual(ctx.exception.status, 400) + validate_farm_id(" ") + self.assertEqual(ctx.exception.code, "bad_farm") - def test_missing_required_param(self) -> None: - with self.assertRaises(GameError) as ctx: - run_action(self.engine, "plant", {"plot": 1}) - self.assertEqual(ctx.exception.code, "missing_param") + def test_normal_ids_pass(self): + for raw in ("alice", "ai-1", "farm_01", "牧场一号", "A" * 24, "123"): + with self.subTest(raw=raw): + self.assertEqual(validate_farm_id(raw), raw) - def test_unknown_param_rejected(self) -> None: - with self.assertRaises(GameError) as ctx: - run_action(self.engine, "plant", {"plot": 1, "crop": "wheat", "magic": 1}) - self.assertEqual(ctx.exception.code, "bad_param") - self.assertIn("magic", ctx.exception.message) + def test_surrounding_whitespace_is_trimmed(self): + self.assertEqual(validate_farm_id(" alice "), "alice") - def test_bad_type_rejected(self) -> None: + def test_numbers_are_stringified(self): + self.assertEqual(validate_farm_id(42), "42") + + def test_path_traversal_is_refused(self): + """这是这个校验存在的核心理由:ID 会被拼进文件路径。""" + for raw in ("../secret", "..\\secret", "a/b", "a\\b", "..", "."): + with self.subTest(raw=raw): + with self.assertRaises(GameError) as ctx: + validate_farm_id(raw) + self.assertEqual(ctx.exception.code, "bad_farm") + + def test_other_illegal_shapes_are_refused(self): + for raw in ("a" * 25, "a b", "a.b", "a:b", "a*b", "牧场\n一号", "\x00"): + with self.subTest(raw=raw): + with self.assertRaises(GameError) as ctx: + validate_farm_id(raw) + self.assertEqual(ctx.exception.code, "bad_farm") + self.assertEqual(ctx.exception.status, 400) + + def test_non_scalar_is_refused(self): with self.assertRaises(GameError) as ctx: - run_action(self.engine, "plant", {"plot": "第一块地", "crop": "wheat"}) + validate_farm_id(["alice"]) + self.assertEqual(ctx.exception.code, "bad_farm") + + +class OwnerTestCase(unittest.TestCase): + def test_missing_owner_is_empty_string(self): + self.assertEqual(validate_owner(None), "") + + def test_scalars_are_accepted_and_trimmed(self): + self.assertEqual(validate_owner(" alice "), "alice") + self.assertEqual(validate_owner(42), "42") + + def test_overlong_owner_is_refused(self): + with self.assertRaises(GameError) as ctx: + validate_owner("x" * 33) self.assertEqual(ctx.exception.code, "bad_param") - def test_bool_param_forms(self) -> None: - result = run_action(self.engine, "pause", {"paused": "true"}) - self.assertTrue(self.engine.paused) - result = run_action(self.engine, "pause", {"paused": False}) - self.assertFalse(self.engine.paused) - self.assertEqual(result["action"], "pause") + def test_containers_are_refused(self): with self.assertRaises(GameError): - run_action(self.engine, "pause", {"paused": "也许"}) + validate_owner(["alice"]) - def test_harvest_all_with_nothing_ripe(self) -> None: + +class FarmSessionTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-session-") + + def test_clock_is_scheduled_immediately(self): + """回归:不设 next_tick_at 的话,新建牧场头 0.2 秒里倒计时是 None, + 客户端会把它显示成"已暂停 / 手动模式"。""" + session = FarmSession("alice", self.dir, 2400.0) + self.assertIsNotNone(session.engine.next_tick_at) + self.assertIsNotNone(session.snapshot()["seconds_to_tick"]) + self.assertGreater(session.snapshot()["seconds_to_tick"], 0) + + def test_snapshot_and_summary_carry_both_layers(self): + session = FarmSession("alice", self.dir, 2400.0, owner="爱丽丝") + snap = session.snapshot() + self.assertEqual(snap["farm"], "alice") + self.assertEqual(snap["owner"], "爱丽丝") + self.assertEqual(snap["tick_seconds"], 2400.0) + + summary = session.summary(online=2) + self.assertEqual(summary["farm"], "alice") + self.assertEqual(summary["online"], 2) + self.assertIn("plots", summary) + self.assertIn("idle_seconds", summary) + + def test_owner_defaults_to_the_farm_id(self): + session = FarmSession("alice", self.dir, 2400.0) + self.assertEqual(session.owner, "alice") + + def test_new_farm_writes_a_welcome_log(self): + session = FarmSession("alice", self.dir, 2400.0) + texts = [entry["text"] for entry in session.engine.state.log] + self.assertTrue(any("alice" in text for text in texts)) + + def test_save_then_reload_keeps_progress(self): + session = FarmSession("alice", self.dir, 2400.0) + session.engine.state.day = 9 + session.engine.state.gold = 777 + path = session.save() + self.assertTrue(os.path.exists(path)) + + reopened = FarmSession("alice", self.dir, 2400.0) + self.assertEqual(reopened.engine.state.day, 9) + self.assertEqual(reopened.engine.state.gold, 777) + self.assertIn("第 9 天", reopened.load_message) + + def test_broken_save_degrades_to_a_new_farm(self): + path = os.path.join(self.dir, "alice.json") + with open(path, "w", encoding="utf-8") as handle: + handle.write("这不是 JSON") + session = FarmSession("alice", self.dir, 2400.0) + self.assertEqual(session.engine.state.day, 1) + self.assertIn("已开新档", session.load_message) + + def test_reset_clears_progress_and_counts_as_an_action(self): + session = FarmSession("alice", self.dir, 2400.0) + session.engine.state.day = 5 + session.reset() + self.assertEqual(session.engine.state.day, 1) + self.assertEqual(session.actions_taken, 1) + + def test_advance_day_remembers_the_events(self): + session = FarmSession("alice", self.dir, 2400.0) + session.engine.plant(1, "wheat") + session.advance_day("测试") + self.assertEqual(session.engine.state.day, 2) + self.assertIsInstance(session.last_events, list) + + def test_touch_marks_dirty_and_counts_actions(self): + session = FarmSession("alice", self.dir, 2400.0) + session.dirty = False + session.touch(count_action=True) + self.assertTrue(session.dirty) + self.assertEqual(session.actions_taken, 1) + + def test_autosave_skips_when_clean(self): + session = FarmSession("alice", self.dir, 2400.0) + session.dirty = False + session.saved_at = 0.0 + self.assertFalse(session.autosave()) + + def test_autosave_writes_when_dirty(self): + session = FarmSession("alice", self.dir, 2400.0) + session.dirty = True + session.saved_at = 0.0 + self.assertTrue(session.autosave()) + self.assertTrue(os.path.exists(session.path)) + + def test_autosave_waits_between_writes(self): + session = FarmSession("alice", self.dir, 2400.0) + session.dirty = True + session.saved_at = time.time() + self.assertFalse(session.autosave(), "刚存过就不用再存") + + def test_state_message_shape(self): + session = FarmSession("alice", self.dir, 2400.0) + message = session.state_message(seq=7, note="给你") + self.assertEqual(message["type"], "state") + self.assertEqual(message["seq"], 7) + self.assertEqual(message["farm"], "alice") + self.assertEqual(message["note"], "给你") + self.assertIn("state", message) + self.assertIn("log", message) + + +class SessionManagerTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-manager-") + + def manager(self, **kwargs) -> SessionManager: + return SessionManager(save_dir=self.dir, tick_seconds=2400.0, **kwargs) + + def test_get_creates_and_caches(self): + manager = self.manager() + first = manager.get("alice") + second = manager.get("alice") + self.assertIs(first, second) + self.assertEqual(manager.count(), 1) + self.assertEqual(manager.ids(), ["alice"]) + + def test_get_without_create_reports_404(self): + manager = self.manager() with self.assertRaises(GameError) as ctx: - run_action(self.engine, "harvest_all", {}) - self.assertEqual(ctx.exception.code, "nothing_to_harvest") + manager.get("ghost", create=False) + self.assertEqual(ctx.exception.code, "no_such_farm") + self.assertEqual(ctx.exception.status, 404) - def test_business_error_keeps_code(self) -> None: - """业务错误要保留机器可读的 code,AI 靠它分支。""" + def test_find_does_not_create(self): + manager = self.manager() + self.assertIsNone(manager.find("ghost")) + manager.get("alice") + self.assertIsNotNone(manager.find("alice")) + + def test_capacity_limit_is_503(self): + manager = self.manager(max_sessions=2) + manager.get("a") + manager.get("b") with self.assertRaises(GameError) as ctx: - run_action(self.engine, "plant", {"plot": 99, "crop": "wheat"}) - self.assertEqual(ctx.exception.code, "no_such_plot") - with self.assertRaises(GameError) as ctx: - run_action(self.engine, "buy_animal", {"kind": "dragon"}) - self.assertEqual(ctx.exception.code, "unknown_animal") + manager.get("c") + self.assertEqual(ctx.exception.code, "too_many_farms") + self.assertEqual(ctx.exception.status, 503) - -class TestFarmId(unittest.TestCase): - """牧场 ID 会变成文件名,必须挡住路径穿越之类的输入。""" - - def test_normal(self) -> None: - for name in ("default", "alice", "ai-1", "玩家甲", "farm_01"): - self.assertEqual(validate_farm_id(name), name) - - def test_default_when_empty(self) -> None: - self.assertEqual(validate_farm_id(None), "default") - self.assertEqual(validate_farm_id(""), "default") - - def test_rejects_path_traversal(self) -> None: - for bad in ("../etc/passwd", "a/b", "a\\b", "..", "a b", "x" * 40, "农场:1"): - with self.assertRaises(GameError): - validate_farm_id(bad) - - -class TestSessionManager(unittest.TestCase): - """多牧场隔离与回收策略。""" - - def setUp(self) -> None: - self.tmp = tempfile.mkdtemp(prefix="farm-session-") - self.manager = SessionManager(save_dir=self.tmp, tick_seconds=60) - - def tearDown(self) -> None: - self.manager.shutdown() - shutil.rmtree(self.tmp, ignore_errors=True) - - def test_farms_are_isolated(self) -> None: - alice = self.manager.get("alice") - bob = self.manager.get("bob") + def test_farms_are_isolated_from_each_other(self): + manager = self.manager() + alice = manager.get("alice") + bob = manager.get("bob") alice.engine.plant(1, "wheat") alice.engine.state.gold = 999 + self.assertTrue(bob.engine.state.plots[0].is_empty) + self.assertEqual(bob.engine.state.gold, config.START_GOLD) - self.assertIsNone(bob.engine.state.plots[0].crop) - self.assertNotEqual(bob.engine.state.gold, 999) - self.assertEqual(self.manager.count(), 2) - self.assertIs(self.manager.get("alice"), alice) - - def test_save_and_reload_roundtrip(self) -> None: - alice = self.manager.get("alice") - alice.engine.plant(1, "carrot") - alice.engine.state.gold = 777 - alice.save() - - fresh = SessionManager(save_dir=self.tmp, tick_seconds=60) - try: - again = fresh.get("alice") - self.assertEqual(again.engine.state.gold, 777) - self.assertEqual(again.engine.state.plots[0].crop, "carrot") - finally: - fresh.shutdown() - - def test_leaderboard_sorted_by_net_worth(self) -> None: - poor = self.manager.get("poor") - rich = self.manager.get("rich") - poor.engine.state.gold = 10 + def test_leaderboard_sorts_by_net_worth(self): + manager = self.manager() + rich = manager.get("rich") + manager.get("poor") rich.engine.state.gold = 5000 - rows = self.manager.leaderboard() + rows = manager.leaderboard() self.assertEqual(rows[0]["farm"], "rich") - self.assertEqual(rows[0]["rank"], 1) - self.assertEqual(rows[1]["farm"], "poor") + self.assertEqual([row["rank"] for row in rows], [1, 2]) + self.assertGreater(rows[0]["net_worth"], rows[1]["net_worth"]) + self.assertGreaterEqual(rows[0]["net_worth"], 5000.0) - def test_reap_only_drops_idle_offline_farms(self) -> None: - busy = self.manager.get("busy") - idle = self.manager.get("idle") - busy.last_active = time.time() - 9999 - idle.last_active = time.time() - 9999 - self.manager.idle_timeout = 10 + def test_leaderboard_shows_online_counts(self): + manager = self.manager() + manager.get("alice") + rows = manager.leaderboard({"alice": 3}) + self.assertEqual(rows[0]["online"], 3) - dropped = self.manager.reap(online_map={"busy": 1}) - self.assertEqual(dropped, ["idle"]) - self.assertIsNotNone(self.manager.find("busy")) - self.assertIsNone(self.manager.find("idle")) - # 被回收的牧场一定已经落盘 - self.assertTrue(os.path.exists(os.path.join(self.tmp, "idle.json"))) + def test_reap_drops_idle_farms_after_saving(self): + manager = self.manager(idle_timeout=0.01) + session = manager.get("alice") + session.engine.state.gold = 321 + session.last_active = time.time() - 10 - def test_paused_farm_is_not_reaped(self) -> None: - session = self.manager.get("thinking") + dropped = manager.reap() + self.assertEqual(dropped, ["alice"]) + self.assertEqual(manager.count(), 0) + self.assertTrue(os.path.exists(session.path), "回收前必须先落盘") + + def test_reap_keeps_farms_that_are_still_fresh(self): + manager = self.manager(idle_timeout=3600) + manager.get("alice") + self.assertEqual(manager.reap(), []) + self.assertEqual(manager.count(), 1) + + def test_reap_keeps_farms_with_viewers(self): + manager = self.manager(idle_timeout=0.01) + manager.get("alice") + session = manager.get("alice") + session.last_active = time.time() - 10 + self.assertEqual(manager.reap({"alice": 1}), []) + self.assertEqual(manager.count(), 1) + + def test_reap_keeps_paused_farms(self): + """暂停是人为操作,说明玩家还打算回来。""" + manager = self.manager(idle_timeout=0.01) + session = manager.get("alice") session.engine.pause(True) - session.last_active = time.time() - 9999 - self.manager.idle_timeout = 10 - self.assertEqual(self.manager.reap(), []) + session.last_active = time.time() - 10 + self.assertEqual(manager.reap(), []) + self.assertEqual(manager.count(), 1) - def test_max_sessions_guard(self) -> None: - manager = SessionManager(save_dir=self.tmp, max_sessions=2) + def test_autosave_all_reports_how_many_were_written(self): + manager = self.manager() + manager.get("alice") + bob = manager.get("bob") + alice = manager.get("alice") + alice.dirty = True + bob.dirty = False + alice.saved_at = bob.saved_at = 0.0 + self.assertEqual(manager.autosave_all(), 1) + + def test_shutdown_saves_every_farm(self): + manager = self.manager() + for name in ("alice", "bob"): + manager.get(name) + manager.shutdown() + for name in ("alice", "bob"): + self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json"))) + + +class FarmClockTestCase(unittest.TestCase): + """多牧场共用一个时钟线程——不是每个牧场一个。""" + + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-clock-") + self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0) + + def test_one_thread_advances_every_farm(self): + alice = self.manager.get("alice") + bob = self.manager.get("bob") + for session in (alice, bob): + session.engine.tick_seconds = 0.05 # 绕过 clamp,把一天压到 50ms + session.engine.next_tick_at = time.time() + session.engine.tick_seconds + + clock = FarmClock(self.manager, accuracy=0.02) + clock.start() try: - manager.get("a") - manager.get("b") - with self.assertRaises(GameError) as ctx: - manager.get("c") - self.assertEqual(ctx.exception.code, "too_many_farms") + advanced = wait_until( + lambda: alice.engine.state.day >= 2 and bob.engine.state.day >= 2, timeout=4.0 + ) finally: - manager.shutdown() + clock.stop() + self.assertTrue(advanced, "两个牧场都应该自己往前走") + self.assertGreater(clock.ticks, 0) + + def test_paused_farm_stays_put_while_others_run(self): + frozen = self.manager.get("frozen") + running = self.manager.get("running") + frozen.engine.pause(True) + for session in (frozen, running): + session.engine.tick_seconds = 0.05 + session.engine.next_tick_at = time.time() + 0.05 + + clock = FarmClock(self.manager, accuracy=0.02) + clock.start() + try: + advanced = wait_until(lambda: running.engine.state.day >= 2, timeout=4.0) + finally: + clock.stop() + self.assertTrue(advanced) + self.assertEqual(frozen.engine.state.day, 1, "暂停的牧场不该被推进") + + def test_stats_reports_ticks_and_accuracy(self): + clock = FarmClock(self.manager, accuracy=0.25) + self.assertEqual(clock.stats(), (0, 0.25)) + + +class HubTestCase(unittest.TestCase): + def setUp(self): + self.hub = ConnectionHub() + + def client(self, farm: str = "alice", broken: bool = False) -> Client: + return Client(farm, FakeLoop(broken=broken), name="tester") + + def test_register_and_online_counts(self): + alice = self.client("alice") + bob = self.client("bob") + self.hub.register(alice) + self.hub.register(bob) + self.assertEqual(self.hub.online("alice"), 1) + self.assertEqual(self.hub.total(), 2) + self.assertEqual(self.hub.online_map(), {"alice": 1, "bob": 1}) + + def test_unregister_clears_the_empty_bucket(self): + alice = self.client("alice") + self.hub.register(alice) + self.hub.unregister(alice) + self.assertEqual(self.hub.online("alice"), 0) + self.assertFalse(alice.alive) + self.assertEqual(self.hub.online_map(), {}) + + def test_broadcast_stays_inside_one_farm(self): + alice = self.client("alice") + bob = self.client("bob") + self.hub.register(alice) + self.hub.register(bob) + sent = self.hub.broadcast("alice", {"type": "state"}) + self.assertEqual(sent, 1) + self.assertEqual(alice.queue.qsize(), 1) + self.assertEqual(bob.queue.qsize(), 0, "不能把 A 的状态推给 B") + + def test_broadcast_can_exclude_the_sender(self): + alice = self.client("alice") + other = self.client("alice") + self.hub.register(alice) + self.hub.register(other) + self.hub.broadcast("alice", {"type": "notice"}, exclude=alice) + self.assertEqual(alice.queue.qsize(), 0) + self.assertEqual(other.queue.qsize(), 1) + + def test_broadcast_all_reaches_every_farm(self): + self.hub.register(self.client("alice")) + self.hub.register(self.client("bob")) + self.assertEqual(self.hub.broadcast_all({"type": "notice"}), 2) + + def test_close_all_pushes_the_bye_message(self): + alice = self.client("alice") + self.hub.register(alice) + self.assertEqual(self.hub.close_all(), 1) + self.assertEqual(alice.queue.get_nowait()["type"], "bye") + self.assertEqual(CLOSE_MESSAGE["type"], "bye") + + def test_snapshot_lists_connection_stats(self): + client = self.client("alice") + self.hub.register(client) + rows = self.hub.snapshot() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["client_id"], client.cid) + self.assertEqual(rows[0]["farm"], "alice") + self.assertIn("online_seconds", rows[0]) + + def test_push_is_dropped_when_the_loop_is_gone(self): + client = self.client("alice", broken=True) + client.push({"type": "state"}) + self.assertFalse(client.alive, "循环已关闭就该老实标记自己死了") + + def test_push_after_unregister_is_a_no_op(self): + client = self.client("alice") + self.hub.register(client) + self.hub.unregister(client) + client.push({"type": "state"}) + self.assertEqual(client.queue.qsize(), 0) + + def test_full_queue_drops_the_oldest_message(self): + """实时状态宁可少一帧,也不能把连接拖死。""" + from farm.server import hub as hub_mod + + client = self.client("alice") + for i in range(hub_mod.QUEUE_SIZE + 5): + client._enqueue({"type": "state", "seq": i}) + self.assertEqual(client.queue.qsize(), hub_mod.QUEUE_SIZE) + self.assertEqual(client.queue.get_nowait()["seq"], 5, "最旧的应被丢掉") + self.assertGreater(client.stats()["dropped"], 0) + + +class SseProtocolTestCase(unittest.TestCase): + def test_frame_is_single_line_json(self): + frame = sse_frame({"type": "state", "text": "你好"}) + self.assertTrue(frame.startswith("data: ")) + self.assertTrue(frame.endswith("\n\n")) + payload = json.loads(frame[len("data: ") :].strip()) + self.assertEqual(payload["text"], "你好") + self.assertNotIn("\\u", frame, "中文不该被转义成 \\uXXXX") + + def test_newlines_inside_values_stay_escaped(self): + """SSE 靠空行分帧,所以 JSON 里绝不能出现真换行。""" + frame = sse_frame({"text": "第一行\n第二行"}) + body = frame[len("data: ") : -2] + self.assertEqual(len(body.splitlines()), 1) + + def test_heartbeat_is_a_comment_line(self): + self.assertEqual(sse_heartbeat(), ": keep-alive\n\n") + + def test_sequence_numbers_only_go_up(self): + values = [next_seq() for _ in range(50)] + self.assertEqual(values, sorted(values)) + self.assertEqual(len(set(values)), len(values)) + + def test_state_message_is_json_serializable(self): + session = FarmSession("alice", tempfile.mkdtemp(prefix="simplefarm-msg-"), 2400.0) + message = build_state_message(session, next_seq(), extra={"online": 1}) + json.dumps(message, ensure_ascii=False) + self.assertEqual(message["online"], 1) + self.assertEqual(message["type"], "state") + + +class PublisherTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-pub-") + self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0) + self.hub = ConnectionHub() + self.publisher = Publisher(self.manager, self.hub, autosave_interval=3600.0) + self.session = self.manager.get("alice") + + def attach(self) -> Client: + client = Client("alice", FakeLoop(), name="tui") + self.hub.register(client) + return client + + def test_push_state_reaches_the_viewer(self): + client = self.attach() + self.publisher._push_state(self.session, time.time()) + message = client.queue.get_nowait() + self.assertEqual(message["type"], "state") + self.assertEqual(message["online"], 1) + self.assertEqual(message["state"]["farm"], "alice") + self.assertEqual(self.publisher.pushes, 1) + + def test_crossing_a_day_pushes_a_day_message_first(self): + client = self.attach() + self.session.engine.state.day = 2 + self.publisher._push_state(self.session, time.time()) + first = client.queue.get_nowait() + second = client.queue.get_nowait() + self.assertEqual(first["type"], "day") + self.assertEqual(first["day"], 2) + self.assertEqual(second["type"], "state") + + def test_day_message_is_only_sent_once_per_day(self): + client = self.attach() + self.session.engine.state.day = 2 + self.publisher._push_state(self.session, time.time()) + while not client.queue.empty(): + client.queue.get_nowait() + self.publisher._push_state(self.session, time.time()) + self.assertEqual(client.queue.get_nowait()["type"], "state") + + def test_errors_are_counted_instead_of_crashing(self): + """推送线程绝不能因为单个牧场出错而退出。""" + + class Exploding: + def all(self): + raise RuntimeError("牧场数据坏了") + + publisher = Publisher(Exploding(), self.hub, autosave_interval=3600.0) + publisher.start() + try: + self.assertTrue(wait_until(lambda: publisher.errors > 0, timeout=3.0)) + self.assertTrue(publisher.is_alive(), "出了错也要继续活着") + finally: + publisher.stop() + + def test_uptime_is_a_number(self): + self.assertGreaterEqual(self.publisher.uptime(), 0.0) + + +class ServerHandleTestCase(unittest.TestCase): + def test_start_callbacks_all_run(self): + handle = ServerHandle() + seen: list[str] = [] + handle.on_start(lambda: seen.append("a")) + handle.on_start(lambda: seen.append("b")) + handle.notify_start() + self.assertEqual(seen, ["a", "b"]) + + def test_request_stop_is_accepted_once(self): + handle = ServerHandle() + calls: list[str] = [] + handle.on_stop(lambda: calls.append("stop")) + self.assertTrue(handle.request_stop()) + self.assertEqual(calls, ["stop"]) + self.assertFalse(handle.request_stop(), "重复请求要如实回答") + self.assertEqual(calls, ["stop"]) + + def test_a_failing_hook_does_not_block_the_others(self): + handle = ServerHandle() + calls: list[str] = [] + + def boom() -> None: + raise RuntimeError("钩子炸了") + + handle.on_stop(boom) + handle.on_stop(lambda: calls.append("还是跑了")) + handle.on_start(boom) + handle.notify_start() + self.assertTrue(handle.request_stop()) + self.assertEqual(calls, ["还是跑了"]) -# ======================================================================= REST @unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI") -class TestRestAPI(unittest.TestCase): - """用 TestClient 在进程内验证 HTTP 语义。""" +class BriefAndRulesTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-brief-") + self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0) + self.hub = ConnectionHub() + self.session = self.manager.get("alice") - def setUp(self) -> None: - from farm.server.app import ServerConfig, create_app + def test_brief_keys_exist_in_the_snapshot(self): + """回归:snapshot 改字段名时,brief 必须跟着改,否则动作回执直接 500。""" + snap = self.session.snapshot() + for key in BRIEF_KEYS: + self.assertIn(key, snap, f"快照里没有 {key}") - self.tmp = tempfile.mkdtemp(prefix="farm-api-") - self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, tick_seconds=60) - self.app = create_app(self.cfg) - self.client = TestClient(self.app) - self.client.__enter__() + def test_brief_exposes_the_to_do_lists(self): + self.session.engine.plant(1, "wheat") + self.session.engine.state.plots[0].growth = 2 + self.session.engine.buy_animal("chicken") + data = brief(self.session, self.hub) + self.assertEqual(data["farm"], "alice") + self.assertEqual(data["ripe_plots"], [1]) + self.assertEqual(len(data["hungry_animals"]), 1) + self.assertEqual(data["collectable_animals"], []) + self.assertEqual(data["online"], 0) - def tearDown(self) -> None: - self.client.__exit__(None, None, None) - shutil.rmtree(self.tmp, ignore_errors=True) + def test_brief_is_json_serializable(self): + json.dumps(brief(self.session, self.hub), ensure_ascii=False) - # ------------------------------------------------------------ 基础 - def test_health_without_token(self) -> None: - data = self.client.get("/api/health").json() - self.assertTrue(data["ok"]) - self.assertIn("uptime", data) + def test_rules_no_longer_mention_sleeping(self): + """「休息」已经整体移除,给 AI 的说明书不能还留着它。""" + text = rules_text() + self.assertNotIn("sleep", text) + self.assertNotIn("休息", text) + self.assertNotIn("快进", text.replace("没有快进", "")) + self.assertIn("40 分钟", text) - def test_dashboard_and_text_index(self) -> None: - page = self.client.get("/") - self.assertEqual(page.status_code, 200) - self.assertIn("SimpleFarm", page.text) - self.assertIn("WebSocket", page.text) - text = self.client.get("/text").text + def test_rules_point_at_the_recommended_loop(self): + text = rules_text() self.assertIn("/api/actions", text) + self.assertIn("/api/stream", text) - def test_help_lists_actions(self) -> None: - data = self.client.get("/api/help").json() - self.assertIn("rules", data) - names = {item["action"] for item in data["actions"]} - self.assertIn("plant", names) - self.assertIn("harvest_all", names) - def test_openapi_schema_available(self) -> None: - schema = self.client.get("/openapi.json").json() - self.assertIn("/api/actions", schema["paths"]) +@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI") +class AppWiringTestCase(unittest.TestCase): + def setUp(self): + self.cfg = ServerConfig( + save_dir=tempfile.mkdtemp(prefix="simplefarm-app-"), tick_seconds=2400.0 + ) + self.app = create_app(self.cfg) - # ------------------------------------------------------------ 牧场读写 - def test_state_creates_farm_lazily(self) -> None: - data = self.client.get("/api/state?farm=newbie").json() - self.assertEqual(data["state"]["day"], 1) - self.assertEqual(data["state"]["gold"], config.START_GOLD) + def test_app_state_is_fully_wired(self): + for name in ("manager", "hub", "publisher", "clock", "handle", "counters"): + self.assertTrue(hasattr(self.app.state, name), name) - def test_generic_action_entry(self) -> None: - body = {"action": "plant", "params": {"plot": 1, "crop": "wheat"}} - data = self.client.post("/api/actions?farm=one", json=body).json() - self.assertTrue(data["ok"]) - self.assertIn("已种下", data["message"]) - self.assertEqual( - data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"] + def test_as_asgi_is_the_identity(self): + self.assertIs(as_asgi(self.app), self.app) + + def test_default_config_matches_the_game_defaults(self): + cfg = ServerConfig() + self.assertEqual(cfg.host, "127.0.0.1") + self.assertEqual(cfg.tick_seconds, config.DEFAULT_TICK_SECONDS) + self.assertTrue(cfg.allow_remote_control) + + def test_build_app_is_a_convenience_wrapper(self): + from farm.server.app import build_app + + app = build_app(port=9999) + self.assertEqual(app.state.cfg.port, 9999) + self.assertEqual(app.state.cfg.host, "127.0.0.1") + + def test_int_query_helper(self): + self.assertEqual(_int(None, 20, 1, 200), 20) + self.assertEqual(_int("", 20, 1, 200), 20) + self.assertEqual(_int("5", 20, 1, 200), 5) + self.assertEqual(_int("999", 20, 1, 200), 200) + self.assertEqual(_int("-5", 20, 1, 200), 1) + with self.assertRaises(GameError) as ctx: + _int("abc", 20, 1, 200) + self.assertEqual(ctx.exception.code, "bad_param") + + +class DaemonTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-daemon-") + self.pid_file = os.path.join(self.dir, "server.pid") + self.log_file = os.path.join(self.dir, "server.log") + + def manager(self, port: int) -> DaemonManager: + return DaemonManager( + host="127.0.0.1", + port=port, + save_dir=self.dir, + pid_file=self.pid_file, + log_file=self.log_file, ) - state = self.client.get("/api/state?farm=one").json()["state"] - self.assertEqual(state["plots"][0]["crop"], "wheat") + def test_own_process_is_alive_and_nonsense_pids_are_not(self): + self.assertTrue(pid_alive(os.getpid())) + self.assertFalse(pid_alive(0)) + self.assertFalse(pid_alive(-1)) - def test_flat_params_also_accepted(self) -> None: - """{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。""" - data = self.client.post( - "/api/actions?farm=flat", json={"action": "plant", "plot": 1, "crop": "wheat"} + def test_port_busy_detects_a_listening_socket(self): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(16) # 至少要能容纳本用例的两连接 + port = int(listener.getsockname()[1]) + self.assertTrue(port_busy("127.0.0.1", port)) + self.assertTrue(self.manager(port).port_busy()) + self.assertFalse(port_busy("127.0.0.1", port), "监听关掉之后就该是空闲的") + + def test_pid_file_round_trip(self): + manager = self.manager(free_port()) + self.assertIsNone(manager.read_pid()) + manager.write_pid(4321) + self.assertEqual(manager.read_pid(), 4321) + manager.clear_pid() + self.assertIsNone(manager.read_pid()) + + def test_stale_pid_file_is_cleaned_up(self): + manager = self.manager(free_port()) + manager.write_pid(999_999_999) # 必定不存在的 pid + self.assertIsNone(manager.current()) + self.assertIsNone(manager.read_pid(), "陈旧记录应当被顺手清掉") + + def test_status_reports_not_running_for_a_dead_port(self): + info = self.manager(free_port()).status() + self.assertFalse(info["running"]) + self.assertIsNone(info["pid"]) + self.assertIsNone(info["health"]) + + def test_probe_and_world_return_none_without_a_server(self): + manager = self.manager(free_port()) + self.assertIsNone(manager.probe()) + self.assertIsNone(manager.world()) + + def test_command_passes_the_pid_file_through(self): + """pid 必须由服务进程自己写:父进程拿到的 pid 在 Windows 上未必是监听者。""" + args = self.manager(8765).command() + self.assertIn("--pid-file", args) + self.assertEqual(args[args.index("--pid-file") + 1], self.pid_file) + + def test_stop_on_idle_port_is_not_an_error(self): + ok, message = self.manager(free_port()).stop() + self.assertTrue(ok) + self.assertIn("没有运行", message) + + def test_tail_returns_empty_for_a_missing_log(self): + self.assertEqual(self.manager(free_port()).tail(), []) + + def test_tail_reads_the_last_lines(self): + with open(self.log_file, "w", encoding="utf-8") as handle: + handle.write("\n".join(f"第{i}行" for i in range(50))) + lines = self.manager(free_port()).tail(5) + self.assertEqual(len(lines), 5) + self.assertEqual(lines[-1], "第49行") + + +@unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)") +class RestApiTestCase(unittest.TestCase): + """进程内打接口:语义、鉴权、多牧场隔离。""" + + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-rest-") + self.app = create_app(ServerConfig(save_dir=self.dir, tick_seconds=2400.0)) + self.client = TestClient(self.app) + + def test_health_needs_no_token(self): + response = self.client.get("/api/health") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["service"], "SimpleFarm") + self.assertIn("clock_ticks", payload) + + def test_state_and_catalog(self): + payload = self.client.get("/api/state?farm=alice").json() + self.assertEqual(payload["farm"], "alice") + self.assertEqual(payload["state"]["day"], 1) + self.assertEqual(payload["online"], 0) + + catalog = self.client.get("/api/catalog?farm=alice").json()["catalog"] + self.assertEqual(len(catalog["crops"]), len(config.CROPS)) + self.assertIn("ap_cost", catalog) + + def test_action_then_read_it_back(self): + response = self.client.post( + "/api/actions?farm=alice", + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + ) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["action"], "plant") + self.assertEqual(payload["state"]["plot_count"], config.BASE_PLOTS) + self.assertIn("seq", payload) + + snap = self.client.get("/api/state?farm=alice").json()["state"] + self.assertEqual(snap["plots"][0]["crop"], "wheat") + + def test_flat_parameters_are_accepted(self): + """AI 常把参数平铺在顶层,接口对此要宽容。""" + payload = self.client.post( + "/api/actions?farm=alice", json={"action": "plant", "plot": 2, "crop": "wheat"} ).json() - self.assertTrue(data["ok"]) + self.assertTrue(payload["ok"]) - def test_convenience_route(self) -> None: - data = self.client.post("/api/plant?farm=short", json={"plot": 2, "crop": "carrot"}).json() - self.assertTrue(data["ok"]) - self.assertEqual(data["action"], "plant") + def test_full_flag_returns_the_whole_snapshot(self): + payload = self.client.post( + "/api/actions?farm=alice&full=1", json={"action": "buy_feed", "params": {}} + ).json() + self.assertIn("snapshot", payload) + self.assertIn("plots", payload["snapshot"]) - def test_action_error_shape(self) -> None: + def test_errors_are_machine_readable(self): + response = self.client.post("/api/actions?farm=alice", json={"action": "fly"}) + self.assertEqual(response.status_code, 400) + payload = response.json() + self.assertFalse(payload["ok"]) + self.assertEqual(payload["error"], "unknown_action") + + def test_missing_parameter_is_a_400(self): response = self.client.post( - "/api/actions?farm=err", json={"action": "plant", "params": {"plot": 1, "crop": "nope"}} + "/api/actions?farm=alice", json={"action": "plant", "params": {"plot": 1}} ) self.assertEqual(response.status_code, 400) - data = response.json() - self.assertFalse(data["ok"]) - self.assertEqual(data["error"], "unknown_crop") - self.assertIn("message", data) + self.assertEqual(response.json()["error"], "missing_param") - def test_out_of_range_plot(self) -> None: + def test_broken_json_body(self): response = self.client.post( - "/api/actions?farm=err", json={"action": "water", "params": {"plot": 999}} - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "no_such_plot") - - def test_bad_json_body(self) -> None: - response = self.client.post( - "/api/actions?farm=err", content=b"{oops", headers={"Content-Type": "application/json"} + "/api/actions?farm=alice", + content="{ not json", + headers={"Content-Type": "application/json"}, ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "bad_json") - def test_actions_endpoint_matches_state(self) -> None: - data = self.client.get("/api/actions?farm=solo").json() - names = {item["action"] for item in data["actions"]} - self.assertIn("plant", names) # 有空地 + 有钱 → 可播种 - self.assertNotIn("harvest", names) # 还没种东西 → 不可收获 - - def test_batch_endpoint(self) -> None: - items = { - "actions": [ - {"action": "buy_feed", "params": {"quantity": 5}}, - {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, - {"action": "harvest", "params": {"plot": 1}}, # 必然失败 - ] - } - data = self.client.post("/api/batch?farm=multi", json=items).json() - self.assertEqual(data["done"], 2) - self.assertEqual(data["failed"], 1) - self.assertEqual(data["results"][2]["error"], "not_ripe") - - def test_log_entries_and_since(self) -> None: - self.client.post( - "/api/actions?farm=logging", - json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + def test_non_object_body(self): + response = self.client.post( + "/api/actions?farm=alice", + content="[1,2,3]", + headers={"Content-Type": "application/json"}, ) - entries = self.client.get("/api/log?farm=logging").json()["entries"] - self.assertTrue(entries) - last = entries[-1]["seq"] - newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"] - self.assertEqual(newer, []) - self.client.post("/api/actions?farm=logging", json={"action": "save"}) - newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"] - self.assertTrue(newer) + self.assertEqual(response.status_code, 400) - def test_farms_list_and_isolation(self) -> None: - self.client.post( - "/api/actions?farm=alice", json={"action": "buy_feed", "params": {"quantity": 10}} - ) - self.client.post("/api/actions?farm=bob", json={"action": "save"}) - rows = self.client.get("/api/farms").json()["farms"] - by_name = {row["farm"]: row for row in rows} - self.assertIn("alice", by_name) - self.assertEqual(by_name["alice"]["gold"], config.START_GOLD - 10 * config.FEED_UNIT_PRICE) + def test_farm_header_wins_over_the_default(self): + payload = self.client.get("/api/state", headers={"X-Farm-Id": "from-header"}).json() + self.assertEqual(payload["farm"], "from-header") - def test_create_farm_reports_created_flag(self) -> None: - first = self.client.post("/api/farms", json={"farm": "zoe", "owner": "小Z"}).json() - self.assertTrue(first["created"]) - self.assertEqual(first["summary"]["owner"], "小Z") - again = self.client.post("/api/farms", json={"farm": "zoe"}).json() - self.assertFalse(again["created"]) - - def test_bad_farm_id_rejected(self) -> None: - response = self.client.get("/api/state?farm=../evil") + def test_bad_farm_id_is_refused(self): + response = self.client.get("/api/state?farm=../etc") self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "bad_farm") - def test_farm_via_header(self) -> None: - data = self.client.get("/api/state", headers={"X-Farm-Id": "header-farm"}).json() - self.assertEqual(data["farm"], "header-farm") + def test_batch_runs_every_action_and_reports_failures(self): + payload = self.client.post( + "/api/batch?farm=alice", + json={ + "actions": [ + {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, # 这块地已占用 + {"action": "buy_feed", "params": {"quantity": 5}}, + ] + }, + ).json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["done"], 2) + self.assertEqual(payload["failed"], 1) + self.assertEqual(payload["results"][1]["error"], "plot_busy") - def test_admin_save(self) -> None: + def test_batch_validates_its_container(self): + response = self.client.post("/api/batch?farm=alice", json={"actions": []}) + self.assertEqual(response.status_code, 400) + response = self.client.post("/api/batch?farm=alice", json={"actions": "nope"}) + self.assertEqual(response.status_code, 400) + + def test_batch_has_an_upper_bound(self): + items = [{"action": "buy_feed", "params": {"quantity": 1}} for _ in range(51)] + response = self.client.post("/api/batch?farm=alice", json={"actions": items}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "too_many") + + def test_generated_per_action_routes_work(self): + payload = self.client.post("/api/buy_feed?farm=alice", json={"quantity": 3}).json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["action"], "buy_feed") + + def test_log_supports_limits_and_incremental_reads(self): + self.client.post("/api/actions?farm=alice", json={"action": "buy_feed", "params": {}}) + first = self.client.get("/api/log?farm=alice&limit=2").json() + self.assertLessEqual(first["count"], 2) + mark = first["last_seq"] + + self.client.post("/api/actions?farm=alice", json={"action": "buy_feed", "params": {}}) + newer = self.client.get(f"/api/log?farm=alice&since={mark}").json() + self.assertTrue(all(entry["seq"] > mark for entry in newer["entries"])) + + def test_log_rejects_a_non_numeric_limit(self): + response = self.client.get("/api/log?farm=alice&limit=abc") + self.assertEqual(response.status_code, 400) + + def test_farms_list_and_create(self): + created = self.client.post("/api/farms", json={"farm": "alice", "owner": "爱丽丝"}).json() + self.assertTrue(created["created"]) + self.assertEqual(created["farm"], "alice") + self.assertEqual(created["state"]["farm"], "alice") + + again = self.client.post("/api/farms", json={"farm": "alice"}).json() + self.assertFalse(again["created"]) + + rows = self.client.get("/api/farms").json()["farms"] + self.assertEqual(rows[0]["farm"], "alice") + + def test_create_farm_can_tune_the_clock(self): + self.client.post("/api/farms", json={"farm": "fast", "tick": 5}) + snap = self.client.get("/api/state?farm=fast").json()["state"] + self.assertEqual(snap["tick_seconds"], 5.0) + + def test_create_farm_rejects_a_bad_tick(self): + response = self.client.post("/api/farms", json={"farm": "fast", "tick": "soon"}) + self.assertEqual(response.status_code, 400) + + def test_world_view(self): + self.client.post("/api/farms", json={"farm": "alice"}) + payload = self.client.get("/api/world").json() + self.assertEqual(payload["farms"], 1) + self.assertIn("leaderboard", payload) + self.assertIn("connections", payload) + + def test_help_describes_the_actions(self): + payload = self.client.get("/api/help").json() + names = {item["action"] for item in payload["actions"]} + self.assertIn("plant", names) + self.assertNotIn("sleep", names) + + def test_farms_do_not_leak_into_each_other(self): self.client.post( - "/api/actions?farm=saver", + "/api/actions?farm=alice", json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, ) - data = self.client.post("/api/admin/save").json() - self.assertIn("saver", data["farms"]) - self.assertTrue(os.path.exists(os.path.join(self.tmp, "saver.json"))) + bob = self.client.get("/api/state?farm=bob").json()["state"] + self.assertIsNone(bob["plots"][0]["crop"]) - def test_world_view(self) -> None: - self.client.post("/api/actions?farm=w1", json={"action": "save"}) - data = self.client.get("/api/world").json() - self.assertEqual(data["farms"], 1) - self.assertEqual(data["leaderboard"][0]["farm"], "w1") + def test_save_goes_through_the_session_not_the_default_path(self): + """存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。""" + self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}}) + self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json"))) + self.assertFalse(os.path.exists(os.path.join(self.dir, "save.json"))) + + def test_reset_only_touches_its_own_farm(self): + self.client.post( + "/api/actions?farm=alice", + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + ) + self.client.post( + "/api/actions?farm=bob", + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + ) + self.client.post("/api/actions?farm=alice", json={"action": "reset", "params": {}}) + + alice = self.client.get("/api/state?farm=alice").json()["state"] + bob = self.client.get("/api/state?farm=bob").json()["state"] + self.assertIsNone(alice["plots"][0]["crop"]) + self.assertEqual(bob["plots"][0]["crop"], "wheat") + + def test_admin_save_writes_every_farm(self): + self.client.post("/api/farms", json={"farm": "alice"}) + self.client.post("/api/farms", json={"farm": "bob"}) + payload = self.client.post("/api/admin/save").json() + self.assertEqual(sorted(payload["farms"]), ["alice", "bob"]) + + def test_sleep_endpoint_is_gone(self): + """整条"手动推进一天"的链路都不该存在。""" + response = self.client.post("/api/actions?farm=alice", json={"action": "sleep"}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "unknown_action") + + def test_dashboard_pages_render(self): + self.assertEqual(self.client.get("/").status_code, 200) + text = self.client.get("/text").text + self.assertIn("SimpleFarm", text) -@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI") -class TestAuth(unittest.TestCase): - """令牌鉴权:没带令牌必须 401,带了才能操作。""" - - def setUp(self) -> None: - from farm.server.app import ServerConfig, create_app - - self.tmp = tempfile.mkdtemp(prefix="farm-auth-") - self.app = create_app(ServerConfig(save_dir=self.tmp, token="s3cret", tick_seconds=60)) +@unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)") +class AuthTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="simplefarm-auth-") + self.app = create_app(ServerConfig(save_dir=self.dir, token="s3cret", tick_seconds=2400.0)) self.client = TestClient(self.app) - self.client.__enter__() - def tearDown(self) -> None: - self.client.__exit__(None, None, None) - shutil.rmtree(self.tmp, ignore_errors=True) - - def test_health_is_public(self) -> None: + def test_health_stays_open(self): self.assertEqual(self.client.get("/api/health").status_code, 200) - def test_state_requires_token(self) -> None: + def test_state_needs_a_token(self): response = self.client.get("/api/state") self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["error"], "unauthorized") - def test_token_via_header(self) -> None: + def test_wrong_token_is_refused(self): + response = self.client.get("/api/state?token=nope") + self.assertEqual(response.status_code, 401) + + def test_query_token_works(self): + self.assertEqual(self.client.get("/api/state?token=s3cret").status_code, 200) + + def test_header_token_works(self): response = self.client.get("/api/state", headers={"X-API-Token": "s3cret"}) self.assertEqual(response.status_code, 200) - def test_token_via_query(self) -> None: - response = self.client.get("/api/state?token=s3cret") - self.assertEqual(response.status_code, 200) - - def test_wrong_token(self) -> None: - self.assertEqual(self.client.get("/api/state?token=nope").status_code, 401) - - def test_action_blocked_without_token(self) -> None: - response = self.client.post("/api/actions", json={"action": "buy_plot"}) + def test_writes_are_guarded_too(self): + response = self.client.post("/api/actions?farm=alice", json={"action": "buy_feed"}) self.assertEqual(response.status_code, 401) - -# ======================================================================= 真网络 -@unittest.skipUnless(HAS_FASTAPI and HAS_UVICORN, "未安装 FastAPI/uvicorn") -class TestLiveServer(unittest.TestCase): - """真的起 uvicorn,用真实客户端走 HTTP 与 WebSocket。""" - - @classmethod - def setUpClass(cls) -> None: - from farm.server.app import ServerConfig, create_app - - cls.tmp = tempfile.mkdtemp(prefix="farm-live-") - cls.port = free_port() - cls.url = f"http://127.0.0.1:{cls.port}" + def test_remote_shutdown_can_be_switched_off(self): app = create_app( ServerConfig( - host="127.0.0.1", - port=cls.port, - save_dir=cls.tmp, - tick_seconds=600, - autosave_interval=3600, + save_dir=self.dir, token="s3cret", allow_remote_control=False, tick_seconds=2400.0 ) ) - import uvicorn + client = TestClient(app) + response = client.post("/api/admin/shutdown?token=s3cret") + self.assertEqual(response.status_code, 403) - cls.server = uvicorn.Server( - uvicorn.Config( - app, - host="127.0.0.1", - port=cls.port, - log_level="warning", - access_log=False, - lifespan="on", - ) + +@unittest.skipUnless(HAS_UVICORN and HAS_FASTAPI, "未安装 uvicorn / FastAPI") +class LiveServerTestCase(unittest.TestCase): + """真起一个服务器进程内的 uvicorn,用真 socket 说话。""" + + @classmethod + def setUpClass(cls): + cls.dir = tempfile.mkdtemp(prefix="simplefarm-live-") + cls.port = free_port() + cls.cfg = ServerConfig( + host="127.0.0.1", port=cls.port, tick_seconds=config.MIN_TICK_SECONDS, save_dir=cls.dir ) - cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True) + cls.app = create_app(cls.cfg) + config_obj = uvicorn.Config( + as_asgi(cls.app), + host="127.0.0.1", + port=cls.port, + log_level="warning", + http="zttp", + http2=True, + timeout_graceful_shutdown=3, + ) + cls.server = uvicorn.Server(config_obj) + # __main__ 里做的那件事:停机请求要能把 should_exit 置位 + cls.app.state.handle.on_stop(lambda: setattr(cls.server, "should_exit", True)) + cls.thread = threading.Thread(target=cls.server.run, name="test-live-server", daemon=True) cls.thread.start() - - deadline = time.time() + 20 - while time.time() < deadline: - try: - with socket.create_connection(("127.0.0.1", cls.port), timeout=0.5): - break - except OSError: - time.sleep(0.2) - else: # pragma: no cover + if not wait_until(cls.healthy, timeout=20.0): raise unittest.SkipTest("服务器没能在 20 秒内起来") @classmethod - def tearDownClass(cls) -> None: - cls.server.should_exit = True - cls.thread.join(timeout=10) - shutil.rmtree(cls.tmp, ignore_errors=True) + def healthy(cls) -> bool: + try: + with net.open_url(f"http://127.0.0.1:{cls.port}/api/health", timeout=1.0) as response: + json.loads(response.read().decode("utf-8")) + return True + except Exception: + return False - def client(self, farm: str, realtime: bool = True): + @classmethod + def tearDownClass(cls): + cls.app.state.handle.request_stop() + cls.thread.join(timeout=8.0) + + def endpoint(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def connect(self, farm: str, realtime: bool = False): from farm.client import RemoteFarm - return RemoteFarm( - endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, autostart=True - ) + return RemoteFarm(endpoint=self.endpoint(), farm=farm, realtime=realtime, timeout=5.0) - def test_http_roundtrip_through_real_client(self) -> None: - client = self.client("live-http", realtime=False) + def read_stream(self, farm: str, frames: int = 1, timeout: float = 8.0) -> list[dict]: + """裸读 SSE 流,取到指定帧数就断开——顺便验证协议本身。""" + url = f"{self.endpoint()}/api/stream?farm={farm}" + request = urllib.request.Request(url, headers={"Accept": "text/event-stream"}) + collected: list[dict] = [] + with net.open_url(request, timeout=timeout) as response: + content_type = response.headers.get("content-type") or "" + self.assertTrue(content_type.startswith("text/event-stream"), content_type) + for raw in response: + line = raw.decode("utf-8", errors="replace").strip() + if not line.startswith("data:"): + continue + collected.append(json.loads(line[len("data:") :].strip())) + if len(collected) >= frames: + break + return collected + + def test_http_round_trip(self): + client = self.connect("live-a") + self.assertTrue(client.connected, client.last_error) + before = client.state.gold + client.plant(1, "wheat") + self.assertEqual(client.state.plots[0].crop, "wheat") + self.assertLess(client.state.gold, before) + + def test_two_clients_share_one_farm(self): + first = self.connect("live-shared") + second = self.connect("live-shared") + first.buy_feed(4) + self.assertTrue(wait_until(lambda: second.refresh() and second.state.feed >= 9)) + self.assertEqual(second.state.feed, first.state.feed) + + def test_other_farms_stay_separate(self): + alice = self.connect("live-alice") + bob = self.connect("live-bob") + alice.plant(1, "wheat") + bob.refresh() + self.assertIsNone(bob.state.plots[0].crop) + + def test_sse_stream_opens_with_a_welcome_frame(self): + frames = self.read_stream("live-sse", frames=1) + welcome = frames[0] + self.assertEqual(welcome["type"], "welcome") + self.assertEqual(welcome["farm"], "live-sse") + self.assertIn("catalog", welcome) + self.assertIn("actions", welcome) + self.assertEqual(welcome["server"]["version"], self.app.version) + + def test_sse_pushes_what_another_client_did(self): + listener = self.connect("live-push", realtime=True) try: - state = client.snapshot() - self.assertEqual(state["farm"], "live-http") - result = client.plant(1, "wheat") - self.assertTrue(result["ok"]) - self.assertEqual(client.state.plots[0].crop, "wheat") - self.assertFalse(client.state.plots[0].is_ripe) - - with self.assertRaises(GameError) as ctx: - client.plant(1, "carrot") # 地已经占用了 - self.assertEqual(ctx.exception.code, "plot_busy") - - actions = client.available_actions() - names = {item["action"] for item in actions} - self.assertIn("plant", names) # 还有空地可以种 - self.assertNotIn("harvest", names) # 小麦还没成熟 - - client.water(1) # 播种后水分 2/3,可以补满 - self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY) - with self.assertRaises(GameError) as ctx: - client.water(1) # 水分已满,再浇就是错的 - self.assertEqual(ctx.exception.code, "already_watered") - - catalog = client.catalog() - self.assertTrue(catalog["crops"]) - self.assertTrue(client.leaderboard()) - self.assertIn("规则速览", client.help_text()) - finally: - client.stop() - - def test_two_clients_talk_to_the_same_farm(self) -> None: - """两个客户端改同一个牧场:一方操作,另一方从推送里看到。""" - watcher = self.client("shared") - actor = self.client("shared") - try: - deadline = time.time() + 8 - while time.time() < deadline and len(watcher.state.plots) == 0: - time.sleep(0.1) - self.assertTrue(watcher.state.plots) - - actor.plant(1, "carrot") - deadline = time.time() + 8 - while time.time() < deadline and watcher.state.plots[0].crop != "carrot": - time.sleep(0.1) - self.assertEqual( - watcher.state.plots[0].crop, "carrot", "实时通道应该把另一方的操作推过来" + self.assertTrue( + wait_until(lambda: listener.mode == "sse", timeout=8.0), listener.last_error + ) + actor = self.connect("live-push") + actor.buy_feed(6) + self.assertTrue( + wait_until(lambda: listener.state.feed >= 9, timeout=8.0), + "另一个客户端的操作应当被推过来", ) - self.assertEqual(watcher.mode, "ws") finally: - watcher.stop() - actor.stop() + listener.stop() - def test_websocket_channel_is_really_used(self) -> None: - """写了动作应该走 WS 通道,而不是退化到 HTTP。""" - client = self.client("ws-farm") - try: - deadline = time.time() + 8 - while time.time() < deadline and client.mode != "ws": - time.sleep(0.1) - self.assertEqual(client.mode, "ws", "WebSocket 没能连上") - client.buy_feed(3) - self.assertEqual(client.stats["ws_actions"], 1) - self.assertEqual(client.stats["http"] > 0, True) - finally: - client.stop() + def test_writes_keep_working_after_the_stream_is_cut(self): + client = self.connect("live-cut", realtime=True) + self.assertTrue(wait_until(lambda: client.mode == "sse", timeout=8.0)) + client.stop() + client.buy_feed(2) + self.assertGreaterEqual(client.state.feed, 7) - def test_farms_do_not_leak_into_each_other(self) -> None: - a = self.client("iso-a", realtime=False) - b = self.client("iso-b", realtime=False) - try: - a.plant(1, "wheat") - self.assertIsNone(b.state.plots[0].crop) - self.assertNotEqual(a.state.gold, b.state.gold) - finally: - a.stop() - b.stop() - - def test_save_on_server_persists(self) -> None: - client = self.client("persist", realtime=False) - try: - client.plant(1, "wheat") - client.save() - path = os.path.join(self.tmp, "persist.json") - self.assertTrue(os.path.exists(path)) - with open(path, encoding="utf-8") as handle: - payload = json.load(handle) - self.assertEqual(payload["state"]["plots"][0]["crop"], "wheat") - finally: - client.stop() - - def test_bot_can_play_via_the_api(self) -> None: - """规则机器人只调接口,也应该能真的把农场经营起来。""" + def test_bot_can_run_purely_over_the_wire(self): from farm.bot import RuleBot - client = self.client("bot-farm", realtime=False) - bot = RuleBot(client, verbose=False, pause=0.02, name="bot-farm") - try: - summary = bot.run(rounds=12, idle_limit=3) - self.assertGreaterEqual(summary["actions"], 1) - self.assertIn("net_worth", summary) - self.assertEqual(bot.failures, 0) - self.assertTrue(client.available_actions() is not None) - finally: - client.stop() + client = self.connect("live-bot") + bot = RuleBot(client, verbose=False, pause=0.0, name="tester") + summary = bot.run(rounds=6, idle_limit=1) + self.assertGreaterEqual(summary["actions"], 1) + self.assertEqual(summary["name"], "tester") - def test_rate_free_path_uses_ws_and_falls_back(self) -> None: - """WS 断开后写操作要能自动退回 HTTP,而不是直接失败。""" - client = self.client("fallback") - try: - deadline = time.time() + 8 - while time.time() < deadline and client.mode != "ws": - time.sleep(0.1) - self.assertEqual(client.mode, "ws") - # 强行掐断:让后续写操作只能走 HTTP - client._stop.set() - client._ws_socket = None - result = client.buy_feed(1) - self.assertTrue(result["ok"]) - finally: - client.stop() + def test_health_reports_a_running_world(self): + with net.open_url(f"{self.endpoint()}/api/health", timeout=3.0) as response: + payload = json.loads(response.read().decode("utf-8")) + self.assertTrue(payload["ok"]) + self.assertGreaterEqual(payload["farms"], 1) + + def test_second_instance_on_the_same_port_refuses_to_start(self): + """端口自检必须用"能不能连上"判断,否则会静默顶掉已有实例。""" + result = subprocess.run( + [sys.executable, "-m", "farm.server", "--port", str(self.port)], + capture_output=True, + text=True, + timeout=60, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + ) + self.assertEqual(result.returncode, 3, result.stdout + result.stderr) + self.assertIn("已经有程序在监听", result.stdout + result.stderr) + + def test_graceful_shutdown_writes_every_farm(self): + """停机路径必须落盘。这里只验证"再启一个实例仍能读到存档"的前置条件。""" + client = self.connect("live-save") + client.plant(1, "wheat") + client.save() + self.assertTrue(os.path.exists(os.path.join(self.dir, "live-save.json"))) + + +class SessionIsolationAcrossProcessesTestCase(unittest.TestCase): + """守护进程的 pid 文件语义——不启动真服务,只验证文件层的约定。""" + + def test_clear_pid_only_removes_our_own_record(self): + from farm.server.__main__ import clear_pid_file + + directory = tempfile.mkdtemp(prefix="simplefarm-pid-") + path = os.path.join(directory, "server.pid") + + with open(path, "w", encoding="utf-8") as handle: + handle.write("424242") + clear_pid_file(path) + self.assertTrue(os.path.exists(path), "不是自己写的 pid 文件不能删") + + with open(path, "w", encoding="utf-8") as handle: + handle.write(str(os.getpid())) + clear_pid_file(path) + self.assertFalse(os.path.exists(path)) + + def test_clear_pid_tolerates_missing_files(self): + from farm.server.__main__ import clear_pid_file + + clear_pid_file(None) + clear_pid_file(os.path.join(tempfile.mkdtemp(), "nothing-here.pid")) + + def test_tick_is_clamped_by_the_entry_point(self): + from farm.server.__main__ import clamp_tick + + self.assertEqual(clamp_tick(0), config.MIN_TICK_SECONDS) + self.assertEqual(clamp_tick(10**9), config.MAX_TICK_SECONDS) + self.assertEqual(clamp_tick(900), 900.0) if __name__ == "__main__": diff --git a/tests/test_tui_keys.py b/tests/test_tui_keys.py new file mode 100644 index 0000000..1955089 --- /dev/null +++ b/tests/test_tui_keys.py @@ -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) diff --git a/tools/bench.py b/tools/bench.py new file mode 100644 index 0000000..7e06f0e --- /dev/null +++ b/tools/bench.py @@ -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()) diff --git a/tools/preview_tui.py b/tools/preview_tui.py index 56dc1d0..b10a8b6 100644 --- a/tools/preview_tui.py +++ b/tools/preview_tui.py @@ -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 diff --git a/tools/sse_demo.py b/tools/sse_demo.py new file mode 100644 index 0000000..8ab734a --- /dev/null +++ b/tools/sse_demo.py @@ -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()) diff --git a/tools/ws_demo.py b/tools/ws_demo.py deleted file mode 100644 index da01ffc..0000000 --- a/tools/ws_demo.py +++ /dev/null @@ -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()) diff --git a/uv.lock b/uv.lock index dfb2f0b..121a03d 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]