From a7725f8812a619c22db341328e3da79e1315a921 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:12:40 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=B7=A5=E7=A8=8B=E5=8C=96=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E5=89=8D=E7=9A=84=E5=9F=BA=E7=BA=BF=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前, 先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。 此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。 --- .gitignore | 35 + .python-version | 1 + .workbuddy/memory/2026-09-18.md | 49 + .workbuddy/memory/MEMORY.md | 38 + README.md | 424 +++++++ _ruff1.txt | 2078 +++++++++++++++++++++++++++++++ _sync.log | 33 + _uvlock.log | 294 +++++ ai_player.py | 78 ++ farm/__init__.py | 20 + farm/bot.py | 221 ++++ farm/client.py | 721 +++++++++++ farm/config.py | 172 +++ farm/engine.py | 743 +++++++++++ farm/keys.py | 142 +++ farm/models.py | 324 +++++ farm/net.py | 63 + farm/server/__init__.py | 60 + farm/server/__main__.py | 164 +++ farm/server/actions.py | 240 ++++ farm/server/app.py | 688 ++++++++++ farm/server/daemon.py | 351 ++++++ farm/server/hub.py | 250 ++++ farm/server/session.py | 309 +++++ farm/server/webui.py | 455 +++++++ farm/storage.py | 129 ++ farm/tui.py | 784 ++++++++++++ main.py | 627 ++++++++++ pyproject.toml | 113 ++ requirements.txt | 15 + tests/test_engine.py | 458 +++++++ tests/test_server.py | 596 +++++++++ tools/preview_tui.py | 139 +++ tools/ws_demo.py | 99 ++ uv.lock | 958 ++++++++++++++ 35 files changed, 11871 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .workbuddy/memory/2026-09-18.md create mode 100644 .workbuddy/memory/MEMORY.md create mode 100644 README.md create mode 100644 _ruff1.txt create mode 100644 _sync.log create mode 100644 _uvlock.log create mode 100644 ai_player.py create mode 100644 farm/__init__.py create mode 100644 farm/bot.py create mode 100644 farm/client.py create mode 100644 farm/config.py create mode 100644 farm/engine.py create mode 100644 farm/keys.py create mode 100644 farm/models.py create mode 100644 farm/net.py create mode 100644 farm/server/__init__.py create mode 100644 farm/server/__main__.py create mode 100644 farm/server/actions.py create mode 100644 farm/server/app.py create mode 100644 farm/server/daemon.py create mode 100644 farm/server/hub.py create mode 100644 farm/server/session.py create mode 100644 farm/server/webui.py create mode 100644 farm/storage.py create mode 100644 farm/tui.py create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/test_engine.py create mode 100644 tests/test_server.py create mode 100644 tools/preview_tui.py create mode 100644 tools/ws_demo.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9e6b59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# ------------------------------------------------------------------ Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +build/ +dist/ + +# ------------------------------------------------------- uv / 虚拟环境 +# uv.lock 要**提交**:它是可复现构建的依据,不是缓存。 +.venv/ +venv/ + +# ------------------------------------------------------------- 工具缓存 +# 这些是纯本地缓存,换个机器重新生成即可,提交上去只会制造冲突。 +.ruff_cache/ +.pyright/ +.mypy_cache/ +.pytest_cache/ + +# ------------------------------------------------- SimpleFarm 运行期数据 +# run/ :守护进程的 pid 与日志(换台机器、换个端口就失效) +# saves/ :玩家存档(真实游戏进度,属于本地数据,不该进版本库) +run/ +saves/ +tests/_tmp/ +_uvpy.txt + +# --------------------------------------------------------- 编辑器 / 系统 +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db +desktop.ini diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.workbuddy/memory/2026-09-18.md b/.workbuddy/memory/2026-09-18.md new file mode 100644 index 0000000..a28bd76 --- /dev/null +++ b/.workbuddy/memory/2026-09-18.md @@ -0,0 +1,49 @@ +# 2026-09-18 · SimpleFarm 从单机 TUI 改造为小型多人游戏服务器 + +## 需求 +用户先要求"CLI/TUI 农场游戏 + HTTP/gRPC 接口让 AI 参与";中途改方向: +**放弃 gRPC,改用 FastAPI/aiohttp**,要求:守护进程可后台运行、SSE 或 WS 二选一、 +TUI 只在用户玩时作为客户端、用户也能用 HTTP 调用、支持多用户(小型游戏服务器)。 + +## 最终架构(v2.0) +- 服务端 `farm/server/`:`app.py`(FastAPI REST+WS) / `actions.py`(动作表) / + `session.py`(多牧场会话 + 单一 FarmClock 统一时钟) / `hub.py`(WS 连接池 + 250ms 节流推送) / + `daemon.py`(守护进程) / `webui.py`(内嵌网页客户端)。 +- 客户端 `farm/client.py` 的 `RemoteFarm`:与 `FarmEngine` **接口同构**, + 所以 `tui.py` 不用改渲染逻辑就能从"本地引擎"切到"远程服务器"。 + 写操作优先走 WS(带 id 回执),通道类错误自动退回 HTTP;读数靠 WS 推送。 +- `farm/bot.py` 规则机器人 + `ai_player.py` 外挂机器人:只调接口,不 import 游戏规则。 +- 统一入口 `main.py`:serve/stop/status/logs/play/solo/do/batch/actions/state/log/watch/bot/farms/help。 + +## 关键决策 +- **选 WebSocket 而非 SSE**:这个游戏动作上行和状态下行是同一件业务的两半, + SSE 只能下行,等于要维护两套鉴权/重连。 +- **删掉 gRPC**(`farm/api.py` 旧 stdlib HTTP 层、`farm/grpc_server.py`、`proto/`、`tools/gen_proto.py`): + 接口主要喂 LLM/脚本,它们只会捏 HTTP+JSON。 +- 多牧场模型:一个牧场 = 一个玩家 = 一份存档 + 一个引擎;空闲 30 分钟先落盘再移出内存。 +- 动作表 `server/actions.py` 是所有入口(HTTP / WS / CLI / 机器人)的唯一执行通道,参数校验只写一次。 + +## 踩过的坑(重要,可复用) +1. **本地请求被系统代理截走**:机器上设了 `HTTP_PROXY` 但没有 `no_proxy`, + `urllib` 把发给 127.0.0.1 的请求也交给代理,本机服务器报 `502 upstream connect failed`。 + 解决:`farm/net.py` 的 `open_url()`,回环地址走 `ProxyHandler({})` 直连。 +2. **pid 归属**:起初父进程把 `Popen().pid` 写进 pid 文件,但 Windows 上 venv 的 + `python.exe` 可能是转发器,`os.getpid()`(40564)≠ 父进程看到的 pid(20244),`stop` 会杀错进程。 + 解决:`--pid-file` 传给子进程,子进程在 lifespan 启动(端口已绑定)后写自己的 pid,退出时删除; + `stop` 优先走 HTTP `/api/admin/shutdown` 优雅停机(能落盘),拿不到 pid 也能停。 +3. **精简状态会清空视图**:动作回执里的 `state` 是精简版(无 `plots`/`animals`), + 客户端若整体替换就会把农田格子清空。解决:`StateView.apply()` 按"含 plots 即整体替换, + 否则合并字段",并且 HTTP 动作带 `full=1` / WS 回执带 `snapshot` 让客户端立刻刷新。 +4. **`save` 动作必须由会话处理**:`engine.save()` 会写默认路径,多牧场下会互相覆盖; + 在 `app.execute_action()` 里把 save/reset 拦截为会话级操作。 +5. 本机 Bash 的 coreutils 坏了(`ls`/`head`/`tail`/`grep` 都不存在), + 所有查看/清理动作改用 Python 脚本完成;管道会直接让命令失败,**不要用管道**。 + +## 验证 +- 97 项测试全绿(引擎 45 + 服务器 52),含真 uvicorn + 真客户端 + 真 WebSocket 端到端。 +- 实测:daemon 启动 → status 正确 pid → actions/do/batch → 两牧场隔离与排行榜 → + WS 收到 welcome/state/result/day → 机器人 120→197 金币 → stop 优雅停机落盘。 +- TUI 远程渲染验证:`tools/preview_tui.py --url http://127.0.0.1:8793 --farm alice` + (112 列 / 76 列都正常,头部显示 `牧场名 @ 服务器`)。 +- 依赖装在隔离 venv:`C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe` + (fastapi 0.141.1 / uvicorn 0.53 / websockets 17.1 / httpx 0.28)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..0fc7399 --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -0,0 +1,38 @@ +# SimpleFarm 项目长期约定 + +## 是什么 +终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里, +TUI 是纯客户端,同时开放 HTTP + WebSocket 接口给 AI/脚本调用(服务端不内置 AI)。 + +## 运行方式 +- 依赖放隔离 venv:`C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe` + (服务端需要 fastapi + uvicorn[standard] + websockets;客户端与 TUI 只用标准库) +- 服务端:`python main.py serve --daemon`,网页 ,文档 /docs +- 玩:`python main.py play --farm 名字`;单机:`python main.py solo` +- AI/脚本入口:`main.py actions | do <动作> '' | batch | state --json | watch | bot` +- 测试:`python -m unittest discover -s tests`(97 项;不需要 FastAPI 时只跑 test_engine.py) + +## 架构约定(改动时必须遵守) +- **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket; + `farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。 +- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义, + 这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。 +- **多牧场**:一个 farm = 一个玩家 = 一份 `saves/<名字>.json` + 一个引擎 + 独立时钟; + 时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。 +- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理, + 不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。 +- **实时通道**:WebSocket(`/ws`)是唯一实时通道,推送节流 250ms,消息带自增 `seq`。 + 写操作优先走 WS(带 `id` 回执与 `snapshot`),只有 ws_closed/timeout/network + 才退回 HTTP 重试;**业务错误必须原样抛出,不能重试**。 + +## 环境禁忌(本机特有) +- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep` 缺失),**不要用管道**,会整条命令失败; + 查看/删除文件一律写 Python 脚本。 +- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的 + `open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。 +- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`), + 父进程的 `Popen().pid` 不可信。 + +## 相关文档 +- 项目入口文档:`README.md`(接口表、WS 协议、边界处理、设计取舍全在里面) +- 当天工作记录:`.workbuddy/memory/2026-09-18.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000..6573dce --- /dev/null +++ b/README.md @@ -0,0 +1,424 @@ +# SimpleFarm · 小型农场游戏服务器 + +一个可以在终端里玩的农场经营游戏,同时也是一个**小型多人游戏服务器**: + +* 游戏逻辑跑在**服务端进程**里,可以后台常驻; +* 玩家在**终端界面(TUI)**里玩自己的牧场,也可以直接开浏览器玩; +* 每个玩家一个独立牧场(存档隔离),互相能看到排行榜; +* 同时开放 **HTTP 接口 + WebSocket 实时通道**,AI / 脚本 / 机器人可以和人玩同一局游戏—— + 服务端**不内置任何 AI**,它只提供协议。 + +``` + ┌──────────────┐ HTTP /api/* ┌────────────────────────┐ + │ AI / 脚本 │ ───────────────────────▶ │ │ + │ 机器人 │ ◀─────────────────────── │ FastAPI 服务器 │ + └──────────────┘ JSON 响应 │ (可后台常驻) │ + ┌──────────────┐ WebSocket /ws │ │ + │ 终端界面 TUI │ ◀───────────────────────▶ │ FarmClock 统一时钟 │ + └──────────────┘ 状态推送 + 动作回执 │ SessionManager 多牧场 │ + ┌──────────────┐ WebSocket /ws │ ConnectionHub 推送 │ + │ 浏览器客户端 │ ◀───────────────────────▶ │ 存档(每牧场一份) │ + └──────────────┘ └────────────────────────┘ + │ + farm.engine 纯游戏规则 +``` + +--- + +## 1. 快速开始 + +```bash +pip install -r requirements.txt # 只有服务端需要第三方依赖 + +python main.py serve --daemon # ① 后台启动服务器 +python main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口) +python main.py bot --farm ai-1 # ③ 让机器人玩家一起经营 +python main.py status # ④ 看服务器状态 +python main.py stop # ⑤ 优雅停机(会把所有牧场存档) +``` + +浏览器打开 就是网页版客户端(可多开几个标签页,用不同牧场名, +能实时看到对方的操作)。 + +不想装任何第三方库、只想单机玩玩: + +```bash +python main.py solo # 本地引擎 + 终端界面,不开服务器 +``` + +--- + +## 2. 玩法 + +| 系统 | 规则 | +| --- | --- | +| **时间** | 每个牧场每 15 秒自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | +| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获和收取产出免费。用完只能等第二天,或主动 `sleep`(2 秒冷却) | +| **水分** | 作物每天消耗 1 点水分(上限 3),水分为 0 停止生长;播种自带 2 点 | +| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 | +| **牲畜** | 鸡 / 羊 / 牛,每天喂食才会累计产出周期;连续 3 天不喂会**永久离开** | +| **市场** | 作物与畜产品价格每天在基础价 ±18% 内波动,高位卖出更赚 | +| **土地** | 初始 6 块,每升 1 级 +2 块,上限 16 块,买地价格指数递增 | +| **目标** | 净资产(金币+土地+牲畜+库存+地里作物)做到 10000 金币,共 5 个里程碑 | +| **丰收** | 收获有 12% 概率触发双倍产出 | + +净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。 + +--- + +## 3. 终端界面 + +`python main.py play` 打开。核心交互: + +``` + [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 + [F]喂食 [C]收取 [Tab]换牲畜 [B]买动物 [L]买地 [K]买饲料 + [N]休息 [空格]暂停 [S]存档 [R]重置 [Q]退出 +``` + +界面用双宽字符感知的对齐算法(`display_width`),在 Windows Terminal / iTerm / VSCode 里边框都不会歪。 + +**TUI 是纯客户端**:它通过 WebSocket 订阅状态、通过 WebSocket/HTTP 提交动作, +自己不持有任何游戏状态。所以: + +* 服务端可以在另一台机器上,`python main.py play --url http://10.0.0.5:8765 --farm alice`; +* 关掉 TUI 不会影响服务器,你的牧场继续按时间生长; +* 你在 TUI 里操作的同时,AI 可以对同一个牧场操作,双方立刻能看到对方的结果。 + +--- + +## 4. 命令行 + +| 命令 | 作用 | +| --- | --- | +| `serve [--daemon]` | 启动服务器(前台 / 后台) | +| `stop [--force]` | 优雅停机:先让服务器落盘,再退出 | +| `status [--logs] [--json]` | 服务器状态 + 排行榜 + 日志尾部 | +| `logs -n 40` | 查看后台日志 | +| `play --farm NAME` | 终端界面(TUI) | +| `solo` | 离线单机(本地引擎,不开服务器) | +| `actions` | **列出当前所有合法动作**(AI 推荐入口) | +| `do <动作> ''` | 执行一个动作 | +| `batch ''` | 一个请求执行多个动作 | +| `state [--json]` | 查看状态(`--json` 为机器可读) | +| `log -n 20` | 最近事件 | +| `watch` | 实时滚动状态与日志(适合盯着 AI 玩) | +| `bot --farm ai-1` | 规则机器人替你经营一个牧场 | +| `farms` | 牧场排行榜 | +| `help` | 玩法与接口说明 | + +实例: + +```bash +$ python main.py actions +牧场 default · 第 3 天 · 金币 96 · 行动点 7/10 +共 9 个候选动作: + + [ 1] harvest {"plot": 2} (免费) + 2 号地的胡萝卜已成熟 + [ 2] plant {"plot": 4, "crop": "wheat"} (gold:5 ap:1) + 4 号地空闲,可种小麦 + ... + +$ python main.py do harvest '{"plot":2}' +✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s) + 第 3 天 · 金币 200 · 行动点 7/10 · 净资产 512.0 +``` + +--- + +## 5. 给 AI 的 HTTP 接口 + +所有接口都在 `/api/*`,响应统一是 `{"ok": bool, "message": str, ...}`; +失败时带机器可读的 `error`(如 `not_enough_gold` / `no_ap` / `not_ripe`)与 HTTP 状态码。 + +**多牧场**:用 `?farm=名字` 或请求头 `X-Farm-Id` 指定;不指定就是 `default`。 +牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。 + +### 5.1 最短决策闭环 + +``` +GET /api/actions?farm=me → 当前所有**合法**动作(含开销与 why) +POST /api/actions → {"action":"<上一步的 action>","params":{...}} + ← 回执里直接带执行后的状态,无需再查一次 +``` + +### 5.2 接口一览 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/health` | 健康检查(唯一不需要令牌的接口) | +| GET | `/api/help` | 规则 + 动作清单 + 参数类型 | +| GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 | +| GET | `/api/farms` | 牧场列表(按净资产排名) | +| POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 | +| GET | `/api/state` | 完整状态快照 | +| GET | `/api/catalog` | 作物 / 牲畜 / 实时价格 / 每单位收益 | +| GET | `/api/actions` | 当前合法动作(AI 推荐入口) | +| GET | `/api/log?limit=20&since=0` | 事件流(`since` 做增量拉取) | +| POST | `/api/actions` | **通用动作入口** | +| POST | `/api/batch` | 批量动作(一次最多 50 个) | +| POST | `/api/{动作名}` | 便捷入口:`/api/plant` `/api/harvest` … 等 13 个 | +| POST | `/api/admin/save` | 全部存档 | +| POST | `/api/admin/shutdown` | 优雅停机 | +| GET | `/docs` | 自动生成的 Swagger 文档 | + +动作清单(`GET /api/help` 里有完整参数表): + +``` +plant(plot,crop) water(plot) harvest(plot) harvest_all() +buy_animal(kind) feed(animal_id,quantity?) collect(animal_id) +buy_feed(quantity?) buy_plot() sleep() pause(paused?) save() reset() +``` + +### 5.3 请求示例 + +```bash +# 看现在能做什么 +curl 'http://127.0.0.1:8765/api/actions?farm=alice' + +# 播种 +curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \ + -H 'Content-Type: application/json' \ + -d '{"action":"plant","params":{"plot":1,"crop":"wheat"}}' + +# 一次干三件事(AI 省流) +curl -X POST 'http://127.0.0.1:8765/api/batch?farm=alice' \ + -H 'Content-Type: application/json' \ + -d '{"actions":[{"action":"harvest_all"}, + {"action":"buy_feed","params":{"quantity":10}}, + {"action":"plant","params":{"plot":1,"crop":"carrot"}}]}' + +# 要完整快照(默认只回精简状态) +curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice&full=1' ... +``` + +响应示例: + +```json +{ + "ok": true, + "action": "plant", + "message": "1 号地已种下小麦,2 天后成熟", + "farm": "alice", + "seq": 42, + "state": { + "day": 3, "gold": 91, "level": 1, "ap": 6, "feed": 5, + "ripe_plots": [], "hungry_animals": [], "collectable_animals": [], + "net_worth": 401.0, "seconds_to_tick": 11.4, "paused": false, "online": 2 + }, + "log": [{"seq": 41, "day": 3, "kind": "action", "text": "在 1 号地播下小麦种子"}] +} +``` + +> `state.ripe_plots` / `hungry_animals` / `collectable_animals` 是特意为 AI 准备的 +> "待办清单"字段:不用遍历整个快照就知道该干什么。 + +**鉴权**:服务器用 `--token` 启动后,所有接口都要带 `X-API-Token: ` 头或 `?token=`。 +没带令牌返回 `401 {"ok":false,"error":"unauthorized"}`。 + +--- + +## 6. WebSocket 实时通道 + +> 为什么是 WebSocket 而不是 SSE?SSE 只能服务端 → 客户端单向推流, +> 而这个游戏的上行(播种、喂食)和下行(状态、事件)是同一件业务的两半: +> 用 SSE 就得再开一条 HTTP 通道,等于两套鉴权、两套重连、两套错误处理。 +> WebSocket 一次握手双向跑,还能把动作回执直接顺着同一条连接回给调用方。 + +连接: + +``` +ws://127.0.0.1:8765/ws?farm=alice&token=<可选>&name=<昵称> +``` + +### 客户端 → 服务器 + +| 消息 | 说明 | +| --- | --- | +| `{"type":"action","id":1,"action":"plant","params":{"plot":1,"crop":"wheat"}}` | 执行动作,回执带同一个 `id` | +| `{"type":"sync","id":2}` | 主动拉一次完整状态 + 合法动作 | +| `{"type":"help","id":3}` | 拉规则与动作说明 | +| `{"type":"ping"}` | 心跳,回 `pong` | + +### 服务器 → 客户端 + +| 消息 | 触发时机 | +| --- | --- | +| `{"type":"welcome", "state":…, "catalog":…, "actions":…, "log":…, "online":1}` | 连接建立 | +| `{"type":"state", "seq":…, "state":…, "log":[…]}` | 状态变化时推送(**节流合并到 4 次/秒**) | +| `{"type":"day", "day":5, "events":[…]}` | 进入新的一天 | +| `{"type":"result", "id":1, "ok":true, "message":…, "snapshot":…}` | 动作回执 | +| `{"type":"notice", "text":"web 来到了牧场(在线 2)"}` | 有人加入 / 离开 | +| `{"type":"error", "error":"bad_json", "message":…}` | 协议错误 | + +用 Python 直接玩(不依赖本项目代码): + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://127.0.0.1:8765/ws?farm=demo") as ws: + welcome = json.loads(await ws.recv()) + print("第", welcome["state"]["day"], "天") + await ws.send(json.dumps({"type": "action", "id": 1, "action": "harvest_all"})) + async for raw in ws: + msg = json.loads(raw) + if msg["type"] == "result": + print(msg["message"]) + elif msg["type"] == "state": + print("金币", msg["state"]["gold"]) + +asyncio.run(main()) +``` + +--- + +## 7. 多人模型 + +* **一个牧场 = 一个玩家**:独立引擎、独立时钟、独立存档(`saves/<牧场名>.json`)。 +* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。 +* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。 +* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。 +* 无人在线的牧场在 `--idle-timeout`(默认 30 分钟)后被移出内存,**先落盘再释放**; + 玩家回来时自动从存档恢复。 +* 上限 `--max-sessions`(默认 64),超出返回 `503 too_many_farms`。 + +局域网联机: + +```bash +python main.py serve --daemon --host 0.0.0.0 --port 8765 --token 找个密码 +python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个密码 +``` + +--- + +## 8. 守护进程 + +`serve --daemon` 之后: + +| 项目 | 位置 / 命令 | +| --- | --- | +| 进程号 | `run/server.pid`(**由服务进程自己写**,见下方说明) | +| 日志 | `run/server.log`(`python main.py logs -n 50`) | +| 停止 | `python main.py stop`(先走 HTTP 优雅停机,失败再发信号) | +| 状态 | `python main.py status` | +| 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) | +| 接口文档 | | + +实现上不依赖 systemd / nohup / 任何第三方库:POSIX 用新会话、Windows 用 `DETACHED_PROCESS`, +父进程退出后服务继续活着。自动存档每 60 秒一次,停机时再存一次。 + +> **为什么 pid 由子进程自己写?** 一开始是父进程把 `Popen().pid` 记进 pid 文件, +> 结果在 Windows 上踩了坑:venv 里的 `python.exe` 可能是个**转发器**, +> 父进程拿到的 pid 和真正在监听端口的进程不是同一个,`stop` 于是杀错了对象。 +> 现在改成:守护进程把 `--pid-file` 传给子进程,子进程在端口绑定成功(lifespan 启动)后 +> 才写入自己的 `os.getpid()`,退出时再删掉。`stop` 则优先走 HTTP 优雅停机, +> 拿不到 pid 也能停下来。 + +--- + +## 9. 代码结构 + +``` +main.py 统一入口:serve / play / do / bot / status ... +ai_player.py 外挂式机器人玩家(只用接口,不 import 任何游戏规则) +farm/ + config.py 数值与静态表(作物、牲畜、经济、时间) + models.py Plot / Animal / GameState —— 只持有数据 + engine.py FarmEngine / Scheduler —— 纯游戏规则(不依赖网络与终端) + storage.py 存档读写(临时文件 + 原子替换) + keys.py 跨平台按键读取 + net.py 回环请求绕过系统代理的小工具 + tui.py 终端界面(自适应宽度、双宽字符对齐,与引擎解耦) + client.py RemoteFarm —— 把远程服务器伪装成本地引擎(HTTP + WS) + bot.py 规则机器人:给候选动作打分(可整体替换为 LLM 策略) + server/ + app.py FastAPI 应用:REST + WebSocket + 多牧场调度 + actions.py 动作表:所有入口共用的唯一执行通道 + 参数校验 + session.py 牧场会话与统一时钟 + hub.py WebSocket 连接池与状态推送(线程 → 事件循环的桥) + daemon.py 守护进程:start / stop / status / 日志 / pid + webui.py 内嵌网页客户端(单页,原生 JS + WebSocket) + __main__.py python -m farm.server(前台运行) +tools/ + preview_tui.py 把 TUI 渲染成文本(--url 可连真实服务器) + ws_demo.py 最小 WebSocket 客户端,验证协议 +tests/ + test_engine.py 引擎规则与存档(单机层) + test_server.py 动作表 / REST / 鉴权 / 多牧场隔离 / WebSocket 端到端 +``` + +分层原则:**规则不认识网络,网络不认识终端。** +`farm.engine` 不知道有 HTTP,`tui.py` 不知道有 socket,`server/*` 不知道有终端。 +所以三端共享完全一致的裁判逻辑,也能各自单独测试。 + +--- + +## 10. 测试 + +```bash +python -m unittest discover -s tests -v # 全部(97 项) +python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI +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. 边界处理与设计取舍 + +**输入校验**(唯一入口 `server/actions.py`): + +* 未知动作 → `400 unknown_action`,并列出可用动作; +* 缺必需参数 → `missing_param`;多传参数 → `bad_param`(明确拒绝而不是静默忽略); +* 类型错误 → `bad_param`,但宽容地把 `"3"` 这类数字字符串转成整数(命令行/表单常见); +* 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币/行动点不足 → `not_enough_gold` / `no_ap`; +* 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`a/b` 之类的路径穿越; +* 请求体上限 64KB、批量动作上限 50、`limit` 参数自动夹取到合法区间。 + +**并发与容错**: + +* 引擎内 `RLock` 串行化同一牧场,多牧场并行; +* WebSocket 每条连接一个出口队列(满 32 条丢最旧的),慢客户端不会拖垮服务端; +* 推送节流 250ms,高频操作自动合并成一帧;每条推送带自增 `seq`,客户端据此丢弃乱序帧; +* 引擎调用统一丢进线程池,不阻塞事件循环; +* 单条连接/单个牧场出错不影响其它牧场; +* 存档用"临时文件 + `os.replace`"原子替换,崩溃不会写坏存档;存档读取失败会降级为开新档而不是崩溃。 + +**本地请求不走系统代理**(`farm/net.py`): + +机器上设了 `HTTP_PROXY` 却没有 `no_proxy` 时(公司网络、容器、CI 沙箱很常见), +`urllib` 会把发给 `127.0.0.1` 的请求也塞给代理,本机游戏服务器会莫名其妙"连不上", +报的还是 `502 upstream connect failed` 这种看不出根因的错误。 +所以客户端与守护进程探测统一走 `farm.net.open_url()`:目标是回环地址就直连, +目标是别的机器才尊重环境里的代理设置。(WebSocket 不受影响,`websockets` 默认不读代理变量。) + +**测试产物**:`tools/preview_tui.py` 可以把任意时刻的界面渲染成文本(支持 `--url` 连真实服务器), +`tools/ws_demo.py` 是一个最小 WebSocket 客户端,用来确认"动作上行、状态下行"两条路都通。 + +**为什么放弃 gRPC**:这套接口是给 LLM/脚本用的,它们几乎都只会捏 HTTP 和 JSON; +gRPC 需要 `.proto` 编译产物、需要客户端装 `grpcio`,接入成本换来的性能在这个场景里毫无意义。 +HTTP + WebSocket 用 `curl` 就能上手,`/docs` 还能自动生成交互式文档。 + +*(曾经实现的 gRPC 版本已经移除:`farm/grpc_server.py`、`proto/farm.proto`、`tools/gen_proto.py`。)* diff --git a/_ruff1.txt b/_ruff1.txt new file mode 100644 index 0000000..1e4c46e --- /dev/null +++ b/_ruff1.txt @@ -0,0 +1,2078 @@ +=== ruff 版本 === +ruff 0.16.8 + +=== ruff check --statistics === +225 UP006 [*] non-pep585-annotation + 54 UP045 [*] non-pep604-annotation-optional + 32 UP035 [-] deprecated-import + 23 E501 [ ] line-too-long + 18 PTH120 [ ] os-path-dirname + 15 PTH118 [ ] os-path-join + 12 B904 [ ] raise-without-from-inside-except + 12 SIM105 [ ] suppressible-exception + 11 PTH123 [ ] builtin-open + 8 PTH100 [ ] os-path-abspath + 7 PTH103 [ ] os-makedirs + 6 RUF100 [*] unused-noqa + 5 PTH110 [ ] os-path-exists + 5 UP015 [*] redundant-open-modes + 5 PERF203 [ ] try-except-in-loop + 5 RUF023 [*] unsorted-dunder-slots + 3 PTH107 [ ] os-remove + 3 UP037 [*] quoted-annotation + 3 RUF046 [ ] unnecessary-cast-to-int + 3 I001 [*] unsorted-imports + 2 F541 [*] f-string-missing-placeholders + 2 PERF401 [ ] manual-list-comprehension + 2 RUF012 [ ] mutable-class-default + 2 UP007 [*] non-pep604-annotation-union + 2 F401 [*] unused-import + 1 E741 [ ] ambiguous-variable-name + 1 RUF005 [ ] collection-literal-concatenation + 1 SIM115 [ ] open-file-with-context-handler + 1 PTH105 [ ] os-replace + 1 UP031 [ ] printf-string-formatting + 1 RET504 [ ] unnecessary-assign + 1 RUF022 [*] unsorted-dunder-all + 1 B007 [ ] unused-loop-control-variable + 1 F841 [ ] unused-variable +Found 474 errors. +[*] 312 fixable with the `--fix` option (17 hidden fixes can be enabled with the `--unsafe-fixes` option). + +=== ruff format --check(哪些文件格式不合规) === +unformatted: File would be reformatted + --> README.md:260:1 + | +259 | +260 + +261 | async def main(): +-------------------------------------------------------------------------------- +272 | +273 + +274 | asyncio.run(main()) + | + +unformatted: File would be reformatted + --> ai_player.py:53:29 + | +52 | try: + - client = RemoteFarm(endpoint=args.url, farm=args.farm, token=args.token, + - name=f"bot-{args.farm}", realtime=False) +53 + client = RemoteFarm( +54 + endpoint=args.url, +55 + farm=args.farm, +56 + token=args.token, +57 + name=f"bot-{args.farm}", +58 + realtime=False, +59 + ) +60 | except GameError as exc: +-------------------------------------------------------------------------------- +65 | snapshot = client.snapshot() + - print(f"已连接 {args.url} · 牧场「{args.farm}」· 第 {snapshot.get('day')} 天 · " + - f"金币 {snapshot.get('gold')}") +66 + print( +67 + f"已连接 {args.url} · 牧场「{args.farm}」· 第 {snapshot.get('day')} 天 · " +68 + f"金币 {snapshot.get('gold')}" +69 + ) +70 | +-------------------------------------------------------------------------------- +76 | bot.stop() + - summary = {"name": args.farm, "actions": bot.actions_executed, + - "failures": bot.failures, "interrupted": True} +77 + summary = { +78 + "name": args.farm, +79 + "actions": bot.actions_executed, +80 + "failures": bot.failures, +81 + "interrupted": True, +82 + } +83 | + | + +unformatted: File would be reformatted + --> farm\__init__.py:18:14 + | +17 | __all__ = [ + - "config", "models", "engine", "storage", "keys", "tui", + - "client", "bot", "server", +18 + "config", +19 + "models", +20 + "engine", +21 + "storage", +22 + "keys", +23 + "tui", +24 + "client", +25 + "bot", +26 + "server", +27 | ] + | + +unformatted: File would be reformatted + --> farm\bot.py:30:18 + | +29 | + - def __init__(self, client, verbose: bool = True, pause: float = 0.25, + - name: str = "bot") -> None: + - self.client = client # 任何与 RemoteFarm 接口一致的对象 +30 + def __init__( +31 + self, client, verbose: bool = True, pause: float = 0.25, name: str = "bot" +32 + ) -> None: +33 + self.client = client # 任何与 RemoteFarm 接口一致的对象 +34 | self.verbose = verbose +-------------------------------------------------------------------------------- +54 | # ------------------------------------------------------------ 打分 + - def score(self, action: Dict[str, Any], state: Dict[str, Any], + - catalog: Dict[str, Any]) -> float: +55 + def score( +56 + self, action: Dict[str, Any], state: Dict[str, Any], catalog: Dict[str, Any] +57 + ) -> float: +58 | """给一个候选动作打分。分数越高越先执行。 +-------------------------------------------------------------------------------- +94 | if name == "buy_animal": + - info = next((a for a in catalog.get("animals", []) + - if a["key"] == params.get("kind")), None) +95 + info = next( +96 + (a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None +97 + ) +98 | cost = info["cost"] if info else 0 +-------------------------------------------------------------------------------- +124 | try: + - result = self.client._action(name, params) # 走通用动作入口 +125 + result = self.client._action(name, params) # 走通用动作入口 +126 | except GameError as exc: +-------------------------------------------------------------------------------- +156 | # ------------------------------------------------------------ 主循环 + - def run(self, rounds: int = 0, max_minutes: float = 0.0, + - idle_limit: int = 8) -> Dict[str, Any]: +157 + def run(self, rounds: int = 0, max_minutes: float = 0.0, idle_limit: int = 8) -> Dict[str, Any]: +158 | """跑机器人。 +-------------------------------------------------------------------------------- +165 | round_no = 0 + - self.say(f"机器人 {self.name} 上线(第 {start_day} 天," + - f"{self.client.transport_label()})") +166 + self.say(f"机器人 {self.name} 上线(第 {start_day} 天,{self.client.transport_label()})") +167 | +-------------------------------------------------------------------------------- +202 | } + - self.say(f"机器人 {self.name} 下线:跑了 {summary['days']} 天、" + - f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}") +203 + self.say( +204 + f"机器人 {self.name} 下线:跑了 {summary['days']} 天、" +205 + f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}" +206 + ) +207 | return summary +-------------------------------------------------------------------------------- +220 | + - bot.choose = choose # type: ignore[assignment] +221 + bot.choose = choose # type: ignore[assignment] +222 | bot.name = kwargs.get("name", "random") + | + +unformatted: File would be reformatted + --> farm\client.py:234:18 + | +233 | + - def __init__(self, endpoint: str = DEFAULT_ENDPOINT, farm: str = "default", + - token: Optional[str] = None, name: str = "", timeout: float = 6.0, + - realtime: bool = True, autostart: bool = True) -> None: +234 + def __init__( +235 + self, +236 + endpoint: str = DEFAULT_ENDPOINT, +237 + farm: str = "default", +238 + token: Optional[str] = None, +239 + name: str = "", +240 + timeout: float = 6.0, +241 + realtime: bool = True, +242 + autostart: bool = True, +243 + ) -> None: +244 | self.endpoint = self._normalize(endpoint) +-------------------------------------------------------------------------------- +252 | self.connected = False + - self.mode = "http" # http / ws / poll +253 + self.mode = "http" # http / ws / poll +254 | self.last_error = "" +-------------------------------------------------------------------------------- +265 | self._ws_loop: Any = None + - self._catalog: Dict[str, Any] = {"crops": [], "animals": [], + - "feed_price": config.FEED_UNIT_PRICE, + - "water_capacity": config.WATER_CAPACITY, + - "ap_cost": config.AP_COST} +266 + self._catalog: Dict[str, Any] = { +267 + "crops": [], +268 + "animals": [], +269 + "feed_price": config.FEED_UNIT_PRICE, +270 + "water_capacity": config.WATER_CAPACITY, +271 + "ap_cost": config.AP_COST, +272 + } +273 | self._log: Deque[Dict[str, Any]] = deque(maxlen=LOG_KEEP) +-------------------------------------------------------------------------------- +286 | if endpoint.startswith("tcp://"): + - endpoint = "http://" + endpoint[len("tcp://"):] +287 + endpoint = "http://" + endpoint[len("tcp://") :] +288 | if not endpoint.startswith(("http://", "https://")): +-------------------------------------------------------------------------------- +302 | + - def _http(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None, + - **params: Any) -> Dict[str, Any]: +303 + def _http( +304 + self, method: str, path: str, payload: Optional[Dict[str, Any]] = None, **params: Any +305 + ) -> Dict[str, Any]: +306 | url = self.url(path, **params) +-------------------------------------------------------------------------------- +326 | "network", + - f"连不上服务器 {self.endpoint}({exc})。" + - "请先启动:python main.py serve --daemon", +327 + f"连不上服务器 {self.endpoint}({exc})。请先启动:python main.py serve --daemon", +328 | status=503, +-------------------------------------------------------------------------------- +415 | self._log_seq = max(self._log_seq, seq) + - self._log = deque(sorted(self._log, key=lambda e: int(e.get("seq", 0))), + - maxlen=LOG_KEEP) +416 + self._log = deque( +417 + sorted(self._log, key=lambda e: int(e.get("seq", 0))), maxlen=LOG_KEEP +418 + ) +419 | +420 | # ------------------------------------------------------------ 写接口 + - def _action(self, action: str, params: Optional[Dict[str, Any]] = None, + - transport: str = "auto") -> Dict[str, Any]: +421 + def _action( +422 + self, action: str, params: Optional[Dict[str, Any]] = None, transport: str = "auto" +423 + ) -> Dict[str, Any]: +424 | """执行一个动作。 +-------------------------------------------------------------------------------- +430 | params = params or {} + - use_ws = (transport == "ws") or (transport == "auto" and self.mode == "ws" + - and self.connected) +431 + use_ws = (transport == "ws") or ( +432 + transport == "auto" and self.mode == "ws" and self.connected +433 + ) +434 | if use_ws: +-------------------------------------------------------------------------------- +451 | # full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次" + - data = self._http("POST", "/api/actions", {"action": action, "params": params}, + - full=1) +452 + data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1) +453 | self._apply(data.get("snapshot") or data.get("state") or {}) +-------------------------------------------------------------------------------- +528 | self._stop.clear() + - self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", + - daemon=True) +529 + self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True) +530 | self._thread.start() +-------------------------------------------------------------------------------- +599 | 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: +600 + async with websockets.connect( +601 + self.ws_url, +602 + open_timeout=self.timeout, +603 + ping_interval=20, +604 + ping_timeout=20, +605 + max_size=4 * 1024 * 1024, +606 + ) as socket: +607 | self.mode = "ws" +-------------------------------------------------------------------------------- +667 | 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"), + - }) +668 + future.set_result( +669 + { +670 + "ok": True, +671 + "message": message.get("message", "完成"), +672 + **(message.get("result") or {}), +673 + "state": message.get("state"), +674 + "snapshot": message.get("snapshot"), +675 + "log": message.get("log"), +676 + } +677 + ) +678 | else: + - future.set_exception(GameError(message.get("error", "error"), + - message.get("message", "操作失败"))) +679 + future.set_exception( +680 + GameError(message.get("error", "error"), message.get("message", "操作失败")) +681 + ) +682 | return +683 | +684 | if kind == "day": + - self._merge_log([{ + - "seq": self._log_seq + 1, + - "day": message.get("day", 0), + - "kind": "info", + - "text": f"第 {message.get('day')} 天开始", + - }] + [{ + - "seq": self._log_seq + 2 + i, + - "day": message.get("day", 0), + - "kind": "warn", + - "text": text, + - } for i, text in enumerate(message.get("events") or [])]) +685 + self._merge_log( +686 + [ +687 + { +688 + "seq": self._log_seq + 1, +689 + "day": message.get("day", 0), +690 + "kind": "info", +691 + "text": f"第 {message.get('day')} 天开始", +692 + } +693 + ] +694 + + [ +695 + { +696 + "seq": self._log_seq + 2 + i, +697 + "day": message.get("day", 0), +698 + "kind": "warn", +699 + "text": text, +700 + } +701 + for i, text in enumerate(message.get("events") or []) +702 + ] +703 + ) +704 | return +-------------------------------------------------------------------------------- +716 | + - def _ws_action(self, action: str, params: Dict[str, Any], + - timeout: Optional[float] = None) -> Dict[str, Any]: +717 + def _ws_action( +718 + self, action: str, params: Dict[str, Any], timeout: Optional[float] = None +719 + ) -> Dict[str, Any]: +720 | with self._lock: + | + +unformatted: File would be reformatted + --> farm\config.py:15:19 + | +14 | + - START_GOLD = 120 # 初始金币 + - START_LEVEL = 1 # 初始等级 + - START_AP = 10 # 每天行动点上限 + - START_FEED = 5 # 初始饲料库存 +15 + START_GOLD = 120 # 初始金币 +16 + START_LEVEL = 1 # 初始等级 +17 + START_AP = 10 # 每天行动点上限 +18 + START_FEED = 5 # 初始饲料库存 +19 | + - BASE_PLOTS = 6 # 初始土地数量 + - MAX_PLOTS = 16 # 土地数量硬上限 + - PLOT_BASE_PRICE = 200 # 第一块新地的价格 +20 + BASE_PLOTS = 6 # 初始土地数量 +21 + MAX_PLOTS = 16 # 土地数量硬上限 +22 + PLOT_BASE_PRICE = 200 # 第一块新地的价格 +23 | PLOT_PRICE_GROWTH = 1.75 # 每多买一块,价格乘以该系数 +24 | + - FEED_UNIT_PRICE = 6 # 主动购买饲料的单价 +25 + FEED_UNIT_PRICE = 6 # 主动购买饲料的单价 +26 | FEED_EMERGENCY_PRICE = 9 # 自动补货(缺饲料时喂食)的惩罚单价 + - FEED_BULK = 10 # 一次购买饲料的默认批量 +27 + FEED_BULK = 10 # 一次购买饲料的默认批量 +28 | + - WATER_CAPACITY = 3 # 一次浇水可支撑的天数 +29 + WATER_CAPACITY = 3 # 一次浇水可支撑的天数 +30 | PLANT_INITIAL_MOISTURE = 2 # 播种后地块自带的水分 + - PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%) + - LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍) + - MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限 + - MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限 +31 + PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%) +32 + LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍) +33 + MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限 +34 + MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限 +35 | +-------------------------------------------------------------------------------- +50 | +51 + +52 | # 经验曲线:升到 level+1 所需经验 +-------------------------------------------------------------------------------- +56 | +57 + +58 | # 升级奖励金币 +-------------------------------------------------------------------------------- +76 | "wheat": { + - "name": "小麦", "seed_cost": 5, "grow_days": 2, + - "base_price": 14, "exp": 3, "level_req": 1, +77 + "name": "小麦", +78 + "seed_cost": 5, +79 + "grow_days": 2, +80 + "base_price": 14, +81 + "exp": 3, +82 + "level_req": 1, +83 | }, +84 | "carrot": { + - "name": "胡萝卜", "seed_cost": 8, "grow_days": 3, + - "base_price": 26, "exp": 5, "level_req": 1, +85 + "name": "胡萝卜", +86 + "seed_cost": 8, +87 + "grow_days": 3, +88 + "base_price": 26, +89 + "exp": 5, +90 + "level_req": 1, +91 | }, +92 | "tomato": { + - "name": "番茄", "seed_cost": 16, "grow_days": 4, + - "base_price": 52, "exp": 9, "level_req": 2, +93 + "name": "番茄", +94 + "seed_cost": 16, +95 + "grow_days": 4, +96 + "base_price": 52, +97 + "exp": 9, +98 + "level_req": 2, +99 | }, +100 | "corn": { + - "name": "玉米", "seed_cost": 28, "grow_days": 6, + - "base_price": 95, "exp": 14, "level_req": 3, +101 + "name": "玉米", +102 + "seed_cost": 28, +103 + "grow_days": 6, +104 + "base_price": 95, +105 + "exp": 14, +106 + "level_req": 3, +107 | }, +108 | "pumpkin": { + - "name": "南瓜", "seed_cost": 45, "grow_days": 8, + - "base_price": 180, "exp": 22, "level_req": 4, +109 + "name": "南瓜", +110 + "seed_cost": 45, +111 + "grow_days": 8, +112 + "base_price": 180, +113 + "exp": 22, +114 + "level_req": 4, +115 | }, +116 | "strawberry": { + - "name": "草莓", "seed_cost": 70, "grow_days": 10, + - "base_price": 320, "exp": 35, "level_req": 5, +117 + "name": "草莓", +118 + "seed_cost": 70, +119 + "grow_days": 10, +120 + "base_price": 320, +121 + "exp": 35, +122 + "level_req": 5, +123 | }, +-------------------------------------------------------------------------------- +149 | "chicken": { + - "name": "鸡", "cost": 90, "product": "鸡蛋", + - "base_price": 15, "cycle": 1, "feed_per_day": 1, + - "exp": 4, "level_req": 1, +150 + "name": "鸡", +151 + "cost": 90, +152 + "product": "鸡蛋", +153 + "base_price": 15, +154 + "cycle": 1, +155 + "feed_per_day": 1, +156 + "exp": 4, +157 + "level_req": 1, +158 | }, +159 | "sheep": { + - "name": "羊", "cost": 220, "product": "羊毛", + - "base_price": 55, "cycle": 2, "feed_per_day": 1, + - "exp": 9, "level_req": 2, +160 + "name": "羊", +161 + "cost": 220, +162 + "product": "羊毛", +163 + "base_price": 55, +164 + "cycle": 2, +165 + "feed_per_day": 1, +166 + "exp": 9, +167 + "level_req": 2, +168 | }, +169 | "cow": { + - "name": "牛", "cost": 480, "product": "牛奶", + - "base_price": 72, "cycle": 1, "feed_per_day": 2, + - "exp": 15, "level_req": 3, +170 + "name": "牛", +171 + "cost": 480, +172 + "product": "牛奶", +173 + "base_price": 72, +174 + "cycle": 1, +175 + "feed_per_day": 2, +176 + "exp": 15, +177 + "level_req": 3, +178 | }, +-------------------------------------------------------------------------------- +190 | MAX_TICK_SECONDS = 600.0 + - SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒) +191 + SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒) +192 | + | + +unformatted: File would be reformatted + --> farm\engine.py:80:36 + | +79 | "day": self.state.day, + - "kind": kind, # info / good / bad / warn / action +80 + "kind": kind, # info / good / bad / warn / action +81 | "text": text, +-------------------------------------------------------------------------------- +150 | "locked": self.state.level < info["level_req"], + - "profit_per_day": round( + - (price - feed_cost) / max(1, info["cycle"]), 2 + - ), +151 + "profit_per_day": round((price - feed_cost) / max(1, info["cycle"]), 2), +152 | } +-------------------------------------------------------------------------------- +368 | if plot.crop is not None: + - raise GameError("plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}") +369 + raise GameError( +370 + "plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}" +371 + ) +372 | +-------------------------------------------------------------------------------- +428 | lucky = "(丰收!)" if quantity > 1 else "" + - self.add_log( + - f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good" + - ) +429 + self.add_log(f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good") +430 | levelups = self._gain_exp(info["exp"]) +-------------------------------------------------------------------------------- +643 | if plot.is_ripe: + - events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟") +644 + events.append( +645 + f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟" +646 + ) +647 | else: + | + +unformatted: File would be reformatted + --> farm\models.py:26:29 + | +25 | def __init__(self, index: int) -> None: + - self.index = index # 0-based 下标,对外展示用 index+1 +26 + self.index = index # 0-based 下标,对外展示用 index+1 +27 | self.crop: Optional[str] = None + - self.growth: float = 0.0 # 已生长天数 + - self.moisture: int = 0 # 剩余可支撑天数 +28 + self.growth: float = 0.0 # 已生长天数 +29 + self.moisture: int = 0 # 剩余可支撑天数 +30 | self.planted_day: int = 0 +-------------------------------------------------------------------------------- +104 | self.kind = kind + - self.fed = False # 今天是否已喂食 + - self.progress = 0 # 距下次产出的累计天数 + - self.starve = 0 # 连续未进食天数 + - self.pending = 0 # 待收集的产出数量 +105 + self.fed = False # 今天是否已喂食 +106 + self.progress = 0 # 距下次产出的累计天数 +107 + self.starve = 0 # 连续未进食天数 +108 + self.pending = 0 # 待收集的产出数量 +109 | +-------------------------------------------------------------------------------- +210 | return -1 + - return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** self.plots_bought)) +211 + return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**self.plots_bought)) +212 | +-------------------------------------------------------------------------------- +231 | land = sum( + - config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** i) + - for i in range(self.plots_bought) +232 + config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH**i) for i in range(self.plots_bought) +233 | ) +-------------------------------------------------------------------------------- +296 | plots.extend(Plot(i) for i in range(len(plots), count)) + - state.plots = plots[:config.MAX_PLOTS] +297 + state.plots = plots[: config.MAX_PLOTS] +298 | for i, plot in enumerate(state.plots): + | + +unformatted: File would be reformatted + --> farm\net.py:46:14 + | +45 | + - def open_url(target: Union[str, urllib.request.Request], timeout: float = 6.0, + - host: Optional[str] = None) -> Any: +46 + def open_url( +47 + target: Union[str, urllib.request.Request], timeout: float = 6.0, host: Optional[str] = None +48 + ) -> Any: +49 | """打开一个 URL 或 Request,回环地址自动绕过代理。 + | + +unformatted: File would be reformatted + --> farm\server\__init__.py:25:19 + | +24 | _LAZY = { + - "API_VERSION", "SERVER_NAME", "ServerConfig", "ServerHandle", + - "build_app", "create_app", "rules_text", "brief", + - "Action", "Param", "ACTIONS", "run_action", "describe_actions", "action_names", + - "FarmSession", "SessionManager", "FarmClock", "validate_farm_id", "DEFAULT_FARM", + - "Client", "ConnectionHub", "Publisher", + - "DaemonManager", "pid_alive", + - "DASHBOARD_HTML", "dashboard_text", +25 + "API_VERSION", +26 + "SERVER_NAME", +27 + "ServerConfig", +28 + "ServerHandle", +29 + "build_app", +30 + "create_app", +31 + "rules_text", +32 + "brief", +33 + "Action", +34 + "Param", +35 + "ACTIONS", +36 + "run_action", +37 + "describe_actions", +38 + "action_names", +39 + "FarmSession", +40 + "SessionManager", +41 + "FarmClock", +42 + "validate_farm_id", +43 + "DEFAULT_FARM", +44 + "Client", +45 + "ConnectionHub", +46 + "Publisher", +47 + "DaemonManager", +48 + "pid_alive", +49 + "DASHBOARD_HTML", +50 + "dashboard_text", +51 | } +-------------------------------------------------------------------------------- +55 | + - def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性 +56 + def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性 +57 | if name not in _LAZY: +-------------------------------------------------------------------------------- +64 | module = import_module("farm.server.webui") + - elif name in ("Action", "Param", "ACTIONS", "run_action", "describe_actions", + - "action_names"): +65 + elif name in ("Action", "Param", "ACTIONS", "run_action", "describe_actions", "action_names"): +66 | module = import_module("farm.server.actions") + - elif name in ("FarmSession", "SessionManager", "FarmClock", "validate_farm_id", + - "DEFAULT_FARM"): +67 + elif name in ("FarmSession", "SessionManager", "FarmClock", "validate_farm_id", "DEFAULT_FARM"): +68 | module = import_module("farm.server.session") + | + +unformatted: File would be reformatted + --> farm\server\__main__.py:23:25 + | +22 | ) + - parser.add_argument("--host", default="127.0.0.1", + - help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0") +23 + parser.add_argument( +24 + "--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0" +25 + ) +26 | parser.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765") +27 | parser.add_argument("--token", default=None, help="访问令牌,设置后所有接口都要带") + - parser.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS, + - help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒") +28 + parser.add_argument( +29 + "--tick", +30 + type=float, +31 + default=config.DEFAULT_TICK_SECONDS, +32 + help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒", +33 + ) +34 | parser.add_argument("--save-dir", default=None, help="存档目录,默认 <项目根>/saves") + - parser.add_argument("--save", dest="save_file", default=None, + - help="兼容旧参数:指定 default 牧场的存档文件") +35 + parser.add_argument( +36 + "--save", dest="save_file", default=None, help="兼容旧参数:指定 default 牧场的存档文件" +37 + ) +38 | parser.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限") + - parser.add_argument("--idle-timeout", type=float, default=1800.0, + - help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800") +39 + parser.add_argument( +40 + "--idle-timeout", +41 + type=float, +42 + default=1800.0, +43 + help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800", +44 + ) +45 | parser.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称") + - parser.add_argument("--log-level", default="info", + - choices=["critical", "error", "warning", "info", "debug"]) +46 + parser.add_argument( +47 + "--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"] +48 + ) +49 | parser.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求") + - parser.add_argument("--no-remote-control", action="store_true", + - help="禁止通过接口停机") + - parser.add_argument("--daemon", action="store_true", + - help="转到后台运行(等价于 python main.py serve --daemon)") + - parser.add_argument("--pid-file", default=None, + - help="把本进程 pid 写到该文件(守护进程模式会自动指定)") +50 + parser.add_argument("--no-remote-control", action="store_true", help="禁止通过接口停机") +51 + parser.add_argument( +52 + "--daemon", action="store_true", help="转到后台运行(等价于 python main.py serve --daemon)" +53 + ) +54 + parser.add_argument( +55 + "--pid-file", default=None, help="把本进程 pid 写到该文件(守护进程模式会自动指定)" +56 + ) +57 | parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help=argparse.SUPPRESS) +-------------------------------------------------------------------------------- +82 | manager = DaemonManager( + - host=args.host, port=args.port, token=args.token, + - tick=clamp_tick(args.tick), save_dir=save_dir, +83 + host=args.host, +84 + port=args.port, +85 + token=args.token, +86 + tick=clamp_tick(args.tick), +87 + save_dir=save_dir, +88 | pid_file=args.pid_file or DEFAULT_PID_FILE, +-------------------------------------------------------------------------------- +101 | import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级 +102 + +103 | ws_ok = True +-------------------------------------------------------------------------------- +153 | if not ws_ok: + - print(" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;" + - "网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'") +154 + print( +155 + " ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;" +156 + "网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'" +157 + ) +158 | if cfg.token: +-------------------------------------------------------------------------------- +173 | if not getattr(server, "started", False): + - print(f"启动失败:端口 {cfg.port} 可能已被占用。" + - f"换一个端口试试:--port {cfg.port + 1}", file=sys.stderr) +174 + print( +175 + f"启动失败:端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}", +176 + file=sys.stderr, +177 + ) +178 | return 3 + | + +unformatted: File would be reformatted + --> farm\server\actions.py:27:18 + | +26 | + - def __init__(self, name: str, kind: str, required: bool = True, + - default: Any = None, desc: str = "") -> None: +27 + def __init__( +28 + self, name: str, kind: str, required: bool = True, default: Any = None, desc: str = "" +29 + ) -> None: +30 | self.name = name + - self.kind = kind # "int" / "str" / "bool" +31 + self.kind = kind # "int" / "str" / "bool" +32 | self.required = required +-------------------------------------------------------------------------------- +76 | + - def __init__(self, name: str, summary: str, params: List[Param], + - handler: Callable[[FarmEngine, Dict[str, Any]], Dict[str, Any]], + - realtime: bool = True) -> None: +77 + def __init__( +78 + self, +79 + name: str, +80 + summary: str, +81 + params: List[Param], +82 + handler: Callable[[FarmEngine, Dict[str, Any]], Dict[str, Any]], +83 + realtime: bool = True, +84 + ) -> None: +85 | self.name = name +-------------------------------------------------------------------------------- +150 | + - _register(Action("plant", "在指定地块播种", [ + - Param("plot", "int", True, None, "地块编号,从 1 开始"), + - Param("crop", "str", True, None, "作物标识,如 wheat"), + - ], lambda e, p: e.plant(p["plot"], p["crop"]))) +151 + _register( +152 + Action( +153 + "plant", +154 + "在指定地块播种", +155 + [ +156 + Param("plot", "int", True, None, "地块编号,从 1 开始"), +157 + Param("crop", "str", True, None, "作物标识,如 wheat"), +158 + ], +159 + lambda e, p: e.plant(p["plot"], p["crop"]), +160 + ) +161 + ) +162 | + - _register(Action("water", "给指定地块浇水(补满水分)", [ + - Param("plot", "int", True, None, "地块编号,从 1 开始"), + - ], lambda e, p: e.water(p["plot"]))) +163 + _register( +164 + Action( +165 + "water", +166 + "给指定地块浇水(补满水分)", +167 + [ +168 + Param("plot", "int", True, None, "地块编号,从 1 开始"), +169 + ], +170 + lambda e, p: e.water(p["plot"]), +171 + ) +172 + ) +173 | + - _register(Action("harvest", "收获指定地块的成熟作物", [ + - Param("plot", "int", True, None, "地块编号,从 1 开始"), + - ], lambda e, p: e.harvest(p["plot"]))) +174 + _register( +175 + Action( +176 + "harvest", +177 + "收获指定地块的成熟作物", +178 + [ +179 + Param("plot", "int", True, None, "地块编号,从 1 开始"), +180 + ], +181 + lambda e, p: e.harvest(p["plot"]), +182 + ) +183 + ) +184 | +185 | _register(Action("harvest_all", "一键收获所有成熟作物", [], _harvest_all)) +186 | + - _register(Action("buy_animal", "购买牲畜", [ + - Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"), + - ], lambda e, p: e.buy_animal(p["kind"]))) +187 + _register( +188 + Action( +189 + "buy_animal", +190 + "购买牲畜", +191 + [ +192 + Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"), +193 + ], +194 + lambda e, p: e.buy_animal(p["kind"]), +195 + ) +196 + ) +197 | + - _register(Action("feed", "喂食指定牲畜", [ + - Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"), + - Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"), + - ], lambda e, p: e.feed(p["animal_id"], p.get("quantity")))) +198 + _register( +199 + Action( +200 + "feed", +201 + "喂食指定牲畜", +202 + [ +203 + Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"), +204 + Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"), +205 + ], +206 + lambda e, p: e.feed(p["animal_id"], p.get("quantity")), +207 + ) +208 + ) +209 | + - _register(Action("collect", "收取牲畜的产出并自动出售", [ + - Param("animal_id", "int", True, None, "牲畜编号"), + - ], lambda e, p: e.collect(p["animal_id"]))) +210 + _register( +211 + Action( +212 + "collect", +213 + "收取牲畜的产出并自动出售", +214 + [ +215 + Param("animal_id", "int", True, None, "牲畜编号"), +216 + ], +217 + lambda e, p: e.collect(p["animal_id"]), +218 + ) +219 + ) +220 | + - _register(Action("buy_feed", "购买饲料", [ + - Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"), + - ], lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK)))) +221 + _register( +222 + Action( +223 + "buy_feed", +224 + "购买饲料", +225 + [ +226 + Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"), +227 + ], +228 + lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK)), +229 + ) +230 + ) +231 | +-------------------------------------------------------------------------------- +235 | + - _register(Action("pause", "暂停或恢复自动推进时间", [ + - Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"), + - ], _pause)) +236 + _register( +237 + Action( +238 + "pause", +239 + "暂停或恢复自动推进时间", +240 + [ +241 + Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"), +242 + ], +243 + _pause, +244 + ) +245 + ) +246 | +-------------------------------------------------------------------------------- +259 | + - def run_action(engine: FarmEngine, name: Any, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +260 + def run_action( +261 + engine: FarmEngine, name: Any, params: Optional[Dict[str, Any]] = None +262 + ) -> Dict[str, Any]: +263 | """校验参数并执行动作。 + | + +unformatted: File would be reformatted + --> farm\server\app.py:128:9 + | +127 | f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天" + - "(POST /api/actions {\"action\":\"sleep\"} 可主动推进一天,有 2 秒冷却)。\n" +128 + '(POST /api/actions {"action":"sleep"} 可主动推进一天,有 2 秒冷却)。\n' +129 | f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。" +-------------------------------------------------------------------------------- +141 | " GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n" + - " POST /api/actions → {\"action\":\"<上一步里的 action>\"," + - "\"params\":{...}} 直接执行\n" +142 + ' POST /api/actions → {"action":"<上一步里的 action>",' +143 + '"params":{...}} 直接执行\n' +144 | " 响应里的 state 就是执行后的最新状态,无需再查一次。\n" +145 | "\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;" + - "也可以直接在 WS 上发送 {\"type\":\"action\",\"action\":\"plant\"," + - "\"params\":{\"plot\":1,\"crop\":\"wheat\"},\"id\":1} 并等待 result 回执。\n" +146 + '也可以直接在 WS 上发送 {"type":"action","action":"plant",' +147 + '"params":{"plot":1,"crop":"wheat"},"id":1} 并等待 result 回执。\n' +148 | ) +-------------------------------------------------------------------------------- +252 | session.engine.add_log(f"已存档到 {path}", "info") + - return {"ok": True, "action": "save", "path": path, + - "message": f"已存档到 {path}"} +253 + return {"ok": True, "action": "save", "path": path, "message": f"已存档到 {path}"} +254 | if key == "reset": +255 | session.reset() + - return {"ok": True, "action": "reset", + - "message": f"牧场「{session.farm_id}」已重置,重新开始经营"} +256 + return { +257 + "ok": True, +258 + "action": "reset", +259 + "message": f"牧场「{session.farm_id}」已重置,重新开始经营", +260 + } +261 | return actions_mod.run_action(session.engine, name, params) +262 | + - def run_and_wrap(session: FarmSession, name: Any, params: Any, + - full: bool = False) -> Dict[str, Any]: +263 + def run_and_wrap( +264 + session: FarmSession, name: Any, params: Any, full: bool = False +265 + ) -> Dict[str, Any]: +266 | """执行动作并拼装统一响应。HTTP 与 WS 共用。""" +-------------------------------------------------------------------------------- +318 | "log": "GET /api/log?farm=NAME&limit=20&since=0", + - "execute": "POST /api/actions {\"action\":\"plant\",\"params\":{\"plot\":1,\"crop\":\"wheat\"}}", + - "farms": "GET /api/farms | POST /api/farms {\"farm\":\"NAME\"}", +319 + "execute": 'POST /api/actions {"action":"plant","params":{"plot":1,"crop":"wheat"}}', +320 + "farms": 'GET /api/farms | POST /api/farms {"farm":"NAME"}', +321 | "realtime": "WS /ws?farm=NAME", +-------------------------------------------------------------------------------- +409 | limit = _int(request.query_params.get("limit"), 20, 1, 200) + - since = _int(request.query_params.get("since"), 0, 0, 10 ** 9) +410 + since = _int(request.query_params.get("since"), 0, 0, 10**9) +411 | entries = session.engine.recent_log(limit=limit, since=since) +-------------------------------------------------------------------------------- +540 | # 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题 + - await websocket.send_json({"type": "error", "error": exc.code, + - "message": exc.message}) +541 + await websocket.send_json({"type": "error", "error": exc.code, "message": exc.message}) +542 | await websocket.close(code=1013, reason=exc.message[:100]) +-------------------------------------------------------------------------------- +546 | + - await websocket.send_json({ + - "type": "welcome", + - "client_id": client.cid, + - "farm": farm, + - "server": { + - "name": SERVER_NAME, + - "version": API_VERSION, + - "world": cfg.world_name, + - "tick_seconds": session.engine.tick_seconds, + - }, + - "state": session.snapshot(), + - "catalog": session.engine.catalog(), + - "actions": session.engine.available_actions(), + - "log": session.engine.recent_log(limit=12), + - "online": hub.online(farm), + - }) +547 + await websocket.send_json( +548 + { +549 + "type": "welcome", +550 + "client_id": client.cid, +551 + "farm": farm, +552 + "server": { +553 + "name": SERVER_NAME, +554 + "version": API_VERSION, +555 + "world": cfg.world_name, +556 + "tick_seconds": session.engine.tick_seconds, +557 + }, +558 + "state": session.snapshot(), +559 + "catalog": session.engine.catalog(), +560 + "actions": session.engine.available_actions(), +561 + "log": session.engine.recent_log(limit=12), +562 + "online": hub.online(farm), +563 + } +564 + ) +565 | hub.broadcast( +566 | farm, + - {"type": "notice", "farm": farm, + - "text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})"}, +567 + { +568 + "type": "notice", +569 + "farm": farm, +570 + "text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})", +571 + }, +572 | exclude=client, +-------------------------------------------------------------------------------- +594 | except json.JSONDecodeError as exc: + - await websocket.send_json({"type": "error", "error": "bad_json", + - "message": f"不是合法 JSON:{exc}"}) +595 + await websocket.send_json( +596 + {"type": "error", "error": "bad_json", "message": f"不是合法 JSON:{exc}"} +597 + ) +598 | return +599 | if not isinstance(data, dict): + - await websocket.send_json({"type": "error", "error": "bad_json", + - "message": "消息必须是 JSON 对象"}) +600 + await websocket.send_json( +601 + {"type": "error", "error": "bad_json", "message": "消息必须是 JSON 对象"} +602 + ) +603 | return +-------------------------------------------------------------------------------- +612 | 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), + - }) +613 + await websocket.send_json( +614 + { +615 + "type": "sync", +616 + "id": rid, +617 + "seq": next_seq(), +618 + "farm": session.farm_id, +619 + "state": session.snapshot(), +620 + "actions": session.engine.available_actions(), +621 + "log": session.engine.recent_log(limit=20), +622 + } +623 + ) +624 | return +625 | +626 | if kind == "help": + - await websocket.send_json({"type": "help", "id": rid, "rules": rules_text(), + - "actions": actions_mod.describe_actions()}) +627 + await websocket.send_json( +628 + { +629 + "type": "help", +630 + "id": rid, +631 + "rules": rules_text(), +632 + "actions": actions_mod.describe_actions(), +633 + } +634 + ) +635 | return +-------------------------------------------------------------------------------- +640 | result = await asyncio.to_thread( + - execute_action, session, data.get("action"), +641 + execute_action, +642 + session, +643 + data.get("action"), +644 | data.get("params") or {}, +645 | ) +646 | except GameError as exc: +647 | counters["errors"] += 1 + - await websocket.send_json({"type": "result", "id": rid, "ok": False, + - "error": exc.code, "message": exc.message, + - "farm": session.farm_id}) +648 + await websocket.send_json( +649 + { +650 + "type": "result", +651 + "id": rid, +652 + "ok": False, +653 + "error": exc.code, +654 + "message": exc.message, +655 + "farm": session.farm_id, +656 + } +657 + ) +658 | return +659 | counters["actions"] += 1 +660 | 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(), + - }) +661 + await websocket.send_json( +662 + { +663 + "type": "result", +664 + "id": rid, +665 + "ok": True, +666 + "farm": session.farm_id, +667 + "message": result.get("message", "完成"), +668 + "result": result, +669 + "state": brief(session, hub), +670 + # 回执里带上完整快照:客户端可以立刻刷新画面,不必等下一次推送 +671 + "snapshot": session.snapshot(), +672 + "log": session.engine.recent_log(limit=3), +673 + "seq": next_seq(), +674 + } +675 + ) +676 | return +677 | + - await websocket.send_json({"type": "error", "error": "unknown_message", + - "message": f"不认识的消息类型 {kind!r}", + - "hint": "可用:action / ping / sync / help"}) +678 + await websocket.send_json( +679 + { +680 + "type": "error", +681 + "error": "unknown_message", +682 + "message": f"不认识的消息类型 {kind!r}", +683 + "hint": "可用:action / ping / sync / help", +684 + } +685 + ) +686 | +-------------------------------------------------------------------------------- +701 | session.farm_id, + - {"type": "notice", "farm": session.farm_id, + - "text": f"{client.name or client.cid} 离开了牧场(在线 {hub.online(session.farm_id)})"}, +702 + { +703 + "type": "notice", +704 + "farm": session.farm_id, +705 + "text": f"{client.name or client.cid} 离开了牧场(在线 {hub.online(session.farm_id)})", +706 + }, +707 | ) + | + +unformatted: File would be reformatted + --> farm\server\daemon.py:51:60 + | +50 | kernel32 = ctypes.windll.kernel32 + - handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION +51 + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION +52 | if not handle: +-------------------------------------------------------------------------------- +70 | + - def __init__(self, host: str = "127.0.0.1", port: int = 8765, + - token: Optional[str] = None, tick: float = 15.0, + - save_dir: str = "", pid_file: str = DEFAULT_PID_FILE, + - log_file: str = DEFAULT_LOG_FILE, python: Optional[str] = None) -> None: +71 + def __init__( +72 + self, +73 + host: str = "127.0.0.1", +74 + port: int = 8765, +75 + token: Optional[str] = None, +76 + tick: float = 15.0, +77 + save_dir: str = "", +78 + pid_file: str = DEFAULT_PID_FILE, +79 + log_file: str = DEFAULT_LOG_FILE, +80 + python: Optional[str] = None, +81 + ) -> None: +82 | self.host = host +-------------------------------------------------------------------------------- +150 | 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 +151 + return ( +152 + sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port)) +153 + == 0 +154 + ) +155 | +156 | # ------------------------------------------------------------ 启动 +157 | def command(self) -> List[str]: +158 | args = [ + - self.python, "-m", "farm.server", + - "--host", self.host, + - "--port", str(self.port), + - "--tick", str(self.tick), + - "--save-dir", self.save_dir, +159 + self.python, +160 + "-m", +161 + "farm.server", +162 + "--host", +163 + self.host, +164 + "--port", +165 + str(self.port), +166 + "--tick", +167 + str(self.tick), +168 + "--save-dir", +169 + self.save_dir, +170 | # 关键:pid 文件由**服务进程自己**写。 +171 | # Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid +172 | # 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。 + - "--pid-file", self.pid_file, +173 + "--pid-file", +174 + self.pid_file, +175 | ] +-------------------------------------------------------------------------------- +190 | os.makedirs(os.path.dirname(self.pid_file), exist_ok=True) + - self.clear_pid() # 清掉上一次留下的陈旧 pid +191 + self.clear_pid() # 清掉上一次留下的陈旧 pid +192 | env = dict(os.environ) +-------------------------------------------------------------------------------- +245 | + - return False, ( + - f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}" + - ) +246 + return False, (f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}") +247 | +-------------------------------------------------------------------------------- +277 | if force and os.name == "nt": + - subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], + - capture_output=True, check=False) +278 + subprocess.run( +279 + ["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False +280 + ) +281 | if self._wait_offline(pid, 3.0): +-------------------------------------------------------------------------------- +308 | url += "?token=" + urllib.parse.quote(self.token) + - request = urllib.request.Request(url, data=b"{}", method="POST", + - headers={"Content-Type": "application/json"}) +309 + request = urllib.request.Request( +310 + url, data=b"{}", method="POST", headers={"Content-Type": "application/json"} +311 + ) +312 | try: + | + +unformatted: File would be reformatted + --> farm\server\hub.py:31:24 + | +30 | + - STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次 + - QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢) +31 + STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次 +32 + QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢) +33 | +-------------------------------------------------------------------------------- +50 | + - __slots__ = ("cid", "farm_id", "loop", "queue", "name", "joined_at", "alive", + - "_sent", "_dropped") +51 + __slots__ = ( +52 + "cid", +53 + "farm_id", +54 + "loop", +55 + "queue", +56 + "name", +57 + "joined_at", +58 + "alive", +59 + "_sent", +60 + "_dropped", +61 + ) +62 | +-------------------------------------------------------------------------------- +156 | # ------------------------------------------------------------ 广播 + - def broadcast(self, farm_id: str, message: Dict[str, Any], + - exclude: Optional[Client] = None) -> int: +157 + def broadcast( +158 + self, farm_id: str, message: Dict[str, Any], exclude: Optional[Client] = None +159 + ) -> int: +160 | sent = 0 +-------------------------------------------------------------------------------- +231 | session.published_day = day + - self.hub.broadcast(session.farm_id, { + - "type": "day", + - "farm": session.farm_id, + - "day": day, + - "events": session.last_events, + - "at": now, + - }) +232 + self.hub.broadcast( +233 + session.farm_id, +234 + { +235 + "type": "day", +236 + "farm": session.farm_id, +237 + "day": day, +238 + "events": session.last_events, +239 + "at": now, +240 + }, +241 + ) +242 | self.hub.broadcast(session.farm_id, payload) +-------------------------------------------------------------------------------- +248 | + - def build_state_message(session, seq: int, note: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +249 + def build_state_message( +250 + session, seq: int, note: str = "", extra: Optional[Dict[str, Any]] = None +251 + ) -> Dict[str, Any]: +252 | """构造给客户端的状态消息(HTTP 与 WS 共用同一份结构)。""" + | + +unformatted: File would be reformatted + --> farm\server\session.py:66:73 + | +65 | + - def __init__(self, farm_id: str, save_dir: str, tick_seconds: float, + - owner: str = "") -> None: +66 + def __init__(self, farm_id: str, save_dir: str, tick_seconds: float, owner: str = "") -> None: +67 | self.farm_id = farm_id +-------------------------------------------------------------------------------- +182 | + - def __init__(self, save_dir: str, tick_seconds: float = config.DEFAULT_TICK_SECONDS, + - max_sessions: int = 64, idle_timeout: float = 1800.0) -> None: +183 + def __init__( +184 + self, +185 + save_dir: str, +186 + tick_seconds: float = config.DEFAULT_TICK_SECONDS, +187 + max_sessions: int = 64, +188 + idle_timeout: float = 1800.0, +189 + ) -> None: +190 | self.save_dir = save_dir +-------------------------------------------------------------------------------- +209 | "too_many_farms", + - f"服务器牧场数量已达上限({self.max_sessions})," + - "请稍后再试或联系管理员扩容", +210 + f"服务器牧场数量已达上限({self.max_sessions}),请稍后再试或联系管理员扩容", +211 | status=503, +-------------------------------------------------------------------------------- +258 | if session.engine.paused: + - continue # 暂停是人为操作,说明玩家还会回来 +259 + continue # 暂停是人为操作,说明玩家还会回来 +260 | if now - session.last_active < self.idle_timeout: +-------------------------------------------------------------------------------- +264 | except OSError: + - continue # 存不下就别丢,宁可占着内存 +265 + continue # 存不下就别丢,宁可占着内存 +266 | with self._lock: + | + +unformatted: File would be reformatted + --> farm\server\webui.py:434:9 + | +433 | " -H 'Content-Type: application/json' \\\n" + - " -d '{\"action\":\"plant\",\"params\":{\"plot\":1,\"crop\":\"wheat\"}}'\n" +434 + ' -d \'{"action":"plant","params":{"plot":1,"crop":"wheat"}}\'\n' +435 | "\n" +-------------------------------------------------------------------------------- +447 | "------\n" + - "POST /api/actions {\"action\":\"...\",\"params\":{...}} 通用入口\n" + - "POST /api/batch {\"actions\":[{...},{...}]} 批量\n" +448 + 'POST /api/actions {"action":"...","params":{...}} 通用入口\n' +449 + 'POST /api/batch {"actions":[{...},{...}]} 批量\n' +450 | "POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n" +451 | " collect|buy_feed|buy_plot|sleep|pause|save|reset 便捷入口\n" + - "POST /api/farms {\"farm\":\"名字\"} 创建/进入牧场\n" +452 + 'POST /api/farms {"farm":"名字"} 创建/进入牧场\n' +453 | "POST /api/admin/save 全部存档\n" + | + +unformatted: File would be reformatted + --> farm\tui.py:44:18 + | +43 | ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + - CELL_WIDTH = 17 # 单块农田格子的显示宽度 +44 + CELL_WIDTH = 17 # 单块农田格子的显示宽度 +45 | CELL_INNER = CELL_WIDTH - 2 + - SIDE_WIDTH = 36 # 右侧栏宽度 +46 + SIDE_WIDTH = 36 # 右侧栏宽度 +47 | MIN_TERM_WIDTH = 76 + - MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量 +48 + MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量 +49 | +-------------------------------------------------------------------------------- +135 | # ---------------------------------------------------------------- 绘制工具 + - def draw_box(title: str, body: List[str], width: int, accent: Optional[str] = None, + - use_color: bool = True) -> List[str]: +136 + def draw_box( +137 + title: str, body: List[str], width: int, accent: Optional[str] = None, use_color: bool = True +138 + ) -> List[str]: +139 | """画一个带标题的方框,返回行列表(宽度精确等于 ``width``)。""" +-------------------------------------------------------------------------------- +168 | + - def __init__(self, engine: Any, save_path: Optional[str] = None, + - use_color: bool = True, autosave: bool = True, + - server_label: str = "") -> None: +169 + def __init__( +170 + self, +171 + engine: Any, +172 + save_path: Optional[str] = None, +173 + use_color: bool = True, +174 + autosave: bool = True, +175 + server_label: str = "", +176 + ) -> None: +177 | # engine 既可以是本地 FarmEngine,也可以是远程 RemoteFarm——只要接口一致 +-------------------------------------------------------------------------------- +185 | self.running = True + - self.mode = "main" # main / help / select_crop / select_animal / confirm_reset +186 + self.mode = "main" # main / help / select_crop / select_animal / confirm_reset +187 | self.plot_cursor = 0 +-------------------------------------------------------------------------------- +211 | try: + - self.engine.save(self.save_path) # 本地引擎写文件;远程引擎让服务器落盘 +212 + self.engine.save(self.save_path) # 本地引擎写文件;远程引擎让服务器落盘 +213 | except Exception: +-------------------------------------------------------------------------------- +620 | filled_count = max(0, min(10, int(round(ratio * 10)))) + - progress = color("█" * filled_count + "░" * (10 - filled_count), + - C_GREEN if plot["is_ripe"] else C_CYAN, self.use_color) +621 + progress = color( +622 + "█" * filled_count + "░" * (10 - filled_count), +623 + C_GREEN if plot["is_ripe"] else C_CYAN, +624 + self.use_color, +625 + ) +626 | +-------------------------------------------------------------------------------- +640 | body = [ + - color("│", border, self.use_color) + pad(line1, CELL_INNER) + color("│", border, self.use_color), + - color("│", border, self.use_color) + pad(line2, CELL_INNER) + color("│", border, self.use_color), +641 + color("│", border, self.use_color) +642 + + pad(line1, CELL_INNER) +643 + + color("│", border, self.use_color), +644 + color("│", border, self.use_color) +645 + + pad(line2, CELL_INNER) +646 + + color("│", border, self.use_color), +647 | ] +-------------------------------------------------------------------------------- +658 | # 只渲染一个跟随光标的窗口,避免牲畜变多后把界面撑爆 + - start = max(0, min(max(0, len(animals) - MAX_VISIBLE_ANIMALS), + - self.animal_cursor - MAX_VISIBLE_ANIMALS // 2)) +659 + start = max( +660 + 0, +661 + min( +662 + max(0, len(animals) - MAX_VISIBLE_ANIMALS), +663 + self.animal_cursor - MAX_VISIBLE_ANIMALS // 2, +664 + ), +665 + ) +666 | for index in range(start, min(len(animals), start + MAX_VISIBLE_ANIMALS)): +-------------------------------------------------------------------------------- +671 | if animal["pending"] > 0: + - status = color(f"{animal['product']}x{animal['pending']}", + - C_BOLD + C_GOLD, self.use_color) +672 + status = color( +673 + f"{animal['product']}x{animal['pending']}", C_BOLD + C_GOLD, self.use_color +674 + ) +675 | elif animal["is_sick"]: +-------------------------------------------------------------------------------- +683 | + - head = marker + color(f" #{animal['id']} {animal['name']}", + - C_WHITE, self.use_color) +684 + head = marker + color(f" #{animal['id']} {animal['name']}", C_WHITE, self.use_color) +685 | rows.append(pad(head, 13) + status) +-------------------------------------------------------------------------------- +689 | rows.append(color(f" …还有 {hidden} 只(Tab 切换)", C_DIM, self.use_color)) + - pasture = draw_box(f"牧场 {snap['animal_count']}/{snap['max_animals']}", rows, + - side_width, accent=C_ORANGE, use_color=self.use_color) +690 + pasture = draw_box( +691 + f"牧场 {snap['animal_count']}/{snap['max_animals']}", +692 + rows, +693 + side_width, +694 + accent=C_ORANGE, +695 + use_color=self.use_color, +696 + ) +697 | +-------------------------------------------------------------------------------- +719 | ) + - market = draw_box("市场行情", market_rows, side_width, + - accent=C_CYAN, use_color=self.use_color) +720 + market = draw_box( +721 + "市场行情", market_rows, side_width, accent=C_CYAN, use_color=self.use_color +722 + ) +723 | +-------------------------------------------------------------------------------- +780 | rows = [ + - color(" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。", C_WHITE, self.use_color), +781 + color( +782 + " 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。", +783 + C_WHITE, +784 + self.use_color, +785 + ), +786 | "", +787 | color(" 时间", C_BOLD + C_GOLD, self.use_color) + - + color(" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。", C_GRAY, self.use_color), +788 + + color( +789 + " 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。", +790 + C_GRAY, +791 + self.use_color, +792 + ), +793 | color(" 水分", C_BOLD + C_GOLD, self.use_color) + - + color(" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。", C_GRAY, self.use_color), +794 + + color( +795 + " 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。", +796 + C_GRAY, +797 + self.use_color, +798 + ), +799 | color(" 行动点", C_BOLD + C_GOLD, self.use_color) + - + color(" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color), +800 + + color( +801 + " 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color +802 + ), +803 | color(" 牲畜", C_BOLD + C_GOLD, self.use_color) + - + color(" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color), +804 + + color( +805 + " 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color +806 + ), +807 | color(" 市场", C_BOLD + C_GOLD, self.use_color) +808 | + color(" 作物和畜产品价格每天波动 ±18%,高点卖出更赚。", C_GRAY, self.use_color), +809 | "", +810 | color(" 快捷键", C_BOLD + C_GOLD, self.use_color) + - + color(" P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出", C_GRAY, self.use_color), +811 + + color( +812 + " P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出", +813 + C_GRAY, +814 + self.use_color, +815 + ), +816 | color(" ", C_GRAY, self.use_color) + - + color(" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置", C_GRAY, self.use_color), +817 + + color( +818 + " B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置", +819 + C_GRAY, +820 + self.use_color, +821 + ), +822 | "", +823 | color(" AI 接入", C_BOLD + C_GOLD, self.use_color) + - + color(" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,", + - C_GRAY, self.use_color), +824 + + color( +825 + " 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,", +826 + C_GRAY, +827 + self.use_color, +828 + ), +829 | color(" ", C_GRAY, self.use_color) + - + color(" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。", + - C_GRAY, self.use_color), +830 + + color( +831 + " AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。", C_GRAY, self.use_color +832 + ), +833 | "", + | + +unformatted: File would be reformatted + --> main.py:101:26 + | +100 | p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)") + - p_serve.add_argument("--host", default="127.0.0.1", + - help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0") +101 + p_serve.add_argument( +102 + "--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0" +103 + ) +104 | p_serve.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765") +105 | p_serve.add_argument("--token", default=None, help="访问令牌,设置后接口需要携带") + - p_serve.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS, + - help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒") +106 + p_serve.add_argument( +107 + "--tick", +108 + type=float, +109 + default=config.DEFAULT_TICK_SECONDS, +110 + help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒", +111 + ) +112 | p_serve.add_argument("--save-dir", default=None, help="存档目录,默认 <项目>/saves") +113 | p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称") +114 | p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限") + - p_serve.add_argument("--idle-timeout", type=float, default=1800.0, + - help="无人访问多少秒后把牧场移出内存(存档保留)") +115 + p_serve.add_argument( +116 + "--idle-timeout", +117 + type=float, +118 + default=1800.0, +119 + help="无人访问多少秒后把牧场移出内存(存档保留)", +120 + ) +121 | p_serve.add_argument("--no-remote-control", action="store_true", help="禁止接口停机") +122 | p_serve.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求") + - p_serve.add_argument("--log-level", default="info", + - choices=["critical", "error", "warning", "info", "debug"]) +123 + p_serve.add_argument( +124 + "--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"] +125 + ) +126 | p_serve.add_argument("--log-file", default=None, help="守护进程日志路径") +-------------------------------------------------------------------------------- +151 | p_play.add_argument("--no-color", action="store_true") + - p_play.add_argument("--no-realtime", action="store_true", + - help="不用 WebSocket,改走 HTTP 轮询") +152 + p_play.add_argument("--no-realtime", action="store_true", help="不用 WebSocket,改走 HTTP 轮询") +153 | +-------------------------------------------------------------------------------- +161 | p_do.add_argument("action", help="动作名:plant / water / harvest / ...") + - p_do.add_argument("params", nargs="?", default="{}", + - help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\'') +162 + p_do.add_argument( +163 + "params", nargs="?", default="{}", help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\'' +164 + ) +165 | _add_client_args(p_do) +-------------------------------------------------------------------------------- +214 | return RemoteFarm( + - endpoint=args.url, farm=getattr(args, "farm", "default"), +215 + endpoint=args.url, +216 + farm=getattr(args, "farm", "default"), +217 | token=getattr(args, "token", None), +218 | name=getattr(args, "name", "") or "cli", + - realtime=realtime, autostart=True, +219 + realtime=realtime, +220 + autostart=True, +221 | ) +-------------------------------------------------------------------------------- +235 | argv = [ + - "--host", args.host, "--port", str(args.port), + - "--tick", str(args.tick), "--world", args.world, + - "--max-sessions", str(args.max_sessions), + - "--idle-timeout", str(args.idle_timeout), + - "--log-level", args.log_level, +236 + "--host", +237 + args.host, +238 + "--port", +239 + str(args.port), +240 + "--tick", +241 + str(args.tick), +242 + "--world", +243 + args.world, +244 + "--max-sessions", +245 + str(args.max_sessions), +246 + "--idle-timeout", +247 + str(args.idle_timeout), +248 + "--log-level", +249 + args.log_level, +250 | ] +-------------------------------------------------------------------------------- +307 | for row in world["leaderboard"][:5]: + - print(f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}" + - f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}") +308 + print( +309 + f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}" +310 + f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}" +311 + ) +312 | +-------------------------------------------------------------------------------- +342 | label = f"{client.farm} @ {client.host}" + - app = TuiApp(client, save_path=None, use_color=not args.no_color, + - autosave=True, server_label=label) +343 + app = TuiApp( +344 + client, save_path=None, use_color=not args.no_color, autosave=True, server_label=label +345 + ) +346 | try: +-------------------------------------------------------------------------------- +392 | except json.JSONDecodeError as exc: + - return die(f"参数不是合法 JSON:{exc}", '例子:python main.py do plant \'{"plot":1,"crop":"wheat"}\'') +393 + return die( +394 + f"参数不是合法 JSON:{exc}", +395 + '例子:python main.py do plant \'{"plot":1,"crop":"wheat"}\'', +396 + ) +397 | if not isinstance(params, dict): +-------------------------------------------------------------------------------- +403 | except GameError as exc: + - return die(f"{args.action} 失败({exc.code}):{exc.message}", + - "看看现在能做什么:python main.py actions") +404 + return die( +405 + f"{args.action} 失败({exc.code}):{exc.message}", +406 + "看看现在能做什么:python main.py actions", +407 + ) +408 | + - print(ok("✓ ") + str(result.get("message", "完成")) + - + dim(f" ({time.time() - started:.2f}s)")) + - extra = {k: v for k, v in result.items() + - if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list))} +409 + print(ok("✓ ") + str(result.get("message", "完成")) + dim(f" ({time.time() - started:.2f}s)")) +410 + extra = { +411 + k: v +412 + for k, v in result.items() +413 + if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list)) +414 + } +415 | if extra: +416 | print(dim(" " + " ".join(f"{k}={v}" for k, v in extra.items()))) +417 | state = result.get("state") or {} +418 | if state: + - print(f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " + - f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}") +419 + print( +420 + f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " +421 + f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}" +422 + ) +423 | return 0 +-------------------------------------------------------------------------------- +432 | except json.JSONDecodeError as exc: + - return die(f"actions 不是合法 JSON:{exc}", + - '例子:python main.py batch \'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'') +433 + return die( +434 + f"actions 不是合法 JSON:{exc}", +435 + '例子:python main.py batch \'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'', +436 + ) +437 | if not isinstance(items, list): +-------------------------------------------------------------------------------- +453 | items = client.available_actions() + - print(f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " + - f"行动点 {state.get('ap')}/{state.get('max_ap')}") +454 + print( +455 + f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " +456 + f"行动点 {state.get('ap')}/{state.get('max_ap')}" +457 + ) +458 | if not items: +-------------------------------------------------------------------------------- +465 | cost_text = " ".join(f"{k}:{v}" for k, v in cost.items()) or "免费" + - print(f" [{index:>2}] {item['action']:<12} {json.dumps(item['params'], ensure_ascii=False):<42}" + - f" ({cost_text})") +466 + print( +467 + f" [{index:>2}] {item['action']:<12} {json.dumps(item['params'], ensure_ascii=False):<42}" +468 + f" ({cost_text})" +469 + ) +470 | print(dim(f" {item.get('reason', '')}")) +-------------------------------------------------------------------------------- +481 | print(f"牧场 {snap.get('farm')}") + - print(f"第 {snap.get('day')} 天" + - + (f" · {snap.get('seconds_to_tick')}s 后进入下一天" + - if snap.get("seconds_to_tick") is not None else " · 已暂停")) + - print(f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} " + - f"({snap.get('exp')}/{snap.get('exp_needed')})") +482 + print( +483 + f"第 {snap.get('day')} 天" +484 + + ( +485 + f" · {snap.get('seconds_to_tick')}s 后进入下一天" +486 + if snap.get("seconds_to_tick") is not None +487 + else " · 已暂停" +488 + ) +489 + ) +490 + print( +491 + f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} " +492 + f"({snap.get('exp')}/{snap.get('exp_needed')})" +493 + ) +494 | print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}") +-------------------------------------------------------------------------------- +500 | status = "可收获" if plot["is_ripe"] else f"还需{plot['days_left']}天" + - print(f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}" + - f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}") +501 + print( +502 + f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}" +503 + f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}" +504 + ) +505 | for animal in snap.get("animals", []): + - note = (f"{animal['product']} x{animal['pending']} 待收" if animal["pending"] + - else ("已喂食" if animal["fed"] else "未喂食")) +506 + note = ( +507 + f"{animal['product']} x{animal['pending']} 待收" +508 + if animal["pending"] +509 + else ("已喂食" if animal["fed"] else "未喂食") +510 + ) +511 | print(f" #{animal['id']} {animal['name']:<4} {note}") +-------------------------------------------------------------------------------- +537 | online = snap.get("online") + - head = (f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | " + - f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | " + - f"净资产 {snap.get('net_worth')}") +538 + head = ( +539 + f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | " +540 + f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | " +541 + f"净资产 {snap.get('net_worth')}" +542 + ) +543 | if online is not None: +-------------------------------------------------------------------------------- +572 | world = client.world() + - print(f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · " + - f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个") +573 + print( +574 + f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · " +575 + f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个" +576 + ) +577 | print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n") +578 | print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}") +579 | for row in world.get("leaderboard", []): + - print(f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} " + - f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}") +580 + print( +581 + f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} " +582 + f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}" +583 + ) +584 | return 0 +-------------------------------------------------------------------------------- +660 | health = info.get("health") or {} + - print(ok("✓ 服务器正在运行") + - + f" http://{info['host']}:{info['port']}/ " + - + dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}")) +661 + print( +662 + ok("✓ 服务器正在运行") +663 + + f" http://{info['host']}:{info['port']}/ " +664 + + dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}") +665 + ) +666 | else: +-------------------------------------------------------------------------------- +677 | return handler(args) + - except SystemExit as exc: # make_client 里的友好退出 +678 + except SystemExit as exc: # make_client 里的友好退出 +679 | return int(exc.code or 0) + | + +unformatted: File would be reformatted + --> tests\test_engine.py:19:26 + | +18 | + - from farm import config # noqa: E402 +19 + from farm import config # noqa: E402 +20 | from farm.engine import FarmEngine, GameError # noqa: E402 + - from farm.models import GameState, Plot # noqa: E402 +21 + from farm.models import GameState, Plot # noqa: E402 +22 | from farm.storage import load_state, save_state # noqa: E402 +-------------------------------------------------------------------------------- +192 | def test_buy_plot_without_gold(self) -> None: + - self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步 +193 + self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步 +194 | self.engine.state.gold = 0 +-------------------------------------------------------------------------------- +199 | def test_buy_plot_blocked_at_level_cap(self) -> None: + - self.engine.state.gold = 10 ** 6 +200 + self.engine.state.gold = 10**6 +201 | with self.assertRaises(GameError) as ctx: +-------------------------------------------------------------------------------- +264 | def test_pasture_limit(self) -> None: + - self.engine.state.gold = 10 ** 6 +265 + self.engine.state.gold = 10**6 +266 | for _ in range(config.MAX_ANIMALS): +-------------------------------------------------------------------------------- +282 | def test_buy_feed_validation(self) -> None: + - self.engine.state.gold = 10 ** 6 +283 + self.engine.state.gold = 10**6 +284 | with self.assertRaises(GameError): +-------------------------------------------------------------------------------- +352 | self.engine.buy_animal("chicken") + - self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言 +353 + self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言 +354 | self.engine.state.feed = 7 +-------------------------------------------------------------------------------- +404 | self.assertEqual(state.level, 1) + - self.assertIsNone(state.plots[0].crop) # 非法作物被清空 + - self.assertEqual(state.animals, []) # 非法动物被丢弃 + - self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值 +405 + self.assertIsNone(state.plots[0].crop) # 非法作物被清空 +406 + self.assertEqual(state.animals, []) # 非法动物被丢弃 +407 + self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值 +408 | self.assertGreaterEqual(len(state.plots), config.BASE_PLOTS) +409 | +410 | # ============================================================ 并发 +411 | def test_concurrent_operations_are_safe(self) -> None: + - self.engine.state.gold = 10 ** 6 +412 + self.engine.state.gold = 10**6 +413 | errors = [] + | + +unformatted: File would be reformatted + --> tests\test_server.py:35:22 + | +34 | HAS_FASTAPI = True + - except ImportError: # pragma: no cover - 取决于环境 +35 + except ImportError: # pragma: no cover - 取决于环境 +36 | HAS_FASTAPI = False +-------------------------------------------------------------------------------- +41 | HAS_UVICORN = True + - except ImportError: # pragma: no cover +42 + except ImportError: # pragma: no cover +43 | HAS_UVICORN = False +-------------------------------------------------------------------------------- +233 | self.tmp = tempfile.mkdtemp(prefix="farm-api-") + - self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, + - tick_seconds=60) +234 + self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, tick_seconds=60) +235 | self.app = create_app(self.cfg) +-------------------------------------------------------------------------------- +278 | self.assertIn("已种下", data["message"]) + - self.assertEqual(data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"]) +279 + self.assertEqual( +280 + data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"] +281 + ) +282 | +-------------------------------------------------------------------------------- +287 | """{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。""" + - data = self.client.post("/api/actions?farm=flat", + - json={"action": "plant", "plot": 1, "crop": "wheat"}).json() +288 + data = self.client.post( +289 + "/api/actions?farm=flat", json={"action": "plant", "plot": 1, "crop": "wheat"} +290 + ).json() +291 | self.assertTrue(data["ok"]) +-------------------------------------------------------------------------------- +298 | def test_action_error_shape(self) -> None: + - response = self.client.post("/api/actions?farm=err", + - json={"action": "plant", "params": {"plot": 1, "crop": "nope"}}) +299 + response = self.client.post( +300 + "/api/actions?farm=err", json={"action": "plant", "params": {"plot": 1, "crop": "nope"}} +301 + ) +302 | self.assertEqual(response.status_code, 400) +-------------------------------------------------------------------------------- +308 | def test_out_of_range_plot(self) -> None: + - response = self.client.post("/api/actions?farm=err", + - json={"action": "water", "params": {"plot": 999}}) +309 + response = self.client.post( +310 + "/api/actions?farm=err", json={"action": "water", "params": {"plot": 999}} +311 + ) +312 | self.assertEqual(response.status_code, 400) +313 | self.assertEqual(response.json()["error"], "no_such_plot") +314 | +315 | def test_bad_json_body(self) -> None: + - response = self.client.post("/api/actions?farm=err", content=b"{oops", + - headers={"Content-Type": "application/json"}) +316 + response = self.client.post( +317 + "/api/actions?farm=err", content=b"{oops", headers={"Content-Type": "application/json"} +318 + ) +319 | self.assertEqual(response.status_code, 400) +-------------------------------------------------------------------------------- +324 | names = {item["action"] for item in data["actions"]} + - self.assertIn("plant", names) # 有空地 + 有钱 → 可播种 + - self.assertNotIn("harvest", names) # 还没种东西 → 不可收获 +325 + self.assertIn("plant", names) # 有空地 + 有钱 → 可播种 +326 + self.assertNotIn("harvest", names) # 还没种东西 → 不可收获 +327 | +328 | def test_batch_endpoint(self) -> None: + - items = {"actions": [ + - {"action": "buy_feed", "params": {"quantity": 5}}, + - {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + - {"action": "harvest", "params": {"plot": 1}}, # 必然失败 + - ]} +329 + items = { +330 + "actions": [ +331 + {"action": "buy_feed", "params": {"quantity": 5}}, +332 + {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, +333 + {"action": "harvest", "params": {"plot": 1}}, # 必然失败 +334 + ] +335 + } +336 | data = self.client.post("/api/batch?farm=multi", json=items).json() +-------------------------------------------------------------------------------- +341 | def test_log_entries_and_since(self) -> None: + - self.client.post("/api/actions?farm=logging", + - json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}) +342 + self.client.post( +343 + "/api/actions?farm=logging", +344 + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, +345 + ) +346 | entries = self.client.get("/api/log?farm=logging").json()["entries"] +-------------------------------------------------------------------------------- +355 | def test_farms_list_and_isolation(self) -> None: + - self.client.post("/api/actions?farm=alice", + - json={"action": "buy_feed", "params": {"quantity": 10}}) +356 + self.client.post( +357 + "/api/actions?farm=alice", json={"action": "buy_feed", "params": {"quantity": 10}} +358 + ) +359 | self.client.post("/api/actions?farm=bob", json={"action": "save"}) +360 | rows = self.client.get("/api/farms").json()["farms"] +361 | by_name = {row["farm"]: row for row in rows} +362 | self.assertIn("alice", by_name) + - self.assertEqual(by_name["alice"]["gold"], + - config.START_GOLD - 10 * config.FEED_UNIT_PRICE) +363 + self.assertEqual(by_name["alice"]["gold"], config.START_GOLD - 10 * config.FEED_UNIT_PRICE) +364 | +-------------------------------------------------------------------------------- +381 | def test_admin_save(self) -> None: + - self.client.post("/api/actions?farm=saver", + - json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}) +382 + self.client.post( +383 + "/api/actions?farm=saver", +384 + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, +385 + ) +386 | data = self.client.post("/api/admin/save").json() +-------------------------------------------------------------------------------- +432 | def test_action_blocked_without_token(self) -> None: + - response = self.client.post("/api/actions", + - json={"action": "buy_plot"}) +433 + response = self.client.post("/api/actions", json={"action": "buy_plot"}) +434 | self.assertEqual(response.status_code, 401) +-------------------------------------------------------------------------------- +448 | cls.url = f"http://127.0.0.1:{cls.port}" + - app = create_app(ServerConfig(host="127.0.0.1", port=cls.port, save_dir=cls.tmp, + - tick_seconds=600, autosave_interval=3600)) +449 + app = create_app( +450 + ServerConfig( +451 + host="127.0.0.1", +452 + port=cls.port, +453 + save_dir=cls.tmp, +454 + tick_seconds=600, +455 + autosave_interval=3600, +456 + ) +457 + ) +458 | import uvicorn +459 | + - cls.server = uvicorn.Server(uvicorn.Config( + - app, host="127.0.0.1", port=cls.port, log_level="warning", + - access_log=False, lifespan="on", + - )) +460 + cls.server = uvicorn.Server( +461 + uvicorn.Config( +462 + app, +463 + host="127.0.0.1", +464 + port=cls.port, +465 + log_level="warning", +466 + access_log=False, +467 + lifespan="on", +468 + ) +469 + ) +470 | cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True) +-------------------------------------------------------------------------------- +479 | time.sleep(0.2) + - else: # pragma: no cover +480 + else: # pragma: no cover +481 | raise unittest.SkipTest("服务器没能在 20 秒内起来") +-------------------------------------------------------------------------------- +491 | + - return RemoteFarm(endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, + - autostart=True) +492 + return RemoteFarm( +493 + endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, autostart=True +494 + ) +495 | +-------------------------------------------------------------------------------- +506 | with self.assertRaises(GameError) as ctx: + - client.plant(1, "carrot") # 地已经占用了 +507 + client.plant(1, "carrot") # 地已经占用了 +508 | self.assertEqual(ctx.exception.code, "plot_busy") +509 | +510 | actions = client.available_actions() +511 | names = {item["action"] for item in actions} + - self.assertIn("plant", names) # 还有空地可以种 + - self.assertNotIn("harvest", names) # 小麦还没成熟 +512 + self.assertIn("plant", names) # 还有空地可以种 +513 + self.assertNotIn("harvest", names) # 小麦还没成熟 +514 | + - client.water(1) # 播种后水分 2/3,可以补满 +515 + client.water(1) # 播种后水分 2/3,可以补满 +516 | self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY) +517 | with self.assertRaises(GameError) as ctx: + - client.water(1) # 水分已满,再浇就是错的 +518 + client.water(1) # 水分已满,再浇就是错的 +519 | self.assertEqual(ctx.exception.code, "already_watered") +-------------------------------------------------------------------------------- +541 | time.sleep(0.1) + - self.assertEqual(watcher.state.plots[0].crop, "carrot", + - "实时通道应该把另一方的操作推过来") +542 + self.assertEqual( +543 + watcher.state.plots[0].crop, "carrot", "实时通道应该把另一方的操作推过来" +544 + ) +545 | self.assertEqual(watcher.mode, "ws") + | + +unformatted: File would be reformatted + --> tools\preview_tui.py:30:30 + | +29 | from farm.engine import FarmEngine # noqa: E402 + - from farm.tui import TuiApp # noqa: E402 +30 + from farm.tui import TuiApp # noqa: E402 +31 | +-------------------------------------------------------------------------------- +39 | client = RemoteFarm(endpoint=url, farm=farm, token=token, realtime=False) + - app = TuiApp(client, use_color=True, autosave=False, + - server_label=f"{client.farm} @ {client.host}") +40 + app = TuiApp( +41 + client, use_color=True, autosave=False, server_label=f"{client.farm} @ {client.host}" +42 + ) +43 | return client, app +-------------------------------------------------------------------------------- +103 | parser.add_argument("--color", action="store_true", help="保留 ANSI 颜色") + - parser.add_argument("--url", default=None, + - help="连到真实服务器渲染(例如 http://127.0.0.1:8765)") +104 + parser.add_argument( +105 + "--url", default=None, help="连到真实服务器渲染(例如 http://127.0.0.1:8765)" +106 + ) +107 | parser.add_argument("--farm", default="default", help="--url 模式下要看的牧场") +-------------------------------------------------------------------------------- +116 | state = engine.snapshot() + - print(f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · " + - f"金币 {state.get('gold')} · 在线 {state.get('online')}\n") +117 + print( +118 + f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · " +119 + f"金币 {state.get('gold')} · 在线 {state.get('online')}\n" +120 + ) +121 | else: + | + +unformatted: File would be reformatted + --> tools\ws_demo.py:41:15 + | +40 | state = welcome.get("state", {}) + - print(f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · " + - f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个") +41 + print( +42 + f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · " +43 + f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个" +44 + ) +45 | +-------------------------------------------------------------------------------- +65 | if kind == "result": + - print(f"← result(id={message.get('id')}):{message.get('message')} " + - f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]") +66 + print( +67 + f"← result(id={message.get('id')}):{message.get('message')} " +68 + f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]" +69 + ) +70 | elif kind == "state": +71 | 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')}") +72 + print( +73 + f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · " +74 + f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · " +75 + f"在线 {message.get('online')}" +76 + ) +77 | elif kind == "day": + | + +23 files would be reformatted, 4 files already formatted diff --git a/_sync.log b/_sync.log new file mode 100644 index 0000000..19c63c5 --- /dev/null +++ b/_sync.log @@ -0,0 +1,33 @@ +Resolved 28 packages in 7ms +Downloading ruff (10.1MiB) + Downloaded ruff +Prepared 6 packages in 4m 29s +warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance. + If the cache and target directories are on different filesystems, hardlinking may not be supported. + If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning. +Installed 24 packages in 12.11s + + annotated-doc==0.0.5 + + annotated-types==0.8.0 + + anyio==4.15.1 + + certifi==2026.7.22 + + click==8.5.0 + + fastapi==0.141.1 + + h11==0.16.0 + + httpcore==1.0.9 + + httptools==0.8.0 + + httpx==0.28.1 + + idna==3.20 + + nodeenv==1.10.0 + + pydantic==2.13.5 + + pydantic-core==2.46.5 + + pyright==1.1.414 + + python-dotenv==1.2.3 + + pyyaml==6.0.3 + + ruff==0.16.8 + + starlette==1.6.0 + + typing-extensions==4.16.0 + + typing-inspection==0.4.4 + + uvicorn==0.53.0 + + watchfiles==1.2.0 + + websockets==17.1 +EXIT=0 diff --git a/_uvlock.log b/_uvlock.log new file mode 100644 index 0000000..ba2ca8a --- /dev/null +++ b/_uvlock.log @@ -0,0 +1,294 @@ +DEBUG Found workspace root: `D:\SimpleFarm` +DEBUG Adding root workspace member: `D:\SimpleFarm` +DEBUG Found workspace configuration at `D:\SimpleFarm\pyproject.toml` +DEBUG Searching for user configuration in: `C:\Users\Administrator\AppData\Roaming\uv\uv.toml` +DEBUG uv 0.12.14 (ce3bd7931 2026-09-14 x86_64-pc-windows-msvc) +DEBUG Found project root: `D:\SimpleFarm` +DEBUG Reading Python requests from version file at `D:\SimpleFarm\.python-version` +DEBUG Using Python request `3.13` from version file at `.python-version` +DEBUG Checking for Python environment at: `.venv` +DEBUG The project environment's Python version satisfies the request: `Python 3.13` +DEBUG Using request connect timeout of 10s and read timeout of 45s +DEBUG Found static `pyproject.toml` for: simplefarm @ file:///D:/SimpleFarm +DEBUG Solving with installed Python version: 3.13.15 +DEBUG Solving with target Python version: >=3.10 +DEBUG Adding direct dependency: simplefarm* +DEBUG Adding direct dependency: simplefarm:dev* +DEBUG Searching for a compatible version of simplefarm @ file:///D:/SimpleFarm (*) +DEBUG Adding direct dependency: fastapi>=0.110 +DEBUG Adding direct dependency: uvicorn[standard]>=0.27 +DEBUG Searching for a compatible version of simplefarm @ file:///D:/SimpleFarm (*) +DEBUG Adding direct dependency: simplefarm:dev==2.0.0 +DEBUG Searching for a compatible version of simplefarm @ file:///D:/SimpleFarm (==2.0.0) +DEBUG Adding direct dependency: httpx>=0.27 +DEBUG Adding direct dependency: pyright>=1.1.390 +DEBUG Adding direct dependency: ruff>=0.8.0 +DEBUG Found fresh response for: https://pypi.org/simple/fastapi/ +DEBUG Found fresh response for: https://pypi.org/simple/uvicorn/ +DEBUG Searching for a compatible version of fastapi (>=0.110) +DEBUG Selecting: fastapi==0.141.1 [compatible] (fastapi-0.141.1-py3-none-any.whl) +DEBUG Found fresh response for: https://pypi.org/simple/httpx/ +DEBUG Found fresh response for: https://pypi.org/simple/pyright/ +DEBUG Found fresh response for: https://pypi.org/simple/ruff/ +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl.metadata +DEBUG Adding transitive dependency for fastapi==0.141.1: annotated-doc>=0.0.2 +DEBUG Adding transitive dependency for fastapi==0.141.1: pydantic>=2.9.0 +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl.metadata +DEBUG Adding transitive dependency for fastapi==0.141.1: starlette>=0.46.0 +DEBUG Adding transitive dependency for fastapi==0.141.1: typing-extensions>=4.8.0 +DEBUG Adding transitive dependency for fastapi==0.141.1: typing-inspection>=0.4.2 +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl.metadata +DEBUG Searching for a compatible version of uvicorn[standard] (>=0.27) +DEBUG Selecting: uvicorn==0.53.0 [compatible] (uvicorn-0.53.0-py3-none-any.whl) +DEBUG Adding transitive dependency for uvicorn==0.53.0: uvicorn==0.53.0 +DEBUG Adding transitive dependency for uvicorn==0.53.0: uvicorn[standard]==0.53.0 +DEBUG Searching for a compatible version of uvicorn (==0.53.0) +DEBUG Selecting: uvicorn==0.53.0 [compatible] (uvicorn-0.53.0-py3-none-any.whl) +DEBUG Adding transitive dependency for uvicorn==0.53.0: click>=7.0 +DEBUG Adding transitive dependency for uvicorn==0.53.0: h11>=0.8 +DEBUG Adding transitive dependency for uvicorn==0.53.0: typing-extensions{python_full_version < '3.11'}>=4.0 +DEBUG Searching for a compatible version of uvicorn[standard] (==0.53.0) +DEBUG Selecting: uvicorn==0.53.0 [compatible] (uvicorn-0.53.0-py3-none-any.whl) +DEBUG Adding transitive dependency for uvicorn==0.53.0: httptools>=0.8.0 +DEBUG Adding transitive dependency for uvicorn==0.53.0: python-dotenv>=0.13 +DEBUG Adding transitive dependency for uvicorn==0.53.0: pyyaml>=5.1 +DEBUG Adding transitive dependency for uvicorn==0.53.0: uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'}>=0.15.1 +DEBUG Adding transitive dependency for uvicorn==0.53.0: watchfiles>=0.20 +DEBUG Adding transitive dependency for uvicorn==0.53.0: websockets>=13.0 +DEBUG Searching for a compatible version of httpx (>=0.27) +DEBUG Selecting: httpx==0.28.1 [compatible] (httpx-0.28.1-py3-none-any.whl) +DEBUG Adding transitive dependency for httpx==0.28.1: anyio* +DEBUG Adding transitive dependency for httpx==0.28.1: certifi* +DEBUG Adding transitive dependency for httpx==0.28.1: httpcore>=1.dev0, <2.dev0 +DEBUG Adding transitive dependency for httpx==0.28.1: idna* +DEBUG Searching for a compatible version of pyright (>=1.1.390) +DEBUG Selecting: pyright==1.1.414 [compatible] (pyright-1.1.414-py3-none-any.whl) +DEBUG Adding transitive dependency for pyright==1.1.414: nodeenv>=1.6.0 +DEBUG Adding transitive dependency for pyright==1.1.414: typing-extensions>=4.1 +DEBUG Searching for a compatible version of ruff (>=0.8.0) +DEBUG Selecting: ruff==0.16.8 [compatible] (ruff-0.16.8-py3-none-linux_armv6l.whl) +DEBUG Found fresh response for: https://pypi.org/simple/annotated-doc/ +DEBUG Found fresh response for: https://pypi.org/simple/starlette/ +DEBUG Found fresh response for: https://pypi.org/simple/typing-inspection/ +DEBUG Found fresh response for: https://pypi.org/simple/pydantic/ +DEBUG Found fresh response for: https://pypi.org/simple/typing-extensions/ +DEBUG Searching for a compatible version of annotated-doc (>=0.0.2) +DEBUG Selecting: annotated-doc==0.0.5 [compatible] (annotated_doc-0.0.5-py3-none-any.whl) +DEBUG Found fresh response for: https://pypi.org/simple/h11/ +DEBUG Found fresh response for: https://pypi.org/simple/click/ +DEBUG Found fresh response for: https://pypi.org/simple/python-dotenv/ +DEBUG Found fresh response for: https://pypi.org/simple/httptools/ +DEBUG Found fresh response for: https://pypi.org/simple/pyyaml/ +DEBUG Found fresh response for: https://pypi.org/simple/uvloop/ +DEBUG Found fresh response for: https://pypi.org/simple/watchfiles/ +DEBUG Found fresh response for: https://pypi.org/simple/anyio/ +DEBUG Found fresh response for: https://pypi.org/simple/certifi/ +DEBUG Found fresh response for: https://pypi.org/simple/websockets/ +DEBUG Found fresh response for: https://pypi.org/simple/idna/ +DEBUG Found fresh response for: https://pypi.org/simple/httpcore/ +DEBUG Found fresh response for: https://pypi.org/simple/nodeenv/ +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl.metadata +DEBUG Searching for a compatible version of pydantic (>=2.9.0) +DEBUG Selecting: pydantic==2.13.5 [compatible] (pydantic-2.13.5-py3-none-any.whl) +DEBUG Adding transitive dependency for pydantic==2.13.5: annotated-types>=0.6.0 +DEBUG Adding transitive dependency for pydantic==2.13.5: pydantic-core>=2.46.5, <2.46.5+ +DEBUG Adding transitive dependency for pydantic==2.13.5: typing-extensions>=4.14.1 +DEBUG Adding transitive dependency for pydantic==2.13.5: typing-inspection>=0.4.2 +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl.metadata +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl.metadata +DEBUG Found fresh response for: https://pypi.org/simple/annotated-types/ +DEBUG No cache entry for: https://pypi.org/simple/pydantic-core/ +DEBUG Sending fresh GET request for: https://pypi.org/simple/pydantic-core/ +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl.metadata +DEBUG Searching for a compatible version of pydantic-core (>=2.46.5, <2.46.5+) +DEBUG Selecting: pydantic-core==2.46.5 [compatible] (pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl) +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl.metadata +DEBUG Adding transitive dependency for pydantic-core==2.46.5: typing-extensions>=4.14.1 +DEBUG Searching for a compatible version of starlette (>=0.46.0) +DEBUG Selecting: starlette==1.6.0 [compatible] (starlette-1.6.0-py3-none-any.whl) +DEBUG Adding transitive dependency for starlette==1.6.0: anyio>=3.6.2, <5 +DEBUG Adding transitive dependency for starlette==1.6.0: typing-extensions{python_full_version < '3.13'}>=4.10.0 +DEBUG Searching for a compatible version of typing-extensions (>=4.14.1) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.11'} (>=4.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions==4.16.0 +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions{python_full_version < '3.11'}==4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.11'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.13'} (>=4.10.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions==4.16.0 +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions{python_full_version < '3.13'}==4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.13'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-inspection (>=0.4.2) +DEBUG Selecting: typing-inspection==0.4.4 [compatible] (typing_inspection-0.4.4-py3-none-any.whl) +DEBUG Adding transitive dependency for typing-inspection==0.4.4: typing-extensions>=4.15.0 +DEBUG Searching for a compatible version of click (>=7.0) +DEBUG Selecting: click==8.5.0 [compatible] (click-8.5.0-py3-none-any.whl) +DEBUG Searching for a compatible version of h11 (>=0.8) +DEBUG Selecting: h11==0.16.0 [compatible] (h11-0.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of httptools (>=0.8.0) +DEBUG Selecting: httptools==0.8.0 [compatible] (httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of python-dotenv (>=0.13) +DEBUG Selecting: python-dotenv==1.2.3 [compatible] (python_dotenv-1.2.3-py3-none-any.whl) +DEBUG Searching for a compatible version of pyyaml (>=5.1) +DEBUG Selecting: pyyaml==6.0.3 [compatible] (pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl) +DEBUG Searching for a compatible version of uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'} (>=0.15.1) +DEBUG Selecting: uvloop==0.22.1 [compatible] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Adding transitive dependency for uvloop==0.22.1: uvloop==0.22.1 +DEBUG Adding transitive dependency for uvloop==0.22.1: uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'}==0.22.1 +DEBUG Searching for a compatible version of uvloop (==0.22.1) +DEBUG Selecting: uvloop==0.22.1 [compatible] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'} (==0.22.1) +DEBUG Selecting: uvloop==0.22.1 [compatible] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of watchfiles (>=0.20) +DEBUG Selecting: watchfiles==1.2.0 [compatible] (watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl) +DEBUG Adding transitive dependency for watchfiles==1.2.0: anyio>=3.0.0 +DEBUG Searching for a compatible version of websockets (>=13.0) +DEBUG Forking Python requirement `>=3.10` on `>=3.11` for websockets==17.1 (split `python_full_version == '3.10.*'`, split `python_full_version >= '3.11'`) +DEBUG Narrowed `requires-python` bound to: >=3.10, <3.11 +DEBUG Narrowed `requires-python` bound to: >=3.11 +DEBUG Solving split (markers: python_full_version >= '3.11') (requires-python: RequiresPython { specifiers: VersionSpecifiers([VersionSpecifier { operator: GreaterThanEqual, version: "3.11" }]), range: RequiresPythonRange(LowerBound(Included("3.11")), UpperBound(Unbounded)) }) +DEBUG Searching for a compatible version of websockets (>=13.0) +DEBUG Selecting: websockets==17.1 [compatible] (websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl) +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl.metadata +DEBUG Searching for a compatible version of anyio (>=3.6.2, <5) +DEBUG Selecting: anyio==4.15.1 [compatible] (anyio-4.15.1-py3-none-any.whl) +DEBUG Adding transitive dependency for anyio==4.15.1: idna>=2.8 +DEBUG Adding transitive dependency for anyio==4.15.1: typing-extensions{python_full_version < '3.15'}>=4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.15'} (>=4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions==4.16.0 +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions{python_full_version < '3.15'}==4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.15'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [compatible] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of certifi (*) +DEBUG Selecting: certifi==2026.7.22 [compatible] (certifi-2026.7.22-py3-none-any.whl) +DEBUG Searching for a compatible version of httpcore (>=1.dev0, <2.dev0) +DEBUG Selecting: httpcore==1.0.9 [compatible] (httpcore-1.0.9-py3-none-any.whl) +DEBUG Adding transitive dependency for httpcore==1.0.9: certifi* +DEBUG Adding transitive dependency for httpcore==1.0.9: h11>=0.16 +DEBUG Searching for a compatible version of idna (>=2.8) +DEBUG Selecting: idna==3.20 [compatible] (idna-3.20-py3-none-any.whl) +DEBUG Searching for a compatible version of nodeenv (>=1.6.0) +DEBUG Selecting: nodeenv==1.10.0 [compatible] (nodeenv-1.10.0-py2.py3-none-any.whl) +DEBUG Searching for a compatible version of annotated-types (>=0.6.0) +DEBUG Selecting: annotated-types==0.8.0 [compatible] (annotated_types-0.8.0-py3-none-any.whl) +DEBUG Tried 26 versions: annotated-doc 1, annotated-types 1, anyio 1, certifi 1, click 1, fastapi 1, h11 1, httpcore 1, httptools 1, httpx 1, idna 1, nodeenv 1, pydantic 1, pydantic-core 1, pyright 1, python-dotenv 1, pyyaml 1, ruff 1, simplefarm 1, starlette 1, typing-extensions 1, typing-inspection 1, uvicorn 1, uvloop 1, watchfiles 1, websockets 1 +DEBUG split `python_full_version >= '3.11'` resolution took 0.005s +DEBUG Solving split (markers: python_full_version == '3.10.*') (requires-python: RequiresPython { specifiers: VersionSpecifiers([VersionSpecifier { operator: GreaterThanEqual, version: "3.10" }, VersionSpecifier { operator: LessThan, version: "3.11" }]), range: RequiresPythonRange(LowerBound(Included("3.10")), UpperBound(Excluded("3.11"))) }) +DEBUG Searching for a compatible version of websockets (>=13.0) +DEBUG No compatible version found for: Python +DEBUG Recording unit propagation conflict of Python from incompatibility of (websockets) +DEBUG Searching for a compatible version of typing-extensions (>=4.8.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.11'} (>=4.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.11'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'} (>=0.15.1) +DEBUG Selecting: uvloop==0.22.1 [preference] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of uvloop (==0.22.1) +DEBUG Selecting: uvloop==0.22.1 [preference] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of uvloop{platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'} (==0.22.1) +DEBUG Selecting: uvloop==0.22.1 [preference] (uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of httpx (>=0.27) +DEBUG Selecting: httpx==0.28.1 [preference] (httpx-0.28.1-py3-none-any.whl) +DEBUG Searching for a compatible version of pyright (>=1.1.390) +DEBUG Selecting: pyright==1.1.414 [preference] (pyright-1.1.414-py3-none-any.whl) +DEBUG Searching for a compatible version of ruff (>=0.8.0) +DEBUG Selecting: ruff==0.16.8 [preference] (ruff-0.16.8-py3-none-linux_armv6l.whl) +DEBUG Searching for a compatible version of annotated-doc (>=0.0.2) +DEBUG Selecting: annotated-doc==0.0.5 [preference] (annotated_doc-0.0.5-py3-none-any.whl) +DEBUG Searching for a compatible version of pydantic (>=2.9.0) +DEBUG Selecting: pydantic==2.13.5 [preference] (pydantic-2.13.5-py3-none-any.whl) +DEBUG Searching for a compatible version of pydantic-core (>=2.46.5, <2.46.5+) +DEBUG Selecting: pydantic-core==2.46.5 [preference] (pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl) +DEBUG Searching for a compatible version of starlette (>=0.46.0) +DEBUG Selecting: starlette==1.6.0 [preference] (starlette-1.6.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.13'} (>=4.10.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.13'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of typing-inspection (>=0.4.2) +DEBUG Selecting: typing-inspection==0.4.4 [preference] (typing_inspection-0.4.4-py3-none-any.whl) +DEBUG Searching for a compatible version of click (>=7.0) +DEBUG Selecting: click==8.5.0 [preference] (click-8.5.0-py3-none-any.whl) +DEBUG Searching for a compatible version of h11 (>=0.8) +DEBUG Selecting: h11==0.16.0 [preference] (h11-0.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of httptools (>=0.8.0) +DEBUG Selecting: httptools==0.8.0 [preference] (httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Searching for a compatible version of python-dotenv (>=0.13) +DEBUG Selecting: python-dotenv==1.2.3 [preference] (python_dotenv-1.2.3-py3-none-any.whl) +DEBUG Searching for a compatible version of pyyaml (>=5.1) +DEBUG Selecting: pyyaml==6.0.3 [preference] (pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl) +DEBUG Searching for a compatible version of watchfiles (>=0.20) +DEBUG Selecting: watchfiles==1.2.0 [preference] (watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl) +DEBUG Searching for a compatible version of websockets (>=13.0, <=17.0.1) +DEBUG Recording unit propagation conflict of Python from incompatibility of (websockets) +DEBUG Searching for a compatible version of websockets (>=13.0, <=17.0) +DEBUG Recording unit propagation conflict of Python from incompatibility of (websockets) +DEBUG Searching for a compatible version of websockets (>=13.0, <=16.1.1) +DEBUG Selecting: websockets==16.1.1 [compatible] (websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl) +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl.metadata +DEBUG Searching for a compatible version of anyio (>=3.6.2, <5) +DEBUG Selecting: anyio==4.15.1 [preference] (anyio-4.15.1-py3-none-any.whl) +DEBUG Adding transitive dependency for anyio==4.15.1: exceptiongroup{python_full_version < '3.11'}>=1.0.2 +DEBUG Adding transitive dependency for anyio==4.15.1: idna>=2.8 +DEBUG Adding transitive dependency for anyio==4.15.1: typing-extensions{python_full_version < '3.15'}>=4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.15'} (>=4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions==4.16.0 +DEBUG Adding transitive dependency for typing-extensions==4.16.0: typing-extensions{python_full_version < '3.15'}==4.16.0 +DEBUG Searching for a compatible version of typing-extensions{python_full_version < '3.15'} (==4.16.0) +DEBUG Selecting: typing-extensions==4.16.0 [preference] (typing_extensions-4.16.0-py3-none-any.whl) +DEBUG Searching for a compatible version of certifi (*) +DEBUG Selecting: certifi==2026.7.22 [preference] (certifi-2026.7.22-py3-none-any.whl) +DEBUG Searching for a compatible version of httpcore (>=1.dev0, <2.dev0) +DEBUG Selecting: httpcore==1.0.9 [preference] (httpcore-1.0.9-py3-none-any.whl) +DEBUG Adding transitive dependency for httpcore==1.0.9: certifi* +DEBUG Adding transitive dependency for httpcore==1.0.9: h11>=0.16 +DEBUG Searching for a compatible version of idna (>=2.8) +DEBUG Selecting: idna==3.20 [preference] (idna-3.20-py3-none-any.whl) +DEBUG Searching for a compatible version of nodeenv (>=1.6.0) +DEBUG Selecting: nodeenv==1.10.0 [preference] (nodeenv-1.10.0-py2.py3-none-any.whl) +DEBUG Searching for a compatible version of annotated-types (>=0.6.0) +DEBUG Selecting: annotated-types==0.8.0 [preference] (annotated_types-0.8.0-py3-none-any.whl) +DEBUG No cache entry for: https://pypi.org/simple/exceptiongroup/ +DEBUG Sending fresh GET request for: https://pypi.org/simple/exceptiongroup/ +DEBUG Searching for a compatible version of exceptiongroup{python_full_version < '3.11'} (>=1.0.2) +DEBUG Selecting: exceptiongroup==1.3.1 [compatible] (exceptiongroup-1.3.1-py3-none-any.whl) +DEBUG Adding transitive dependency for exceptiongroup==1.3.1: exceptiongroup==1.3.1 +DEBUG Adding transitive dependency for exceptiongroup==1.3.1: exceptiongroup{python_full_version < '3.11'}==1.3.1 +DEBUG Searching for a compatible version of exceptiongroup (==1.3.1) +DEBUG Selecting: exceptiongroup==1.3.1 [compatible] (exceptiongroup-1.3.1-py3-none-any.whl) +DEBUG Found fresh response for: https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl.metadata +DEBUG Adding transitive dependency for exceptiongroup==1.3.1: typing-extensions{python_full_version < '3.13'}>=4.6.0 +DEBUG Searching for a compatible version of exceptiongroup{python_full_version < '3.11'} (==1.3.1) +DEBUG Selecting: exceptiongroup==1.3.1 [compatible] (exceptiongroup-1.3.1-py3-none-any.whl) +DEBUG Adding transitive dependency for exceptiongroup==1.3.1: typing-extensions{python_full_version < '3.13'}>=4.6.0 +DEBUG Tried 27 versions: annotated-doc 1, annotated-types 1, anyio 1, certifi 1, click 1, exceptiongroup 1, fastapi 1, h11 1, httpcore 1, httptools 1, httpx 1, idna 1, nodeenv 1, pydantic 1, pydantic-core 1, pyright 1, python-dotenv 1, pyyaml 1, ruff 1, simplefarm 1, starlette 1, typing-extensions 1, typing-inspection 1, uvicorn 1, uvloop 1, watchfiles 1, websockets 1 +DEBUG split `python_full_version == '3.10.*'` resolution took 0.141s +INFO Solved your requirements for 2 environments +DEBUG Distinct solution for split (markers: python_full_version >= '3.11') with 26 package(s) +DEBUG Distinct solution for split (markers: python_full_version == '3.10.*') with 27 package(s) +Resolved 28 packages in 48.80s +EXIT=0 diff --git a/ai_player.py b/ai_player.py new file mode 100644 index 0000000..2e175ce --- /dev/null +++ b/ai_player.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""外挂式 AI/机器人玩家——完全通过 HTTP + WebSocket 接口参与游戏。 + +这个脚本**不**导入任何游戏规则代码,只用接口。换句话说: +它对服务器来说和一个远程 LLM Agent 没有任何区别。 + +用法 +---- + python ai_player.py # 连本机 default 牧场 + python ai_player.py --farm ai-1 --minutes 5 # 经营 ai-1 五分钟 + python ai_player.py --url http://10.0.0.5:8765 --farm my-bot + +策略在哪里 +---------- +决策逻辑在 :mod:`farm.bot` 的 ``RuleBot.score()``:那是一张"什么该先做"的 +打分表。想换成真 LLM,只需要把「读 /api/actions 之后选哪个」这一步换成模型 +调用——接口调用序列完全一样: + + 读 GET /api/actions → 挑一个 → POST /api/actions → 读回执里的 state +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from farm.bot import RuleBot, random_bot +from farm.client import RemoteFarm +from farm.engine import GameError + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python ai_player.py", + description="通过 HTTP/WebSocket 接口参与 SimpleFarm 的机器人玩家", + ) + 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("--token", default=None, help="访问令牌(服务器启用时必填)") + parser.add_argument("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限") + parser.add_argument("--minutes", type=float, default=0.0, help="最多跑多少分钟,0 表示不限") + parser.add_argument("--pause", type=float, default=0.25, help="动作间隔(秒)") + parser.add_argument("--random", action="store_true", help="用随机策略(对照组)") + parser.add_argument("--quiet", action="store_true", help="只输出结果,不打过程") + return parser + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + + try: + client = RemoteFarm(endpoint=args.url, farm=args.farm, token=args.token, + name=f"bot-{args.farm}", realtime=False) + except GameError as exc: + print(f"✗ 连不上服务器:{exc.message}", file=sys.stderr) + print(f" 先启动:python main.py serve --daemon", file=sys.stderr) + return 2 + + snapshot = client.snapshot() + print(f"已连接 {args.url} · 牧场「{args.farm}」· 第 {snapshot.get('day')} 天 · " + f"金币 {snapshot.get('gold')}") + + factory = random_bot if args.random else RuleBot + bot = factory(client, verbose=not args.quiet, pause=args.pause, name=args.farm) + try: + summary = bot.run(rounds=args.rounds, max_minutes=args.minutes) + except KeyboardInterrupt: + bot.stop() + summary = {"name": args.farm, "actions": bot.actions_executed, + "failures": bot.failures, "interrupted": True} + + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/farm/__init__.py b/farm/__init__.py new file mode 100644 index 0000000..441ed8b --- /dev/null +++ b/farm/__init__.py @@ -0,0 +1,20 @@ +"""SimpleFarm —— 一个可以被人和 AI 同时经营的小型农场游戏服务器。 + +分层 +---- +* :mod:`farm.engine` / :mod:`farm.models` / :mod:`farm.config` + 纯游戏规则与数据,不依赖终端也不依赖网络。 +* :mod:`farm.server` + 服务器侧:FastAPI 应用、动作表、多牧场会话、WebSocket 推送、守护进程。 +* :mod:`farm.client` + 客户端侧:把远程服务器伪装成本地引擎,让 TUI 和脚本无需关心网络。 +* :mod:`farm.tui` + 终端界面,人和服务器之间的"手柄"。 +""" + +__version__ = "2.0.0" + +__all__ = [ + "config", "models", "engine", "storage", "keys", "tui", + "client", "bot", "server", +] diff --git a/farm/bot.py b/farm/bot.py new file mode 100644 index 0000000..fa31f45 --- /dev/null +++ b/farm/bot.py @@ -0,0 +1,221 @@ +"""规则机器人玩家——"AI 也能玩"的最小可用示例。 + +它的意义不在于"内置了 AI",而在于证明一件事: +**服务器把状态和合法动作都摊开了,外面是谁在决策根本不重要。** +这个机器人做决策的方式是"给候选动作打分",而一个 LLM 做决策的方式是 +"读同一份 ``/api/actions``,用语言模型选一个"。两者的接口调用序列一模一样。 + +所以: + +* :meth:`RuleBot.choose` 里那段打分逻辑 = 可以整体替换成 LLM 的"策略函数"; +* :meth:`RuleBot.step` 的执行路径 = LLM 将来要走的那条路(读动作 → 执行 → 看回执)。 + +它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑—— +换个 ``--url`` 就是一个独立的外挂玩家。 +""" + +from __future__ import annotations + +import random +import threading +import time +from typing import Any, Dict, List, Optional + +from .engine import GameError + + +class RuleBot: + """一个会种地、会养殖、会投资的规则机器人。""" + + def __init__(self, client, verbose: bool = True, pause: float = 0.25, + name: str = "bot") -> None: + self.client = client # 任何与 RemoteFarm 接口一致的对象 + self.verbose = verbose + self.pause = pause + self.name = name + self.done: List[str] = [] + self.failures = 0 + self.actions_executed = 0 + self._stop = threading.Event() + + # ------------------------------------------------------------ 输出 + def say(self, text: str) -> None: + if self.verbose: + print(f"[{time.strftime('%H:%M:%S')}] {text}", flush=True) + + def stop(self) -> None: + self._stop.set() + + @property + def stopped(self) -> bool: + return self._stop.is_set() + + # ------------------------------------------------------------ 打分 + def score(self, action: Dict[str, Any], state: Dict[str, Any], + catalog: Dict[str, Any]) -> float: + """给一个候选动作打分。分数越高越先执行。 + + 这张表就是整个"策略":先做免费的,再喂饱牲畜,再补水,再扩张。 + 换成 LLM 时,把这里换成一次模型调用即可,其余代码不用动。 + """ + name = action.get("action") + params = action.get("params") or {} + gold = state.get("gold", 0) + feed = state.get("feed", 0) + ap = state.get("ap", 0) + + if name == "harvest": + return 100.0 + if name == "collect": + return 96.0 + if name == "feed": + return 92.0 + if name == "water": + return 84.0 + if name == "plant": + # 优先种"每天净收益"最高的作物 + crop = params.get("crop") + entry = next((c for c in catalog.get("crops", []) if c["key"] == crop), None) + per_day = entry["profit_per_day"] if entry else 0.0 + return 60.0 + max(0.0, per_day) + if name == "buy_feed": + # 饲料快见底才补,且别把钱花光 + if feed <= 2 and gold >= 200: + return 40.0 + return 5.0 + if name == "buy_plot": + price = state.get("next_plot_price") or 0 + # 地太贵就别买,留下周转资金 + if price > 0 and gold >= price * 3: + return 34.0 + return 4.0 + if name == "buy_animal": + info = next((a for a in catalog.get("animals", []) + if a["key"] == params.get("kind")), None) + cost = info["cost"] if info else 0 + if cost and gold >= cost * 3 and ap >= 1: + return 30.0 + (info.get("profit_per_day", 0) if info else 0) + return 3.0 + return 1.0 + + def choose(self) -> Optional[Dict[str, Any]]: + """在服务器给出的合法动作里挑一个最优的。""" + actions = self.client.available_actions() + if not actions: + return None + state = self.client.snapshot() + catalog = self.client.catalog() + best = max(actions, key=lambda item: self.score(item, state, catalog)) + if self.score(best, state, catalog) <= 1.0: + return None + return best + + # ------------------------------------------------------------ 单步 + def step(self) -> Optional[str]: + """执行一个动作,返回动作名;没得做或做不了返回 None。""" + action = self.choose() + if action is None: + return None + name = action["action"] + params = action.get("params") or {} + try: + result = self.client._action(name, params) # 走通用动作入口 + except GameError as exc: + self.failures += 1 + if exc.code == "cooldown": + # 休息有冷却;这是正常节奏,不该记成失败 + self.failures -= 1 + self.say(f"冷却中… {exc.message}") + time.sleep(1.0) + return None + self.say(f"✗ {name} 失败({exc.code}):{exc.message}") + return None + self.actions_executed += 1 + self.done.append(name) + self.say(f"✓ {name} {params} → {result.get('message', '')}") + if self.pause: + time.sleep(self.pause) + return name + + def rest(self) -> bool: + """行动点用完时推进一天。""" + try: + result = self.client.sleep() + except GameError as exc: + if exc.code == "cooldown": + time.sleep(1.0) + return True + self.say(f"✗ sleep 失败:{exc.message}") + return False + self.say(f"— {result.get('message', '进入下一天')}") + return True + + # ------------------------------------------------------------ 主循环 + def run(self, rounds: int = 0, max_minutes: float = 0.0, + idle_limit: int = 8) -> Dict[str, Any]: + """跑机器人。 + + ``rounds`` 为 0 表示不限轮数;``max_minutes`` 为 0 表示不限时间。 + """ + deadline = (time.time() + max_minutes * 60) if max_minutes > 0 else None + start_day = self.client.snapshot().get("day", 1) + idle = 0 + round_no = 0 + self.say(f"机器人 {self.name} 上线(第 {start_day} 天," + f"{self.client.transport_label()})") + + while not self._stop.is_set(): + round_no += 1 + if rounds and round_no > rounds: + break + if deadline and time.time() > deadline: + self.say("到时间了,收工。") + break + + state = self.client.snapshot() + if state.get("ap", 0) <= 0: + # 行动点用完:休息推进一天,顺便等冷却 + self.rest() + continue + + did = self.step() + if did is None: + idle += 1 + if idle >= idle_limit: + self.say("暂时没有可做的事,休息一天再看。") + self.rest() + idle = 0 + else: + time.sleep(0.4) + else: + idle = 0 + + end_day = self.client.snapshot().get("day", start_day) + summary = { + "name": self.name, + "days": end_day - start_day, + "actions": self.actions_executed, + "failures": self.failures, + "net_worth": self.client.snapshot().get("net_worth"), + "gold": self.client.snapshot().get("gold"), + } + self.say(f"机器人 {self.name} 下线:跑了 {summary['days']} 天、" + f"执行 {summary['actions']} 个动作、净资产 {summary['net_worth']}") + return summary + + +def random_bot(client, **kwargs) -> RuleBot: + """随机策略机器人:用来做对照实验(证明"有策略"确实比乱按强)。""" + bot = RuleBot(client, **kwargs) + original = bot.choose + + def choose() -> Optional[Dict[str, Any]]: + actions = client.available_actions() + if not actions: + return None + return random.choice(actions) + + bot.choose = choose # type: ignore[assignment] + bot.name = kwargs.get("name", "random") + del original + return bot diff --git a/farm/client.py b/farm/client.py new file mode 100644 index 0000000..df853cb --- /dev/null +++ b/farm/client.py @@ -0,0 +1,721 @@ +"""远程农场客户端:让本地代码(TUI、脚本、机器人)像用本地引擎一样用服务器。 + +设计目标 +-------- +TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程里,客户端必须 +换成"远程把手"。为了不把上千行渲染代码推倒重写,这里做了一个**接口同构**的 +:class:`RemoteFarm`: + + 本地引擎 远程客户端 + ---------- ---------- + engine.state.plots[0].is_ripe state.plots[0].is_ripe + engine.harvest(1) harvest(1) + engine.catalog() catalog() + engine.recent_log(5) recent_log(5) + +字段名和方法名都对齐,所以 :mod:`farm.tui` 不需要知道自己在跟谁说话。 + +双通道 +------ +* **写操作走 HTTP**(POST /api/actions):语义清晰、可重试、失败有明确错误码。 +* **读数走 WebSocket**:服务器在状态变化时主动推过来,界面不用轮询。 +* WS 不可用时自动降级成"HTTP 轮询 + 写操作",功能不缺,只是延迟高一点。 + +请求库用的是标准库 ``urllib``,所以即使机器上没装任何第三方包,TUI 也能跑。 +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import deque +from concurrent.futures import Future, TimeoutError as FutureTimeout +from typing import Any, Callable, Deque, Dict, List, Optional + +from . import config +from .engine import GameError +from . import net + +DEFAULT_ENDPOINT = "http://127.0.0.1:8765" +LOG_KEEP = 200 +WS_RECONNECT_DELAY = 3.0 + + +# --------------------------------------------------------------------- 状态视图 +class PlotView: + """一块地的只读视图(字段名与 :class:`farm.models.Plot` 对齐)。""" + + __slots__ = ("_d",) + + def __init__(self, data: Dict[str, Any]) -> None: + self._d = data + + @property + def index(self) -> int: + return int(self._d.get("index", 0)) + + @property + def position(self) -> int: + return int(self._d.get("position", self.index + 1)) + + @property + def crop(self) -> Optional[str]: + return self._d.get("crop") + + @property + def crop_name(self) -> str: + return self._d.get("crop_name") or "" + + @property + def growth(self) -> float: + return float(self._d.get("growth", 0.0)) + + @property + def grow_days(self) -> int: + return int(self._d.get("grow_days", 0)) + + @property + def days_left(self) -> int: + return int(self._d.get("days_left", 0)) + + @property + def moisture(self) -> int: + return int(self._d.get("moisture", 0)) + + @property + def is_ripe(self) -> bool: + return bool(self._d.get("is_ripe")) + + @property + def is_empty(self) -> bool: + return bool(self._d.get("is_empty", self.crop is None)) + + @property + def stage(self) -> str: + return self._d.get("stage", "") + + def to_dict(self) -> Dict[str, Any]: + return dict(self._d) + + +class AnimalView: + """一只牲畜的只读视图(``aid`` 对齐本地模型的 ``.aid``)。""" + + __slots__ = ("_d",) + + def __init__(self, data: Dict[str, Any]) -> None: + self._d = data + + @property + def aid(self) -> int: + return int(self._d.get("id", 0)) + + @property + def kind(self) -> str: + return self._d.get("kind", "") + + @property + def name(self) -> str: + return self._d.get("name", "") + + @property + def product(self) -> str: + return self._d.get("product", "") + + @property + def fed(self) -> bool: + return bool(self._d.get("fed")) + + @property + def pending(self) -> int: + return int(self._d.get("pending", 0)) + + @property + def cycle(self) -> int: + return int(self._d.get("cycle", 1)) + + @property + def progress(self) -> int: + return int(self._d.get("progress", 0)) + + @property + def starve(self) -> int: + return int(self._d.get("starve", 0)) + + @property + def days_to_product(self) -> int: + return int(self._d.get("days_to_product", 0)) + + @property + def is_sick(self) -> bool: + return bool(self._d.get("is_sick")) + + def to_dict(self) -> Dict[str, Any]: + return dict(self._d) + + +class StateView: + """整局游戏的只读视图。刻意保留 ``to_dict()``,方便直接落盘或转发。""" + + def __init__(self, data: Optional[Dict[str, Any]] = None) -> None: + self._d: Dict[str, Any] = {} + self.apply(data or {}) + + def apply(self, data: Dict[str, Any]) -> None: + """用新数据刷新内部视图。 + + 服务器会发两种东西:**完整快照**(带 plots/animals)和**精简状态** + (动作回执里的 state,只有几个标量)。前者整体替换,后者只做字段合并—— + 否则一次播种就会把 TUI 的农田格子清空。 + """ + if not data: + return + if "plots" in data: + merged = dict(data) + else: + merged = dict(self._d) + merged.update(data) + self._d = merged + self.plots = [PlotView(p) for p in self._d.get("plots", []) if isinstance(p, dict)] + self.animals = [AnimalView(a) for a in self._d.get("animals", []) if isinstance(a, dict)] + + # 常用标量字段 + @property + def day(self) -> int: + return int(self._d.get("day", 1)) + + @property + def gold(self) -> int: + return int(self._d.get("gold", 0)) + + @property + def level(self) -> int: + return int(self._d.get("level", 1)) + + @property + def exp(self) -> int: + return int(self._d.get("exp", 0)) + + @property + def ap(self) -> int: + return int(self._d.get("ap", 0)) + + @property + def feed(self) -> int: + return int(self._d.get("feed", 0)) + + @property + def market(self) -> Dict[str, float]: + return self._d.get("market", {}) + + def get(self, key: str, default: Any = None) -> Any: + return self._d.get(key, default) + + def __getitem__(self, key: str) -> Any: + return self._d[key] + + def to_dict(self) -> Dict[str, Any]: + return dict(self._d) + + # 初始化占位,避免属性访问落空 + plots: List[PlotView] = [] + animals: List[AnimalView] = [] + + +# --------------------------------------------------------------------- 客户端 +class RemoteFarm: + """远程农场的句柄:接口与 :class:`farm.engine.FarmEngine` 保持一致。""" + + def __init__(self, endpoint: str = DEFAULT_ENDPOINT, farm: str = "default", + token: Optional[str] = None, name: str = "", timeout: float = 6.0, + realtime: bool = True, autostart: bool = True) -> None: + self.endpoint = self._normalize(endpoint) + self.farm = farm or "default" + self.token = token or "" + self.name = name + self.timeout = timeout + self.realtime_enabled = realtime + + self.state = StateView() + self.connected = False + self.mode = "http" # http / ws / poll + self.last_error = "" + self.last_message = "" + self.server_info: Dict[str, Any] = {} + self.on_change: Optional[Callable[[], None]] = None + + self._lock = threading.RLock() + self._pending: Dict[int, Future] = {} + self._req_id = 0 + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._ws_socket: Any = None + self._ws_loop: Any = None + self._catalog: Dict[str, Any] = {"crops": [], "animals": [], + "feed_price": config.FEED_UNIT_PRICE, + "water_capacity": config.WATER_CAPACITY, + "ap_cost": config.AP_COST} + self._log: Deque[Dict[str, Any]] = deque(maxlen=LOG_KEEP) + self._log_seq = 0 + self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0} + + if autostart: + self.refresh(raise_on_error=True) + if realtime: + self.start() + + # ------------------------------------------------------------ 基础设施 + @staticmethod + def _normalize(endpoint: str) -> str: + endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/") + if endpoint.startswith("tcp://"): + endpoint = "http://" + endpoint[len("tcp://"):] + if not endpoint.startswith(("http://", "https://")): + endpoint = "http://" + endpoint + return endpoint + + @property + def host(self) -> str: + return urllib.parse.urlsplit(self.endpoint).netloc or "127.0.0.1:8765" + + def url(self, path: str, **params: Any) -> str: + query = {"farm": self.farm} + if self.token: + query["token"] = self.token + query.update({k: v for k, v in params.items() if v is not None}) + return f"{self.endpoint}{path}?{urllib.parse.urlencode(query)}" + + def _http(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None, + **params: Any) -> Dict[str, Any]: + url = self.url(path, **params) + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + self.stats["http"] += 1 + try: + # 走 net.open_url:本地服务器不会被系统代理截走 + with net.open_url(request, timeout=self.timeout) as response: + body = response.read().decode("utf-8", errors="replace") + status = response.status + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + status = exc.code + except (urllib.error.URLError, OSError, ValueError) as exc: + self.stats["errors"] += 1 + self.connected = False + raise GameError( + "network", + f"连不上服务器 {self.endpoint}({exc})。" + "请先启动:python main.py serve --daemon", + status=503, + ) + try: + data_out = json.loads(body) if body else {} + except json.JSONDecodeError: + raise GameError("bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502) + if not isinstance(data_out, dict): + raise GameError("bad_response", "服务器返回的 JSON 不是对象", 502) + if not data_out.get("ok", True): + self.stats["errors"] += 1 + raise GameError( + data_out.get("error", "server_error"), + data_out.get("message", "服务器拒绝了这次操作"), + status=status if status >= 400 else 400, + ) + self.connected = True + self.last_error = "" + return data_out + + # ------------------------------------------------------------ 读接口 + def refresh(self, raise_on_error: bool = False) -> bool: + """主动拉一次全量数据(首次连接、以及 WS 断开后的兜底)。""" + try: + data = self._http("GET", "/api/state") + self._apply_online(data.get("state") or {}, data.get("online")) + catalog = self._http("GET", "/api/catalog") + self._catalog = catalog.get("catalog") or self._catalog + logs = self._http("GET", "/api/log", limit=30) + self._merge_log(logs.get("entries") or []) + return True + except GameError as exc: + self.connected = False + self.last_error = exc.message + if raise_on_error: + raise + return False + + def _apply_online(self, state: Dict[str, Any], online: Any) -> None: + """把"在线人数"合进状态视图:TUI 头部和网页都要显示它。""" + if state and isinstance(online, int): + state = dict(state) + state["online"] = online + self._apply(state) + + def snapshot(self) -> Dict[str, Any]: + return self.state.to_dict() + + def catalog(self) -> Dict[str, Any]: + return self._catalog + + def recent_log(self, limit: int = 12, since: int = 0) -> List[Dict[str, Any]]: + with self._lock: + entries = [e for e in self._log if int(e.get("seq", 0)) > since] + return entries[-limit:] if limit > 0 else entries + + def available_actions(self) -> List[Dict[str, Any]]: + data = self._http("GET", "/api/actions") + self._apply(data.get("state") or {}) + return data.get("actions") or [] + + def leaderboard(self) -> List[Dict[str, Any]]: + return self._http("GET", "/api/farms").get("farms") or [] + + def world(self) -> Dict[str, Any]: + return self._http("GET", "/api/world") + + def help_text(self) -> str: + return self._http("GET", "/api/help").get("rules", "") + + def _apply(self, state: Dict[str, Any]) -> None: + if not state: + return + self.state.apply(state) + if self.on_change is not None: + try: + self.on_change() + except Exception: + pass + + def _merge_log(self, entries: List[Dict[str, Any]]) -> None: + with self._lock: + known = {int(e.get("seq", 0)) for e in self._log} + for entry in entries: + seq = int(entry.get("seq", 0)) + if seq and seq not in known: + self._log.append(entry) + known.add(seq) + self._log_seq = max(self._log_seq, seq) + self._log = deque(sorted(self._log, key=lambda e: int(e.get("seq", 0))), + maxlen=LOG_KEEP) + + # ------------------------------------------------------------ 写接口 + def _action(self, action: str, params: Optional[Dict[str, Any]] = None, + transport: str = "auto") -> Dict[str, Any]: + """执行一个动作。 + + 优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误 + (连接断了、超时)才退回 HTTP 重试——业务错误必须原样抛给玩家, + 否则"金币不足"会被悄悄重试成两次操作。 + """ + params = params or {} + use_ws = (transport == "ws") or (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._merge_log(data.get("log") or []) + return data + + # ---- 与本地引擎同名的方法 + def plant(self, plot: Any, crop: Any) -> Dict[str, Any]: + return self._action("plant", {"plot": plot, "crop": crop}) + + def water(self, plot: Any) -> Dict[str, Any]: + return self._action("water", {"plot": plot}) + + def harvest(self, plot: Any) -> Dict[str, Any]: + return self._action("harvest", {"plot": plot}) + + def harvest_all(self) -> Dict[str, Any]: + return self._action("harvest_all") + + def buy_animal(self, kind: Any) -> Dict[str, Any]: + return self._action("buy_animal", {"kind": kind}) + + def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"animal_id": animal_id} + if quantity is not None: + params["quantity"] = quantity + return self._action("feed", params) + + def collect(self, animal_id: Any) -> Dict[str, Any]: + return self._action("collect", {"animal_id": animal_id}) + + def buy_feed(self, quantity: Any = config.FEED_BULK) -> Dict[str, Any]: + return self._action("buy_feed", {"quantity": quantity}) + + def buy_plot(self) -> Dict[str, Any]: + return self._action("buy_plot") + + def sleep(self) -> Dict[str, Any]: + return self._action("sleep") + + def pause(self, paused: Optional[bool] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if paused is not None: + params["paused"] = paused + data = self._action("pause", params) + self._apply(data.get("state") or {}) + # 本地视图也要反映暂停态,TUI 的倒计时依赖它 + if "paused" in data: + merged = self.state.to_dict() + merged["paused"] = data["paused"] + merged["seconds_to_tick"] = None if data["paused"] else merged.get("seconds_to_tick") + self.state.apply(merged) + return data + + def reset(self) -> Dict[str, Any]: + data = self._action("reset") + with self._lock: + self._log.clear() + self._log_seq = 0 + self.refresh() + return data + + def save(self, path: Optional[str] = None) -> Dict[str, Any]: + """远程存档:位置由服务器决定,``path`` 参数仅为与本地引擎保持同签名。""" + return self._action("save") + + def batch(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + """批量执行(一个请求干多件事),给机器人/AI 用。""" + data = self._http("POST", "/api/batch", {"actions": items}, full=1) + self._apply(data.get("snapshot") or data.get("state") or {}) + self._merge_log(data.get("log") or []) + return data + + # ------------------------------------------------------------ WebSocket + def start(self) -> None: + if not self.realtime_enabled or self._thread is not None: + return + self._stop.clear() + self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", + daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + with self._lock: + for future in self._pending.values(): + if not future.done(): + future.set_exception(GameError("closed", "连接已关闭")) + self._pending.clear() + + def transport_label(self) -> str: + if self.mode == "ws": + return "WebSocket 实时" + if self.mode == "poll": + return "HTTP 轮询" + return "HTTP" + + @property + def ws_url(self) -> str: + parts = urllib.parse.urlsplit(self.endpoint) + scheme = "wss" if parts.scheme == "https" else "ws" + query = {"farm": self.farm} + if self.token: + query["token"] = self.token + if self.name: + query["name"] = self.name + return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}" + + def _realtime_loop(self) -> None: + """实时线程:先试 WebSocket,不行就退化成 HTTP 轮询,永不放弃重连。""" + while not self._stop.is_set(): + try: + self._ws_session() + except Exception as exc: + self.connected = False + self.last_error = f"{type(exc).__name__}: {exc}" + if self.mode != "ws": + self.mode = "poll" + self._fail_pending(self.last_error) + if self._stop.is_set(): + break + self._poll_until_reconnect() + + def _poll_until_reconnect(self) -> None: + """WS 断开期间的降级通道:轻量轮询 + 到点再试一次 WS。""" + if self.mode == "ws": + self.mode = "poll" + deadline = time.time() + WS_RECONNECT_DELAY + while not self._stop.is_set() and time.time() < deadline: + try: + data = self._http("GET", "/api/state") + self._apply(data.get("state") or {}) + except GameError: + pass + self._stop.wait(0.5) + + def _ws_session(self) -> None: + import asyncio + + try: + import websockets + except ImportError: + # 没装 websockets:直接走轮询模式,功能不减 + self.mode = "poll" + self._stop.wait(WS_RECONNECT_DELAY) + return + + asyncio.run(self._ws_main(websockets)) + + async def _ws_main(self, websockets) -> None: + async with websockets.connect(self.ws_url, open_timeout=self.timeout, + ping_interval=20, ping_timeout=20, + max_size=4 * 1024 * 1024) as socket: + self.mode = "ws" + self.connected = True + self.last_error = "" + self._ws_socket = socket + self._ws_loop = asyncio.get_running_loop() + try: + while not self._stop.is_set(): + try: + raw = await asyncio.wait_for(socket.recv(), timeout=30.0) + except asyncio.TimeoutError: + await socket.send(json.dumps({"type": "ping"})) + continue + self._handle_ws_message(raw) + finally: + self._ws_socket = None + self._ws_loop = None + self._fail_pending("WebSocket 连接已断开") + + def _fail_pending(self, reason: str) -> None: + """连接断了以后,把还在等回执的动作请求全部失败掉。""" + with self._lock: + pending, self._pending = self._pending, {} + for future in pending.values(): + if not future.done(): + future.set_exception(GameError("ws_closed", reason)) + + def _handle_ws_message(self, raw: Any) -> None: + try: + message = json.loads(raw) + except (TypeError, json.JSONDecodeError): + return + if not isinstance(message, dict): + return + kind = message.get("type") + + if kind == "welcome": + self.server_info = message.get("server") or {} + self.farm = message.get("farm", self.farm) + 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 in ("state", "sync"): + if message.get("catalog"): + self._catalog = message["catalog"] + self._apply_online(message.get("state") or {}, message.get("online")) + self._merge_log(message.get("log") or []) + self.stats["pushes"] += 1 + return + + if kind == "result": + rid = message.get("id") + with self._lock: + future = self._pending.pop(rid, None) if rid is not None else None + if future is None or future.done(): + return + if message.get("ok"): + self._merge_log(message.get("log") or []) + future.set_result({ + "ok": True, + "message": message.get("message", "完成"), + **(message.get("result") or {}), + "state": message.get("state"), + "snapshot": message.get("snapshot"), + "log": message.get("log"), + }) + else: + future.set_exception(GameError(message.get("error", "error"), + message.get("message", "操作失败"))) + return + + if kind == "day": + self._merge_log([{ + "seq": self._log_seq + 1, + "day": message.get("day", 0), + "kind": "info", + "text": f"第 {message.get('day')} 天开始", + }] + [{ + "seq": self._log_seq + 2 + i, + "day": message.get("day", 0), + "kind": "warn", + "text": text, + } for i, text in enumerate(message.get("events") or [])]) + return + + if kind == "error": + self.last_error = message.get("message", "") + + def _ws_send(self, payload: Dict[str, Any]) -> None: + """把消息发到 WS 线程的事件循环里(跨线程投递)。""" + socket = getattr(self, "_ws_socket", None) + loop = getattr(self, "_ws_loop", None) + if socket is None or loop is None: + raise GameError("ws_closed", "WebSocket 尚未连接") + asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop) + + def _ws_action(self, action: str, params: Dict[str, Any], + timeout: Optional[float] = 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) + raise GameError("timeout", "服务器没有在超时时间内回应") + + +class RemoteBotClient(RemoteFarm): + """给机器人用的薄封装:多一个"等服务器起来"的便利方法。""" + + def wait_online(self, seconds: float = 30.0) -> bool: + deadline = time.time() + seconds + while time.time() < deadline: + if self.refresh(): + return True + time.sleep(1.0) + return False diff --git a/farm/config.py b/farm/config.py new file mode 100644 index 0000000..a1571d7 --- /dev/null +++ b/farm/config.py @@ -0,0 +1,172 @@ +"""游戏平衡参数与静态定义。 + +本模块只描述"世界是什么样的"(作物表、动物表、经济参数), +不包含任何状态或逻辑,方便单独调参。 +""" + +from __future__ import annotations + +from typing import Any, Dict + +# -------------------------------------------------------------------------- +# 基础经济参数 +# -------------------------------------------------------------------------- + +START_GOLD = 120 # 初始金币 +START_LEVEL = 1 # 初始等级 +START_AP = 10 # 每天行动点上限 +START_FEED = 5 # 初始饲料库存 + +BASE_PLOTS = 6 # 初始土地数量 +MAX_PLOTS = 16 # 土地数量硬上限 +PLOT_BASE_PRICE = 200 # 第一块新地的价格 +PLOT_PRICE_GROWTH = 1.75 # 每多买一块,价格乘以该系数 + +FEED_UNIT_PRICE = 6 # 主动购买饲料的单价 +FEED_EMERGENCY_PRICE = 9 # 自动补货(缺饲料时喂食)的惩罚单价 +FEED_BULK = 10 # 一次购买饲料的默认批量 + +WATER_CAPACITY = 3 # 一次浇水可支撑的天数 +PLANT_INITIAL_MOISTURE = 2 # 播种后地块自带的水分 +PRICE_VOLATILITY = 0.18 # 每天价格波动幅度(±18%) +LUCKY_CHANCE = 0.12 # 丰收暴击概率(产出翻倍) +MAX_LOG_ENTRIES = 300 # 内存中保留的事件条数上限 +MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限 + +# 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS) +PLOTS_PER_LEVEL = 2 + +# 行动点消耗表 +AP_COST = { + "plant": 1, + "water": 1, + "harvest": 0, + "feed": 1, + "collect": 0, + "buy_animal": 0, + "buy_plot": 0, + "buy_feed": 0, +} + +# 经验曲线:升到 level+1 所需经验 +def exp_to_next(level: int) -> int: + """返回从 level 升到 level+1 所需的经验值。""" + return int(80 * (max(1, level) ** 1.6)) + +# 升级奖励金币 +def level_up_bonus(level: int) -> int: + """升到 level 时发放的金币奖励。""" + return 80 + 40 * level + + +# -------------------------------------------------------------------------- +# 作物表 +# -------------------------------------------------------------------------- +# key : 内部标识(API 与存档使用) +# name : 中文名 +# seed_cost : 种子价格 +# grow_days : 成熟所需生长天数(需要水分支撑) +# base_price : 收获物的基础售价 +# exp : 每次收获获得的经验 +# level_req : 解锁所需等级 + +CROPS: Dict[str, Dict[str, Any]] = { + "wheat": { + "name": "小麦", "seed_cost": 5, "grow_days": 2, + "base_price": 14, "exp": 3, "level_req": 1, + }, + "carrot": { + "name": "胡萝卜", "seed_cost": 8, "grow_days": 3, + "base_price": 26, "exp": 5, "level_req": 1, + }, + "tomato": { + "name": "番茄", "seed_cost": 16, "grow_days": 4, + "base_price": 52, "exp": 9, "level_req": 2, + }, + "corn": { + "name": "玉米", "seed_cost": 28, "grow_days": 6, + "base_price": 95, "exp": 14, "level_req": 3, + }, + "pumpkin": { + "name": "南瓜", "seed_cost": 45, "grow_days": 8, + "base_price": 180, "exp": 22, "level_req": 4, + }, + "strawberry": { + "name": "草莓", "seed_cost": 70, "grow_days": 10, + "base_price": 320, "exp": 35, "level_req": 5, + }, +} + +# 生长阶段阈值(按生长进度 0.0~1.0 划分) +GROWTH_STAGES = [ + (0.00, "已播种"), + (0.30, "幼苗"), + (0.65, "生长中"), + (1.00, "已成熟"), +] + + +# -------------------------------------------------------------------------- +# 动物表 +# -------------------------------------------------------------------------- +# key : 内部标识 +# name : 中文名 +# cost : 购买价格 +# product : 产出物标识(中文名) +# base_price : 产出物基础售价 +# cycle : 产出周期(天) +# feed_per_day: 每天消耗饲料数 +# exp : 每次收集产出获得的经验 +# level_req : 解锁所需等级 + +ANIMALS: Dict[str, Dict[str, Any]] = { + "chicken": { + "name": "鸡", "cost": 90, "product": "鸡蛋", + "base_price": 15, "cycle": 1, "feed_per_day": 1, + "exp": 4, "level_req": 1, + }, + "sheep": { + "name": "羊", "cost": 220, "product": "羊毛", + "base_price": 55, "cycle": 2, "feed_per_day": 1, + "exp": 9, "level_req": 2, + }, + "cow": { + "name": "牛", "cost": 480, "product": "牛奶", + "base_price": 72, "cycle": 1, "feed_per_day": 2, + "exp": 15, "level_req": 3, + }, +} + +MAX_ANIMALS = 8 # 牧场总容量 + + +# -------------------------------------------------------------------------- +# 时间 +# -------------------------------------------------------------------------- + +DEFAULT_TICK_SECONDS = 15.0 # 自动推进一天所需的真实秒数 +MIN_TICK_SECONDS = 3.0 +MAX_TICK_SECONDS = 600.0 +SLEEP_COOLDOWN = 2.0 # 手动"休息"推进一天的冷却(秒) + + +# -------------------------------------------------------------------------- +# 进度目标 +# -------------------------------------------------------------------------- + +GOALS = [ + (200, "起步:攒够 200 金币"), + (600, "小农:攒够 600 金币"), + (1500, "农场主:攒够 1500 金币"), + (4000, "庄园主:攒够 4000 金币"), + (10000, "农业大亨:攒够 10000 金币"), +] + + +def goal_index(net_worth: float) -> int: + """返回已达成的最低目标索引(-1 表示一个都没达成)。""" + reached = -1 + for i, (amount, _) in enumerate(GOALS): + if net_worth >= amount: + reached = i + return reached diff --git a/farm/engine.py b/farm/engine.py new file mode 100644 index 0000000..72625b9 --- /dev/null +++ b/farm/engine.py @@ -0,0 +1,743 @@ +"""核心游戏引擎。 + +这一层是纯粹的"游戏规则",不依赖终端、不依赖网络。 +TUI 与 HTTP/gRPC 接口都调用同一组方法,因此三方共享完全一致的裁判逻辑。 + +线程模型 +-------- +所有对状态的读写都在 ``self._lock`` 保护下进行,TUI 主线程、 +自动推进线程、HTTP 工作线程可以安全并发调用。 + +错误约定 +-------- +业务规则不满足时抛出 :class:`GameError`(带机器可读的 ``code``), +调用方据此返回 4xx JSON 或界面提示。 +""" + +from __future__ import annotations + +import random +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from . import config +from .models import Animal, GameState, Plot, clamp + + +class GameError(Exception): + """业务规则错误。``code`` 供 API 调用方程序化判断。""" + + def __init__(self, code: str, message: str, status: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status = status + + def to_dict(self) -> Dict[str, Any]: + return {"ok": False, "error": self.code, "message": self.message} + + +class FarmEngine: + """农场游戏引擎。""" + + def __init__(self, tick_seconds: float = config.DEFAULT_TICK_SECONDS) -> None: + self._lock = threading.RLock() + self.state = GameState() + self.tick_seconds = clamp( + float(tick_seconds), config.MIN_TICK_SECONDS, config.MAX_TICK_SECONDS + ) + + self._log_seq = 0 + self._last_sleep = 0.0 + self.paused = False + # 由 Scheduler 维护:下一次自动推进的绝对时间戳(无调度器时为 None) + self.next_tick_at: Optional[float] = None + # 状态变化回调(TUI 用它触发重绘) + self._listeners: List[Callable[[], None]] = [] + + # ================================================================ 订阅 + def subscribe(self, callback: Callable[[], None]) -> None: + with self._lock: + self._listeners.append(callback) + + def _notify(self) -> None: + for callback in list(self._listeners): + try: + callback() + except Exception: + # 监听器异常不能影响游戏主流程 + pass + + # ================================================================ 日志 + def add_log(self, text: str, kind: str = "info") -> None: + with self._lock: + self._log_seq += 1 + self.state.log.append( + { + "seq": self._log_seq, + "day": self.state.day, + "kind": kind, # info / good / bad / warn / action + "text": text, + } + ) + if len(self.state.log) > config.MAX_LOG_ENTRIES: + del self.state.log[: len(self.state.log) - config.MAX_LOG_ENTRIES] + + def recent_log(self, limit: int = 12, since: int = 0) -> List[Dict[str, Any]]: + with self._lock: + entries = [e for e in self.state.log if e["seq"] > since] + return entries[-limit:] if limit > 0 else entries + + # ================================================================ 查询 + def snapshot(self) -> Dict[str, Any]: + """返回完整状态快照(供 TUI 渲染与 API 响应)。""" + with self._lock: + data = self.state.to_dict() + data["tick_seconds"] = self.tick_seconds + data["paused"] = self.paused + data["seconds_to_tick"] = self.seconds_to_tick + data["last_event"] = self.state.log[-1] if self.state.log else None + return data + + @property + def seconds_to_tick(self) -> Optional[float]: + if self.paused or self.next_tick_at is None: + return None + return round(max(0.0, self.next_tick_at - time.time()), 1) + + def market_price(self, key: str) -> float: + with self._lock: + return round(self.state.market.get(key, 0.0), 2) + + def catalog(self) -> Dict[str, Any]: + """返回所有可种植/可养殖对象及其在市场上的实时价格。""" + with self._lock: + crops = [] + for key, info in config.CROPS.items(): + price = self.market_price(key) + gross = price + days = int(info["grow_days"]) + crops.append( + { + "key": key, + "name": info["name"], + "seed_cost": info["seed_cost"], + "grow_days": days, + "price": price, + "exp": info["exp"], + "level_req": info["level_req"], + "locked": self.state.level < info["level_req"], + "profit": round(gross - info["seed_cost"], 2), + "profit_per_day": round((gross - info["seed_cost"]) / days, 2), + } + ) + animals = [] + for key, info in config.ANIMALS.items(): + price = self.market_price(f"animal:{key}") + feed_cost = info["feed_per_day"] * config.FEED_UNIT_PRICE * info["cycle"] + animals.append( + { + "key": key, + "name": info["name"], + "cost": info["cost"], + "product": info["product"], + "price": price, + "cycle": info["cycle"], + "feed_per_day": info["feed_per_day"], + "exp": info["exp"], + "level_req": info["level_req"], + "locked": self.state.level < info["level_req"], + "profit_per_day": round( + (price - feed_cost) / max(1, info["cycle"]), 2 + ), + } + ) + return { + "crops": crops, + "animals": animals, + "feed_price": config.FEED_UNIT_PRICE, + "water_capacity": config.WATER_CAPACITY, + "ap_cost": config.AP_COST, + } + + def available_actions(self) -> List[Dict[str, Any]]: + """列出当前**合法**的动作,供 AI 直接决策使用。 + + 每个动作包含:``action``(接口名)、``params``(参数模板)、 + ``cost``(金币/行动点开销)、``reason``(为什么值得做)。 + """ + with self._lock: + actions: List[Dict[str, Any]] = [] + state = self.state + + # ---- 收获 / 浇水 / 播种 + for plot in state.plots: + if plot.crop is None: + for key, info in config.CROPS.items(): + if state.level < info["level_req"]: + continue + if state.gold < info["seed_cost"] or state.ap < config.AP_COST["plant"]: + continue + actions.append( + { + "action": "plant", + "params": {"plot": plot.index + 1, "crop": key}, + "cost": {"gold": info["seed_cost"], "ap": config.AP_COST["plant"]}, + "reason": f"{plot.index + 1} 号地空闲,可种{info['name']}", + } + ) + elif plot.is_ripe: + actions.append( + { + "action": "harvest", + "params": {"plot": plot.index + 1}, + "cost": {"gold": 0, "ap": config.AP_COST["harvest"]}, + "reason": f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟", + } + ) + elif plot.moisture == 0 and state.ap >= config.AP_COST["water"]: + actions.append( + { + "action": "water", + "params": {"plot": plot.index + 1}, + "cost": {"gold": 0, "ap": config.AP_COST["water"]}, + "reason": f"{plot.index + 1} 号地缺水,作物已停止生长", + } + ) + + # ---- 动物 + for animal in state.animals: + if animal.pending > 0: + actions.append( + { + "action": "collect", + "params": {"animal_id": animal.aid}, + "cost": {"gold": 0, "ap": config.AP_COST["collect"]}, + "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取", + } + ) + elif not animal.fed: + need = animal.info["feed_per_day"] + cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE + if state.gold >= cost and state.ap >= config.AP_COST["feed"]: + actions.append( + { + "action": "feed", + "params": {"animal_id": animal.aid}, + "cost": {"gold": cost, "ap": config.AP_COST["feed"]}, + "reason": f"{animal.name}(#{animal.aid})今天还没喂食", + } + ) + + # ---- 投资类 + price = state.next_plot_price + if price > 0 and state.gold >= price: + actions.append( + { + "action": "buy_plot", + "params": {}, + "cost": {"gold": price, "ap": config.AP_COST["buy_plot"]}, + "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地", + } + ) + if state.feed < 5 and state.gold >= config.FEED_UNIT_PRICE * config.FEED_BULK: + actions.append( + { + "action": "buy_feed", + "params": {"quantity": config.FEED_BULK}, + "cost": { + "gold": config.FEED_UNIT_PRICE * config.FEED_BULK, + "ap": config.AP_COST["buy_feed"], + }, + "reason": "饲料库存偏低,建议补货", + } + ) + for key, info in config.ANIMALS.items(): + if state.level < info["level_req"]: + continue + if len(state.animals) >= config.MAX_ANIMALS or state.gold < info["cost"]: + continue + actions.append( + { + "action": "buy_animal", + "params": {"kind": key}, + "cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]}, + "reason": f"可购买{info['name']}({info['cost']} 金币)", + } + ) + return actions + + # ================================================================ 内部工具 + def _get_plot(self, plot_no: Any) -> Plot: + """把 1-based 的地块编号转成 Plot,越界即报错。""" + try: + number = int(plot_no) + except (TypeError, ValueError): + raise GameError("bad_param", f"地块编号必须是整数,收到 {plot_no!r}") + if number < 1 or number > len(self.state.plots): + raise GameError( + "no_such_plot", + f"地块编号 {number} 不存在(当前共 {len(self.state.plots)} 块地)", + ) + return self.state.plots[number - 1] + + def _get_animal(self, animal_id: Any) -> Animal: + try: + aid = int(animal_id) + except (TypeError, ValueError): + raise GameError("bad_param", f"动物编号必须是整数,收到 {animal_id!r}") + for animal in self.state.animals: + if animal.aid == aid: + return animal + raise GameError("no_such_animal", f"没有编号为 {aid} 的动物") + + def _spend_ap(self, action: str) -> None: + cost = config.AP_COST.get(action, 0) + if cost and self.state.ap < cost: + raise GameError( + "no_ap", + f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。" + f"按 N 休息或等待下一天。", + ) + if cost: + self.state.ap -= cost + + def _spend_gold(self, amount: int, what: str) -> None: + amount = int(amount) + if amount < 0: + raise GameError("bad_param", "金额不能为负") + if self.state.gold < amount: + raise GameError( + "not_enough_gold", + f"金币不足:{what}需要 {amount},你只有 {self.state.gold}", + ) + self.state.gold -= amount + self.state.total_spent += amount + + def _earn(self, amount: float, reason: str) -> int: + earned = max(0, int(round(amount))) + self.state.gold += earned + self.state.total_earned += earned + return earned + + def _gain_exp(self, amount: int) -> List[str]: + """增加经验并处理连续升级,返回升级提示文本。""" + messages: List[str] = [] + self.state.exp += int(amount) + guard = 0 + while self.state.exp >= self.state.exp_needed and guard < 50: + guard += 1 + self.state.exp -= self.state.exp_needed + self.state.level += 1 + bonus = config.level_up_bonus(self.state.level) + self.state.gold += bonus + unlocked = [ + info["name"] + for info in config.CROPS.values() + if info["level_req"] == self.state.level + ] + [ + info["name"] + for info in config.ANIMALS.values() + if info["level_req"] == self.state.level + ] + text = f"升级到 Lv.{self.state.level}!奖励 {bonus} 金币" + if unlocked: + text += f",解锁:{'、'.join(unlocked)}" + messages.append(text) + self.add_log(text, "good") + return messages + + def _roll_yield(self) -> int: + """丰收回合判定:常规产出 1,暴击产出 2。""" + return 2 if random.random() < config.LUCKY_CHANCE else 1 + + # ================================================================ 动作 + def plant(self, plot_no: Any, crop: Any) -> Dict[str, Any]: + with self._lock: + if not isinstance(crop, str) or crop not in config.CROPS: + raise GameError( + "unknown_crop", + f"未知作物 {crop!r},可选:{'、'.join(config.CROPS)}", + ) + info = config.CROPS[crop] + if self.state.level < info["level_req"]: + raise GameError( + "locked", + f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}", + ) + plot = self._get_plot(plot_no) + if plot.crop is not None: + raise GameError("plot_busy", f"{plot.index + 1} 号地已经种了{config.CROPS[plot.crop]['name']}") + + self._spend_gold(info["seed_cost"], f"购买{info['name']}种子") + self._spend_ap("plant") + + plot.crop = crop + plot.growth = 0.0 + plot.moisture = config.PLANT_INITIAL_MOISTURE + plot.planted_day = self.state.day + + self.add_log(f"在 {plot.index + 1} 号地播下{info['name']}种子", "action") + self._notify() + return { + "ok": True, + "message": f"{plot.index + 1} 号地已种下{info['name']},{info['grow_days']} 天后成熟", + "plot": plot.to_dict(), + } + + def water(self, plot_no: Any) -> Dict[str, Any]: + with self._lock: + plot = self._get_plot(plot_no) + if plot.crop is None: + raise GameError("plot_empty", f"{plot.index + 1} 号地是空地,不需要浇水") + if plot.moisture >= config.WATER_CAPACITY: + raise GameError( + "already_watered", + f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})", + ) + self._spend_ap("water") + plot.moisture = config.WATER_CAPACITY + self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action") + self._notify() + return {"ok": True, "message": f"{plot.index + 1} 号地已浇水", "plot": plot.to_dict()} + + def harvest(self, plot_no: Any) -> Dict[str, Any]: + with self._lock: + plot = self._get_plot(plot_no) + if plot.crop is None: + raise GameError("plot_empty", f"{plot.index + 1} 号地是空地") + if not plot.is_ripe: + raise GameError( + "not_ripe", + f"{config.CROPS[plot.crop]['name']}还差 {plot.days_left} 天成熟", + ) + crop_key = plot.crop + info = config.CROPS[crop_key] + self._spend_ap("harvest") + + quantity = self._roll_yield() + price = self.market_price(crop_key) + earned = self._earn(price * quantity, info["name"]) + + plot.crop = None + plot.growth = 0.0 + plot.moisture = 0 + self.state.total_harvested += quantity + + lucky = "(丰收!)" if quantity > 1 else "" + self.add_log( + f"收获 {quantity} 份{info['name']}{lucky},+{earned} 金币", "good" + ) + levelups = self._gain_exp(info["exp"]) + self._notify() + return { + "ok": True, + "message": f"收获 {quantity} 份{info['name']},获得 {earned} 金币", + "quantity": quantity, + "earned": earned, + "exp": info["exp"], + "level_ups": levelups, + } + + def buy_animal(self, kind: Any) -> Dict[str, Any]: + with self._lock: + if not isinstance(kind, str) or kind not in config.ANIMALS: + raise GameError( + "unknown_animal", + f"未知动物 {kind!r},可选:{'、'.join(config.ANIMALS)}", + ) + info = config.ANIMALS[kind] + if self.state.level < info["level_req"]: + raise GameError( + "locked", + f"{info['name']}需要 Lv.{info['level_req']},当前 Lv.{self.state.level}", + ) + if len(self.state.animals) >= config.MAX_ANIMALS: + raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)") + + self._spend_gold(info["cost"], f"购买{info['name']}") + self._spend_ap("buy_animal") + + animal = Animal(kind) + self.state.animals.append(animal) + self.add_log(f"买了一只{info['name']}(#{animal.aid}),{info['cost']} 金币", "action") + self._notify() + return { + "ok": True, + "message": f"购入{info['name']} #{animal.aid},记得每天喂食", + "animal": animal.to_dict(), + } + + def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]: + with self._lock: + animal = self._get_animal(animal_id) + if animal.fed: + raise GameError("already_fed", f"{animal.name} #{animal.aid} 今天已经吃过了") + + need = int(animal.info["feed_per_day"]) + if quantity is None: + quantity = need + try: + quantity = int(quantity) + except (TypeError, ValueError): + raise GameError("bad_param", "喂食数量必须是整数") + if quantity <= 0: + raise GameError("bad_param", "喂食数量必须大于 0") + if quantity > need: + quantity = need # 多喂无益,自动收窄到实际需求 + + if self.state.feed < quantity: + shortfall = quantity - self.state.feed + cost = shortfall * config.FEED_EMERGENCY_PRICE + self._spend_gold(cost, f"临时采购 {shortfall} 份饲料") + self.state.feed += shortfall + self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn") + + self._spend_ap("feed") + self.state.feed -= quantity + animal.fed = True + animal.starve = 0 + self.add_log(f"喂了{animal.name} #{animal.aid} {quantity} 份饲料", "action") + self._notify() + return { + "ok": True, + "message": f"{animal.name} #{animal.aid} 吃饱了,剩余饲料 {self.state.feed}", + "feed_left": self.state.feed, + "animal": animal.to_dict(), + } + + def collect(self, animal_id: Any) -> Dict[str, Any]: + with self._lock: + animal = self._get_animal(animal_id) + if animal.pending <= 0: + raise GameError( + "nothing_to_collect", + f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}" + f"(还需 {animal.days_to_product} 天)", + ) + self._spend_ap("collect") + quantity = animal.pending + animal.pending = 0 + price = self.market_price(f"animal:{animal.kind}") + earned = self._earn(price * quantity, animal.product) + self.state.total_collected += quantity + + self.add_log( + f"收取 {quantity} 份{animal.product}({animal.name} #{animal.aid}),+{earned} 金币", + "good", + ) + levelups = self._gain_exp(animal.info["exp"] * quantity) + self._notify() + return { + "ok": True, + "message": f"收取 {quantity} 份{animal.product},获得 {earned} 金币", + "quantity": quantity, + "earned": earned, + "level_ups": levelups, + } + + def buy_feed(self, quantity: Any = config.FEED_BULK) -> Dict[str, Any]: + with self._lock: + try: + quantity = int(quantity) + except (TypeError, ValueError): + raise GameError("bad_param", "饲料数量必须是整数") + if quantity <= 0: + raise GameError("bad_param", "饲料数量必须大于 0") + if quantity > config.MAX_FEED_PURCHASE: + raise GameError( + "too_many", + f"单次最多购买 {config.MAX_FEED_PURCHASE} 份饲料(请求 {quantity})", + ) + cost = quantity * config.FEED_UNIT_PRICE + self._spend_gold(cost, f"购买 {quantity} 份饲料") + self.state.feed += quantity + self.add_log(f"购买 {quantity} 份饲料(-{cost} 金币)", "action") + self._notify() + return { + "ok": True, + "message": f"购入 {quantity} 份饲料,库存 {self.state.feed}", + "feed": self.state.feed, + } + + def buy_plot(self) -> Dict[str, Any]: + with self._lock: + price = self.state.next_plot_price + if price < 0: + raise GameError( + "plot_limit", + f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦", + ) + self._spend_gold(price, "开垦新土地") + self._spend_ap("buy_plot") + plot = Plot(len(self.state.plots)) + self.state.plots.append(plot) + self.state.plots_bought += 1 + self.add_log(f"开垦了第 {plot.index + 1} 块地(-{price} 金币)", "action") + self._notify() + return { + "ok": True, + "message": f"新增 {plot.index + 1} 号地,共 {len(self.state.plots)} 块", + "plot": plot.to_dict(), + "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: Optional[bool] = None) -> Dict[str, Any]: + with self._lock: + self.paused = bool(paused) if paused is not None else not self.paused + self.add_log("时间已暂停" if self.paused else "时间继续流动", "warn") + self._notify() + return {"ok": True, "paused": self.paused} + + def reset(self, keep_tick: bool = True) -> Dict[str, Any]: + with self._lock: + tick = self.tick_seconds + self.state = GameState() + self._log_seq = 0 + self._last_sleep = 0.0 + self.paused = False + if keep_tick: + self.tick_seconds = tick + self.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info") + self._notify() + return {"ok": True, "message": "农场已重置"} + + def save(self, path: Optional[str] = None) -> Dict[str, Any]: + """把当前状态写入磁盘。 + + 单机模式下由 TUI 直接调用;服务器模式里存档位置由牧场会话决定, + 但保留同名方法是为了让界面代码不必区分"本地/远程"。 + """ + from .storage import save_state + + target = save_state(self.state, path) + self.add_log(f"已存档到 {target}", "info") + return {"ok": True, "message": f"已存档到 {target}", "path": target} + + # ================================================================ 时间推进 + def advance_day(self, reason: str = "自动") -> List[str]: + """推进游戏内一天,返回本日发生的事件文本。""" + with self._lock: + state = self.state + state.day += 1 + state.ap = state.max_ap + events: List[str] = [] + + # ---- 作物生长 + for plot in state.plots: + if plot.crop is None: + continue + if plot.moisture > 0: + plot.moisture -= 1 + plot.growth = min(plot.grow_days, plot.growth + 1) + if plot.is_ripe: + events.append(f"{plot.index + 1} 号地的{config.CROPS[plot.crop]['name']}已成熟") + else: + events.append(f"{plot.index + 1} 号地缺水,作物停止生长") + + # ---- 牲畜 + dead: List[Animal] = [] + for animal in state.animals: + if animal.fed: + animal.fed = False + animal.starve = 0 + animal.progress += 1 + if animal.progress >= animal.cycle: + animal.progress = 0 + animal.pending += 1 + else: + animal.starve += 1 + if animal.starve >= 3: + dead.append(animal) + elif animal.starve == 2: + events.append(f"{animal.name} #{animal.aid} 已经两天没吃东西了") + + for animal in dead: + state.animals.remove(animal) + events.append(f"{animal.name} #{animal.aid} 因长期饥饿离你而去") + self.add_log(f"{animal.name} #{animal.aid} 因长期饥饿离开农场", "bad") + + # ---- 市场波动 + self._shuffle_market() + + # ---- 写入日志(最多 4 条,避免刷屏) + for text in events[:4]: + self.add_log(text, "warn") + if events: + self.add_log(f"第 {state.day} 天开始({reason}),行动点已恢复", "info") + else: + self.add_log(f"第 {state.day} 天开始({reason}),一切安好", "info") + + self._notify() + return events + + def _shuffle_market(self) -> None: + """每天让市场价格围绕基准价波动。""" + vol = config.PRICE_VOLATILITY + for key, info in config.CROPS.items(): + base = float(info["base_price"]) + factor = random.uniform(1 - vol, 1 + vol) + # 轻微均值回归,避免价格长期漂移 + current = self.state.market.get(key, base) + blended = current * 0.35 + base * 0.65 + self.state.market[key] = round(clamp(blended * factor, base * 0.55, base * 1.55), 2) + for key, info in config.ANIMALS.items(): + base = float(info["base_price"]) + factor = random.uniform(1 - vol, 1 + vol) + current = self.state.market.get(f"animal:{key}", base) + blended = current * 0.35 + base * 0.65 + self.state.market[f"animal:{key}"] = round( + clamp(blended * factor, base * 0.55, base * 1.55), 2 + ) + + +class Scheduler: + """自动推进游戏时间的后台线程。""" + + def __init__(self, engine: FarmEngine, enabled: bool = True) -> None: + self.engine = engine + self.enabled = enabled + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def start(self) -> None: + if not self.enabled or self._thread is not None: + return + self.engine.next_tick_at = time.time() + self.engine.tick_seconds + self._thread = threading.Thread(target=self._run, name="farm-clock", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self.engine.next_tick_at = None + + def _run(self) -> None: + while not self._stop.wait(0.2): + engine = self.engine + if engine.paused: + # 暂停时顺延,保证恢复后仍有完整的一个 tick + engine.next_tick_at = time.time() + engine.tick_seconds + continue + deadline = engine.next_tick_at + if deadline is None: + deadline = time.time() + engine.tick_seconds + if time.time() >= deadline: + try: + engine.advance_day(reason="时间流逝") + except Exception: + pass + engine.next_tick_at = time.time() + engine.tick_seconds + else: + engine.next_tick_at = deadline diff --git a/farm/keys.py b/farm/keys.py new file mode 100644 index 0000000..eef3d6b --- /dev/null +++ b/farm/keys.py @@ -0,0 +1,142 @@ +"""跨平台非阻塞键盘读取。 + +Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``, +两端统一返回规范化的按键名,TUI 层无需关心平台差异。 + +在非交互式环境(管道、重定向)下 ``available`` 为 False, +TUI 会退化为无输入的只读模式。 +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +IS_WINDOWS = os.name == "nt" + +# Windows 下的扩展键(以 \x00 / \xe0 开头) +_WIN_EXTENDED = { + "H": "UP", + "P": "DOWN", + "K": "LEFT", + "M": "RIGHT", + "G": "HOME", + "O": "END", + "S": "DELETE", +} + +_WIN_CTRL = { + "\x03": "CTRL_C", + "\r": "ENTER", + "\n": "ENTER", + "\t": "TAB", + "\x08": "BACKSPACE", + "\x1b": "ESC", + " ": "SPACE", +} + + +class KeyReader: + """把终端的原始字节流转换成按键名。""" + + def __init__(self) -> None: + self.available = sys.stdin is not None and sys.stdin.isatty() + self._fd: Optional[int] = None + self._saved_term = None + + if self.available and not IS_WINDOWS: + try: + import termios + import tty + + self._fd = sys.stdin.fileno() + self._saved_term = termios.tcgetattr(self._fd) + # cbreak: 立即返回按键,但保留 Ctrl-C 等信号 + tty.setcbreak(self._fd) + except Exception: + self.available = False + self._saved_term = None + + if self.available and IS_WINDOWS: + try: + import msvcrt # noqa: F401 + except Exception: + self.available = False + + # ------------------------------------------------------------------ 读取 + def poll(self) -> Optional[str]: + """非阻塞读取一个按键;没有按键时返回 None。""" + if not self.available: + return None + return self._poll_windows() if IS_WINDOWS else self._poll_posix() + + def _poll_windows(self) -> Optional[str]: + import msvcrt + + if not msvcrt.kbhit(): + return None + char = msvcrt.getwch() + + if char in ("\x00", "\xe0"): + if not msvcrt.kbhit(): + return None + return _WIN_EXTENDED.get(msvcrt.getwch().upper()) + if char in _WIN_CTRL: + return _WIN_CTRL[char] + return char.lower() + + def _poll_posix(self) -> Optional[str]: + import select + + if self._fd is None: + return None + try: + ready, _, _ = select.select([self._fd], [], [], 0) + except (OSError, ValueError): + return None + if not ready: + return None + try: + data = os.read(self._fd, 8) + except OSError: + return None + if not data: + return None + + text = data.decode("utf-8", errors="ignore") + if not text: + return None + + # 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D + if text.startswith("\x1b["): + return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) + if text.startswith("\x1bO"): + return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) + + char = text[0] + mapping = { + "\x03": "CTRL_C", + "\r": "ENTER", + "\n": "ENTER", + "\t": "TAB", + "\x7f": "BACKSPACE", + "\x08": "BACKSPACE", + "\x1b": "ESC", + " ": "SPACE", + } + if char in mapping: + return mapping[char] + return char.lower() + + # ------------------------------------------------------------------ 清理 + def close(self) -> None: + if self._fd is not None and self._saved_term is not None: + try: + import termios + + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_term) + except Exception: + pass + self._fd = None + self._saved_term = None diff --git a/farm/models.py b/farm/models.py new file mode 100644 index 0000000..ba05f4b --- /dev/null +++ b/farm/models.py @@ -0,0 +1,324 @@ +"""游戏数据模型。 + +设计原则:模型只负责"持有数据 + 结构化转换",所有规则判定放在 engine.py。 +这样 HTTP 层和 TUI 层都能拿到同一份状态快照。 +""" + +from __future__ import annotations + +import itertools +from typing import Any, Dict, List, Optional + +from . import config + + +def clamp(value: float, low: float, high: float) -> float: + """把 value 限制在 [low, high] 区间内。""" + return max(low, min(high, value)) + + +class Plot: + """一块土地。""" + + __slots__ = ("index", "crop", "growth", "moisture", "planted_day") + + def __init__(self, index: int) -> None: + self.index = index # 0-based 下标,对外展示用 index+1 + self.crop: Optional[str] = None + self.growth: float = 0.0 # 已生长天数 + self.moisture: int = 0 # 剩余可支撑天数 + self.planted_day: int = 0 + + # ---------------------------------------------------------------- 查询 + @property + def is_empty(self) -> bool: + return self.crop is None + + @property + def grow_days(self) -> int: + if self.crop is None: + return 0 + return int(config.CROPS[self.crop]["grow_days"]) + + @property + def is_ripe(self) -> bool: + return self.crop is not None and self.growth >= self.grow_days + + @property + def days_left(self) -> int: + if self.crop is None: + return 0 + return max(0, self.grow_days - int(self.growth)) + + @property + def stage(self) -> str: + """人类可读的生长阶段。""" + if self.crop is None: + return "空地" + progress = self.growth / max(1, self.grow_days) + name = "已播种" + for threshold, label in config.GROWTH_STAGES: + if progress >= threshold: + name = label + return name + + # ---------------------------------------------------------------- 序列化 + def to_dict(self) -> Dict[str, Any]: + info = config.CROPS.get(self.crop) if self.crop else None + return { + "index": self.index, + "position": self.index + 1, + "crop": self.crop, + "crop_name": info["name"] if info else None, + "growth": round(self.growth, 2), + "grow_days": self.grow_days, + "days_left": self.days_left, + "is_ripe": self.is_ripe, + "is_empty": self.is_empty, + "moisture": self.moisture, + "water_capacity": config.WATER_CAPACITY, + "stage": self.stage, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Plot": + plot = cls(int(data.get("index", 0))) + crop = data.get("crop") + # 校验存档里的作物名仍然合法,避免旧存档导致 KeyError + plot.crop = crop if crop in config.CROPS else None + plot.growth = float(data.get("growth", 0.0)) + plot.moisture = int(data.get("moisture", 0)) + plot.planted_day = int(data.get("planted_day", 0)) + return plot + + +class Animal: + """一只牲畜。""" + + _id_counter = itertools.count(1) + + __slots__ = ("aid", "kind", "fed", "progress", "starve", "pending") + + def __init__(self, kind: str, aid: Optional[int] = None) -> None: + self.aid = aid if aid is not None else next(Animal._id_counter) + self.kind = kind + self.fed = False # 今天是否已喂食 + self.progress = 0 # 距下次产出的累计天数 + self.starve = 0 # 连续未进食天数 + self.pending = 0 # 待收集的产出数量 + + # ---------------------------------------------------------------- 查询 + @property + def info(self) -> Dict[str, Any]: + return config.ANIMALS[self.kind] + + @property + def name(self) -> str: + return self.info["name"] + + @property + def product(self) -> str: + return self.info["product"] + + @property + def cycle(self) -> int: + return int(self.info["cycle"]) + + @property + def days_to_product(self) -> int: + return max(0, self.cycle - self.progress) + + @property + def is_sick(self) -> bool: + """连续 2 天没吃东西,进入"虚弱"状态。""" + return self.starve >= 2 + + # ---------------------------------------------------------------- 序列化 + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.aid, + "kind": self.kind, + "name": self.name, + "product": self.product, + "fed": self.fed, + "progress": self.progress, + "cycle": self.cycle, + "days_to_product": self.days_to_product, + "pending": self.pending, + "starve": self.starve, + "is_sick": self.is_sick, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Optional["Animal"]: + kind = data.get("kind") + if kind not in config.ANIMALS: + return None + animal = cls(kind, int(data.get("id", 0)) or None) + animal.fed = bool(data.get("fed", False)) + animal.progress = int(data.get("progress", 0)) + animal.starve = int(data.get("starve", 0)) + animal.pending = int(data.get("pending", 0)) + # 恢复 id 计数器,避免新动物与旧动物 id 冲突 + Animal._id_counter = itertools.count(max(animal.aid + 1, next(Animal._id_counter))) + return animal + + +class GameState: + """完整的游戏状态。所有字段都必须是可 JSON 序列化的。""" + + def __init__(self) -> None: + self.day: int = 1 + self.gold: int = config.START_GOLD + self.level: int = config.START_LEVEL + self.exp: int = 0 + self.ap: int = config.START_AP + self.feed: int = config.START_FEED + self.plots: List[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)] + self.animals: List[Animal] = [] + self.log: List[Dict[str, Any]] = [] + + # 市场价格(每天波动):key -> 当前单价 + self.market: Dict[str, float] = { + key: float(info["base_price"]) for key, info in config.CROPS.items() + } + for key, info in config.ANIMALS.items(): + self.market[f"animal:{key}"] = float(info["base_price"]) + + # 统计 + self.total_harvested: int = 0 + self.total_collected: int = 0 + self.total_earned: int = 0 + self.total_spent: int = 0 + self.plots_bought: int = 0 + + # ---------------------------------------------------------------- 派生属性 + @property + def max_ap(self) -> int: + return config.START_AP + + @property + def max_plots(self) -> int: + """当前等级允许持有的土地上限。""" + allowed = config.BASE_PLOTS + config.PLOTS_PER_LEVEL * (self.level - 1) + return int(clamp(allowed, config.BASE_PLOTS, config.MAX_PLOTS)) + + @property + def next_plot_price(self) -> int: + """下一块土地的价格;已达上限时返回 -1。""" + if len(self.plots) >= self.max_plots: + return -1 + return int(config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** self.plots_bought)) + + @property + def exp_needed(self) -> int: + return config.exp_to_next(self.level) + + @property + def inventory_value(self) -> float: + return self.feed * config.FEED_UNIT_PRICE + + @property + def livestock_value(self) -> float: + return sum( + a.pending * self.market.get(f"animal:{a.kind}", 0) + a.info["cost"] * 0.5 + for a in self.animals + ) + + @property + def net_worth(self) -> float: + """净资产:金币 + 已购地价 + 牲畜折价 + 库存 + 地里未收的作物。""" + land = sum( + config.PLOT_BASE_PRICE * (config.PLOT_PRICE_GROWTH ** i) + for i in range(self.plots_bought) + ) + crops = 0.0 + for plot in self.plots: + if plot.crop: + crops += self.market.get(plot.crop, 0) * (plot.growth / max(1, plot.grow_days)) + return self.gold + land + self.livestock_value + self.inventory_value + crops + + @property + def goal_index(self) -> int: + return config.goal_index(self.net_worth) + + @property + def next_goal(self) -> Optional[Dict[str, Any]]: + nxt = self.goal_index + 1 + if nxt >= len(config.GOALS): + return None + amount, text = config.GOALS[nxt] + return {"amount": amount, "text": text} + + # ---------------------------------------------------------------- 序列化 + def to_dict(self) -> Dict[str, Any]: + return { + "day": self.day, + "gold": self.gold, + "level": self.level, + "exp": self.exp, + "exp_needed": self.exp_needed, + "ap": self.ap, + "max_ap": self.max_ap, + "feed": self.feed, + "plots": [p.to_dict() for p in self.plots], + "plot_count": len(self.plots), + "max_plots": self.max_plots, + "next_plot_price": self.next_plot_price, + "animals": [a.to_dict() for a in self.animals], + "animal_count": len(self.animals), + "max_animals": config.MAX_ANIMALS, + "market": {k: round(v, 2) for k, v in self.market.items()}, + "net_worth": round(self.net_worth, 2), + "goal_index": self.goal_index, + "next_goal": self.next_goal, + "stats": { + "harvested": self.total_harvested, + "collected": self.total_collected, + "earned": self.total_earned, + "spent": self.total_spent, + }, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GameState": + state = cls() + state.day = max(1, int(data.get("day", 1))) + state.gold = max(0, int(data.get("gold", config.START_GOLD))) + state.level = max(1, int(data.get("level", 1))) + state.exp = max(0, int(data.get("exp", 0))) + state.feed = max(0, int(data.get("feed", 0))) + state.ap = int(clamp(int(data.get("ap", state.max_ap)), 0, state.max_ap)) + + plots = [Plot.from_dict(p) for p in data.get("plots", []) if isinstance(p, dict)] + # 保证至少有初始土地,且不超过硬上限 + count = int(clamp(len(plots) or config.BASE_PLOTS, config.BASE_PLOTS, config.MAX_PLOTS)) + if len(plots) < count: + plots.extend(Plot(i) for i in range(len(plots), count)) + state.plots = plots[:config.MAX_PLOTS] + for i, plot in enumerate(state.plots): + plot.index = i + + animals: List[Animal] = [] + for item in data.get("animals", [])[: config.MAX_ANIMALS]: + if isinstance(item, dict): + animal = Animal.from_dict(item) + if animal is not None: + animals.append(animal) + state.animals = animals + + market = data.get("market", {}) + for key in list(state.market.keys()): + try: + value = float(market.get(key, state.market[key])) + except (TypeError, ValueError): + continue + state.market[key] = clamp(value, 1.0, 100000.0) + + stats = data.get("stats", {}) + state.total_harvested = max(0, int(stats.get("harvested", 0))) + state.total_collected = max(0, int(stats.get("collected", 0))) + state.total_earned = max(0, int(stats.get("earned", 0))) + state.total_spent = max(0, int(stats.get("spent", 0))) + state.plots_bought = max(0, len(state.plots) - config.BASE_PLOTS) + return state diff --git a/farm/net.py b/farm/net.py new file mode 100644 index 0000000..402a1e2 --- /dev/null +++ b/farm/net.py @@ -0,0 +1,63 @@ +"""网络小工具:本地回环请求不该走系统代理。 + +为什么需要这个模块 +------------------ +在有 ``HTTP_PROXY`` 的机器上(公司网络、容器、CI 沙箱都常见),标准库 +``urllib`` 会把 **所有** 请求——包括发给 ``127.0.0.1`` 的——都塞给代理。 +于是本机自己开的游戏服务器会莫名其妙地连不上,报的还是"502 上游连接失败" +这种看不出根因的错误。 + +游戏客户端只应该关心"我和服务器之间"这一跳: +* 目标是回环地址(127.0.0.1 / localhost / ::1)→ 直连,不经过任何代理; +* 目标是别的机器 → 尊重用户环境里的代理设置。 + +WebSocket 那边不需要处理:``websockets`` 库默认不读代理环境变量。 +""" + +from __future__ import annotations + +import urllib.parse +import urllib.request +from typing import Any, Optional, Union + +LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0", ""} + +_no_proxy_opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def is_loopback(host: Optional[str]) -> bool: + """判断主机名是否是本机回环(含 127.x.x.x 网段与 IPv6 回环)。""" + if host is None: + return False + host = host.strip().lower().strip("[]") + if host in LOOPBACK_HOSTS: + return True + return host.startswith("127.") + + +def host_of(target: Union[str, urllib.request.Request]) -> Optional[str]: + url = target.full_url if isinstance(target, urllib.request.Request) else target + try: + return urllib.parse.urlsplit(url).hostname + except ValueError: + return None + + +def open_url(target: Union[str, urllib.request.Request], timeout: float = 6.0, + host: Optional[str] = None) -> Any: + """打开一个 URL 或 Request,回环地址自动绕过代理。 + + 返回值是 ``http.client.HTTPResponse``,用法与 ``urllib.request.urlopen`` 一致。 + """ + if not isinstance(target, urllib.request.Request): + target = urllib.request.Request(target, headers={"Accept": "application/json"}) + target_host = host or host_of(target) + if is_loopback(target_host): + return _no_proxy_opener.open(target, timeout=timeout) + return urllib.request.urlopen(target, timeout=timeout) + + +def endpoint_host(url: str, default: str = "127.0.0.1") -> str: + """从 ``http://host:port`` 里取出 host(带默认值)。""" + parts = urllib.parse.urlsplit(url if "://" in url else "http://" + url) + return parts.hostname or default diff --git a/farm/server/__init__.py b/farm/server/__init__.py new file mode 100644 index 0000000..308578b --- /dev/null +++ b/farm/server/__init__.py @@ -0,0 +1,60 @@ +"""SimpleFarm 服务器包。 + +把原本"一个进程一局游戏"的单机引擎,改造成一个可以多人同时在线、 +可以后台常驻、既能被人玩也能被 AI 调用的小型游戏服务器。 + +模块划分 +-------- +* :mod:`farm.server.app` —— FastAPI 应用(REST + WebSocket) +* :mod:`farm.server.actions` —— 动作表:所有入口共用的唯一执行通道 +* :mod:`farm.server.session` —— 多牧场会话与统一时钟 +* :mod:`farm.server.hub` —— WebSocket 连接池与状态推送 +* :mod:`farm.server.daemon` —— 守护进程(start/stop/status) +* :mod:`farm.server.webui` —— 内嵌网页客户端 + +``app`` 依赖 FastAPI,而 ``daemon`` 只依赖标准库。为了让 +``python main.py stop`` 在没有装 FastAPI 的机器上也能用,这里采用**惰性导出**: +真正 import 到用例时才加载对应子模块。 +""" + +from __future__ import annotations + +from typing import Any + +_LAZY = { + "API_VERSION", "SERVER_NAME", "ServerConfig", "ServerHandle", + "build_app", "create_app", "rules_text", "brief", + "Action", "Param", "ACTIONS", "run_action", "describe_actions", "action_names", + "FarmSession", "SessionManager", "FarmClock", "validate_farm_id", "DEFAULT_FARM", + "Client", "ConnectionHub", "Publisher", + "DaemonManager", "pid_alive", + "DASHBOARD_HTML", "dashboard_text", +} + +__all__ = sorted(_LAZY) + + +def __getattr__(name: str) -> Any: # PEP 562:模块级惰性属性 + if name not in _LAZY: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from importlib import import_module + + if name in ("DaemonManager", "pid_alive"): + module = import_module("farm.server.daemon") + elif name in ("DASHBOARD_HTML", "dashboard_text"): + module = import_module("farm.server.webui") + elif name in ("Action", "Param", "ACTIONS", "run_action", "describe_actions", + "action_names"): + module = import_module("farm.server.actions") + elif name in ("FarmSession", "SessionManager", "FarmClock", "validate_farm_id", + "DEFAULT_FARM"): + module = import_module("farm.server.session") + elif name in ("Client", "ConnectionHub", "Publisher"): + module = import_module("farm.server.hub") + else: + module = import_module("farm.server.app") + return getattr(module, name) + + +def __dir__() -> list: + return sorted(set(list(globals()) + __all__)) diff --git a/farm/server/__main__.py b/farm/server/__main__.py new file mode 100644 index 0000000..77e046c --- /dev/null +++ b/farm/server/__main__.py @@ -0,0 +1,164 @@ +"""``python -m farm.server`` —— 服务器进程入口(前台运行)。 + +守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行 +再拉起一个本模块的实例,只是不带 ``--daemon``。 +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from .. import config +from .daemon import DEFAULT_LOG_FILE, DEFAULT_PID_FILE, PROJECT_ROOT, DaemonManager + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m farm.server", + description="SimpleFarm 游戏服务器:HTTP 接口给 AI,WebSocket 给实时界面。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--host", default="127.0.0.1", + help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0") + parser.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765") + parser.add_argument("--token", default=None, help="访问令牌,设置后所有接口都要带") + parser.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS, + help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒") + parser.add_argument("--save-dir", default=None, help="存档目录,默认 <项目根>/saves") + parser.add_argument("--save", dest="save_file", default=None, + help="兼容旧参数:指定 default 牧场的存档文件") + parser.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限") + parser.add_argument("--idle-timeout", type=float, default=1800.0, + help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800") + parser.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称") + parser.add_argument("--log-level", default="info", + choices=["critical", "error", "warning", "info", "debug"]) + parser.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求") + parser.add_argument("--no-remote-control", action="store_true", + help="禁止通过接口停机") + parser.add_argument("--daemon", action="store_true", + help="转到后台运行(等价于 python main.py serve --daemon)") + parser.add_argument("--pid-file", default=None, + help="把本进程 pid 写到该文件(守护进程模式会自动指定)") + parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help=argparse.SUPPRESS) + return parser + + +def clamp_tick(value: float) -> float: + return max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, float(value))) + + +def dependency_hint() -> str: + return ( + "缺少运行服务器所需的依赖。请先安装:\n" + " uv sync\n" + "如果不想用 uv,也可以用 pip:\n" + " pip install fastapi 'uvicorn[standard]'\n" + f"\n当前解释器:{sys.executable}" + ) + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves") + if args.save_file: + save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir + + if args.daemon: + manager = DaemonManager( + host=args.host, port=args.port, token=args.token, + tick=clamp_tick(args.tick), save_dir=save_dir, + pid_file=args.pid_file or DEFAULT_PID_FILE, + log_file=args.log_file or DEFAULT_LOG_FILE, + ) + ok, message = manager.start() + print(message) + return 0 if ok else 1 + + try: + import uvicorn + except ImportError: + print(dependency_hint(), file=sys.stderr) + return 2 + try: + import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级 + ws_ok = True + except ImportError: + ws_ok = False + + from .app import ServerConfig, create_app + + cfg = ServerConfig( + host=args.host, + port=args.port, + token=args.token, + tick_seconds=clamp_tick(args.tick), + save_dir=save_dir, + max_sessions=max(1, args.max_sessions), + idle_timeout=max(60.0, args.idle_timeout), + world_name=args.world, + allow_remote_control=not args.no_remote_control, + ) + app = create_app(cfg) + + server_config = uvicorn.Config( + app, + host=cfg.host, + port=cfg.port, + log_level=args.log_level, + access_log=args.access_log, + loop="asyncio", + lifespan="on", + ) + server = uvicorn.Server(server_config) + # 让 POST /api/admin/shutdown 能触发优雅退出(lifespan 里会把存档写掉) + app.state.handle.on_stop(lambda: setattr(server, "should_exit", True)) + + # pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正 + # 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。 + def write_pid_file() -> None: + if not args.pid_file: + return + try: + os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True) + with open(args.pid_file, "w", encoding="utf-8") as handle: + handle.write(str(os.getpid())) + except OSError as exc: + print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr) + + app.state.handle.on_start(write_pid_file) + + print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…") + print(f" 监听 http://{cfg.host}:{cfg.port}/") + print(f" 存档目录 {save_dir}") + print(f" 实时通道 ws://{cfg.host}:{cfg.port}/ws?farm=<牧场名>") + if not ws_ok: + print(" ⚠ 未检测到 websockets 库,/ws 实时通道可能不可用;" + "网页端会自动降级为轮询。安装:pip install 'uvicorn[standard]'") + if cfg.token: + print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数") + + server.run() + + # 收尾:把自己写的 pid 文件清掉,避免下次 status 看到"幽灵进程" + if args.pid_file: + try: + if os.path.exists(args.pid_file): + with open(args.pid_file, "r", encoding="utf-8") as handle: + if handle.read().strip() == str(os.getpid()): + os.remove(args.pid_file) + except OSError: + pass + + if not getattr(server, "started", False): + print(f"启动失败:端口 {cfg.port} 可能已被占用。" + f"换一个端口试试:--port {cfg.port + 1}", file=sys.stderr) + return 3 + print("服务器已停止。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/farm/server/actions.py b/farm/server/actions.py new file mode 100644 index 0000000..c494a2d --- /dev/null +++ b/farm/server/actions.py @@ -0,0 +1,240 @@ +"""统一的动作表:HTTP、WebSocket、命令行、机器人都走这一份映射。 + +为什么要有这一层 +---------------- +引擎(``farm.engine.FarmEngine``)提供的是**方法**,但外部调用者(AI Agent、 +脚本、网页)手里拿到的只有字符串动作名 + 参数字典。如果让每个入口各自写一遍 +``if action == "plant"`` 的分发,规则就会散落在三四个文件里,迟早出现 +"HTTP 能做、WS 不能做" 这种不一致。 + +所以这里集中定义:动作名 → 参数规格 → 引擎调用 → 返回结果。 +新增一个玩法动作,只需要在这张表里加一行。 +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from .. import config +from ..engine import FarmEngine, GameError + + +class Param: + """一个动作参数的规格说明(同时用于校验与自动生成给 AI 的文档)。""" + + __slots__ = ("name", "kind", "required", "default", "desc") + + def __init__(self, name: str, kind: str, required: bool = True, + default: Any = None, desc: str = "") -> None: + self.name = name + self.kind = kind # "int" / "str" / "bool" + self.required = required + self.default = default + self.desc = desc + + def coerce(self, raw: Any) -> Any: + """把外部传来的值转成期望类型,失败时抛出带 code 的业务异常。""" + if self.kind == "bool": + if isinstance(raw, bool): + return raw + if isinstance(raw, str) and raw.lower() in ("true", "false", "1", "0"): + return raw.lower() in ("true", "1") + if isinstance(raw, int): + return bool(raw) + raise GameError("bad_param", f"参数 {self.name} 需要布尔值,收到 {raw!r}") + if self.kind == "int": + if isinstance(raw, bool): + raise GameError("bad_param", f"参数 {self.name} 需要整数,收到布尔值") + if isinstance(raw, int): + return raw + if isinstance(raw, float) and raw.is_integer(): + return int(raw) + if isinstance(raw, str) and raw.strip().lstrip("+-").isdigit(): + return int(raw.strip()) + raise GameError("bad_param", f"参数 {self.name} 需要整数,收到 {raw!r}") + if self.kind == "str": + if isinstance(raw, (str, int, float)): + return str(raw) + raise GameError("bad_param", f"参数 {self.name} 需要字符串,收到 {raw!r}") + return raw + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "type": self.kind, + "required": self.required, + "default": self.default, + "desc": self.desc, + } + + +class Action: + """一个可执行动作。""" + + __slots__ = ("name", "summary", "params", "handler", "realtime") + + def __init__(self, name: str, summary: str, params: List[Param], + handler: Callable[[FarmEngine, Dict[str, Any]], Dict[str, Any]], + realtime: bool = True) -> None: + self.name = name + self.summary = summary + self.params = params + self.handler = handler + self.realtime = realtime + + def to_dict(self) -> Dict[str, Any]: + return { + "action": self.name, + "summary": self.summary, + "params": [p.to_dict() for p in self.params], + } + + +# ---------------------------------------------------------------------- 处理器 +def _harvest_all(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]: + """收获所有成熟作物。逐块调用引擎,个别地块失败不影响其它地块。""" + total_gold = 0 + total_units = 0 + plots: List[int] = [] + errors: List[str] = [] + # 复制列表:harvest 会修改 state.plots 中元素的内容,但不会增删元素 + for plot in list(engine.state.plots): + if not plot.is_ripe: + continue + try: + result = engine.harvest(plot.index + 1) + except GameError as exc: + errors.append(f"{plot.index + 1}号地:{exc.message}") + continue + total_gold += int(result.get("earned", 0)) + total_units += int(result.get("quantity", 0)) + plots.append(plot.index + 1) + + if not plots and not errors: + raise GameError("nothing_to_harvest", "当前没有成熟的作物可以收获") + return { + "message": f"收获 {len(plots)} 块地共 {total_units} 份作物,+{total_gold} 金币", + "plots": plots, + "quantity": total_units, + "earned": total_gold, + "errors": errors, + } + + +def _save(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]: + return engine.save() + + +def _reset(engine: FarmEngine, _params: Dict[str, Any]) -> Dict[str, Any]: + return engine.reset() + + +def _pause(engine: FarmEngine, params: Dict[str, Any]) -> Dict[str, Any]: + value = params.get("paused") + return engine.pause(value if isinstance(value, bool) else None) + + +# ---------------------------------------------------------------------- 动作表 +ACTIONS: Dict[str, Action] = {} + + +def _register(action: Action) -> None: + ACTIONS[action.name] = action + + +_register(Action("plant", "在指定地块播种", [ + Param("plot", "int", True, None, "地块编号,从 1 开始"), + Param("crop", "str", True, None, "作物标识,如 wheat"), +], lambda e, p: e.plant(p["plot"], p["crop"]))) + +_register(Action("water", "给指定地块浇水(补满水分)", [ + Param("plot", "int", True, None, "地块编号,从 1 开始"), +], lambda e, p: e.water(p["plot"]))) + +_register(Action("harvest", "收获指定地块的成熟作物", [ + Param("plot", "int", True, None, "地块编号,从 1 开始"), +], lambda e, p: e.harvest(p["plot"]))) + +_register(Action("harvest_all", "一键收获所有成熟作物", [], _harvest_all)) + +_register(Action("buy_animal", "购买牲畜", [ + Param("kind", "str", True, None, "牲畜标识:chicken / sheep / cow"), +], lambda e, p: e.buy_animal(p["kind"]))) + +_register(Action("feed", "喂食指定牲畜", [ + Param("animal_id", "int", True, None, "牲畜编号(state.animals[].id)"), + Param("quantity", "int", False, None, "喂食份数,默认按需求;多喂无益"), +], lambda e, p: e.feed(p["animal_id"], p.get("quantity")))) + +_register(Action("collect", "收取牲畜的产出并自动出售", [ + Param("animal_id", "int", True, None, "牲畜编号"), +], lambda e, p: e.collect(p["animal_id"]))) + +_register(Action("buy_feed", "购买饲料", [ + Param("quantity", "int", False, config.FEED_BULK, f"份数,默认 {config.FEED_BULK}"), +], lambda e, p: e.buy_feed(p.get("quantity", config.FEED_BULK)))) + +_register(Action("buy_plot", "花钱开垦一块新土地", [], lambda e, p: e.buy_plot())) + +_register(Action("sleep", "主动推进一天(有冷却)", [], lambda e, p: e.sleep())) + +_register(Action("pause", "暂停或恢复自动推进时间", [ + Param("paused", "bool", False, None, "true 暂停 / false 恢复;省略则反转当前状态"), +], _pause)) + +_register(Action("save", "把当前农场写入存档文件", [], _save)) + +_register(Action("reset", "重置农场,从头开始经营", [], _reset)) + + +def action_names() -> List[str]: + return sorted(ACTIONS) + + +def describe_actions() -> List[Dict[str, Any]]: + return [ACTIONS[name].to_dict() for name in action_names()] + + +def run_action(engine: FarmEngine, name: Any, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """校验参数并执行动作。 + + 这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。 + """ + if not isinstance(name, str) or not name.strip(): + raise GameError("bad_param", "缺少动作名 action") + key = name.strip().lower() + action = ACTIONS.get(key) + if action is None: + raise GameError( + "unknown_action", + f"未知动作 {name!r},可用动作:{'、'.join(action_names())}", + ) + if params is None: + params = {} + if not isinstance(params, dict): + raise GameError("bad_param", "params 必须是对象") + + known = {p.name for p in action.params} + unknown = [k for k in params if k not in known] + if unknown: + allowed = "、".join(sorted(known)) or "无" + raise GameError( + "bad_param", + f"动作 {key} 不认识参数 {'、'.join(unknown)}(可用:{allowed})", + ) + + clean: Dict[str, Any] = {} + for spec in action.params: + if spec.name in params and params[spec.name] is not None: + clean[spec.name] = spec.coerce(params[spec.name]) + elif spec.required: + raise GameError("missing_param", f"动作 {key} 缺少必需参数 {spec.name}") + elif spec.default is not None: + clean[spec.name] = spec.default + + result = action.handler(engine, clean) + if not isinstance(result, dict): + result = {"message": str(result)} + result.setdefault("ok", True) + result["action"] = key + return result diff --git a/farm/server/app.py b/farm/server/app.py new file mode 100644 index 0000000..9ba2064 --- /dev/null +++ b/farm/server/app.py @@ -0,0 +1,688 @@ +"""FastAPI 应用:REST + WebSocket + 网页客户端。 + +接口设计原则 +------------ +1. **一切皆 JSON**:每个响应都带 ``ok`` 字段,出错时带机器可读的 ``error`` + 与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。 +2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``), + 这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。 +3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家, + 每个牧场有自己的引擎、时钟与存档;互不影响。 +4. **实时通道**:``/ws`` 推送状态,人和 AI 都能订阅;HTTP 与 WS 改的是 + 同一个引擎,所以「网页里点一下,AI 那边立刻看到」。 +""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse + +from .. import config +from ..engine import GameError +from . import actions as actions_mod +from .hub import Client, ConnectionHub, Publisher, next_seq +from .session import DEFAULT_FARM, FarmClock, FarmSession, SessionManager, validate_farm_id +from .webui import DASHBOARD_HTML, dashboard_text + +API_VERSION = "2.0" +SERVER_NAME = "SimpleFarm" + + +@dataclass +class ServerConfig: + """服务器配置(由命令行解析而来)。""" + + host: str = "127.0.0.1" + port: int = 8765 + token: Optional[str] = None + tick_seconds: float = config.DEFAULT_TICK_SECONDS + save_dir: str = "saves" + max_sessions: int = 64 + idle_timeout: float = 1800.0 + autosave_interval: float = 60.0 + allow_remote_control: bool = True + world_name: str = "SimpleFarm 公共农场" + extra: Dict[str, Any] = field(default_factory=dict) + + +class ServerHandle: + """让 HTTP 接口能够优雅地要求本进程退出,并暴露进程生命周期的钩子。 + + * ``on_start``:监听端口成功后触发(用于写 pid 文件等); + * ``on_stop``:请求关闭时触发(uvicorn 用它设置 ``should_exit``)。 + """ + + def __init__(self) -> None: + self._start_callbacks: List[Callable[[], None]] = [] + self._stop_callbacks: List[Callable[[], None]] = [] + self.stopping = False + + def on_start(self, callback: Callable[[], None]) -> None: + self._start_callbacks.append(callback) + + def on_stop(self, callback: Callable[[], None]) -> None: + self._stop_callbacks.append(callback) + + def notify_start(self) -> None: + for callback in list(self._start_callbacks): + try: + callback() + except Exception: + pass + + def request_stop(self) -> bool: + if self.stopping: + return False + self.stopping = True + for callback in list(self._stop_callbacks): + try: + callback() + except Exception: + pass + return True + + +# ---------------------------------------------------------------------- 工具 +def brief(session: FarmSession, hub: ConnectionHub) -> Dict[str, Any]: + """操作后回给调用者的精简状态:够做下一步决策,又不至于太吵。""" + snap = session.snapshot() + return { + "farm": session.farm_id, + "day": snap["day"], + "gold": snap["gold"], + "level": snap["level"], + "exp": snap["exp"], + "exp_needed": snap["exp_needed"], + "ap": snap["ap"], + "max_ap": snap["max_ap"], + "feed": snap["feed"], + "plot_count": snap["plot_count"], + "max_plots": snap["max_plots"], + "next_plot_price": snap["next_plot_price"], + "animal_count": snap["animal_count"], + "max_animals": snap["max_animals"], + "ripe_plots": [p["position"] for p in snap["plots"] if p["is_ripe"]], + "hungry_animals": [a["id"] for a in snap["animals"] if not a["fed"]], + "collectable_animals": [a["id"] for a in snap["animals"] if a["pending"] > 0], + "net_worth": snap["net_worth"], + "goal_index": snap["goal_index"], + "seconds_to_tick": snap["seconds_to_tick"], + "paused": snap["paused"], + "online": hub.online(session.farm_id), + } + + +def rules_text() -> str: + return ( + f"{SERVER_NAME} 规则速览(给 AI 玩家的上下文)\n" + "==========================================\n" + f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS:.0f} 秒自动推进一天(可配置)。" + f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n" + f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天" + "(POST /api/actions {\"action\":\"sleep\"} 可主动推进一天,有 2 秒冷却)。\n" + f"3. 水分:作物每天消耗 1 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长。" + f"播种时自带 {config.PLANT_INITIAL_MOISTURE} 点水分。\n" + f"4. 收获:成熟后 harvest 获得金币与经验,有 {config.LUCKY_CHANCE:.0%} 概率丰收翻倍。\n" + "5. 牲畜:每天必须喂食才会累计产出周期;连续 3 天未进食会永久离开农场。\n" + f"6. 市场:作物与畜产品价格每天在基础价 ±{config.PRICE_VOLATILITY:.0%} 内波动," + "高价卖出收益更高。\n" + f"7. 土地:初始 {config.BASE_PLOTS} 块,每升 1 级多 {config.PLOTS_PER_LEVEL} 块," + f"上限 {config.MAX_PLOTS} 块;购地价格每次递增。\n" + "8. 目标:把净资产(金币 + 土地 + 牲畜 + 库存 + 地里作物)做到 10000 金币。\n" + "9. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;" + "排行榜见 GET /api/world。\n" + "\n推荐决策循环(AI 最短路径):\n" + " GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n" + " POST /api/actions → {\"action\":\"<上一步里的 action>\"," + "\"params\":{...}} 直接执行\n" + " 响应里的 state 就是执行后的最新状态,无需再查一次。\n" + "\n实时通道:ws://host/ws?farm=me ,服务器在状态变化时推送 state 消息;" + "也可以直接在 WS 上发送 {\"type\":\"action\",\"action\":\"plant\"," + "\"params\":{\"plot\":1,\"crop\":\"wheat\"},\"id\":1} 并等待 result 回执。\n" + ) + + +# ---------------------------------------------------------------------- 应用 +def create_app(cfg: Optional[ServerConfig] = None) -> FastAPI: + cfg = cfg or ServerConfig() + manager = SessionManager( + save_dir=cfg.save_dir, + tick_seconds=cfg.tick_seconds, + max_sessions=cfg.max_sessions, + idle_timeout=cfg.idle_timeout, + ) + hub = ConnectionHub() + publisher = Publisher(manager, hub, autosave_interval=cfg.autosave_interval) + clock = FarmClock(manager) + handle = ServerHandle() + started_at = time.time() + counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0} + + async def lifespan(_app: FastAPI) -> Any: + clock.start() + publisher.start() + # 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了" + handle.notify_start() + try: + yield + finally: + publisher.stop() + clock.stop() + manager.shutdown() + + app = FastAPI( + title=f"{SERVER_NAME} 游戏服务器", + version=API_VERSION, + description="命令行农场游戏的服务端:HTTP 给 AI 用,WebSocket 给实时界面用。", + lifespan=lifespan, + ) + app.state.cfg = cfg + app.state.manager = manager + app.state.hub = hub + app.state.publisher = publisher + app.state.clock = clock + app.state.handle = handle + app.state.started_at = started_at + app.state.counters = counters + + # ------------------------------------------------------------ 异常处理 + @app.exception_handler(GameError) + async def _game_error(_request: Request, exc: GameError) -> JSONResponse: + counters["errors"] += 1 + return JSONResponse(exc.to_dict(), status_code=exc.status) + + @app.exception_handler(json.JSONDecodeError) + async def _bad_json(_request: Request, exc: json.JSONDecodeError) -> JSONResponse: + counters["errors"] += 1 + return JSONResponse( + {"ok": False, "error": "bad_json", "message": f"请求体不是合法 JSON:{exc}"}, + status_code=400, + ) + + # ------------------------------------------------------------ 鉴权与取牧场 + def authorized(supplied: Optional[str]) -> bool: + if not cfg.token: + return True + return supplied == cfg.token + + def token_of(request: Request) -> Optional[str]: + return request.headers.get("x-api-token") or request.query_params.get("token") + + def guard(request: Request) -> None: + counters["requests"] += 1 + if not authorized(token_of(request)): + raise GameError("unauthorized", "缺少或错误的 API Token", status=401) + + def session_of(request: Request, owner: str = "") -> FarmSession: + farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM + return manager.get(farm, create=True, owner=owner) + + def full_requested(request: Request) -> bool: + raw = (request.query_params.get("full") or "").lower() + return raw in ("1", "true", "yes", "on") + + async def body_of(request: Request) -> Dict[str, Any]: + raw = await request.body() + if not raw: + return {} + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GameError("bad_json", f"请求体不是合法 JSON:{exc}") + if not isinstance(data, dict): + raise GameError("bad_json", "请求体必须是 JSON 对象") + return data + + def execute_action(session: FarmSession, name: Any, params: Any) -> Dict[str, Any]: + """执行一个动作,返回引擎/会话级的结果字典。 + + ``save`` 与 ``reset`` 必须由**牧场会话**处理而不是引擎: + ``engine.save()`` 只会写到默认存档路径,多牧场环境下会把别人的存档覆盖掉。 + 这两个动作单独拦截,其余全部走统一的动作表。 + """ + key = name.strip().lower() if isinstance(name, str) else "" + if key == "save": + path = session.save() + session.engine.add_log(f"已存档到 {path}", "info") + return {"ok": True, "action": "save", "path": path, + "message": f"已存档到 {path}"} + if key == "reset": + session.reset() + return {"ok": True, "action": "reset", + "message": f"牧场「{session.farm_id}」已重置,重新开始经营"} + return actions_mod.run_action(session.engine, name, params) + + def run_and_wrap(session: FarmSession, name: Any, params: Any, + full: bool = False) -> Dict[str, Any]: + """执行动作并拼装统一响应。HTTP 与 WS 共用。""" + result = execute_action(session, name, params) + counters["actions"] += 1 + session.touch(count_action=True) + payload: Dict[str, Any] = dict(result) + payload["ok"] = True + payload["farm"] = session.farm_id + payload["seq"] = next_seq() + payload["state"] = brief(session, hub) + payload["log"] = session.engine.recent_log(limit=3) + if full: + payload["snapshot"] = session.snapshot() + return payload + + # ================================================================ 页面 + @app.get("/", response_class=HTMLResponse, include_in_schema=False) + async def index() -> HTMLResponse: + return HTMLResponse(DASHBOARD_HTML) + + @app.get("/text", response_class=PlainTextResponse, include_in_schema=False) + async def text_index() -> PlainTextResponse: + return PlainTextResponse(dashboard_text(cfg.host, cfg.port)) + + # ================================================================ 元信息 + @app.get("/api/health", tags=["元信息"], summary="健康检查(无需鉴权)") + async def health() -> Dict[str, Any]: + return { + "ok": True, + "service": SERVER_NAME, + "version": API_VERSION, + "uptime": round(time.time() - started_at, 1), + "farms": manager.count(), + "clients": hub.total(), + "clock_ticks": clock.ticks, + "saves_since_start": publisher.pushes, + } + + @app.get("/api/help", tags=["元信息"], summary="玩法规则与接口说明(给 AI 的说明书)") + async def help_page(request: Request) -> Dict[str, Any]: + guard(request) + return { + "ok": True, + "service": SERVER_NAME, + "version": API_VERSION, + "rules": rules_text(), + "actions": actions_mod.describe_actions(), + "crops": config.CROPS, + "animals": config.ANIMALS, + "endpoints": { + "state": "GET /api/state?farm=NAME", + "catalog": "GET /api/catalog?farm=NAME", + "actions": "GET /api/actions?farm=NAME", + "log": "GET /api/log?farm=NAME&limit=20&since=0", + "execute": "POST /api/actions {\"action\":\"plant\",\"params\":{\"plot\":1,\"crop\":\"wheat\"}}", + "farms": "GET /api/farms | POST /api/farms {\"farm\":\"NAME\"}", + "realtime": "WS /ws?farm=NAME", + }, + } + + @app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟") + async def world(request: Request) -> Dict[str, Any]: + guard(request) + return { + "ok": True, + "world": cfg.world_name, + "uptime": round(time.time() - started_at, 1), + "farms": manager.count(), + "clients": hub.total(), + "connections": hub.snapshot(), + "leaderboard": manager.leaderboard(hub.online_map()), + "tick_seconds": cfg.tick_seconds, + "started_at": started_at, + } + + # ================================================================ 牧场 + @app.get("/api/farms", tags=["世界"], summary="牧场排行榜") + async def list_farms(request: Request) -> Dict[str, Any]: + guard(request) + rows = manager.leaderboard(hub.online_map()) + return {"ok": True, "count": len(rows), "farms": rows} + + @app.post("/api/farms", tags=["世界"], summary="创建或进入一个牧场") + async def create_farm(request: Request) -> Dict[str, Any]: + guard(request) + body = await body_of(request) + farm = validate_farm_id(body.get("farm") or body.get("name")) + owner = str(body.get("owner") or "") + existed = manager.find(farm) is not None + session = manager.get(farm, create=True, owner=owner) + tick = body.get("tick") + if tick is not None: + try: + tick_value = float(tick) + except (TypeError, ValueError): + raise GameError("bad_param", "tick 必须是数字") + session.engine.tick_seconds = max( + config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, tick_value) + ) + session.engine.next_tick_at = time.time() + session.engine.tick_seconds + return { + "ok": True, + "created": not existed, + "message": ("牧场已创建" if not existed else "牧场已在运行") + f":{farm}", + "farm": farm, + "summary": session.summary(hub.online(farm)), + "state": brief(session, hub), + } + + # ================================================================ 单牧场只读 + @app.get("/api/state", tags=["牧场"], summary="完整状态快照") + async def get_state(request: Request) -> Dict[str, Any]: + guard(request) + session = session_of(request) + return { + "ok": True, + "farm": session.farm_id, + "state": session.snapshot(), + "online": hub.online(session.farm_id), + } + + @app.get("/api/catalog", tags=["牧场"], summary="作物/牲畜目录与实时价格") + async def get_catalog(request: Request) -> Dict[str, Any]: + guard(request) + session = session_of(request) + return {"ok": True, "farm": session.farm_id, "catalog": session.engine.catalog()} + + @app.get("/api/actions", tags=["牧场"], summary="当前所有合法动作(AI 推荐入口)") + async def get_actions(request: Request) -> Dict[str, Any]: + guard(request) + session = session_of(request) + items = session.engine.available_actions() + return { + "ok": True, + "farm": session.farm_id, + "count": len(items), + "actions": items, + "state": brief(session, hub), + } + + @app.get("/api/log", tags=["牧场"], summary="最近事件") + async def get_log(request: Request) -> Dict[str, Any]: + guard(request) + session = session_of(request) + limit = _int(request.query_params.get("limit"), 20, 1, 200) + since = _int(request.query_params.get("since"), 0, 0, 10 ** 9) + entries = session.engine.recent_log(limit=limit, since=since) + return { + "ok": True, + "farm": session.farm_id, + "count": len(entries), + "entries": entries, + "last_seq": entries[-1]["seq"] if entries else since, + } + + # ================================================================ 写操作 + @app.post("/api/actions", tags=["动作"], summary="通用动作入口(推荐 AI 使用)") + async def post_action(request: Request) -> Dict[str, Any]: + guard(request) + body = await body_of(request) + session = session_of(request) + name = body.get("action") or body.get("name") + params = body.get("params") + if params is None: + # 允许把参数平铺在顶层:{"action":"plant","plot":1,"crop":"wheat"} + params = {k: v for k, v in body.items() if k not in ("action", "name", "params")} + return run_and_wrap(session, name, params, full_requested(request)) + + @app.post("/api/batch", tags=["动作"], summary="批量执行动作(一个请求干多件事)") + async def post_batch(request: Request) -> Dict[str, Any]: + """给 AI 的省流入口:一次提交多个动作,按顺序执行。 + + 每个动作独立成败,失败的只记错误不中断后续动作。 + """ + guard(request) + body = await body_of(request) + session = session_of(request) + items = body.get("actions") + if not isinstance(items, list) or not items: + raise GameError("bad_param", "actions 必须是至少含一个元素的数组") + if len(items) > 50: + raise GameError("too_many", "单次批量最多 50 个动作") + results: List[Dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + results.append({"ok": False, "error": "bad_param", "message": "每个动作必须是对象"}) + continue + try: + payload = run_and_wrap( + session, item.get("action"), item.get("params") or {}, full=False + ) + results.append({k: v for k, v in payload.items() if k != "state"}) + except GameError as exc: + counters["errors"] += 1 + results.append({**exc.to_dict(), "action": item.get("action")}) + ok_count = sum(1 for r in results if r.get("ok")) + payload: Dict[str, Any] = { + "ok": True, + "farm": session.farm_id, + "done": ok_count, + "failed": len(results) - ok_count, + "results": results, + "state": brief(session, hub), + } + if full_requested(request): + payload["snapshot"] = session.snapshot() + return payload + + def make_action_route(action_name: str) -> Callable[[Request], Any]: + async def route(request: Request) -> Dict[str, Any]: + guard(request) + body = await body_of(request) + session = session_of(request) + return run_and_wrap(session, action_name, body, full_requested(request)) + + route.__name__ = f"post_{action_name}" + return route + + for _name, _spec in actions_mod.ACTIONS.items(): + app.add_api_route( + f"/api/{_name}", + make_action_route(_name), + methods=["POST"], + name=f"action_{_name}", + tags=["动作"], + summary=f"{_spec.summary}(等价于 POST /api/actions)", + ) + + # ================================================================ 管理 + @app.post("/api/admin/save", tags=["管理"], summary="把所有牧场写入存档") + async def admin_save(request: Request) -> Dict[str, Any]: + guard(request) + saved: List[str] = [] + for session in manager.all(): + try: + session.save() + saved.append(session.farm_id) + except OSError as exc: + raise GameError("save_failed", f"{session.farm_id} 存档失败:{exc}", status=500) + return {"ok": True, "message": f"已保存 {len(saved)} 个牧场", "farms": saved} + + @app.post("/api/admin/shutdown", tags=["管理"], summary="优雅停机(守护进程 stop 使用)") + async def admin_shutdown(request: Request) -> Dict[str, Any]: + guard(request) + if not cfg.allow_remote_control: + raise GameError("forbidden", "服务器未开放远程停机", status=403) + manager.shutdown() + accepted = handle.request_stop() + return { + "ok": True, + "message": "服务器正在优雅停机,存档已写入" if accepted else "停机请求已受理(重复)", + } + + # ================================================================ WebSocket + @app.websocket("/ws") + async def ws_endpoint(websocket: WebSocket) -> None: + params = websocket.query_params + supplied = websocket.headers.get("x-api-token") or params.get("token") + if not authorized(supplied): + # 4401 是自定义关闭码,客户端据此提示"令牌不对"而不是"网络断了" + await websocket.close(code=4401, reason="unauthorized") + return + + try: + farm = validate_farm_id(params.get("farm") or DEFAULT_FARM) + except GameError as exc: + await websocket.close(code=4400, reason=exc.message[:120]) + return + + await websocket.accept() + loop = asyncio.get_running_loop() + client = Client(farm, loop, name=(params.get("name") or "")[:32]) + try: + session = manager.get(farm, create=True) + except GameError as exc: + # 例如牧场数量达到上限:说清楚原因再关,别让客户端以为是网络问题 + await websocket.send_json({"type": "error", "error": exc.code, + "message": exc.message}) + await websocket.close(code=1013, reason=exc.message[:100]) + return + hub.register(client) + session.touch() + + await websocket.send_json({ + "type": "welcome", + "client_id": client.cid, + "farm": farm, + "server": { + "name": SERVER_NAME, + "version": API_VERSION, + "world": cfg.world_name, + "tick_seconds": session.engine.tick_seconds, + }, + "state": session.snapshot(), + "catalog": session.engine.catalog(), + "actions": session.engine.available_actions(), + "log": session.engine.recent_log(limit=12), + "online": hub.online(farm), + }) + hub.broadcast( + farm, + {"type": "notice", "farm": farm, + "text": f"{client.name or client.cid} 来到了牧场(在线 {hub.online(farm)})"}, + exclude=client, + ) + + async def writer() -> None: + while True: + message = await client.next_message() + await websocket.send_json(message) + + async def reader() -> None: + while True: + raw = await websocket.receive_text() + counters["ws_messages"] += 1 + await handle_ws_message(raw) + + async def handle_ws_message(raw: str) -> None: + """处理一条客户端消息。 + + 注意:这里只做「解析 + 派发」,真正的引擎调用丢给线程池, + 因此一条慢查询不会让同一个连接的心跳/推送卡住。 + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + await websocket.send_json({"type": "error", "error": "bad_json", + "message": f"不是合法 JSON:{exc}"}) + return + if not isinstance(data, dict): + await websocket.send_json({"type": "error", "error": "bad_json", + "message": "消息必须是 JSON 对象"}) + return + + kind = str(data.get("type") or "").lower() + rid = data.get("id") + + if kind == "ping": + await websocket.send_json({"type": "pong", "id": rid, "t": time.time()}) + return + + if kind in ("sync", "refresh", "state"): + await websocket.send_json({ + "type": "sync", + "id": rid, + "seq": next_seq(), + "farm": session.farm_id, + "state": session.snapshot(), + "actions": session.engine.available_actions(), + "log": session.engine.recent_log(limit=20), + }) + return + + if kind == "help": + await websocket.send_json({"type": "help", "id": rid, "rules": rules_text(), + "actions": actions_mod.describe_actions()}) + return + + if kind == "action": + try: + # 引擎是同步的,丢到线程池里跑,别堵住事件循环 + result = await asyncio.to_thread( + execute_action, session, data.get("action"), + data.get("params") or {}, + ) + except GameError as exc: + counters["errors"] += 1 + await websocket.send_json({"type": "result", "id": rid, "ok": False, + "error": exc.code, "message": exc.message, + "farm": session.farm_id}) + return + counters["actions"] += 1 + session.touch(count_action=True) + await websocket.send_json({ + "type": "result", + "id": rid, + "ok": True, + "farm": session.farm_id, + "message": result.get("message", "完成"), + "result": result, + "state": brief(session, hub), + # 回执里带上完整快照:客户端可以立刻刷新画面,不必等下一次推送 + "snapshot": session.snapshot(), + "log": session.engine.recent_log(limit=3), + "seq": next_seq(), + }) + return + + await websocket.send_json({"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: + for task in (writer_task, reader_task): + task.cancel() + hub.unregister(client) + session.touch() + hub.broadcast( + session.farm_id, + {"type": "notice", "farm": session.farm_id, + "text": f"{client.name or client.cid} 离开了牧场(在线 {hub.online(session.farm_id)})"}, + ) + + return app + + +def _int(raw: Optional[str], default: int, low: int, high: int) -> int: + if raw is None or raw == "": + return default + try: + value = int(raw) + except (TypeError, ValueError): + raise GameError("bad_param", f"参数必须是整数,收到 {raw!r}") + return max(low, min(high, value)) + + +def build_app(host: str = "127.0.0.1", port: int = 8765, **kwargs: Any) -> FastAPI: + """给外部(测试、嵌入式调用)用的便捷工厂。""" + return create_app(ServerConfig(host=host, port=port, **kwargs)) diff --git a/farm/server/daemon.py b/farm/server/daemon.py new file mode 100644 index 0000000..28c2646 --- /dev/null +++ b/farm/server/daemon.py @@ -0,0 +1,351 @@ +"""守护进程管理:把服务器丢到后台跑,然后像服务一样 start / stop / status。 + +为什么不用 nohup 或 systemd +--------------------------- +需求是"允许后台运行",而这个项目要能在 Windows / macOS / Linux 上都直接跑, +不能依赖某个平台的服务管理器。所以这里用最朴素也最通用的办法: + +* ``subprocess.Popen`` 拉起一个**脱离父进程**的子进程(POSIX 用新会话, + Windows 用 DETACHED_PROCESS),父进程退出后它继续活着; +* 用 ``run/server.pid`` 记住它的 pid,用 ``run/server.log`` 收走它的输出; +* 停的时候先试着走 HTTP 优雅停机(能落盘),失败再发信号。 + +这套做法不依赖任何第三方库,也不需要管理员权限。 +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +from .. import net + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +RUN_DIR = os.path.join(PROJECT_ROOT, "run") +DEFAULT_PID_FILE = os.path.join(RUN_DIR, "server.pid") +DEFAULT_LOG_FILE = os.path.join(RUN_DIR, "server.log") + +_STILL_ACTIVE = 259 + + +def pid_alive(pid: int) -> bool: + """跨平台地判断一个 pid 是否还活着。 + + 注意:Windows 上 ``os.kill(pid, 0)`` 会**真的**把进程杀掉 + (CPython 用 TerminateProcess 实现),所以这里必须走 Win32 API。 + """ + if pid <= 0: + return False + if os.name == "nt": + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION + if not handle: + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + return code.value == _STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except (OSError, ProcessLookupError): + return False + return True + + +class DaemonManager: + """负责在后台拉起/停止 ``farm.server`` 进程。""" + + def __init__(self, host: str = "127.0.0.1", port: int = 8765, + token: Optional[str] = None, tick: float = 15.0, + save_dir: str = "", pid_file: str = DEFAULT_PID_FILE, + log_file: str = DEFAULT_LOG_FILE, python: Optional[str] = None) -> None: + self.host = host + self.port = port + self.token = token + self.tick = tick + self.save_dir = save_dir or os.path.join(PROJECT_ROOT, "saves") + # 防御式默认:调用方传 None 时用标准位置,绝不让 pid_file 变成 None + self.pid_file = pid_file or DEFAULT_PID_FILE + self.log_file = log_file or DEFAULT_LOG_FILE + self.python = python or sys.executable + + # ------------------------------------------------------------ pid 文件 + def read_pid(self) -> Optional[int]: + try: + with open(self.pid_file, "r", encoding="utf-8") as handle: + return int(handle.read().strip()) + except (OSError, ValueError): + return None + + def write_pid(self, pid: int) -> None: + os.makedirs(os.path.dirname(self.pid_file), exist_ok=True) + with open(self.pid_file, "w", encoding="utf-8") as handle: + handle.write(str(pid)) + + def clear_pid(self) -> None: + try: + os.remove(self.pid_file) + except OSError: + pass + + def current(self) -> Optional[int]: + """返回正在运行的服务器 pid;pid 文件过期则顺手清理。""" + pid = self.read_pid() + if pid is None: + return None + if pid_alive(pid): + return pid + self.clear_pid() + return None + + # ------------------------------------------------------------ 探测 + def probe(self, timeout: float = 1.5) -> Optional[Dict[str, Any]]: + """访问 /api/health,成功说明服务真的在监听。 + + 用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走, + 否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。 + """ + url = f"http://{self.host}:{self.port}/api/health" + try: + with net.open_url(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError): + return None + + def world(self, timeout: float = 2.0) -> Optional[Dict[str, Any]]: + url = f"http://{self.host}:{self.port}/api/world" + if self.token: + url += "?token=" + urllib.parse.quote(self.token) + try: + with net.open_url(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError): + return None + + def port_busy(self) -> bool: + """端口被别的程序占了吗(用于给出"换端口"的提示)。""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.6) + return sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", + self.port)) == 0 + + # ------------------------------------------------------------ 启动 + def command(self) -> List[str]: + args = [ + self.python, "-m", "farm.server", + "--host", self.host, + "--port", str(self.port), + "--tick", str(self.tick), + "--save-dir", self.save_dir, + # 关键:pid 文件由**服务进程自己**写。 + # Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid + # 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。 + "--pid-file", self.pid_file, + ] + if self.token: + args += ["--token", self.token] + return args + + def start(self, wait: float = 12.0) -> Tuple[bool, str]: + existing = self.current() + if existing is not None: + return False, f"服务器已经在运行(pid {existing}),无需重复启动" + if self.probe() is not None: + return False, f"端口 {self.port} 上已经有一个 SimpleFarm 服务器在跑" + if self.port_busy(): + return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899" + + os.makedirs(os.path.dirname(self.log_file), exist_ok=True) + os.makedirs(os.path.dirname(self.pid_file), exist_ok=True) + self.clear_pid() # 清掉上一次留下的陈旧 pid + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONPATH"] = PROJECT_ROOT + os.pathsep + env.get("PYTHONPATH", "") + + kwargs: Dict[str, Any] = { + "cwd": PROJECT_ROOT, + "env": env, + "stdin": subprocess.DEVNULL, + } + if os.name == "nt": + # DETACHED_PROCESS:父进程退出后子进程继续活; + # CREATE_NEW_PROCESS_GROUP:方便单独给它发 Ctrl+Break + kwargs["creationflags"] = ( + subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + else: + kwargs["start_new_session"] = True + + log_handle = open(self.log_file, "a", encoding="utf-8") + try: + # 日志交给子进程自己写文件:父进程退出后文件描述符依然有效 + process = subprocess.Popen( + self.command(), + stdout=log_handle, + stderr=log_handle, + **kwargs, + ) + except OSError as exc: + log_handle.close() + return False, f"启动失败:{exc}" + else: + log_handle.close() + + # 等它真的能响应,而不是"进程起来了但端口还没监听" + deadline = time.time() + wait + while time.time() < deadline: + if self.probe() is not None: + pid = self.current() or process.pid + return True, ( + f"服务器已在后台启动(pid {pid})\n" + f" 网页 http://{self.host}:{self.port}/\n" + f" 日志 {self.log_file}\n" + f" 停止 python main.py stop" + ) + if process.poll() is not None and self.read_pid() is None: + return False, ( + f"服务器进程启动后立刻退出(退出码 {process.returncode})。" + f"日志:{self.log_file}" + ) + time.sleep(0.25) + + return False, ( + f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}" + ) + + # ------------------------------------------------------------ 停止 + def stop(self, force: bool = False) -> Tuple[bool, str]: + pid = self.current() + online = self.probe() is not None + if pid is None and not online: + self.clear_pid() + return True, "服务器当前没有运行" + + # 先请它自己优雅退出:这条路会把所有牧场写进存档 + if online and self._request_shutdown() and self._wait_offline(pid, 8.0): + self.clear_pid() + return True, f"服务器已优雅停机(pid {pid or '未知'},所有牧场已存档)" + + if pid is None: + return False, ( + f"服务器仍在 {self.host}:{self.port} 上响应,但 pid 文件里没有记录," + "无法确认该结束哪个进程。请手动处理,或用 docker/任务管理器结束它。" + ) + + try: + os.kill(pid, signal.SIGTERM) + except OSError as exc: + self.clear_pid() + return False, f"发送停止信号失败:{exc}" + + if self._wait_offline(pid, 5.0): + self.clear_pid() + return True, f"服务器已停止(pid {pid})" + + if force and os.name == "nt": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, check=False) + if self._wait_offline(pid, 3.0): + self.clear_pid() + return True, f"服务器已强制结束(pid {pid})" + elif force: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + if self._wait_offline(pid, 3.0): + self.clear_pid() + return True, f"服务器已强制结束(pid {pid})" + + return False, ( + f"进程 {pid} 没有在超时内退出。" + + ("" if force else "可加 --force 强制结束。") + + f" 日志:{self.log_file}" + ) + + def restart(self) -> Tuple[bool, str]: + stopped, message = self.stop() + if not stopped: + return False, message + return self.start() + + def _request_shutdown(self) -> bool: + url = f"http://{self.host}:{self.port}/api/admin/shutdown" + if self.token: + url += "?token=" + urllib.parse.quote(self.token) + request = urllib.request.Request(url, data=b"{}", method="POST", + headers={"Content-Type": "application/json"}) + try: + with net.open_url(request, timeout=3.0) as response: + return response.status == 200 + except (urllib.error.URLError, OSError): + return False + + @staticmethod + def _wait_gone(pid: int, seconds: float) -> bool: + deadline = time.time() + seconds + while time.time() < deadline: + if not pid_alive(pid): + return True + time.sleep(0.2) + return not pid_alive(pid) + + def _wait_offline(self, pid: Optional[int], seconds: float) -> bool: + """等到进程消失 **且** 端口不再响应。 + + 两件事都要等:进程可能已经死了但端口还被 TIME_WAIT 占着, + 也可能 pid 文件缺失(拿不到 pid),那就只看端口。 + """ + deadline = time.time() + seconds + while time.time() < deadline: + process_gone = pid is None or not pid_alive(pid) + if process_gone and self.probe(timeout=0.6) is None: + return True + time.sleep(0.25) + process_gone = pid is None or not pid_alive(pid) + return process_gone and self.probe(timeout=0.6) is None + + # ------------------------------------------------------------ 状态 + def status(self) -> Dict[str, Any]: + pid = self.current() + health = self.probe() + info: Dict[str, Any] = { + "pid": pid, + "running": pid is not None or health is not None, + "host": self.host, + "port": self.port, + "log_file": self.log_file, + "pid_file": self.pid_file, + "health": health, + } + if health: + info["url"] = f"http://{self.host}:{self.port}/" + info["farms"] = health.get("farms") + info["clients"] = health.get("clients") + info["uptime"] = health.get("uptime") + return info + + def tail(self, lines: int = 40) -> List[str]: + try: + with open(self.log_file, "r", encoding="utf-8", errors="replace") as handle: + return handle.read().splitlines()[-lines:] + except OSError: + return [] diff --git a/farm/server/hub.py b/farm/server/hub.py new file mode 100644 index 0000000..720929a --- /dev/null +++ b/farm/server/hub.py @@ -0,0 +1,250 @@ +"""实时通道:WebSocket 连接池 + 状态推送线程。 + +为什么选 WebSocket 而不是 SSE +----------------------------- +这个游戏是**双向**的:服务器要往下推状态(作物成熟、别人来串门), +客户端也要往上推动作(播种、喂食)。SSE 只能单向下行,上行还得再开一个 +HTTP 通道,等于把一条逻辑连接拆成两套鉴权、两套重连、两套错误处理。 +WebSocket 一次握手就能双向跑,还能带上 ``action`` 回执,所以这里选它。 + +线程模型(重点) +---------------- +uvicorn 跑在 asyncio 事件循环里,而游戏引擎是同步的、跑在工作线程里。 +两者之间不能直接调用对方的 API,所以: + +* 每个连接持有一个 :class:`asyncio.Queue` 作为**出口缓冲区**; +* 任意线程想发消息,就 ``loop.call_soon_threadsafe()`` 把消息塞进队列; +* 每条连接有自己的写协程,只负责「从队列取出 → ws.send_json」。 + +这样引擎线程永远不会阻塞在网络上,事件循环也永远不会被磁盘/锁卡住。 +""" + +from __future__ import annotations + +import asyncio +import itertools +import threading +import time +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set + +STATE_INTERVAL = 0.25 # 状态推送的最小间隔(秒),天然把高频操作合并成一次 +QUEUE_SIZE = 32 # 单连接出口缓冲区;满了就丢最旧的(状态类消息可丢) + +_seq_counter = itertools.count(1) + + +def next_seq() -> int: + """全局单调递增的消息序号。 + + 客户端靠它判断"这条状态比我看过的新",从而丢弃乱序/重复的帧。 + 放在 hub 里是因为推送线程和 HTTP 层都要用它。 + """ + return next(_seq_counter) + + +class Client: + """一条 WebSocket 连接。""" + + _ids = itertools.count(1) + + __slots__ = ("cid", "farm_id", "loop", "queue", "name", "joined_at", "alive", + "_sent", "_dropped") + + def __init__(self, farm_id: str, loop: asyncio.AbstractEventLoop, name: str = "") -> None: + self.cid = f"c{next(Client._ids)}" + self.farm_id = farm_id + self.loop = loop + self.queue: asyncio.Queue = asyncio.Queue(maxsize=QUEUE_SIZE) + self.name = name + self.joined_at = time.time() + self.alive = True + self._sent = 0 + self._dropped = 0 + + # ------------------------------------------------------------ 线程安全投递 + def push(self, message: Dict[str, Any]) -> None: + """从任意线程把消息排队到该连接的写协程。""" + if not self.alive: + return + try: + self.loop.call_soon_threadsafe(self._enqueue, message) + except RuntimeError: + # 事件循环已关闭(客户端刚好断开) + self.alive = False + + def _enqueue(self, message: Dict[str, Any]) -> None: + if self.queue.full(): + # 丢最旧的一条:实时状态宁可少一帧,也不能让连接被拖死 + try: + self.queue.get_nowait() + self._dropped += 1 + except asyncio.QueueEmpty: + pass + self.queue.put_nowait(message) + + async def next_message(self) -> Dict[str, Any]: + message = await self.queue.get() + self._sent += 1 + return message + + def stats(self) -> Dict[str, Any]: + return { + "client_id": self.cid, + "farm": self.farm_id, + "name": self.name, + "sent": self._sent, + "dropped": self._dropped, + "online_seconds": round(time.time() - self.joined_at, 1), + } + + +class ConnectionHub: + """按牧场分组的连接登记处。""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._by_farm: Dict[str, Set[Client]] = defaultdict(set) + self._all: Set[Client] = set() + + # ------------------------------------------------------------ 增删 + def register(self, client: Client) -> None: + with self._lock: + self._by_farm[client.farm_id].add(client) + self._all.add(client) + + def unregister(self, client: Client) -> None: + with self._lock: + client.alive = False + bucket = self._by_farm.get(client.farm_id) + if bucket is not None: + bucket.discard(client) + if not bucket: + self._by_farm.pop(client.farm_id, None) + self._all.discard(client) + + # ------------------------------------------------------------ 查询 + def clients(self, farm_id: str) -> List[Client]: + with self._lock: + return list(self._by_farm.get(farm_id, ())) + + def online(self, farm_id: str) -> int: + with self._lock: + return len(self._by_farm.get(farm_id, ())) + + def online_map(self) -> Dict[str, int]: + with self._lock: + return {farm: len(clients) for farm, clients in self._by_farm.items() if clients} + + def total(self) -> int: + with self._lock: + return len(self._all) + + def snapshot(self) -> List[Dict[str, Any]]: + with self._lock: + return [c.stats() for c in sorted(self._all, key=lambda c: c.cid)] + + # ------------------------------------------------------------ 广播 + def broadcast(self, farm_id: str, message: Dict[str, Any], + exclude: Optional[Client] = None) -> 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: Optional[Client] = None) -> int: + with self._lock: + targets = list(self._all) + sent = 0 + for client in targets: + if client is exclude: + continue + client.push(message) + sent += 1 + return sent + + +class Publisher(threading.Thread): + """后台推送线程:把「状态变了」这件事节流后送给在线的客户端。 + + 引擎的 ``_notify()`` 只在 session 上打一个 dirty 标记,真正的推送由这里 + 按 :data:`STATE_INTERVAL` 合并发送。好处是玩家连点十次浇水也只推一帧, + 而 AI 在一秒内调二十次接口也不会把网页刷爆。 + """ + + def __init__(self, sessions, hub: ConnectionHub, autosave_interval: float = 60.0) -> None: + super().__init__(name="farm-publisher", daemon=True) + self.sessions = sessions + self.hub = hub + self.autosave_interval = autosave_interval + self._stop = threading.Event() + self.pushes = 0 + self._last_autosave = time.time() + self._started_at = time.time() + + def stop(self) -> None: + self._stop.set() + + def run(self) -> None: + while not self._stop.wait(STATE_INTERVAL): + now = time.time() + try: + for session in self.sessions.all(): + if self.hub.online(session.farm_id) == 0: + # 没人在看就不做序列化,省 CPU + session.dirty = False + continue + if not session.dirty: + continue + session.dirty = False + self._push_state(session, now) + except Exception: + # 推送线程绝不能因为单个牧场出错而退出 + pass + + if now - self._last_autosave >= self.autosave_interval: + self._last_autosave = now + try: + self.sessions.autosave_all() + except Exception: + pass + + def _push_state(self, session, now: float) -> None: + payload = session.state_message(seq=next_seq()) + # 在线人数放在信封上而不是 state 里:它是连接属性,不是游戏状态 + payload["online"] = self.hub.online(session.farm_id) + day = session.engine.state.day + if day != session.published_day: + session.published_day = day + self.hub.broadcast(session.farm_id, { + "type": "day", + "farm": session.farm_id, + "day": day, + "events": session.last_events, + "at": now, + }) + self.hub.broadcast(session.farm_id, payload) + self.pushes += 1 + + def uptime(self) -> float: + return round(time.time() - self._started_at, 1) + + +def build_state_message(session, seq: int, note: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """构造给客户端的状态消息(HTTP 与 WS 共用同一份结构)。""" + message: Dict[str, Any] = { + "type": "state", + "seq": seq, + "farm": session.farm_id, + "state": session.snapshot(), + "log": session.engine.recent_log(limit=8), + "at": time.time(), + } + if note: + message["note"] = note + if extra: + message.update(extra) + return message diff --git a/farm/server/session.py b/farm/server/session.py new file mode 100644 index 0000000..478dcc1 --- /dev/null +++ b/farm/server/session.py @@ -0,0 +1,309 @@ +"""多牧场会话管理——服务器版的"世界"。 + +一个牧场 = 一个玩家 = 一份独立存档 + 一个独立时钟。 +牧场之间互不干扰(各自的作物、金币、市场),但它们共享同一个服务器进程, +因此可以互相看到排行榜、串门看别人的农场。 + +并发模型 +-------- +* 每个牧场有一个 :class:`FarmEngine`,引擎内部自带 ``RLock``; +* 同一牧场的多个请求由这把锁串行化(不会出现"两个人同时种同一块地"); +* 不同牧场之间完全并行,互不阻塞; +* 所有牧场的时钟由**一个** :class:`FarmClock` 线程统一驱动, + 而不是每个牧场一个线程——牧场多了也不会有一堆线程在空转。 +""" + +from __future__ import annotations + +import os +import re +import threading +import time +from typing import Any, Dict, List, Optional, Tuple + +from .. import config +from ..engine import FarmEngine, GameError +from ..storage import load_state, save_state + +DEFAULT_FARM = "default" +MAX_FARM_ID = 24 + +# 牧场 ID 会变成存档文件名,所以只允许"安全且可读"的字符 +_FARM_ID_RE = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{1,%d}$" % MAX_FARM_ID) + + +def validate_farm_id(raw: Any, default: str = DEFAULT_FARM) -> str: + """校验牧场 ID,返回规范化后的值。""" + if raw is None or raw == "": + return default + if isinstance(raw, (int, float)): + raw = str(raw) + if not isinstance(raw, str): + raise GameError("bad_farm", "牧场 ID 必须是字符串") + fid = raw.strip() + if not _FARM_ID_RE.match(fid): + raise GameError( + "bad_farm", + f"牧场 ID {fid!r} 不合法:只允许 1~{MAX_FARM_ID} 个汉字、字母、数字、下划线或短横线", + ) + return fid + + +def validate_owner(raw: Any) -> str: + if raw is None: + return "" + if not isinstance(raw, (str, int, float)): + raise GameError("bad_param", "owner 必须是字符串") + text = str(raw).strip() + if len(text) > 32: + raise GameError("bad_param", "owner 最长 32 个字符") + return text + + +class FarmSession: + """一个独立的农场玩家。""" + + def __init__(self, farm_id: str, save_dir: str, tick_seconds: float, + owner: str = "") -> None: + self.farm_id = farm_id + self.owner = validate_owner(owner) or farm_id + self.save_dir = save_dir + self.path = os.path.join(save_dir, f"{farm_id}.json") + self._lock = threading.RLock() + + self.engine = FarmEngine(tick_seconds=tick_seconds) + # 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写 + self.dirty = True + self.published_day = 0 + self.last_events: List[str] = [] + self.created_at = time.time() + self.last_active = time.time() + self.saved_at = 0.0 + self.actions_taken = 0 + self.load_message = "" + + self.engine.subscribe(self._on_change) + self.load() + + # ------------------------------------------------------------ 生命周期 + def _on_change(self) -> None: + """引擎的任何状态变化都会走到这里(可能来自任意线程)。""" + self.dirty = True + + def load(self) -> str: + state, message = load_state(self.path) + if state is not None: + self.engine.state = state + self.load_message = message + self.engine.add_log(f"服务器载入存档:第 {state.day} 天继续经营", "info") + else: + self.engine.add_log(f"新的牧场「{self.farm_id}」开张,欢迎光临!", "info") + self.load_message = f"{message},已开新档" + self.published_day = self.engine.state.day + self.dirty = True + return self.load_message + + def save(self) -> str: + with self._lock: + path = save_state(self.engine.state, self.path) + self.saved_at = time.time() + return path + + def autosave(self) -> bool: + """只在有变化时落盘,返回是否真的写了。""" + if not self.dirty or self.save_dir == "": + return False + if time.time() - self.saved_at < 5: + return False + try: + self.save() + return True + except OSError: + return False + + def shutdown(self) -> None: + try: + self.save() + except OSError: + pass + + def touch(self, count_action: bool = False) -> None: + self.last_active = time.time() + if count_action: + self.actions_taken += 1 + self.dirty = True + + def advance_day(self, reason: str = "时间流逝") -> List[str]: + with self._lock: + events = self.engine.advance_day(reason=reason) + self.last_events = events + self.dirty = True + return events + + def reset(self) -> None: + with self._lock: + self.engine.reset() + self.touch(count_action=True) + + # ------------------------------------------------------------ 查询 + def snapshot(self) -> Dict[str, Any]: + data = self.engine.snapshot() + data["farm"] = self.farm_id + data["owner"] = self.owner + data["tick_seconds"] = self.engine.tick_seconds + return data + + def summary(self, online: int = 0) -> Dict[str, Any]: + """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。""" + snap = self.engine.snapshot() + return { + "farm": self.farm_id, + "owner": self.owner, + "day": snap["day"], + "gold": snap["gold"], + "level": snap["level"], + "net_worth": snap["net_worth"], + "plots": f"{snap['plot_count']}/{snap['max_plots']}", + "animals": snap["animal_count"], + "goal_index": snap["goal_index"], + "paused": snap["paused"], + "online": online, + "actions": self.actions_taken, + "idle_seconds": round(time.time() - self.last_active, 1), + } + + def state_message(self, seq: int = 0, note: str = "") -> Dict[str, Any]: + from .hub import build_state_message + + return build_state_message(self, seq, note) + + +class SessionManager: + """所有牧场的登记处。""" + + def __init__(self, save_dir: str, tick_seconds: float = config.DEFAULT_TICK_SECONDS, + max_sessions: int = 64, idle_timeout: float = 1800.0) -> None: + self.save_dir = save_dir + self.tick_seconds = tick_seconds + self.max_sessions = max_sessions + self.idle_timeout = idle_timeout + self._lock = threading.RLock() + self._sessions: Dict[str, FarmSession] = {} + os.makedirs(save_dir, exist_ok=True) + + # ------------------------------------------------------------ 取用 + def get(self, farm_id: str, create: bool = True, owner: str = "") -> FarmSession: + farm_id = validate_farm_id(farm_id) + with self._lock: + session = self._sessions.get(farm_id) + if session is not None: + return session + if not create: + raise GameError("no_such_farm", f"牧场「{farm_id}」不存在", status=404) + if len(self._sessions) >= self.max_sessions: + raise GameError( + "too_many_farms", + f"服务器牧场数量已达上限({self.max_sessions})," + "请稍后再试或联系管理员扩容", + status=503, + ) + session = FarmSession(farm_id, self.save_dir, self.tick_seconds, owner) + self._sessions[farm_id] = session + return session + + def find(self, farm_id: str) -> Optional[FarmSession]: + with self._lock: + return self._sessions.get(validate_farm_id(farm_id)) + + def all(self) -> List[FarmSession]: + with self._lock: + return list(self._sessions.values()) + + def ids(self) -> List[str]: + with self._lock: + return sorted(self._sessions) + + def count(self) -> int: + with self._lock: + return len(self._sessions) + + # ------------------------------------------------------------ 世界视图 + def leaderboard(self, online_map: Optional[Dict[str, int]] = None) -> List[Dict[str, Any]]: + online_map = online_map or {} + rows = [s.summary(online_map.get(s.farm_id, 0)) for s in self.all()] + rows.sort(key=lambda row: (-row["net_worth"], -row["day"], row["farm"])) + for rank, row in enumerate(rows, start=1): + row["rank"] = rank + return rows + + # ------------------------------------------------------------ 维护 + def autosave_all(self) -> int: + saved = 0 + for session in self.all(): + if session.autosave(): + saved += 1 + return saved + + def reap(self, online_map: Optional[Dict[str, int]] = None) -> List[str]: + """回收长时间无人访问的牧场:先落盘,再从内存里移除。""" + online_map = online_map or {} + now = time.time() + dropped: List[str] = [] + for session in self.all(): + if online_map.get(session.farm_id, 0) > 0: + continue + if session.engine.paused: + continue # 暂停是人为操作,说明玩家还会回来 + if now - session.last_active < self.idle_timeout: + continue + try: + session.shutdown() + except OSError: + continue # 存不下就别丢,宁可占着内存 + with self._lock: + self._sessions.pop(session.farm_id, None) + dropped.append(session.farm_id) + return dropped + + def shutdown(self) -> None: + for session in self.all(): + session.shutdown() + + +class FarmClock(threading.Thread): + """统一驱动所有牧场的游戏时间。""" + + def __init__(self, manager: SessionManager, accuracy: float = 0.2) -> None: + super().__init__(name="farm-clock", daemon=True) + self.manager = manager + self.accuracy = accuracy + self._stop = threading.Event() + self.ticks = 0 + + def stop(self) -> None: + self._stop.set() + + def run(self) -> None: + while not self._stop.wait(self.accuracy): + now = time.time() + for session in self.manager.all(): + engine = session.engine + try: + if engine.paused: + # 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick + engine.next_tick_at = now + engine.tick_seconds + continue + deadline = engine.next_tick_at + if deadline is None: + engine.next_tick_at = now + engine.tick_seconds + continue + if now >= deadline: + session.advance_day("时间流逝") + engine.next_tick_at = now + engine.tick_seconds + self.ticks += 1 + except Exception: + # 单个牧场出错不影响其它牧场的时间 + engine.next_tick_at = now + engine.tick_seconds + + def stats(self) -> Tuple[int, float]: + return self.ticks, self.accuracy diff --git a/farm/server/webui.py b/farm/server/webui.py new file mode 100644 index 0000000..ebfa290 --- /dev/null +++ b/farm/server/webui.py @@ -0,0 +1,455 @@ +"""内嵌的网页客户端。 + +为什么要内嵌而不是写一堆静态文件 +-------------------------------- +服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理 +静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket +协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。 + +注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string`` +渲染它(为此把需要拼接的部分放在 :func:`dashboard_text` 里)。 +""" + +from __future__ import annotations + +DASHBOARD_HTML = r""" + + + + +SimpleFarm 服务器 + + + + + SimpleFarm 小型农场游戏服务器 + 未连接 + + + + 进入牧场 + + + + + + + + + 农田 + + + + 牧场 + + + + 鸡 90 + 羊 220 + 牛 480 + + 买牲畜 + + + + + + 快捷动作 + + 一键收获 + 买地 + 买饲料x10 + 休息一天 + 暂停/继续 + 存档 + 重置 + + + + + 市场行情 红涨绿跌 + + + + 服务器排行榜 + + + + 农事日志 + + + + + + + + + + + +""" + + +def dashboard_text(host: str, port: int) -> str: + """给终端/curl 看的纯文本索引。""" + return ( + "SimpleFarm 游戏服务器\n" + "======================\n" + f"网页客户端:http://{host}:{port}/\n" + f"接口文档 :http://{host}:{port}/docs\n" + f"健康检查 :http://{host}:{port}/api/health\n" + f"实时通道 :ws://{host}:{port}/ws?farm=<牧场名>\n" + "\n" + "给 AI / 脚本的最短路径\n" + "----------------------\n" + f"curl 'http://{host}:{port}/api/actions?farm=default'\n" + f"curl -X POST 'http://{host}:{port}/api/actions?farm=default' \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '{\"action\":\"plant\",\"params\":{\"plot\":1,\"crop\":\"wheat\"}}'\n" + "\n" + "读取类\n" + "------\n" + "GET /api/world 世界概览 + 排行榜\n" + "GET /api/farms 牧场列表\n" + "GET /api/state 完整状态\n" + "GET /api/catalog 作物/牲畜/价格\n" + "GET /api/actions 当前合法动作(AI 推荐入口)\n" + "GET /api/log 最近事件\n" + "GET /api/help 规则 + 动作清单 + 参数说明\n" + "\n" + "写入类\n" + "------\n" + "POST /api/actions {\"action\":\"...\",\"params\":{...}} 通用入口\n" + "POST /api/batch {\"actions\":[{...},{...}]} 批量\n" + "POST /api/plant|water|harvest|harvest_all|buy_animal|feed|\n" + " collect|buy_feed|buy_plot|sleep|pause|save|reset 便捷入口\n" + "POST /api/farms {\"farm\":\"名字\"} 创建/进入牧场\n" + "POST /api/admin/save 全部存档\n" + "POST /api/admin/shutdown 优雅停机\n" + ) diff --git a/farm/storage.py b/farm/storage.py new file mode 100644 index 0000000..6bedd51 --- /dev/null +++ b/farm/storage.py @@ -0,0 +1,129 @@ +"""存档读写。 + +存档是纯 JSON,方便人工检查,也方便外部工具/AI 直接读取分析。 +写入采用"临时文件 + 原子替换",避免中途崩溃导致存档损坏。 +""" + +from __future__ import annotations + +import json +import os +import time +from typing import Any, Dict, Optional, Tuple + +from .models import GameState + +SAVE_VERSION = 1 + + +def default_save_path() -> str: + """默认存档位置:项目根目录下的 saves/save.json。""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(root, "saves", "save.json") + + +def save_state(state: GameState, path: Optional[str] = None) -> str: + """把状态写入磁盘,返回实际写入路径。""" + path = path or default_save_path() + directory = os.path.dirname(os.path.abspath(path)) + os.makedirs(directory, exist_ok=True) + + payload = { + "version": SAVE_VERSION, + "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "state": state.to_dict(), + } + # 注意:to_dict() 里的派生字段在读取时会被忽略,这里只取原始字段 + payload["state"] = _raw_state(state) + + tmp_path = path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + return path + + +def _raw_state(state: GameState) -> Dict[str, Any]: + """导出可完整还原的原始字段(与 to_dict 的展示字段不同)。""" + return { + "day": state.day, + "gold": state.gold, + "level": state.level, + "exp": state.exp, + "ap": state.ap, + "feed": state.feed, + "plots": [ + { + "index": p.index, + "crop": p.crop, + "growth": p.growth, + "moisture": p.moisture, + "planted_day": p.planted_day, + } + for p in state.plots + ], + "animals": [ + { + "id": a.aid, + "kind": a.kind, + "fed": a.fed, + "progress": a.progress, + "starve": a.starve, + "pending": a.pending, + } + for a in state.animals + ], + "market": state.market, + "stats": { + "harvested": state.total_harvested, + "collected": state.total_collected, + "earned": state.total_earned, + "spent": state.total_spent, + }, + } + + +def load_state(path: Optional[str] = None) -> Tuple[Optional[GameState], str]: + """读取存档。 + + 返回 ``(state, message)``;失败时 ``state`` 为 None 并给出原因, + 调用方应当降级为"开新档"而不是崩溃。 + """ + path = path or default_save_path() + if not os.path.exists(path): + return None, f"未找到存档 {path}" + + try: + with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + return None, f"存档无法解析:{exc}" + + if not isinstance(payload, dict): + return None, "存档格式错误:根节点不是对象" + + version = payload.get("version", 0) + if version != SAVE_VERSION: + return None, f"存档版本不兼容(文件 v{version},当前 v{SAVE_VERSION})" + + raw = payload.get("state") + if not isinstance(raw, dict): + return None, "存档缺少 state 字段" + + try: + state = GameState.from_dict(raw) + except (TypeError, ValueError, KeyError) as exc: + return None, f"存档内容异常:{exc}" + + return state, f"已载入存档(第 {state.day} 天)" + + +def delete_save(path: Optional[str] = None) -> bool: + path = path or default_save_path() + try: + os.remove(path) + return True + except OSError: + return False diff --git a/farm/tui.py b/farm/tui.py new file mode 100644 index 0000000..6150428 --- /dev/null +++ b/farm/tui.py @@ -0,0 +1,784 @@ +"""终端用户界面(TUI)。 + +渲染策略 +-------- +不做整屏清空,而是把光标归位后逐行重写并用 ``\\x1b[K`` 清理行尾, +这样刷新时几乎没有闪烁。 + +宽度处理 +-------- +中文是双宽字符,所有对齐都基于 :func:`display_width` 计算, +保证边框在 Windows Terminal / iTerm / VSCode 终端下都不歪。 +""" + +from __future__ import annotations + +import os +import re +import shutil +import sys +import time +import unicodedata +from typing import Any, Dict, List, Optional, Tuple + +from . import config +from .engine import GameError +from .keys import KeyReader +from .storage import default_save_path + +# ---------------------------------------------------------------- 颜色 +C_RESET = "\x1b[0m" +C_DIM = "\x1b[38;5;242m" +C_GRAY = "\x1b[38;5;246m" +C_WHITE = "\x1b[38;5;253m" +C_GOLD = "\x1b[38;5;221m" +C_GREEN = "\x1b[38;5;114m" +C_RED = "\x1b[38;5;203m" +C_BLUE = "\x1b[38;5;111m" +C_CYAN = "\x1b[38;5;80m" +C_ORANGE = "\x1b[38;5;215m" +C_PURPLE = "\x1b[38;5;176m" +C_BOLD = "\x1b[1m" + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") +CELL_WIDTH = 17 # 单块农田格子的显示宽度 +CELL_INNER = CELL_WIDTH - 2 +SIDE_WIDTH = 36 # 右侧栏宽度 +MIN_TERM_WIDTH = 76 +MAX_VISIBLE_ANIMALS = 5 # 侧栏同时最多展示的牲畜数量 + + +def strip_ansi(text: str) -> str: + return ANSI_RE.sub("", text) + + +def display_width(text: str) -> int: + """计算字符串在终端中占用的列数(中文算 2 列)。""" + width = 0 + for char in strip_ansi(text): + if unicodedata.combining(char): + continue + width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 + return width + + +def pad(text: str, width: int, align: str = "left", fill: str = " ") -> str: + """按显示宽度补空格。``align`` 可为 left / right / center。 + + 内容比 ``width`` 还宽时就地截断,保证任何一行都不会挤破边框。 + """ + gap = width - display_width(text) + if gap < 0: + text = truncate(text, width) + gap = max(0, width - display_width(text)) + if align == "right": + return fill * gap + text + if align == "center": + left = gap // 2 + return fill * left + text + fill * (gap - left) + return text + fill * gap + + +def truncate(text: str, width: int, ellipsis: str = "…") -> str: + """按显示宽度截断(不破坏 ANSI 色彩)。""" + if display_width(text) <= width: + return text + out = [] + used = 0 + limit = max(0, width - 1) + for char in strip_ansi(text): + char_width = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 + if used + char_width > limit: + break + out.append(char) + used += char_width + return "".join(out) + ellipsis + + +def color(text: str, code: str, enabled: bool = True) -> str: + return f"{code}{text}{C_RESET}" if enabled else text + + +def bar(filled: int, total: int, width: int, on: str = "█", off: str = "░") -> str: + """生成进度条字符串(不含颜色)。""" + if total <= 0: + total = 1 + filled = max(0, min(width, int(round(width * filled / total)))) + return on * filled + off * (width - filled) + + +# ---------------------------------------------------------------- 终端控制 +def enable_ansi() -> bool: + """在 Windows 上开启 VT 转义序列与 UTF-8 输出,返回是否成功。""" + if os.name != "nt": + return True + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + # 输出代码页切到 UTF-8,避免中文/制表符乱码 + kernel32.SetConsoleOutputCP(65001) + handle = kernel32.GetStdHandle(-11) + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) + return True + except Exception: + return False + + +def terminal_size() -> Tuple[int, int]: + size = shutil.get_terminal_size(fallback=(110, 30)) + return max(MIN_TERM_WIDTH, size.columns), max(18, size.lines) + + +# ---------------------------------------------------------------- 绘制工具 +def draw_box(title: str, body: List[str], width: int, accent: Optional[str] = None, + use_color: bool = True) -> List[str]: + """画一个带标题的方框,返回行列表(宽度精确等于 ``width``)。""" + inner = max(0, width - 2) + head = f" {title} " + if use_color and accent: + head = color(head, accent) + fill_len = max(0, inner - 1 - display_width(head)) + top = "┌─" + head + "─" * fill_len + "┐" + bottom = "└" + "─" * inner + "┘" + lines = [top] + for row in body: + lines.append("│" + pad(truncate(row, inner), inner) + "│") + lines.append(bottom) + return lines + + +def hstack(left: List[str], right: List[str], gap: int = 1) -> List[str]: + """把两列行按行并排,短的一侧用空格补齐。""" + height = max(len(left), len(right)) + left_width = max((display_width(line) for line in left), default=0) + out = [] + for i in range(height): + l = left[i] if i < len(left) else "" + r = right[i] if i < len(right) else "" + out.append(pad(l, left_width) + " " * gap + r) + return out + + +class TuiApp: + """交互式界面主程序。""" + + def __init__(self, engine: Any, save_path: Optional[str] = None, + use_color: bool = True, autosave: bool = True, + server_label: str = "") -> None: + # engine 既可以是本地 FarmEngine,也可以是远程 RemoteFarm——只要接口一致 + self.engine = engine + self.save_path = save_path or default_save_path() + self.use_color = use_color + self.autosave = autosave + self.server_label = server_label + self.reader = KeyReader() + + self.running = True + self.mode = "main" # main / help / select_crop / select_animal / confirm_reset + self.plot_cursor = 0 + self.animal_cursor = 0 + self.grid_cols = 3 + self.menu: List[Dict[str, Any]] = [] + self.menu_title = "" + self.pending_action = "" + self.message = "" + self.message_color = C_WHITE + self.message_until = 0.0 + self._last_render = 0.0 + + # ================================================================ 生命周期 + def _enter(self) -> None: + if enable_ansi() and os.name == "nt": + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H") + sys.stdout.write(C_RESET) + sys.stdout.flush() + + def _exit(self) -> None: + if self.autosave: + try: + self.engine.save(self.save_path) # 本地引擎写文件;远程引擎让服务器落盘 + except Exception: + pass + self.reader.close() + sys.stdout.write(C_RESET + "\x1b[?25h\n") + sys.stdout.flush() + + def run(self) -> None: + self._enter() + try: + while self.running: + key = self.reader.poll() + if key: + try: + self._handle_key(key) + except GameError as exc: + self._flash(exc.message, C_RED) + except Exception as exc: # 任何意外都不能让界面崩溃 + self._flash(f"内部错误:{exc}", C_RED) + + now = time.time() + if now - self._last_render >= 0.1: + self._render() + self._last_render = now + time.sleep(0.03) + except KeyboardInterrupt: + pass + finally: + self._exit() + + # ================================================================ 提示 + def _flash(self, text: str, tone: str = C_GREEN, seconds: float = 4.0) -> None: + self.message = text + self.message_color = tone + self.message_until = time.time() + seconds + + def _act(self, func, *args) -> None: + """统一执行引擎动作并显示结果。""" + try: + result = func(*args) + self._flash(result.get("message", "完成"), C_GREEN) + except GameError as exc: + self._flash(exc.message, C_RED) + + # ================================================================ 输入 + def _handle_key(self, key: str) -> None: + if key in ("CTRL_C",): + self.running = False + return + + if self.mode == "help": + self.mode = "main" + return + if self.mode == "confirm_reset": + if key == "y": + self.engine.reset() + self.plot_cursor = 0 + self.animal_cursor = 0 + self._flash("农场已重置,重新开始吧", C_GOLD) + else: + self._flash("已取消重置", C_GRAY) + self.mode = "main" + return + if self.mode in ("select_crop", "select_animal"): + self._handle_menu_key(key) + return + + # ---- 主模式 + handler = { + "q": self._quit, + "?": self._open_help, + "up": lambda: self._move_plot(-self.grid_cols), + "down": lambda: self._move_plot(self.grid_cols), + "left": lambda: self._move_plot(-1), + "right": lambda: self._move_plot(1), + "w": lambda: self._act(self.engine.water, self.plot_cursor + 1), + "h": lambda: self._act(self.engine.harvest, self.plot_cursor + 1), + "e": self._harvest_all, + "p": self._open_crop_menu, + "b": self._open_animal_menu, + "f": self._feed, + "c": self._collect, + "l": lambda: self._act(self.engine.buy_plot), + "k": lambda: self._act(self.engine.buy_feed, config.FEED_BULK), + "n": lambda: self._act(self.engine.sleep), + "s": self._save, + "r": self._ask_reset, + " ": self._toggle_pause, + "tab": self._toggle_focus, + "tab\t": self._toggle_focus, + } + action = handler.get(key) + if action: + action() + + def _quit(self) -> None: + self.running = False + + def _open_help(self) -> None: + self.mode = "help" + + def _move_plot(self, delta: int) -> None: + count = len(self.engine.state.plots) + if count == 0: + return + self.plot_cursor = max(0, min(count - 1, self.plot_cursor + delta)) + + def _toggle_pause(self) -> None: + result = self.engine.pause() + self._flash("时间已暂停" if result["paused"] else "时间恢复流动", C_ORANGE) + + def _toggle_focus(self) -> None: + animals = self.engine.state.animals + if not animals: + self._flash("牧场里还没有动物", C_GRAY) + return + self.animal_cursor = (self.animal_cursor + 1) % len(animals) + + def _harvest_all(self) -> None: + """一键收获所有成熟作物。""" + engine = self.engine + targets = [p.index + 1 for p in engine.state.plots if p.is_ripe] + if not targets: + self._flash("暂时没有成熟的作物", C_GRAY) + return + total = 0 + count = 0 + for number in targets: + try: + result = engine.harvest(number) + total += result["earned"] + count += result["quantity"] + except GameError: + continue + self._flash(f"收获 {count} 份作物,共 +{total} 金币", C_GREEN) + + # ---------------------------------------------------------- 菜单交互 + def _open_crop_menu(self) -> None: + plot = self.engine.state.plots[self.plot_cursor] if self.engine.state.plots else None + if plot is None: + return + if plot.crop is not None: + self._flash(f"{plot.index + 1} 号地已种了作物,先收获再说", C_RED) + return + + items: List[Dict[str, Any]] = [] + catalog = {c["key"]: c for c in self.engine.catalog()["crops"]} + for key, info in config.CROPS.items(): + entry = catalog.get(key, {}) + price = entry.get("price", info["base_price"]) + profit = price - info["seed_cost"] + items.append( + { + "key": key, + "label": info["name"], + "cost": info["seed_cost"], + "detail": ( + f"种子{info['seed_cost']:>3} {info['grow_days']:>2}天 " + f"售价{price:>5.0f} 净赚 {profit:+.0f}" + ), + "locked": self.engine.state.level < info["level_req"], + "lock_hint": f"需 Lv.{info['level_req']}", + "affordable": self.engine.state.gold >= info["seed_cost"], + } + ) + self.menu = items + self.menu_title = f"选择要种在 {plot.index + 1} 号地的作物" + self.mode = "select_crop" + + def _open_animal_menu(self) -> None: + if len(self.engine.state.animals) >= config.MAX_ANIMALS: + self._flash("牧场已满,无法再养", C_RED) + return + catalog = {a["key"]: a for a in self.engine.catalog()["animals"]} + items = [] + for key, info in config.ANIMALS.items(): + entry = catalog.get(key, {}) + items.append( + { + "key": key, + "label": info["name"], + "cost": info["cost"], + "detail": ( + f"{info['cost']:>4}金币 产{info['product']}" + f" 每{info['cycle']}天 日净收益{entry.get('profit_per_day', 0):.0f}" + ), + "locked": self.engine.state.level < info["level_req"], + "lock_hint": f"需 Lv.{info['level_req']}", + "affordable": self.engine.state.gold >= info["cost"], + } + ) + self.menu = items + self.menu_title = "选择要购买的动物" + self.mode = "select_animal" + + def _handle_menu_key(self, key: str) -> None: + if key in ("esc", "q"): + self.mode = "main" + self._flash("已取消", C_GRAY) + return + if not key.isdigit(): + return + index = int(key) + if index == 0 or index > len(self.menu): + self._flash("没有这个选项", C_RED) + return + + item = self.menu[index - 1] + if item["locked"]: + self._flash(f"尚未解锁({item['lock_hint']})", C_RED) + return + + if self.mode == "select_crop": + self._act(self.engine.plant, self.plot_cursor + 1, item["key"]) + else: + self._act(self.engine.buy_animal, item["key"]) + self.mode = "main" + + def _feed(self) -> None: + animals = self.engine.state.animals + if not animals: + self._flash("牧场里还没有动物,按 B 购买", C_GRAY) + return + self.animal_cursor = min(self.animal_cursor, len(animals) - 1) + self._act(self.engine.feed, animals[self.animal_cursor].aid) + + def _collect(self) -> None: + animals = self.engine.state.animals + if not animals: + self._flash("牧场里还没有动物", C_GRAY) + return + self.animal_cursor = min(self.animal_cursor, len(animals) - 1) + self._act(self.engine.collect, animals[self.animal_cursor].aid) + + def _save(self) -> None: + try: + result = self.engine.save(self.save_path) + message = result.get("message", "已存档") + self._flash(message if len(message) < 46 else "已存档", C_GREEN) + except OSError as exc: + self._flash(f"存档失败:{exc}", C_RED) + except GameError as exc: + self._flash(f"存档失败:{exc.message}", C_RED) + + def _ask_reset(self) -> None: + self.mode = "confirm_reset" + + # ================================================================ 渲染 + def _render(self) -> None: + width, height = terminal_size() + state = self.engine.state + snapshot = self.engine.snapshot() + + # 地块光标可能因为增删而越界 + if state.plots: + self.plot_cursor = max(0, min(self.plot_cursor, len(state.plots) - 1)) + + # 先把两栏宽度定死,保证 hstack 之后总宽正好等于终端宽度 + side_width = min(SIDE_WIDTH, max(28, width // 3)) + left_width = max(20, width - side_width - 1) + + side_col = self._render_side(snapshot, side_width) + farm_col = self._render_farm(snapshot, left_width) + + middle = hstack(farm_col, side_col, gap=1) + bottom = self._render_bottom(snapshot, width) + + lines: List[str] = [] + lines.append(self._render_header(snapshot, width)) + lines.extend(self._render_status(snapshot, width)) + lines.extend(middle) + lines.extend(bottom) + + if len(lines) > height - 1: + lines = lines[: height - 1] + + out = ["\x1b[H"] + for line in lines: + out.append(truncate(line, width) + "\x1b[K\r\n") + # 清掉本轮剩余行 + out.append("\x1b[J") + sys.stdout.write("".join(out)) + sys.stdout.flush() + + # ---------------------------------------------------------- header + def _render_header(self, snap: Dict[str, Any], width: int) -> str: + title = color(" SimpleFarm", C_BOLD + C_GREEN, self.use_color) + if self.server_label: + title += color(f" {self.server_label} ", C_BOLD + C_CYAN, self.use_color) + else: + title += color(" 农场经营 ", C_GRAY, self.use_color) + left = "╭─" + title + + online = snap.get("online") + if online is not None and online > 1: + tag = color(f"👥{online} ", C_ORANGE, self.use_color) + else: + tag = "" + + if snap["paused"]: + right = tag + color("⏸ 已暂停 ", C_ORANGE, self.use_color) + else: + countdown = snap.get("seconds_to_tick") + if countdown is None: + right = tag + color("手动模式 ", C_GRAY, self.use_color) + else: + right = tag + color( + f"第 {snap['day']} 天 · 下一日 {countdown:.1f}s ", C_CYAN, self.use_color + ) + + gap = max(1, width - display_width(left) - display_width(right) - 1) + line = left + "─" * gap + right + "╮" + return line + + # ---------------------------------------------------------- 状态区 + def _render_status(self, snap: Dict[str, Any], width: int) -> List[str]: + net_worth = snap["net_worth"] + goal = snap["next_goal"] + reached = snap["goal_index"] + + ap_ratio = snap["ap"] / max(1, snap["max_ap"]) + ap_color = C_GREEN if ap_ratio > 0.4 else (C_ORANGE if ap_ratio > 0 else C_RED) + ap_bar = bar(snap["ap"], snap["max_ap"], 10) + + line1 = ( + color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color) + + color(" 金币", C_GRAY, self.use_color) + + " " + + color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color) + + color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color) + + " " + + color("行动点 ", C_GRAY, self.use_color) + + color(ap_bar, ap_color, self.use_color) + + color(f" {snap['ap']}/{snap['max_ap']}", C_WHITE, self.use_color) + + " " + + color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color) + ) + + if goal: + progress = bar(int(net_worth), int(goal["amount"]), 12) + pct = min(100, int(100 * net_worth / max(1, goal["amount"]))) + goal_text = ( + color(" 目标 ", C_GRAY, self.use_color) + + color(goal["text"], C_CYAN, self.use_color) + + " " + + color(progress, C_BLUE, self.use_color) + + color(f" {pct}%", C_WHITE, self.use_color) + ) + else: + goal_text = color(" 目标 ", C_GRAY, self.use_color) + color( + "全部达成!你是真正的农业大亨", C_GOLD, self.use_color + ) + + line2 = ( + color(f" 净资产 {net_worth:>7.0f}", C_GRAY, self.use_color) + + " " + + goal_text + + " " + + color(f"地块 {snap['plot_count']}/{snap['max_plots']}", C_GRAY, self.use_color) + + " " + + color(f"牲畜 {snap['animal_count']}/{snap['max_animals']}", C_GRAY, self.use_color) + ) + return [pad(line1, width), pad(line2, width)] + + # ---------------------------------------------------------- 农田 + def _render_farm(self, snap: Dict[str, Any], width: int) -> List[str]: + inner = width - 2 + # 格子之间留 1 列间隙,避免相邻边框粘成 "││" + cols = max(1, (inner + 1) // (CELL_WIDTH + 1)) + self.grid_cols = cols + plots = snap["plots"] + + rows: List[str] = [] + for start in range(0, len(plots), cols): + chunk = plots[start : start + cols] + parts = [self._render_cell(cell, width).split("\n") for cell in chunk] + for line_index in range(3): + row = " ".join(part[line_index] for part in parts if line_index < len(part)) + rows.append(pad(row, inner)) + rows.append("") # 行间距 + + if rows and rows[-1] == "": + rows.pop() + + title = f"农田 {snap['plot_count']}/{snap['max_plots']} 块" + return draw_box(title, rows, width, accent=C_GREEN, use_color=self.use_color) + + def _render_cell(self, plot: Dict[str, Any], farm_width: int) -> str: + selected = plot["index"] == self.plot_cursor + border = C_BOLD + C_GREEN if selected else C_DIM + + if plot["is_empty"]: + name_text = color(" 空地", C_GRAY, self.use_color) + state_text = color("空闲", C_DIM, self.use_color) + progress = color("·" * 10, C_DIM, self.use_color) + water = color("░" * config.WATER_CAPACITY, C_DIM, self.use_color) + else: + info = config.CROPS.get(plot["crop"], {"name": "?"}) + if plot["is_ripe"]: + state_text = color("可收获", C_BOLD + C_GREEN, self.use_color) + elif plot["moisture"] == 0: + state_text = color("缺水!", C_RED, self.use_color) + else: + state_text = color(f"剩{plot['days_left']}天", C_WHITE, self.use_color) + + name_text = color(f" {plot['position']} {info['name']}", C_WHITE, self.use_color) + + ratio = plot["growth"] / max(1, plot["grow_days"]) + filled_count = max(0, min(10, int(round(ratio * 10)))) + progress = color("█" * filled_count + "░" * (10 - filled_count), + C_GREEN if plot["is_ripe"] else C_CYAN, self.use_color) + + water_level = plot["moisture"] + water = color( + "▓" * water_level + "░" * (config.WATER_CAPACITY - water_level), + C_BLUE if water_level else C_RED, + self.use_color, + ) + + spacer = max(0, CELL_INNER - display_width(name_text) - display_width(state_text)) + line1 = name_text + " " * spacer + state_text + line2 = progress + " " + water + + top = color("╭" + "─" * CELL_INNER + "╮", border, self.use_color) + bottom = color("╰" + "─" * CELL_INNER + "╯", border, self.use_color) + body = [ + color("│", border, self.use_color) + pad(line1, CELL_INNER) + color("│", border, self.use_color), + color("│", border, self.use_color) + pad(line2, CELL_INNER) + color("│", border, self.use_color), + ] + return "\n".join([top] + body + [bottom]) + + # ---------------------------------------------------------- 侧栏 + def _render_side(self, snap: Dict[str, Any], side_width: int = SIDE_WIDTH) -> List[str]: + animals = snap["animals"] + rows: List[str] = [] + if not animals: + rows.append(color(" 牧场空空的,按 B 购买", C_DIM, self.use_color)) + rows.append(color(" 鸡/羊/牛需每日喂食才产出", C_DIM, self.use_color)) + else: + # 只渲染一个跟随光标的窗口,避免牲畜变多后把界面撑爆 + start = max(0, min(max(0, len(animals) - MAX_VISIBLE_ANIMALS), + self.animal_cursor - MAX_VISIBLE_ANIMALS // 2)) + for index in range(start, min(len(animals), start + MAX_VISIBLE_ANIMALS)): + animal = animals[index] + focused = index == self.animal_cursor + marker = color("▸", C_BOLD + C_GREEN, self.use_color) if focused else " " + + if animal["pending"] > 0: + status = color(f"{animal['product']}x{animal['pending']}", + C_BOLD + C_GOLD, self.use_color) + elif animal["is_sick"]: + status = color("虚弱!", C_RED, self.use_color) + elif not animal["fed"]: + status = color("未喂食", C_ORANGE, self.use_color) + elif animal["days_to_product"] <= 1: + status = color("即将产出", C_GREEN, self.use_color) + else: + status = color(f"再{animal['days_to_product']}天", C_GRAY, self.use_color) + + head = marker + color(f" #{animal['id']} {animal['name']}", + C_WHITE, self.use_color) + rows.append(pad(head, 13) + status) + + hidden = len(animals) - start - MAX_VISIBLE_ANIMALS + if hidden > 0: + rows.append(color(f" …还有 {hidden} 只(Tab 切换)", C_DIM, self.use_color)) + pasture = draw_box(f"牧场 {snap['animal_count']}/{snap['max_animals']}", rows, + side_width, accent=C_ORANGE, use_color=self.use_color) + + # ---- 市场行情(中国习惯:涨红跌绿) + market_rows: List[str] = [] + name_by_key = {k: v["name"] for k, v in config.CROPS.items()} + ranked = sorted( + ((k, v) for k, v in snap["market"].items() if not k.startswith("animal:")), + key=lambda item: -item[1], + )[:4] + for key, price in ranked: + base = config.CROPS[key]["base_price"] + if price > base * 1.06: + trend, tone = "涨", C_RED + elif price < base * 0.94: + trend, tone = "跌", C_GREEN + else: + trend, tone = "平", C_GRAY + label = color(f" {name_by_key.get(key, key)}", C_WHITE, self.use_color) + value = color(f"{price:>6.0f} {trend}", tone, self.use_color) + spacer = max(1, side_width - 4 - display_width(label) - display_width(value)) + market_rows.append(label + " " * spacer + value) + market_rows.append( + color(f" 饲料 {config.FEED_UNIT_PRICE} 金币/份(按 K 购买)", C_DIM, self.use_color) + ) + market = draw_box("市场行情", market_rows, side_width, + accent=C_CYAN, use_color=self.use_color) + + return pasture + market + + # ---------------------------------------------------------- 底部 + def _render_bottom(self, snap: Dict[str, Any], width: int) -> List[str]: + if self.mode == "help": + return self._render_help(width) + if self.mode in ("select_crop", "select_animal"): + return self._render_menu(width) + + rows: List[str] = [] + now = time.time() + if self.message and now < self.message_until: + rows.append(color(" " + self.message, self.message_color, self.use_color)) + else: + rows.append(color(" 按 ? 查看全部快捷键与玩法说明", C_DIM, self.use_color)) + + for entry in self.engine.recent_log(limit=5): + tone = { + "good": C_GREEN, + "bad": C_RED, + "warn": C_ORANGE, + "action": C_CYAN, + }.get(entry["kind"], C_GRAY) + rows.append( + color(f" [D{entry['day']}] ", C_DIM, self.use_color) + + color(entry["text"], tone, 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 = [ + color(keys, C_GRAY, self.use_color), + color(keys2, C_GRAY, self.use_color), + color(keys3, C_GRAY, self.use_color), + ] + return log_box + [pad(h, width) for h in hint] + + def _render_menu(self, width: int) -> List[str]: + rows: List[str] = [] + rows.append(color(" " + self.menu_title + "(按 Esc 取消)", C_CYAN, self.use_color)) + for i, item in enumerate(self.menu, start=1): + if item["locked"]: + label = color(f" [{i}] {item['label']}", C_DIM, self.use_color) + detail = color(f"{item['detail']} ✗ {item['lock_hint']}", C_DIM, self.use_color) + else: + label = color(f" [{i}] {item['label']}", C_BOLD + C_WHITE, self.use_color) + tone = C_GREEN if item["affordable"] else C_RED + note = "" if item["affordable"] else " 金币不足" + detail = color(f"{item['detail']}{note}", tone, self.use_color) + rows.append(label + pad("", max(1, 20 - display_width(label))) + detail) + return draw_box("菜单", rows, width, accent=C_GOLD, use_color=self.use_color) + + def _render_help(self, width: int) -> List[str]: + rows = [ + color(" 玩法目标:靠种地和养殖把净资产做到 10000 金币,解锁全部 5 个里程碑。", C_WHITE, self.use_color), + "", + color(" 时间", C_BOLD + C_GOLD, self.use_color) + + color(" 每 15 秒自动推进一天,作物成长、牲畜产奶、市场价格都在这一天结算。", C_GRAY, self.use_color), + color(" 水分", C_BOLD + C_GOLD, self.use_color) + + color(" 作物每天消耗 1 点水分,水为 0 就停止生长。按 W 浇水恢复到 3 点。", C_GRAY, self.use_color), + color(" 行动点", C_BOLD + C_GOLD, self.use_color) + + color(" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color), + color(" 牲畜", C_BOLD + C_GOLD, self.use_color) + + color(" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color), + color(" 市场", C_BOLD + C_GOLD, self.use_color) + + color(" 作物和畜产品价格每天波动 ±18%,高点卖出更赚。", C_GRAY, self.use_color), + "", + color(" 快捷键", C_BOLD + C_GOLD, self.use_color) + + color(" P 播种 / W 浇水 / H 收获 / E 一键收获 / F 喂食 / C 收取产出", C_GRAY, self.use_color), + color(" ", C_GRAY, self.use_color) + + color(" B 买动物 / L 开垦土地 / K 买饲料 / N 休息 / 空格 暂停 / S 存档 / R 重置", C_GRAY, self.use_color), + "", + color(" AI 接入", C_BOLD + C_GOLD, self.use_color) + + color(" 游戏跑在服务器进程里,HTTP 接口与 WebSocket 通道同时开放,", + C_GRAY, self.use_color), + color(" ", C_GRAY, self.use_color) + + color(" AI 与你在同一个农场里操作(见 README「给 AI 的接口」)。", + C_GRAY, self.use_color), + "", + color(" 按任意键返回", C_DIM, self.use_color), + ] + return draw_box("帮助 · 玩法说明", rows, width, accent=C_BLUE, use_color=self.use_color) diff --git a/main.py b/main.py new file mode 100644 index 0000000..505e3d2 --- /dev/null +++ b/main.py @@ -0,0 +1,627 @@ +#!/usr/bin/env python3 +"""SimpleFarm 统一入口。 + +一个进程管两件事:**服务器**(后台常驻,人多人在线)和**客户端**(你自己玩、或者脚本/AI 调接口)。 + +快速开始 +-------- + uv sync # 建好环境(只有服务端需要第三方依赖) + uv run main.py serve --daemon # 后台启动小型游戏服务器 + uv run main.py play # 打开终端界面玩自己的牧场 + uv run main.py play --farm alice # 换个牧场名就是另一个玩家 + uv run main.py status # 看服务器状态 + uv run main.py stop # 优雅停机(会把所有牧场存档) + +给脚本 / AI 用的 +---------------- + uv run main.py actions # 我当前能做哪些事 + uv run main.py do plant '{"plot":1,"crop":"wheat"}' + uv run main.py state --json # 完整状态(机器可读) + uv run main.py watch # 实时滚动状态与日志 + uv run main.py bot --farm ai-1 # 让规则机器人替你经营一个牧场 + curl http://127.0.0.1:8765/api/actions # 直接打接口也行 + +浏览器打开 http://127.0.0.1:8765/ 可以看到网页版客户端。 + +没装 uv?用 `python main.py ...` 效果完全一样,命令里的 "uv run " 去掉即可。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time + +from farm import config + +DEFAULT_URL = "http://127.0.0.1:8765" + +BANNER = r""" + ____ _ _ _____ + / ___|(_)_ __ ___ _ __ | | ___ | ___|_ _ _ __ _ __ ___ + \___ \| | '_ ` _ \| '_ \| |/ _ \| |_ / _` | '__| '_ ` _ \ + ___) | | | | | | | |_) | | __/| _| (_| | | | | | | | | + |____/|_|_| |_| |_| .__/|_|\___||_| \__,_|_| |_| |_| |_| + |_| + 一个小型农场游戏服务器:人在终端玩,AI 走接口玩 +""" + + +# ---------------------------------------------------------------------- 输出 +def _supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() + + +def paint(text: str, code: str) -> str: + if not _supports_color(): + return text + return f"\x1b[{code}m{text}\x1b[0m" + + +def ok(text: str) -> str: + return paint(text, "32") + + +def warn(text: str) -> str: + return paint(text, "33") + + +def bad(text: str) -> str: + return paint(text, "31") + + +def dim(text: str) -> str: + return paint(text, "90") + + +def die(message: str, hint: str = "", code: int = 1) -> int: + print(bad("✗ ") + message, file=sys.stderr) + if hint: + print(dim(" " + hint.replace("\n", "\n ")), file=sys.stderr) + return code + + +# ---------------------------------------------------------------------- 参数 +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python main.py", + description="SimpleFarm:终端农场游戏 + 小型多人游戏服务器(HTTP/WebSocket 供 AI 调用)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=BANNER, + ) + sub = parser.add_subparsers(dest="command", metavar="<命令>") + + # ---- 服务器 + p_serve = sub.add_parser("serve", help="启动游戏服务器(可后台运行)") + p_serve.add_argument("--daemon", action="store_true", help="转到后台运行(守护进程)") + p_serve.add_argument("--host", default="127.0.0.1", + help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0") + p_serve.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765") + p_serve.add_argument("--token", default=None, help="访问令牌,设置后接口需要携带") + p_serve.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS, + help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒") + p_serve.add_argument("--save-dir", default=None, help="存档目录,默认 <项目>/saves") + p_serve.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称") + p_serve.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限") + p_serve.add_argument("--idle-timeout", type=float, default=1800.0, + help="无人访问多少秒后把牧场移出内存(存档保留)") + p_serve.add_argument("--no-remote-control", action="store_true", help="禁止接口停机") + p_serve.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求") + p_serve.add_argument("--log-level", default="info", + choices=["critical", "error", "warning", "info", "debug"]) + p_serve.add_argument("--log-file", default=None, help="守护进程日志路径") + p_serve.add_argument("--pid-file", default=None, help="守护进程 pid 文件路径") + + p_stop = sub.add_parser("stop", help="停止后台服务器(会先落盘存档)") + p_stop.add_argument("--port", type=int, default=8765) + p_stop.add_argument("--host", default="127.0.0.1") + p_stop.add_argument("--token", default=None) + p_stop.add_argument("--force", action="store_true", help="超时后强制结束进程") + + p_status = sub.add_parser("status", help="查看服务器状态") + p_status.add_argument("--port", type=int, default=8765) + p_status.add_argument("--host", default="127.0.0.1") + p_status.add_argument("--token", default=None) + p_status.add_argument("--logs", action="store_true", help="同时打印日志尾部") + p_status.add_argument("--json", action="store_true", help="输出机器可读的 JSON") + + p_logs = sub.add_parser("logs", help="查看服务器日志尾部") + p_logs.add_argument("-n", "--lines", type=int, default=40) + + # ---- 客户端 + p_play = sub.add_parser("play", help="打开终端界面(TUI)连接服务器玩") + p_play.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}") + p_play.add_argument("--farm", default="default", help="牧场名,默认 default") + p_play.add_argument("--token", default=None) + p_play.add_argument("--name", default="", help="在服务器上显示的昵称") + p_play.add_argument("--no-color", action="store_true") + p_play.add_argument("--no-realtime", action="store_true", + help="不用 WebSocket,改走 HTTP 轮询") + + p_solo = sub.add_parser("solo", help="离线单机模式(本地引擎 + 终端界面,不开服务器)") + p_solo.add_argument("--tick", type=float, default=config.DEFAULT_TICK_SECONDS) + p_solo.add_argument("--save", default=None) + p_solo.add_argument("--new", action="store_true", help="忽略旧存档") + p_solo.add_argument("--no-color", action="store_true") + + p_do = sub.add_parser("do", help="执行一个动作(脚本/AI 用途)") + p_do.add_argument("action", help="动作名:plant / water / harvest / ...") + p_do.add_argument("params", nargs="?", default="{}", + help='JSON 参数,例如 \'{"plot":1,"crop":"wheat"}\'') + _add_client_args(p_do) + + p_batch = sub.add_parser("batch", help="批量执行动作(一个请求干多件事)") + p_batch.add_argument("actions", help='JSON 数组,例如 \'[{"action":"plant","params":{...}}]\'') + _add_client_args(p_batch) + + p_actions = sub.add_parser("actions", help="列出当前所有合法动作") + _add_client_args(p_actions) + + p_state = sub.add_parser("state", help="查看当前状态") + _add_client_args(p_state) + p_state.add_argument("--json", action="store_true", help="输出完整 JSON") + + p_log = sub.add_parser("log", help="查看最近事件") + _add_client_args(p_log) + p_log.add_argument("-n", "--lines", type=int, default=20) + + p_watch = sub.add_parser("watch", help="实时滚动状态与日志(适合观察 AI 玩)") + _add_client_args(p_watch) + p_watch.add_argument("--interval", type=float, default=1.0) + + p_bot = sub.add_parser("bot", help="启动规则机器人替你经营一个牧场") + _add_client_args(p_bot) + p_bot.add_argument("--rounds", type=int, default=0, help="最多执行多少轮,0 表示不限") + p_bot.add_argument("--minutes", type=float, default=0.0, help="最多跑多少分钟,0 表示不限") + p_bot.add_argument("--pause", type=float, default=0.25, help="每个动作之间的间隔秒数") + + p_farms = sub.add_parser("farms", help="查看服务器上的牧场排行榜") + _add_client_args(p_farms) + + p_help = sub.add_parser("help", help="玩法与接口说明") + _add_client_args(p_help) + + return parser + + +def _add_client_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--url", default=DEFAULT_URL, help=f"服务器地址,默认 {DEFAULT_URL}") + parser.add_argument("--farm", default="default", help="牧场名,默认 default") + parser.add_argument("--token", default=None, help="访问令牌") + + +# ---------------------------------------------------------------------- 客户端 +def make_client(args, realtime: bool = False): + """构造远程客户端;连不上时给出可执行的下一步建议。""" + from farm.client import RemoteFarm + from farm.engine import GameError + + try: + return RemoteFarm( + endpoint=args.url, farm=getattr(args, "farm", "default"), + token=getattr(args, "token", None), + name=getattr(args, "name", "") or "cli", + realtime=realtime, autostart=True, + ) + except GameError as exc: + hint = ( + f"服务器地址:{args.url}\n" + "先启动服务器:python main.py serve --daemon\n" + "再确认地址端口:python main.py status" + ) + raise SystemExit(die(f"无法连接服务器:{exc.message}", hint)) + + +# ---------------------------------------------------------------------- 子命令 +def cmd_serve(args) -> int: + from farm.server.__main__ import main as server_main + + argv = [ + "--host", args.host, "--port", str(args.port), + "--tick", str(args.tick), "--world", args.world, + "--max-sessions", str(args.max_sessions), + "--idle-timeout", str(args.idle_timeout), + "--log-level", args.log_level, + ] + if args.save_dir: + argv += ["--save-dir", args.save_dir] + if args.token: + argv += ["--token", args.token] + if args.daemon: + argv.append("--daemon") + if args.no_remote_control: + argv.append("--no-remote-control") + if args.access_log: + argv.append("--access-log") + if args.log_file: + argv += ["--log-file", args.log_file] + if args.pid_file: + argv += ["--pid-file", args.pid_file] + return server_main(argv) + + +def cmd_stop(args) -> int: + from farm.server.daemon import DaemonManager + + manager = DaemonManager(host=args.host, port=args.port, token=args.token) + success, message = manager.stop(force=args.force) + print((ok("✓ ") if success else warn("• ")) + message) + return 0 if success else 1 + + +def cmd_status(args) -> int: + from farm.server.daemon import DaemonManager + + manager = DaemonManager(host=args.host, port=args.port, token=args.token) + info = manager.status() + + if args.json: + print(json.dumps(info, ensure_ascii=False, indent=2)) + return 0 + + if not info["running"]: + print(warn("• 服务器未运行")) + print(dim(f" 启动:python main.py serve --daemon")) + print(dim(f" 地址:http://{args.host}:{args.port}/")) + return 1 + + health = info.get("health") or {} + print(ok("✓ 服务器运行中")) + print(f" 进程 pid {info['pid'] or '未知(不是本脚本启动的)'}") + print(f" 地址 http://{info['host']}:{info['port']}/") + if health: + print(f" 运行时长 {health.get('uptime', 0):.0f} 秒") + print(f" 牧场 {health.get('farms', 0)} 个 · 在线连接 {health.get('clients', 0)} 个") + print(f" 时钟 已推进 {health.get('clock_ticks', 0)} 天") + print(f" 存档目录 {manager.save_dir}") + print(f" 日志 {info['log_file']}") + + world = manager.world() + if world and world.get("leaderboard"): + print(" 排行榜") + for row in world["leaderboard"][:5]: + print(f" #{row['rank']} {row['farm']:<14} 净资产 {row['net_worth']:>8.0f}" + f" · 第 {row['day']} 天 · Lv.{row['level']} · 在线 {row['online']}") + + if args.logs: + print(dim(" ---- 日志尾部 ----")) + for line in manager.tail(15): + print(" " + line) + return 0 + + +def cmd_logs(args) -> int: + from farm.server.daemon import DaemonManager + + manager = DaemonManager() + lines = manager.tail(args.lines) + if not lines: + print(warn("• 还没有日志(服务器从未在后台启动过)")) + return 1 + for line in lines: + print(line) + return 0 + + +def cmd_play(args) -> int: + from farm.tui import TuiApp + + if not (sys.stdin is not None and sys.stdin.isatty()): + return die( + "当前终端不是交互式终端,无法启动界面。", + "在真实终端里运行,或用命令行模式:python main.py actions / do / watch", + ) + client = make_client(args, realtime=not args.no_realtime) + label = f"{client.farm} @ {client.host}" + app = TuiApp(client, save_path=None, use_color=not args.no_color, + autosave=True, server_label=label) + try: + app.run() + finally: + client.stop() + return 0 + + +def cmd_solo(args) -> int: + from farm.engine import FarmEngine, Scheduler + from farm.storage import default_save_path, load_state + from farm.tui import TuiApp + + if not (sys.stdin is not None and sys.stdin.isatty()): + return die("当前终端不是交互式终端,无法启动界面。") + + tick = max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, args.tick)) + engine = FarmEngine(tick_seconds=tick) + save_path = args.save or default_save_path() + if args.new: + engine.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info") + else: + state, message = load_state(save_path) + if state is not None: + engine.state = state + engine.add_log(f"欢迎回来,第 {state.day} 天继续经营", "info") + print(message) + else: + engine.add_log("新的一天,新的开始。欢迎来到 SimpleFarm!", "info") + print(f"{message},已开新档。") + + scheduler = Scheduler(engine, enabled=True) + scheduler.start() + app = TuiApp(engine, save_path=save_path, use_color=not args.no_color) + try: + app.run() + finally: + scheduler.stop() + return 0 + + +def cmd_do(args) -> int: + from farm.engine import GameError + + client = make_client(args) + try: + params = json.loads(args.params) if args.params else {} + except json.JSONDecodeError as exc: + return die(f"参数不是合法 JSON:{exc}", '例子:python main.py do plant \'{"plot":1,"crop":"wheat"}\'') + if not isinstance(params, dict): + return die("参数必须是 JSON 对象") + + started = time.time() + try: + result = client._action(args.action, params) + except GameError as exc: + return die(f"{args.action} 失败({exc.code}):{exc.message}", + "看看现在能做什么:python main.py actions") + + print(ok("✓ ") + str(result.get("message", "完成")) + + dim(f" ({time.time() - started:.2f}s)")) + extra = {k: v for k, v in result.items() + if k not in ("ok", "message", "state", "log", "action") and not isinstance(v, (dict, list))} + if extra: + print(dim(" " + " ".join(f"{k}={v}" for k, v in extra.items()))) + state = result.get("state") or {} + if state: + print(f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " + f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}") + return 0 + + +def cmd_batch(args) -> int: + from farm.engine import GameError + + client = make_client(args) + try: + items = json.loads(args.actions) + except json.JSONDecodeError as exc: + return die(f"actions 不是合法 JSON:{exc}", + '例子:python main.py batch \'[{"action":"harvest_all"},{"action":"buy_feed","params":{"quantity":5}}]\'') + if not isinstance(items, list): + return die("actions 必须是 JSON 数组") + try: + data = client.batch(items) + except GameError as exc: + return die(f"批量执行失败:{exc.message}") + for item in data.get("results", []): + mark = ok("✓") if item.get("ok") else bad("✗") + print(f" {mark} {item.get('action')}: {item.get('message', item.get('error'))}") + print(f" 共 {data.get('done')} 个成功 / {data.get('failed')} 个失败") + return 0 + + +def cmd_actions(args) -> int: + client = make_client(args) + state = client.snapshot() + items = client.available_actions() + print(f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " + f"行动点 {state.get('ap')}/{state.get('max_ap')}") + if not items: + print(warn("• 当前没有可执行的动作")) + print(dim(" 试试:python main.py do sleep (推进一天)")) + return 0 + print(f"共 {len(items)} 个候选动作:\n") + for index, item in enumerate(items, start=1): + cost = item.get("cost") or {} + cost_text = " ".join(f"{k}:{v}" for k, v in cost.items()) or "免费" + print(f" [{index:>2}] {item['action']:<12} {json.dumps(item['params'], ensure_ascii=False):<42}" + f" ({cost_text})") + print(dim(f" {item.get('reason', '')}")) + print(dim("\n执行方式:python main.py do <动作名> '<参数JSON>'")) + return 0 + + +def cmd_state(args) -> int: + client = make_client(args) + if args.json: + print(json.dumps(client.snapshot(), ensure_ascii=False, indent=2)) + return 0 + snap = client.snapshot() + print(f"牧场 {snap.get('farm')}") + print(f"第 {snap.get('day')} 天" + + (f" · {snap.get('seconds_to_tick')}s 后进入下一天" + if snap.get("seconds_to_tick") is not None else " · 已暂停")) + print(f"金币 {snap.get('gold')} 等级 Lv.{snap.get('level')} " + f"({snap.get('exp')}/{snap.get('exp_needed')})") + print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}") + print(f"净资产 {snap.get('net_worth')}") + print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块") + for plot in snap.get("plots", []): + if plot["is_empty"]: + continue + status = "可收获" if plot["is_ripe"] else f"还需{plot['days_left']}天" + print(f" {plot['position']:>2} 号地 {plot['crop_name']:<6} 生长 {plot['growth']}" + f"/{plot['grow_days']} 水分 {plot['moisture']} · {status}") + for animal in snap.get("animals", []): + note = (f"{animal['product']} x{animal['pending']} 待收" if animal["pending"] + else ("已喂食" if animal["fed"] else "未喂食")) + print(f" #{animal['id']} {animal['name']:<4} {note}") + return 0 + + +def cmd_log(args) -> int: + client = make_client(args) + entries = client.recent_log(limit=args.lines) + for entry in entries: + print(f"[D{entry['day']:>3}] {entry['text']}") + return 0 + + +def cmd_watch(args) -> int: + """实时滚动:状态每轮刷新,日志增量打印。适合盯着 AI 玩。""" + import threading + + client = make_client(args, realtime=True) + print(BANNER) + print(f"正在观察牧场「{client.farm}」({client.url('')}),Ctrl+C 退出\n") + try: + last_seq = 0 + while True: + snap = client.snapshot() + for entry in client.recent_log(limit=0, since=last_seq): + last_seq = max(last_seq, int(entry.get("seq", 0))) + print(f"[D{entry['day']:>3}] {entry['text']}") + online = snap.get("online") + head = (f"第 {snap.get('day')} 天 | 金币 {snap.get('gold')} | " + f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | " + f"净资产 {snap.get('net_worth')}") + if online is not None: + head += f" | 在线 {online}" + print("\r" + head + " " * 8, end="", flush=True) + time.sleep(max(0.2, args.interval)) + except KeyboardInterrupt: + print("\n\n已退出观察。") + finally: + client.stop() + return 0 + + +def cmd_bot(args) -> int: + from farm.bot import RuleBot + + client = make_client(args, realtime=False) + bot = RuleBot(client, pause=args.pause, name=args.farm) + try: + summary = bot.run(rounds=args.rounds, max_minutes=args.minutes) + except KeyboardInterrupt: + bot.stop() + print("\n已中断。") + summary = {"actions": bot.actions_executed, "failures": bot.failures} + print(json.dumps(summary, ensure_ascii=False)) + print(dim("提示:机器人和你调的是同一套接口,随时可以用 python main.py actions 接手。")) + return 0 + + +def cmd_farms(args) -> int: + client = make_client(args) + world = client.world() + print(f"世界:{world.get('world')} · 已运行 {world.get('uptime', 0):.0f} 秒 · " + f"牧场 {world.get('farms')} 个 · 连接 {world.get('clients')} 个") + print(f"时钟:每 {world.get('tick_seconds')} 秒推进一天\n") + print(f" {'#':>2} {'牧场':<14} {'净资产':>9} {'金币':>7} {'天':>4} {'等级':>4} {'在线':>4}") + for row in world.get("leaderboard", []): + print(f" {row['rank']:>2} {row['farm']:<14} {row['net_worth']:>9.0f} " + f"{row['gold']:>7} {row['day']:>4} {row['level']:>4} {row['online']:>4}") + return 0 + + +def cmd_help(args) -> int: + client = make_client(args) + print(client.help_text()) + return 0 + + +# ---------------------------------------------------------------------- 主入口 +QUICKSTART = """ +快速开始 +-------- + 0. 建好环境(只有服务端需要依赖) uv sync + 1. 启动服务器(后台常驻) uv run main.py serve --daemon + 2. 打开网页客户端 http://127.0.0.1:8765/ + 3. 终端里玩自己的牧场 uv run main.py play --farm 你的名字 + 4. 让 AI / 机器人一起玩 uv run main.py bot --farm ai-1 + 5. 收工 uv run main.py stop + +改代码之后跑这些 +---------------- + uv run ruff check . # 静态检查(未用导入、可疑写法……) + uv run ruff format . # 统一格式 + uv run pyright # 类型检查 + uv run python -m unittest discover -s tests # 全部测试 + +直接调接口(AI / 脚本) +----------------------- + curl 'http://127.0.0.1:8765/api/actions?farm=default' + curl -X POST 'http://127.0.0.1:8765/api/actions?farm=default' \\ + -H 'Content-Type: application/json' \\ + -d '{"action":"plant","params":{"plot":1,"crop":"wheat"}}' + + uv run main.py actions # 我当前能做什么 + uv run main.py do harvest '{"plot":1}' + uv run main.py state --json # 机器可读的完整状态 + uv run main.py watch # 实时看别人/机器人玩 + +WebSocket(实时推送) +--------------------- + ws://127.0.0.1:8765/ws?farm=default + 服务器在状态变化时推送 {"type":"state", ...}; + 也可以在 WS 上直接发动作:{"type":"action","action":"plant", + "params":{"plot":1,"crop":"wheat"},"id":1},会收到 {"type":"result",...} 回执。 +""" + + +def main(argv=None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + handlers = { + "serve": cmd_serve, + "stop": cmd_stop, + "status": cmd_status, + "logs": cmd_logs, + "play": cmd_play, + "solo": cmd_solo, + "do": cmd_do, + "batch": cmd_batch, + "actions": cmd_actions, + "state": cmd_state, + "log": cmd_log, + "watch": cmd_watch, + "bot": cmd_bot, + "farms": cmd_farms, + "help": cmd_help, + } + if not args.command: + print(BANNER) + try: + from farm.server.daemon import DaemonManager + + info = DaemonManager().status() + if info["running"]: + health = info.get("health") or {} + print(ok("✓ 服务器正在运行") + + f" http://{info['host']}:{info['port']}/ " + + dim(f"牧场 {health.get('farms', 0)} 个 · 在线 {health.get('clients', 0)}")) + else: + print(warn("• 服务器未运行")) + except Exception: + pass + print(QUICKSTART) + return 0 + + handler = handlers.get(args.command) + if handler is None: + return die(f"未知命令 {args.command}", "python main.py --help 查看全部命令") + try: + return handler(args) + except SystemExit as exc: # make_client 里的友好退出 + return int(exc.code or 0) + except KeyboardInterrupt: + print("\n已中断。") + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d24d478 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,113 @@ +# SimpleFarm —— 项目元数据与工具配置 +# +# 这个文件是**唯一真源**:依赖、Python 版本、lint 规则、类型检查规则全都写在这里。 +# 日常操作全部交给 uv,不需要手动激活虚拟环境: +# +# uv sync # 按 uv.lock 建好 .venv(含开发依赖) +# uv run main.py serve --daemon +# uv run ruff check . # 静态检查 +# uv run ruff format . # 格式化 +# uv run pyright # 类型检查 +# +# 为什么写 package = false:这是一个**应用**,不是要发布到 PyPI 的库。 +# 关掉打包之后,uv 只负责环境与依赖,不引入任何构建后端(hatchling/setuptools 都不需要), +# `python main.py` 与 `uv run main.py` 行为完全一致。 + +[project] +name = "simplefarm" +version = "2.0.0" +description = "终端农场经营游戏 + 小型多人游戏服务器:人玩 TUI,AI 走 HTTP/WebSocket 接口" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = ["game", "tui", "cli", "fastapi", "websocket", "llm", "agent"] +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Topic :: Games/Entertainment :: Simulation", +] + +# 运行依赖 —— 只有**服务端**需要。 +# 终端界面、命令行客户端、规则机器人全部只用标准库, +# 所以 `uv run main.py solo` 这类离线玩法不需要装下面这两个。 +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", # [standard] 带上 websockets,实时推送靠它 +] + +# 开发依赖(`uv sync` 默认就会装,用 `uv sync --no-dev` 可以跳过)。 +# httpx 不是可选项:FastAPI 的 TestClient 建立在它之上,测试必需。 +[dependency-groups] +dev = [ + "ruff>=0.8.0", + "pyright>=1.1.390", + "httpx>=0.27", +] + +[tool.uv] +package = false + +# --------------------------------------------------------------------- ruff +# 职责二合一:`ruff format` 管格式,`ruff check` 管正确性与一致性。 +# 之所以不用 Black + isort + flake8 三件套,是因为 ruff 一个二进制就覆盖了它们, +# 而且配置在同一次解析里生效,不会出现"格式化完 lint 又报"的拉锯。 +[tool.ruff] +line-length = 100 # 贴着现有代码定:只有 18 行超 100,用默认的 88 会重排 122 行 +target-version = "py310" # 与 requires-python 对齐 +src = ["farm", "tests", "tools"] +# 文档里的示例代码是"给人看的排版",不交给格式化器管 +extend-exclude = ["README.md"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle 错误 + "W", # pycodestyle 警告 + "F", # pyflakes:未使用的导入/变量、未定义的名字 + "I", # isort:导入排序 + "UP", # pyupgrade:用新语法替换过时写法 + "B", # flake8-bugbear:容易踩坑的写法(可变默认值、裸 except、丢异常链……) + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RET", # flake8-return:多余的 else / 多余的中间变量 + "PERF",# 明显的性能坑(比如循环里做成员判断) + "RUF", # ruff 自带规则 +] +# 为什么**没有**启用 PTH(flake8-use-pathlib): +# 这个项目的路径绝大多数是"字符串身份"——要塞进 subprocess 命令行、要写进 JSON +# 存档、要打印给用户看。全部换成 pathlib 只是把字符串包一层再拆一层, +# 换来 70 处纯风格改动,却掩盖了真正有价值的修复。路径运算本来就少, +# 现有 os.path 用法是合适的选择。 +ignore = [ + # RUF001/002/003 会把中文全角标点当成"含糊的 Unicode 字符"报出来。 + # 这个项目注释与界面文案本来就是中文,全角括号是正确写法,不是错误。 + "RUF001", + "RUF002", + "RUF003", +] + +[tool.ruff.lint.per-file-ignores] +# 内嵌的 HTML/JS 模板:整块写在一个字符串里,按 100 列硬拆反而更难读也更难改 +"farm/server/webui.py" = ["E501"] +# 测试里注释是"解释为什么这么断言",紧跟断言更清楚;含长中文行属正常 +"tests/*" = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["farm"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" # Windows/Linux 混用时不要强改行尾,避免整文件 diff + +# ------------------------------------------------------------------ pyright +# 只做到"能进 CI 拦住真问题"的程度: +# - basic 模式抓得住 None 误用、参数个数/类型不符、属性不存在这类真 bug; +# - 不去追 Unknown 类型(本项目的快照是 dict[str, Any] 结构,追下去全是噪音)。 +[tool.pyright] +include = ["farm", "tests", "tools", "main.py", "ai_player.py"] +exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"] +pythonVersion = "3.10" +typeCheckingMode = "basic" +reportMissingTypeStubs = false # 让"没装 third-party 存根"保持安静 +reportUnusedImport = true +reportUnnecessaryIsInstance = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43f26fb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# SimpleFarm 运行依赖 +# +# 服务器需要: +fastapi>=0.110 +uvicorn[standard]>=0.27 # 含 websockets,WebSocket 实时通道需要它 + +# 只有跑测试才需要(TestClient 依赖 httpx): +# httpx>=0.27 +# +# 装一次就好: +# pip install -r requirements.txt +# +# 注意:终端界面(TUI)与命令行客户端只用标准库, +# 所以即使不装上面这些,`python main.py solo / play / do` 依然能跑; +# 只有 `python main.py serve`(服务端)需要 FastAPI。 diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..2b669ac --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,458 @@ +"""核心引擎的单元测试。 + +运行方式: + python -m unittest discover -s tests -v +或: + python tests/test_engine.py +""" + +from __future__ import annotations + +import os +import random +import sys +import threading +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from farm import config # noqa: E402 +from farm.engine import FarmEngine, GameError # noqa: E402 +from farm.models import GameState, Plot # noqa: E402 +from farm.storage import load_state, save_state # noqa: E402 + + +class EngineTestCase(unittest.TestCase): + def setUp(self) -> None: + random.seed(20260918) + self.engine = FarmEngine(tick_seconds=5) + # 存档相关测试会写文件,统一放到临时目录 + self.tmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp") + os.makedirs(self.tmp, exist_ok=True) + + # ============================================================ 初始状态 + def test_initial_state(self) -> None: + state = self.engine.state + self.assertEqual(state.day, 1) + self.assertEqual(state.gold, config.START_GOLD) + self.assertEqual(state.level, 1) + self.assertEqual(state.ap, config.START_AP) + self.assertEqual(len(state.plots), config.BASE_PLOTS) + self.assertEqual(state.animals, []) + self.assertEqual(state.exp_needed, config.exp_to_next(1)) + + # ============================================================ 种植 + def test_plant_costs_gold_and_ap(self) -> None: + before_gold = self.engine.state.gold + before_ap = self.engine.state.ap + self.engine.plant(1, "wheat") + plot = self.engine.state.plots[0] + self.assertEqual(plot.crop, "wheat") + self.assertEqual(self.engine.state.gold, before_gold - config.CROPS["wheat"]["seed_cost"]) + self.assertEqual(self.engine.state.ap, before_ap - config.AP_COST["plant"]) + self.assertEqual(plot.moisture, config.PLANT_INITIAL_MOISTURE) + + def test_plant_twice_on_same_plot_fails(self) -> None: + self.engine.plant(1, "wheat") + with self.assertRaises(GameError) as ctx: + self.engine.plant(1, "carrot") + self.assertEqual(ctx.exception.code, "plot_busy") + + def test_plant_out_of_range_plot(self) -> None: + for bad in (0, -1, 99, "abc", None): + with self.assertRaises(GameError) as ctx: + self.engine.plant(bad, "wheat") + self.assertIn(ctx.exception.code, ("no_such_plot", "bad_param")) + + def test_plant_unknown_crop(self) -> None: + with self.assertRaises(GameError) as ctx: + self.engine.plant(1, "banana") + self.assertEqual(ctx.exception.code, "unknown_crop") + with self.assertRaises(GameError) as ctx2: + self.engine.plant(1, 123) + self.assertEqual(ctx2.exception.code, "unknown_crop") + + def test_plant_locked_crop(self) -> None: + with self.assertRaises(GameError) as ctx: + self.engine.plant(1, "pumpkin") + self.assertEqual(ctx.exception.code, "locked") + + def test_plant_without_gold(self) -> None: + self.engine.state.gold = 1 + with self.assertRaises(GameError) as ctx: + self.engine.plant(1, "wheat") + self.assertEqual(ctx.exception.code, "not_enough_gold") + + def test_plant_without_ap(self) -> None: + self.engine.state.ap = 1 + self.engine.plant(1, "wheat") + with self.assertRaises(GameError) as ctx: + self.engine.plant(2, "wheat") + self.assertEqual(ctx.exception.code, "no_ap") + + # ============================================================ 生长与水分 + def test_crop_needs_water_to_grow(self) -> None: + self.engine.plant(1, "wheat") + plot = self.engine.state.plots[0] + # 播种自带 2 点水分,推进 2 天刚好成熟 + self.engine.advance_day() + self.engine.advance_day() + self.assertTrue(plot.is_ripe) + + def test_crop_stops_growing_without_water(self) -> None: + self.engine.state.level = 5 + self.engine.plant(1, "corn") # 需要 6 天 + plot = self.engine.state.plots[0] + for _ in range(5): + self.engine.advance_day() + # 只有 2 点初始水分,所以最多长 2 天 + self.assertEqual(plot.growth, 2) + self.assertEqual(plot.moisture, 0) + self.assertFalse(plot.is_ripe) + + def test_water_restores_moisture(self) -> None: + self.engine.state.level = 5 + self.engine.plant(1, "corn") + plot = self.engine.state.plots[0] + self.engine.state.ap = 10 + self.engine.water(1) + self.assertEqual(plot.moisture, config.WATER_CAPACITY) + + def test_water_empty_plot_fails(self) -> None: + with self.assertRaises(GameError) as ctx: + self.engine.water(1) + self.assertEqual(ctx.exception.code, "plot_empty") + + def test_water_when_full_fails(self) -> None: + self.engine.plant(1, "wheat") + self.engine.water(1) + with self.assertRaises(GameError) as ctx: + self.engine.water(1) + self.assertEqual(ctx.exception.code, "already_watered") + + # ============================================================ 收获 + def test_harvest_not_ripe(self) -> None: + self.engine.plant(1, "wheat") + with self.assertRaises(GameError) as ctx: + self.engine.harvest(1) + self.assertEqual(ctx.exception.code, "not_ripe") + + def test_harvest_empty_plot(self) -> None: + with self.assertRaises(GameError) as ctx: + self.engine.harvest(1) + self.assertEqual(ctx.exception.code, "plot_empty") + + def test_harvest_gives_gold_and_exp(self) -> None: + self.engine.plant(1, "wheat") + self.engine.advance_day() + self.engine.advance_day() + gold = self.engine.state.gold + result = self.engine.harvest(1) + self.assertTrue(result["ok"]) + self.assertGreater(result["earned"], 0) + self.assertEqual(self.engine.state.gold, gold + result["earned"]) + self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"]) + self.assertIsNone(self.engine.state.plots[0].crop) + + def test_exp_accumulates(self) -> None: + self.engine.plant(1, "wheat") + self.engine.advance_day() + self.engine.advance_day() + self.engine.harvest(1) + self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"]) + + # ============================================================ 等级与土地 + def test_level_up_unlocks_and_rewards(self) -> None: + state = self.engine.state + state.exp = state.exp_needed - 1 + # 种一茬小麦拿经验,触发升级 + self.engine.plant(1, "wheat") + self.engine.advance_day() + self.engine.advance_day() + gold_before = state.gold + self.engine.harvest(1) + self.assertEqual(state.level, 2) + self.assertGreaterEqual(state.gold, gold_before + config.level_up_bonus(2)) + self.assertEqual(state.max_plots, config.BASE_PLOTS + config.PLOTS_PER_LEVEL) + + def test_buy_plot_and_limit(self) -> None: + self.engine.state.gold = 99999 + self.engine.state.level = 10 + before = len(self.engine.state.plots) + self.engine.buy_plot() + self.assertEqual(len(self.engine.state.plots), before + 1) + + while len(self.engine.state.plots) < config.MAX_PLOTS: + self.engine.buy_plot() + self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS) + with self.assertRaises(GameError) as ctx: + self.engine.buy_plot() + self.assertEqual(ctx.exception.code, "plot_limit") + + def test_buy_plot_without_gold(self) -> None: + self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步 + self.engine.state.gold = 0 + with self.assertRaises(GameError) as ctx: + self.engine.buy_plot() + self.assertEqual(ctx.exception.code, "not_enough_gold") + + def test_buy_plot_blocked_at_level_cap(self) -> None: + self.engine.state.gold = 10 ** 6 + with self.assertRaises(GameError) as ctx: + self.engine.buy_plot() + self.assertEqual(ctx.exception.code, "plot_limit") + + # ============================================================ 牲畜 + def test_buy_animal_and_produce(self) -> None: + self.engine.state.gold = 1000 + result = self.engine.buy_animal("chicken") + self.assertEqual(result["animal"]["kind"], "chicken") + self.assertEqual(len(self.engine.state.animals), 1) + + animal = self.engine.state.animals[0] + self.engine.feed(animal.aid) + self.assertTrue(animal.fed) + self.engine.advance_day() + self.assertEqual(animal.pending, 1) + + gold = self.engine.state.gold + result = self.engine.collect(animal.aid) + self.assertEqual(result["quantity"], 1) + self.assertGreater(self.engine.state.gold, gold - 1) + self.assertEqual(animal.pending, 0) + + def test_feed_twice_fails(self) -> None: + self.engine.state.gold = 1000 + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + self.engine.feed(animal.aid) + with self.assertRaises(GameError) as ctx: + self.engine.feed(animal.aid) + self.assertEqual(ctx.exception.code, "already_fed") + + def test_collect_without_product(self) -> None: + self.engine.state.gold = 1000 + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + with self.assertRaises(GameError) as ctx: + self.engine.collect(animal.aid) + self.assertEqual(ctx.exception.code, "nothing_to_collect") + + def test_animal_starves_to_death(self) -> None: + self.engine.state.gold = 1000 + self.engine.buy_animal("chicken") + for _ in range(3): + self.engine.advance_day() + self.assertEqual(len(self.engine.state.animals), 0) + + def test_animal_survives_if_fed(self) -> None: + self.engine.state.gold = 1000 + self.engine.state.feed = 10 + self.engine.buy_animal("chicken") + animal = self.engine.state.animals[0] + for _ in range(6): + self.engine.feed(animal.aid) + self.engine.advance_day() + self.assertEqual(len(self.engine.state.animals), 1) + self.assertGreater(animal.pending, 0) + + def test_unknown_animal(self) -> None: + with self.assertRaises(GameError) as ctx: + self.engine.buy_animal("dragon") + self.assertEqual(ctx.exception.code, "unknown_animal") + + def test_pasture_limit(self) -> None: + self.engine.state.gold = 10 ** 6 + for _ in range(config.MAX_ANIMALS): + self.engine.buy_animal("chicken") + with self.assertRaises(GameError) as ctx: + self.engine.buy_animal("chicken") + self.assertEqual(ctx.exception.code, "pasture_full") + + def test_emergency_feed_when_out_of_stock(self) -> None: + self.engine.state.gold = 1000 + self.engine.buy_animal("chicken") + self.engine.state.feed = 0 + animal = self.engine.state.animals[0] + gold = self.engine.state.gold + self.engine.feed(animal.aid) + self.assertTrue(animal.fed) + self.assertLess(self.engine.state.gold, gold) # 被扣了高价补货费 + + def test_buy_feed_validation(self) -> None: + self.engine.state.gold = 10 ** 6 + with self.assertRaises(GameError): + self.engine.buy_feed(0) + with self.assertRaises(GameError): + self.engine.buy_feed(-5) + with self.assertRaises(GameError): + self.engine.buy_feed(config.MAX_FEED_PURCHASE + 1) + with self.assertRaises(GameError): + self.engine.buy_feed("十份") + self.engine.buy_feed(5) + self.assertEqual(self.engine.state.feed, config.START_FEED + 5) + + # ============================================================ 时间与市场 + def test_advance_day_resets_ap(self) -> None: + self.engine.state.ap = 0 + self.engine.advance_day() + self.assertEqual(self.engine.state.ap, config.START_AP) + self.assertEqual(self.engine.state.day, 2) + + def test_sleep_cooldown(self) -> None: + self.engine.sleep() + with self.assertRaises(GameError) as ctx: + self.engine.sleep() + self.assertEqual(ctx.exception.code, "cooldown") + + def test_market_prices_stay_in_range(self) -> None: + for _ in range(60): + self.engine.advance_day() + for key, info in config.CROPS.items(): + price = self.engine.market_price(key) + base = info["base_price"] + self.assertGreaterEqual(price, base * 0.5) + self.assertLessEqual(price, base * 1.6) + + def test_pause_toggle(self) -> None: + self.engine.pause(True) + self.assertTrue(self.engine.paused) + self.engine.pause(False) + self.assertFalse(self.engine.paused) + result = self.engine.pause() + self.assertTrue(result["paused"]) + self.engine.pause(False) + + # ============================================================ 查询接口 + def test_snapshot_is_serializable(self) -> None: + import json + + self.engine.plant(1, "wheat") + snap = self.engine.snapshot() + text = json.dumps(snap, ensure_ascii=False) + self.assertIn("plots", text) + + def test_available_actions_reference_valid_plots(self) -> None: + actions = self.engine.available_actions() + self.assertTrue(any(a["action"] == "plant" for a in actions)) + for action in actions: + if action["action"] in ("plant", "water", "harvest"): + plot = action["params"]["plot"] + self.assertTrue(1 <= plot <= len(self.engine.state.plots)) + + def test_catalog_marks_locked(self) -> None: + catalog = self.engine.catalog() + by_key = {c["key"]: c for c in catalog["crops"]} + self.assertFalse(by_key["wheat"]["locked"]) + self.assertTrue(by_key["pumpkin"]["locked"]) + + # ============================================================ 存档 + def test_save_load_roundtrip(self) -> None: + self.engine.plant(1, "carrot") + self.engine.buy_animal("chicken") + self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言 + self.engine.state.feed = 7 + self.engine.state.day = 13 + path = os.path.join(self.tmp, "roundtrip.json") + + save_state(self.engine.state, path) + loaded, message = load_state(path) + self.assertIsNotNone(loaded, message) + assert loaded is not None + self.assertEqual(loaded.gold, 500) + self.assertEqual(loaded.feed, 7) + self.assertEqual(loaded.day, 13) + self.assertEqual(loaded.plots[0].crop, "carrot") + self.assertEqual(loaded.animals[0].kind, "chicken") + + def test_load_missing_file(self) -> None: + state, message = load_state(os.path.join(self.tmp, "nope.json")) + self.assertIsNone(state) + self.assertIn("未找到", message) + + def test_load_corrupt_file(self) -> None: + path = os.path.join(self.tmp, "broken.json") + with open(path, "w", encoding="utf-8") as handle: + handle.write("{ not valid json") + state, message = load_state(path) + self.assertIsNone(state) + self.assertIn("解析", message) + + def test_load_wrong_version(self) -> None: + import json + + path = os.path.join(self.tmp, "old.json") + with open(path, "w", encoding="utf-8") as handle: + json.dump({"version": 99, "state": {}}, handle) + state, message = load_state(path) + self.assertIsNone(state) + self.assertIn("版本", message) + + def test_state_from_dict_repairs_bad_data(self) -> None: + state = GameState.from_dict( + { + "day": -5, + "gold": -100, + "level": 0, + "plots": [{"index": 0, "crop": "不存在", "growth": 999, "moisture": 99}], + "animals": [{"kind": "独角兽"}], + "market": {"wheat": "not a number"}, + } + ) + self.assertEqual(state.day, 1) + self.assertEqual(state.gold, 0) + self.assertEqual(state.level, 1) + self.assertIsNone(state.plots[0].crop) # 非法作物被清空 + self.assertEqual(state.animals, []) # 非法动物被丢弃 + self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值 + self.assertGreaterEqual(len(state.plots), config.BASE_PLOTS) + + # ============================================================ 并发 + def test_concurrent_operations_are_safe(self) -> None: + self.engine.state.gold = 10 ** 6 + errors = [] + + def worker(index: int) -> None: + try: + for _ in range(30): + self.engine.snapshot() + self.engine.available_actions() + if index % 3 == 0: + try: + self.engine.buy_feed(1) + except GameError: + pass + except Exception as exc: # 记录任何非预期异常 + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(12)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(errors, []) + + +class PlotModelTestCase(unittest.TestCase): + def test_stage_progression(self) -> None: + plot = Plot(0) + self.assertEqual(plot.stage, "空地") + plot.crop = "wheat" + self.assertEqual(plot.stage, "已播种") + plot.growth = 0.7 + self.assertEqual(plot.stage, "幼苗") + plot.growth = 1.5 + self.assertEqual(plot.stage, "生长中") + plot.growth = 2 + self.assertTrue(plot.is_ripe) + self.assertEqual(plot.stage, "已成熟") + + def test_days_left_never_negative(self) -> None: + plot = Plot(0) + plot.crop = "wheat" + plot.growth = 99 + self.assertEqual(plot.days_left, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..041ce17 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,596 @@ +"""服务器层测试:动作表校验、REST 接口、多牧场隔离、鉴权、WebSocket 端到端。 + +分三层 +------ +1. :class:`TestActionTable` / :class:`TestFarmId` —— 纯函数,不需要服务器; +2. :class:`TestRestAPI` —— 用 FastAPI 的 TestClient 在进程内打接口; +3. :class:`TestLiveServer` —— 真的起一个 uvicorn,再用 :class:`farm.client.RemoteFarm` + 通过 HTTP + WebSocket 连上去。这一层才是"客户端到底行不行"的答案。 + +如果机器上没装 FastAPI/uvicorn,第 2、3 层会跳过而不是失败—— +单机引擎的测试在 test_engine.py,那部分永远可跑。 +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import tempfile +import threading +import time +import unittest + +from farm import config +from farm.engine import FarmEngine, GameError +from farm.server.actions import ACTIONS, describe_actions, run_action +from farm.server.session import FarmSession, SessionManager, validate_farm_id + +try: + import fastapi # noqa: F401 + from fastapi.testclient import TestClient + + HAS_FASTAPI = True +except ImportError: # pragma: no cover - 取决于环境 + HAS_FASTAPI = False + +try: + import uvicorn # noqa: F401 + + HAS_UVICORN = True +except ImportError: # pragma: no cover + HAS_UVICORN = False + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +# ======================================================================= 纯逻辑 +class TestActionTable(unittest.TestCase): + """动作表是所有入口的唯一执行通道,参数校验必须严格。""" + + def setUp(self) -> None: + self.engine = FarmEngine(tick_seconds=60) + + def test_all_actions_documented(self) -> None: + described = describe_actions() + self.assertEqual(len(described), len(ACTIONS)) + for item in described: + self.assertTrue(item["summary"]) + self.assertIn(item["action"], ACTIONS) + + def test_plant_and_harvest_via_action_table(self) -> None: + result = run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"}) + self.assertTrue(result["ok"]) + self.assertEqual(result["action"], "plant") + state = self.engine.snapshot() + self.assertEqual(state["plots"][0]["crop"], "wheat") + + self.engine.advance_day() + self.engine.advance_day() + harvested = run_action(self.engine, "harvest", {"plot": 1}) + self.assertGreater(harvested["earned"], 0) + + def test_string_number_is_coerced(self) -> None: + """命令行/表单常把数字传成字符串,这里必须能识别。""" + result = run_action(self.engine, "plant", {"plot": "1", "crop": "wheat"}) + self.assertTrue(result["ok"]) + + def test_unknown_action_rejected(self) -> None: + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "fly_to_moon", {}) + self.assertEqual(ctx.exception.code, "unknown_action") + self.assertEqual(ctx.exception.status, 400) + + def test_missing_required_param(self) -> None: + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "plant", {"plot": 1}) + self.assertEqual(ctx.exception.code, "missing_param") + + def test_unknown_param_rejected(self) -> None: + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "plant", {"plot": 1, "crop": "wheat", "magic": 1}) + self.assertEqual(ctx.exception.code, "bad_param") + self.assertIn("magic", ctx.exception.message) + + def test_bad_type_rejected(self) -> None: + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "plant", {"plot": "第一块地", "crop": "wheat"}) + self.assertEqual(ctx.exception.code, "bad_param") + + def test_bool_param_forms(self) -> None: + result = run_action(self.engine, "pause", {"paused": "true"}) + self.assertTrue(self.engine.paused) + result = run_action(self.engine, "pause", {"paused": False}) + self.assertFalse(self.engine.paused) + self.assertEqual(result["action"], "pause") + with self.assertRaises(GameError): + run_action(self.engine, "pause", {"paused": "也许"}) + + def test_harvest_all_with_nothing_ripe(self) -> None: + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "harvest_all", {}) + self.assertEqual(ctx.exception.code, "nothing_to_harvest") + + def test_business_error_keeps_code(self) -> None: + """业务错误要保留机器可读的 code,AI 靠它分支。""" + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "plant", {"plot": 99, "crop": "wheat"}) + self.assertEqual(ctx.exception.code, "no_such_plot") + with self.assertRaises(GameError) as ctx: + run_action(self.engine, "buy_animal", {"kind": "dragon"}) + self.assertEqual(ctx.exception.code, "unknown_animal") + + +class TestFarmId(unittest.TestCase): + """牧场 ID 会变成文件名,必须挡住路径穿越之类的输入。""" + + def test_normal(self) -> None: + for name in ("default", "alice", "ai-1", "玩家甲", "farm_01"): + self.assertEqual(validate_farm_id(name), name) + + def test_default_when_empty(self) -> None: + self.assertEqual(validate_farm_id(None), "default") + self.assertEqual(validate_farm_id(""), "default") + + def test_rejects_path_traversal(self) -> None: + for bad in ("../etc/passwd", "a/b", "a\\b", "..", "a b", "x" * 40, "农场:1"): + with self.assertRaises(GameError): + validate_farm_id(bad) + + +class TestSessionManager(unittest.TestCase): + """多牧场隔离与回收策略。""" + + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp(prefix="farm-session-") + self.manager = SessionManager(save_dir=self.tmp, tick_seconds=60) + + def tearDown(self) -> None: + self.manager.shutdown() + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_farms_are_isolated(self) -> None: + alice = self.manager.get("alice") + bob = self.manager.get("bob") + alice.engine.plant(1, "wheat") + alice.engine.state.gold = 999 + + self.assertIsNone(bob.engine.state.plots[0].crop) + self.assertNotEqual(bob.engine.state.gold, 999) + self.assertEqual(self.manager.count(), 2) + self.assertIs(self.manager.get("alice"), alice) + + def test_save_and_reload_roundtrip(self) -> None: + alice = self.manager.get("alice") + alice.engine.plant(1, "carrot") + alice.engine.state.gold = 777 + alice.save() + + fresh = SessionManager(save_dir=self.tmp, tick_seconds=60) + try: + again = fresh.get("alice") + self.assertEqual(again.engine.state.gold, 777) + self.assertEqual(again.engine.state.plots[0].crop, "carrot") + finally: + fresh.shutdown() + + def test_leaderboard_sorted_by_net_worth(self) -> None: + poor = self.manager.get("poor") + rich = self.manager.get("rich") + poor.engine.state.gold = 10 + rich.engine.state.gold = 5000 + rows = self.manager.leaderboard() + self.assertEqual(rows[0]["farm"], "rich") + self.assertEqual(rows[0]["rank"], 1) + self.assertEqual(rows[1]["farm"], "poor") + + def test_reap_only_drops_idle_offline_farms(self) -> None: + busy = self.manager.get("busy") + idle = self.manager.get("idle") + busy.last_active = time.time() - 9999 + idle.last_active = time.time() - 9999 + self.manager.idle_timeout = 10 + + dropped = self.manager.reap(online_map={"busy": 1}) + self.assertEqual(dropped, ["idle"]) + self.assertIsNotNone(self.manager.find("busy")) + self.assertIsNone(self.manager.find("idle")) + # 被回收的牧场一定已经落盘 + self.assertTrue(os.path.exists(os.path.join(self.tmp, "idle.json"))) + + def test_paused_farm_is_not_reaped(self) -> None: + session = self.manager.get("thinking") + session.engine.pause(True) + session.last_active = time.time() - 9999 + self.manager.idle_timeout = 10 + self.assertEqual(self.manager.reap(), []) + + def test_max_sessions_guard(self) -> None: + manager = SessionManager(save_dir=self.tmp, max_sessions=2) + try: + manager.get("a") + manager.get("b") + with self.assertRaises(GameError) as ctx: + manager.get("c") + self.assertEqual(ctx.exception.code, "too_many_farms") + finally: + manager.shutdown() + + +# ======================================================================= REST +@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI") +class TestRestAPI(unittest.TestCase): + """用 TestClient 在进程内验证 HTTP 语义。""" + + def setUp(self) -> None: + from farm.server.app import ServerConfig, create_app + + self.tmp = tempfile.mkdtemp(prefix="farm-api-") + self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, + tick_seconds=60) + self.app = create_app(self.cfg) + self.client = TestClient(self.app) + self.client.__enter__() + + def tearDown(self) -> None: + self.client.__exit__(None, None, None) + shutil.rmtree(self.tmp, ignore_errors=True) + + # ------------------------------------------------------------ 基础 + def test_health_without_token(self) -> None: + data = self.client.get("/api/health").json() + self.assertTrue(data["ok"]) + self.assertIn("uptime", data) + + def test_dashboard_and_text_index(self) -> None: + page = self.client.get("/") + self.assertEqual(page.status_code, 200) + self.assertIn("SimpleFarm", page.text) + self.assertIn("WebSocket", page.text) + text = self.client.get("/text").text + self.assertIn("/api/actions", text) + + def test_help_lists_actions(self) -> None: + data = self.client.get("/api/help").json() + self.assertIn("rules", data) + names = {item["action"] for item in data["actions"]} + self.assertIn("plant", names) + self.assertIn("harvest_all", names) + + def test_openapi_schema_available(self) -> None: + schema = self.client.get("/openapi.json").json() + self.assertIn("/api/actions", schema["paths"]) + + # ------------------------------------------------------------ 牧场读写 + def test_state_creates_farm_lazily(self) -> None: + data = self.client.get("/api/state?farm=newbie").json() + self.assertEqual(data["state"]["day"], 1) + self.assertEqual(data["state"]["gold"], config.START_GOLD) + + def test_generic_action_entry(self) -> None: + body = {"action": "plant", "params": {"plot": 1, "crop": "wheat"}} + data = self.client.post("/api/actions?farm=one", json=body).json() + self.assertTrue(data["ok"]) + self.assertIn("已种下", data["message"]) + self.assertEqual(data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"]) + + state = self.client.get("/api/state?farm=one").json()["state"] + self.assertEqual(state["plots"][0]["crop"], "wheat") + + def test_flat_params_also_accepted(self) -> None: + """{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。""" + data = self.client.post("/api/actions?farm=flat", + json={"action": "plant", "plot": 1, "crop": "wheat"}).json() + self.assertTrue(data["ok"]) + + def test_convenience_route(self) -> None: + data = self.client.post("/api/plant?farm=short", json={"plot": 2, "crop": "carrot"}).json() + self.assertTrue(data["ok"]) + self.assertEqual(data["action"], "plant") + + def test_action_error_shape(self) -> None: + response = self.client.post("/api/actions?farm=err", + json={"action": "plant", "params": {"plot": 1, "crop": "nope"}}) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertFalse(data["ok"]) + self.assertEqual(data["error"], "unknown_crop") + self.assertIn("message", data) + + def test_out_of_range_plot(self) -> None: + response = self.client.post("/api/actions?farm=err", + json={"action": "water", "params": {"plot": 999}}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "no_such_plot") + + def test_bad_json_body(self) -> None: + response = self.client.post("/api/actions?farm=err", content=b"{oops", + headers={"Content-Type": "application/json"}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "bad_json") + + def test_actions_endpoint_matches_state(self) -> None: + data = self.client.get("/api/actions?farm=solo").json() + names = {item["action"] for item in data["actions"]} + self.assertIn("plant", names) # 有空地 + 有钱 → 可播种 + self.assertNotIn("harvest", names) # 还没种东西 → 不可收获 + + def test_batch_endpoint(self) -> None: + items = {"actions": [ + {"action": "buy_feed", "params": {"quantity": 5}}, + {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, + {"action": "harvest", "params": {"plot": 1}}, # 必然失败 + ]} + data = self.client.post("/api/batch?farm=multi", json=items).json() + self.assertEqual(data["done"], 2) + self.assertEqual(data["failed"], 1) + self.assertEqual(data["results"][2]["error"], "not_ripe") + + def test_log_entries_and_since(self) -> None: + self.client.post("/api/actions?farm=logging", + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}) + entries = self.client.get("/api/log?farm=logging").json()["entries"] + self.assertTrue(entries) + last = entries[-1]["seq"] + newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"] + self.assertEqual(newer, []) + self.client.post("/api/actions?farm=logging", json={"action": "save"}) + newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"] + self.assertTrue(newer) + + def test_farms_list_and_isolation(self) -> None: + self.client.post("/api/actions?farm=alice", + json={"action": "buy_feed", "params": {"quantity": 10}}) + self.client.post("/api/actions?farm=bob", json={"action": "save"}) + rows = self.client.get("/api/farms").json()["farms"] + by_name = {row["farm"]: row for row in rows} + self.assertIn("alice", by_name) + self.assertEqual(by_name["alice"]["gold"], + config.START_GOLD - 10 * config.FEED_UNIT_PRICE) + + def test_create_farm_reports_created_flag(self) -> None: + first = self.client.post("/api/farms", json={"farm": "zoe", "owner": "小Z"}).json() + self.assertTrue(first["created"]) + self.assertEqual(first["summary"]["owner"], "小Z") + again = self.client.post("/api/farms", json={"farm": "zoe"}).json() + self.assertFalse(again["created"]) + + def test_bad_farm_id_rejected(self) -> None: + response = self.client.get("/api/state?farm=../evil") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "bad_farm") + + def test_farm_via_header(self) -> None: + data = self.client.get("/api/state", headers={"X-Farm-Id": "header-farm"}).json() + self.assertEqual(data["farm"], "header-farm") + + def test_admin_save(self) -> None: + self.client.post("/api/actions?farm=saver", + json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}}) + data = self.client.post("/api/admin/save").json() + self.assertIn("saver", data["farms"]) + self.assertTrue(os.path.exists(os.path.join(self.tmp, "saver.json"))) + + def test_world_view(self) -> None: + self.client.post("/api/actions?farm=w1", json={"action": "save"}) + data = self.client.get("/api/world").json() + self.assertEqual(data["farms"], 1) + self.assertEqual(data["leaderboard"][0]["farm"], "w1") + + +@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI") +class TestAuth(unittest.TestCase): + """令牌鉴权:没带令牌必须 401,带了才能操作。""" + + def setUp(self) -> None: + from farm.server.app import ServerConfig, create_app + + self.tmp = tempfile.mkdtemp(prefix="farm-auth-") + self.app = create_app(ServerConfig(save_dir=self.tmp, token="s3cret", tick_seconds=60)) + self.client = TestClient(self.app) + self.client.__enter__() + + def tearDown(self) -> None: + self.client.__exit__(None, None, None) + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_health_is_public(self) -> None: + self.assertEqual(self.client.get("/api/health").status_code, 200) + + def test_state_requires_token(self) -> None: + response = self.client.get("/api/state") + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["error"], "unauthorized") + + def test_token_via_header(self) -> None: + response = self.client.get("/api/state", headers={"X-API-Token": "s3cret"}) + self.assertEqual(response.status_code, 200) + + def test_token_via_query(self) -> None: + response = self.client.get("/api/state?token=s3cret") + self.assertEqual(response.status_code, 200) + + def test_wrong_token(self) -> None: + self.assertEqual(self.client.get("/api/state?token=nope").status_code, 401) + + def test_action_blocked_without_token(self) -> None: + response = self.client.post("/api/actions", + json={"action": "buy_plot"}) + self.assertEqual(response.status_code, 401) + + +# ======================================================================= 真网络 +@unittest.skipUnless(HAS_FASTAPI and HAS_UVICORN, "未安装 FastAPI/uvicorn") +class TestLiveServer(unittest.TestCase): + """真的起 uvicorn,用真实客户端走 HTTP 与 WebSocket。""" + + @classmethod + def setUpClass(cls) -> None: + from farm.server.app import ServerConfig, create_app + + cls.tmp = tempfile.mkdtemp(prefix="farm-live-") + cls.port = free_port() + cls.url = f"http://127.0.0.1:{cls.port}" + app = create_app(ServerConfig(host="127.0.0.1", port=cls.port, save_dir=cls.tmp, + tick_seconds=600, autosave_interval=3600)) + import uvicorn + + cls.server = uvicorn.Server(uvicorn.Config( + app, host="127.0.0.1", port=cls.port, log_level="warning", + access_log=False, lifespan="on", + )) + cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True) + cls.thread.start() + + deadline = time.time() + 20 + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", cls.port), timeout=0.5): + break + except OSError: + time.sleep(0.2) + else: # pragma: no cover + raise unittest.SkipTest("服务器没能在 20 秒内起来") + + @classmethod + def tearDownClass(cls) -> None: + cls.server.should_exit = True + cls.thread.join(timeout=10) + shutil.rmtree(cls.tmp, ignore_errors=True) + + def client(self, farm: str, realtime: bool = True): + from farm.client import RemoteFarm + + return RemoteFarm(endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, + autostart=True) + + def test_http_roundtrip_through_real_client(self) -> None: + client = self.client("live-http", realtime=False) + try: + state = client.snapshot() + self.assertEqual(state["farm"], "live-http") + result = client.plant(1, "wheat") + self.assertTrue(result["ok"]) + self.assertEqual(client.state.plots[0].crop, "wheat") + self.assertFalse(client.state.plots[0].is_ripe) + + with self.assertRaises(GameError) as ctx: + client.plant(1, "carrot") # 地已经占用了 + self.assertEqual(ctx.exception.code, "plot_busy") + + actions = client.available_actions() + names = {item["action"] for item in actions} + self.assertIn("plant", names) # 还有空地可以种 + self.assertNotIn("harvest", names) # 小麦还没成熟 + + client.water(1) # 播种后水分 2/3,可以补满 + self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY) + with self.assertRaises(GameError) as ctx: + client.water(1) # 水分已满,再浇就是错的 + self.assertEqual(ctx.exception.code, "already_watered") + + catalog = client.catalog() + self.assertTrue(catalog["crops"]) + self.assertTrue(client.leaderboard()) + self.assertIn("规则速览", client.help_text()) + finally: + client.stop() + + def test_two_clients_talk_to_the_same_farm(self) -> None: + """两个客户端改同一个牧场:一方操作,另一方从推送里看到。""" + watcher = self.client("shared") + actor = self.client("shared") + try: + deadline = time.time() + 8 + while time.time() < deadline and len(watcher.state.plots) == 0: + time.sleep(0.1) + self.assertTrue(watcher.state.plots) + + actor.plant(1, "carrot") + deadline = time.time() + 8 + while time.time() < deadline and watcher.state.plots[0].crop != "carrot": + time.sleep(0.1) + self.assertEqual(watcher.state.plots[0].crop, "carrot", + "实时通道应该把另一方的操作推过来") + self.assertEqual(watcher.mode, "ws") + finally: + watcher.stop() + actor.stop() + + def test_websocket_channel_is_really_used(self) -> None: + """写了动作应该走 WS 通道,而不是退化到 HTTP。""" + client = self.client("ws-farm") + try: + deadline = time.time() + 8 + while time.time() < deadline and client.mode != "ws": + time.sleep(0.1) + self.assertEqual(client.mode, "ws", "WebSocket 没能连上") + client.buy_feed(3) + self.assertEqual(client.stats["ws_actions"], 1) + self.assertEqual(client.stats["http"] > 0, True) + finally: + client.stop() + + def test_farms_do_not_leak_into_each_other(self) -> None: + a = self.client("iso-a", realtime=False) + b = self.client("iso-b", realtime=False) + try: + a.plant(1, "wheat") + self.assertIsNone(b.state.plots[0].crop) + self.assertNotEqual(a.state.gold, b.state.gold) + finally: + a.stop() + b.stop() + + def test_save_on_server_persists(self) -> None: + client = self.client("persist", realtime=False) + try: + client.plant(1, "wheat") + client.save() + path = os.path.join(self.tmp, "persist.json") + self.assertTrue(os.path.exists(path)) + with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["state"]["plots"][0]["crop"], "wheat") + finally: + client.stop() + + def test_bot_can_play_via_the_api(self) -> None: + """规则机器人只调接口,也应该能真的把农场经营起来。""" + from farm.bot import RuleBot + + client = self.client("bot-farm", realtime=False) + bot = RuleBot(client, verbose=False, pause=0.02, name="bot-farm") + try: + summary = bot.run(rounds=12, idle_limit=3) + self.assertGreaterEqual(summary["actions"], 1) + self.assertIn("net_worth", summary) + self.assertEqual(bot.failures, 0) + self.assertTrue(client.available_actions() is not None) + finally: + client.stop() + + def test_rate_free_path_uses_ws_and_falls_back(self) -> None: + """WS 断开后写操作要能自动退回 HTTP,而不是直接失败。""" + client = self.client("fallback") + try: + deadline = time.time() + 8 + while time.time() < deadline and client.mode != "ws": + time.sleep(0.1) + self.assertEqual(client.mode, "ws") + # 强行掐断:让后续写操作只能走 HTTP + client._stop.set() + client._ws_socket = None + result = client.buy_feed(1) + self.assertTrue(result["ok"]) + finally: + client.stop() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/preview_tui.py b/tools/preview_tui.py new file mode 100644 index 0000000..c5a7634 --- /dev/null +++ b/tools/preview_tui.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""把 TUI 渲染结果输出到终端,用于检查排版与配色。 + +不进入交互循环,也不读取键盘——只是把某一时刻的完整画面画出来。 + +用法: + python tools/preview_tui.py # 使用默认的中等规模农场(本地引擎) + python tools/preview_tui.py --width 100 # 指定终端宽度 + python tools/preview_tui.py --color # 输出带 ANSI 颜色 + python tools/preview_tui.py --scene empty # 初始状态 + python tools/preview_tui.py --scene rich # 后期大农场 + python tools/preview_tui.py --scene menu # 播种菜单 + python tools/preview_tui.py --url http://127.0.0.1:8765 --farm alice + # 渲染**真实服务器**上的牧场 +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import os +import random +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from farm.engine import FarmEngine # noqa: E402 +from farm.tui import TuiApp # noqa: E402 + +SCENES = ("empty", "mixed", "rich", "menu", "help") + + +def build_remote(url: str, farm: str, token: str | None) -> tuple[object, TuiApp]: + """连到真实服务器,取一份现状来渲染。""" + from farm.client import RemoteFarm + + client = RemoteFarm(endpoint=url, farm=farm, token=token, realtime=False) + app = TuiApp(client, use_color=True, autosave=False, + server_label=f"{client.farm} @ {client.host}") + return client, app + + +def build_engine(scene: str) -> tuple[FarmEngine, TuiApp]: + random.seed(42) + engine = FarmEngine(tick_seconds=15) + app = TuiApp(engine, use_color=True, autosave=False) + + if scene == "empty": + return engine, app + + # ---- 制造一个"进行中"的农场 + engine.state.level = 2 + engine.state.gold = 340 + engine.state.feed = 4 + engine.plant(1, "wheat") + engine.water(1) + engine.plant(2, "carrot") + engine.plant(3, "wheat") + engine.plant(4, "carrot") + engine.advance_day() + engine.advance_day() + engine.buy_animal("chicken") + engine.feed(1) + + if scene == "rich": + engine.state.level = 4 + engine.state.gold = 2400 + engine.state.feed = 22 + for i in range(2): + engine.state.gold = 99999 + engine.buy_plot() + engine.plant(5, "corn") + engine.plant(6, "tomato") + engine.water(5) + engine.state.gold = 2400 + engine.buy_animal("sheep") + engine.buy_animal("cow") + engine.advance_day() + engine.advance_day() + engine.add_log("收获 2 份小麦(丰收!),+31 金币", "good") + engine.add_log("升级到 Lv.4!奖励 240 金币,解锁:南瓜", "good") + + if scene == "menu": + # 找第一块空地,把播种菜单打开 + empty = next((p.index for p in engine.state.plots if p.is_empty), 0) + app.plot_cursor = empty + app._open_crop_menu() + + if scene == "help": + app.mode = "help" + + return engine, app + + +def main() -> int: + parser = argparse.ArgumentParser(description="渲染一帧 TUI 画面") + parser.add_argument("--width", type=int, default=112, help="终端宽度") + parser.add_argument("--height", type=int, default=32, help="终端高度") + parser.add_argument("--scene", choices=SCENES, default="mixed", help="场景") + parser.add_argument("--color", action="store_true", help="保留 ANSI 颜色") + parser.add_argument("--url", default=None, + help="连到真实服务器渲染(例如 http://127.0.0.1:8765)") + parser.add_argument("--farm", default="default", help="--url 模式下要看的牧场") + parser.add_argument("--token", default=None, help="--url 模式下的访问令牌") + args = parser.parse_args() + + os.environ["COLUMNS"] = str(args.width) + os.environ["LINES"] = str(args.height) + + if args.url: + engine, app = build_remote(args.url, args.farm, args.token) + state = engine.snapshot() + print(f"# 来自真实服务器 {args.url} · 牧场 {args.farm} · 第 {state.get('day')} 天 · " + f"金币 {state.get('gold')} · 在线 {state.get('online')}\n") + else: + engine, app = build_engine(args.scene) + # 预览脚本没有跑 Scheduler,这里手动给个倒计时,让画面更接近真实运行状态 + if engine.next_tick_at is None and not engine.paused: + import time as _time + + engine.next_tick_at = _time.time() + 8.4 + + app.use_color = args.color + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + app._render() + + output = buffer.getvalue() + # 去掉清屏、光标控制与行尾清理符,预览里只保留可见内容 + output = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", output) + print(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ws_demo.py b/tools/ws_demo.py new file mode 100644 index 0000000..bf4deb1 --- /dev/null +++ b/tools/ws_demo.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""WebSocket 通道演示脚本:证明"服务器推、客户端拉/发"这一对方向都通。 + +它同时是一个"外部客户端"的样板——完全不知道游戏内部结构,只知道协议: + + 连接 → 收 welcome(含状态)→ 发一个动作 → 收 result 回执 → 收 state 推送 + +用法: + python tools/ws_demo.py --url http://127.0.0.1:8765 --farm demo --seconds 8 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import urllib.parse + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def ws_url(endpoint: str, farm: str, token: str | None) -> str: + parts = urllib.parse.urlsplit(endpoint if "://" in endpoint else "http://" + endpoint) + scheme = "wss" if parts.scheme == "https" else "ws" + query = {"farm": farm, "name": "ws-demo"} + if token: + query["token"] = token + return f"{scheme}://{parts.netloc}/ws?{urllib.parse.urlencode(query)}" + + +async def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int: + import websockets + + url = ws_url(endpoint, farm, token) + print(f"连接 {url}") + async with websockets.connect(url, open_timeout=8) as socket: + welcome = json.loads(await socket.recv()) + state = welcome.get("state", {}) + print(f"← welcome:牧场 {welcome['farm']} · 第 {state.get('day')} 天 · " + f"金币 {state.get('gold')} · 候选动作 {len(welcome.get('actions', []))} 个") + + async def sender() -> None: + """通过 WebSocket 发一个动作,等回执。""" + await asyncio.sleep(0.3) + request = {"type": "action", "id": 1, "action": "sleep", "params": {}} + print(f"→ action:{json.dumps(request, ensure_ascii=False)}") + await socket.send(json.dumps(request)) + + deadline = asyncio.get_event_loop().time() + seconds + send_task = asyncio.create_task(sender()) + counts: dict[str, int] = {} + try: + while asyncio.get_event_loop().time() < deadline: + try: + raw = await asyncio.wait_for(socket.recv(), timeout=seconds) + except asyncio.TimeoutError: + break + message = json.loads(raw) + kind = message.get("type", "?") + counts[kind] = counts.get(kind, 0) + 1 + if kind == "result": + print(f"← result(id={message.get('id')}):{message.get('message')} " + f"[通过 WebSocket 提交的动作,ok={message.get('ok')}]") + elif kind == "state": + snapshot = message.get("state", {}) + print(f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · " + f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · " + f"在线 {message.get('online')}") + elif kind == "day": + print(f"← day:进入第 {message.get('day')} 天,事件 {message.get('events')}") + elif kind == "notice": + print(f"← notice:{message.get('text')}") + else: + print(f"← {kind}") + finally: + send_task.cancel() + + print(f"\n消息统计:{counts}") + if counts.get("result", 0) < 1 or counts.get("state", 0) < 1: + print("✗ WebSocket 通道没有按预期工作(result / state 消息缺失)") + return 1 + print("✓ WebSocket 双向通道正常:动作可上行,状态会主动下行") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="WebSocket 通道演示") + parser.add_argument("--url", default="http://127.0.0.1:8765") + parser.add_argument("--farm", default="ws-demo") + parser.add_argument("--token", default=None) + parser.add_argument("--seconds", type=float, default=8.0) + args = parser.parse_args() + return asyncio.run(run(args.url, args.farm, args.token, args.seconds)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..dfb2f0b --- /dev/null +++ b/uv.lock @@ -0,0 +1,958 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "simplefarm" +version = "2.0.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.110" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.27" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pyright", specifier = ">=1.1.390" }, + { name = "ruff", specifier = ">=0.8.0" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, + { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, + { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, + { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload-time = "2026-08-26T14:55:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload-time = "2026-08-26T14:55:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload-time = "2026-08-26T14:55:24.748Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload-time = "2026-08-26T14:55:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload-time = "2026-08-26T14:55:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload-time = "2026-08-26T14:55:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload-time = "2026-08-26T14:55:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload-time = "2026-08-26T14:55:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload-time = "2026-08-26T14:55:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload-time = "2026-08-26T14:55:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload-time = "2026-08-26T14:55:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload-time = "2026-08-26T14:55:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload-time = "2026-08-26T14:55:36.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload-time = "2026-08-26T14:55:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload-time = "2026-08-26T14:55:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload-time = "2026-08-26T14:55:40.498Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload-time = "2026-08-26T14:55:41.763Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload-time = "2026-08-26T14:55:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload-time = "2026-08-26T14:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload-time = "2026-08-26T14:58:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload-time = "2026-08-26T14:58:26.689Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload-time = "2026-08-26T17:25:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload-time = "2026-08-26T17:25:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload-time = "2026-08-26T17:25:28.134Z" }, + { 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" }, +]