chore: 工程化改造前的基线快照

在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
snowy-yang committed 2026-09-18 21:12:40 +08:00
commit a7725f8812
35 files changed
+11871

No files matched your search

+35
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.13
+49
View File
@@ -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)。
+38
View File
@@ -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`,网页 <http://127.0.0.1:8765/>,文档 /docs
- 玩:`python main.py play --farm 名字`;单机:`python main.py solo`
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | 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`
+424
View File
@@ -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 # ⑤ 优雅停机(会把所有牧场存档)
```
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
能实时看到对方的操作)。
不想装任何第三方库、只想单机玩玩:
```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 <动作> '<JSON参数>'` | 执行一个动作 |
| `batch '<JSON数组>'` | 一个请求执行多个动作 |
| `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>` 头或 `?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,可直接阅读分析) |
| 接口文档 | <http://127.0.0.1:8765/docs> |
实现上不依赖 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`。)*
+2078
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -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
+294
View File
@@ -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
+78
View File
@@ -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())
+20
View File
@@ -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",
]
+221
View File
@@ -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
+721
View File
@@ -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
+172
View File
@@ -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
+743
View File
@@ -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
+142
View File
@@ -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
+324
View File
@@ -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
+63
View File
@@ -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
+60
View File
@@ -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__))
+164
View File
@@ -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())
+240
View File
@@ -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
+688
View File
@@ -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))
+351
View File
@@ -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 []
+250
View File
@@ -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
+309
View File
@@ -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
+455
View File
@@ -0,0 +1,455 @@
"""内嵌的网页客户端。
为什么要内嵌而不是写一堆静态文件
--------------------------------
服务端只需要一个 ``/`` 路由就能给出完整的可视化客户端,部署时不用管理
静态目录、不用配 CORS,`pip install` 完就能玩。页面本身也是 WebSocket
协议的一份"活文档":想接自己的客户端时,照着这里的 JS 抄就行。
注意:这个字符串里既有 CSS 又有 JS,花括号很多,所以**不要**用 ``f-string``
渲染它(为此把需要拼接的部分放在 :func:`dashboard_text` 里)。
"""
from __future__ import annotations
DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleFarm 服务器</title>
<style>
:root {
--bg: #f4f7f2; --card: #ffffff; --line: #dde5d8; --ink: #23301f;
--dim: #6b7a66; --green: #3f8f4f; --red: #c0392b; --gold: #b8860b;
--blue: #2f6fb0; --cyan: #2b8a8a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
}
header {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 14px 20px; background: var(--card); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
header h1 { font-size: 18px; margin: 0; }
header h1 span { font-size: 12px; color: var(--dim); font-weight: normal; margin-left: 6px; }
.spacer { flex: 1; }
input, select, button {
font: inherit; padding: 6px 10px; border: 1px solid var(--line);
border-radius: 8px; background: #fff; color: var(--ink);
}
button { cursor: pointer; transition: .15s; }
button:hover { border-color: var(--green); color: var(--green); }
button.primary { background: var(--green); border-color: var(--green); color: #fff; }
button.primary:hover { background: #357a43; color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
.dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dim); }
.dot i { width: 9px; height: 9px; border-radius: 50%; background: #c9d2c4; display: block; }
.dot.on i { background: var(--green); box-shadow: 0 0 0 3px rgba(63,143,79,.18); }
.dot.bad i { background: var(--red); }
main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
.stat b { display: block; font-size: 18px; }
.stat small { color: var(--dim); }
.cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; }
.card h2 { font-size: 14px; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
.card h2 em { font-style: normal; color: var(--dim); font-size: 12px; font-weight: normal; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 10px; }
.plot { border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: #fbfdfa; }
.plot.ripe { border-color: var(--green); background: #f1faf2; }
.plot.dry { border-color: var(--red); background: #fdf3f2; }
.plot .top { display: flex; justify-content: space-between; font-weight: 600; }
.plot .meta { color: var(--dim); font-size: 12px; }
.bar { height: 6px; border-radius: 4px; background: #e7ede4; overflow: hidden; margin: 5px 0; }
.bar i { display: block; height: 100%; background: var(--green); }
.water i { background: var(--blue); }
.plot .btns { display: flex; gap: 6px; margin-top: 7px; }
.plot .btns button { flex: 1; padding: 3px 0; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--dim); font-weight: normal; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.up { color: var(--red); } .down { color: var(--green); } .flat { color: var(--dim); }
.log { max-height: 190px; overflow: auto; font-size: 13px; }
.log div { padding: 1px 0; }
.log .d { color: var(--dim); margin-right: 6px; font-variant-numeric: tabular-nums; }
.k-good { color: var(--green); } .k-bad { color: var(--red); }
.k-warn { color: var(--gold); } .k-action { color: var(--cyan); }
.notice { font-size: 12px; color: var(--dim); min-height: 18px; }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px;
font-size: 13px; opacity: 0; transition: .25s; pointer-events: none; z-index: 20;
}
.toast.show { opacity: .93; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: none; }
ul { padding: 0; margin: 0; }
</style>
</head>
<body>
<header>
<h1>SimpleFarm <span>小型农场游戏服务器</span></h1>
<div class="dot" id="status"><i></i><span id="statustext">未连接</span></div>
<div class="spacer"></div>
<input id="farm" size="10" placeholder="牧场名" />
<input id="token" size="10" placeholder="令牌(可选)" />
<button class="primary" id="join">进入牧场</button>
</header>
<main>
<div class="stats" id="stats"></div>
<div class="cols">
<div>
<div class="card">
<h2>农田 <em id="farmhint"></em></h2>
<div class="grid" id="plots"></div>
</div>
<div class="card">
<h2>牧场 <em id="animalhint"></em></h2>
<ul class="animals" id="animals"></ul>
<div class="row" style="margin-top:8px">
<select id="buyanimal">
<option value="chicken">鸡 90</option>
<option value="sheep">羊 220</option>
<option value="cow">牛 480</option>
</select>
<button data-act="buy_animal">买牲畜</button>
</div>
</div>
</div>
<div>
<div class="card">
<h2>快捷动作</h2>
<div class="row">
<button class="primary" data-act="harvest_all">一键收获</button>
<button data-act="buy_plot">买地</button>
<button data-act="buy_feed">买饲料x10</button>
<button data-act="sleep">休息一天</button>
<button data-act="pause">暂停/继续</button>
<button data-act="save">存档</button>
<button data-act="reset">重置</button>
</div>
<div class="notice" id="notice"></div>
</div>
<div class="card">
<h2>市场行情 <em>红涨绿跌</em></h2>
<table id="market"></table>
</div>
<div class="card">
<h2>服务器排行榜 <em id="worldinfo"></em></h2>
<table id="board"></table>
</div>
<div class="card">
<h2>农事日志</h2>
<div class="log" id="log"></div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
let ws = null, state = null, catalog = null, pollTimer = null, reconnectTimer = null, pending = 0;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
corn: "玉米", pumpkin: "南瓜", strawberry: "草莓"
};
function toast(text) {
const el = $("toast");
el.textContent = text;
el.classList.add("show");
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove("show"), 2200);
}
function setStatus(text, cls) {
$("statustext").textContent = text;
$("status").className = "dot " + (cls || "");
}
function farmName() { return ($("farm").value || "default").trim() || "default"; }
function token() { return ($("token").value || "").trim(); }
function headers() {
const h = { "Content-Type": "application/json", "X-Farm-Id": farmName() };
if (token()) h["X-API-Token"] = token();
return h;
}
async function api(path, options) {
const res = await fetch(path, options);
let data = null;
try { data = await res.json(); } catch (e) { data = { ok: false, message: "响应不是 JSON" }; }
if (!res.ok || data.ok === false) {
toast(data.message || ("请求失败 " + res.status));
return null;
}
return data;
}
async function act(action, params) {
const data = await api("/api/actions" + (token() ? "?token=" + encodeURIComponent(token()) : ""), {
method: "POST", headers: headers(),
body: JSON.stringify({ action: action, params: params || {} })
});
if (data) {
toast(data.message);
if (data.state) refreshFromBrief(data.state);
}
}
/* 动作响应里只有精简状态,这里把它合并进完整状态,避免多查一次 */
function refreshFromBrief(brief) {
if (!state) { syncNow(); return; }
Object.assign(state, brief);
render();
}
function syncNow() {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: "sync" }));
return;
}
api("/api/state?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : ""), { headers: headers() })
.then((d) => { if (d) { state = d.state; render(); } });
}
function connect() {
if (ws) { try { ws.close(); } catch (e) {} }
clearTimeout(reconnectTimer);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/ws?farm=" + encodeURIComponent(farmName()) +
(token() ? "&token=" + encodeURIComponent(token()) : "") + "&name=web";
setStatus("连接中…", "");
ws = new WebSocket(url);
ws.onopen = () => {
setStatus("已连接(WebSocket)", "on");
clearInterval(pollTimer); pollTimer = null;
api("/api/farms", { headers: headers() });
};
ws.onmessage = (ev) => {
let msg = null;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handle(msg);
};
ws.onclose = (ev) => {
if (ev.code === 4401) { setStatus("令牌不正确", "bad"); }
else setStatus("已断开,3 秒后重连", "bad");
if (!pollTimer) pollTimer = setInterval(syncNow, 1500); // 降级为轮询
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => setStatus("连接异常", "bad");
}
function handle(msg) {
if (msg.type === "welcome") {
state = msg.state; catalog = msg.catalog;
$("farmhint").textContent = "共 " + state.plot_count + "/" + state.max_plots + " 块";
render();
} else if (msg.type === "state" || msg.type === "sync") {
state = msg.state; if (msg.catalog) catalog = msg.catalog;
renderLog(msg.log);
render();
} else if (msg.type === "result") {
toast(msg.message);
if (msg.snapshot) { state = msg.snapshot; render(); }
else if (msg.state) { refreshFromBrief(msg.state); }
renderLog(msg.log);
} else if (msg.type === "day") {
renderLog(null);
} else if (msg.type === "notice") {
$("notice").textContent = msg.text;
} else if (msg.type === "error") {
toast(msg.message || "服务器返回错误");
}
}
function render() {
if (!state) return;
const s = state;
$("stats").innerHTML = [
["第几天", s.day], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"],
["下一天", s.seconds_to_tick === null ? "已暂停" : s.seconds_to_tick + "s"]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join("");
/* 农田 */
$("plots").innerHTML = s.plots.map((p) => {
const ratio = p.grow_days ? Math.round(p.growth / p.grow_days * 100) : 0;
const water = Math.round(p.moisture / (p.water_capacity || 3) * 100);
const cls = p.is_ripe ? "ripe" : (p.crop && p.moisture === 0 ? "dry" : "");
const title = p.is_empty ? (p.position + " 号地 · 空地")
: (p.position + " 号地 · " + (cropNames[p.crop] || p.crop) + " · " + p.stage);
const meta = p.is_empty ? "空闲" : (p.is_ripe ? "已成熟,可收获" : "还需 " + p.days_left + " 天");
let btns = "";
if (p.is_ripe) btns = '<button data-h="' + p.position + '">收获</button>';
else if (p.is_empty) btns = '<button data-p="' + p.position + '">播种</button>';
else if (p.moisture < p.water_capacity) btns = '<button data-w="' + p.position + '">浇水</button>';
return '<div class="plot ' + cls + '"><div class="top"><span>' + title +
'</span></div><div class="meta">' + meta + '</div>' +
'<div class="bar"><i style="width:' + ratio + '%"></i></div>' +
'<div class="bar water"><i style="width:' + water + '%"></i></div>' +
'<div class="btns">' + btns + '</div></div>';
}).join("");
/* 牧场 */
$("animalhint").textContent = s.animal_count + "/" + s.max_animals + " 只";
$("animals").innerHTML = s.animals.length ? s.animals.map((a) =>
'<li><b>#' + a.id + ' ' + a.name + '</b><span class="meta">' + (
a.pending > 0 ? '<span class="up">' + a.product + ' x' + a.pending + ' 待收</span>'
: a.is_sick ? '<span class="down">虚弱,急需喂食</span>'
: a.fed ? '已喂食' : '<span class="k-warn">今天还没喂</span>') +
'</span><span class="spacer"></span>' +
(a.fed ? '' : '<button data-f="' + a.id + '">喂食</button>') +
(a.pending > 0 ? '<button data-c="' + a.id + '">收取</button>' : '') + '</li>'
).join("") : '<li style="color:#6b7a66">牧场空空的,先买一只吧</li>';
/* 市场 */
const rows = Object.entries(s.market).filter((kv) => kv[0].indexOf("animal:") !== 0);
const base = { wheat: 14, carrot: 26, tomato: 52, corn: 95, pumpkin: 180, strawberry: 320 };
$("market").innerHTML = '<tr><th>作物</th><th class="num">现价</th><th class="num">基准</th></tr>' +
rows.sort((a, b) => b[1] - a[1]).map((kv) => {
const b = base[kv[0]] || kv[1];
const cls = kv[1] > b * 1.03 ? "up" : (kv[1] < b * 0.97 ? "down" : "flat");
const arrow = cls === "up" ? "↑" : (cls === "down" ? "↓" : "→");
return '<tr><td>' + (cropNames[kv[0]] || kv[0]) + '</td><td class="num ' + cls + '">' +
kv[1].toFixed(0) + " " + arrow + '</td><td class="num">' + b + '</td></tr>';
}).join("");
renderLog(null);
renderBoard();
}
function renderLog(entries) {
if (entries) entries.forEach((e) => logAppend(e));
else if (!logAppend._cache) {
api("/api/log?limit=12&farm=" + encodeURIComponent(farmName()), { headers: headers() })
.then((d) => { if (d) d.entries.forEach(logAppend); });
}
}
function logAppend(e) {
if (logAppend._last && e.seq <= logAppend._last) return;
logAppend._last = e.seq;
logAppend._cache = true;
const box = $("log");
const div = document.createElement("div");
div.className = "k-" + e.kind;
div.innerHTML = '<span class="d">D' + e.day + '</span>' + e.text;
box.insertBefore(div, box.firstChild);
while (box.childNodes.length > 60) box.removeChild(box.lastChild);
}
async function renderBoard() {
const d = await api("/api/world", { headers: headers() });
if (!d) return;
$("worldinfo").textContent = d.farms + " 个牧场 · " + d.clients + " 个连接 · 运行 " +
Math.round(d.uptime) + " 秒";
$("board").innerHTML = '<tr><th>#</th><th>牧场</th><th class="num">净资产</th>' +
'<th class="num">天</th><th class="num">在线</th></tr>' +
d.leaderboard.slice(0, 10).map((r) =>
'<tr><td>' + r.rank + '</td><td>' + r.farm + '</td><td class="num">' +
Math.round(r.net_worth) + '</td><td class="num">' + r.day + '</td><td class="num">' +
r.online + '</td></tr>').join("");
}
/* ---- 事件委托:地块按钮 / 牲畜按钮 / 快捷动作 ---- */
document.addEventListener("click", (ev) => {
const t = ev.target;
if (t.tagName !== "BUTTON") return;
if (t.dataset.w) act("water", { plot: +t.dataset.w });
else if (t.dataset.h) act("harvest", { plot: +t.dataset.h });
else if (t.dataset.p) {
const plot = +t.dataset.p;
const pool = state ? Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
}) : [];
const pick = window.prompt("要种什么?输入编号:\n" +
pool.map((k, i) => (i + 1) + ". " + cropNames[k]).join("\n"), "1");
const idx = parseInt(pick, 10);
if (!idx || idx < 1 || idx > pool.length) return;
act("plant", { plot: plot, crop: pool[idx - 1] });
}
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); }
else if (t.dataset.act) act(t.dataset.act);
});
$("join").addEventListener("click", () => {
localStorage.setItem("sf.farm", farmName());
localStorage.setItem("sf.token", token());
fetch("/api/farms", {
method: "POST", headers: headers(),
body: JSON.stringify({ farm: farmName() })
}).then((r) => r.json()).then((d) => { toast(d.message || ""); });
connect();
});
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
/* ---- 启动 ---- */
$("farm").value = localStorage.getItem("sf.farm") || "default";
$("token").value = localStorage.getItem("sf.token") || "";
connect();
setInterval(renderBoard, 5000);
setInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
</script>
</body>
</html>
"""
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"
)
+129
View File
@@ -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
+784
View File
@@ -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)
+627
View File
@@ -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())
+113
View File
@@ -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
+15
View File
@@ -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。
+458
View File
@@ -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)
+596
View File
@@ -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)
+139
View File
@@ -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())
+99
View File
@@ -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())
Generated
+958
View File
@@ -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" },
]