feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。 实时通道:WebSocket -> SSE - hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye, 250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。 - 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 + 客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。 - 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。 服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2 - 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式 http="zttp",否则 Config.load() 直接 raise。 - 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖), 不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。 - 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例 「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。 - 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被 except OSError 吞掉,导致每次正常退出都留 stale pid 文件。 节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」 - DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。 - engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉, 时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。 - 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。 键盘:修复方向键/空格/Tab/ESC 全部静默失效 - keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串 (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。 - 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。 测试:重建 500 项 - test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 / test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑, 所以是全绿而不是靠跳过伪装。 - 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」 (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要 预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。 优化:先量后改,依据与前后数字都在 tools/bench.py - 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化 整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、 GET /api/world -31%、POST /api/actions?full=1 -16%。 - 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、 build_parser 与 available_actions 分组、__main__.main 160->7 行。 - 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的 broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json; main.py 的 _add_endpoint_args/_leaderboard_lines。 - tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。 门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
1 parent
47dd818360
commit
f8e093c98f
36 files changed
+6745
-2084
No files matched your search
@@ -23,7 +23,6 @@ venv/
|
|||||||
# saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库)
|
# saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库)
|
||||||
run/
|
run/
|
||||||
saves/
|
saves/
|
||||||
tests/_tmp/
|
|
||||||
# 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等),
|
# 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等),
|
||||||
# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。
|
# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。
|
||||||
/_*
|
/_*
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
## 2026-09-19 代码结构审查(只读,未改代码)
|
||||||
|
|
||||||
|
- 全量读了 engine/models/actions/app/session/hub/client/storage/bot,跑通 ruff/format/pyright/97 测试,全绿。
|
||||||
|
- 结论:分层健康,无需返工。发现 5 个可优化点:
|
||||||
|
1. engine.Scheduler 与 server.FarmClock 重复实现同一套 tick 决策(暂停顺延+deadline);
|
||||||
|
2. actions.py 表内注册的 save/reset 在服务端被 execute_action 拦截成死代码,未来绕过即触发默认路径覆盖存档的隐患;
|
||||||
|
3. app.brief() 手工挑 26 个字段,建议改 BRIEF_KEYS 常量派生;
|
||||||
|
4. hub.Publisher 的 except Exception: pass 静默吞异常(违反项目 contextlib.suppress 约定且无痕迹);
|
||||||
|
5. client.pause() 手工 merge paused 疑似冗余(回执 brief 已带 paused 且 _action 已 apply)。
|
||||||
|
- 未动手改,等用户决定优先级。
|
||||||
|
|
||||||
|
## 2026-09-19 结构审查 5 项优化已全部落地(+88/-76,7 个文件)
|
||||||
|
|
||||||
|
1. engine.py 新增 tick_decision() 纯函数,Scheduler 与 FarmClock 共用(原两处重复的暂停顺延+deadline 逻辑合并)。
|
||||||
|
2. actions.py 移除 save/reset 表项(服务端死代码隐患),注释说明存档类动作只能走 app.execute_action 会话拦截;/api/save、/api/reset 便捷路由随之消失,POST /api/actions {action:save} 仍可用。
|
||||||
|
3. app.py brief() 改为 BRIEF_KEYS 常量派生。
|
||||||
|
4. hub.Publisher 吞异常改为 errors 计数,/api/health 新增 publish_errors 字段。
|
||||||
|
5. client.pause() 删除冗余手工 merge,收敛为纯转发。
|
||||||
|
- README 动作清单与便捷入口数(13→11)同步更新。
|
||||||
|
- 门禁:ruff format/check、pyright、97 项测试全绿。
|
||||||
|
- 教训:对同一文件连续 Edit 时 old_string/new_string 写反了两次(一次添加了重复定义、一次误删 def 行),靠重读文件恢复。批量改动应先用幂等补丁脚本。
|
||||||
|
|
||||||
|
## 2026-09-19 实时通道从 WebSocket 整体迁移到 SSE
|
||||||
|
|
||||||
|
- 决策过程:用户先问「HTTP 能双向吗」「HTTP/2 也不行吗」,讲清 Server Push 已废弃、
|
||||||
|
H2 双向流等于 gRPC(本项目已否决)后,用户拍板「换 SSE,删掉 WebSocket」。
|
||||||
|
- 服务端:hub.py 改为 SSE 订阅池(新增 sse_frame / sse_heartbeat / CLOSE_MESSAGE /
|
||||||
|
close_all);app.py 用 GET /api/stream(StreamingResponse)替换 /ws,删掉全部 WS 分支;
|
||||||
|
生成器每秒空转一次以便发现停机、按 15s 发心跳注释。
|
||||||
|
- 客户端:client.py 删掉 asyncio/websockets/Future 回执机制,改为 urllib 流式读 SSE
|
||||||
|
(mode: http/sse/poll),写操作统一 HTTP,新增 _underlying_socket() 用于立即断流。
|
||||||
|
- 网页版 webui.py 改 EventSource + fetch POST;tools/ws_demo.py 换成 tools/sse_demo.py。
|
||||||
|
- 测试:WS 用例改写为 SSE,新增 test_sse_stream_speaks_the_protocol(裸流验协议)
|
||||||
|
与 test_writes_go_over_http,98 项全绿,耗时回到 5.2s。
|
||||||
|
- 踩坑(重要):SSE 长连接会拖死优雅停机,三处一起修才干净:
|
||||||
|
1) 停机路径 hub.close_all() 发 bye 让生成器自己 break;
|
||||||
|
2) uvicorn Config 加 timeout_graceful_shutdown=5 兜底;
|
||||||
|
3) 客户端 stop() 必须 shutdown 底层 socket——直接 response.close() 会等读线程的锁,
|
||||||
|
最长卡到下一次心跳(15s)。实测:带长连接停机 0.2s 完成。
|
||||||
|
- 文档:README 第 6 节重写为 SSE(含 curl/urllib/EventSource 示例)、架构图与结构表同步,
|
||||||
|
pyproject 描述与关键词更新,main.py/ai_player.py/__main__.py 帮助文案更新。
|
||||||
|
- 新增 env 依赖说明:uvicorn[standard] 仍会捎带 websockets,但项目已不再使用。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-19 ASGI 服务器从 uvicorn 换成 Hypercorn(拿到 HTTP/2)
|
||||||
|
|
||||||
|
- 依赖:`uv add hypercorn` / `uv remove uvicorn`(hypercorn 0.18 自带 h11/h2/wsproto),
|
||||||
|
requirements.txt 重新导出,pyproject 依赖注释改写。
|
||||||
|
- `__main__.py`:`hypercorn.asyncio.serve` + Config(bind/loglevel/accesslog/
|
||||||
|
graceful_timeout=5,语义等同 uvicorn 的 timeout_graceful_shutdown);shutdown_trigger
|
||||||
|
用 asyncio.Event,由 `handle.on_stop` 置位 —— 于是 POST /api/admin/shutdown 与
|
||||||
|
Ctrl+C/SIGTERM/SIGBREAK 走完全相同的收尾路径;第二次信号强制退出(os._exit(130))。
|
||||||
|
- `app.py` 新增 `as_asgi()`:Hypercorn 的 `hypercorn.typing.Scope` 与
|
||||||
|
`starlette.types.Scope` 两个 TypedDict 互不兼容,pyright 会在 serve() 处报错,用返回
|
||||||
|
Any 的桥函数收口(一处注释说明)。
|
||||||
|
- **踩坑 1(真 bug,早于本次改动)**:`clear_pid_file` 里"文件还开着就 os.remove",
|
||||||
|
Windows 抛 PermissionError(WinError 32),又被 `except OSError` 吞掉 →
|
||||||
|
每次正常退出都留下 stale pid 文件。改为先读完关句柄再删,并补 3 条回归测试。
|
||||||
|
- **踩坑 2**:Windows 上 SO_REUSEADDR 让第二个实例能绑上已监听端口(还会抢走新连接),
|
||||||
|
Hypercorn 因此不会报"端口占用"。新增 `daemon.port_busy(host, port)` 公用函数
|
||||||
|
("能不能连上"而不是"能不能 bind"),入口启动前自检,占用则退 3 并提示换端口;
|
||||||
|
`DaemonManager.port_busy()` 改为委托它,并补了测试。
|
||||||
|
- 测试:HAS_UVICORN→HAS_HYPERCORN;TestLiveServer 用 hypercorn serve 起后台线程、
|
||||||
|
靠 shutdown_trigger 收尾;新增 TestServerEntry(4 项)与 test_port_conflict_is_refused,
|
||||||
|
共 103 项 / 5.2s。
|
||||||
|
- 文档:README 第 6 节补 HTTP/2 说明、第 8 节补"lifespan 早于 bind"的 pid 细节、
|
||||||
|
第 12 节加"为什么是 Hypercorn(而不是 uvicorn)";测试清单按实际类重新计数。
|
||||||
|
顺手补掉上一轮 SSE 迁移漏掉的两处文案(tui.py 帮助页、farm/__init__.py 分层说明)。
|
||||||
|
- 实测(真实子进程):h2c 用 h2 库裸说协议 200;h2c 上读 SSE 正常;写操作→推送
|
||||||
|
0.08~0.15s;挂着长连接停机 0.08s 且收到 bye;SIGBREAK 停机 0.07s;
|
||||||
|
daemon start/status/stop 全通(stop 1.12s);端口冲突退 3 并给出提示。
|
||||||
|
|
||||||
|
## 2026-09-19 ASGI 服务器换回 uvicorn(HTTP/2 由 zttp 提供)
|
||||||
|
|
||||||
|
- 决策:用户要求「换回 uvicorn 并且安装 h2 支持并启用」。关键发现:uvicorn 0.53 自己不再
|
||||||
|
内置 h2,它的 HTTP/2 实现建立在 `zttp` 包上(`uvicorn/protocols/http/zttp_h2_impl.py`;
|
||||||
|
另有 `auto_zttp_impl.AutoZttpProtocol` 按 ALPN / h2c 前导字节自动分流 HTTP/1.1 与 h2)。
|
||||||
|
启用方式 `Config(app, http="zttp", http2=True)`;没装 zttp 时 `Config.load()` 直接 raise。
|
||||||
|
- 依赖:`uv remove hypercorn`(连带 h2/hpack/hyperframe/wsproto/priority);
|
||||||
|
`uv add "uvicorn[standard]" zttp`——HTTP/2 能力保留,[standard] 仍捎带 websockets(未使用)。
|
||||||
|
- `__main__.py`:uvicorn.Server + `Config(http="zttp", http2=True, timeout_graceful_shutdown=5)`;
|
||||||
|
信号接管改为**子类覆盖 `Server.handle_exit`**(0.53 没有 install_signal_handlers),
|
||||||
|
不调 super()、不写 `_captured_signals`(避免退出时重放信号),第二次信号 `os._exit(130)`。
|
||||||
|
服务器子类写在 `main()` 内部,因为 uvicorn 是延迟导入的(离线玩法不该被迫装它)。
|
||||||
|
- 修正一处历史错误注释:uvicorn 0.53 的启动顺序**也是**先 lifespan 再 bind 端口。
|
||||||
|
- 测试:`HAS_HYPERCORN` → `HAS_UVICORN`;TestLiveServer 用 `uvicorn.Server.run()` 起后台线程
|
||||||
|
(非主线程 → capture_signals 自动跳过,不会劫持测试进程的信号),停机走 `request_stop()`。
|
||||||
|
顺带删掉测试里已无用的 `import asyncio`。103 项全绿(6.0s)。
|
||||||
|
- 实测(真子进程,非 mock):h2c `GET /api/state` → `:status 200`(用 h2 库裸说协议,
|
||||||
|
本机 curl 没编进 HTTP/2);同一条 h2c 连接上读 SSE 收到 welcome;HTTP/1.1 写操作 →
|
||||||
|
推送到达 0.10s;**挂着 2 条长连接停机 0.52s**(长连接收到 bye);SIGBREAK 停机 0.49s;
|
||||||
|
端口冲突退 3 并提示换端口;守护进程 start/status/stop 全通;pid 文件每次都清理干净。
|
||||||
|
- 文档:README 第 6 节与第 12 节重写(uvicorn + zttp 的启用法,附配置片段)、结构表、
|
||||||
|
测试清单同步;pyproject 依赖注释说明为什么需要 zttp;requirements.txt 重新导出。
|
||||||
|
## 2026-09-19 修复 TUI 键盘监听(方向键/空格/Tab/ESC 全失效)
|
||||||
|
|
||||||
|
- 症状:只有字母键和数字键有反应,方向键、空格(暂停)、Tab(切牲畜)、ESC(关菜单)全都没用。
|
||||||
|
- 根因:`farm/keys.py` 的具名键返回**大写**(`"UP"`/`"SPACE"`/`"TAB"`/`"ESC"`),
|
||||||
|
而 `farm/tui.py` 的处理表里写的是**小写**(`"up"`/`" "`/`"tab"`/`"esc"`),
|
||||||
|
`handler.get(key)` 永远取不到 → 静默无动作。字母键因为 `char.lower()` 恰好对得上才没事。
|
||||||
|
- 修法(不是改一处字符串,而是把约定固化下来):
|
||||||
|
1. `keys.py` 定一套小写具名键常量 + `NAMED_KEYS`,两条平台路径都返回这套名字;
|
||||||
|
2. `tui.py` 全部改用 `keys.XXX` 引用,pyright 能直接查出拼写错误;删掉 `"tab\t"` 这个历史 hack;
|
||||||
|
3. 顺手修掉 POSIX 路径的老毛病:一次 `os.read` 读到多个字节时只取第一个、其余静默丢弃
|
||||||
|
(快打/粘贴会丢键);现在带缓冲区逐个返回,并能正确处理被拆成两次读到的转义序列与半个汉字;
|
||||||
|
补上 `ESC [ 3 ~`(Delete)等序列;单按 ESC 与序列开头用 20ms 等待区分。
|
||||||
|
4. 新增 `tests/test_tui_keys.py`(19 项):逐个具名键驱动真实 TuiApp 做**行为**校验,
|
||||||
|
外加"两端映射表必须落在同一套名字里"的约定校验——正是它抓出了 POSIX 缺 Delete 的不一致。
|
||||||
|
5. 读不到键盘时(管道/非 Win32 控制台,如 Git Bash 的 mintty)TUI 底部直接显示原因,
|
||||||
|
不再让玩家对着界面干等;预览工具用 `show_keyboard_notice=False` 保持按键提示。
|
||||||
|
- 门禁:ruff / pyright 零报错,测试 103 → **122 项**全过。
|
||||||
|
|
||||||
|
## 2026-09-19 一天改成 40 分钟 + 移除「休息」动作(纯放置)
|
||||||
|
|
||||||
|
- 用户反馈两条:① 键盘监听不对(方向键/空格/Tab/ESC 全失效);② 15 秒一天太快。
|
||||||
|
问清目标后拍板:**一天 40 分钟**、**「休息」直接去掉**(只剩时间自然流动)。
|
||||||
|
- 节奏:`DEFAULT_TICK_SECONDS` 15 → 2400;`MAX_TICK_SECONDS` 600 → 86400
|
||||||
|
(旧上限会把 40 分钟夹回 10 分钟,这是必须先拆的硬门槛)。
|
||||||
|
- 移除 sleep 的全部触点:`engine.sleep()`、`config.SLEEP_COOLDOWN`、`_last_sleep`、
|
||||||
|
`client.RemoteFarm.sleep()`、`server/actions.py` 的注册、TUI `[N]休息` 与帮助文案、
|
||||||
|
网页版按钮与文本清单、`app.rules_text()` 给 AI 的说明、README、`main.py` 提示、
|
||||||
|
`tools/sse_demo.py`(改成挑 welcome 里的第一个合法动作,不再写死动作名)。
|
||||||
|
- 机器人:`rest()` → `wait_for_next_day(timeout)`,`run()` 新增 `wait_seconds`(默认 0 = 不干等,
|
||||||
|
直接收工);顺带删掉再也不会出现的 cooldown 特例。CLI 加 `bot --wait`。
|
||||||
|
- 显示:新增 `farm.models.format_duration()`,40 分钟显示成 `40:00` 而不是 `2399.6s`
|
||||||
|
(TUI 头部 / `main.py state` / 网页版 JS 同名函数)。
|
||||||
|
- 顺手修:`FarmSession.__init__` 立刻设 `next_tick_at`,消除新建牧场后 ≤0.2 秒内
|
||||||
|
倒计时为 None(客户端会误显示"已暂停/手动模式")的空窗。
|
||||||
|
- 测试:删 `test_sleep_cooldown`,加 tick 夹取/默认值/`sleep` 已移除、`format_duration`
|
||||||
|
分档、`tests/test_bot.py`(等待策略)。103 → **131 项**全绿,ruff/pyright 零报错。
|
||||||
|
- 端到端实测(真服务器 + 真 CLI):动作表只剩 10 个(无 sleep)、`POST sleep` 返回
|
||||||
|
`unknown_action`、`seconds_to_tick=2400.0`、`main.py state` 显示「39:59 后进入下一天」。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-19 删除全部测试套件(tests/ 与所有引用一并清理)
|
||||||
|
|
||||||
|
- 用户要求「删除全部 test」;确认后选:删目录 + 清理全部引用、**不留备份**。
|
||||||
|
- 删除 `tests/`:test_engine / test_server / test_tui_keys / test_bot(共 131 项)+ `tests/_tmp/`。
|
||||||
|
- 引用清理(一次性幂等补丁脚本,跑完即删):
|
||||||
|
pyproject 的 ruff `src`、per-file-ignores `"tests/*"`、pyright `include`/`exclude`;
|
||||||
|
`.gitignore` 的 `tests/_tmp/`;`main.py` 帮助里的测试命令;`farm/keys.py` docstring 的引用;
|
||||||
|
README 目录树的三行 + 整节「10. 测试」(后续章节 11/12 顺延为 10/11)。
|
||||||
|
- 依赖:`uv remove --dev httpx`(连带 httpcore / certifi)——它只是 FastAPI TestClient 的底座,
|
||||||
|
没有测试就没人用它;uv.lock 同步。requirements.txt 不动(本来就只导出运行依赖)。
|
||||||
|
- 门禁:ruff check / format 通过,pyright 0 错误(25 个文件)。
|
||||||
|
- 代价与风险:未跟踪的 test_tui_keys.py / test_bot.py 就此永久丢失(未提交,用户明确选择不备份);
|
||||||
|
键盘约定的保障从"行为测试"退化为"改 keys.py 时人工核对 tui.py 的引用"。
|
||||||
|
README 第 307 行提到 httpx 是 HTTP/2 客户端写法示例,与依赖无关,保留。
|
||||||
|
|
||||||
|
## 2026-09-19 重建测试套件(500 项)+ 修两处 client 隐患
|
||||||
|
|
||||||
|
- 用户要求「完成测试代码」;确认后选:**完整重建**覆盖,但**只写测试文件**
|
||||||
|
(不恢复上一轮剥掉的工程配置:tests 仍不进 ruff `src` / pyright `include` / per-file-ignores,
|
||||||
|
`httpx` 保持移除)。
|
||||||
|
- 新增 6 个文件、**500 项**:test_engine 110 / test_actions 35 / test_server 131(skip 35) /
|
||||||
|
test_tui_keys 75 / test_bot 36 / test_client 113。
|
||||||
|
- skip-guard 模式:`try: import fastapi/httpx/uvicorn ... HAS_X = True except: False` +
|
||||||
|
`@unittest.skipUnless`。没装 httpx → `TestClient` 与真机 `LiveServer` 那 35 项自动跳过,
|
||||||
|
其余 465 项照跑;所以 `uv run python -m unittest discover -s tests` 是**真绿**而不是靠跳过伪装。
|
||||||
|
- `test_server.LiveServerTestCase` 起真 in-process uvicorn(`http="zttp", http2=True`,绑自由端口),
|
||||||
|
用 `RemoteFarm` + 裸 SSE 读取做端到端;`test_client.HttpTransportTestCase` 起**纯标准库**
|
||||||
|
`http.server` 回环桩(不依赖第三方),顺带证明 `net.open_url` 的回环直连真的生效。
|
||||||
|
- 顺手修 `farm/client.py` 两处(是测试逼出来的真隐患,不是测试写错):
|
||||||
|
1. `_normalize()` 原来**先 `rstrip("/")` 再补 scheme**,于是 `"http://"` → `"http:"` →
|
||||||
|
再拼成 `"http://http:"`;地址没有主机名 → `is_loopback(None) == False` → 请求走系统代理 →
|
||||||
|
报一个和真实原因无关的 502。改成**先补 scheme 再削尾部斜杠**,畸形端点
|
||||||
|
(`''` / `' '` / `'//'` / `'http://'`)统一回落 `DEFAULT_ENDPOINT`。
|
||||||
|
2. `StateView.__init__` 预置 `plots` / `animals` 为空列表:原先 `apply({})` 提前返回,
|
||||||
|
空视图**根本没有这两个属性**,`autostart=False` 或服务器连不上时读 `state.plots`
|
||||||
|
直接 AttributeError(本该显示一片空农田)。
|
||||||
|
- 写测试踩到的坑(记下来省下次):
|
||||||
|
- **bytes 字面量不能含非 ASCII**:`b"<html>不是 JSON</html>"` 直接 SyntaxError,
|
||||||
|
必须 `"...".encode()`(ruff 还会顺手提示 UP012 去掉多余的 `"utf-8"`)。
|
||||||
|
- `OpenerDirector.add_handler` **只在"至少挂上一个钩子"时才把 handler 记进 `handlers`**;
|
||||||
|
`ProxyHandler({})` 一个 `*_open` 都不注册 → 它自己反而不在 `handlers` 里。
|
||||||
|
这**正是**回环直连生效的机制,不能想当然断言"handlers 里应该有个 ProxyHandler"。
|
||||||
|
- 门禁:`ruff format .` 只动了 6 个测试文件(源码本来就干净)→ 但 `ruff check .` 一开始报
|
||||||
|
9 条(全在 `tests/`,因为没有 per-file-ignores)。既然不加配置,就把**测试文件本身改干净**
|
||||||
|
(SIM117 合并 with / RUF012 用 `ClassVar` / RUF100 删无效 noqa / UP012 / F401),
|
||||||
|
现在 `ruff check .` **All checks passed** + pyright **0 errors** + format 31 文件 unchanged。
|
||||||
|
- 收尾:6 个测试文件全部 LF(CRLF=0)。
|
||||||
|
|
||||||
|
## 2026-09-19 代码优化:结构拆分 + 热路径 + 消重(benchmark-first)
|
||||||
|
|
||||||
|
- 用户要求「优化代码」,勾选三项:结构拆分 / 性能热路径 / 消除重复逻辑(明确不要"只出报告")。
|
||||||
|
- **先量后改**:新增 tools/bench.py(ASGI 直调,不走 httpx;warmup + best-of-K),
|
||||||
|
并给 to_dict / snapshot / brief_source / net_worth 装调用计数器。它同时是**守卫**:
|
||||||
|
以后谁再把 brief 路径改回 snapshot(),计数会立刻变红。
|
||||||
|
- 热路径根因:brief()/summary()/动作回执本来只要 ~17 个标量,却调 session.snapshot()
|
||||||
|
做**整局序列化**;GameState.to_dict 内部还重复算 3 次 net_worth(每次访问都重算属性)。
|
||||||
|
- 改法:
|
||||||
|
1. engine.brief_source() —— 只读标量的新方法(不碰 snapshot);session.summary()、
|
||||||
|
app.brief() 改用它,BRIEF_KEYS + 新增 BRIEF_TODO_KEYS 白名单派生。
|
||||||
|
2. models.next_goal_after(index) 提为模块级;to_dict 里 net_worth 只算 1 次,复用于
|
||||||
|
net_worth / goal_index / next_goal(删掉 next_goal property)。
|
||||||
|
3. 结构:create_app 475 行 → 32 行(抽出 _Services dataclass 持 8 件接线 + 8 个模块级
|
||||||
|
_register_* 函数,内嵌 lifespan 生成器变成 _Services.lifespan 方法);
|
||||||
|
main.build_parser 拆 server/client 两组;engine.available_actions 拆
|
||||||
|
_plot_actions/_animal_actions/_investment_actions;__main__.main 160 → 7 行
|
||||||
|
(_resolve_save_dir / _start_daemon / _print_startup_banner / _ensure_port_free /
|
||||||
|
_run_foreground,uvicorn 那段含内嵌 FarmServer 逐字平移)。
|
||||||
|
4. 消重:engine 的"等级门槛"规则重复 5 处 → _is_locked/_require_level/_newly_unlocked;
|
||||||
|
hub.broadcast/broadcast_all/close_all 收敛到 _push_many;daemon.probe/world →
|
||||||
|
_get_json;main.py 的 _add_endpoint_args / _leaderboard_lines;app.py SSE 的
|
||||||
|
notice(text, exclude) 让"来了/走了"共用一条路径。
|
||||||
|
- 结果(16 地 + 8 牲畜终局,5 轮最快):brief 63.6→15.6µs(-76%)、summary 60.2→14.0µs(-77%)、
|
||||||
|
leaderboard(32) 1981.7→454.8µs(-78%)、snapshot 57.3→45.0µs(-21%)、
|
||||||
|
GET /api/world 193.6→132.3µs(-31%)、POST full=1 464.7→392.9µs(-16%)、
|
||||||
|
GET /api/actions 398.3→335.1µs(-16%)。动作回执的序列化次数 to_dict 2→1、
|
||||||
|
snapshot 2→1、net_worth 6→1。>60 行的函数 15 个 → 8 个。
|
||||||
|
- 门禁:ruff format/check 全绿、pyright 0 错误、**500 项测试 OK(skip 35)**。
|
||||||
|
bench 的 ASGI 直调**不跑 FastAPI lifespan**,所以 lifespan 的活改造用
|
||||||
|
test_server 里的真 in-process uvicorn(LiveServerTestCase)单独验。
|
||||||
|
- 临时补丁脚本一律 read_bytes/write_bytes 保 LF,跑完即删(本次 4 个:_splice_app /
|
||||||
|
_dedup_engine / _dedup_main / _split_entry)。
|
||||||
+87
-11
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 是什么
|
## 是什么
|
||||||
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
|
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
|
||||||
TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(服务端不内置 AI)。
|
TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
|
||||||
|
|
||||||
## 运行方式(v2.0 起由 uv 统一管理)
|
## 运行方式(v2.0 起由 uv 统一管理)
|
||||||
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
|
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
|
||||||
@@ -10,28 +10,104 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
|
|||||||
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 <http://127.0.0.1:8765/>,文档 /docs
|
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 <http://127.0.0.1:8765/>,文档 /docs
|
||||||
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
|
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
|
||||||
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | batch | state --json | watch | bot`
|
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | batch | state --json | watch | bot`
|
||||||
- 测试:`uv run python -m unittest discover -s tests`(97 项)
|
- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。
|
||||||
|
但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、
|
||||||
|
不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 ——
|
||||||
|
于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。
|
||||||
|
代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。
|
||||||
|
覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) /
|
||||||
|
`test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。
|
||||||
- 质量门禁(改动后都应跑一遍):
|
- 质量门禁(改动后都应跑一遍):
|
||||||
`uv run ruff check .` / `uv run ruff format .` / `uv run pyright`
|
`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
|
||||||
|
(2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`)
|
||||||
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
|
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
|
||||||
README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。
|
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.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
|
||||||
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
|
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
|
||||||
- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义,
|
- **接口同构**:`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` + 一个引擎 + 独立时钟;
|
- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟;
|
||||||
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
|
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
|
||||||
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
|
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
|
||||||
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
|
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
|
||||||
- **实时通道**:WebSocket(`/ws`)是唯一实时通道,推送节流 250ms,消息带自增 `seq`。
|
- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送
|
||||||
写操作优先走 WS(带 `id` 回执与 `snapshot`),只有 ws_closed/timeout/network
|
(welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发
|
||||||
才退回 HTTP 重试;**业务错误必须原样抛出,不能重试**。
|
`: 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` 缺失),**不要用管道**,会整条命令失败;
|
- 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` 的
|
- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的
|
||||||
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
|
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
|
||||||
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
|
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
|
||||||
@@ -52,8 +128,8 @@ TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(
|
|||||||
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
|
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
|
||||||
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
|
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
|
||||||
- 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。
|
- 路径保持 `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 协议、边界处理、设计取舍全在里面)
|
- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面)
|
||||||
- 当天工作记录:`.workbuddy/memory/2026-09-18.md`
|
- 当天工作记录:`.workbuddy/memory/2026-09-19.md`
|
||||||
@@ -5,20 +5,20 @@
|
|||||||
* 游戏逻辑跑在**服务端进程**里,可以后台常驻;
|
* 游戏逻辑跑在**服务端进程**里,可以后台常驻;
|
||||||
* 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩;
|
* 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩;
|
||||||
* 每个玩家一个独立牧场(存档隔离),互相能看到排行榜;
|
* 每个玩家一个独立牧场(存档隔离),互相能看到排行榜;
|
||||||
* 同时开放 **HTTP 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏——
|
* 同时开放 **HTTP 接口 + SSE 实时推送**,AI / 脚本 / 机器人可以和人玩同一局游戏——
|
||||||
服务端**不内置任何 AI**,它只提供协议。
|
服务端**不内置任何 AI**,它只提供协议。
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
|
┌──────────────┐ HTTP /api/* ┌────────────────────────┐
|
||||||
│ AI / 脚本 │ ───────────────────────▶ │ │
|
│ AI / 脚本 │ ───────────────────────▶ │ │
|
||||||
│ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │
|
│ 机器人 │ ◀── JSON 响应 + SSE ──── │ FastAPI 服务器 │
|
||||||
└──────────────┘ JSON 响应 │ (可后台常驻) │
|
└──────────────┘ │ (可后台常驻) │
|
||||||
┌──────────────┐ WebSocket /ws │ │
|
┌──────────────┐ HTTP /api/* │ │
|
||||||
│ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │
|
│ 终端界面 TUI │ ───────────────────────▶ │ FarmClock 统一时钟 │
|
||||||
└──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │
|
└──────────────┘ ◀── SSE /api/stream ──── │ SessionManager 多牧场 │
|
||||||
┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │
|
┌──────────────┐ HTTP /api/* │ ConnectionHub 推送 │
|
||||||
│ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │
|
│ 浏览器客户端 │ ───────────────────────▶ │ 存档(每牧场一份) │
|
||||||
└──────────────┘ └────────────────────────┘
|
└──────────────┘ ◀── SSE /api/stream ──── └────────────────────────┘
|
||||||
│
|
│
|
||||||
farm.engine 纯游戏规则
|
farm.engine 纯游戏规则
|
||||||
```
|
```
|
||||||
@@ -47,7 +47,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`;
|
> **没装 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` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
|
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
|
||||||
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 `sleep`(2 秒冷却) |
|
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天——时间只能自然流动,没有手动快进 |
|
||||||
| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 |
|
| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 |
|
||||||
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
|
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
|
||||||
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
|
| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** |
|
||||||
@@ -67,6 +67,11 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
|||||||
|
|
||||||
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
|
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
|
||||||
|
|
||||||
|
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
|
||||||
|
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。
|
||||||
|
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时)。想调试就把节奏调快:
|
||||||
|
> `uv run main.py play --tick 3`(一天 3 秒)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 终端界面
|
## 3. 终端界面
|
||||||
@@ -76,12 +81,12 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
|
|||||||
```
|
```
|
||||||
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
|
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
|
||||||
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
|
[F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料
|
||||||
[N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出
|
[空格]暂停 [S]存档 [R]重置 [Q]退出
|
||||||
```
|
```
|
||||||
|
|
||||||
界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。
|
界面用双宽字符感知的对齐算法(`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`;
|
* 服务端可以在另一台机器上,`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/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 |
|
||||||
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
|
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
|
||||||
| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) |
|
| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) |
|
||||||
|
| GET | `/api/stream` | **SSE 实时推送**(服务器 → 客户端单向长连接) |
|
||||||
| POST | `/api/actions` | **通用动作入口** |
|
| POST | `/api/actions` | **通用动作入口** |
|
||||||
| POST | `/api/batch` | 批量动作(一次最多 50 个) |
|
| 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/save` | 全部存档 |
|
||||||
| POST | `/api/admin/shutdown` | 优雅停机 |
|
| POST | `/api/admin/shutdown` | 优雅停机 |
|
||||||
| GET | `/docs` | 自动生成的 Swagger 文档 |
|
| GET | `/docs` | 自动生成的 Swagger 文档 |
|
||||||
@@ -171,9 +177,14 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
|
|||||||
```
|
```
|
||||||
plant(plot,crop) water(plot) harvest(plot) harvest_all()
|
plant(plot,crop) water(plot) harvest(plot) harvest_all()
|
||||||
buy_animal(kind) feed(animal_id,quantity?) collect(animal_id)
|
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 请求示例
|
### 5.3 请求示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -208,7 +219,7 @@ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ...
|
|||||||
"state": {
|
"state": {
|
||||||
"day": 3, "gold": 91, "level": 1, "ap": 6, "feed": 5,
|
"day": 3, "gold": 91, "level": 1, "ap": 6, "feed": 5,
|
||||||
"ripe_plots": [], "hungry_animals": [], "collectable_animals": [],
|
"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 号地播下小麦种子"}]
|
"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 请求。SSE 正好是"一条 HTTP 长连接单向推流",
|
||||||
> 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。
|
> 于是上下行各用最自然的协议,不必为了一条推送引入双向握手、消息帧与心跳管理。
|
||||||
> WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。
|
> 顺带换来的好处:`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 加一个空行:
|
||||||
|
|
||||||
| 消息 | 说明 |
|
```
|
||||||
| --- | --- |
|
data: {"type":"welcome","farm":"alice","state":{…},"catalog":{…},"actions":[…],"online":1}
|
||||||
| `{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}}` | 执行动作,回执带同一个 `id` |
|
|
||||||
| `{"type":"sync","id":2}` | 主动拉一次完整状态 + 合法动作 |
|
data: {"type":"state","seq":42,"farm":"alice","state":{…},"log":[…],"online":2}
|
||||||
| `{"type":"help","id":3}` | 拉规则与动作说明 |
|
|
||||||
| `{"type":"ping"}` | 心跳,回 `pong` |
|
: keep-alive
|
||||||
|
```
|
||||||
|
|
||||||
### 服务器 → 客户端
|
### 服务器 → 客户端
|
||||||
|
|
||||||
| 消息 | 触发时机 |
|
| 类型 | 触发时机 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `{"type":"welcome", "state":…, "catalog":…, "actions":…, "log":…, "online":1}` | 连接建立 |
|
| `welcome` | 连接建立:一次给全状态、目录、合法动作、日志、在线人数 |
|
||||||
| `{"type":"state", "seq":…, "state":…, "log":[…]}` | 状态变化时推送(**节流合并到 4 次/秒**) |
|
| `state` | 状态变化时推送(**节流合并到 4 次/秒**),带自增 `seq` |
|
||||||
| `{"type":"day", "day":5, "events":[…]}` | 进入新的一天 |
|
| `day` | 进入新的一天,附当日事件 |
|
||||||
| `{"type":"result", "id":1, "ok":true, "message":…, "snapshot":…}` | 动作回执 |
|
| `notice` | 有人加入 / 离开牧场 |
|
||||||
| `{"type":"notice", "text":"web 来到了牧场(在线 2)"}` | 有人加入 / 离开 |
|
| `bye` | 服务器正在停机(收到就可以收摊了) |
|
||||||
| `{"type":"error", "error":"bad_json", "message":…}` | 协议错误 |
|
| `error` | 通道层错误 |
|
||||||
|
|
||||||
用 Python 直接玩(不依赖本项目代码):
|
心跳:无消息时每 15 秒发一行 `: keep-alive`(SSE 注释行),既防中间代理掐掉空闲连接,
|
||||||
|
也让客户端能区分"只是没消息"和"连接真的断了"。
|
||||||
|
|
||||||
|
### 客户端 → 服务器
|
||||||
|
|
||||||
|
写操作**不占用**这条通道,直接 `POST /api/actions` 即可(见上一节)。
|
||||||
|
两条路改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
||||||
|
|
||||||
|
用 Python 直接读(只用标准库,不依赖本项目代码):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio, json, websockets
|
import json, urllib.request
|
||||||
|
|
||||||
async def main():
|
request = urllib.request.Request(
|
||||||
async with websockets.connect("ws://127.0.0.1:8765/ws?farm=demo") as ws:
|
"http://127.0.0.1:8765/api/stream?farm=demo",
|
||||||
welcome = json.loads(await ws.recv())
|
headers={"Accept": "text/event-stream"},
|
||||||
print("第", welcome["state"]["day"], "天")
|
)
|
||||||
await ws.send(json.dumps({"type": "action", "id": 1, "action": "harvest_all"}))
|
with urllib.request.urlopen(request, timeout=45) as response:
|
||||||
async for raw in ws:
|
for raw in response: # 一行一行流式读,不等响应结束
|
||||||
msg = json.loads(raw)
|
line = raw.decode("utf-8").strip()
|
||||||
if msg["type"] == "result":
|
if not line.startswith("data:"):
|
||||||
print(msg["message"])
|
continue
|
||||||
elif msg["type"] == "state":
|
message = json.loads(line[5:])
|
||||||
print("金币", msg["state"]["gold"])
|
if message["type"] == "welcome":
|
||||||
|
print("第", message["state"]["day"], "天")
|
||||||
asyncio.run(main())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
浏览器里就是一行:
|
||||||
|
|
||||||
|
```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. 多人模型
|
## 7. 多人模型
|
||||||
@@ -315,9 +348,12 @@ uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个
|
|||||||
> **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件,
|
> **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件,
|
||||||
> 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**,
|
> 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**,
|
||||||
> 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。
|
> 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。
|
||||||
> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程在端口绑定成功(lifespan 启动)后
|
> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程启动时写入自己的 `os.getpid()`,
|
||||||
> 才写入自己的 `os.getpid()`,退出时再删掉。`stop` 则优先走 HTTP 优雅停机,
|
> 退出时再删掉。`stop` 则优先走 HTTP 优雅停机,拿不到 pid 也能停下来。
|
||||||
> 拿不到 pid 也能停下来。
|
>
|
||||||
|
> 细节:uvicorn 的 lifespan 启动**早于**端口绑定,所以 pid 文件会比"端口可连"早半步出现。
|
||||||
|
> 万一端口被占用、进程随即退出,收尾的 `finally` 会把这个 pid 文件删掉,
|
||||||
|
> 不会留下幽灵进程让 `stop` 误判。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -338,62 +374,28 @@ farm/
|
|||||||
keys.py 跨平台按键读取
|
keys.py 跨平台按键读取
|
||||||
net.py 回环请求绕过系统代理的小工具
|
net.py 回环请求绕过系统代理的小工具
|
||||||
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
|
tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦)
|
||||||
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP + WS)
|
client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP 写 + SSE 读)
|
||||||
bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略)
|
bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略)
|
||||||
server/
|
server/
|
||||||
app.py FastAPI 应用:REST + WebSocket + 多牧场调度
|
app.py FastAPI 应用:REST + SSE + 多牧场调度
|
||||||
actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验
|
actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验
|
||||||
session.py 牧场会话与统一时钟
|
session.py 牧场会话与统一时钟
|
||||||
hub.py WebSocket 连接池与状态推送(线程 → 事件循环的桥)
|
hub.py SSE 订阅池与状态推送(线程 → 事件循环的桥)
|
||||||
daemon.py 守护进程:start / stop / status / 日志 / pid
|
daemon.py 守护进程:start / stop / status / 日志 / pid
|
||||||
webui.py 内嵌网页客户端(单页,原生 JS + WebSocket)
|
webui.py 内嵌网页客户端(单页,原生 JS + EventSource)
|
||||||
__main__.py uv run python -m farm.server(前台运行)
|
__main__.py uv run python -m farm.server(uvicorn 前台运行 + HTTP/2)
|
||||||
tools/
|
tools/
|
||||||
preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器)
|
preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器)
|
||||||
ws_demo.py 最小 WebSocket 客户端,验证协议
|
sse_demo.py 最小 SSE 客户端(只用标准库),验证协议
|
||||||
tests/
|
|
||||||
test_engine.py 引擎规则与存档(单机层)
|
|
||||||
test_server.py 动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端
|
|
||||||
```
|
```
|
||||||
|
|
||||||
分层原则:**规则不认识网络,网络不认识终端。**
|
分层原则:**规则不认识网络,网络不认识终端。**
|
||||||
`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。
|
`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。
|
||||||
所以三端共享完全一致的裁判逻辑,也能各自单独测试。
|
所以三端共享完全一致的裁判逻辑。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 测试
|
## 10. 工程化:uv + ruff + pyright
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里——
|
依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里——
|
||||||
单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果,
|
单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果,
|
||||||
@@ -449,7 +451,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 边界处理与设计取舍
|
## 11. 边界处理与设计取舍
|
||||||
|
|
||||||
**输入校验**(唯一入口 `server/actions.py`):
|
**输入校验**(唯一入口 `server/actions.py`):
|
||||||
|
|
||||||
@@ -463,7 +465,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
|||||||
**并发与容错**:
|
**并发与容错**:
|
||||||
|
|
||||||
* 引擎内 `RLock` 串行化同一牧场,多牧场并行;
|
* 引擎内 `RLock` 串行化同一牧场,多牧场并行;
|
||||||
* WebSocket 每条连接一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
|
* SSE 每条订阅一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端;
|
||||||
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
|
* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧;
|
||||||
* 引擎调用统一丢进线程池,不阻塞事件循环;
|
* 引擎调用统一丢进线程池,不阻塞事件循环;
|
||||||
* 单条连接/单个牧场出错不影响其它牧场;
|
* 单条连接/单个牧场出错不影响其它牧场;
|
||||||
@@ -475,13 +477,35 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
|
|||||||
`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上",
|
`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上",
|
||||||
报的还是 `502 upstream connect failed` 这种看不出根因的错误。
|
报的还是 `502 upstream connect failed` 这种看不出根因的错误。
|
||||||
所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连,
|
所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连,
|
||||||
目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,`websockets` 默认不读代理变量。)
|
目标是别的机器才尊重环境里的代理设置。(SSE 长连接也走这条路,所以本机连本机不会被代理截走。)
|
||||||
|
|
||||||
**测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器),
|
**测试产物**:`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**:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON;
|
||||||
gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。
|
gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。
|
||||||
HTTP + WebSocket 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
|
HTTP + SSE 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。
|
||||||
|
|
||||||
*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)*
|
*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)*
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""外挂式 AI/机器人玩家——完全通过 HTTP + WebSocket 接口参与游戏。
|
"""外挂式 AI/机器人玩家——完全通过 HTTP + SSE 接口参与游戏。
|
||||||
|
|
||||||
这个脚本**不**导入任何游戏规则代码,只用接口。换句话说:
|
这个脚本**不**导入任何游戏规则代码,只用接口。换句话说:
|
||||||
它对服务器来说和一个远程 LLM Agent 没有任何区别。
|
它对服务器来说和一个远程 LLM Agent 没有任何区别。
|
||||||
@@ -33,7 +33,7 @@ from farm.engine import GameError
|
|||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="uv run ai_player.py",
|
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("--url", default="http://127.0.0.1:8765", help="服务器地址")
|
||||||
parser.add_argument("--farm", default="ai-1", help="经营哪个牧场,默认 ai-1")
|
parser.add_argument("--farm", default="ai-1", help="经营哪个牧场,默认 ai-1")
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
* :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config`
|
* :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config`
|
||||||
纯游戏规则与数据,不依赖终端也不依赖网络。
|
纯游戏规则与数据,不依赖终端也不依赖网络。
|
||||||
* :mod:`farm.server`
|
* :mod:`farm.server`
|
||||||
服务器侧:FastAPI 应用、动作表、多牧场会话、WebSocket 推送、守护进程。
|
服务器侧:FastAPI 应用、动作表、多牧场会话、SSE 推送、守护进程。
|
||||||
* :mod:`farm.client`
|
* :mod:`farm.client`
|
||||||
客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。
|
客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。
|
||||||
* :mod:`farm.tui`
|
* :mod:`farm.tui`
|
||||||
|
|||||||
+44
-22
@@ -12,6 +12,10 @@
|
|||||||
|
|
||||||
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
|
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
|
||||||
换个 ``--url`` 就是一个独立的外挂玩家。
|
换个 ``--url`` 就是一个独立的外挂玩家。
|
||||||
|
|
||||||
|
节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,所以行动点
|
||||||
|
用完之后它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
|
||||||
|
就是"最多等多久",默认 0——一天 40 分钟,干等不划算。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +26,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .engine import GameError
|
from .engine import GameError
|
||||||
|
from .models import format_duration
|
||||||
|
|
||||||
|
|
||||||
class RuleBot:
|
class RuleBot:
|
||||||
@@ -125,12 +130,6 @@ class RuleBot:
|
|||||||
result = self.client._action(name, params) # 走通用动作入口
|
result = self.client._action(name, params) # 走通用动作入口
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
self.failures += 1
|
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}")
|
self.say(f"✗ {name} 失败({exc.code}):{exc.message}")
|
||||||
return None
|
return None
|
||||||
self.actions_executed += 1
|
self.actions_executed += 1
|
||||||
@@ -140,24 +139,45 @@ class RuleBot:
|
|||||||
time.sleep(self.pause)
|
time.sleep(self.pause)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
def rest(self) -> bool:
|
def wait_for_next_day(self, timeout: float) -> bool:
|
||||||
"""行动点用完时推进一天。"""
|
"""没得可做时等时间自己流动到下一天。
|
||||||
try:
|
|
||||||
result = self.client.sleep()
|
返回 True 表示等到了新的一天;``timeout`` 为 0 表示不等待、直接收工
|
||||||
except GameError as exc:
|
(机器人本来就该"把能做的做完就走",不像真人会挂机守着)。
|
||||||
if exc.code == "cooldown":
|
"""
|
||||||
time.sleep(1.0)
|
state = self.client.snapshot()
|
||||||
return True
|
if state.get("paused"):
|
||||||
self.say(f"✗ sleep 失败:{exc.message}")
|
self.say("— 时间被暂停了,再等也不会有下一天,先收工。")
|
||||||
return False
|
return False
|
||||||
self.say(f"— {result.get('message', '进入下一天')}")
|
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
|
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
|
deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None
|
||||||
start_day = self.client.snapshot().get("day", 1)
|
start_day = self.client.snapshot().get("day", 1)
|
||||||
@@ -175,16 +195,18 @@ class RuleBot:
|
|||||||
|
|
||||||
state = self.client.snapshot()
|
state = self.client.snapshot()
|
||||||
if state.get("ap", 0) <= 0:
|
if state.get("ap", 0) <= 0:
|
||||||
# 行动点用完:休息推进一天,顺便等冷却
|
# 行动点用完:只能等这一天自己走完,没有手动快进
|
||||||
self.rest()
|
if not self.wait_for_next_day(wait_seconds):
|
||||||
|
break
|
||||||
|
idle = 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
did = self.step()
|
did = self.step()
|
||||||
if did is None:
|
if did is None:
|
||||||
idle += 1
|
idle += 1
|
||||||
if idle >= idle_limit:
|
if idle >= idle_limit:
|
||||||
self.say("暂时没有可做的事,休息一天再看。")
|
if not self.wait_for_next_day(wait_seconds):
|
||||||
self.rest()
|
break
|
||||||
idle = 0
|
idle = 0
|
||||||
else:
|
else:
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|||||||
+104
-186
@@ -15,20 +15,21 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程
|
|||||||
|
|
||||||
字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。
|
字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。
|
||||||
|
|
||||||
双通道
|
读写分工
|
||||||
------
|
--------
|
||||||
* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。
|
* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。
|
||||||
* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。
|
* **读数走 SSE**(GET /api/stream):服务器在状态变化时顺着一条 HTTP 长连接推过来,
|
||||||
* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
|
界面不用轮询;上行下行都是普通 HTTP,不需要任何第三方库。
|
||||||
|
* SSE 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。
|
||||||
|
|
||||||
请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。
|
请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -36,8 +37,6 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import Future
|
|
||||||
from concurrent.futures import TimeoutError as FutureTimeout
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import config, net
|
from . import config, net
|
||||||
@@ -45,7 +44,19 @@ from .engine import GameError
|
|||||||
|
|
||||||
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
|
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
|
||||||
LOG_KEEP = 200
|
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:
|
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||||
self._d: dict[str, Any] = {}
|
self._d: dict[str, Any] = {}
|
||||||
|
# 空视图也要长得像"空农场":还没收到第一份快照时(或服务器连不上时),
|
||||||
|
# 上层会直接读 state.plots 渲染,不能甩出 AttributeError。
|
||||||
|
self.plots: list[PlotView] = []
|
||||||
|
self.animals: list[AnimalView] = []
|
||||||
self.apply(data or {})
|
self.apply(data or {})
|
||||||
|
|
||||||
def apply(self, data: dict[str, Any]) -> None:
|
def apply(self, data: dict[str, Any]) -> None:
|
||||||
@@ -248,19 +263,17 @@ class RemoteFarm:
|
|||||||
|
|
||||||
self.state = StateView()
|
self.state = StateView()
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self.mode = "http" # http / ws / poll
|
self.mode = "http" # http(只有写请求)/ sse(长连接推送)/ poll(轮询兜底)
|
||||||
self.last_error = ""
|
self.last_error = ""
|
||||||
self.last_message = ""
|
self.last_message = ""
|
||||||
self.server_info: dict[str, Any] = {}
|
self.server_info: dict[str, Any] = {}
|
||||||
self.on_change: Callable[[], None] | None = None
|
self.on_change: Callable[[], None] | None = None
|
||||||
|
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._pending: dict[int, Future] = {}
|
|
||||||
self._req_id = 0
|
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._ws_socket: Any = None
|
self._stream_response: Any = None
|
||||||
self._ws_loop: Any = None
|
self._stream_socket: Any = None
|
||||||
self._catalog: dict[str, Any] = {
|
self._catalog: dict[str, Any] = {
|
||||||
"crops": [],
|
"crops": [],
|
||||||
"animals": [],
|
"animals": [],
|
||||||
@@ -270,7 +283,7 @@ class RemoteFarm:
|
|||||||
}
|
}
|
||||||
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
|
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
|
||||||
self._log_seq = 0
|
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:
|
if autostart:
|
||||||
self.refresh(raise_on_error=True)
|
self.refresh(raise_on_error=True)
|
||||||
@@ -280,12 +293,18 @@ class RemoteFarm:
|
|||||||
# ------------------------------------------------------------ 基础设施
|
# ------------------------------------------------------------ 基础设施
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize(endpoint: str) -> str:
|
def _normalize(endpoint: str) -> str:
|
||||||
endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
|
endpoint = (endpoint or "").strip()
|
||||||
if endpoint.startswith("tcp://"):
|
if endpoint.startswith("tcp://"):
|
||||||
endpoint = "http://" + endpoint[len("tcp://") :]
|
endpoint = "http://" + endpoint[len("tcp://") :]
|
||||||
if not endpoint.startswith(("http://", "https://")):
|
elif not endpoint.startswith(("http://", "https://")):
|
||||||
endpoint = "http://" + endpoint
|
endpoint = "http://" + 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 endpoint
|
||||||
|
return DEFAULT_ENDPOINT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self) -> str:
|
def host(self) -> str:
|
||||||
@@ -346,7 +365,7 @@ class RemoteFarm:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ 读接口
|
# ------------------------------------------------------------ 读接口
|
||||||
def refresh(self, raise_on_error: bool = False) -> bool:
|
def refresh(self, raise_on_error: bool = False) -> bool:
|
||||||
"""主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。"""
|
"""主动拉一次全量数据(首次连接、以及 SSE 断开后的兜底)。"""
|
||||||
try:
|
try:
|
||||||
data = self._http("GET", "/api/state")
|
data = self._http("GET", "/api/state")
|
||||||
self._apply_online(data.get("state") or {}, data.get("online"))
|
self._apply_online(data.get("state") or {}, data.get("online"))
|
||||||
@@ -417,38 +436,15 @@ class RemoteFarm:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------ 写接口
|
# ------------------------------------------------------------ 写接口
|
||||||
def _action(
|
def _action(self, action: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
self, action: str, params: dict[str, Any] | None = None, transport: str = "auto"
|
"""执行一个动作:写操作统一走 HTTP POST /api/actions。
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""执行一个动作。
|
|
||||||
|
|
||||||
优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
|
实时通道只负责下行推送,所以这里不需要"优先哪条通道"的判断;
|
||||||
(连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家,
|
``full=1`` 让服务器顺手把完整快照带回来,省掉"操作后再查一次"。
|
||||||
否则"金币不足"会被悄悄重试成两次操作。
|
|
||||||
"""
|
"""
|
||||||
params = params or {}
|
data = self._http(
|
||||||
use_ws = (transport == "ws") or (
|
"POST", "/api/actions", {"action": action, "params": params or {}}, full=1
|
||||||
transport == "auto" and self.mode == "ws" and self.connected
|
|
||||||
)
|
)
|
||||||
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._apply(data.get("snapshot") or data.get("state") or {})
|
||||||
self._merge_log(data.get("log") or [])
|
self._merge_log(data.get("log") or [])
|
||||||
return data
|
return data
|
||||||
@@ -484,22 +480,13 @@ class RemoteFarm:
|
|||||||
def buy_plot(self) -> dict[str, Any]:
|
def buy_plot(self) -> dict[str, Any]:
|
||||||
return self._action("buy_plot")
|
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]:
|
def pause(self, paused: bool | None = None) -> dict[str, Any]:
|
||||||
|
# 动作回执里的 state(brief)已带 paused 与 seconds_to_tick,
|
||||||
|
# _action 内部会合并进本地视图,这里只需转发参数
|
||||||
params: dict[str, Any] = {}
|
params: dict[str, Any] = {}
|
||||||
if paused is not None:
|
if paused is not None:
|
||||||
params["paused"] = paused
|
params["paused"] = paused
|
||||||
data = self._action("pause", params)
|
return 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
|
|
||||||
|
|
||||||
def reset(self) -> dict[str, Any]:
|
def reset(self) -> dict[str, Any]:
|
||||||
data = self._action("reset")
|
data = self._action("reset")
|
||||||
@@ -520,60 +507,64 @@ class RemoteFarm:
|
|||||||
self._merge_log(data.get("log") or [])
|
self._merge_log(data.get("log") or [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# ------------------------------------------------------------ WebSocket
|
# ------------------------------------------------------------ 实时通道(SSE)
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if not self.realtime_enabled or self._thread is not None:
|
if not self.realtime_enabled or self._thread is not None:
|
||||||
return
|
return
|
||||||
self._stop.clear()
|
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()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
with self._lock:
|
# 主动掐断长连接,否则服务端那条流式响应会一直挂着,拖慢它的优雅停机。
|
||||||
for future in self._pending.values():
|
# 注意不能直接 response.close():读线程正持有那把锁,close 会一直等它,
|
||||||
if not future.done():
|
# 而它要等下一次心跳(15 秒)才醒。直接 shutdown 底层 socket 才能立刻唤醒。
|
||||||
future.set_exception(GameError("closed", "连接已关闭"))
|
sock = self._stream_socket
|
||||||
self._pending.clear()
|
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:
|
def transport_label(self) -> str:
|
||||||
if self.mode == "ws":
|
if self.mode == "sse":
|
||||||
return "WebSocket 实时"
|
return "SSE 实时"
|
||||||
if self.mode == "poll":
|
if self.mode == "poll":
|
||||||
return "HTTP 轮询"
|
return "HTTP 轮询"
|
||||||
return "HTTP"
|
return "HTTP"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ws_url(self) -> str:
|
def stream_url(self) -> str:
|
||||||
parts = urllib.parse.urlsplit(self.endpoint)
|
"""SSE 长连接地址。
|
||||||
scheme = "wss" if parts.scheme == "https" else "ws"
|
|
||||||
query = {"farm": self.farm}
|
令牌只能走查询参数:``EventSource`` 与 ``urllib`` 都没法给长连接加自定义头。
|
||||||
|
"""
|
||||||
|
query: dict[str, Any] = {"farm": self.farm}
|
||||||
if self.token:
|
if self.token:
|
||||||
query["token"] = self.token
|
query["token"] = self.token
|
||||||
if self.name:
|
if self.name:
|
||||||
query["name"] = 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:
|
def _realtime_loop(self) -> None:
|
||||||
"""实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。"""
|
"""实时线程:读 SSE 长连接;断了就一边轮询一边重连,永不放弃。"""
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
self._ws_session()
|
self._stream_once()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||||
if self.mode != "ws":
|
|
||||||
self.mode = "poll"
|
self.mode = "poll"
|
||||||
self._fail_pending(self.last_error)
|
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
break
|
break
|
||||||
self._poll_until_reconnect()
|
self._poll_until_reconnect()
|
||||||
|
|
||||||
def _poll_until_reconnect(self) -> None:
|
def _poll_until_reconnect(self) -> None:
|
||||||
"""WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。"""
|
"""SSE 断开期间的降级通道:轻量轮询 + 到点再试一次长连接。"""
|
||||||
if self.mode == "ws":
|
deadline = time.time() + STREAM_RECONNECT_DELAY
|
||||||
self.mode = "poll"
|
|
||||||
deadline = time.time() + WS_RECONNECT_DELAY
|
|
||||||
while not self._stop.is_set() and time.time() < deadline:
|
while not self._stop.is_set() and time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
data = self._http("GET", "/api/state")
|
data = self._http("GET", "/api/state")
|
||||||
@@ -582,64 +573,46 @@ class RemoteFarm:
|
|||||||
pass
|
pass
|
||||||
self._stop.wait(0.5)
|
self._stop.wait(0.5)
|
||||||
|
|
||||||
def _ws_session(self) -> None:
|
def _stream_once(self) -> None:
|
||||||
import asyncio
|
"""读一次 SSE 流,直到断开或主动停止。
|
||||||
|
|
||||||
try:
|
直接按行迭代响应体:一行一行拿,不把响应收完,
|
||||||
import websockets
|
所以这是真正的流式读取,而且只用标准库。
|
||||||
except ImportError:
|
"""
|
||||||
# 没装 websockets:直接走轮询模式,功能不减
|
request = urllib.request.Request(self.stream_url, headers={"Accept": "text/event-stream"})
|
||||||
self.mode = "poll"
|
# 服务器每 HEARTBEAT_SECONDS 秒发一次心跳,读超时要比它宽裕
|
||||||
self._stop.wait(WS_RECONNECT_DELAY)
|
with net.open_url(request, timeout=max(self.timeout, STREAM_READ_TIMEOUT)) as response:
|
||||||
return
|
self._stream_response = response
|
||||||
|
self._stream_socket = _underlying_socket(response)
|
||||||
asyncio.run(self._ws_main(websockets))
|
self.mode = "sse"
|
||||||
|
|
||||||
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"
|
|
||||||
self.connected = True
|
self.connected = True
|
||||||
self.last_error = ""
|
self.last_error = ""
|
||||||
self._ws_socket = socket
|
|
||||||
self._ws_loop = asyncio.get_running_loop()
|
|
||||||
try:
|
try:
|
||||||
while not self._stop.is_set():
|
for raw_line in response:
|
||||||
try:
|
if self._stop.is_set():
|
||||||
raw = await asyncio.wait_for(socket.recv(), timeout=30.0)
|
return
|
||||||
except asyncio.TimeoutError:
|
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||||
await socket.send(json.dumps({"type": "ping"}))
|
if not line or line.startswith(":"):
|
||||||
|
# 空行是帧分隔符;冒号开头是心跳注释,都无需处理
|
||||||
continue
|
continue
|
||||||
self._handle_ws_message(raw)
|
if line.startswith("data:"):
|
||||||
|
self._handle_stream_message(line[len("data:") :].strip())
|
||||||
finally:
|
finally:
|
||||||
self._ws_socket = None
|
self._stream_response = None
|
||||||
self._ws_loop = None
|
self._stream_socket = None
|
||||||
self._fail_pending("WebSocket 连接已断开")
|
|
||||||
|
|
||||||
def _fail_pending(self, reason: str) -> None:
|
def _handle_stream_message(self, payload: 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:
|
|
||||||
try:
|
try:
|
||||||
message = json.loads(raw)
|
message = json.loads(payload)
|
||||||
except (TypeError, json.JSONDecodeError):
|
except json.JSONDecodeError:
|
||||||
return
|
return
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
return
|
return
|
||||||
kind = message.get("type")
|
kind = message.get("type")
|
||||||
|
|
||||||
if kind == "welcome":
|
if kind in ("welcome", "state", "sync"):
|
||||||
self.server_info = message.get("server") or {}
|
if message.get("server"):
|
||||||
|
self.server_info = message["server"]
|
||||||
self.farm = message.get("farm", self.farm)
|
self.farm = message.get("farm", self.farm)
|
||||||
if message.get("catalog"):
|
if message.get("catalog"):
|
||||||
self._catalog = message["catalog"]
|
self._catalog = message["catalog"]
|
||||||
@@ -648,38 +621,6 @@ class RemoteFarm:
|
|||||||
self.stats["pushes"] += 1
|
self.stats["pushes"] += 1
|
||||||
return
|
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":
|
if kind == "day":
|
||||||
self._merge_log(
|
self._merge_log(
|
||||||
[
|
[
|
||||||
@@ -704,36 +645,13 @@ class RemoteFarm:
|
|||||||
|
|
||||||
if kind == "error":
|
if kind == "error":
|
||||||
self.last_error = message.get("message", "")
|
self.last_error = message.get("message", "")
|
||||||
|
self.stats["errors"] += 1
|
||||||
|
return
|
||||||
|
|
||||||
def _ws_send(self, payload: dict[str, Any]) -> None:
|
if kind == "bye":
|
||||||
"""把消息发到 WS 线程的事件循环里(跨线程投递)。"""
|
# 服务器主动收尾(停机):记下原因,让上层提示"服务器已停止"
|
||||||
socket = getattr(self, "_ws_socket", None)
|
self.connected = False
|
||||||
loop = getattr(self, "_ws_loop", None)
|
self.last_error = message.get("message", "服务器已停机")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteBotClient(RemoteFarm):
|
class RemoteBotClient(RemoteFarm):
|
||||||
|
|||||||
+6
-4
@@ -185,10 +185,12 @@ MAX_ANIMALS = 8 # 牧场总容量
|
|||||||
# 时间
|
# 时间
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数
|
# 一天有多长(真实秒数)。慢是刻意的:这是“放置农场”——
|
||||||
MIN_TICK_SECONDS = 3.0
|
# 一天 40 分钟,作物 2~10 天成熟(小麦 80 分钟、草莓约 6.7 小时)。
|
||||||
MAX_TICK_SECONDS = 600.0
|
# 没有任何“手动推进一天”的接口,时间只能自然流动。
|
||||||
SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒)
|
DEFAULT_TICK_SECONDS = 2400.0
|
||||||
|
MIN_TICK_SECONDS = 3.0 # 调试用:把一天缩到几秒
|
||||||
|
MAX_TICK_SECONDS = 86400.0 # 上限给到一整天,想多慢都不会被夹住
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
+137
-71
@@ -51,7 +51,6 @@ class FarmEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._log_seq = 0
|
self._log_seq = 0
|
||||||
self._last_sleep = 0.0
|
|
||||||
self.paused = False
|
self.paused = False
|
||||||
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
|
# 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None)
|
||||||
self.next_tick_at: float | None = None
|
self.next_tick_at: float | None = None
|
||||||
@@ -106,6 +105,44 @@ class FarmEngine:
|
|||||||
return None
|
return None
|
||||||
return round(max(0.0, self.next_tick_at - time.time()), 1)
|
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:
|
def market_price(self, key: str) -> float:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return round(self.state.market.get(key, 0.0), 2)
|
return round(self.state.market.get(key, 0.0), 2)
|
||||||
@@ -127,7 +164,7 @@ class FarmEngine:
|
|||||||
"price": price,
|
"price": price,
|
||||||
"exp": info["exp"],
|
"exp": info["exp"],
|
||||||
"level_req": info["level_req"],
|
"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": round(gross - info["seed_cost"], 2),
|
||||||
"profit_per_day": round((gross - info["seed_cost"]) / days, 2),
|
"profit_per_day": round((gross - info["seed_cost"]) / days, 2),
|
||||||
}
|
}
|
||||||
@@ -147,7 +184,7 @@ class FarmEngine:
|
|||||||
"feed_per_day": info["feed_per_day"],
|
"feed_per_day": info["feed_per_day"],
|
||||||
"exp": info["exp"],
|
"exp": info["exp"],
|
||||||
"level_req": info["level_req"],
|
"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),
|
"profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -166,47 +203,62 @@ class FarmEngine:
|
|||||||
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
|
``cost``(金币/行动点开销)、``reason``(为什么值得做)。
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
actions: list[dict[str, Any]] = []
|
|
||||||
state = self.state
|
state = self.state
|
||||||
|
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:
|
for plot in state.plots:
|
||||||
|
position = plot.index + 1
|
||||||
if plot.crop is None:
|
if plot.crop is None:
|
||||||
for key, info in config.CROPS.items():
|
for key, info in plantable:
|
||||||
if state.level < info["level_req"]:
|
|
||||||
continue
|
|
||||||
if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]:
|
|
||||||
continue
|
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"action": "plant",
|
"action": "plant",
|
||||||
"params": {"plot": plot.index + 1, "crop": key},
|
"params": {"plot": position, "crop": key},
|
||||||
"cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]},
|
"cost": {"gold": info["seed_cost"], "ap": plant_ap},
|
||||||
"reason": f"{plot.index + 1} 号地空闲,可种{info['name']}",
|
"reason": f"{position} 号地空闲,可种{info['name']}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif plot.is_ripe:
|
elif plot.is_ripe:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"action": "harvest",
|
"action": "harvest",
|
||||||
"params": {"plot": plot.index + 1},
|
"params": {"plot": position},
|
||||||
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
|
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]},
|
||||||
"reason": (
|
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
|
||||||
f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
|
elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"action": "water",
|
"action": "water",
|
||||||
"params": {"plot": plot.index + 1},
|
"params": {"plot": position},
|
||||||
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
|
"cost": {"gold": 0, "ap": config.AP_COST["water"]},
|
||||||
"reason": f"{plot.index + 1} 号地缺水,作物已停止生长",
|
"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:
|
for animal in state.animals:
|
||||||
if animal.pending > 0:
|
if animal.pending > 0:
|
||||||
actions.append(
|
actions.append(
|
||||||
@@ -229,8 +281,11 @@ class FarmEngine:
|
|||||||
"reason": f"{animal.name}(#{animal.aid})今天还没喂食",
|
"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
|
price = state.next_plot_price
|
||||||
if price > 0 and state.gold >= price:
|
if price > 0 and state.gold >= price:
|
||||||
actions.append(
|
actions.append(
|
||||||
@@ -241,20 +296,18 @@ class FarmEngine:
|
|||||||
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
|
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK:
|
bulk_cost = config.FEED_UNIT_PRICE * config.FEED_BULK
|
||||||
|
if state.feed < 5 and state.gold >= bulk_cost:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"action": "buy_feed",
|
"action": "buy_feed",
|
||||||
"params": {"quantity": config.FEED_BULK},
|
"params": {"quantity": config.FEED_BULK},
|
||||||
"cost": {
|
"cost": {"gold": bulk_cost, "ap": config.AP_COST["buy_feed"]},
|
||||||
"gold": config.FEED_UNIT_PRICE * config.FEED_BULK,
|
|
||||||
"ap": config.AP_COST["buy_feed"],
|
|
||||||
},
|
|
||||||
"reason": "饲料库存偏低,建议补货",
|
"reason": "饲料库存偏低,建议补货",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for key, info in config.ANIMALS.items():
|
for key, info in config.ANIMALS.items():
|
||||||
if state.level < info["level_req"]:
|
if self._is_locked(info):
|
||||||
continue
|
continue
|
||||||
if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
|
if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]:
|
||||||
continue
|
continue
|
||||||
@@ -269,6 +322,32 @@ class FarmEngine:
|
|||||||
return actions
|
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:
|
def _get_plot(self, plot_no: Any) -> Plot:
|
||||||
"""把 1-based 的地块编号转成 Plot,越界即报错。"""
|
"""把 1-based 的地块编号转成 Plot,越界即报错。"""
|
||||||
try:
|
try:
|
||||||
@@ -299,7 +378,7 @@ class FarmEngine:
|
|||||||
raise GameError(
|
raise GameError(
|
||||||
"no_ap",
|
"no_ap",
|
||||||
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
|
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
|
||||||
f"按 N 休息或等待下一天。",
|
f"等一天的时间自然走完就会恢复。",
|
||||||
)
|
)
|
||||||
if cost:
|
if cost:
|
||||||
self.state.ap -= cost
|
self.state.ap -= cost
|
||||||
@@ -333,15 +412,7 @@ class FarmEngine:
|
|||||||
self.state.level += 1
|
self.state.level += 1
|
||||||
bonus = config.level_up_bonus(self.state.level)
|
bonus = config.level_up_bonus(self.state.level)
|
||||||
self.state.gold += bonus
|
self.state.gold += bonus
|
||||||
unlocked = [
|
unlocked = self._newly_unlocked(self.state.level)
|
||||||
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
|
|
||||||
]
|
|
||||||
text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币"
|
text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币"
|
||||||
if unlocked:
|
if unlocked:
|
||||||
text += f",解锁:{'、'.join(unlocked)}"
|
text += f",解锁:{'、'.join(unlocked)}"
|
||||||
@@ -362,11 +433,7 @@ class FarmEngine:
|
|||||||
f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}",
|
f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}",
|
||||||
)
|
)
|
||||||
info = config.CROPS[crop]
|
info = config.CROPS[crop]
|
||||||
if self.state.level < info["level_req"]:
|
self._require_level(info)
|
||||||
raise GameError(
|
|
||||||
"locked",
|
|
||||||
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
|
|
||||||
)
|
|
||||||
plot = self._get_plot(plot_no)
|
plot = self._get_plot(plot_no)
|
||||||
if plot.crop is not None:
|
if plot.crop is not None:
|
||||||
raise GameError(
|
raise GameError(
|
||||||
@@ -451,11 +518,7 @@ class FarmEngine:
|
|||||||
f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}",
|
f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}",
|
||||||
)
|
)
|
||||||
info = config.ANIMALS[kind]
|
info = config.ANIMALS[kind]
|
||||||
if self.state.level < info["level_req"]:
|
self._require_level(info)
|
||||||
raise GameError(
|
|
||||||
"locked",
|
|
||||||
f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}",
|
|
||||||
)
|
|
||||||
if len(self.state.animals) >= config.MAX_ANIMALS:
|
if len(self.state.animals) >= config.MAX_ANIMALS:
|
||||||
raise GameError("pasture_full", f"牧场已满(上限 {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,
|
"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]:
|
def pause(self, paused: bool | None = None) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.paused = bool(paused) if paused is not None else not self.paused
|
self.paused = bool(paused) if paused is not None else not self.paused
|
||||||
@@ -610,7 +662,6 @@ class FarmEngine:
|
|||||||
tick = self.tick_seconds
|
tick = self.tick_seconds
|
||||||
self.state = GameState()
|
self.state = GameState()
|
||||||
self._log_seq = 0
|
self._log_seq = 0
|
||||||
self._last_sleep = 0.0
|
|
||||||
self.paused = False
|
self.paused = False
|
||||||
if keep_tick:
|
if keep_tick:
|
||||||
self.tick_seconds = 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:
|
class Scheduler:
|
||||||
"""自动推进游戏时间的后台线程。"""
|
"""自动推进游戏时间的后台线程(单机模式;服务器版见 farm.server.session.FarmClock)。"""
|
||||||
|
|
||||||
def __init__(self, engine: FarmEngine, enabled: bool = True) -> None:
|
def __init__(self, engine: FarmEngine, enabled: bool = True) -> None:
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
@@ -732,16 +805,9 @@ class Scheduler:
|
|||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
while not self._stop.wait(0.2):
|
while not self._stop.wait(0.2):
|
||||||
engine = self.engine
|
engine = self.engine
|
||||||
if engine.paused:
|
advance, engine.next_tick_at = tick_decision(
|
||||||
# 暂停时顺延,保证恢复后仍有完整的一个 tick
|
engine.paused, engine.next_tick_at, engine.tick_seconds, time.time()
|
||||||
engine.next_tick_at = time.time() + engine.tick_seconds
|
)
|
||||||
continue
|
if advance:
|
||||||
deadline = engine.next_tick_at
|
|
||||||
if deadline is None:
|
|
||||||
deadline = time.time() + engine.tick_seconds
|
|
||||||
if time.time() >= deadline:
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
engine.advance_day(reason="时间流逝")
|
engine.advance_day(reason="时间流逝")
|
||||||
engine.next_tick_at = time.time() + engine.tick_seconds
|
|
||||||
else:
|
|
||||||
engine.next_tick_at = deadline
|
|
||||||
+227
-45
@@ -1,10 +1,21 @@
|
|||||||
"""跨平台非阻塞键盘读取。
|
"""跨平台非阻塞键盘读取。
|
||||||
|
|
||||||
Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``,
|
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,
|
在非交互式环境(管道、重定向)下 ``available`` 为 False,
|
||||||
TUI 会退化为无输入的只读模式。
|
TUI 会退化为无输入的只读模式,并把 :attr:`KeyReader.reason` 显示给玩家。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,30 +23,95 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
IS_WINDOWS = os.name == "nt"
|
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 = {
|
_WIN_EXTENDED = {
|
||||||
"H": "UP",
|
"H": UP,
|
||||||
"P": "DOWN",
|
"P": DOWN,
|
||||||
"K": "LEFT",
|
"K": LEFT,
|
||||||
"M": "RIGHT",
|
"M": RIGHT,
|
||||||
"G": "HOME",
|
"G": HOME,
|
||||||
"O": "END",
|
"O": END,
|
||||||
"S": "DELETE",
|
"S": DELETE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Windows 控制字符
|
||||||
_WIN_CTRL = {
|
_WIN_CTRL = {
|
||||||
"\x03": "CTRL_C",
|
"\x03": CTRL_C,
|
||||||
"\r": "ENTER",
|
"\r": ENTER,
|
||||||
"\n": "ENTER",
|
"\n": ENTER,
|
||||||
"\t": "TAB",
|
"\t": TAB,
|
||||||
"\x08": "BACKSPACE",
|
"\x08": BACKSPACE,
|
||||||
"\x1b": "ESC",
|
"\x7f": BACKSPACE,
|
||||||
" ": "SPACE",
|
"\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:
|
class KeyReader:
|
||||||
"""把终端的原始字节流转换成按键名。"""
|
"""把终端的原始字节流转换成按键名。"""
|
||||||
@@ -43,12 +119,16 @@ class KeyReader:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._fd: int | None = None
|
self._fd: int | None = None
|
||||||
self._saved_term = None
|
self._saved_term = None
|
||||||
|
self._raw = b"" # POSIX:上一次没解析完的字节(半个转义序列、半个汉字)
|
||||||
|
|
||||||
|
self.reason = "" # 读不到键盘时的解释,用于提示玩家
|
||||||
|
self.available = False
|
||||||
|
|
||||||
# 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
|
# 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
|
||||||
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
|
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
|
||||||
stdin = sys.stdin
|
stdin = sys.stdin
|
||||||
if stdin is None or not stdin.isatty():
|
if stdin is None or not stdin.isatty():
|
||||||
self.available = False
|
self.reason = "标准输入不是终端(管道或重定向)"
|
||||||
return
|
return
|
||||||
self.available = True
|
self.available = True
|
||||||
|
|
||||||
@@ -56,6 +136,17 @@ class KeyReader:
|
|||||||
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
|
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
|
||||||
if importlib.util.find_spec("msvcrt") is None:
|
if importlib.util.find_spec("msvcrt") is None:
|
||||||
self.available = False
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -71,6 +162,7 @@ class KeyReader:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.available = False
|
self.available = False
|
||||||
self._saved_term = None
|
self._saved_term = None
|
||||||
|
self.reason = "无法把终端切到 cbreak 模式"
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 读取
|
# ------------------------------------------------------------------ 读取
|
||||||
def poll(self) -> str | None:
|
def poll(self) -> str | None:
|
||||||
@@ -79,6 +171,7 @@ class KeyReader:
|
|||||||
return None
|
return None
|
||||||
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
|
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Windows
|
||||||
def _poll_windows(self) -> str | None:
|
def _poll_windows(self) -> str | None:
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
@@ -87,56 +180,144 @@ class KeyReader:
|
|||||||
char = msvcrt.getwch()
|
char = msvcrt.getwch()
|
||||||
|
|
||||||
if char in ("\x00", "\xe0"):
|
if char in ("\x00", "\xe0"):
|
||||||
if not msvcrt.kbhit():
|
code = self._win_extended_code()
|
||||||
return None
|
# 认不出来的功能键就丢掉,不要退化成 ESC 之类的错误动作
|
||||||
return _WIN_EXTENDED.get(msvcrt.getwch().upper())
|
return _WIN_EXTENDED.get(code) if code else None
|
||||||
if char in _WIN_CTRL:
|
named = _WIN_CTRL.get(char)
|
||||||
return _WIN_CTRL[char]
|
return named if named else char.lower()
|
||||||
return 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:
|
def _poll_posix(self) -> str | None:
|
||||||
import select
|
import select
|
||||||
|
|
||||||
if self._fd is None:
|
if self._fd is None:
|
||||||
return None
|
return None
|
||||||
|
if not self._raw:
|
||||||
try:
|
try:
|
||||||
ready, _, _ = select.select([self._fd], [], [], 0)
|
ready, _, _ = select.select([self._fd], [], [], 0)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return None
|
return None
|
||||||
if not ready:
|
if not ready:
|
||||||
return None
|
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:
|
try:
|
||||||
data = os.read(self._fd, 8)
|
data = os.read(self._fd, 64)
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
# 一次读到的可能是好几个按键:全部留在缓冲区里逐个返回,
|
||||||
|
# 不能像老实现那样只取第一个字符、把其余的悄悄丢掉。
|
||||||
|
self._raw += data
|
||||||
|
return data
|
||||||
|
|
||||||
text = data.decode("utf-8", errors="ignore")
|
def _read_ready(self, timeout: float) -> bool:
|
||||||
if not text:
|
"""等一小会儿再读一次,用于判断不完整的转义序列与半个字符。"""
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
# 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D
|
if raw[0:1] == b"\x1b":
|
||||||
if text.startswith("\x1b["):
|
seq = self._read_escape_sequence()
|
||||||
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
|
if seq is None:
|
||||||
if text.startswith("\x1bO"):
|
return None # 还没读全,等下一轮 poll
|
||||||
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3])
|
if seq == b"":
|
||||||
|
return ESC # 就是单按 ESC
|
||||||
|
return _POSIX_CSI.get(seq.decode("latin-1"))
|
||||||
|
|
||||||
char = text[0]
|
first = raw[0]
|
||||||
mapping = {
|
if first < 0x80:
|
||||||
"\x03": "CTRL_C",
|
self._raw = raw[1:]
|
||||||
"\r": "ENTER",
|
char = chr(first)
|
||||||
"\n": "ENTER",
|
named = _POSIX_CTRL.get(char)
|
||||||
"\t": "TAB",
|
return named if named else char.lower()
|
||||||
"\x7f": "BACKSPACE",
|
|
||||||
"\x08": "BACKSPACE",
|
# 多字节 UTF-8:先凑齐整个字符再解码,免得半个汉字被解成乱码
|
||||||
"\x1b": "ESC",
|
length = 2 if first < 0xE0 else 3 if first < 0xF0 else 4
|
||||||
" ": "SPACE",
|
if len(raw) < length:
|
||||||
}
|
self._read_ready(PENDING_WAIT)
|
||||||
if char in mapping:
|
raw = self._raw
|
||||||
return mapping[char]
|
if len(raw) < length:
|
||||||
|
return None # 等下一轮 poll 再来
|
||||||
|
char = raw[:length].decode("utf-8", errors="replace")
|
||||||
|
self._raw = raw[length:]
|
||||||
return char.lower()
|
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:
|
def close(self) -> None:
|
||||||
if self._fd is not None and self._saved_term is not None:
|
if self._fd is not None and self._saved_term is not None:
|
||||||
@@ -148,3 +329,4 @@ class KeyReader:
|
|||||||
pass
|
pass
|
||||||
self._fd = None
|
self._fd = None
|
||||||
self._saved_term = None
|
self._saved_term = None
|
||||||
|
self._raw = b""
|
||||||
+35
-8
@@ -17,6 +17,33 @@ def clamp(value: float, low: float, high: float) -> float:
|
|||||||
return max(low, min(high, value))
|
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:
|
class Plot:
|
||||||
"""一块土地。"""
|
"""一块土地。"""
|
||||||
|
|
||||||
@@ -243,14 +270,14 @@ class GameState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def next_goal(self) -> dict[str, Any] | None:
|
def next_goal(self) -> dict[str, Any] | None:
|
||||||
nxt = self.goal_index + 1
|
return next_goal_after(self.goal_index)
|
||||||
if nxt >= len(config.GOALS):
|
|
||||||
return None
|
|
||||||
amount, text = config.GOALS[nxt]
|
|
||||||
return {"amount": amount, "text": text}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 序列化
|
# ---------------------------------------------------------------- 序列化
|
||||||
def to_dict(self) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"day": self.day,
|
"day": self.day,
|
||||||
"gold": self.gold,
|
"gold": self.gold,
|
||||||
@@ -268,9 +295,9 @@ class GameState:
|
|||||||
"animal_count": len(self.animals),
|
"animal_count": len(self.animals),
|
||||||
"max_animals": config.MAX_ANIMALS,
|
"max_animals": config.MAX_ANIMALS,
|
||||||
"market": {k: round(v, 2) for k, v in self.market.items()},
|
"market": {k: round(v, 2) for k, v in self.market.items()},
|
||||||
"net_worth": round(self.net_worth, 2),
|
"net_worth": round(worth, 2),
|
||||||
"goal_index": self.goal_index,
|
"goal_index": goal,
|
||||||
"next_goal": self.next_goal,
|
"next_goal": next_goal_after(goal),
|
||||||
"stats": {
|
"stats": {
|
||||||
"harvested": self.total_harvested,
|
"harvested": self.total_harvested,
|
||||||
"collected": self.total_collected,
|
"collected": self.total_collected,
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理;
|
* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理;
|
||||||
* 目标是别的机器 → 尊重用户环境里的代理设置。
|
* 目标是别的机器 → 尊重用户环境里的代理设置。
|
||||||
|
|
||||||
WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。
|
SSE 长连接同样经由这里:客户端读 ``GET /api/stream`` 用的是 urllib,
|
||||||
|
所以回环地址也必须直连,否则第一条状态推送就收不到。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -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.actions` —— 动作表:所有入口共用的唯一执行通道
|
||||||
* :mod:`farm.server.session` —— 多牧场会话与统一时钟
|
* :mod:`farm.server.session` —— 多牧场会话与统一时钟
|
||||||
* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送
|
* :mod:`farm.server.hub` —— SSE 订阅池与状态推送
|
||||||
* :mod:`farm.server.daemon` —— 守护进程(start/stop/status)
|
* :mod:`farm.server.daemon` —— 守护进程(start/stop/status)
|
||||||
* :mod:`farm.server.webui` —— 内嵌网页客户端
|
* :mod:`farm.server.webui` —— 内嵌网页客户端
|
||||||
|
|
||||||
|
|||||||
+181
-48
@@ -1,5 +1,14 @@
|
|||||||
"""``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。
|
"""``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` 用同样的命令行
|
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
|
||||||
再拉起一个本模块的实例,只是不带 ``--daemon``。
|
再拉起一个本模块的实例,只是不带 ``--daemon``。
|
||||||
"""
|
"""
|
||||||
@@ -7,18 +16,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
from .. import config
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="uv run python -m farm.server",
|
prog="uv run python -m farm.server",
|
||||||
description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。",
|
description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -68,18 +88,45 @@ def dependency_hint() -> str:
|
|||||||
"缺少运行服务器所需的依赖。请先安装:\n"
|
"缺少运行服务器所需的依赖。请先安装:\n"
|
||||||
" uv sync\n"
|
" uv sync\n"
|
||||||
"如果不想用 uv,也可以用 pip:\n"
|
"如果不想用 uv,也可以用 pip:\n"
|
||||||
" pip install fastapi 'uvicorn[standard]'\n"
|
" pip install 'uvicorn[standard]' fastapi zttp\n"
|
||||||
f"\n当前解释器:{sys.executable}"
|
f"\n当前解释器:{sys.executable}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def clear_pid_file(path: str | None) -> None:
|
||||||
args = build_parser().parse_args(argv)
|
"""删掉自己写的 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")
|
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
|
||||||
if args.save_file:
|
if args.save_file:
|
||||||
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
|
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
|
||||||
|
return save_dir
|
||||||
|
|
||||||
if args.daemon:
|
|
||||||
|
def _start_daemon(args, save_dir: str) -> int:
|
||||||
|
"""把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。"""
|
||||||
manager = DaemonManager(
|
manager = DaemonManager(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
@@ -93,15 +140,46 @@ def main(argv=None) -> int:
|
|||||||
print(message)
|
print(message)
|
||||||
return 0 if ok else 1
|
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:
|
try:
|
||||||
import uvicorn
|
from uvicorn import Config as UvicornConfig
|
||||||
|
from uvicorn import Server as UvicornServer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(dependency_hint(), file=sys.stderr)
|
print(dependency_hint(), file=sys.stderr)
|
||||||
return 2
|
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(
|
cfg = ServerConfig(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
@@ -114,67 +192,122 @@ def main(argv=None) -> int:
|
|||||||
world_name=args.world,
|
world_name=args.world,
|
||||||
allow_remote_control=not args.no_remote_control,
|
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 = create_app(cfg)
|
||||||
app,
|
handle = app.state.handle
|
||||||
|
|
||||||
|
uv_config = UvicornConfig(
|
||||||
|
as_asgi(app),
|
||||||
host=cfg.host,
|
host=cfg.host,
|
||||||
port=cfg.port,
|
port=cfg.port,
|
||||||
log_level=args.log_level,
|
log_level=args.log_level,
|
||||||
access_log=args.access_log,
|
access_log=args.access_log,
|
||||||
loop="asyncio",
|
# HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol,
|
||||||
lifespan="on",
|
# 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 里会把存档写掉)
|
# 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是
|
||||||
app.state.handle.on_stop(lambda: setattr(server, "should_exit", True))
|
# 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。
|
||||||
|
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 未必是真正
|
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
|
||||||
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
|
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
|
||||||
|
# uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口",
|
||||||
|
# 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败,
|
||||||
|
# 下面的 finally 会把 pid 文件删掉,不留幽灵。
|
||||||
def write_pid_file() -> None:
|
def write_pid_file() -> None:
|
||||||
if not args.pid_file:
|
if not args.pid_file:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
|
||||||
with open(args.pid_file, "w", encoding="utf-8") as handle:
|
with open(args.pid_file, "w", encoding="utf-8") as file_handle:
|
||||||
handle.write(str(os.getpid()))
|
file_handle.write(str(os.getpid()))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
|
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_startup_banner(cfg, save_dir)
|
||||||
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(
|
|
||||||
" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;"
|
|
||||||
"网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'"
|
|
||||||
)
|
|
||||||
if cfg.token:
|
|
||||||
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
|
|
||||||
|
|
||||||
server.run()
|
status = 0
|
||||||
|
|
||||||
# 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程"
|
|
||||||
if args.pid_file:
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(args.pid_file):
|
server.run()
|
||||||
with open(args.pid_file, encoding="utf-8") as handle:
|
except OSError as exc:
|
||||||
if handle.read().strip() == str(os.getpid()):
|
|
||||||
os.remove(args.pid_file)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not getattr(server, "started", False):
|
|
||||||
print(
|
print(
|
||||||
f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
|
f"启动失败:{exc}\n"
|
||||||
|
f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 3
|
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("服务器已停止。")
|
print("服务器已停止。")
|
||||||
return 0
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+6
-16
@@ -1,11 +1,11 @@
|
|||||||
"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。
|
"""统一的动作表:HTTP、命令行、机器人都走这一份映射。
|
||||||
|
|
||||||
为什么要有这一层
|
为什么要有这一层
|
||||||
----------------
|
----------------
|
||||||
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
|
引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、
|
||||||
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
|
脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍
|
||||||
``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现
|
``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]:
|
def _pause(engine: FarmEngine, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
value = params.get("paused")
|
value = params.get("paused")
|
||||||
return engine.pause(value if isinstance(value, bool) else None)
|
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("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot()))
|
||||||
|
|
||||||
_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep()))
|
|
||||||
|
|
||||||
_register(
|
_register(
|
||||||
Action(
|
Action(
|
||||||
"pause",
|
"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]:
|
def action_names() -> list[str]:
|
||||||
return sorted(ACTIONS)
|
return sorted(ACTIONS)
|
||||||
|
|
||||||
|
|||||||
+332
-330
@@ -1,4 +1,4 @@
|
|||||||
"""FastAPI 应用:REST + WebSocket + 网页客户端。
|
"""FastAPI 应用:REST + SSE + 网页客户端。
|
||||||
|
|
||||||
接口设计原则
|
接口设计原则
|
||||||
------------
|
------------
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
|
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
|
||||||
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
|
||||||
每个牧场有自己的引擎、时钟与存档;互不影响。
|
每个牧场有自己的引擎、时钟与存档;互不影响。
|
||||||
4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是
|
4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
|
||||||
同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
|
||||||
|
两者改的是同一个引擎,所以「网页里点一下,AI 那边立刻看到」。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,13 +23,23 @@ from collections.abc import AsyncIterator, Callable
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..engine import GameError
|
from ..engine import GameError
|
||||||
from . import actions as actions_mod
|
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 .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id
|
||||||
from .webui import DASHBOARD_HTML, dashboard_text
|
from .webui import DASHBOARD_HTML, dashboard_text
|
||||||
|
|
||||||
@@ -56,8 +67,8 @@ class ServerConfig:
|
|||||||
class ServerHandle:
|
class ServerHandle:
|
||||||
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
|
"""让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。
|
||||||
|
|
||||||
* ``on_start``:监听端口成功后触发(用于写 pid 文件等);
|
* ``on_start``:ASGI 生命周期启动时触发(用于写 pid 文件等);
|
||||||
* ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。
|
* ``on_stop``:请求关闭时触发(uvicorn 用它把 ``should_exit`` 置位,开始优雅退出)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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]:
|
def brief(session: FarmSession, hub: ConnectionHub) -> dict[str, Any]:
|
||||||
"""操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。"""
|
"""操作后回给调用者的精简状态。
|
||||||
snap = session.snapshot()
|
|
||||||
return {
|
只读引擎直接给出的那十几个标量,**不做完整快照**。以前这里是
|
||||||
"farm": session.farm_id,
|
``session.snapshot()`` 之后再挑字段——每块地、每只牲畜都白白序列化一遍,
|
||||||
"day": snap["day"],
|
实测每次动作浪费约 60µs;而 ``full=1`` 时后面还要再序列化一次给快照用。
|
||||||
"gold": snap["gold"],
|
"""
|
||||||
"level": snap["level"],
|
source = session.engine.brief_source()
|
||||||
"exp": snap["exp"],
|
data: dict[str, Any] = {"farm": session.farm_id}
|
||||||
"exp_needed": snap["exp_needed"],
|
data.update({key: source[key] for key in BRIEF_KEYS})
|
||||||
"ap": snap["ap"],
|
data.update({key: source[key] for key in BRIEF_TODO_KEYS})
|
||||||
"max_ap": snap["max_ap"],
|
data["online"] = hub.online(session.farm_id)
|
||||||
"feed": snap["feed"],
|
return data
|
||||||
"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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def rules_text() -> str:
|
def rules_text() -> str:
|
||||||
return (
|
return (
|
||||||
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
|
f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n"
|
||||||
"==========================================\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"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n"
|
||||||
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天"
|
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——"
|
||||||
'(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n'
|
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
|
||||||
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
|
f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。"
|
||||||
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
|
f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n"
|
||||||
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
|
f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n"
|
||||||
@@ -142,15 +166,44 @@ def rules_text() -> str:
|
|||||||
' POST /api/actions → {"action":"<上一步里的 action>",'
|
' POST /api/actions → {"action":"<上一步里的 action>",'
|
||||||
'"params":{...}} 直接执行\n'
|
'"params":{...}} 直接执行\n'
|
||||||
" 响应里的 state 就是执行后的最新状态,无需再查一次。\n"
|
" 响应里的 state 就是执行后的最新状态,无需再查一次。\n"
|
||||||
"\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;"
|
"\n实时通道(单向推送,服务器 → 客户端):GET /api/stream?farm=me 是一条 SSE "
|
||||||
'也可以直接在 WS 上发送 {"type":"action","action":"plant",'
|
'长连接,服务器在状态变化时推 {"type":"state",...};\n'
|
||||||
'"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n'
|
"想主动拉一次用 GET /api/state;写操作一律 POST /api/actions。\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- 应用
|
def as_asgi(app: FastAPI) -> Any:
|
||||||
def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
"""把应用交给 ASGI 服务器时的类型桥。
|
||||||
cfg = cfg or ServerConfig()
|
|
||||||
|
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(
|
manager = SessionManager(
|
||||||
save_dir=cfg.save_dir,
|
save_dir=cfg.save_dir,
|
||||||
tick_seconds=cfg.tick_seconds,
|
tick_seconds=cfg.tick_seconds,
|
||||||
@@ -158,78 +211,66 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
idle_timeout=cfg.idle_timeout,
|
idle_timeout=cfg.idle_timeout,
|
||||||
)
|
)
|
||||||
hub = ConnectionHub()
|
hub = ConnectionHub()
|
||||||
publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval)
|
return cls(
|
||||||
clock = FarmClock(manager)
|
cfg=cfg,
|
||||||
handle = ServerHandle()
|
manager=manager,
|
||||||
started_at = time.time()
|
hub=hub,
|
||||||
counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0}
|
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
|
@contextlib.asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(self, _app: FastAPI) -> AsyncIterator[None]:
|
||||||
clock.start()
|
"""应用生命周期:起时钟与推送线程,退出时按相反顺序收干净。
|
||||||
publisher.start()
|
|
||||||
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
|
装饰成 ``asynccontextmanager`` 是因为 FastAPI 要的就是"能当异步上下文
|
||||||
handle.notify_start()
|
管理器用"的东西,裸 async generator 在类型上并不匹配。
|
||||||
|
"""
|
||||||
|
self.clock.start()
|
||||||
|
self.publisher.start()
|
||||||
|
# 生命周期启动完成,可以对外宣告"我在了"。uvicorn 的顺序是"先跑 lifespan、
|
||||||
|
# 后 bind 端口",所以这里代表"服务已上线",不代表"端口已监听";
|
||||||
|
# bind 失败时 pid 文件由 __main__ 的 finally 负责清掉。
|
||||||
|
self.handle.notify_start()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
publisher.stop()
|
self.publisher.stop()
|
||||||
clock.stop()
|
# 兜底:万一是信号触发退出(没走 /api/admin/shutdown),也把长连接收掉
|
||||||
manager.shutdown()
|
self.hub.close_all()
|
||||||
|
self.clock.stop()
|
||||||
app = FastAPI(
|
self.manager.shutdown()
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ 鉴权与取牧场
|
# ------------------------------------------------------------ 鉴权与取牧场
|
||||||
def authorized(supplied: str | None) -> bool:
|
def authorized(self, supplied: str | None) -> bool:
|
||||||
if not cfg.token:
|
# 没配 token 就是谁都能进(本机自娱自乐的默认形态)
|
||||||
return True
|
return not self.cfg.token or supplied == self.cfg.token
|
||||||
return supplied == cfg.token
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def token_of(request: Request) -> str | None:
|
def token_of(request: Request) -> str | None:
|
||||||
return request.headers.get("x-api-token") or request.query_params.get("token")
|
return request.headers.get("x-api-token") or request.query_params.get("token")
|
||||||
|
|
||||||
def guard(request: Request) -> None:
|
def guard(self, request: Request) -> None:
|
||||||
counters["requests"] += 1
|
"""计数 + 鉴权。所有对外路由的第一句都调它。"""
|
||||||
if not authorized(token_of(request)):
|
self.counters["requests"] += 1
|
||||||
|
if not self.authorized(self.token_of(request)):
|
||||||
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
|
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
|
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:
|
def full_requested(request: Request) -> bool:
|
||||||
raw = (request.query_params.get("full") or "").lower()
|
raw = (request.query_params.get("full") or "").lower()
|
||||||
return raw in ("1", "true", "yes", "on")
|
return raw in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
async def body_of(request: Request) -> dict[str, Any]:
|
async def body_of(request: Request) -> dict[str, Any]:
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -242,12 +283,14 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
raise GameError("bad_json", "请求体必须是 JSON 对象")
|
raise GameError("bad_json", "请求体必须是 JSON 对象")
|
||||||
return data
|
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`` 必须由**牧场会话**处理而不是引擎:
|
``save`` 与 ``reset`` 是**存档类动作**,刻意不在 farm.server.actions
|
||||||
``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。
|
的通用动作表里(那张表只认识引擎方法,而 ``engine.save()`` 只会写到
|
||||||
这两个动作单独拦截,其余全部走统一的动作表。
|
默认存档路径,多牧场环境下会把别人的存档覆盖掉)。它们只能走这里、
|
||||||
|
由牧场会话处理;其余全部走统一的动作表。
|
||||||
"""
|
"""
|
||||||
key = name.strip().lower() if isinstance(name, str) else ""
|
key = name.strip().lower() if isinstance(name, str) else ""
|
||||||
if key == "save":
|
if key == "save":
|
||||||
@@ -264,48 +307,70 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
return actions_mod.run_action(session.engine, name, params)
|
return actions_mod.run_action(session.engine, name, params)
|
||||||
|
|
||||||
def run_and_wrap(
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""执行动作并拼装统一响应。HTTP 与 WS 共用。"""
|
"""执行动作并拼装统一响应。HTTP 各入口共用。"""
|
||||||
result = execute_action(session, name, params)
|
result = self.execute_action(session, name, params)
|
||||||
counters["actions"] += 1
|
self.counters["actions"] += 1
|
||||||
session.touch(count_action=True)
|
session.touch(count_action=True)
|
||||||
payload: dict[str, Any] = dict(result)
|
payload: dict[str, Any] = dict(result)
|
||||||
payload["ok"] = True
|
payload["ok"] = True
|
||||||
payload["farm"] = session.farm_id
|
payload["farm"] = session.farm_id
|
||||||
payload["seq"] = next_seq()
|
payload["seq"] = next_seq()
|
||||||
payload["state"] = brief(session, hub)
|
payload["state"] = brief(session, self.hub)
|
||||||
payload["log"] = session.engine.recent_log(limit=3)
|
payload["log"] = session.engine.recent_log(limit=3)
|
||||||
if full:
|
if full:
|
||||||
payload["snapshot"] = session.snapshot()
|
payload["snapshot"] = session.snapshot()
|
||||||
return payload
|
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)
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def index() -> HTMLResponse:
|
async def index() -> HTMLResponse:
|
||||||
return HTMLResponse(DASHBOARD_HTML)
|
return HTMLResponse(DASHBOARD_HTML)
|
||||||
|
|
||||||
@app.get("/text", response_class=PlainTextResponse, include_in_schema=False)
|
@app.get("/text", response_class=PlainTextResponse, include_in_schema=False)
|
||||||
async def text_index() -> PlainTextResponse:
|
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="健康检查(无需鉴权)")
|
@app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
|
# 刻意不鉴权:探针要能在没有 token 的情况下判断"这个服务还活着吗"
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"service": SERVER_NAME,
|
"service": SERVER_NAME,
|
||||||
"version": API_VERSION,
|
"version": API_VERSION,
|
||||||
"uptime": round(time.time() - started_at, 1),
|
"uptime": round(time.time() - svc.started_at, 1),
|
||||||
"farms": manager.count(),
|
"farms": svc.manager.count(),
|
||||||
"clients": hub.total(),
|
"clients": svc.hub.total(),
|
||||||
"clock_ticks": clock.ticks,
|
"clock_ticks": svc.clock.ticks,
|
||||||
"saves_since_start": publisher.pushes,
|
"saves_since_start": svc.publisher.pushes,
|
||||||
|
"publish_errors": svc.publisher.errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
|
@app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)")
|
||||||
async def help_page(request: Request) -> dict[str, Any]:
|
async def help_page(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"service": SERVER_NAME,
|
"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"}}'
|
'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}'
|
||||||
),
|
),
|
||||||
"farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}',
|
"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="世界概览:排行榜 + 在线连接 + 时钟")
|
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
|
||||||
async def world(request: Request) -> dict[str, Any]:
|
async def world(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"world": cfg.world_name,
|
"world": svc.cfg.world_name,
|
||||||
"uptime": round(time.time() - started_at, 1),
|
"uptime": round(time.time() - svc.started_at, 1),
|
||||||
"farms": manager.count(),
|
"farms": svc.manager.count(),
|
||||||
"clients": hub.total(),
|
"clients": svc.hub.total(),
|
||||||
"connections": hub.snapshot(),
|
"connections": svc.hub.snapshot(),
|
||||||
"leaderboard": manager.leaderboard(hub.online_map()),
|
"leaderboard": svc.manager.leaderboard(svc.hub.online_map()),
|
||||||
"tick_seconds": cfg.tick_seconds,
|
"tick_seconds": svc.cfg.tick_seconds,
|
||||||
"started_at": started_at,
|
"started_at": svc.started_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ================================================================ 牧场
|
|
||||||
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
|
@app.get("/api/farms", tags=["世界"], summary="牧场排行榜")
|
||||||
async def list_farms(request: Request) -> dict[str, Any]:
|
async def list_farms(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
rows = manager.leaderboard(hub.online_map())
|
rows = svc.manager.leaderboard(svc.hub.online_map())
|
||||||
return {"ok": True, "count": len(rows), "farms": rows}
|
return {"ok": True, "count": len(rows), "farms": rows}
|
||||||
|
|
||||||
@app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场")
|
@app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场")
|
||||||
async def create_farm(request: Request) -> dict[str, Any]:
|
async def create_farm(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
body = await body_of(request)
|
body = await svc.body_of(request)
|
||||||
farm = validate_farm_id(body.get("farm") or body.get("name"))
|
farm = validate_farm_id(body.get("farm") or body.get("name"))
|
||||||
owner = str(body.get("owner") or "")
|
owner = str(body.get("owner") or "")
|
||||||
existed = manager.find(farm) is not None
|
existed = svc.manager.find(farm) is not None
|
||||||
session = manager.get(farm, create=True, owner=owner)
|
session = svc.manager.get(farm, create=True, owner=owner)
|
||||||
tick = body.get("tick")
|
tick = body.get("tick")
|
||||||
if tick is not None:
|
if tick is not None:
|
||||||
try:
|
try:
|
||||||
tick_value = float(tick)
|
tick_value = float(tick)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise GameError("bad_param", "tick 必须是数字") from None
|
raise GameError("bad_param", "tick 必须是数字") from None
|
||||||
|
# 直接赋值绕过了引擎构造时的夹取,所以这里得自己夹一次
|
||||||
session.engine.tick_seconds = max(
|
session.engine.tick_seconds = max(
|
||||||
config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value)
|
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,
|
"created": not existed,
|
||||||
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
|
"message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}",
|
||||||
"farm": farm,
|
"farm": farm,
|
||||||
"summary": session.summary(hub.online(farm)),
|
"summary": session.summary(svc.hub.online(farm)),
|
||||||
"state": brief(session, hub),
|
"state": brief(session, svc.hub),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ================================================================ 单牧场只读
|
|
||||||
|
def _register_farm_routes(app: FastAPI, svc: _Services) -> None:
|
||||||
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
|
@app.get("/api/state", tags=["牧场"], summary="完整状态快照")
|
||||||
async def get_state(request: Request) -> dict[str, Any]:
|
async def get_state(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"farm": session.farm_id,
|
"farm": session.farm_id,
|
||||||
"state": session.snapshot(),
|
"state": session.snapshot(),
|
||||||
"online": hub.online(session.farm_id),
|
"online": svc.hub.online(session.farm_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
|
@app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格")
|
||||||
async def get_catalog(request: Request) -> dict[str, Any]:
|
async def get_catalog(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()}
|
return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()}
|
||||||
|
|
||||||
@app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)")
|
@app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)")
|
||||||
async def get_actions(request: Request) -> dict[str, Any]:
|
async def get_actions(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
items = session.engine.available_actions()
|
items = session.engine.available_actions()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"farm": session.farm_id,
|
"farm": session.farm_id,
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
"actions": items,
|
"actions": items,
|
||||||
"state": brief(session, hub),
|
"state": brief(session, svc.hub),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/log", tags=["牧场"], summary="最近事件")
|
@app.get("/api/log", tags=["牧场"], summary="最近事件")
|
||||||
async def get_log(request: Request) -> dict[str, Any]:
|
async def get_log(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
limit = _int(request.query_params.get("limit"), 20, 1, 200)
|
limit = _int(request.query_params.get("limit"), 20, 1, 200)
|
||||||
since = _int(request.query_params.get("since"), 0, 0, 10**9)
|
since = _int(request.query_params.get("since"), 0, 0, 10**9)
|
||||||
entries = session.engine.recent_log(limit=limit, since=since)
|
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,
|
"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 使用)")
|
@app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)")
|
||||||
async def post_action(request: Request) -> dict[str, Any]:
|
async def post_action(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
body = await body_of(request)
|
body = await svc.body_of(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
name = body.get("action") or body.get("name")
|
name = body.get("action") or body.get("name")
|
||||||
params = body.get("params")
|
params = body.get("params")
|
||||||
if params is None:
|
if params is None:
|
||||||
# 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"}
|
# 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"}
|
||||||
params = {k: v for k, v in body.items() if k not in ("action", "name", "params")}
|
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="批量执行动作(一个请求干多件事)")
|
@app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)")
|
||||||
async def post_batch(request: Request) -> dict[str, Any]:
|
async def post_batch(request: Request) -> dict[str, Any]:
|
||||||
@@ -441,9 +510,9 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
|
|
||||||
每个动作独立成败,失败的只记错误不中断后续动作。
|
每个动作独立成败,失败的只记错误不中断后续动作。
|
||||||
"""
|
"""
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
body = await body_of(request)
|
body = await svc.body_of(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
items = body.get("actions")
|
items = body.get("actions")
|
||||||
if not isinstance(items, list) or not items:
|
if not isinstance(items, list) or not items:
|
||||||
raise GameError("bad_param", "actions 必须是至少含一个元素的数组")
|
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": "每个动作必须是对象"})
|
results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
payload = run_and_wrap(
|
payload = svc.run_and_wrap(
|
||||||
session, item.get("action"), item.get("params") or {}, full=False
|
session, item.get("action"), item.get("params") or {}, full=False
|
||||||
)
|
)
|
||||||
|
# 逐个动作不必重复回精简状态:末尾统一给一份就够了
|
||||||
results.append({k: v for k, v in payload.items() if k != "state"})
|
results.append({k: v for k, v in payload.items() if k != "state"})
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
counters["errors"] += 1
|
svc.counters["errors"] += 1
|
||||||
results.append({**exc.to_dict(), "action": item.get("action")})
|
results.append({**exc.to_dict(), "action": item.get("action")})
|
||||||
ok_count = sum(1 for r in results if r.get("ok"))
|
ok_count = sum(1 for r in results if r.get("ok"))
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -469,22 +539,24 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
"done": ok_count,
|
"done": ok_count,
|
||||||
"failed": len(results) - ok_count,
|
"failed": len(results) - ok_count,
|
||||||
"results": results,
|
"results": results,
|
||||||
"state": brief(session, hub),
|
"state": brief(session, svc.hub),
|
||||||
}
|
}
|
||||||
if full_requested(request):
|
if svc.full_requested(request):
|
||||||
payload["snapshot"] = session.snapshot()
|
payload["snapshot"] = session.snapshot()
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def make_action_route(action_name: str) -> Callable[[Request], Any]:
|
def make_action_route(action_name: str) -> Callable[[Request], Any]:
|
||||||
async def route(request: Request) -> dict[str, Any]:
|
async def route(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
body = await body_of(request)
|
body = await svc.body_of(request)
|
||||||
session = session_of(request)
|
session = svc.session_of(request)
|
||||||
return run_and_wrap(session, action_name, body, full_requested(request))
|
return svc.run_and_wrap(session, action_name, body, svc.full_requested(request))
|
||||||
|
|
||||||
route.__name__ = f"post_{action_name}"
|
route.__name__ = f"post_{action_name}"
|
||||||
return route
|
return route
|
||||||
|
|
||||||
|
# 每个动作都额外挂一条自己的路径(POST /api/water 之类),方便直接 curl;
|
||||||
|
# 语义与 POST /api/actions 完全等价。
|
||||||
for _name, _spec in actions_mod.ACTIONS.items():
|
for _name, _spec in actions_mod.ACTIONS.items():
|
||||||
app.add_api_route(
|
app.add_api_route(
|
||||||
f"/api/{_name}",
|
f"/api/{_name}",
|
||||||
@@ -495,12 +567,13 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
summary=f"{_spec.summary}(等价于 POST /api/actions)",
|
summary=f"{_spec.summary}(等价于 POST /api/actions)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ================================================================ 管理
|
|
||||||
|
def _register_admin_routes(app: FastAPI, svc: _Services) -> None:
|
||||||
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
|
@app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档")
|
||||||
async def admin_save(request: Request) -> dict[str, Any]:
|
async def admin_save(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
saved: list[str] = []
|
saved: list[str] = []
|
||||||
for session in manager.all():
|
for session in svc.manager.all():
|
||||||
try:
|
try:
|
||||||
session.save()
|
session.save()
|
||||||
saved.append(session.farm_id)
|
saved.append(session.farm_id)
|
||||||
@@ -512,46 +585,48 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)")
|
@app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)")
|
||||||
async def admin_shutdown(request: Request) -> dict[str, Any]:
|
async def admin_shutdown(request: Request) -> dict[str, Any]:
|
||||||
guard(request)
|
svc.guard(request)
|
||||||
if not cfg.allow_remote_control:
|
if not svc.cfg.allow_remote_control:
|
||||||
raise GameError("forbidden", "服务器未开放远程停机", status=403)
|
raise GameError("forbidden", "服务器未开放远程停机", status=403)
|
||||||
manager.shutdown()
|
svc.manager.shutdown()
|
||||||
accepted = handle.request_stop()
|
# 先让所有 SSE 长连接收尾:否则服务器的优雅停机要一直等它们,变成卡死
|
||||||
|
svc.hub.close_all()
|
||||||
|
accepted = svc.handle.request_stop()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)",
|
"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:
|
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)
|
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
|
||||||
except GameError as exc:
|
# 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
|
||||||
await websocket.close(code=4400, reason=exc.message[:120])
|
session = svc.manager.get(farm, create=True)
|
||||||
return
|
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
client = Client(farm, loop, name=(params.get("name") or "")[:32])
|
client = Client(farm, loop, name=(params.get("name") or "")[:32])
|
||||||
try:
|
svc.hub.register(client)
|
||||||
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)
|
|
||||||
session.touch()
|
session.touch()
|
||||||
|
|
||||||
await websocket.send_json(
|
# "来了/走了"是同一件事的两个方向,差别只有动词,所以共用一个信封
|
||||||
|
who = client.name or client.cid
|
||||||
|
|
||||||
|
def notice(text: str, exclude: Client | None = None) -> None:
|
||||||
|
svc.hub.broadcast(farm, {"type": "notice", "farm": farm, "text": text}, exclude=exclude)
|
||||||
|
|
||||||
|
async def events() -> AsyncIterator[str]:
|
||||||
|
# 首帧等价于原来的 welcome:一次把状态、目录、合法动作、日志给全
|
||||||
|
yield sse_frame(
|
||||||
{
|
{
|
||||||
"type": "welcome",
|
"type": "welcome",
|
||||||
"client_id": client.cid,
|
"client_id": client.cid,
|
||||||
@@ -559,163 +634,90 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
|
|||||||
"server": {
|
"server": {
|
||||||
"name": SERVER_NAME,
|
"name": SERVER_NAME,
|
||||||
"version": API_VERSION,
|
"version": API_VERSION,
|
||||||
"world": cfg.world_name,
|
"world": svc.cfg.world_name,
|
||||||
"tick_seconds": session.engine.tick_seconds,
|
"tick_seconds": session.engine.tick_seconds,
|
||||||
},
|
},
|
||||||
"state": session.snapshot(),
|
"state": session.snapshot(),
|
||||||
"catalog": session.engine.catalog(),
|
"catalog": session.engine.catalog(),
|
||||||
"actions": session.engine.available_actions(),
|
"actions": session.engine.available_actions(),
|
||||||
"log": session.engine.recent_log(limit=12),
|
"log": session.engine.recent_log(limit=12),
|
||||||
"online": hub.online(farm),
|
"online": svc.hub.online(farm),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
hub.broadcast(
|
notice(
|
||||||
farm,
|
f"{who} 来到了牧场(在线 {svc.hub.online(farm)})",
|
||||||
{
|
|
||||||
"type": "notice",
|
|
||||||
"farm": farm,
|
|
||||||
"text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})",
|
|
||||||
},
|
|
||||||
exclude=client,
|
exclude=client,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
async def writer() -> None:
|
last_beat = time.time()
|
||||||
while True:
|
while True:
|
||||||
message = await client.next_message()
|
if svc.handle.stopping:
|
||||||
await websocket.send_json(message)
|
# 服务器要退了:主动收尾,别让长连接拖住优雅停机
|
||||||
|
yield sse_frame(dict(CLOSE_MESSAGE))
|
||||||
async def reader() -> None:
|
break
|
||||||
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:
|
try:
|
||||||
data = json.loads(raw)
|
message = await asyncio.wait_for(client.next_message(), timeout=IDLE_TICK)
|
||||||
except json.JSONDecodeError as exc:
|
except asyncio.TimeoutError:
|
||||||
await websocket.send_json(
|
# 空转一秒只是为了让上面的停机检查有机会跑;
|
||||||
{"type": "error", "error": "bad_json", "message": f"不是合法 JSON:{exc}"}
|
# 心跳按自己的间隔发,别刷屏
|
||||||
)
|
if time.time() - last_beat >= HEARTBEAT_SECONDS:
|
||||||
return
|
last_beat = time.time()
|
||||||
if not isinstance(data, dict):
|
yield sse_heartbeat()
|
||||||
await websocket.send_json(
|
continue
|
||||||
{"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"}
|
if message.get("type") == "bye":
|
||||||
)
|
yield sse_frame(message)
|
||||||
return
|
break
|
||||||
|
yield sse_frame(message)
|
||||||
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(
|
|
||||||
{
|
|
||||||
"type": "error",
|
|
||||||
"error": "unknown_message",
|
|
||||||
"message": f"不认识的消息类型 {kind!r}",
|
|
||||||
"hint": "可用:action / ping / sync / help",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
finally:
|
||||||
for task in (writer_task, reader_task):
|
svc.hub.unregister(client)
|
||||||
task.cancel()
|
|
||||||
hub.unregister(client)
|
|
||||||
session.touch()
|
session.touch()
|
||||||
hub.broadcast(
|
notice(f"{who} 离开了牧场(在线 {svc.hub.online(farm)})")
|
||||||
session.farm_id,
|
|
||||||
{
|
return StreamingResponse(
|
||||||
"type": "notice",
|
events(),
|
||||||
"farm": session.farm_id,
|
media_type="text/event-stream",
|
||||||
"text": (
|
headers={
|
||||||
f"{client.name or client.cid} 离开了牧场"
|
"Cache-Control": "no-cache",
|
||||||
f"(在线 {hub.online(session.farm_id)})"
|
"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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-22
@@ -19,6 +19,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -66,6 +67,27 @@ def pid_alive(pid: int) -> bool:
|
|||||||
return True
|
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:
|
class DaemonManager:
|
||||||
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
|
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
|
||||||
|
|
||||||
@@ -118,39 +140,35 @@ class DaemonManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------ 探测
|
# ------------------------------------------------------------ 探测
|
||||||
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
|
def _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None:
|
||||||
"""访问 /api/health,成功说明服务真的在监听。
|
"""访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。
|
||||||
|
|
||||||
用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
|
一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
|
||||||
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
|
否则在设了 ``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:
|
末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组,
|
||||||
url = f"http://{self.host}:{self.port}/api/world"
|
调用方的 ``.get(...)`` 会直接崩在属性访问上。
|
||||||
if self.token:
|
"""
|
||||||
|
url = f"http://{self.host}:{self.port}{path}"
|
||||||
|
if token and self.token:
|
||||||
url += "?token=" + urllib.parse.quote(self.token)
|
url += "?token=" + urllib.parse.quote(self.token)
|
||||||
try:
|
try:
|
||||||
with net.open_url(url, timeout=timeout) as response:
|
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):
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
return None
|
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:
|
def port_busy(self) -> bool:
|
||||||
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
|
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
|
||||||
import socket
|
return port_busy(self.host, self.port)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ 启动
|
# ------------------------------------------------------------ 启动
|
||||||
def command(self) -> list[str]:
|
def command(self) -> list[str]:
|
||||||
|
|||||||
+71
-28
@@ -1,20 +1,23 @@
|
|||||||
"""实时通道:WebSocket 连接池 + 状态推送线程。
|
"""实时通道:SSE 订阅池 + 状态推送线程。
|
||||||
|
|
||||||
为什么选 WebSocket 而不是 SSE
|
为什么是 SSE
|
||||||
-----------------------------
|
------------
|
||||||
这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门),
|
这个游戏的下行(作物成熟、别人来串门)需要服务器**主动**推给客户端,
|
||||||
客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个
|
而上行(播种、喂食)本来就是普通的 HTTP 写请求。SSE(Server-Sent Events)
|
||||||
HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。
|
正好是"一条 HTTP 长连接单向推流",上行复用 REST,两边各用最自然的协议:
|
||||||
WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。
|
|
||||||
|
* 纯 HTTP 语义:`curl`、浏览器 `EventSource`、任何 HTTP 客户端都能读;
|
||||||
|
* 不用额外协议栈:客户端只需 urllib 流式读,连第三方库都不用装;
|
||||||
|
* 天然支持重连(浏览器 EventSource 自动重连,本项目的客户端自己重连)。
|
||||||
|
|
||||||
线程模型(重点)
|
线程模型(重点)
|
||||||
----------------
|
----------------
|
||||||
uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。
|
uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。
|
||||||
两者之间不能直接调用对方的 API,所以:
|
两者之间不能直接调用对方的 API,所以:
|
||||||
|
|
||||||
* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
|
* 每条订阅持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**;
|
||||||
* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列;
|
* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列;
|
||||||
* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。
|
* 每条订阅有一个生成器协程,只负责「从队列取出 → yield 成 SSE 帧」。
|
||||||
|
|
||||||
这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。
|
这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。
|
||||||
"""
|
"""
|
||||||
@@ -24,13 +27,19 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import itertools
|
import itertools
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次
|
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)
|
_seq_counter = itertools.count(1)
|
||||||
|
|
||||||
@@ -44,8 +53,22 @@ def next_seq() -> int:
|
|||||||
return next(_seq_counter)
|
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:
|
class Client:
|
||||||
"""一条 WebSocket 连接。"""
|
"""一条 SSE 订阅(一个浏览器标签页 / 一个 TUI / 一个脚本)。"""
|
||||||
|
|
||||||
_ids = itertools.count(1)
|
_ids = itertools.count(1)
|
||||||
|
|
||||||
@@ -74,7 +97,7 @@ class Client:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ 线程安全投递
|
# ------------------------------------------------------------ 线程安全投递
|
||||||
def push(self, message: dict[str, Any]) -> None:
|
def push(self, message: dict[str, Any]) -> None:
|
||||||
"""从任意线程把消息排队到该连接的写协程。"""
|
"""从任意线程把消息排队到该订阅的生成器协程。"""
|
||||||
if not self.alive:
|
if not self.alive:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -110,7 +133,7 @@ class Client:
|
|||||||
|
|
||||||
|
|
||||||
class ConnectionHub:
|
class ConnectionHub:
|
||||||
"""按牧场分组的连接登记处。"""
|
"""按牧场分组的 SSE 订阅登记处。"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
@@ -154,21 +177,27 @@ class ConnectionHub:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)]
|
return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)]
|
||||||
|
|
||||||
# ------------------------------------------------------------ 广播
|
# ------------------------------------------------------------ 收尾
|
||||||
def broadcast(
|
def close_all(self) -> int:
|
||||||
self, farm_id: str, message: dict[str, Any], exclude: Client | None = None
|
"""让所有 SSE 流主动收尾,返回受影响的订阅数。
|
||||||
) -> int:
|
|
||||||
sent = 0
|
|
||||||
for client in self.clients(farm_id):
|
|
||||||
if client is exclude:
|
|
||||||
continue
|
|
||||||
client.push(message)
|
|
||||||
sent += 1
|
|
||||||
return sent
|
|
||||||
|
|
||||||
def broadcast_all(self, message: dict[str, Any], exclude: Client | None = None) -> int:
|
停机时必须做这件事:SSE 是**永不自己结束**的长连接,只要还有一条挂着,
|
||||||
|
uvicorn 的优雅停机就要一直等它,`stop` 会卡到超时。发一条 ``bye``
|
||||||
|
让每个流的生成器自己 break,连接随之正常关闭。
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
targets = list(self._all)
|
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
|
sent = 0
|
||||||
for client in targets:
|
for client in targets:
|
||||||
if client is exclude:
|
if client is exclude:
|
||||||
@@ -177,9 +206,21 @@ class ConnectionHub:
|
|||||||
sent += 1
|
sent += 1
|
||||||
return sent
|
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):
|
class Publisher(threading.Thread):
|
||||||
"""后台推送线程:把「状态变了」这件事节流后送给在线的客户端。
|
"""后台推送线程:把「状态变了」这件事节流后送给在线的订阅者。
|
||||||
|
|
||||||
引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里
|
引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里
|
||||||
按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧,
|
按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧,
|
||||||
@@ -193,6 +234,7 @@ class Publisher(threading.Thread):
|
|||||||
self.autosave_interval = autosave_interval
|
self.autosave_interval = autosave_interval
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self.pushes = 0
|
self.pushes = 0
|
||||||
|
self.errors = 0
|
||||||
self._last_autosave = time.time()
|
self._last_autosave = time.time()
|
||||||
self._started_at = time.time()
|
self._started_at = time.time()
|
||||||
|
|
||||||
@@ -213,8 +255,9 @@ class Publisher(threading.Thread):
|
|||||||
session.dirty = False
|
session.dirty = False
|
||||||
self._push_state(session, now)
|
self._push_state(session, now)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 推送线程绝不能因为单个牧场出错而退出
|
# 推送线程绝不能因为单个牧场出错而退出;
|
||||||
pass
|
# 但也不能完全无声——计数进 self.errors,/api/health 可观测
|
||||||
|
self.errors += 1
|
||||||
|
|
||||||
if now - self._last_autosave >= self.autosave_interval:
|
if now - self._last_autosave >= self.autosave_interval:
|
||||||
self._last_autosave = now
|
self._last_autosave = now
|
||||||
|
|||||||
+25
-21
@@ -23,7 +23,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..engine import FarmEngine, GameError
|
from ..engine import FarmEngine, GameError, tick_decision
|
||||||
from ..storage import load_state, save_state
|
from ..storage import load_state, save_state
|
||||||
|
|
||||||
DEFAULT_FARM = "default"
|
DEFAULT_FARM = "default"
|
||||||
@@ -72,6 +72,10 @@ class FarmSession:
|
|||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
self.engine = FarmEngine(tick_seconds=tick_seconds)
|
self.engine = FarmEngine(tick_seconds=tick_seconds)
|
||||||
|
# 立刻排好第一次推进:否则新建牧场要等时钟线程下一轮(≤ accuracy 秒)
|
||||||
|
# 才有 deadline,这段时间 snapshot 里的倒计时是 None,
|
||||||
|
# 客户端会误以为"已暂停 / 手动模式"。
|
||||||
|
self.engine.next_tick_at = time.time() + self.engine.tick_seconds
|
||||||
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
|
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
self.published_day = 0
|
self.published_day = 0
|
||||||
@@ -153,19 +157,24 @@ class FarmSession:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def summary(self, online: int = 0) -> dict[str, Any]:
|
def summary(self, online: int = 0) -> dict[str, Any]:
|
||||||
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。"""
|
"""给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。
|
||||||
snap = self.engine.snapshot()
|
|
||||||
|
走 :meth:`FarmEngine.brief_source` 而**不是** ``snapshot()``:
|
||||||
|
排行榜会对每个牧场调一次,而这里只用得上十几个标量——为它序列化全量
|
||||||
|
地块与牲畜,成本会随牧场数线性放大(32 个牧场实测 ~1.9ms)。
|
||||||
|
"""
|
||||||
|
brief = self.engine.brief_source()
|
||||||
return {
|
return {
|
||||||
"farm": self.farm_id,
|
"farm": self.farm_id,
|
||||||
"owner": self.owner,
|
"owner": self.owner,
|
||||||
"day": snap["day"],
|
"day": brief["day"],
|
||||||
"gold": snap["gold"],
|
"gold": brief["gold"],
|
||||||
"level": snap["level"],
|
"level": brief["level"],
|
||||||
"net_worth": snap["net_worth"],
|
"net_worth": brief["net_worth"],
|
||||||
"plots": f"{snap['plot_count']}/{snap['max_plots']}",
|
"plots": f"{brief['plot_count']}/{brief['max_plots']}",
|
||||||
"animals": snap["animal_count"],
|
"animals": brief["animal_count"],
|
||||||
"goal_index": snap["goal_index"],
|
"goal_index": brief["goal_index"],
|
||||||
"paused": snap["paused"],
|
"paused": brief["paused"],
|
||||||
"online": online,
|
"online": online,
|
||||||
"actions": self.actions_taken,
|
"actions": self.actions_taken,
|
||||||
"idle_seconds": round(time.time() - self.last_active, 1),
|
"idle_seconds": round(time.time() - self.last_active, 1),
|
||||||
@@ -292,17 +301,12 @@ class FarmClock(threading.Thread):
|
|||||||
for session in self.manager.all():
|
for session in self.manager.all():
|
||||||
engine = session.engine
|
engine = session.engine
|
||||||
try:
|
try:
|
||||||
if engine.paused:
|
# tick 决策与单机 Scheduler 共用同一份纯函数,避免两处逻辑漂移
|
||||||
# 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick
|
advance, engine.next_tick_at = tick_decision(
|
||||||
engine.next_tick_at = now + engine.tick_seconds
|
engine.paused, engine.next_tick_at, engine.tick_seconds, now
|
||||||
continue
|
)
|
||||||
deadline = engine.next_tick_at
|
if advance:
|
||||||
if deadline is None:
|
|
||||||
engine.next_tick_at = now + engine.tick_seconds
|
|
||||||
continue
|
|
||||||
if now >= deadline:
|
|
||||||
session.advance_day("时间流逝")
|
session.advance_day("时间流逝")
|
||||||
engine.next_tick_at = now + engine.tick_seconds
|
|
||||||
self.ticks += 1
|
self.ticks += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
# 单个牧场出错不影响其它牧场的时间
|
# 单个牧场出错不影响其它牧场的时间
|
||||||
|
|||||||
+25
-30
@@ -3,7 +3,7 @@
|
|||||||
为什么要内嵌而不是写一堆静态文件
|
为什么要内嵌而不是写一堆静态文件
|
||||||
--------------------------------
|
--------------------------------
|
||||||
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
|
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
|
||||||
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
|
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 SSE
|
||||||
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
|
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
|
||||||
|
|
||||||
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
|
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
|
||||||
@@ -132,7 +132,6 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|||||||
<button class="primary" data-act="harvest_all">一键收获</button>
|
<button class="primary" data-act="harvest_all">一键收获</button>
|
||||||
<button data-act="buy_plot">买地</button>
|
<button data-act="buy_plot">买地</button>
|
||||||
<button data-act="buy_feed">买饲料x10</button>
|
<button data-act="buy_feed">买饲料x10</button>
|
||||||
<button data-act="sleep">休息一天</button>
|
|
||||||
<button data-act="pause">暂停/继续</button>
|
<button data-act="pause">暂停/继续</button>
|
||||||
<button data-act="save">存档</button>
|
<button data-act="save">存档</button>
|
||||||
<button data-act="reset">重置</button>
|
<button data-act="reset">重置</button>
|
||||||
@@ -159,7 +158,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
|
let es = null, state = null, catalog = null, pollTimer = null;
|
||||||
|
|
||||||
const cropNames = {
|
const cropNames = {
|
||||||
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
|
||||||
@@ -218,40 +217,34 @@ function refreshFromBrief(brief) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncNow() {
|
function syncNow() {
|
||||||
if (ws && ws.readyState === 1) {
|
|
||||||
ws.send(JSON.stringify({ type: "sync" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
api("/api/state?farm=" + encodeURIComponent(farmName()) +
|
api("/api/state?farm=" + encodeURIComponent(farmName()) +
|
||||||
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
|
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
|
||||||
.then((d) => { if (d) { state = d.state; render(); } });
|
.then((d) => { if (d) { state = d.state; render(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 实时通道:SSE 长连接(EventSource 自带断线重连,不用自己写重连逻辑) */
|
||||||
function connect() {
|
function connect() {
|
||||||
if (ws) { try { ws.close(); } catch (e) {} }
|
if (es) { try { es.close(); } catch (e) {} }
|
||||||
clearTimeout(reconnectTimer);
|
clearInterval(pollTimer); pollTimer = null;
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const url = "/api/stream?farm=" + encodeURIComponent(farmName()) +
|
||||||
const url = proto + "//" + location.host + "/ws?farm=" + encodeURIComponent(farmName()) +
|
|
||||||
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
|
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
|
||||||
setStatus("连接中…", "");
|
setStatus("连接中…", "");
|
||||||
ws = new WebSocket(url);
|
es = new EventSource(url);
|
||||||
ws.onopen = () => {
|
es.onopen = () => {
|
||||||
setStatus("已连接(WebSocket)", "on");
|
setStatus("已连接(SSE 实时)", "on");
|
||||||
clearInterval(pollTimer); pollTimer = null;
|
clearInterval(pollTimer); pollTimer = null;
|
||||||
api("/api/farms", { headers: headers() });
|
api("/api/farms", { headers: headers() });
|
||||||
};
|
};
|
||||||
ws.onmessage = (ev) => {
|
es.onmessage = (ev) => {
|
||||||
let msg = null;
|
let msg = null;
|
||||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
handle(msg);
|
handle(msg);
|
||||||
};
|
};
|
||||||
ws.onclose = (ev) => {
|
es.onerror = () => {
|
||||||
if (ev.code === 4401) { setStatus("令牌不正确", "bad"); }
|
/* EventSource 会自己重连;这期间先用轮询顶上,体验不中断 */
|
||||||
else setStatus("已断开,3 秒后重连", "bad");
|
setStatus("推送断开,轮询中…", "bad");
|
||||||
if (!pollTimer) pollTimer = setInterval(syncNow, 1500); // 降级为轮询
|
if (!pollTimer) pollTimer = setInterval(syncNow, 1500);
|
||||||
reconnectTimer = setTimeout(connect, 3000);
|
|
||||||
};
|
};
|
||||||
ws.onerror = () => setStatus("连接异常", "bad");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handle(msg) {
|
function handle(msg) {
|
||||||
@@ -263,11 +256,6 @@ function handle(msg) {
|
|||||||
state = msg.state; if (msg.catalog) catalog = msg.catalog;
|
state = msg.state; if (msg.catalog) catalog = msg.catalog;
|
||||||
renderLog(msg.log);
|
renderLog(msg.log);
|
||||||
render();
|
render();
|
||||||
} else if (msg.type === "result") {
|
|
||||||
toast(msg.message);
|
|
||||||
if (msg.snapshot) { state = msg.snapshot; render(); }
|
|
||||||
else if (msg.state) { refreshFromBrief(msg.state); }
|
|
||||||
renderLog(msg.log);
|
|
||||||
} else if (msg.type === "day") {
|
} else if (msg.type === "day") {
|
||||||
renderLog(null);
|
renderLog(null);
|
||||||
} else if (msg.type === "notice") {
|
} else if (msg.type === "notice") {
|
||||||
@@ -277,6 +265,14 @@ function handle(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 一天 40 分钟,直接显示秒数是一串大数,换算成 m:ss */
|
||||||
|
function fmtLeft(sec) {
|
||||||
|
if (sec === null || sec === undefined) return "已暂停";
|
||||||
|
const t = Math.max(0, Math.round(sec));
|
||||||
|
if (t < 60) return t + "s";
|
||||||
|
return Math.floor(t / 60) + ":" + String(t % 60).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const s = state;
|
const s = state;
|
||||||
@@ -284,7 +280,7 @@ function render() {
|
|||||||
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
|
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
|
||||||
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
|
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
|
||||||
["在线", s.online !== undefined ? s.online : "—"],
|
["在线", s.online !== undefined ? s.online : "—"],
|
||||||
["下一天", s.seconds_to_tick === null ? "已暂停" : s.seconds_to_tick + "s"]
|
["下一天", fmtLeft(s.seconds_to_tick)]
|
||||||
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
|
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
|
||||||
|
|
||||||
/* 农田 */
|
/* 农田 */
|
||||||
@@ -409,7 +405,6 @@ $("farm").value = localStorage.getItem("sf.farm") || "default";
|
|||||||
$("token").value = localStorage.getItem("sf.token") || "";
|
$("token").value = localStorage.getItem("sf.token") || "";
|
||||||
connect();
|
connect();
|
||||||
setInterval(renderBoard, 5000);
|
setInterval(renderBoard, 5000);
|
||||||
setInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -424,7 +419,7 @@ def dashboard_text(host: str, port: int) -> str:
|
|||||||
f"网页客户端:http://{host}:{port}/\n"
|
f"网页客户端:http://{host}:{port}/\n"
|
||||||
f"接口文档 :http://{host}:{port}/docs\n"
|
f"接口文档 :http://{host}:{port}/docs\n"
|
||||||
f"健康检查 :http://{host}:{port}/api/health\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"
|
"\n"
|
||||||
"给 AI / 脚本的最短路径\n"
|
"给 AI / 脚本的最短路径\n"
|
||||||
"----------------------\n"
|
"----------------------\n"
|
||||||
@@ -448,7 +443,7 @@ def dashboard_text(host: str, port: int) -> str:
|
|||||||
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
|
'POST /api/actions {"action":"...","params":{...}} 通用入口\n'
|
||||||
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
|
'POST /api/batch {"actions":[{...},{...}]} 批量\n'
|
||||||
"POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\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/farms {"farm":"名字"} 创建/进入牧场\n'
|
||||||
"POST /api/admin/save 全部存档\n"
|
"POST /api/admin/save 全部存档\n"
|
||||||
"POST /api/admin/shutdown 优雅停机\n"
|
"POST /api/admin/shutdown 优雅停机\n"
|
||||||
|
|||||||
+37
-21
@@ -22,9 +22,10 @@ import time
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import config
|
from . import config, keys
|
||||||
from .engine import GameError
|
from .engine import GameError
|
||||||
from .keys import KeyReader
|
from .keys import KeyReader
|
||||||
|
from .models import format_duration
|
||||||
from .storage import default_save_path
|
from .storage import default_save_path
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 颜色
|
# ---------------------------------------------------------------- 颜色
|
||||||
@@ -181,6 +182,8 @@ class TuiApp:
|
|||||||
self.autosave = autosave
|
self.autosave = autosave
|
||||||
self.server_label = server_label
|
self.server_label = server_label
|
||||||
self.reader = KeyReader()
|
self.reader = KeyReader()
|
||||||
|
# 读不到键盘时是否在界面上提示:预览工具画的是定格画面,会关掉它
|
||||||
|
self.show_keyboard_notice = True
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
|
self.mode = "main" # main / help / select_crop / select_animal / confirm_reset
|
||||||
@@ -256,7 +259,7 @@ class TuiApp:
|
|||||||
|
|
||||||
# ================================================================ 输入
|
# ================================================================ 输入
|
||||||
def _handle_key(self, key: str) -> None:
|
def _handle_key(self, key: str) -> None:
|
||||||
if key in ("CTRL_C",):
|
if key == keys.CTRL_C:
|
||||||
self.running = False
|
self.running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -278,13 +281,15 @@ class TuiApp:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ---- 主模式
|
# ---- 主模式
|
||||||
|
# 键名一律取 farm.keys 的规范化常量:写成 keys.XXX 后 pyright 能直接
|
||||||
|
# 查出拼写错误,不会再出现"两端命名不一致、按键静默失效"这种事。
|
||||||
handler = {
|
handler = {
|
||||||
"q": self._quit,
|
"q": self._quit,
|
||||||
"?": self._open_help,
|
"?": self._open_help,
|
||||||
"up": lambda: self._move_plot(-self.grid_cols),
|
keys.UP: lambda: self._move_plot(-self.grid_cols),
|
||||||
"down": lambda: self._move_plot(self.grid_cols),
|
keys.DOWN: lambda: self._move_plot(self.grid_cols),
|
||||||
"left": lambda: self._move_plot(-1),
|
keys.LEFT: lambda: self._move_plot(-1),
|
||||||
"right": lambda: self._move_plot(1),
|
keys.RIGHT: lambda: self._move_plot(1),
|
||||||
"w": lambda: self._act(self.engine.water, self.plot_cursor + 1),
|
"w": lambda: self._act(self.engine.water, self.plot_cursor + 1),
|
||||||
"h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1),
|
"h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1),
|
||||||
"e": self._harvest_all,
|
"e": self._harvest_all,
|
||||||
@@ -294,12 +299,10 @@ class TuiApp:
|
|||||||
"c": self._collect,
|
"c": self._collect,
|
||||||
"l": lambda: self._act(self.engine.buy_plot),
|
"l": lambda: self._act(self.engine.buy_plot),
|
||||||
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
|
"k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK),
|
||||||
"n": lambda: self._act(self.engine.sleep),
|
|
||||||
"s": self._save,
|
"s": self._save,
|
||||||
"r": self._ask_reset,
|
"r": self._ask_reset,
|
||||||
" ": self._toggle_pause,
|
keys.SPACE: self._toggle_pause,
|
||||||
"tab": self._toggle_focus,
|
keys.TAB: self._toggle_focus,
|
||||||
"tab\t": self._toggle_focus,
|
|
||||||
}
|
}
|
||||||
action = handler.get(key)
|
action = handler.get(key)
|
||||||
if action:
|
if action:
|
||||||
@@ -406,7 +409,7 @@ class TuiApp:
|
|||||||
self.mode = "select_animal"
|
self.mode = "select_animal"
|
||||||
|
|
||||||
def _handle_menu_key(self, key: str) -> None:
|
def _handle_menu_key(self, key: str) -> None:
|
||||||
if key in ("esc", "q"):
|
if key in (keys.ESC, "q"):
|
||||||
self.mode = "main"
|
self.mode = "main"
|
||||||
self._flash("已取消", C_GRAY)
|
self._flash("已取消", C_GRAY)
|
||||||
return
|
return
|
||||||
@@ -516,7 +519,9 @@ class TuiApp:
|
|||||||
right = tag + color("手动模式 ", C_GRAY, self.use_color)
|
right = tag + color("手动模式 ", C_GRAY, self.use_color)
|
||||||
else:
|
else:
|
||||||
right = tag + color(
|
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)
|
gap = max(1, width - display_width(left) - display_width(right) - 1)
|
||||||
@@ -749,13 +754,23 @@ class TuiApp:
|
|||||||
|
|
||||||
log_box = draw_box("农事日志", rows, width, accent=C_PURPLE, use_color=self.use_color)
|
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 = [
|
hint = [
|
||||||
color(keys, C_GRAY, self.use_color),
|
color(" [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获", C_GRAY, self.use_color),
|
||||||
color(keys2, C_GRAY, self.use_color),
|
color(
|
||||||
color(keys3, C_GRAY, self.use_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]
|
return log_box + [pad(h, width) for h in hint]
|
||||||
|
|
||||||
@@ -784,7 +799,8 @@ class TuiApp:
|
|||||||
"",
|
"",
|
||||||
color(" 时间", C_BOLD + C_GOLD, self.use_color)
|
color(" 时间", C_BOLD + C_GOLD, self.use_color)
|
||||||
+ color(
|
+ color(
|
||||||
" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。",
|
f" 每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟自动推进一天,"
|
||||||
|
"作物成长、牲畜产奶、市场价都在这一天结算;只能等,没有快进。",
|
||||||
C_GRAY,
|
C_GRAY,
|
||||||
self.use_color,
|
self.use_color,
|
||||||
),
|
),
|
||||||
@@ -813,14 +829,14 @@ class TuiApp:
|
|||||||
),
|
),
|
||||||
color(" ", C_GRAY, self.use_color)
|
color(" ", C_GRAY, self.use_color)
|
||||||
+ color(
|
+ color(
|
||||||
" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置",
|
" B 买动物 / L 开垦土地 / K 买饲料 / 空格 暂停 / S 存档 / R 重置",
|
||||||
C_GRAY,
|
C_GRAY,
|
||||||
self.use_color,
|
self.use_color,
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
|
color(" AI 接入", C_BOLD + C_GOLD, self.use_color)
|
||||||
+ color(
|
+ color(
|
||||||
" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,",
|
" 游戏跑在服务器进程里,HTTP 接口给 AI、SSE 长连接给实时界面,",
|
||||||
C_GRAY,
|
C_GRAY,
|
||||||
self.use_color,
|
self.use_color,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from farm import config
|
from farm import config
|
||||||
|
from farm.models import format_duration
|
||||||
|
|
||||||
DEFAULT_URL = "http://127.0.0.1:8765"
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""命令行总表:服务器那几个子命令 + 客户端那几个子命令。"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="uv run main.py",
|
prog="uv run main.py",
|
||||||
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP/WebSocket 供 AI 调用)",
|
description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP + SSE 供 AI 调用)",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=BANNER,
|
epilog=BANNER,
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", metavar="<命令>")
|
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 = sub.add_parser("serve", help="启动游戏服务器(可后台运行)")
|
||||||
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
|
p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)")
|
||||||
p_serve.add_argument(
|
p_serve.add_argument(
|
||||||
@@ -127,29 +135,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
|
p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径")
|
||||||
|
|
||||||
p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)")
|
p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)")
|
||||||
p_stop.add_argument("--port", type=int, default=8765)
|
_add_endpoint_args(p_stop)
|
||||||
p_stop.add_argument("--host", default="127.0.0.1")
|
|
||||||
p_stop.add_argument("--token", default=None)
|
|
||||||
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
|
p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程")
|
||||||
|
|
||||||
p_status = sub.add_parser("status", help="查看服务器状态")
|
p_status = sub.add_parser("status", help="查看服务器状态")
|
||||||
p_status.add_argument("--port", type=int, default=8765)
|
_add_endpoint_args(p_status)
|
||||||
p_status.add_argument("--host", default="127.0.0.1")
|
|
||||||
p_status.add_argument("--token", default=None)
|
|
||||||
p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部")
|
p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部")
|
||||||
p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
|
p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
|
||||||
|
|
||||||
p_logs = sub.add_parser("logs", help="查看服务器日志尾部")
|
p_logs = sub.add_parser("logs", help="查看服务器日志尾部")
|
||||||
p_logs.add_argument("-n", "--lines", type=int, default=40)
|
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 = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩")
|
||||||
p_play.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
|
_add_client_args(p_play)
|
||||||
p_play.add_argument("--farm", default="default", help="牧场名,默认 default")
|
|
||||||
p_play.add_argument("--token", default=None)
|
|
||||||
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
|
p_play.add_argument("--name", default="", help="在服务器上显示的昵称")
|
||||||
p_play.add_argument("--no-color", action="store_true")
|
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 = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)")
|
||||||
p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS)
|
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("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限")
|
||||||
p_bot.add_argument("--minutes", type=float, default=0.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("--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="查看服务器上的牧场排行榜")
|
p_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜")
|
||||||
_add_client_args(p_farms)
|
_add_client_args(p_farms)
|
||||||
@@ -195,15 +202,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_help = sub.add_parser("help", help="玩法与接口说明")
|
p_help = sub.add_parser("help", help="玩法与接口说明")
|
||||||
_add_client_args(p_help)
|
_add_client_args(p_help)
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def _add_client_args(parser: argparse.ArgumentParser) -> None:
|
def _add_client_args(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""连服务器要用的三件套:地址、牧场名、令牌。"""
|
||||||
parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
|
parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}")
|
||||||
parser.add_argument("--farm", default="default", help="牧场名,默认 default")
|
parser.add_argument("--farm", default="default", help="牧场名,默认 default")
|
||||||
parser.add_argument("--token", default=None, help="访问令牌")
|
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):
|
def make_client(args, realtime: bool = False):
|
||||||
"""构造远程客户端;连不上时给出可执行的下一步建议。"""
|
"""构造远程客户端;连不上时给出可执行的下一步建议。"""
|
||||||
@@ -304,11 +331,8 @@ def cmd_status(args) -> int:
|
|||||||
world = manager.world()
|
world = manager.world()
|
||||||
if world and world.get("leaderboard"):
|
if world and world.get("leaderboard"):
|
||||||
print(" 排行榜")
|
print(" 排行榜")
|
||||||
for row in world["leaderboard"][:5]:
|
for line in _leaderboard_lines(world["leaderboard"], limit=5):
|
||||||
print(
|
print(" " + line)
|
||||||
f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}"
|
|
||||||
f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.logs:
|
if args.logs:
|
||||||
print(dim(" ---- 日志尾部 ----"))
|
print(dim(" ---- 日志尾部 ----"))
|
||||||
@@ -458,7 +482,8 @@ def cmd_actions(args) -> int:
|
|||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
print(warn("• 当前没有可执行的动作"))
|
print(warn("• 当前没有可执行的动作"))
|
||||||
print(dim(" 试试:uv run main.py do sleep (推进一天)"))
|
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
|
||||||
|
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
|
||||||
return 0
|
return 0
|
||||||
print(f"共 {len(items)} 个候选动作:\n")
|
print(f"共 {len(items)} 个候选动作:\n")
|
||||||
for index, item in enumerate(items, start=1):
|
for index, item in enumerate(items, start=1):
|
||||||
@@ -481,7 +506,7 @@ def cmd_state(args) -> int:
|
|||||||
print(
|
print(
|
||||||
f"第 {snap.get('day')} 天"
|
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
|
if snap.get("seconds_to_tick") is not None
|
||||||
else " · 已暂停"
|
else " · 已暂停"
|
||||||
)
|
)
|
||||||
@@ -555,7 +580,7 @@ def cmd_bot(args) -> int:
|
|||||||
client = make_client(args, realtime=False)
|
client = make_client(args, realtime=False)
|
||||||
bot = RuleBot(client, pause=args.pause, name=args.farm)
|
bot = RuleBot(client, pause=args.pause, name=args.farm)
|
||||||
try:
|
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:
|
except KeyboardInterrupt:
|
||||||
bot.stop()
|
bot.stop()
|
||||||
print("\n已中断。")
|
print("\n已中断。")
|
||||||
@@ -573,12 +598,8 @@ def cmd_farms(args) -> int:
|
|||||||
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
|
f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个"
|
||||||
)
|
)
|
||||||
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
|
print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n")
|
||||||
print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}")
|
for line in _leaderboard_lines(world.get("leaderboard", [])):
|
||||||
for row in world.get("leaderboard", []):
|
print(" " + line)
|
||||||
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}"
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -604,7 +625,6 @@ QUICKSTART = """
|
|||||||
uv run ruff check . # 静态检查(未用导入、可疑写法……)
|
uv run ruff check . # 静态检查(未用导入、可疑写法……)
|
||||||
uv run ruff format . # 统一格式
|
uv run ruff format . # 统一格式
|
||||||
uv run pyright # 类型检查
|
uv run pyright # 类型检查
|
||||||
uv run python -m unittest discover -s tests # 全部测试
|
|
||||||
|
|
||||||
直接调接口(AI / 脚本)
|
直接调接口(AI / 脚本)
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -618,12 +638,12 @@ QUICKSTART = """
|
|||||||
uv run main.py state --json # 机器可读的完整状态
|
uv run main.py state --json # 机器可读的完整状态
|
||||||
uv run main.py watch # 实时看别人/机器人玩
|
uv run main.py watch # 实时看别人/机器人玩
|
||||||
|
|
||||||
WebSocket(实时推送)
|
SSE 实时推送(只读)
|
||||||
---------------------
|
--------------------
|
||||||
ws://127.0.0.1:8765/ws?farm=default
|
curl -N 'http://127.0.0.1:8765/api/stream?farm=default'
|
||||||
服务器在状态变化时推送 {"type":"state", ...};
|
服务器在状态变化时推 {"type":"state", ...},每帧一行 data: JSON;
|
||||||
也可以在 WS 上直接发动作:{"type":"action","action":"plant",
|
没有消息时每 15 秒发一行 ": keep-alive" 心跳。
|
||||||
"params":{"plot":1,"crop":"wheat"},"id":1},会收到 {"type":"result",...} 回执。
|
写操作走 HTTP:POST /api/actions,两条路改的是同一个引擎。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -16,11 +16,11 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "simplefarm"
|
name = "simplefarm"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
description = "终端农场经营游戏 + 小型多人游戏服务器:人玩 TUI,AI 走 HTTP/WebSocket 接口"
|
description = "终端农场经营游戏 + 小型多人游戏服务器:人玩 TUI,AI 走 HTTP + SSE 接口"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
keywords = ["game", "tui", "cli", "fastapi", "websocket", "llm", "agent"]
|
keywords = ["game", "tui", "cli", "fastapi", "sse", "llm", "agent"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Topic :: Games/Entertainment :: Simulation",
|
"Topic :: Games/Entertainment :: Simulation",
|
||||||
@@ -31,16 +31,22 @@ classifiers = [
|
|||||||
# 所以 `uv run main.py solo` 这类离线玩法不需要装下面这两个。
|
# 所以 `uv run main.py solo` 这类离线玩法不需要装下面这两个。
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.110",
|
"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` 可以跳过)。
|
# 开发依赖(`uv sync` 默认就会装,用 `uv sync --no-dev` 可以跳过)。
|
||||||
# httpx 不是可选项:FastAPI 的 TestClient 建立在它之上,测试必需。
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"pyright>=1.1.390",
|
"pyright>=1.1.390",
|
||||||
"httpx>=0.27",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
@@ -53,7 +59,7 @@ package = false
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100 # 贴着现有代码定:只有 18 行超 100,用默认的 88 会重排 122 行
|
line-length = 100 # 贴着现有代码定:只有 18 行超 100,用默认的 88 会重排 122 行
|
||||||
target-version = "py310" # 与 requires-python 对齐
|
target-version = "py310" # 与 requires-python 对齐
|
||||||
src = ["farm", "tests", "tools"]
|
src = ["farm", "tools"]
|
||||||
# 文档里的示例代码是"给人看的排版",不交给格式化器管
|
# 文档里的示例代码是"给人看的排版",不交给格式化器管
|
||||||
extend-exclude = ["README.md"]
|
extend-exclude = ["README.md"]
|
||||||
|
|
||||||
@@ -92,8 +98,6 @@ ignore = [
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# 内嵌的 HTML/JS 模板:整块写在一个字符串里,按 100 列硬拆反而更难读也更难改
|
# 内嵌的 HTML/JS 模板:整块写在一个字符串里,按 100 列硬拆反而更难读也更难改
|
||||||
"farm/server/webui.py" = ["E501"]
|
"farm/server/webui.py" = ["E501"]
|
||||||
# 测试里注释是"解释为什么这么断言",紧跟断言更清楚;含长中文行属正常
|
|
||||||
"tests/*" = ["E501"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["farm"]
|
known-first-party = ["farm"]
|
||||||
@@ -109,8 +113,8 @@ line-ending = "auto" # Windows/Linux 混用时不要强改行尾,避免
|
|||||||
# - basic 模式抓得住 None 误用、参数个数/类型不符、属性不存在这类真 bug;
|
# - basic 模式抓得住 None 误用、参数个数/类型不符、属性不存在这类真 bug;
|
||||||
# - 不去追 Unknown 类型(本项目的快照是 dict[str, Any] 结构,追下去全是噪音)。
|
# - 不去追 Unknown 类型(本项目的快照是 dict[str, Any] 结构,追下去全是噪音)。
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
include = ["farm", "tests", "tools", "main.py", "ai_player.py"]
|
include = ["farm", "tools", "main.py", "ai_player.py"]
|
||||||
exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"]
|
exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run"]
|
||||||
pythonVersion = "3.10"
|
pythonVersion = "3.10"
|
||||||
# 代码里有 termios / msvcrt 这类平台专属分支。只按当前平台分析,
|
# 代码里有 termios / msvcrt 这类平台专属分支。只按当前平台分析,
|
||||||
# 会把另一侧的调用误报成"模块没有这个属性",所以让 pyright 同时考虑所有平台。
|
# 会把另一侧的调用误报成"模块没有这个属性",所以让 pyright 同时考虑所有平台。
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ typing-extensions==4.16.0
|
|||||||
# starlette
|
# starlette
|
||||||
# typing-inspection
|
# typing-inspection
|
||||||
# uvicorn
|
# uvicorn
|
||||||
|
# zttp
|
||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
@@ -54,3 +55,5 @@ websockets==16.1.1 ; python_full_version < '3.11'
|
|||||||
# via uvicorn
|
# via uvicorn
|
||||||
websockets==17.1 ; python_full_version >= '3.11'
|
websockets==17.1 ; python_full_version >= '3.11'
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
|
zttp==0.0.33
|
||||||
|
# via simplefarm
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
"""远程客户端测试:视图语义、地址归一化、SSE 消息分发、以及 HTTP 传输层。
|
||||||
|
|
||||||
|
这里是「TUI 不需要知道自己在跟谁说话」这句承诺的兑现处:
|
||||||
|
``StateView`` 必须把服务器发来的两种东西区分开——**完整快照**(带 plots/animals)
|
||||||
|
要整体替换,**精简状态**(动作回执里那几个标量)只能做字段合并。搞反了的后果
|
||||||
|
很具体:播一次种,整片农田就从界面上消失了。
|
||||||
|
|
||||||
|
除 ``HttpTransportTestCase`` 会起一个纯标准库的回环桩服务外,其余全部离线;
|
||||||
|
桩服务只绑 127.0.0.1,不依赖 fastapi / httpx / uvicorn。
|
||||||
|
|
||||||
|
uv run python -m unittest tests.test_client -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from farm import config, net
|
||||||
|
from farm.client import (
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
LOG_KEEP,
|
||||||
|
AnimalView,
|
||||||
|
PlotView,
|
||||||
|
RemoteBotClient,
|
||||||
|
RemoteFarm,
|
||||||
|
StateView,
|
||||||
|
_underlying_socket,
|
||||||
|
)
|
||||||
|
from farm.engine import GameError
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- 构造假数据
|
||||||
|
def plot_dict(**overrides):
|
||||||
|
"""一份「服务器会发过来的」地块 JSON。"""
|
||||||
|
data = {
|
||||||
|
"index": 0,
|
||||||
|
"position": 1,
|
||||||
|
"crop": "carrot",
|
||||||
|
"crop_name": "胡萝卜",
|
||||||
|
"growth": 1.0,
|
||||||
|
"grow_days": 3,
|
||||||
|
"days_left": 2,
|
||||||
|
"moisture": 3,
|
||||||
|
"is_ripe": False,
|
||||||
|
"is_empty": False,
|
||||||
|
"stage": "生长中",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def animal_dict(**overrides):
|
||||||
|
data = {
|
||||||
|
"id": 7,
|
||||||
|
"kind": "cow",
|
||||||
|
"name": "奶牛",
|
||||||
|
"product": "milk",
|
||||||
|
"fed": True,
|
||||||
|
"pending": 0,
|
||||||
|
"cycle": 3,
|
||||||
|
"progress": 1,
|
||||||
|
"starve": 0,
|
||||||
|
"days_to_product": 2,
|
||||||
|
"is_sick": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def offline_client(**kwargs):
|
||||||
|
"""不连网络的 RemoteFarm(autostart=False 是关键)。"""
|
||||||
|
kwargs.setdefault("autostart", False)
|
||||||
|
kwargs.setdefault("realtime", False)
|
||||||
|
return RemoteFarm(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
class _StubHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""按路径吐预设响应的小桩,够本文件用。**
|
||||||
|
|
||||||
|
``payloads`` 由测试在 ``setUp`` 里填:path -> (status, body_bytes, content_type)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
payloads: ClassVar[dict[str, tuple[int, bytes, str]]] = {}
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
self._reply()
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if length:
|
||||||
|
self.rfile.read(length)
|
||||||
|
self._reply()
|
||||||
|
|
||||||
|
def _reply(self) -> None:
|
||||||
|
path = urllib.parse.urlsplit(self.path).path
|
||||||
|
status, body, content_type = self.payloads.get(
|
||||||
|
path, (404, b'{"ok": false, "error": "not_found"}', "application/json")
|
||||||
|
)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args) -> None:
|
||||||
|
"""别把访问日志刷到测试输出里。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 视图字段映射
|
||||||
|
class PlotViewTestCase(unittest.TestCase):
|
||||||
|
def test_fields_follow_the_server_payload(self):
|
||||||
|
view = PlotView(plot_dict())
|
||||||
|
self.assertEqual(view.index, 0)
|
||||||
|
self.assertEqual(view.position, 1)
|
||||||
|
self.assertEqual(view.crop, "carrot")
|
||||||
|
self.assertEqual(view.crop_name, "胡萝卜")
|
||||||
|
self.assertEqual(view.growth, 1.0)
|
||||||
|
self.assertEqual(view.grow_days, 3)
|
||||||
|
self.assertEqual(view.days_left, 2)
|
||||||
|
self.assertEqual(view.moisture, 3)
|
||||||
|
self.assertFalse(view.is_ripe)
|
||||||
|
self.assertFalse(view.is_empty)
|
||||||
|
self.assertEqual(view.stage, "生长中")
|
||||||
|
|
||||||
|
def test_missing_fields_fall_back_to_defaults(self):
|
||||||
|
view = PlotView({})
|
||||||
|
self.assertEqual(view.index, 0)
|
||||||
|
# position 缺省时由 index 推出(地块编号是给人看的,从 1 起)
|
||||||
|
self.assertEqual(view.position, 1)
|
||||||
|
self.assertIsNone(view.crop)
|
||||||
|
self.assertEqual(view.crop_name, "")
|
||||||
|
self.assertEqual(view.growth, 0.0)
|
||||||
|
self.assertEqual(view.moisture, 0)
|
||||||
|
self.assertEqual(view.stage, "")
|
||||||
|
|
||||||
|
def test_position_defaults_to_index_plus_one(self):
|
||||||
|
self.assertEqual(PlotView({"index": 4}).position, 5)
|
||||||
|
|
||||||
|
def test_empty_defaults_to_crop_is_none(self):
|
||||||
|
self.assertTrue(PlotView({"crop": None}).is_empty)
|
||||||
|
self.assertFalse(PlotView({"crop": "carrot"}).is_empty)
|
||||||
|
|
||||||
|
def test_explicit_is_empty_wins_over_crop(self):
|
||||||
|
self.assertTrue(PlotView({"crop": "carrot", "is_empty": True}).is_empty)
|
||||||
|
|
||||||
|
def test_numeric_strings_are_coerced(self):
|
||||||
|
# JSON 里数字有可能是字符串,视图不该把它漏给渲染层
|
||||||
|
view = PlotView({"growth": "2.5", "moisture": "3", "is_ripe": 1})
|
||||||
|
self.assertEqual(view.growth, 2.5)
|
||||||
|
self.assertEqual(view.moisture, 3)
|
||||||
|
self.assertIs(view.is_ripe, True)
|
||||||
|
|
||||||
|
def test_to_dict_is_a_copy(self):
|
||||||
|
view = PlotView(plot_dict())
|
||||||
|
dumped = view.to_dict()
|
||||||
|
dumped["crop"] = "corn"
|
||||||
|
self.assertEqual(view.crop, "carrot")
|
||||||
|
|
||||||
|
def test_view_is_read_only(self):
|
||||||
|
view = PlotView(plot_dict())
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
view.index = 9 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalViewTestCase(unittest.TestCase):
|
||||||
|
def test_fields_follow_the_server_payload(self):
|
||||||
|
view = AnimalView(animal_dict())
|
||||||
|
self.assertEqual(view.aid, 7)
|
||||||
|
self.assertEqual(view.kind, "cow")
|
||||||
|
self.assertEqual(view.name, "奶牛")
|
||||||
|
self.assertEqual(view.product, "milk")
|
||||||
|
self.assertTrue(view.fed)
|
||||||
|
self.assertEqual(view.pending, 0)
|
||||||
|
self.assertEqual(view.cycle, 3)
|
||||||
|
self.assertEqual(view.progress, 1)
|
||||||
|
self.assertEqual(view.days_to_product, 2)
|
||||||
|
self.assertFalse(view.is_sick)
|
||||||
|
|
||||||
|
def test_id_maps_to_aid(self):
|
||||||
|
# 服务器用 "id",本地模型叫 .aid —— 同构靠的就是这一层改名
|
||||||
|
self.assertEqual(AnimalView({"id": 12}).aid, 12)
|
||||||
|
|
||||||
|
def test_missing_fields_fall_back_to_defaults(self):
|
||||||
|
view = AnimalView({})
|
||||||
|
self.assertEqual(view.aid, 0)
|
||||||
|
self.assertEqual(view.kind, "")
|
||||||
|
self.assertEqual(view.product, "")
|
||||||
|
self.assertFalse(view.fed)
|
||||||
|
self.assertEqual(view.cycle, 1) # 牲畜至少处在第 1 个周期
|
||||||
|
self.assertEqual(view.starve, 0)
|
||||||
|
self.assertFalse(view.is_sick)
|
||||||
|
|
||||||
|
def test_to_dict_is_a_copy(self):
|
||||||
|
view = AnimalView(animal_dict())
|
||||||
|
dumped = view.to_dict()
|
||||||
|
dumped["kind"] = "sheep"
|
||||||
|
self.assertEqual(view.kind, "cow")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 状态视图语义
|
||||||
|
class StateViewTestCase(unittest.TestCase):
|
||||||
|
def test_full_snapshot_replaces_everything(self):
|
||||||
|
view = StateView({"day": 1, "plots": [plot_dict()], "animals": [animal_dict()]})
|
||||||
|
self.assertEqual(len(view.plots), 1)
|
||||||
|
self.assertEqual(len(view.animals), 1)
|
||||||
|
|
||||||
|
view.apply({"day": 2, "plots": [], "animals": []})
|
||||||
|
self.assertEqual(view.day, 2)
|
||||||
|
self.assertEqual(view.plots, [])
|
||||||
|
self.assertEqual(view.animals, [])
|
||||||
|
|
||||||
|
def test_brief_state_merges_and_keeps_the_farm(self):
|
||||||
|
"""回归:动作回执里的精简 state 绝不能清空农田。"""
|
||||||
|
view = StateView(
|
||||||
|
{"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []}
|
||||||
|
)
|
||||||
|
view.apply({"gold": 60, "ap": 5})
|
||||||
|
|
||||||
|
self.assertEqual(view.gold, 60)
|
||||||
|
self.assertEqual(view.ap, 5)
|
||||||
|
self.assertEqual(view.day, 1, "没提到的字段要保留")
|
||||||
|
self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失")
|
||||||
|
|
||||||
|
def test_brief_state_after_a_full_snapshot_keeps_views_usable(self):
|
||||||
|
view = StateView({"plots": [plot_dict(crop="corn")], "animals": [animal_dict()]})
|
||||||
|
view.apply({"gold": 1})
|
||||||
|
self.assertEqual(view.plots[0].crop, "corn")
|
||||||
|
self.assertEqual(len(view.plots), 1)
|
||||||
|
self.assertEqual(len(view.animals), 1)
|
||||||
|
|
||||||
|
def test_empty_data_is_ignored(self):
|
||||||
|
view = StateView({"day": 3, "gold": 50})
|
||||||
|
view.apply({})
|
||||||
|
self.assertEqual(view.day, 3)
|
||||||
|
self.assertEqual(view.gold, 50)
|
||||||
|
|
||||||
|
def test_initial_construction_is_tolerant(self):
|
||||||
|
view = StateView()
|
||||||
|
self.assertEqual(view.plots, [])
|
||||||
|
self.assertEqual(view.animals, [])
|
||||||
|
self.assertEqual(view.day, 1)
|
||||||
|
self.assertEqual(view.gold, 0)
|
||||||
|
self.assertEqual(view.level, 1)
|
||||||
|
self.assertEqual(view.market, {})
|
||||||
|
|
||||||
|
def test_an_empty_view_looks_like_an_empty_farm(self):
|
||||||
|
"""没收到任何快照时也必须能安全渲染。
|
||||||
|
|
||||||
|
服务器连不上、或客户端是 ``autostart=False`` 起的,界面拿到的是空视图;
|
||||||
|
这时读 ``.plots`` 必须是"空农田",不能是 AttributeError。
|
||||||
|
"""
|
||||||
|
view = StateView()
|
||||||
|
self.assertTrue(hasattr(view, "plots"))
|
||||||
|
self.assertTrue(hasattr(view, "animals"))
|
||||||
|
self.assertEqual(len(view.plots), 0)
|
||||||
|
self.assertEqual(len(view.animals), 0)
|
||||||
|
self.assertEqual(view.to_dict(), {})
|
||||||
|
|
||||||
|
def test_offline_client_exposes_a_renderable_empty_state(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
self.assertEqual(client.state.plots, [])
|
||||||
|
self.assertEqual(client.snapshot(), {})
|
||||||
|
|
||||||
|
def test_non_dict_entries_are_filtered_out(self):
|
||||||
|
view = StateView({"plots": [plot_dict(), "垃圾", 3], "animals": [None, animal_dict()]})
|
||||||
|
self.assertEqual(len(view.plots), 1)
|
||||||
|
self.assertEqual(len(view.animals), 1)
|
||||||
|
|
||||||
|
def test_scalar_accessors_read_through(self):
|
||||||
|
view = StateView(
|
||||||
|
{
|
||||||
|
"day": 5,
|
||||||
|
"gold": 320,
|
||||||
|
"level": 4,
|
||||||
|
"exp": 11,
|
||||||
|
"ap": 2,
|
||||||
|
"feed": 9,
|
||||||
|
"market": {"corn": 12},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual((view.day, view.gold, view.level), (5, 320, 4))
|
||||||
|
self.assertEqual((view.exp, view.ap, view.feed), (11, 2, 9))
|
||||||
|
self.assertEqual(view.market["corn"], 12)
|
||||||
|
|
||||||
|
def test_get_and_item_access(self):
|
||||||
|
view = StateView({"online": 3, "paused": False})
|
||||||
|
self.assertEqual(view.get("online"), 3)
|
||||||
|
self.assertEqual(view.get("nope", "兜底"), "兜底")
|
||||||
|
self.assertEqual(view["online"], 3)
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
_ = view["nope"]
|
||||||
|
|
||||||
|
def test_to_dict_round_trips_the_raw_payload(self):
|
||||||
|
payload = {"day": 2, "gold": 5, "plots": [plot_dict()]}
|
||||||
|
self.assertEqual(StateView(payload).to_dict(), payload)
|
||||||
|
|
||||||
|
def test_to_dict_is_a_copy(self):
|
||||||
|
view = StateView({"gold": 5})
|
||||||
|
dumped = view.to_dict()
|
||||||
|
dumped["gold"] = 999
|
||||||
|
self.assertEqual(view.gold, 5)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 地址与 URL
|
||||||
|
class AddressTestCase(unittest.TestCase):
|
||||||
|
def test_normalize_adds_the_scheme(self):
|
||||||
|
self.assertEqual(RemoteFarm._normalize("127.0.0.1:8765"), "http://127.0.0.1:8765")
|
||||||
|
|
||||||
|
def test_normalize_maps_the_legacy_tcp_scheme(self):
|
||||||
|
self.assertEqual(RemoteFarm._normalize("tcp://127.0.0.1:8765"), "http://127.0.0.1:8765")
|
||||||
|
|
||||||
|
def test_normalize_strips_whitespace_and_trailing_slashes(self):
|
||||||
|
self.assertEqual(RemoteFarm._normalize(" http://host:9000/// "), "http://host:9000")
|
||||||
|
|
||||||
|
def test_normalize_keeps_https(self):
|
||||||
|
self.assertEqual(RemoteFarm._normalize("https://farm.example"), "https://farm.example")
|
||||||
|
|
||||||
|
def test_normalize_falls_back_when_empty(self):
|
||||||
|
self.assertEqual(RemoteFarm._normalize(""), DEFAULT_ENDPOINT)
|
||||||
|
self.assertEqual(RemoteFarm._normalize(" "), DEFAULT_ENDPOINT)
|
||||||
|
|
||||||
|
def test_normalize_falls_back_for_degenerate_endpoints(self):
|
||||||
|
"""畸形端点不能变成"没有主机名"的地址。
|
||||||
|
|
||||||
|
否则 host 是空串,``net.is_loopback`` 判成 False,请求会溜到系统代理上,
|
||||||
|
报回来一个和真实原因毫无关系的 502。回落到本机默认才是对的。
|
||||||
|
"""
|
||||||
|
for endpoint in ("", " ", "//", "http://", "https://", "tcp://"):
|
||||||
|
with self.subTest(endpoint=endpoint):
|
||||||
|
self.assertEqual(RemoteFarm._normalize(endpoint), DEFAULT_ENDPOINT)
|
||||||
|
|
||||||
|
def test_a_normalized_endpoint_always_has_a_host(self):
|
||||||
|
candidates = ("", " ", "http://", "127.0.0.1:1", "http://host:9000/", "tcp://h:1")
|
||||||
|
for endpoint in candidates:
|
||||||
|
with self.subTest(endpoint=endpoint):
|
||||||
|
normalized = RemoteFarm._normalize(endpoint)
|
||||||
|
self.assertTrue(urllib.parse.urlsplit(normalized).netloc, normalized)
|
||||||
|
|
||||||
|
def test_init_stores_a_normalized_endpoint(self):
|
||||||
|
client = offline_client(endpoint=" tcp://127.0.0.1:9999/ ")
|
||||||
|
self.assertEqual(client.endpoint, "http://127.0.0.1:9999")
|
||||||
|
self.assertEqual(client.host, "127.0.0.1:9999")
|
||||||
|
|
||||||
|
def test_farm_name_defaults_to_default(self):
|
||||||
|
self.assertEqual(offline_client(farm="").farm, "default")
|
||||||
|
self.assertEqual(offline_client(farm=None).farm, "default") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_host_falls_back_when_the_endpoint_has_none(self):
|
||||||
|
self.assertEqual(offline_client(endpoint="http://").host, "127.0.0.1:8765")
|
||||||
|
|
||||||
|
def test_url_always_carries_the_farm(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="阿黄")
|
||||||
|
parts = urllib.parse.urlsplit(client.url("/api/state"))
|
||||||
|
self.assertEqual(parts.path, "/api/state")
|
||||||
|
self.assertEqual(urllib.parse.parse_qs(parts.query), {"farm": ["阿黄"]})
|
||||||
|
|
||||||
|
def test_url_includes_the_token_when_present(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="s3cret")
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
|
||||||
|
self.assertEqual(query["token"], ["s3cret"])
|
||||||
|
|
||||||
|
def test_url_omits_the_token_when_absent(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
|
||||||
|
self.assertNotIn("token", query)
|
||||||
|
|
||||||
|
def test_url_carries_extra_params(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
|
||||||
|
url = client.url("/api/log", limit=30)
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
|
||||||
|
self.assertEqual(query["limit"], ["30"])
|
||||||
|
|
||||||
|
def test_url_drops_params_that_are_none(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
|
||||||
|
url = client.url("/api/log", limit=None, since=0)
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
|
||||||
|
self.assertNotIn("limit", query)
|
||||||
|
self.assertEqual(query["since"], ["0"], "0 是有效值,不能当 None 丢掉")
|
||||||
|
|
||||||
|
def test_url_is_fully_qualified(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
|
||||||
|
self.assertTrue(client.url("/api/state").startswith("http://127.0.0.1:1/api/state?"))
|
||||||
|
|
||||||
|
def test_stream_url_carries_farm_and_name(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="阿黄")
|
||||||
|
parts = urllib.parse.urlsplit(client.stream_url)
|
||||||
|
self.assertEqual(parts.path, "/api/stream")
|
||||||
|
query = urllib.parse.parse_qs(parts.query)
|
||||||
|
self.assertEqual(query["farm"], ["a"])
|
||||||
|
# 令牌只能走查询参数:EventSource / urllib 都没法给长连接加自定义头
|
||||||
|
self.assertEqual(query["name"], ["阿黄"])
|
||||||
|
|
||||||
|
def test_stream_url_skips_the_empty_name(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="")
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
|
||||||
|
self.assertNotIn("name", query)
|
||||||
|
|
||||||
|
def test_stream_url_includes_the_token(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="t")
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
|
||||||
|
self.assertEqual(query["token"], ["t"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== SSE 消息分发
|
||||||
|
class StreamMessageTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
|
||||||
|
|
||||||
|
def push(self, message):
|
||||||
|
import json
|
||||||
|
|
||||||
|
self.client._handle_stream_message(json.dumps(message, ensure_ascii=False))
|
||||||
|
|
||||||
|
def test_welcome_installs_state_catalog_and_server_info(self):
|
||||||
|
self.push(
|
||||||
|
{
|
||||||
|
"type": "welcome",
|
||||||
|
"farm": "b",
|
||||||
|
"online": 4,
|
||||||
|
"server": {"version": "2.3"},
|
||||||
|
"catalog": {"crops": [{"key": "carrot"}]},
|
||||||
|
"state": {"day": 6, "gold": 88, "plots": [plot_dict()], "animals": []},
|
||||||
|
"log": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(self.client.farm, "b")
|
||||||
|
self.assertEqual(self.client.server_info, {"version": "2.3"})
|
||||||
|
self.assertEqual(self.client.catalog()["crops"], [{"key": "carrot"}])
|
||||||
|
self.assertEqual(self.client.state.day, 6)
|
||||||
|
self.assertEqual(self.client.state.gold, 88)
|
||||||
|
self.assertEqual(self.client.state.get("online"), 4, "在线人数要合进状态视图")
|
||||||
|
self.assertEqual(len(self.client.state.plots), 1)
|
||||||
|
self.assertEqual(self.client.stats["pushes"], 1)
|
||||||
|
|
||||||
|
def test_state_and_sync_share_the_welcome_branch(self):
|
||||||
|
for kind in ("state", "sync"):
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
self.push({"type": kind, "state": {"gold": 7}, "plots": None})
|
||||||
|
self.assertEqual(self.client.state.gold, 7)
|
||||||
|
self.assertEqual(self.client.stats["pushes"], 2)
|
||||||
|
|
||||||
|
def test_brief_state_push_does_not_wipe_the_farm(self):
|
||||||
|
self.push({"type": "state", "state": {"plots": [plot_dict()], "animals": []}})
|
||||||
|
self.push({"type": "state", "state": {"gold": 3}})
|
||||||
|
self.assertEqual(len(self.client.state.plots), 1)
|
||||||
|
self.assertEqual(self.client.state.gold, 3)
|
||||||
|
|
||||||
|
def test_push_without_state_still_counts_and_keeps_old_data(self):
|
||||||
|
self.push({"type": "state", "state": {"day": 9}})
|
||||||
|
self.push({"type": "state"})
|
||||||
|
self.assertEqual(self.client.state.day, 9)
|
||||||
|
self.assertEqual(self.client.stats["pushes"], 2)
|
||||||
|
|
||||||
|
def test_push_ignores_a_bad_online_value(self):
|
||||||
|
self.push({"type": "state", "online": "很多", "state": {"day": 1}})
|
||||||
|
self.assertIsNone(self.client.state.get("online"))
|
||||||
|
|
||||||
|
def test_online_is_dropped_when_the_state_is_empty(self):
|
||||||
|
self.push({"type": "state", "online": 3})
|
||||||
|
self.assertIsNone(self.client.state.get("online"))
|
||||||
|
|
||||||
|
def test_push_merges_the_log(self):
|
||||||
|
self.push(
|
||||||
|
{
|
||||||
|
"type": "state",
|
||||||
|
"state": {"day": 1},
|
||||||
|
"log": [{"seq": 2, "text": "b"}, {"seq": 1, "text": "a"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entries = self.client.recent_log()
|
||||||
|
self.assertEqual([e["seq"] for e in entries], [1, 2], "日志要按 seq 排序")
|
||||||
|
self.assertEqual([e["text"] for e in entries], ["a", "b"])
|
||||||
|
|
||||||
|
def test_day_message_appends_a_header_and_its_events(self):
|
||||||
|
self.push({"type": "day", "day": 4, "events": ["奶牛生病了", "一块地旱了"]})
|
||||||
|
entries = self.client.recent_log()
|
||||||
|
self.assertEqual(len(entries), 3)
|
||||||
|
self.assertEqual(entries[0]["text"], "第 4 天开始")
|
||||||
|
self.assertEqual(entries[0]["kind"], "info")
|
||||||
|
self.assertEqual([e["text"] for e in entries[1:]], ["奶牛生病了", "一块地旱了"])
|
||||||
|
self.assertTrue(all(e["kind"] == "warn" for e in entries[1:]))
|
||||||
|
self.assertTrue(all(e["day"] == 4 for e in entries))
|
||||||
|
|
||||||
|
def test_day_message_continues_the_sequence(self):
|
||||||
|
self.push({"type": "state", "state": {"day": 1}, "log": [{"seq": 5, "text": "旧"}]})
|
||||||
|
self.push({"type": "day", "day": 2, "events": ["一"]})
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log()], [5, 6, 7])
|
||||||
|
|
||||||
|
def test_day_message_survives_missing_events(self):
|
||||||
|
self.push({"type": "day", "day": 2})
|
||||||
|
entries = self.client.recent_log()
|
||||||
|
self.assertEqual(len(entries), 1)
|
||||||
|
self.assertEqual(entries[0]["text"], "第 2 天开始")
|
||||||
|
|
||||||
|
def test_error_message_is_recorded(self):
|
||||||
|
self.push({"type": "error", "message": "牧场不存在"})
|
||||||
|
self.assertEqual(self.client.last_error, "牧场不存在")
|
||||||
|
self.assertEqual(self.client.stats["errors"], 1)
|
||||||
|
|
||||||
|
def test_bye_disconnects_with_a_reason(self):
|
||||||
|
self.client.connected = True
|
||||||
|
self.push({"type": "bye", "message": "服务器正在停机"})
|
||||||
|
self.assertFalse(self.client.connected)
|
||||||
|
self.assertEqual(self.client.last_error, "服务器正在停机")
|
||||||
|
|
||||||
|
def test_bye_without_a_reason_uses_a_default(self):
|
||||||
|
self.client.connected = True
|
||||||
|
self.push({"type": "bye"})
|
||||||
|
self.assertFalse(self.client.connected)
|
||||||
|
self.assertEqual(self.client.last_error, "服务器已停机")
|
||||||
|
|
||||||
|
def test_malformed_payloads_are_ignored(self):
|
||||||
|
for payload in ("不是 JSON", "[1, 2, 3]", '"字符串"', "3", ""):
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
self.client._handle_stream_message(payload)
|
||||||
|
self.assertEqual(self.client.stats["pushes"], 0)
|
||||||
|
self.assertEqual(self.client.stats["errors"], 0)
|
||||||
|
self.assertEqual(self.client.last_error, "")
|
||||||
|
|
||||||
|
def test_unknown_message_types_are_ignored(self):
|
||||||
|
self.push({"type": "谁知道呢", "state": {"gold": 7}})
|
||||||
|
self.assertEqual(self.client.state.gold, 0)
|
||||||
|
self.assertEqual(self.client.stats["pushes"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 日志与回调
|
||||||
|
class LogAndCallbackTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
|
||||||
|
def test_merge_log_deduplicates_by_seq(self):
|
||||||
|
self.client._merge_log([{"seq": 1, "text": "a"}])
|
||||||
|
self.client._merge_log([{"seq": 1, "text": "a"}, {"seq": 2, "text": "b"}])
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1, 2])
|
||||||
|
|
||||||
|
def test_merge_log_skips_entries_without_a_sequence(self):
|
||||||
|
self.client._merge_log([{"text": "无号"}, {"seq": 0, "text": "零号"}, {"seq": 1}])
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
|
||||||
|
|
||||||
|
def test_merge_log_keeps_only_the_most_recent_entries(self):
|
||||||
|
self.client._merge_log([{"seq": n} for n in range(1, LOG_KEEP + 51)])
|
||||||
|
entries = self.client.recent_log(limit=0)
|
||||||
|
self.assertEqual(len(entries), LOG_KEEP)
|
||||||
|
self.assertEqual(entries[-1]["seq"], LOG_KEEP + 50)
|
||||||
|
|
||||||
|
def test_recent_log_filters_by_since(self):
|
||||||
|
self.client._merge_log([{"seq": n} for n in range(1, 6)])
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log(since=3)], [4, 5])
|
||||||
|
|
||||||
|
def test_recent_log_limit_takes_the_tail(self):
|
||||||
|
self.client._merge_log([{"seq": n} for n in range(1, 6)])
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log(limit=2)], [4, 5])
|
||||||
|
|
||||||
|
def test_recent_log_limit_zero_returns_everything(self):
|
||||||
|
self.client._merge_log([{"seq": n} for n in range(1, 6)])
|
||||||
|
self.assertEqual(len(self.client.recent_log(limit=0)), 5)
|
||||||
|
|
||||||
|
def test_apply_fires_the_change_callback(self):
|
||||||
|
hits = []
|
||||||
|
self.client.on_change = lambda: hits.append(1)
|
||||||
|
self.client._apply({"gold": 1})
|
||||||
|
self.client._apply({"gold": 2})
|
||||||
|
self.assertEqual(len(hits), 2)
|
||||||
|
|
||||||
|
def test_apply_does_not_fire_for_empty_state(self):
|
||||||
|
hits = []
|
||||||
|
self.client.on_change = lambda: hits.append(1)
|
||||||
|
self.client._apply({})
|
||||||
|
self.assertEqual(hits, [])
|
||||||
|
|
||||||
|
def test_a_broken_callback_cannot_break_data_sync(self):
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("界面炸了")
|
||||||
|
|
||||||
|
self.client.on_change = boom
|
||||||
|
self.client._apply({"gold": 42})
|
||||||
|
self.assertEqual(self.client.state.gold, 42)
|
||||||
|
|
||||||
|
def test_transport_label_reports_the_active_channel(self):
|
||||||
|
self.client.mode = "sse"
|
||||||
|
self.assertEqual(self.client.transport_label(), "SSE 实时")
|
||||||
|
self.client.mode = "poll"
|
||||||
|
self.assertEqual(self.client.transport_label(), "HTTP 轮询")
|
||||||
|
self.client.mode = "http"
|
||||||
|
self.assertEqual(self.client.transport_label(), "HTTP")
|
||||||
|
|
||||||
|
def test_catalog_starts_with_local_config_defaults(self):
|
||||||
|
catalog = self.client.catalog()
|
||||||
|
self.assertEqual(catalog["feed_price"], config.FEED_UNIT_PRICE)
|
||||||
|
self.assertEqual(catalog["water_capacity"], config.WATER_CAPACITY)
|
||||||
|
self.assertEqual(catalog["crops"], [])
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 动作转发
|
||||||
|
class ActionForwardingTestCase(unittest.TestCase):
|
||||||
|
"""写操作统一走 POST /api/actions —— 用假的 _http 把请求原样抓下来核对。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
self.requests: list[tuple[str, str, dict, dict]] = []
|
||||||
|
self.reply: dict = {}
|
||||||
|
self.client._http = self._fake_http # type: ignore[method-assign]
|
||||||
|
|
||||||
|
def _fake_http(self, method, path, payload=None, **params):
|
||||||
|
self.requests.append((method, path, payload or {}, params))
|
||||||
|
return self.reply
|
||||||
|
|
||||||
|
def last(self):
|
||||||
|
return self.requests[-1]
|
||||||
|
|
||||||
|
def test_plant_posts_the_right_payload(self):
|
||||||
|
self.client.plant(3, "carrot")
|
||||||
|
method, path, payload, params = self.last()
|
||||||
|
self.assertEqual(method, "POST")
|
||||||
|
self.assertEqual(path, "/api/actions")
|
||||||
|
self.assertEqual(payload, {"action": "plant", "params": {"plot": 3, "crop": "carrot"}})
|
||||||
|
self.assertEqual(params, {"full": 1}, "顺手要一份完整快照,省掉操作后再查一次")
|
||||||
|
|
||||||
|
def test_simple_actions_carry_their_arguments(self):
|
||||||
|
cases = [
|
||||||
|
(lambda: self.client.water(2), "water", {"plot": 2}),
|
||||||
|
(lambda: self.client.harvest(1), "harvest", {"plot": 1}),
|
||||||
|
(lambda: self.client.harvest_all(), "harvest_all", {}),
|
||||||
|
(lambda: self.client.buy_animal("cow"), "buy_animal", {"kind": "cow"}),
|
||||||
|
(lambda: self.client.collect(7), "collect", {"animal_id": 7}),
|
||||||
|
(lambda: self.client.buy_plot(), "buy_plot", {}),
|
||||||
|
]
|
||||||
|
for call, action, params in cases:
|
||||||
|
with self.subTest(action=action):
|
||||||
|
call()
|
||||||
|
self.assertEqual(self.last()[2], {"action": action, "params": params})
|
||||||
|
|
||||||
|
def test_feed_omits_quantity_when_not_given(self):
|
||||||
|
self.client.feed(7)
|
||||||
|
self.assertEqual(self.last()[2]["params"], {"animal_id": 7})
|
||||||
|
|
||||||
|
def test_feed_forwards_quantity(self):
|
||||||
|
self.client.feed(7, 3)
|
||||||
|
self.assertEqual(self.last()[2]["params"], {"animal_id": 7, "quantity": 3})
|
||||||
|
|
||||||
|
def test_buy_feed_defaults_to_bulk(self):
|
||||||
|
self.client.buy_feed()
|
||||||
|
self.assertEqual(self.last()[2]["params"], {"quantity": config.FEED_BULK})
|
||||||
|
|
||||||
|
def test_pause_without_argument_is_a_toggle(self):
|
||||||
|
self.client.pause()
|
||||||
|
self.assertEqual(self.last()[2], {"action": "pause", "params": {}})
|
||||||
|
|
||||||
|
def test_pause_forwards_an_explicit_value(self):
|
||||||
|
self.client.pause(True)
|
||||||
|
self.assertEqual(self.last()[2]["params"], {"paused": True})
|
||||||
|
|
||||||
|
def test_pause_forwards_an_explicit_false(self):
|
||||||
|
# False 与"没传"必须区分开,否则永远只能切不能停
|
||||||
|
self.client.pause(False)
|
||||||
|
self.assertEqual(self.last()[2]["params"], {"paused": False})
|
||||||
|
|
||||||
|
def test_save_forwards_to_the_server(self):
|
||||||
|
self.client.save()
|
||||||
|
self.assertEqual(self.last()[2], {"action": "save", "params": {}})
|
||||||
|
|
||||||
|
def test_batch_posts_a_single_request(self):
|
||||||
|
items = [{"action": "water", "params": {"plot": 1}}, {"action": "harvest", "params": {}}]
|
||||||
|
self.client.batch(items)
|
||||||
|
method, path, payload, params = self.last()
|
||||||
|
self.assertEqual(method, "POST")
|
||||||
|
self.assertEqual(path, "/api/batch")
|
||||||
|
self.assertEqual(payload, {"actions": items})
|
||||||
|
self.assertEqual(params, {"full": 1})
|
||||||
|
self.assertEqual(len(self.requests), 1, "批量就该是一个请求")
|
||||||
|
|
||||||
|
def test_action_applies_the_returned_snapshot(self):
|
||||||
|
self.reply = {"snapshot": {"plots": [plot_dict()], "gold": 10}, "log": [{"seq": 1}]}
|
||||||
|
self.client.water(1)
|
||||||
|
self.assertEqual(self.client.state.gold, 10)
|
||||||
|
self.assertEqual(len(self.client.state.plots), 1)
|
||||||
|
self.assertEqual(len(self.client.recent_log()), 1)
|
||||||
|
|
||||||
|
def test_action_falls_back_to_the_brief_state(self):
|
||||||
|
self.reply = {"state": {"gold": 5}}
|
||||||
|
self.client.water(1)
|
||||||
|
self.assertEqual(self.client.state.gold, 5)
|
||||||
|
|
||||||
|
def test_snapshot_wins_over_brief_state(self):
|
||||||
|
self.reply = {"snapshot": {"gold": 10}, "state": {"gold": 5}}
|
||||||
|
self.client.water(1)
|
||||||
|
self.assertEqual(self.client.state.gold, 10)
|
||||||
|
|
||||||
|
def test_reset_clears_the_log_and_refreshes(self):
|
||||||
|
self.client._merge_log([{"seq": 1, "text": "旧"}])
|
||||||
|
refreshed = []
|
||||||
|
original = self.client.refresh
|
||||||
|
self.client.refresh = lambda *a, **k: refreshed.append(1) or True # type: ignore[method-assign]
|
||||||
|
self.client.reset()
|
||||||
|
self.client.refresh = original # type: ignore[method-assign]
|
||||||
|
self.assertEqual(self.client.recent_log(), [])
|
||||||
|
self.assertEqual(self.last()[2], {"action": "reset", "params": {}})
|
||||||
|
self.assertEqual(refreshed, [1], "重置后要重新拉全量")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 实时通道
|
||||||
|
class RealtimeTestCase(unittest.TestCase):
|
||||||
|
def test_start_is_a_noop_when_realtime_is_off(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=False)
|
||||||
|
client.start()
|
||||||
|
self.assertIsNone(client._thread)
|
||||||
|
|
||||||
|
def test_start_spawns_only_one_thread(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=True)
|
||||||
|
client._realtime_loop = lambda: None # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
client.start()
|
||||||
|
first = client._thread
|
||||||
|
client.start()
|
||||||
|
self.assertIs(client._thread, first, "重复 start 不该开第二条连接")
|
||||||
|
finally:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
def test_stop_is_safe_without_a_connection(self):
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
client.stop() # 不该抛
|
||||||
|
self.assertTrue(client._stop.is_set())
|
||||||
|
|
||||||
|
def test_underlying_socket_walks_the_attribute_chain(self):
|
||||||
|
class _Raw:
|
||||||
|
_sock = "套接字"
|
||||||
|
|
||||||
|
class _Fp:
|
||||||
|
raw = _Raw()
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
fp = _Fp()
|
||||||
|
|
||||||
|
self.assertEqual(_underlying_socket(_Response()), "套接字")
|
||||||
|
|
||||||
|
def test_underlying_socket_degrades_to_none(self):
|
||||||
|
"""掏不到就返回 None,调用方会退化成 close() —— 允许变慢,不允许崩。"""
|
||||||
|
for fake in (object(), type("R", (), {"fp": None})(), type("R", (), {"fp": object()})()):
|
||||||
|
with self.subTest(fake=type(fake).__name__):
|
||||||
|
self.assertIsNone(_underlying_socket(fake))
|
||||||
|
|
||||||
|
def test_stop_shuts_down_the_stream_socket(self):
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class _Sock:
|
||||||
|
def shutdown(self, how):
|
||||||
|
events.append(("shutdown", how))
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def close(self):
|
||||||
|
events.append(("close", None))
|
||||||
|
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
client._stream_socket = _Sock()
|
||||||
|
client._stream_response = _Resp()
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
self.assertIn(("shutdown", socket.SHUT_RDWR), events)
|
||||||
|
self.assertIn(("close", None), events)
|
||||||
|
# shutdown 必须在 close 之前:close 会等读线程的锁,最长拖到下一次心跳
|
||||||
|
self.assertLess(events.index(("shutdown", socket.SHUT_RDWR)), events.index(("close", None)))
|
||||||
|
|
||||||
|
def test_stop_tolerates_broken_stream_objects(self):
|
||||||
|
class _Sock:
|
||||||
|
def shutdown(self, how):
|
||||||
|
raise OSError("已经断了")
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def close(self):
|
||||||
|
raise RuntimeError("也炸了")
|
||||||
|
|
||||||
|
client = offline_client(endpoint="http://127.0.0.1:1/")
|
||||||
|
client._stream_socket = _Sock()
|
||||||
|
client._stream_response = _Resp()
|
||||||
|
client.stop() # 停机路径必须稳
|
||||||
|
|
||||||
|
|
||||||
|
class BotClientTestCase(unittest.TestCase):
|
||||||
|
def test_wait_online_returns_as_soon_as_refresh_works(self):
|
||||||
|
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
|
||||||
|
client.refresh = lambda *a, **k: True # type: ignore[method-assign]
|
||||||
|
self.assertTrue(client.wait_online(seconds=5))
|
||||||
|
|
||||||
|
def test_wait_online_gives_up_after_the_deadline(self):
|
||||||
|
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
|
||||||
|
calls = []
|
||||||
|
client.refresh = lambda *a, **k: calls.append(1) or False # type: ignore[method-assign]
|
||||||
|
self.assertFalse(client.wait_online(seconds=0))
|
||||||
|
self.assertEqual(calls, [], "时限已过就不该再去连")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== HTTP 传输层
|
||||||
|
class HttpTransportTestCase(unittest.TestCase):
|
||||||
|
"""用纯标准库的回环桩服务验证 _http 的成功与各类失败分支。
|
||||||
|
|
||||||
|
这不是"外部服务器":桩只绑 127.0.0.1,起不来就整类跳过。
|
||||||
|
顺带验证了 net.open_url 的回环直连——本机设了 HTTP_PROXY,
|
||||||
|
走代理的话这里会拿到 502,而不是桩真正返回的内容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
server: http.server.ThreadingHTTPServer
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
payload = b'{"ok": true, "state": {"day": 3, "gold": 42}, "online": 2}'
|
||||||
|
_StubHandler.payloads = {
|
||||||
|
"/api/state": (200, payload, "application/json"),
|
||||||
|
"/api/catalog": (
|
||||||
|
200,
|
||||||
|
b'{"ok": true, "catalog": {"feed_price": 6}}',
|
||||||
|
"application/json",
|
||||||
|
),
|
||||||
|
"/api/log": (
|
||||||
|
200,
|
||||||
|
b'{"ok": true, "entries": [{"seq": 1, "text": "a"}]}',
|
||||||
|
"application/json",
|
||||||
|
),
|
||||||
|
"/api/bad": (200, "<html>不是 JSON</html>".encode(), "text/html"),
|
||||||
|
"/api/list": (200, b"[1, 2, 3]", "application/json"),
|
||||||
|
"/api/empty": (204, b"", "application/json"),
|
||||||
|
"/api/rejected": (
|
||||||
|
400,
|
||||||
|
'{"ok": false, "error": "locked", "message": "这块地还没解锁"}'.encode(),
|
||||||
|
"application/json",
|
||||||
|
),
|
||||||
|
"/api/explode": (500, "内部错误,没有 JSON".encode(), "text/plain"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler)
|
||||||
|
except OSError as exc: # pragma: no cover - 环境不允许绑端口
|
||||||
|
raise unittest.SkipTest(f"起不了回环桩服务:{exc}") from exc
|
||||||
|
cls.port = int(cls.server.server_address[1])
|
||||||
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.server.server_close()
|
||||||
|
cls.thread.join(timeout=5)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = offline_client(endpoint=f"127.0.0.1:{self.port}", farm="测试场")
|
||||||
|
|
||||||
|
def test_get_returns_the_parsed_body(self):
|
||||||
|
data = self.client._http("GET", "/api/state")
|
||||||
|
self.assertEqual(data["state"]["gold"], 42)
|
||||||
|
self.assertTrue(self.client.connected)
|
||||||
|
self.assertEqual(self.client.last_error, "")
|
||||||
|
self.assertEqual(self.client.stats["http"], 1)
|
||||||
|
|
||||||
|
def test_loopback_requests_do_not_go_through_the_proxy(self):
|
||||||
|
# 桩返回的是我们自己的 JSON;被系统代理截走的话这里会变成 502 页面
|
||||||
|
self.assertEqual(self.client._http("GET", "/api/catalog")["catalog"]["feed_price"], 6)
|
||||||
|
|
||||||
|
def test_open_url_accepts_a_plain_string(self):
|
||||||
|
url = f"http://127.0.0.1:{self.port}/api/state"
|
||||||
|
with net.open_url(url, timeout=5) as response:
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertIn("gold", response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def test_refresh_pulls_state_catalog_and_log(self):
|
||||||
|
self.assertTrue(self.client.refresh())
|
||||||
|
self.assertEqual(self.client.state.day, 3)
|
||||||
|
self.assertEqual(self.client.state.gold, 42)
|
||||||
|
self.assertEqual(self.client.state.get("online"), 2)
|
||||||
|
self.assertEqual(self.client.catalog()["feed_price"], 6)
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
|
||||||
|
self.assertEqual(self.client.stats["http"], 3)
|
||||||
|
|
||||||
|
def test_refresh_swallows_errors_by_default(self):
|
||||||
|
client = offline_client(endpoint="127.0.0.1:1", farm="测试场") # 没人监听
|
||||||
|
self.assertFalse(client.refresh())
|
||||||
|
self.assertFalse(client.connected)
|
||||||
|
self.assertTrue(client.last_error)
|
||||||
|
|
||||||
|
def test_refresh_can_raise_on_error(self):
|
||||||
|
client = offline_client(endpoint="127.0.0.1:1", farm="测试场")
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
client.refresh(raise_on_error=True)
|
||||||
|
self.assertEqual(ctx.exception.code, "network")
|
||||||
|
|
||||||
|
def test_empty_body_is_treated_as_an_empty_object(self):
|
||||||
|
self.assertEqual(self.client._http("GET", "/api/empty"), {})
|
||||||
|
|
||||||
|
def test_non_json_body_is_reported_as_a_bad_response(self):
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
self.client._http("GET", "/api/bad")
|
||||||
|
self.assertEqual(ctx.exception.code, "bad_response")
|
||||||
|
self.assertEqual(ctx.exception.status, 502)
|
||||||
|
|
||||||
|
def test_json_that_is_not_an_object_is_rejected(self):
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
self.client._http("GET", "/api/list")
|
||||||
|
self.assertEqual(ctx.exception.code, "bad_response")
|
||||||
|
self.assertIn("不是对象", ctx.exception.message)
|
||||||
|
|
||||||
|
def test_server_rejection_becomes_a_game_error(self):
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
self.client._http("POST", "/api/rejected", {"action": "plant"})
|
||||||
|
self.assertEqual(ctx.exception.code, "locked")
|
||||||
|
self.assertEqual(ctx.exception.message, "这块地还没解锁")
|
||||||
|
self.assertEqual(ctx.exception.status, 400)
|
||||||
|
self.assertEqual(self.client.stats["errors"], 1)
|
||||||
|
|
||||||
|
def test_error_responses_that_are_not_json_say_so(self):
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
self.client._http("GET", "/api/explode")
|
||||||
|
self.assertEqual(ctx.exception.code, "bad_response")
|
||||||
|
|
||||||
|
def test_a_dead_server_becomes_a_network_error_with_a_hint(self):
|
||||||
|
dead = offline_client(endpoint=f"127.0.0.1:{free_port()}", farm="测试场")
|
||||||
|
with self.assertRaises(GameError) as ctx:
|
||||||
|
dead._http("GET", "/api/state")
|
||||||
|
self.assertEqual(ctx.exception.code, "network")
|
||||||
|
self.assertEqual(ctx.exception.status, 503)
|
||||||
|
self.assertIn("main.py serve", ctx.exception.message, "错误信息要告诉用户怎么补救")
|
||||||
|
self.assertFalse(dead.connected)
|
||||||
|
self.assertEqual(dead.stats["errors"], 1)
|
||||||
|
|
||||||
|
def test_action_response_is_applied_end_to_end(self):
|
||||||
|
_StubHandler.payloads["/api/actions"] = (
|
||||||
|
200,
|
||||||
|
b'{"ok": true, "snapshot": {"gold": 7, "plots": []}, "log": [{"seq": 9}]}',
|
||||||
|
"application/json",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.client._action("harvest_all")
|
||||||
|
finally:
|
||||||
|
_StubHandler.payloads.pop("/api/actions", None)
|
||||||
|
self.assertEqual(self.client.state.gold, 7)
|
||||||
|
self.assertEqual([e["seq"] for e in self.client.recent_log()], [9])
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== 代理绕行
|
||||||
|
class NetTestCase(unittest.TestCase):
|
||||||
|
def test_loopback_hosts_are_recognized(self):
|
||||||
|
for host in ("127.0.0.1", "127.0.0.5", "localhost", "LocalHost", "::1", "[::1]", ""):
|
||||||
|
with self.subTest(host=host):
|
||||||
|
self.assertTrue(net.is_loopback(host))
|
||||||
|
|
||||||
|
def test_loopback_check_tolerates_surrounding_space(self):
|
||||||
|
self.assertTrue(net.is_loopback(" 127.0.0.1 "))
|
||||||
|
|
||||||
|
def test_remote_hosts_are_not_loopback(self):
|
||||||
|
for host in ("farm.example.com", "10.0.0.1", "192.168.1.9", "128.0.0.1", None):
|
||||||
|
with self.subTest(host=host):
|
||||||
|
self.assertFalse(net.is_loopback(host))
|
||||||
|
|
||||||
|
def test_host_of_extracts_from_a_string_or_a_request(self):
|
||||||
|
self.assertEqual(net.host_of("http://127.0.0.1:8765/api/state"), "127.0.0.1")
|
||||||
|
request = urllib.request.Request("http://example.com:9000/x")
|
||||||
|
self.assertEqual(net.host_of(request), "example.com")
|
||||||
|
|
||||||
|
def test_host_of_returns_none_when_unparseable(self):
|
||||||
|
self.assertIsNone(net.host_of("没有协议也没有主机"))
|
||||||
|
|
||||||
|
def test_endpoint_host_accepts_a_bare_host_port(self):
|
||||||
|
self.assertEqual(net.endpoint_host("127.0.0.1:8765"), "127.0.0.1")
|
||||||
|
|
||||||
|
def test_endpoint_host_falls_back_to_the_default(self):
|
||||||
|
self.assertEqual(net.endpoint_host("http://"), "127.0.0.1")
|
||||||
|
self.assertEqual(net.endpoint_host("", default="10.0.0.1"), "10.0.0.1")
|
||||||
|
|
||||||
|
def test_the_loopback_opener_registers_no_proxy_handler_at_all(self):
|
||||||
|
"""回环直连的机制:这个 opener 里压根没有代理处理器。
|
||||||
|
|
||||||
|
``ProxyHandler({})`` 不会注册任何 ``*_open`` 钩子,而 ``add_handler``
|
||||||
|
只在"至少挂上一个钩子"时才把 handler 记进 ``handlers``,所以它自己
|
||||||
|
反而没被收进去——结果是 http 请求直接进 ``HTTPHandler``,必然直连。
|
||||||
|
"""
|
||||||
|
handlers = net._no_proxy_opener.handlers
|
||||||
|
self.assertFalse([h for h in handlers if isinstance(h, urllib.request.ProxyHandler)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
+887
-433
File diff suppressed because it is too large.
Load diff
+1133
-486
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,655 @@
|
|||||||
|
"""键盘与 TUI 测试:这里曾经漏掉一个只有真按下去才会发现的 bug。
|
||||||
|
|
||||||
|
历史事故
|
||||||
|
--------
|
||||||
|
``keys.py`` 在 Windows 上返回**大写** ``"UP"``,而 ``tui.py`` 的处理表里写的是
|
||||||
|
**小写** ``"up"``,于是方向键、空格、Tab、ESC 全部**静默失效**——字母键因为
|
||||||
|
``char.lower()`` 恰好对得上而正常,所以肉眼很难察觉。
|
||||||
|
|
||||||
|
因此这里的测试分两层:
|
||||||
|
* 约定层:两套平台映射表必须落在**同一批小写键名**里;
|
||||||
|
* 行为层:逐个具名键驱动**真实的** :class:`farm.tui.TuiApp`,确认真的有人处理。
|
||||||
|
|
||||||
|
uv run python -m unittest tests.test_tui_keys -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from farm import config, keys
|
||||||
|
from farm.engine import FarmEngine
|
||||||
|
from farm.keys import KeyReader
|
||||||
|
from farm.tui import (
|
||||||
|
ANSI_RE,
|
||||||
|
TuiApp,
|
||||||
|
bar,
|
||||||
|
display_width,
|
||||||
|
draw_box,
|
||||||
|
hstack,
|
||||||
|
pad,
|
||||||
|
strip_ansi,
|
||||||
|
truncate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 主模式下"按下去必须有反应"的具名键。ENTER / HOME / END / DELETE 等在主界面
|
||||||
|
# 没有绑定,静默忽略是设计如此,所以不在此列(ESC 同理:它只在菜单里取消)。
|
||||||
|
NAMED_KEYS_WITH_MAIN_BINDINGS = (
|
||||||
|
keys.UP,
|
||||||
|
keys.DOWN,
|
||||||
|
keys.LEFT,
|
||||||
|
keys.RIGHT,
|
||||||
|
keys.SPACE,
|
||||||
|
keys.TAB,
|
||||||
|
keys.CTRL_C,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fake_reader(data: bytes) -> KeyReader:
|
||||||
|
"""造一个只带解析能力的 KeyReader:绕过 tty 初始化,直接喂字节。"""
|
||||||
|
reader = KeyReader.__new__(KeyReader)
|
||||||
|
reader._fd = None
|
||||||
|
reader._saved_term = None
|
||||||
|
reader._raw = data
|
||||||
|
reader.reason = ""
|
||||||
|
reader.available = True
|
||||||
|
return reader
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(**kwargs) -> TuiApp:
|
||||||
|
"""真实引擎 + 真实 TuiApp,只是不写盘、不上色,方便断言。"""
|
||||||
|
engine = FarmEngine()
|
||||||
|
engine.state.gold = 3000
|
||||||
|
directory = tempfile.mkdtemp(prefix="simplefarm-tui-")
|
||||||
|
options = {
|
||||||
|
"save_path": os.path.join(directory, "save.json"),
|
||||||
|
"use_color": False,
|
||||||
|
"autosave": False,
|
||||||
|
}
|
||||||
|
options.update(kwargs)
|
||||||
|
return TuiApp(engine, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyNameConventionTestCase(unittest.TestCase):
|
||||||
|
"""约定层:全小写、集中定义、两端一致。"""
|
||||||
|
|
||||||
|
def test_every_named_key_constant_is_lowercase(self):
|
||||||
|
for name in keys.NAMED_KEYS:
|
||||||
|
with self.subTest(key=name):
|
||||||
|
self.assertIsInstance(name, str)
|
||||||
|
self.assertEqual(name, name.lower(), f"{name!r} 必须全小写")
|
||||||
|
self.assertNotEqual(name, "", "空字符串不能当键名")
|
||||||
|
|
||||||
|
def test_named_keys_is_exactly_the_set_of_constants(self):
|
||||||
|
constants = {
|
||||||
|
getattr(keys, attr)
|
||||||
|
for attr in dir(keys)
|
||||||
|
if attr.isupper() and isinstance(getattr(keys, attr), str)
|
||||||
|
}
|
||||||
|
self.assertEqual(set(keys.NAMED_KEYS), constants)
|
||||||
|
|
||||||
|
def test_named_keys_do_not_collide_with_typeable_characters(self):
|
||||||
|
"""具名键不能是单个字符,否则会和普通按键混在一起分不清。"""
|
||||||
|
for name in keys.NAMED_KEYS:
|
||||||
|
with self.subTest(key=name):
|
||||||
|
self.assertGreater(len(name), 1)
|
||||||
|
|
||||||
|
def test_constant_values_are_the_lowercase_form_of_their_names(self):
|
||||||
|
self.assertEqual(keys.UP, "up")
|
||||||
|
self.assertEqual(keys.DOWN, "down")
|
||||||
|
self.assertEqual(keys.LEFT, "left")
|
||||||
|
self.assertEqual(keys.RIGHT, "right")
|
||||||
|
self.assertEqual(keys.SPACE, "space")
|
||||||
|
self.assertEqual(keys.TAB, "tab")
|
||||||
|
self.assertEqual(keys.ESC, "esc")
|
||||||
|
self.assertEqual(keys.ENTER, "enter")
|
||||||
|
self.assertEqual(keys.CTRL_C, "ctrl_c")
|
||||||
|
self.assertEqual(keys.BACKSPACE, "backspace")
|
||||||
|
self.assertEqual(keys.DELETE, "delete")
|
||||||
|
self.assertEqual(keys.HOME, "home")
|
||||||
|
self.assertEqual(keys.END, "end")
|
||||||
|
|
||||||
|
def test_platform_tables_only_produce_known_names(self):
|
||||||
|
tables = {
|
||||||
|
"windows 扩展键": keys._WIN_EXTENDED,
|
||||||
|
"windows 控制键": keys._WIN_CTRL,
|
||||||
|
"posix 控制键": keys._POSIX_CTRL,
|
||||||
|
"posix 转义序列": keys._POSIX_CSI,
|
||||||
|
}
|
||||||
|
for label, table in tables.items():
|
||||||
|
for raw, name in table.items():
|
||||||
|
with self.subTest(table=label, raw=raw):
|
||||||
|
self.assertIn(name, keys.NAMED_KEYS, f"{label} 产出了未登记的键名 {name!r}")
|
||||||
|
self.assertEqual(name, name.lower())
|
||||||
|
|
||||||
|
def test_both_platforms_produce_the_same_function_keys(self):
|
||||||
|
"""这条断言就是那个 bug 的护栏:两端必须产出同一批方向键/功能键。"""
|
||||||
|
windows = set(keys._WIN_EXTENDED.values())
|
||||||
|
posix = set(keys._POSIX_CSI.values())
|
||||||
|
self.assertEqual(windows, posix)
|
||||||
|
self.assertEqual(
|
||||||
|
windows, {keys.UP, keys.DOWN, keys.LEFT, keys.RIGHT, keys.HOME, keys.END, keys.DELETE}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_both_platforms_produce_the_same_control_keys(self):
|
||||||
|
windows = set(keys._WIN_CTRL.values())
|
||||||
|
posix = set(keys._POSIX_CTRL.values())
|
||||||
|
self.assertEqual(posix, windows - {keys.ESC}, "POSIX 的 ESC 走单独的转义序列分支")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyReaderAvailabilityTestCase(unittest.TestCase):
|
||||||
|
def test_non_tty_stdin_degrades_with_a_reason(self):
|
||||||
|
with mock.patch.object(sys, "stdin", None):
|
||||||
|
reader = KeyReader()
|
||||||
|
self.assertFalse(reader.available)
|
||||||
|
self.assertIn("标准输入", reader.reason)
|
||||||
|
|
||||||
|
def test_piped_stdin_degrades_with_a_reason(self):
|
||||||
|
class NotATty:
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with mock.patch.object(sys, "stdin", NotATty()):
|
||||||
|
reader = KeyReader()
|
||||||
|
self.assertFalse(reader.available)
|
||||||
|
self.assertNotEqual(reader.reason, "")
|
||||||
|
|
||||||
|
def test_unavailable_reader_returns_nothing(self):
|
||||||
|
reader = KeyReader.__new__(KeyReader)
|
||||||
|
reader._fd = None
|
||||||
|
reader._saved_term = None
|
||||||
|
reader._raw = b""
|
||||||
|
reader.reason = "测试"
|
||||||
|
reader.available = False
|
||||||
|
self.assertIsNone(reader.poll())
|
||||||
|
|
||||||
|
def test_close_is_safe_without_a_terminal(self):
|
||||||
|
reader = KeyReader.__new__(KeyReader)
|
||||||
|
reader._fd = None
|
||||||
|
reader._saved_term = None
|
||||||
|
reader._raw = b"leftover"
|
||||||
|
reader.reason = ""
|
||||||
|
reader.available = False
|
||||||
|
reader.close()
|
||||||
|
self.assertEqual(reader._raw, b"")
|
||||||
|
|
||||||
|
|
||||||
|
class PosixByteParsingTestCase(unittest.TestCase):
|
||||||
|
"""POSIX 侧把原始字节流翻译成按键名——纯函数,不碰真终端。"""
|
||||||
|
|
||||||
|
def parse(self, data: bytes) -> str | None:
|
||||||
|
return fake_reader(data)._parse_posix()
|
||||||
|
|
||||||
|
def drain(self, data: bytes, times: int = 8) -> list[str]:
|
||||||
|
reader = fake_reader(data)
|
||||||
|
out: list[str] = []
|
||||||
|
for _ in range(times):
|
||||||
|
key = reader._parse_posix()
|
||||||
|
if key is None:
|
||||||
|
break
|
||||||
|
out.append(key)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def test_plain_characters_are_lowercased(self):
|
||||||
|
self.assertEqual(self.parse(b"a"), "a")
|
||||||
|
self.assertEqual(self.parse(b"A"), "a")
|
||||||
|
self.assertEqual(self.parse(b"7"), "7")
|
||||||
|
|
||||||
|
def test_control_characters(self):
|
||||||
|
self.assertEqual(self.parse(b"\x03"), keys.CTRL_C)
|
||||||
|
self.assertEqual(self.parse(b"\r"), keys.ENTER)
|
||||||
|
self.assertEqual(self.parse(b"\n"), keys.ENTER)
|
||||||
|
self.assertEqual(self.parse(b"\t"), keys.TAB)
|
||||||
|
self.assertEqual(self.parse(b"\x7f"), keys.BACKSPACE)
|
||||||
|
self.assertEqual(self.parse(b" "), keys.SPACE)
|
||||||
|
|
||||||
|
def test_arrow_keys(self):
|
||||||
|
self.assertEqual(self.parse(b"\x1b[A"), keys.UP)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[B"), keys.DOWN)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[C"), keys.RIGHT)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[D"), keys.LEFT)
|
||||||
|
|
||||||
|
def test_home_end_and_delete(self):
|
||||||
|
self.assertEqual(self.parse(b"\x1b[H"), keys.HOME)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[F"), keys.END)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[1~"), keys.HOME)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[3~"), keys.DELETE)
|
||||||
|
self.assertEqual(self.parse(b"\x1b[4~"), keys.END)
|
||||||
|
|
||||||
|
def test_application_mode_arrows(self):
|
||||||
|
"""有些终端把方向键发成 ESC O A 而不是 ESC [ A。"""
|
||||||
|
self.assertEqual(self.parse(b"\x1bOA"), keys.UP)
|
||||||
|
self.assertEqual(self.parse(b"\x1bOD"), keys.LEFT)
|
||||||
|
|
||||||
|
def test_lone_escape_is_the_escape_key(self):
|
||||||
|
self.assertEqual(self.parse(b"\x1b"), keys.ESC)
|
||||||
|
|
||||||
|
def test_escape_followed_by_a_plain_character(self):
|
||||||
|
reader = fake_reader(b"\x1bX")
|
||||||
|
self.assertEqual(reader._parse_posix(), keys.ESC)
|
||||||
|
self.assertEqual(reader._raw, b"X", "多出来的字符要留给下一轮,不能吞掉")
|
||||||
|
|
||||||
|
def test_unknown_sequence_is_dropped_not_guessed(self):
|
||||||
|
"""认不出来的功能键宁可丢掉,也不能退化成一个错误的动作。"""
|
||||||
|
self.assertIsNone(self.parse(b"\x1b[99~"))
|
||||||
|
|
||||||
|
def test_utf8_character_is_decoded_as_a_whole(self):
|
||||||
|
self.assertEqual(self.parse("中".encode()), "中")
|
||||||
|
|
||||||
|
def test_half_a_character_waits_for_the_rest(self):
|
||||||
|
reader = fake_reader("中".encode()[:2])
|
||||||
|
self.assertIsNone(reader._parse_posix())
|
||||||
|
self.assertEqual(len(reader._raw), 2, "半个字符要留在缓冲区里等下一轮")
|
||||||
|
reader._raw += "中".encode()[2:]
|
||||||
|
self.assertEqual(reader._parse_posix(), "中")
|
||||||
|
|
||||||
|
def test_several_keys_arriving_at_once_are_returned_one_by_one(self):
|
||||||
|
"""老实现只取第一个字节、把其余静默丢掉,快打或粘贴就会丢键。"""
|
||||||
|
self.assertEqual(self.drain(b"abc"), ["a", "b", "c"])
|
||||||
|
|
||||||
|
def test_mixed_burst_keeps_order(self):
|
||||||
|
self.assertEqual(self.drain(b"a\x1b[B"), ["a", keys.DOWN])
|
||||||
|
|
||||||
|
def test_backspace_then_letter(self):
|
||||||
|
self.assertEqual(self.drain(b"\x7fp"), [keys.BACKSPACE, "p"])
|
||||||
|
|
||||||
|
def test_incomplete_sequence_falls_back_to_escape(self):
|
||||||
|
"""序列读不全又不能干等:当作单按 ESC,总比把按键吞掉好。"""
|
||||||
|
reader = fake_reader(b"\x1b[")
|
||||||
|
self.assertEqual(reader._parse_posix(), keys.ESC)
|
||||||
|
reader._raw = b"\x1b[A"
|
||||||
|
self.assertEqual(reader._parse_posix(), keys.UP)
|
||||||
|
|
||||||
|
|
||||||
|
class TextLayoutTestCase(unittest.TestCase):
|
||||||
|
"""渲染用的纯函数:宽度算错的话,边框在中文环境下就会歪。"""
|
||||||
|
|
||||||
|
def test_ansi_codes_do_not_count_as_width(self):
|
||||||
|
self.assertEqual(display_width("\x1b[1mhi\x1b[0m"), 2)
|
||||||
|
|
||||||
|
def test_cjk_characters_are_double_width(self):
|
||||||
|
self.assertEqual(display_width("中文"), 4)
|
||||||
|
self.assertEqual(display_width("a中"), 3)
|
||||||
|
|
||||||
|
def test_strip_ansi_removes_color_codes(self):
|
||||||
|
self.assertEqual(strip_ansi("\x1b[31m红\x1b[0m"), "红")
|
||||||
|
self.assertIsNone(ANSI_RE.search(strip_ansi("\x1b[31m红\x1b[0m")))
|
||||||
|
|
||||||
|
def test_pad_aligns_by_display_width(self):
|
||||||
|
for align in ("left", "right", "center"):
|
||||||
|
with self.subTest(align=align):
|
||||||
|
self.assertEqual(display_width(pad("中文", 10, align)), 10)
|
||||||
|
|
||||||
|
def test_pad_truncates_overlong_content(self):
|
||||||
|
self.assertEqual(display_width(pad("非常长的一段中文内容", 6)), 6)
|
||||||
|
|
||||||
|
def test_truncate_adds_an_ellipsis(self):
|
||||||
|
text = truncate("非常长的一段中文内容", 7)
|
||||||
|
self.assertLessEqual(display_width(text), 7)
|
||||||
|
self.assertTrue(text.endswith("…"))
|
||||||
|
|
||||||
|
def test_truncate_keeps_short_text_intact(self):
|
||||||
|
self.assertEqual(truncate("短的", 10), "短的")
|
||||||
|
|
||||||
|
def test_bar_renders_filled_and_empty_cells(self):
|
||||||
|
self.assertEqual(bar(0, 10, 10), "░" * 10)
|
||||||
|
self.assertEqual(bar(10, 10, 10), "█" * 10)
|
||||||
|
self.assertEqual(len(bar(5, 10, 10)), 10)
|
||||||
|
self.assertEqual(len(bar(1, 0, 4)), 4, "分母为 0 也不能崩")
|
||||||
|
|
||||||
|
def test_box_lines_are_exactly_the_requested_width(self):
|
||||||
|
lines = draw_box("标题", ["内容", "另一行"], 30, use_color=False)
|
||||||
|
for line in lines:
|
||||||
|
with self.subTest(line=line):
|
||||||
|
self.assertEqual(display_width(line), 30)
|
||||||
|
|
||||||
|
def test_hstack_pads_the_shorter_column(self):
|
||||||
|
merged = hstack(["a", "b"], ["1"])
|
||||||
|
self.assertEqual(len(merged), 2)
|
||||||
|
self.assertTrue(merged[1].startswith("b"))
|
||||||
|
|
||||||
|
|
||||||
|
class TuiNamedKeyBehaviourTestCase(unittest.TestCase):
|
||||||
|
"""行为层:真的把键交给 TuiApp,看它有没有动。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.app = make_app()
|
||||||
|
|
||||||
|
def test_arrow_keys_move_the_plot_cursor(self):
|
||||||
|
self.app.grid_cols = 3
|
||||||
|
self.assertEqual(self.app.plot_cursor, 0)
|
||||||
|
|
||||||
|
self.app._handle_key(keys.RIGHT)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 1)
|
||||||
|
self.app._handle_key(keys.DOWN)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 4)
|
||||||
|
self.app._handle_key(keys.UP)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 1)
|
||||||
|
self.app._handle_key(keys.LEFT)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 0)
|
||||||
|
|
||||||
|
def test_cursor_cannot_leave_the_grid(self):
|
||||||
|
self.app.grid_cols = 3
|
||||||
|
self.app._handle_key(keys.LEFT)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 0)
|
||||||
|
self.app._handle_key(keys.UP)
|
||||||
|
self.assertEqual(self.app.plot_cursor, 0)
|
||||||
|
|
||||||
|
def test_space_toggles_time(self):
|
||||||
|
self.assertFalse(self.app.engine.paused)
|
||||||
|
self.app._handle_key(keys.SPACE)
|
||||||
|
self.assertTrue(self.app.engine.paused)
|
||||||
|
self.app._handle_key(keys.SPACE)
|
||||||
|
self.assertFalse(self.app.engine.paused)
|
||||||
|
|
||||||
|
def test_tab_cycles_through_animals(self):
|
||||||
|
self.app._handle_key(keys.TAB)
|
||||||
|
self.assertIn("还没有动物", self.app.message)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
self.app.engine.buy_animal("chicken")
|
||||||
|
self.app._handle_key(keys.TAB)
|
||||||
|
self.assertEqual(self.app.animal_cursor, 1)
|
||||||
|
self.app._handle_key(keys.TAB)
|
||||||
|
self.assertEqual(self.app.animal_cursor, 0, "应当循环回第一只")
|
||||||
|
|
||||||
|
def test_ctrl_c_quits(self):
|
||||||
|
self.assertTrue(self.app.running)
|
||||||
|
self.app._handle_key(keys.CTRL_C)
|
||||||
|
self.assertFalse(self.app.running)
|
||||||
|
|
||||||
|
def test_every_bound_named_key_does_something(self):
|
||||||
|
"""这是那个 bug 的正面护栏:逐个具名键驱动真实 TuiApp。"""
|
||||||
|
for key in NAMED_KEYS_WITH_MAIN_BINDINGS:
|
||||||
|
with self.subTest(key=key):
|
||||||
|
app = make_app()
|
||||||
|
app.engine.buy_animal("chicken")
|
||||||
|
app.engine.buy_animal("chicken")
|
||||||
|
app.grid_cols = 3
|
||||||
|
app.plot_cursor = 4 # 摆在中间,上下左右才都动得了
|
||||||
|
before = (
|
||||||
|
app.running,
|
||||||
|
app.plot_cursor,
|
||||||
|
app.animal_cursor,
|
||||||
|
app.mode,
|
||||||
|
app.engine.paused,
|
||||||
|
)
|
||||||
|
app._handle_key(key)
|
||||||
|
after = (
|
||||||
|
app.running,
|
||||||
|
app.plot_cursor,
|
||||||
|
app.animal_cursor,
|
||||||
|
app.mode,
|
||||||
|
app.engine.paused,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(before, after, f"按键 {key!r} 没有任何反应")
|
||||||
|
|
||||||
|
def test_unbound_named_keys_are_ignored_without_crashing(self):
|
||||||
|
for key in (keys.ENTER, keys.HOME, keys.END, keys.DELETE, keys.BACKSPACE):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
app = make_app()
|
||||||
|
app._handle_key(key)
|
||||||
|
self.assertEqual(app.plot_cursor, 0)
|
||||||
|
self.assertTrue(app.running)
|
||||||
|
|
||||||
|
def test_unknown_key_is_ignored(self):
|
||||||
|
self.app._handle_key("zzz")
|
||||||
|
self.assertEqual(self.app.plot_cursor, 0)
|
||||||
|
self.assertTrue(self.app.running)
|
||||||
|
|
||||||
|
|
||||||
|
class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = make_app()
|
||||||
|
self.engine = self.app.engine
|
||||||
|
|
||||||
|
def test_p_opens_the_crop_menu_and_a_digit_plants(self):
|
||||||
|
self.app._handle_key("p")
|
||||||
|
self.assertEqual(self.app.mode, "select_crop")
|
||||||
|
self.assertTrue(self.app.menu)
|
||||||
|
self.app._handle_key("1")
|
||||||
|
self.assertEqual(self.app.mode, "main")
|
||||||
|
self.assertIsNotNone(self.engine.state.plots[0].crop)
|
||||||
|
|
||||||
|
def test_escape_closes_the_menu(self):
|
||||||
|
self.app._handle_key("p")
|
||||||
|
self.app._handle_key(keys.ESC)
|
||||||
|
self.assertEqual(self.app.mode, "main")
|
||||||
|
self.assertIsNone(self.engine.state.plots[0].crop)
|
||||||
|
|
||||||
|
def test_menu_rejects_a_locked_choice(self):
|
||||||
|
self.engine.state.level = 1
|
||||||
|
self.app._handle_key("p")
|
||||||
|
locked_index = next(
|
||||||
|
i for i, item in enumerate(self.app.menu, start=1) if item["key"] == "strawberry"
|
||||||
|
)
|
||||||
|
self.app._handle_key(str(locked_index))
|
||||||
|
self.assertIsNone(self.engine.state.plots[0].crop)
|
||||||
|
self.assertIn("尚未解锁", self.app.message)
|
||||||
|
|
||||||
|
def test_menu_ignores_out_of_range_choices(self):
|
||||||
|
self.app._handle_key("p")
|
||||||
|
self.app._handle_key("0")
|
||||||
|
self.assertIn("没有这个选项", self.app.message)
|
||||||
|
self.app._handle_key("9")
|
||||||
|
self.assertIn("没有这个选项", self.app.message)
|
||||||
|
|
||||||
|
def test_b_buys_an_animal_through_the_menu(self):
|
||||||
|
self.app._handle_key("b")
|
||||||
|
self.assertEqual(self.app.mode, "select_animal")
|
||||||
|
self.app._handle_key("1")
|
||||||
|
self.assertEqual(len(self.engine.state.animals), 1)
|
||||||
|
|
||||||
|
def test_w_waters_and_h_harvests_the_selected_plot(self):
|
||||||
|
self.engine.plant(1, "wheat")
|
||||||
|
self.engine.state.plots[0].moisture = 0
|
||||||
|
self.app._handle_key("w")
|
||||||
|
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
|
||||||
|
|
||||||
|
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
|
||||||
|
gold = self.engine.state.gold
|
||||||
|
self.app._handle_key("h")
|
||||||
|
self.assertGreater(self.engine.state.gold, gold)
|
||||||
|
|
||||||
|
def test_e_harvests_everything_ripe(self):
|
||||||
|
for number in (1, 2, 3):
|
||||||
|
self.engine.plant(number, "wheat")
|
||||||
|
for index in (0, 1):
|
||||||
|
self.engine.state.plots[index].growth = 2
|
||||||
|
self.app._handle_key("e")
|
||||||
|
self.assertTrue(self.engine.state.plots[0].is_empty)
|
||||||
|
self.assertTrue(self.engine.state.plots[1].is_empty)
|
||||||
|
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的留着")
|
||||||
|
|
||||||
|
def test_e_without_anything_ripe_says_so(self):
|
||||||
|
self.app._handle_key("e")
|
||||||
|
self.assertIn("没有成熟", self.app.message)
|
||||||
|
|
||||||
|
def test_f_feeds_the_focused_animal(self):
|
||||||
|
self.engine.buy_animal("chicken")
|
||||||
|
self.app._handle_key("f")
|
||||||
|
self.assertTrue(self.engine.state.animals[0].fed)
|
||||||
|
|
||||||
|
def test_c_collects_the_focused_animal(self):
|
||||||
|
self.engine.buy_animal("chicken")
|
||||||
|
self.engine.state.animals[0].pending = 2
|
||||||
|
gold = self.engine.state.gold
|
||||||
|
self.app._handle_key("c")
|
||||||
|
self.assertGreater(self.engine.state.gold, gold)
|
||||||
|
|
||||||
|
def test_l_buys_land_and_k_buys_feed(self):
|
||||||
|
self.engine.state.level = 2
|
||||||
|
plots = len(self.engine.state.plots)
|
||||||
|
self.app._handle_key("l")
|
||||||
|
self.assertEqual(len(self.engine.state.plots), plots + 1)
|
||||||
|
|
||||||
|
feed = self.engine.state.feed
|
||||||
|
self.app._handle_key("k")
|
||||||
|
self.assertEqual(self.engine.state.feed, feed + config.FEED_BULK)
|
||||||
|
|
||||||
|
def test_s_saves_the_game(self):
|
||||||
|
self.app.autosave = False
|
||||||
|
self.app._handle_key("s")
|
||||||
|
self.assertTrue(os.path.exists(self.app.save_path))
|
||||||
|
|
||||||
|
def test_r_asks_before_resetting(self):
|
||||||
|
self.engine.state.day = 5
|
||||||
|
self.app._handle_key("r")
|
||||||
|
self.assertEqual(self.app.mode, "confirm_reset")
|
||||||
|
|
||||||
|
self.app._handle_key("n")
|
||||||
|
self.assertEqual(self.app.mode, "main")
|
||||||
|
self.assertEqual(self.engine.state.day, 5, "没确认就不该重置")
|
||||||
|
|
||||||
|
def test_r_then_y_resets(self):
|
||||||
|
self.engine.state.day = 5
|
||||||
|
self.app._handle_key("r")
|
||||||
|
self.app._handle_key("y")
|
||||||
|
self.assertEqual(self.engine.state.day, 1)
|
||||||
|
|
||||||
|
def test_question_mark_opens_help_and_any_key_closes_it(self):
|
||||||
|
self.app._handle_key("?")
|
||||||
|
self.assertEqual(self.app.mode, "help")
|
||||||
|
self.app._handle_key("x")
|
||||||
|
self.assertEqual(self.app.mode, "main")
|
||||||
|
|
||||||
|
def test_q_quits(self):
|
||||||
|
self.app._handle_key("q")
|
||||||
|
self.assertFalse(self.app.running)
|
||||||
|
|
||||||
|
def test_failed_action_shows_the_reason_instead_of_crashing(self):
|
||||||
|
self.app._handle_key("w") # 空地浇水
|
||||||
|
self.assertIn("空地", self.app.message)
|
||||||
|
self.assertTrue(self.app.running)
|
||||||
|
|
||||||
|
|
||||||
|
class TuiRenderTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = make_app()
|
||||||
|
# TuiApp 自己不驱动时钟,倒计时要靠 next_tick_at 才有值
|
||||||
|
self.app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
|
||||||
|
|
||||||
|
def capture(self) -> str:
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
self.app._render()
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def test_first_frame_contains_every_panel(self):
|
||||||
|
output = self.render_plain()
|
||||||
|
for label in ("SimpleFarm", "农田", "牧场", "市场行情", "农事日志"):
|
||||||
|
with self.subTest(panel=label):
|
||||||
|
self.assertIn(label, output)
|
||||||
|
|
||||||
|
def render_plain(self) -> str:
|
||||||
|
return strip_ansi(self.capture())
|
||||||
|
|
||||||
|
def test_frame_fits_the_terminal_width(self):
|
||||||
|
output = strip_ansi(self.capture())
|
||||||
|
for line in output.splitlines():
|
||||||
|
with self.subTest(line=line[:20]):
|
||||||
|
self.assertLessEqual(display_width(line.rstrip("\r")), 200)
|
||||||
|
|
||||||
|
def test_countdown_uses_the_human_format(self):
|
||||||
|
output = self.render_plain()
|
||||||
|
self.assertNotIn("s \n", output)
|
||||||
|
self.assertRegex(output, r"\d+:\d{2}")
|
||||||
|
|
||||||
|
def test_paused_state_is_visible(self):
|
||||||
|
self.app.engine.pause(True)
|
||||||
|
self.assertIn("已暂停", self.render_plain())
|
||||||
|
|
||||||
|
def test_crop_state_is_visible(self):
|
||||||
|
self.app.engine.plant(1, "wheat")
|
||||||
|
self.assertIn("小麦", self.render_plain())
|
||||||
|
|
||||||
|
def test_market_panel_uses_the_china_convention(self):
|
||||||
|
"""涨红跌绿是中国习惯,别跟着欧美反过来。"""
|
||||||
|
tui_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "farm", "tui.py"
|
||||||
|
)
|
||||||
|
with open(tui_path, encoding="utf-8") as handle:
|
||||||
|
source = handle.read()
|
||||||
|
self.assertIn('trend, tone = "涨", C_RED', source)
|
||||||
|
self.assertIn('trend, tone = "跌", C_GREEN', source)
|
||||||
|
|
||||||
|
def test_help_page_is_current(self):
|
||||||
|
self.app._handle_key("?")
|
||||||
|
output = self.render_plain()
|
||||||
|
self.assertIn("分钟", output)
|
||||||
|
self.assertNotIn("[N]休息", output)
|
||||||
|
self.assertNotIn("15 秒", output)
|
||||||
|
|
||||||
|
def test_keyboard_notice_appears_when_input_is_unavailable(self):
|
||||||
|
self.app.reader.available = False
|
||||||
|
self.app.reader.reason = "标准输入不是终端(管道或重定向)"
|
||||||
|
self.app.show_keyboard_notice = True
|
||||||
|
self.assertIn("读不到按键", self.render_plain())
|
||||||
|
|
||||||
|
def test_keyboard_notice_can_be_suppressed_for_previews(self):
|
||||||
|
self.app.reader.available = False
|
||||||
|
self.app.show_keyboard_notice = False
|
||||||
|
output = self.render_plain()
|
||||||
|
self.assertNotIn("读不到按键", output)
|
||||||
|
self.assertIn("↑↓←→", output)
|
||||||
|
|
||||||
|
def test_menu_frame_renders_choices(self):
|
||||||
|
self.app._handle_key("p")
|
||||||
|
output = self.render_plain()
|
||||||
|
self.assertIn("选择要种在", output)
|
||||||
|
self.assertIn("小麦", output)
|
||||||
|
|
||||||
|
def test_auto_save_only_runs_when_enabled(self):
|
||||||
|
app = make_app(autosave=True)
|
||||||
|
app._exit()
|
||||||
|
self.assertTrue(os.path.exists(app.save_path))
|
||||||
|
|
||||||
|
|
||||||
|
class TuiSnapshotCompatibilityTestCase(unittest.TestCase):
|
||||||
|
"""TUI 要能同时吃本地引擎和远程客户端——两者的快照字段必须对齐。"""
|
||||||
|
|
||||||
|
def test_render_works_with_a_remote_shaped_snapshot(self):
|
||||||
|
from farm.client import StateView
|
||||||
|
|
||||||
|
app = make_app()
|
||||||
|
view = StateView(app.engine.snapshot())
|
||||||
|
app.engine = view # 用一个"长得像引擎"的对象
|
||||||
|
lines = app._render_status(view.to_dict(), 100)
|
||||||
|
rendered = strip_ansi("\n".join(lines))
|
||||||
|
self.assertIn("金币", rendered)
|
||||||
|
|
||||||
|
def test_log_entries_are_rendered(self):
|
||||||
|
app = make_app()
|
||||||
|
app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
|
||||||
|
app.engine.add_log("测试事件", "good")
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
app._render()
|
||||||
|
self.assertIn("测试事件", strip_ansi(buffer.getvalue()))
|
||||||
|
|
||||||
|
def test_recent_messages_expire(self):
|
||||||
|
app = make_app()
|
||||||
|
app._flash("一闪而过", seconds=0.0)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
app._render()
|
||||||
|
self.assertNotIn("一闪而过", strip_ansi(buffer.getvalue()))
|
||||||
|
|
||||||
|
def test_json_round_trip_of_the_snapshot_is_renderable(self):
|
||||||
|
"""远程客户端的快照是过了一遍 JSON 的,字段类型可能变。"""
|
||||||
|
self.app = make_app()
|
||||||
|
raw = json.loads(json.dumps(self.app.engine.snapshot(), ensure_ascii=False))
|
||||||
|
self.app._render_status(raw, 100)
|
||||||
|
self.app._render_farm(raw, 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
+345
@@ -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())
|
||||||
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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.engine import FarmEngine
|
||||||
from farm.tui import TuiApp
|
from farm.tui import TuiApp
|
||||||
|
|
||||||
@@ -44,13 +45,15 @@ def build_remote(url: str, farm: str, token: str | None) -> tuple[RemoteFarm, Tu
|
|||||||
app = TuiApp(
|
app = TuiApp(
|
||||||
client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}"
|
client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}"
|
||||||
)
|
)
|
||||||
|
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
|
||||||
return client, app
|
return client, app
|
||||||
|
|
||||||
|
|
||||||
def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]:
|
def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]:
|
||||||
random.seed(42)
|
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 = TuiApp(engine, use_color=True, autosave=False)
|
||||||
|
app.show_keyboard_notice = False # 定格画面:按键提示要照常画出来
|
||||||
|
|
||||||
if scene == "empty":
|
if scene == "empty":
|
||||||
return engine, app
|
return engine, app
|
||||||
@@ -128,7 +131,8 @@ def main() -> int:
|
|||||||
if engine.next_tick_at is None and not engine.paused:
|
if engine.next_tick_at is None and not engine.paused:
|
||||||
import time as _time
|
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
|
app.use_color = args.color
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.20"
|
version = "3.20"
|
||||||
@@ -438,11 +401,11 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "zttp" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@@ -450,12 +413,12 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.110" },
|
{ 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]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
|
||||||
{ name = "pyright", specifier = ">=1.1.390" },
|
{ name = "pyright", specifier = ">=1.1.390" },
|
||||||
{ name = "ruff", specifier = ">=0.8.0" },
|
{ 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/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" },
|
{ 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" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user