chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前, 先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。 此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
commit
a7725f8812
35 files changed
+11871
No files matched your search
+35
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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)。
|
||||
@@ -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`
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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__))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user