mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-27 20:50:06 +08:00
Compare commits
2
Commits
d0c1741940
...
dc17f7f65c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc17f7f65c | ||
|
|
685cff9293 |
@@ -0,0 +1,90 @@
|
||||
name: AstrBot preview
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Sync main branch in AstrBot repository
|
||||
default: false
|
||||
type: boolean
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: farm-preview-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python: ['3.10', '3.12']
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
PYTHONIOENCODING: utf-8
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- run: >-
|
||||
python -m pip install "Pillow>=10.4,<13"
|
||||
setuptools wheel "PyYAML>=6,<7" build
|
||||
-r integrations/astrbot/requirements.txt
|
||||
- run: python scripts/ci_font.py
|
||||
- run: python scripts/check_workflows.py
|
||||
- run: python scripts/verify_foundation.py
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Verify external asset manifest
|
||||
run: python scripts/check_asset_manifest.py
|
||||
- run: python scripts/build_astrbot.py --output dist/astrbot-preview
|
||||
- name: Verify AstrBot package
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/verify_astrbot.py dist/astrbot-preview/astrbot_plugin_farm-*.zip
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: astrbot-preview
|
||||
path: dist/astrbot-preview/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
sync-preview:
|
||||
if: >-
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish)) &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.repository == 'Shu-Ying/main_plugin_farm'
|
||||
needs: [test, build]
|
||||
runs-on: ubuntu-latest
|
||||
# 沿用真寻环境,使 environment 中的 FARM_RELEASE_TOKEN 同样可用。
|
||||
environment: zhenxun-preview
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: astrbot-preview
|
||||
path: incoming
|
||||
- name: Sync validated AstrBot repository
|
||||
env:
|
||||
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
|
||||
run: >-
|
||||
python scripts/publish_astrbot.py
|
||||
incoming/astrbot_plugin_farm-*.zip --publish
|
||||
@@ -47,6 +47,8 @@ jobs:
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Verify external asset manifest
|
||||
run: python scripts/check_asset_manifest.py
|
||||
- run: python scripts/build_nonebot.py --output dist/nonebot-preview
|
||||
- run: >-
|
||||
python scripts/verify_nonebot.py
|
||||
|
||||
@@ -57,6 +57,8 @@ jobs:
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify external asset manifest
|
||||
run: python scripts/check_asset_manifest.py
|
||||
- name: Build selected target only
|
||||
run: >-
|
||||
python scripts/build_zhenxun.py
|
||||
|
||||
@@ -1,84 +1,52 @@
|
||||
# Farm
|
||||
<div align="center">
|
||||
|
||||
统一维护农场核心,面向真寻、普通 NoneBot 和 AstrBot 分包发布。
|
||||
主仓库:https://github.com/Shu-Ying/main_plugin_farm.git。
|
||||
<img src="./logo.png" width="400" alt="真寻农场 Logo">
|
||||
|
||||
## 当前可用范围
|
||||
<p align="center">
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
|
||||
</a>
|
||||
<a href="https://www.python.org">
|
||||
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
|
||||
</a>
|
||||
<a href="https://github.com/astral-sh/ruff">
|
||||
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
|
||||
</a>
|
||||
<a href="https://nonebot.dev/">
|
||||
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
|
||||
</a>
|
||||
<a href="https://github.com/zhenxun-org/zhenxun_bot">
|
||||
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
第一批框架无关基础核心已实现,位于 src/farm_core:
|
||||
农场核心,其他Bot框架进行分包发布
|
||||
|
||||
- 离线迁移旧版 202 条作物配置。
|
||||
- 注册、账户/库存/地块查询、购买、播种、收获、铲除。
|
||||
- 作物锁定/解锁、指定出售与全部出售、基础文本展示。
|
||||
- SQLite 事务、资产流水、幂等重放及并发保护。
|
||||
- 初始 500 农场币、0 点券、0 经验、3 块普通地。
|
||||
- 可选共享图片层:旧布局与素材、目标尺寸绘制、WebP/JPEG/PNG、
|
||||
有界缓存和受控后台执行,参见 [绘图说明](docs/10-rendering.md)。
|
||||
</div align="center">
|
||||
|
||||
主仓库不能整体放入插件目录。真寻预览包由脚本白名单生成,参见
|
||||
[真寻测试说明](docs/11-zhenxun-preview.md);AstrBot/通用NoneBot接入
|
||||
仍未实现。工作流和同步脚本已准备,真实机器人和远端运行尚未验证。
|
||||
签到、偷菜、升级、兑换和真实玩家数据迁移不在本轮范围。
|
||||
|
||||
## 安装与本地演示
|
||||
|
||||
Python 语法基线为 3.10;本轮在 Windows、Python 3.12.14 上验证。
|
||||
推荐在独立虚拟环境中使用。构建需要 setuptools>=68;
|
||||
运行核心本身没有第三方依赖。
|
||||
|
||||
```text
|
||||
python -m pip install --no-deps --no-build-isolation .
|
||||
python -m farm_core --database runtime/demo.sqlite3
|
||||
```
|
||||
|
||||
演示使用独立的 demo 身份及可控时钟,不需要真实等待作物成熟。
|
||||
指定目录内生成数据库及同名 .log;请使用开发目录,不指向生产数据。
|
||||
相同演示数据库重复运行会重放固定操作结果,不重复发放资产。
|
||||
|
||||
接入方调用示例:
|
||||
|
||||
```python
|
||||
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
||||
|
||||
store = SQLiteStore("runtime/farm.sqlite3")
|
||||
await store.initialize()
|
||||
service = FarmService(store, Catalogue.bundled())
|
||||
who = Identity("deployment-1", "realm-1", "qq", "user-1")
|
||||
try:
|
||||
receipt = await service.register(who, "我的农场", "event-register-1")
|
||||
snapshot = await service.snapshot(who)
|
||||
finally:
|
||||
await store.close()
|
||||
```
|
||||
|
||||
每个写操作传入稳定的 operation_id。调用方负责身份命名空间、权限、
|
||||
会话及发送消息;核心不会自动关联跨平台账号。
|
||||
|
||||
## 离线验证与迁移
|
||||
|
||||
```text
|
||||
python scripts/check_baseline.py
|
||||
python scripts/export_catalogue.py --check
|
||||
python scripts/verify_foundation.py
|
||||
```
|
||||
|
||||
完整验证执行基线校验、可复现导出、规则/事务测试和离线 wheel 安装;
|
||||
打包测试需要已安装的 pip、setuptools、wheel、Pillow和可加载字体,
|
||||
禁止从网络补装;绘图测试可设置FARM_TEST_FONT为本机字体路径。
|
||||
只跑业务测试时,可安装核心后使用:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s tests -p "test_transactions.py" -v
|
||||
```
|
||||
## 简单说明
|
||||
|
||||
[文档索引](docs/README.md)、
|
||||
[基础核心说明](docs/09-foundation.md)、
|
||||
[待确认产品决策](docs/07-decisions.md)。
|
||||
[待确认产品决策](docs/07-decisions.md)
|
||||
|
||||
## 来源与许可证
|
||||
本仓库不是主要插件仓库,安装插件请去各自对应框架的应用商店或者仓库寻找:
|
||||
</br>
|
||||
- [真寻](https://github.com/Shu-Ying/zhenxun_plugin_farm)
|
||||
- [Nonebot](https://github.com/Shu-Ying/nonebot_plugin_farm)
|
||||
- [Astrbot](https://github.com/Shu-Ying/astrbot_plugin_farm)
|
||||
|
||||
`如果没有你需要的框架,那么请在本仓库提交issue吧,我会尽量迁移的(其实是用AI)`
|
||||
|
||||
legacy/zhenxun_plugin_farm 保存旧 Beta 基线,827 个文件保持校验。
|
||||
来源提交 b015c6c1997f146f651b677382980d338a306bb2,保留 GPLv3 LICENSE。
|
||||
基线副本的硬编码服务令牌已改为环境变量;原服务端轮换未完成。
|
||||
新核心不使用该令牌、不访问旧远程服务;可选渲染包独立保存所需旧素材。
|
||||
新核心 0.1.0 为本地基础包版本,不表示三个框架插件已发布。
|
||||
## 开源协议
|
||||
本项目采用的是`GPLv3`许可证进行开源
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
|
||||
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
|
||||
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
|
||||
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
|
||||
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
|
||||
- [ChatGPT](https://chatgpt.com/) *AI神力*
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# AstrBot 商店展示适配 — 2026-10-23
|
||||
|
||||
## 已完成的本地修改
|
||||
|
||||
- 图标发布源:主仓库根目录`logo.png`,1254×1254 RGBA透明PNG。
|
||||
使用内置图片生成工具制作,并清理边缘和留白;未覆盖其他图标。
|
||||
后续添加浅粉文字「真寻农场」。主仓库、NoneBot和真寻README使用高清
|
||||
`logo.png`,显示宽度400像素。AstrBot构建将高清图复制为`logo-readme.png`
|
||||
供README引用,将根目录`logo-astrbot.png`(256×256)复制为商店识别的
|
||||
`logo.png`。两个用途独立;AstrBot原目录中的图片不再作为打包源。
|
||||
- 元数据:保持插件名、作者与仓库身份;添加短介绍、标签、主页及
|
||||
`support_platforms: [aiocqhttp]`。版本继续读取 `version.txt`,本次为2.0.2。
|
||||
- 文档:`readme/astrbot.md`改为用户使用说明,截图和图标采用发布仓库main分支
|
||||
的公开绝对链接;发布前尚未在远端存在的新图片暂时无法通过该链接显示。
|
||||
- 构建时从`src/farm_core/commands.py`读取AstrBot命令,替换README中的
|
||||
`<!-- FARM_COMMANDS -->`标记,不再手写一份独立指令表。
|
||||
- 更新日志:`integrations/astrbot/CHANGELOG.md`,2.0.2标记待发布,
|
||||
未编造1.x历史,不表示已更新线上商店。
|
||||
- 发布包收录README、CHANGELOG、logo和预览图,加入完整性清单。
|
||||
- `verify_astrbot.py --marketplace`额外检查16 MiB大小限制;
|
||||
普通本地安装包校验保留原有完整性边界。
|
||||
|
||||
## 本地交付及检查
|
||||
|
||||
- ZIP:`dist/astrbot-store-assets-20261023/astrbot_plugin_farm-2.0.2.zip`。
|
||||
- 精确大小:25,248,453字节(24.08 MiB)。
|
||||
- 安装目录预览:同目录`repository/`,版本2.0.2。
|
||||
- 两项AstrBot包测试通过:重复构建一致、校验与篡改拒绝、模拟宿主命令、
|
||||
图片及关闭流程,并验证展示资源、透明图标和自动指令表。
|
||||
- 图标为正方形,Alpha范围0~255。未把生成的角色图声明为官方授权素材。
|
||||
- 本轮未安装、重启、提交Git、推送、创建Release或更新Cloud。
|
||||
|
||||
## 待确认与已知限制
|
||||
|
||||
2026-10-23后续进展:已将三端发行包的assets.zip移至farm_service仓库
|
||||
`assets/`,本地新包`dist/astrbot-external-assets-20261023/`大小4,599,804字节
|
||||
(4.39 MiB),通过`--marketplace`大小及完整性检查;以下24.08 MiB为旧产物记录。
|
||||
配置及服务说明以`farm_service/docs/assets.md`为准;尚未推送或验证远端素材URL。
|
||||
|
||||
1. **商店容量限制尚未满足**:官方普通投稿限制16MB,现有素材包
|
||||
`src/farm_render/data/assets.zip`本身已21,264,561字节;完整ZIP24.08 MiB。
|
||||
`--marketplace`已实际执行并拒绝。不能把本地构建成功当作市场发布就绪。
|
||||
后续需决定素材压缩/拆分策略,或与AstrBot维护者确认容量例外;本轮未删减
|
||||
或重新压缩用户现有农场素材以避免画质和布局变化。
|
||||
2. **最低AstrBot版本**:未凭推测加入`astrbot_version`。需要选择最低支持版本
|
||||
并在真实宿主验证后声明;模拟宿主通过不等于真实宿主通过。
|
||||
3. **支持平台**:仅声明当前重点适配的aiocqhttp;其他平台是否加入须验证,
|
||||
不因共享核心不依赖框架就宣称全部平台可用。
|
||||
4. **Cloud同步**:发布页需登录。尚未核实Cloud独立更新日志是否自动同步
|
||||
CHANGELOG或GitHub Release;宿主读取根CHANGELOG已有官方源码依据。
|
||||
后续发布时应检查Cloud的文档、更新日志、信息、版本四个栏目。
|
||||
5. **图标选择**:当前采用真寻抱趴胡萝卜的构图。正方形符合图标比例要求,
|
||||
保留生成原分辨率;256×256为官方推荐而非本轮输出尺寸。
|
||||
如用户希望改坐姿、简化轮廓或调整服装,可基于原图继续修改。
|
||||
6. **角色呈现**:图标为生成式非官方二次创作,识别度及是否最终采用由用户审阅;
|
||||
不声称已获得角色官方授权。代码许可证不能作为角色图像授权的证明。
|
||||
7. **模型请求**:当前工具不能切换或核实主对话模型,未声称已切到GPT-6.1 Sol高。
|
||||
|
||||
## 图标生成说明
|
||||
|
||||
内置图片生成工具;首稿提示:Mahiro Oyama / 緒山まひろ,银白偏粉长发、
|
||||
呆毛和红棕眼,穿不透明宽松淡黄色连帽衫,完整着装、无文字,Q版角色轻松
|
||||
抱趴在一根巨大橙色胡萝卜上,绿色叶片、奶油色贴纸轮廓,透明正方形背景,
|
||||
优先小图辨识。第二轮保留角色与姿势,要求清理游离像素、平滑轮廓和增加留白。
|
||||
|
||||
## 参考
|
||||
|
||||
- https://docs.astrbot.app/dev/star/plugin-new.html
|
||||
- https://docs.astrbot.app/dev/star/plugin-publish.html
|
||||
- https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm
|
||||
- https://onimai.jp/character/mahiro.html
|
||||
@@ -0,0 +1,35 @@
|
||||
# 动态农场帮助菜单
|
||||
|
||||
发送 `农场帮助` 返回单张帮助长图,发送 `农场帮助 文字` 返回完整文字版。
|
||||
未开通农场也可使用;帮助查询不触发旧玩家数据迁移。
|
||||
缺少可用字体或绘图失败时自动返回文字版,并在运行日志记录原因。
|
||||
|
||||
菜单使用包内农场背景、绿色分类栏和浅米色卡片。命令与说明采用固定字号,
|
||||
按实测字宽换行,不省略长指令;分类自动分配到两列并随内容增加图片高度。
|
||||
说明区包含参数、编号示例、虫害、经验及迁移提示。
|
||||
|
||||
图片遵循现有农场图片格式与质量配置;帮助菜单不支持高清参数。
|
||||
同内容、平台、格式及质量的请求复用成图缓存,并发绘制复用同一任务。
|
||||
|
||||
## 增加或调整指令
|
||||
|
||||
唯一的中文命令定义位于 `src/farm_core/commands.py` 的 `COMMAND_SPECS`。
|
||||
每条定义包含注册名称、运行时动作、分类、语法、说明、别名、适用框架,
|
||||
同一主命令的附加语法使用 `extra`,例如 `开垦 条件`。
|
||||
|
||||
新增实际功能时,先实现对应运行时动作,再添加定义。三个框架的中文命令注册、
|
||||
帮助图片及文字版共同读取该定义,无需再修改独立的帮助文案列表。
|
||||
插件加载时读取定义,修改后随正常的插件重新加载生效,不支持在线编辑文件热更新。
|
||||
|
||||
`platforms` 为空时三平台共用;限制为 `('zhenxun',)` 的已接入指令仅在真寻注册
|
||||
并显示。NoneBot 和 AstrBot 不显示该分类的空卡片。框架类型由接入层明确指定,
|
||||
不使用用户可配置的 deployment 名称判断。
|
||||
|
||||
当前新版尚未接入金币兑换,因此不会显示旧版的购买农场币或点券兑换指令。
|
||||
以后接入真寻兑换时,将真实动作及其平台限制一同加入定义即可。
|
||||
|
||||
## 验证边界
|
||||
|
||||
离线测试覆盖平台筛选、注册与帮助一致性、别名、超长命令换行、内容增长、
|
||||
图片格式、质量配置、缓存及并发合并,以及文字兜底和帮助查询无玩家迁移。
|
||||
离线图片和模拟宿主验证不代表已安装到真实机器人。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 更新日志
|
||||
|
||||
## 2.0.2 — 待发布
|
||||
|
||||
### 新增
|
||||
|
||||
- 单张动态帮助图片,按当前平台的指令生成;支持「农场帮助 文字」。
|
||||
- 种子与作物分类仓库,支持分页、作物编号及文字显示。
|
||||
- 开垦与开垦条件查询,按下一实际地块编号校验等级和费用。
|
||||
- 插件图标、市场短介绍、标签及独立更新日志。
|
||||
|
||||
### 调整与兼容
|
||||
|
||||
- 新账号初始6块土地;可可靠识别的旧三地账号一次性追加3块普通空地,
|
||||
保留已开垦成果、原作物及资产。来源不明、地块异常或超出当前布局容量
|
||||
时保留原状并提示管理员处理。
|
||||
- 新播种及下一季的生虫间隔至少2小时;已排定的旧事件保留原计划一次。
|
||||
- 全部农场图片遵循统一质量配置,仅「我的农场」允许追加「高清」。
|
||||
- 提供本地独立存档、交易流水、重复消息保护和失败回滚。
|
||||
- QQ/OneBot图片支持头像缓存;头像不可用时使用默认图像。
|
||||
|
||||
### 升级前请阅读
|
||||
|
||||
- 本条记录描述待发布代码,不代表已更新 AstrBot Cloud 或真实机器人。
|
||||
- 新版没有签到、改名、土地升级、外部金币兑换及旧远程服务功能。
|
||||
- 旧存档请先备份,再在副本中验证迁移;不要直接删除或覆盖真实玩家数据。
|
||||
- 当前重点适配 QQ/OneBot(aiocqhttp),其他平台尚未完成真实环境验证。
|
||||
- 本文件未编造旧1.x版本的发布日期和变更历史。
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"resource_source": {
|
||||
"description": "资源获取地址",
|
||||
"type": "string",
|
||||
"default": "Github",
|
||||
"options": ["Github", "Gitea", "farm_service"],
|
||||
"hint": "启动时校验本地素材SHA256,缺失或不符从所选地址下载;修改后重载。"
|
||||
},
|
||||
"farm_service_url": {
|
||||
"description": "farm_service 地址",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"hint": "始终独立配置,用于资源及后续活动服务;选择farm_service获取素材时必填HTTPS基础地址。"
|
||||
},
|
||||
"font_path": {
|
||||
"description": "中文字体路径",
|
||||
"type": "string",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -11,7 +11,9 @@ from astrbot.api.message_components import At, Image
|
||||
from astrbot.api.star import Context, Star, StarTools
|
||||
|
||||
from .avatars import AvatarCache
|
||||
from .runtime import COMMANDS, Runtime
|
||||
from .runtime import Runtime, command_map
|
||||
|
||||
COMMANDS = command_map('astrbot')
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,6 +37,9 @@ class FarmPlugin(Star):
|
||||
image_format, font_setting='font_path',
|
||||
image_quality=config.get('image_quality', 75),
|
||||
image_quality_provider=lambda: self.config.get('image_quality', 75),
|
||||
framework='astrbot',
|
||||
resource_source=config.get('resource_source', 'Github'),
|
||||
service_url=config.get('farm_service_url', ''),
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
发布目标:https://github.com/Shu-Ying/nonebot_plugin_farm.git
|
||||
本目录负责命令、统一会话、消息、配置与启停,不导入真寻。
|
||||
运行时在打包时复用 `integrations/zhenxun/runtime.py`,核心和渲染模块
|
||||
运行时在打包时复用 `integrations/shared/runtime.py`,核心和渲染模块
|
||||
按白名单私有化。此源码目录不能直接加载,需先构建生成仓库。
|
||||
|
||||
`python scripts/build_nonebot.py --output dist/nonebot-preview` 生成标准
|
||||
|
||||
@@ -9,10 +9,11 @@ require("nonebot_plugin_localstore")
|
||||
|
||||
from ._version import __version__
|
||||
from .config import Config
|
||||
from .runtime import HELP
|
||||
from .runtime import command_specs, help_text
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="农场", description="快乐的农场时光", usage=HELP,
|
||||
name="农场", description="快乐的农场时光",
|
||||
usage=help_text(command_specs('nonebot')),
|
||||
type="application", config=Config,
|
||||
homepage="https://github.com/Shu-Ying/nonebot_plugin_farm",
|
||||
supported_adapters=inherit_supported_adapters(
|
||||
|
||||
@@ -12,7 +12,9 @@ from nonebot_plugin_uninfo import Uninfo
|
||||
|
||||
from .config import Config
|
||||
from .data_paths import data_directory
|
||||
from .runtime import COMMANDS, Runtime
|
||||
from .runtime import Runtime, command_map
|
||||
|
||||
COMMANDS = command_map('nonebot')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = get_plugin_config(Config)
|
||||
@@ -22,6 +24,9 @@ runtime = Runtime(
|
||||
config.farm_font, config.farm_deployment, config.farm_image_format,
|
||||
font_setting="FARM_FONT",
|
||||
image_quality=config.farm_image_quality,
|
||||
framework='nonebot',
|
||||
resource_source=config.farm_resource_source,
|
||||
service_url=config.farm_service_url,
|
||||
)
|
||||
driver.on_startup(runtime.start)
|
||||
driver.on_shutdown(runtime.close)
|
||||
|
||||
@@ -25,3 +25,9 @@ class Config(BaseModel):
|
||||
75,
|
||||
description="全部农场图片编码质量;仅我的农场高清时至少85",
|
||||
)
|
||||
farm_resource_source: Literal['Github', 'Gitea', 'farm_service'] = Field(
|
||||
'Github', description='资源获取地址,修改后重载插件',
|
||||
)
|
||||
farm_service_url: str = Field(
|
||||
'', description='farm_service 地址,独立供资源及活动服务使用',
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""真寻与 NoneBot 共用的运行编排;不导入宿主框架。"""
|
||||
"""各平台适配器共用的运行编排;不导入宿主框架。"""
|
||||
|
||||
import hashlib
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import logging
|
||||
@@ -9,12 +10,16 @@ import shlex
|
||||
|
||||
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
||||
from ._vendor.farm_core.domain import FarmError
|
||||
from ._vendor.farm_core.commands import (
|
||||
command_map, command_specs, help_text,
|
||||
)
|
||||
from ._vendor.farm_core.storage import SCHEMA_VERSION
|
||||
from ._vendor.farm_core.presentation import (
|
||||
plot_ids_text, receipt_text, snapshot_text,
|
||||
)
|
||||
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
|
||||
from ._vendor.farm_render.seasons import season_for
|
||||
from ._vendor.farm_render.bundle import ensure_assets, validate_options
|
||||
from ._vendor.farm_render.shop import shop_page
|
||||
from ._vendor.farm_render.details import detail_rows, details_text
|
||||
from ._vendor.farm_render.warehouse import (
|
||||
@@ -22,36 +27,8 @@ from ._vendor.farm_render.warehouse import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
COMMANDS = {
|
||||
"开通农场":"register", "我的农场":"farm", "农场详述":"details",
|
||||
"我的农场币":"inventory", "我的点券":"inventory",
|
||||
"我的种子":"seeds", "我的作物":"crops",
|
||||
"种子商店":"shop", "农场帮助":"help", "购买种子":"buy",
|
||||
"播种":"sow", "收获":"harvest", "铲除":"eradicate",
|
||||
"作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell",
|
||||
"农场除虫":"clear_pests", "除虫":"clear_pests", "地块编号":"plot_ids",
|
||||
"偷菜":"steal",
|
||||
"开垦":"reclaim",
|
||||
}
|
||||
HELP = (
|
||||
"开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n"
|
||||
"地块编号|查看当前土地编号及开垦位置\n"
|
||||
"偷菜 @目标|每日最多5次成功偷取\n"
|
||||
"开垦|每次增加1块普通空地;开垦 条件|查看等级和费用\n"
|
||||
"我的种子/我的作物 [页码] [文字],默认图片。\n"
|
||||
"全部图片使用农场图片质量配置;仅我的农场支持高清。\n"
|
||||
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
|
||||
"收获|铲除|农场除虫|我的种子 [页码]|我的作物 [页码]\n"
|
||||
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
|
||||
"作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n"
|
||||
"每成功播种1块地获得2经验;收获经验按作物结算。\n"
|
||||
"每铲除1块枯萎地块获得2经验;每清除1只虫获得1经验。\n"
|
||||
"新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。\n"
|
||||
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
|
||||
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
|
||||
"我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n"
|
||||
"旧版农场数据在首次使用时迁移,绑定当前机器人和平台。"
|
||||
)
|
||||
COMMANDS = command_map('zhenxun')
|
||||
HELP = help_text(command_specs('zhenxun'))
|
||||
ERRORS = {
|
||||
'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,'
|
||||
'请联系管理员核查注册或迁移记录。',
|
||||
@@ -100,8 +77,17 @@ class Runtime:
|
||||
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
|
||||
image_format="AUTO", *, font_dir=None,
|
||||
font_setting="FARM_PREVIEW_FONT", image_quality=75,
|
||||
image_quality_provider=None):
|
||||
image_quality_provider=None, framework='zhenxun',
|
||||
resource_source='Github', service_url='',
|
||||
resource_options_provider=None):
|
||||
RenderOptions(quality=image_quality)
|
||||
validate_options(resource_source, service_url)
|
||||
self.resource_source = resource_source
|
||||
self.service_url = service_url
|
||||
self.resource_options_provider = resource_options_provider
|
||||
self.framework = framework
|
||||
self.command_specs = command_specs(framework)
|
||||
self.commands = command_map(framework)
|
||||
self.image_quality = image_quality
|
||||
self.image_quality_provider = image_quality_provider
|
||||
self.data_dir = Path(data_dir).resolve()
|
||||
@@ -147,11 +133,27 @@ class Runtime:
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
))
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if self.resource_options_provider is not None:
|
||||
self.resource_source, self.service_url = (
|
||||
self.resource_options_provider()
|
||||
)
|
||||
bundle_path = await asyncio.to_thread(
|
||||
ensure_assets, self.data_dir, self.resource_source,
|
||||
self.service_url,
|
||||
)
|
||||
except Exception:
|
||||
# 不将下载URL、服务地址或可能带凭据的网络异常写入日志。
|
||||
logger.error('farm assets unavailable; using text; '
|
||||
'check resource source and service address')
|
||||
bundle_path = None
|
||||
for candidate in candidates if bundle_path is not None else ():
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
self.rendering = RenderService(FarmRenderer(candidate))
|
||||
self.rendering = RenderService(FarmRenderer(
|
||||
candidate, bundle_path=bundle_path,
|
||||
))
|
||||
logger.info("farm preview font selected file=%s", candidate.name)
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
@@ -180,6 +182,10 @@ class Runtime:
|
||||
if not self.started:
|
||||
return "农场尚未初始化,请检查启动日志。"
|
||||
try:
|
||||
args = shlex.split(arguments)
|
||||
# 帮助无需注册或迁移玩家数据,绘图失败可直接阅读文字。
|
||||
if action == 'help':
|
||||
return await self.help_menu(args)
|
||||
who = Identity(self.deployment, str(bot_id), platform, str(user_id))
|
||||
try:
|
||||
await self.store.migrate_legacy(
|
||||
@@ -212,7 +218,7 @@ class Runtime:
|
||||
bot_id, platform, str(user_id), message_id, action,
|
||||
)
|
||||
if action == "help":
|
||||
return HELP
|
||||
return await self.help_menu(args)
|
||||
if action == 'reclaim' and args == ['条件']:
|
||||
conditions = await self.service.reclaim_conditions(who)
|
||||
return (
|
||||
@@ -371,7 +377,7 @@ class Runtime:
|
||||
raise ValueError()
|
||||
receipt = await getattr(self.service, action)(who, op)
|
||||
else:
|
||||
return HELP
|
||||
return help_text(self.command_specs)
|
||||
text = receipt_text(receipt)
|
||||
if action == 'register':
|
||||
if receipt.data['created']:
|
||||
@@ -386,3 +392,27 @@ class Runtime:
|
||||
return ERRORS.get(error.code, "操作未完成:" + error.code)
|
||||
except ValueError:
|
||||
return "参数有误,请发送:农场帮助"
|
||||
|
||||
async def help_menu(self, args):
|
||||
if '高清' in args:
|
||||
return '仅我的农场支持高清,请移除高清参数后重试。'
|
||||
if args not in ([], ['文字']):
|
||||
return '参数有误,请发送:农场帮助 或 农场帮助 文字'
|
||||
fallback = help_text(self.command_specs)
|
||||
if args or self.rendering is None:
|
||||
return fallback
|
||||
quality = self.image_quality
|
||||
if self.image_quality_provider is not None:
|
||||
quality = self.image_quality_provider()
|
||||
try:
|
||||
RenderOptions(quality=quality)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning('farm help image quality config invalid')
|
||||
return '农场图片质量配置无效,请设置为1~95的整数。'
|
||||
try:
|
||||
return await self.rendering.help_menu(
|
||||
self.command_specs, self.framework, self.image_format, quality,
|
||||
)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
logger.exception('farm help rendering failed; using text')
|
||||
return fallback
|
||||
@@ -19,6 +19,12 @@ __plugin_meta__ = PluginMetadata(
|
||||
help="全部农场图片的编码质量,整数1~95,默认75;"
|
||||
"数值越高通常越清晰、文件越大。仅我的农场支持高清,"
|
||||
"高清质量不低于85。PNG为无损格式,不受此值影响。",
|
||||
), RegisterConfig(
|
||||
key='资源获取地址', value='Github', default_value='Github',
|
||||
type=str, help='Github、Gitea或farm_service,默认Github;重载生效。',
|
||||
), RegisterConfig(
|
||||
key='farm_service 地址', value='', default_value='', type=str,
|
||||
help='独立服务基础地址,用于资源及后续活动服务;HTTPS。',
|
||||
)],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
@@ -27,6 +27,14 @@ def image_quality():
|
||||
)
|
||||
|
||||
|
||||
def resource_options():
|
||||
from zhenxun.configs.config import Config
|
||||
|
||||
config = Config.get(__package__.split('.')[-1])
|
||||
return (config.get('资源获取地址', 'Github', build_model=False),
|
||||
config.get('farm_service 地址', '', build_model=False))
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
driver = get_driver()
|
||||
data_dir = data_directory(
|
||||
@@ -39,6 +47,7 @@ runtime = Runtime(
|
||||
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
|
||||
font_dir=FONT_PATH,
|
||||
image_quality_provider=image_quality,
|
||||
resource_options_provider=resource_options,
|
||||
)
|
||||
async def start_runtime():
|
||||
from zhenxun.configs.config import Config
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,13 @@
|
||||
# 各平台发布说明
|
||||
|
||||
各平台发布包的 README 统一在此维护:
|
||||
|
||||
- `nonebot.md`:NoneBot。
|
||||
- `zhenxun.md`:真寻。
|
||||
- `astrbot.md`:AstrBot。
|
||||
|
||||
对应工作流通过 `scripts/build_<平台>.py` 读取上述文件,
|
||||
在生成的发布仓库和安装包中写为根目录 `README.md`。
|
||||
文中的相对资源路径以生成的发布仓库根目录为准。
|
||||
|
||||
后续发布文案请修改本目录;原位置的文档保留供历史参考。
|
||||
@@ -0,0 +1,102 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/logo-readme.png" width="400" alt="真寻农场 Logo">
|
||||
</p>
|
||||
|
||||
# 农场 · AstrBot
|
||||
|
||||
开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
|
||||
农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
|
||||
|
||||

|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
在 AstrBot 插件市场搜索 `astrbot_plugin_farm`,或在插件管理中选择
|
||||
「从文件安装」,上传作者提供的完整插件 ZIP。不要只上传 `main.py`。
|
||||
|
||||
安装依赖后重载插件。按你配置的唤醒前缀发送指令,下面以 `/` 为例:
|
||||
|
||||
1. `/开通农场`:创建农场,获得初始6块土地。
|
||||
2. `/种子商店`:查看种子名称、编号与价格。
|
||||
3. `/购买种子 白萝卜 1`,然后 `/播种 白萝卜 1`。
|
||||
4. `/我的农场` 查看状态,生长期间用 `/农场除虫` 清除虫害。
|
||||
5. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
|
||||
|
||||
发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
|
||||
发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
|
||||
|
||||
## 指令
|
||||
|
||||
`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
|
||||
仓库默认图片,例如 `/我的种子 2 文字`。偷菜目标必须通过消息中的 @ 指定。
|
||||
|
||||
<!-- FARM_COMMANDS -->
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `font_path` | 空 | 中文字体路径;留空尝试系统字体,无可用字体时退回文字 |
|
||||
| `data_dir` | 空 | 留空使用 `data/plugin_data/astrbot_plugin_farm`;更改不会自动搬迁存档 |
|
||||
| `image_format` | `AUTO` | `AUTO` 为 JPEG;也可选 `JPEG` 或 `PNG` |
|
||||
| `image_quality` | `75` | 整数1~95,下一次图片请求生效;PNG不受影响 |
|
||||
| `resource_source`(资源获取地址) | `Github` | `Github`、`Gitea`、`farm_service`,重载生效 |
|
||||
| `farm_service_url`(farm_service 地址) | 空 | 独立HTTPS基础地址,供资源及后续活动服务共用;选farm_service下载时必填 |
|
||||
|
||||
除图片质量外,配置变更后请重载插件。高清农场的图片质量不低于85。
|
||||
依赖 Pillow 和 aiohttp,由插件依赖声明安装;插件不捆绑中文字体。
|
||||
当前重点适配 QQ/OneBot(`aiocqhttp`),其他平台尚未完成真实环境验证。
|
||||
|
||||
插件不携带大型`assets.zip`。启动时校验数据目录下缓存素材的SHA256,
|
||||
缺失或不匹配时从所选源下载并校验后原子替换;GitHub/Gitea均从
|
||||
`farm_service`仓库根目录`assets/`获取。默认Github,不自动跨源切换;
|
||||
下载失败保留文字功能,调整来源后重载重试。两项配置始终同时存在,
|
||||
活动功能尚未实现。已有正确缓存时不联网。
|
||||
|
||||
离线部署可将服务仓库的`assets/assets.zip`放到农场数据目录
|
||||
`assets/<SHA256>/assets.zip`;SHA见插件的`_vendor/farm_render/data/asset-manifest.json`。
|
||||
请先提交/发布服务仓库的素材,再发布依赖它的插件;不要把素材ZIP放回插件仓库。
|
||||
|
||||
## 玩法与旧账号兼容
|
||||
|
||||
- 新账号初始6块土地。`/开垦 条件` 查询下一块地的等级和费用,
|
||||
`/开垦` 每次购买1块普通空地;未配置的地块不会扣款或放行。
|
||||
- 可靠识别为初始3块地的旧账号,一次性追加3块普通空地,保留已有开垦成果、
|
||||
作物、加成与资产。来源不明、编号异常或补后超过当前30个显示位置时,
|
||||
保留原状并提示管理员处理,不截断、不折现。
|
||||
- 新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。
|
||||
已排定的旧生虫事件仍按原计划执行一次。
|
||||
- 每只虫降低生长速度5%;成熟时每只虫使本季减产5%。成熟后除虫无法恢复
|
||||
本季减产,请在生长期间及时除虫。
|
||||
- 每日最多5次成功偷菜,北京时间零点重置;无可偷作物不消耗成功次数。
|
||||
- 出售时自动跳过已锁定作物。仓库可使用作物编号操作。
|
||||
|
||||
## 升级与数据备份
|
||||
|
||||
升级前备份 `data/plugin_data/astrbot_plugin_farm`;若自定义 `data_dir`,
|
||||
请备份实际目录。数据库为 `farm.sqlite3`,日志为 `farm-preview.log`。
|
||||
旧库迁移前建议先在副本中验证。更换机器人、平台实例或部署不会自动合并账号。
|
||||
|
||||
新版当前没有签到、改名、土地升级、点券商店、外部金币兑换及旧远程服务功能。
|
||||
请以当前帮助菜单为准,不要套用旧1.x版本指令。缺少稳定消息ID时,
|
||||
插件会拒绝写操作以避免重复扣款。离线包校验及模拟宿主测试不代表线上验证。
|
||||
|
||||
QQ/OneBot 图片查询会请求发送者的腾讯头像用于农场及详情图片;
|
||||
请求失败使用默认头像,成功缓存5分钟、失败缓存30秒,最多缓存16项。
|
||||
其他平台使用默认头像。存档按部署、平台实例、机器人及用户隔离。
|
||||
|
||||
## 更新日志与反馈
|
||||
|
||||
- [更新日志](https://github.com/Shu-Ying/astrbot_plugin_farm/blob/main/CHANGELOG.md)
|
||||
- [反馈问题](https://github.com/Shu-Ying/astrbot_plugin_farm/issues)
|
||||
- [插件商店](https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm)
|
||||
|
||||
代码许可见插件所附 LICENSE。角色图标为非官方二次创作,不表示获得官方授权;
|
||||
角色与原作相关权利不因代码许可证而转移。
|
||||
|
||||
## 开发者本地构建
|
||||
|
||||
开发源为 Farm 主仓库。Windows 双击 `build_astrbot.cmd`,或运行
|
||||
`python scripts/build_plugin.py astrbot`,构建后生成完整 ZIP 和校验文件。
|
||||
每次使用独立输出目录,不安装或启动机器人、不自动推送远端。
|
||||
版本来自 `version.txt`;命令表、元数据与展示文件由构建生成。
|
||||
@@ -0,0 +1,76 @@
|
||||
# NoneBot 农场
|
||||
|
||||
<p align="center"><img src="./logo.png" width="400" alt="真寻农场 Logo"></p>
|
||||
|
||||
从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的
|
||||
业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。
|
||||
|
||||

|
||||
|
||||
## 安装与配置
|
||||
|
||||
需要 Python 3.10 及以上。生成仓库中执行 `pip install .`,或安装经过
|
||||
标签发布的 `nonebot_plugin_farm` 包,然后在 NoneBot 启动脚本中加入:
|
||||
|
||||
```python
|
||||
nonebot.load_plugin("nonebot_plugin_farm")
|
||||
```
|
||||
|
||||
可在 `.env` 中设置:
|
||||
|
||||
```dotenv
|
||||
FARM_FONT=C:/Windows/Fonts/msyh.ttc
|
||||
FARM_IMAGE_FORMAT=AUTO
|
||||
FARM_IMAGE_QUALITY=75
|
||||
FARM_DEPLOYMENT=nonebot
|
||||
FARM_RESOURCE_SOURCE=Github
|
||||
FARM_SERVICE_URL=
|
||||
```
|
||||
|
||||
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
|
||||
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
|
||||
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
|
||||
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
|
||||
|
||||
`FARM_RESOURCE_SOURCE`(资源获取地址)支持`Github`、`Gitea`、`farm_service`,
|
||||
默认`Github`。`FARM_SERVICE_URL`(farm_service 地址)始终独立存在,
|
||||
供资源及后续活动功能共用;选择farm_service下载时须填写HTTPS基础地址。
|
||||
两项修改后重载。插件不包含素材ZIP:每次启动校验本地SHA256,缺失或不匹配
|
||||
才从`farm_service`仓库根目录`assets/assets.zip`下载,校验成功后原子替换。
|
||||
失败时保留文字功能,不自动跨源切换,重载后重试。活动功能尚未实施。
|
||||
|
||||
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
|
||||
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
|
||||
启动时校验配置。只有“我的农场 高清”允许高清,使用1536宽,质量
|
||||
取配置值与85的较大值;其他命令不接受高清。种子/作物仓库保留
|
||||
分页和“文字”选项,例如“我的种子 2 文字”。
|
||||
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
|
||||
|
||||
## 当前命令
|
||||
|
||||
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
|
||||
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
|
||||
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
|
||||
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
|
||||
开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
|
||||
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
|
||||
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
|
||||
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
|
||||
尚未在共享核心中实现,此版本不提供这些命令。
|
||||
|
||||
## 生成与发布
|
||||
|
||||
主仓库 `version.txt` 是版本的唯一来源,生成 ZIP、包版本和清单。
|
||||
`nonebot-preview.yml` 在 PR 中测试和构建,在主仓库 `main` 推送后
|
||||
使用 `FARM_RELEASE_TOKEN` 同步到 `Shu-Ying/nonebot_plugin_farm/main`。
|
||||
同步任务沿用真寻的 `zhenxun-preview` environment,兼容其中已有密钥。
|
||||
|
||||
目标仓库保留 `v*` 标签发布 PyPI 的方式和 `PYPI_TOKEN` 密钥。
|
||||
普通同步只校验、构建,不发布 PyPI。手动推送标签前应确认版本未在
|
||||
PyPI 使用,且标签去掉 `v` 后与 `version.txt` 完全一致。
|
||||
接入时远端旧版为 1.5.7,主仓库为 1.5.4;用户计划后续统一改为
|
||||
2.0.0,本次未提前修改。统一后发布标签应为 `v2.0.0`。
|
||||
|
||||
生成仓库的 `verify_package.py --directory .` 校验文件完整性;
|
||||
`python -m build` 生成 wheel/sdist。远端 CI 和实际机器人消息行为
|
||||
需要在部署环境进一步验证。
|
||||
@@ -0,0 +1,149 @@
|
||||
<div align="center">
|
||||
|
||||
# 真寻农场(zhenxun_plugin_farm)
|
||||
|
||||
<img src="./logo.png" width="400" alt="真寻农场 Logo">
|
||||
|
||||
> 资源配置:「资源获取地址」默认Github,可选Github、Gitea、farm_service;
|
||||
> 「farm_service 地址」始终独立存在,供资源及后续活动服务共用。
|
||||
> 选择farm_service下载时填写HTTPS基础地址,两项修改后重载。
|
||||
> 插件不携带assets.zip,启动校验缓存SHA256,缺失或不匹配时从所选源获取;
|
||||
> Github/Gitea均使用farm_service仓库根目录assets/。失败保留文字功能,重载重试。
|
||||
> 活动服务尚未实现,请先发布服务仓库素材再发布插件。
|
||||
<p align="center">
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
|
||||
</a>
|
||||
<a href="https://www.python.org">
|
||||
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
|
||||
</a>
|
||||
<a href="https://github.com/astral-sh/ruff">
|
||||
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
|
||||
</a>
|
||||
<a href="https://nonebot.dev/">
|
||||
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
|
||||
</a>
|
||||
<a href="https://github.com/zhenxun-org/zhenxun_bot">
|
||||
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
[](https://qm.qq.com/q/7hsOD4rOw2)
|
||||
|
||||
</p>
|
||||
|
||||
你是说可以种地对吧🤔?
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## 目录
|
||||
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
|
||||
- [目录](#目录)
|
||||
- [农场界面](#农场界面)
|
||||
- [如何安装](#如何安装)
|
||||
- [使用指令](#使用指令)
|
||||
- [更新日志(详细):](#更新日志详细)
|
||||
- [用户方面](#用户方面)
|
||||
- [代码方面](#代码方面)
|
||||
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
|
||||
- [关于](#关于)
|
||||
- [致谢](#致谢)
|
||||
- [许可证](#许可证)
|
||||
- [上游仓库](#上游仓库)
|
||||
|
||||
---
|
||||
|
||||
## 农场界面
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 如何安装
|
||||
|
||||
方法一(推荐):在小真寻后台的插件商店下载即可<br>
|
||||
方法二:下载源码放在小真寻`plugin`目录下
|
||||
|
||||
---
|
||||
|
||||
## 使用指令
|
||||
|
||||
| 指令 | 描述 | Tip |
|
||||
| --- | --- | --- |
|
||||
| @小真寻 开通农场 | 首次开通农场 | |
|
||||
| 我的农场 | 你的农场 | |
|
||||
| 农场详述 | 农场详细信息 | |
|
||||
| 我的农场币 | 查询农场币 | |
|
||||
| 种子商店 [筛选关键字] [页数] or [页数] | 查看种子商店 | 当第一个参数为非整数时,会默认进入筛选状态。页数不填默认为1 |
|
||||
| 购买种子 [种子名称] [数量] | 购买种子 | 数量不填默认为1 |
|
||||
| 我的种子 | 查询仓库种子 | |
|
||||
| 播种 [种子名称] [数量] | 播种种子 | 数量不填默认将最大可能播种 |
|
||||
| 收获 | 收获成熟作物 | |
|
||||
| 铲除 | 铲除荒废作物 | |
|
||||
| 我的作物 | 你的作物 | |
|
||||
| 出售作物 [作物名称] [数量] | 从仓库里向系统售卖作物 | 不填写作物名将售卖仓库种全部作物 填作物名不填数量将指定作物全部出售 |
|
||||
| 偷菜 @美波理 | 偷别人的菜 | 每人每天只能偷5次 |
|
||||
| 购买农场币 | 将真寻金币兑换成农场币 | 兑换比例默认为1:2 手续费默认20% |
|
||||
| 更改农场名 [新的农场名] | 改名 | 农场名称无法存储特殊字符 |
|
||||
| 农场签到 | 签到 | 需要注意,该项会从服务器拉取签到数据 |
|
||||
| 土地升级 [地块ID] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
|
||||
|
||||
---
|
||||
|
||||
## 更新日志[(详细)](./log/log.md):
|
||||
用户方面
|
||||
---
|
||||
- 新增土地升级指令,快来将土地升级至红土地、黑土地、金土地吧
|
||||
- 新增查漏补缺作物资源功能,自动更新最新作物和作物资源文件
|
||||
- 定时更新签到文件、作物资源从00:30调整至04:30
|
||||
- 修正了部分土地资源错误的情况
|
||||
- 修正了部分文本信息错误的情况
|
||||
|
||||
代码方面
|
||||
---
|
||||
- 修正部分事件连接机制
|
||||
- 修正网络请求端口
|
||||
|
||||
## 待办事宜 `Todo` 列表
|
||||
|
||||
- [x] 完善我的农场图片,例如左上角显示用户数据
|
||||
- [ ] 完善升级数据、作物数据、作物图片
|
||||
- [x] 签到功能
|
||||
- [x] 在线更新作物信息
|
||||
- [ ] 添加渔场功能
|
||||
- [ ] 增加活动、交易行功能
|
||||
- [ ] 增加交易行总行功能
|
||||
- [ ] 添加其他游戏种子素材
|
||||
- [ ] 想不到了,想到再说
|
||||
|
||||
---
|
||||
|
||||
## 关于
|
||||
|
||||
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
|
||||
|
||||
素材来均源于互联网,侵权请联系我删除
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
|
||||
|
||||
- [QQ农场](https://qqnc.qq.com/) * 绝大多数的素材来源 *
|
||||
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
|
||||
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
|
||||
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
|
||||
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
|
||||
|
||||
## 许可证
|
||||
|
||||
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
|
||||
|
||||
## 上游仓库
|
||||
|
||||
- [Client](https://github.com/Shu-Ying/main_plugin_farm)
|
||||
@@ -1,3 +1,4 @@
|
||||
include version.txt LICENSE README.md FARM-MANIFEST.json verify_package.py
|
||||
include images/farm-preview.png
|
||||
recursive-include nonebot_plugin_farm *.py *.json *.zip *.md
|
||||
recursive-include nonebot_plugin_farm *.py *.json *.md
|
||||
global-exclude assets.zip
|
||||
|
||||
@@ -31,5 +31,5 @@ include = ["nonebot_plugin_farm*"]
|
||||
[tool.setuptools.package-data]
|
||||
"nonebot_plugin_farm._vendor.farm_core" = ["data/*.json"]
|
||||
"nonebot_plugin_farm._vendor.farm_render" = [
|
||||
"data/*.json", "data/*.zip", "data/*.md",
|
||||
"data/*.json", "data/*.md",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
@@ -13,6 +14,24 @@ from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||
from verify_zhenxun import read_package as read_shared
|
||||
|
||||
|
||||
def render_readme(root):
|
||||
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
|
||||
marker = '<!-- FARM_COMMANDS -->'
|
||||
if template.count(marker) != 1:
|
||||
raise ValueError('AstrBot README requires one command marker')
|
||||
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
|
||||
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
|
||||
specs = definitions['command_specs']('astrbot')
|
||||
for group, rows in definitions['help_groups'](specs):
|
||||
for usage, description in rows:
|
||||
values = (group, f'`/{usage}`', description)
|
||||
lines.append('| ' + ' | '.join(
|
||||
value.replace('|', r'\|').replace('\n', ' ')
|
||||
for value in values
|
||||
) + ' |')
|
||||
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
|
||||
|
||||
|
||||
def build(root, output):
|
||||
root, output = Path(root).resolve(), Path(output).resolve()
|
||||
version = read_version(root)
|
||||
@@ -28,11 +47,15 @@ def build(root, output):
|
||||
)}
|
||||
source = root / 'integrations/astrbot'
|
||||
for name in ('main.py', 'avatars.py', '_conf_schema.json',
|
||||
'requirements.txt'):
|
||||
'requirements.txt', 'CHANGELOG.md'):
|
||||
files[name] = (source / name).read_bytes()
|
||||
files['README.md'] = (source / 'README.md').read_bytes()
|
||||
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
|
||||
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
|
||||
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
|
||||
files['images/farm-preview.png'] = shared['images/farm-preview.png']
|
||||
files['README.md'] = render_readme(root)
|
||||
files['runtime.py'] = (
|
||||
root / 'integrations/zhenxun/runtime.py'
|
||||
root / 'integrations/shared/runtime.py'
|
||||
).read_bytes()
|
||||
files['verify_package.py'] = (
|
||||
root / 'scripts/verify_astrbot.py'
|
||||
@@ -40,9 +63,14 @@ def build(root, output):
|
||||
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
||||
files['metadata.yaml'] = (json.dumps({
|
||||
'name': ROOT_NAME, 'display_name': '农场',
|
||||
'desc': '种植、收获、仓库、交易与农场图片,复用Farm共享核心。',
|
||||
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
|
||||
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
|
||||
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
|
||||
'version': version, 'author': 'Shu-Ying',
|
||||
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
|
||||
'social_link': 'https://github.com/Shu-Ying',
|
||||
'tags': ['农场', '种植', '小游戏'],
|
||||
'support_platforms': ['aiocqhttp'],
|
||||
}, ensure_ascii=False, indent=2) + '\n').encode()
|
||||
for name, content in files.items():
|
||||
if name.endswith('.py'):
|
||||
|
||||
@@ -26,14 +26,14 @@ def build(root, output):
|
||||
files[prefix + name] = (integration / name).read_bytes()
|
||||
# 同一运行时源,避免两个适配器的业务命令与渲染行为发生分叉。
|
||||
files[prefix + "runtime.py"] = (
|
||||
root / "integrations/zhenxun/runtime.py"
|
||||
root / "integrations/shared/runtime.py"
|
||||
).read_bytes()
|
||||
files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
|
||||
for package in ("farm_core", "farm_render"):
|
||||
source = root / "src" / package
|
||||
selected = list(source.glob("*.py")) + [
|
||||
path for path in (source / "data").iterdir()
|
||||
if path.suffix in (".json", ".zip", ".md")
|
||||
if path.suffix in (".json", ".md")
|
||||
]
|
||||
for path in selected:
|
||||
if path.is_symlink():
|
||||
@@ -47,8 +47,9 @@ def build(root, output):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
files[f"{prefix}_vendor/{package}/{relative}"] = content
|
||||
for name, source in {
|
||||
"LICENSE": "LICENSE", "README.md": "docs/12-nonebot.md",
|
||||
"LICENSE": "LICENSE", "README.md": "readme/nonebot.md",
|
||||
"version.txt": "version.txt",
|
||||
"logo.png": "logo.png",
|
||||
"images/farm-preview.png": "images/farm-preview.png",
|
||||
"pyproject.toml": "release/nonebot/pyproject.toml",
|
||||
"MANIFEST.in": "release/nonebot/MANIFEST.in",
|
||||
|
||||
@@ -46,17 +46,20 @@ def build(root, output):
|
||||
files = {}
|
||||
integration = root / "integrations/zhenxun"
|
||||
for name in (
|
||||
"__init__.py", "runtime.py", "commands.py", "data_paths.py",
|
||||
"__init__.py", "commands.py", "data_paths.py",
|
||||
"requirements.txt", "config_migration.py",
|
||||
):
|
||||
files[name] = (integration / name).read_bytes()
|
||||
files["runtime.py"] = (
|
||||
root / "integrations/shared/runtime.py"
|
||||
).read_bytes()
|
||||
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
|
||||
for package in ("farm_core", "farm_render"):
|
||||
source = root / "src" / package
|
||||
selected = list(source.glob("*.py")) + [
|
||||
p for p in (source / "data").iterdir() if p.suffix in (
|
||||
".json", ".zip", ".md",
|
||||
)
|
||||
) and p.name != 'assets.zip'
|
||||
]
|
||||
for path in selected:
|
||||
if path.is_symlink():
|
||||
@@ -71,8 +74,9 @@ def build(root, output):
|
||||
files[f"_vendor/{package}/{relative}"] = content
|
||||
for name, source in {
|
||||
"LICENSE": root / "LICENSE",
|
||||
"README.md": root / "docs/11-zhenxun-preview.md",
|
||||
"README.md": root / "readme/zhenxun.md",
|
||||
"version.txt": root / "version.txt",
|
||||
"logo.png": root / "logo.png",
|
||||
"images/farm-preview.png": root / "images/farm-preview.png",
|
||||
"verify_package.py": root / "scripts/verify_zhenxun.py",
|
||||
".github/workflows/verify.yml": root / "release/zhenxun/verify.yml",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""离线核对发布清单、开发素材及布局;不访问远端。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
|
||||
def check(root):
|
||||
data = Path(root) / 'src/farm_render/data'
|
||||
manifest = json.loads((data / 'asset-manifest.json').read_bytes())
|
||||
metadata = json.loads((data / 'metadata.json').read_bytes())
|
||||
bundle = data / 'assets.zip'
|
||||
if (manifest['schema'] != 1
|
||||
or bundle.stat().st_size != manifest['size_bytes']
|
||||
or hashlib.sha256(bundle.read_bytes()).hexdigest()
|
||||
!= manifest['sha256']):
|
||||
raise ValueError('Asset manifest and development ZIP differ')
|
||||
with ZipFile(bundle) as archive:
|
||||
if set(archive.namelist()) != set(metadata['images']):
|
||||
raise ValueError('Asset manifest and layout differ')
|
||||
for source, prefix in (
|
||||
('github', 'https://raw.githubusercontent.com/Shu-Ying/'
|
||||
'farm_service/main/assets/assets.zip'),
|
||||
('gitea', 'https://gitea.diuse.work/Diuse/farm_service/'
|
||||
'raw/branch/main/assets/assets.zip'),
|
||||
):
|
||||
if manifest['sources'][source] != prefix:
|
||||
raise ValueError('Asset source must use service repository')
|
||||
print('Verified external asset manifest and development ZIP')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
check(Path(__file__).resolve().parents[1])
|
||||
@@ -1,4 +1,4 @@
|
||||
"""检查真寻与 NoneBot 工作流的 YAML 和发布边界。"""
|
||||
"""检查真寻、NoneBot 与 AstrBot 工作流的 YAML 和发布边界。"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
@@ -49,7 +49,10 @@ def main():
|
||||
nonebot = yaml.load((
|
||||
root / ".github/workflows/nonebot-preview.yml"
|
||||
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||
for workflow in (source, nonebot):
|
||||
astrbot = yaml.load((
|
||||
root / ".github/workflows/astrbot-preview.yml"
|
||||
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||
for workflow in (source, nonebot, astrbot):
|
||||
install_commands = [
|
||||
step.get("run", "").split()
|
||||
for step in workflow["jobs"]["test"]["steps"]
|
||||
@@ -98,6 +101,21 @@ def main():
|
||||
steps = publishing["jobs"]["publish"]["steps"]
|
||||
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
|
||||
raise ValueError("Keep the existing PyPI secret")
|
||||
trigger = astrbot["on"]
|
||||
sync = astrbot["jobs"]["sync-preview"]
|
||||
if (astrbot["permissions"] != {"contents": "read"}
|
||||
or set(trigger) != set(nonebot["on"])
|
||||
or trigger["push"]["branches"] != ["main"]
|
||||
or set(trigger["workflow_dispatch"]["inputs"]) != {"publish"}
|
||||
or trigger["workflow_dispatch"]["inputs"]["publish"]["default"]
|
||||
!= "false"
|
||||
or sync["environment"] != "zhenxun-preview"
|
||||
or " ".join(sync["if"].split()) != expected_condition
|
||||
or astrbot["jobs"]["build"]["needs"] != "test"
|
||||
or sync["needs"] != ["test", "build"]
|
||||
or sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
|
||||
!= "${{ secrets.FARM_RELEASE_TOKEN }}"):
|
||||
raise ValueError("AstrBot workflow publication boundary differs")
|
||||
print("Workflow YAML and publication guards checked")
|
||||
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ def fit_grid(source, target):
|
||||
]) for point, dest in zip(design, target)]
|
||||
if max(errors) > 10:
|
||||
raise ValueError('Local grid no longer matches reference geometry')
|
||||
return [row[:2] for row in affine], {
|
||||
'rms': math.sqrt(sum(e*e for e in errors)/len(errors)),
|
||||
'maximum': max(errors), 'points': len(errors),
|
||||
# Python 版本的浮点求和及平台数学库可能产生末位差异。
|
||||
# 固定导出精度,避免无意义的差异使 CI 字节校验失败。
|
||||
return [[round(value, 8) for value in row[:2]] for row in affine], {
|
||||
'rms': round(math.sqrt(sum(e*e for e in errors)/len(errors)), 8),
|
||||
'maximum': round(max(errors), 8), 'points': len(errors),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""固定同步 AstrBot main;默认离线 dry-run,普通提交保留历史。"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from verify_astrbot import read_package, verify_files
|
||||
from release_metadata import commit_message
|
||||
|
||||
REPOSITORY = "Shu-Ying/astrbot_plugin_farm"
|
||||
BRANCH = "main"
|
||||
|
||||
|
||||
def github(method, path, payload, token, allow_missing=False):
|
||||
url = f"https://api.github.com/repos/{REPOSITORY}"
|
||||
if path:
|
||||
url += f"/{path}"
|
||||
request = Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "farm-astrbot-publisher"}, method=method,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
if error.code == 404 and method == "GET" and allow_missing:
|
||||
return None
|
||||
raise RuntimeError(
|
||||
f"GitHub request failed: {method} {url}: HTTP {error.code}",
|
||||
) from None
|
||||
|
||||
|
||||
def publish(package, confirmed=False, request=None):
|
||||
files = read_package(package)
|
||||
manifest = verify_files(files)
|
||||
message = commit_message(manifest)
|
||||
result = {"repository": REPOSITORY, "branch": BRANCH,
|
||||
"version": manifest["version"], "message": message,
|
||||
"published": False}
|
||||
if not confirmed:
|
||||
return result
|
||||
if manifest["source"]["dirty"]:
|
||||
raise ValueError("Publish requires a clean source checkout")
|
||||
token = os.environ.get("FARM_RELEASE_TOKEN")
|
||||
if not token:
|
||||
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
|
||||
call = request or (lambda method, path, data: github(
|
||||
method, path, data, token,
|
||||
allow_missing=path.startswith(("git/ref/heads/", "contents/")),
|
||||
))
|
||||
if not call("GET", "", None):
|
||||
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
|
||||
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
|
||||
parent = ref["object"]["sha"] if ref else None
|
||||
if parent:
|
||||
owned = call("GET", f"contents/FARM-MANIFEST.json?ref={BRANCH}", None)
|
||||
if owned is not None:
|
||||
if owned.get("encoding") != "base64":
|
||||
raise ValueError("Existing Farm manifest encoding is invalid")
|
||||
previous = json.loads(base64.b64decode(owned["content"]))
|
||||
if (previous.get("target") != "astrbot"
|
||||
or previous.get("plugin") != manifest["plugin"]):
|
||||
raise ValueError("Existing branch ownership differs")
|
||||
tree = []
|
||||
for name, content in sorted(files.items()):
|
||||
blob = call("POST", "git/blobs", {
|
||||
"content": base64.b64encode(content).decode(),
|
||||
"encoding": "base64",
|
||||
})
|
||||
tree.append({"path": name, "mode": "100644", "type": "blob",
|
||||
"sha": blob["sha"]})
|
||||
generated = call("POST", "git/trees", {"tree": tree})
|
||||
commit = call("POST", "git/commits", {
|
||||
"message": message,
|
||||
"tree": generated["sha"], "parents": [parent] if parent else [],
|
||||
})
|
||||
if parent:
|
||||
current = call("GET", f"git/ref/heads/{BRANCH}", None)
|
||||
if not current or current["object"]["sha"] != parent:
|
||||
raise RuntimeError("Target branch changed; retry explicitly")
|
||||
call("PATCH", f"git/refs/heads/{BRANCH}", {
|
||||
"sha": commit["sha"], "force": False,
|
||||
})
|
||||
else:
|
||||
call("POST", "git/refs", {
|
||||
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
|
||||
})
|
||||
return {**result, "published": True, "commit": commit["sha"]}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package")
|
||||
parser.add_argument("--publish", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(publish(args.package, args.publish), ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,6 +11,7 @@ from zipfile import ZipFile
|
||||
ROOT_NAME = 'astrbot_plugin_farm'
|
||||
MANIFEST = 'FARM-MANIFEST.json'
|
||||
MAX_BYTES = 100 * 1024 * 1024
|
||||
MAX_ARCHIVE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def safe_path(name):
|
||||
@@ -37,10 +38,14 @@ def verify_files(files):
|
||||
if hashlib.sha256(files[name]).hexdigest() != digest:
|
||||
raise ValueError('Package checksum mismatch')
|
||||
required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json',
|
||||
'runtime.py',
|
||||
'runtime.py', 'README.md', 'CHANGELOG.md', 'logo.png',
|
||||
'logo-readme.png',
|
||||
'images/farm-preview.png',
|
||||
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
|
||||
'_vendor/farm_core/__init__.py',
|
||||
'_vendor/farm_render/data/assets.zip'}
|
||||
'_vendor/farm_render/data/asset-manifest.json'}
|
||||
if any(name.endswith('/assets.zip') for name in files):
|
||||
raise ValueError('Resource ZIP must not be in plugin repository')
|
||||
if not required <= set(files):
|
||||
raise ValueError('Incomplete plugin')
|
||||
metadata = json.loads(files['metadata.yaml'])
|
||||
@@ -70,15 +75,19 @@ def read_package(path):
|
||||
return files
|
||||
|
||||
|
||||
def verify_package(path):
|
||||
def verify_package(path, marketplace=False):
|
||||
if marketplace and Path(path).stat().st_size > MAX_ARCHIVE_BYTES:
|
||||
raise ValueError('Marketplace ZIP exceeds 16 MiB')
|
||||
return verify_files(read_package(path))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('package', type=Path)
|
||||
parser.add_argument('--marketplace', action='store_true',
|
||||
help='Also enforce the marketplace 16 MiB limit')
|
||||
args = parser.parse_args()
|
||||
manifest = verify_package(args.package)
|
||||
manifest = verify_package(args.package, marketplace=args.marketplace)
|
||||
print(f"Verified astrbot {manifest['version']}: "
|
||||
f"{len(manifest['files'])} files")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ def main():
|
||||
[sys.executable, "scripts/check_baseline.py"],
|
||||
[sys.executable, "scripts/export_catalogue.py", "--check"],
|
||||
[sys.executable, "scripts/export_render_assets.py", "--check"],
|
||||
[sys.executable, 'scripts/check_asset_manifest.py'],
|
||||
[
|
||||
sys.executable, "-m", "unittest", "discover",
|
||||
"-s", "tests", "-p", "test_*.py", "-v",
|
||||
|
||||
@@ -41,11 +41,13 @@ def verify_files(files):
|
||||
"nonebot_plugin_farm/__init__.py", "nonebot_plugin_farm/config.py",
|
||||
"nonebot_plugin_farm/commands.py", "nonebot_plugin_farm/runtime.py",
|
||||
"nonebot_plugin_farm/_vendor/farm_core/__init__.py",
|
||||
"nonebot_plugin_farm/_vendor/farm_render/data/assets.zip",
|
||||
"nonebot_plugin_farm/_vendor/farm_render/data/asset-manifest.json",
|
||||
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
|
||||
}
|
||||
if not required <= set(files):
|
||||
raise ValueError("Incomplete plugin repository")
|
||||
if any(name.endswith('/assets.zip') for name in files):
|
||||
raise ValueError('Resource ZIP must not be in plugin repository')
|
||||
if files["version.txt"].decode().strip() != manifest["version"]:
|
||||
raise ValueError("Version file differs from manifest")
|
||||
return manifest
|
||||
|
||||
@@ -38,7 +38,9 @@ def verify_files(files):
|
||||
raise ValueError("Package checksum mismatch")
|
||||
required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt",
|
||||
"LICENSE", "_vendor/farm_core/__init__.py",
|
||||
"_vendor/farm_render/data/assets.zip"}
|
||||
"_vendor/farm_render/data/asset-manifest.json"}
|
||||
if any(name.endswith('/assets.zip') for name in files):
|
||||
raise ValueError('Resource ZIP must not be in plugin repository')
|
||||
if not required <= set(files):
|
||||
raise ValueError("Incomplete plugin")
|
||||
return manifest
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""命令注册与帮助共用的声明;平台接入不维护业务帮助副本。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandSpec:
|
||||
name: str # 注册的中文主命令。
|
||||
action: str # 共享运行时动作。
|
||||
group: str # 帮助中的分类。
|
||||
usage: str # 完整调用语法。
|
||||
description: str # 用户可读的简短说明。
|
||||
aliases: tuple[str, ...] = () # 同一动作的中文别名。
|
||||
platforms: tuple[str, ...] = () # 空表示所有框架,否则仅指定框架。
|
||||
extra: tuple[tuple[str, str], ...] = () # 同命令的附加用法及说明。
|
||||
|
||||
|
||||
COMMAND_SPECS = (
|
||||
CommandSpec('开通农场', 'register', '开通与查看', '开通农场 [名称]',
|
||||
'首次开通获得6块土地,可指定农场名称。'),
|
||||
CommandSpec('我的农场', 'farm', '开通与查看', '我的农场 [高清]',
|
||||
'查看农场全景;仅我的农场支持高清。'),
|
||||
CommandSpec('农场详述', 'details', '开通与查看', '农场详述 [页码]',
|
||||
'查看各地块的作物、生长和虫害信息。'),
|
||||
CommandSpec('地块编号', 'plot_ids', '开通与查看', '地块编号',
|
||||
'查看已开垦土地编号及待开垦位置。'),
|
||||
CommandSpec('我的农场币', 'inventory', '开通与查看', '我的农场币',
|
||||
'查看农场币、点券等资产。'),
|
||||
CommandSpec('我的点券', 'inventory', '开通与查看', '我的点券',
|
||||
'查看点券等资产。'),
|
||||
CommandSpec('种子商店', 'shop', '种子与种植', '种子商店 [页码]',
|
||||
'查看种子价格、等级要求与作物编号。'),
|
||||
CommandSpec('购买种子', 'buy', '种子与种植', '购买种子 作物 数量',
|
||||
'购买指定数量的种子。'),
|
||||
CommandSpec('播种', 'sow', '种子与种植', '播种 作物 [数量]',
|
||||
'使用库存种子播种到空地。'),
|
||||
CommandSpec('收获', 'harvest', '田间管理与扩建', '收获',
|
||||
'收获成熟作物,获得作物与经验。'),
|
||||
CommandSpec('农场除虫', 'clear_pests', '田间管理与扩建', '农场除虫',
|
||||
'清除虫害;生长期间及时除虫。', aliases=('除虫',)),
|
||||
CommandSpec('铲除', 'eradicate', '田间管理与扩建', '铲除',
|
||||
'清理枯萎作物,腾出土地。'),
|
||||
CommandSpec('开垦', 'reclaim', '田间管理与扩建', '开垦',
|
||||
'满足等级和费用后,新增1块普通空地。',
|
||||
extra=(('开垦 条件', '查询下一块土地的等级及费用。'),)),
|
||||
CommandSpec('我的种子', 'seeds', '仓库与出售',
|
||||
'我的种子 [页码] [文字]',
|
||||
'查看种子库存;默认图片,追加「文字」切换。'),
|
||||
CommandSpec('我的作物', 'crops', '仓库与出售',
|
||||
'我的作物 [页码] [文字]',
|
||||
'查看作物库存;默认图片,追加「文字」切换。'),
|
||||
CommandSpec('出售作物', 'sell', '仓库与出售',
|
||||
'出售作物 [作物] [数量]',
|
||||
'出售作物获得农场币;省略作物时出售全部未锁定作物。'),
|
||||
CommandSpec('作物加锁', 'lock', '仓库与出售', '作物加锁 作物',
|
||||
'锁定作物,出售时自动跳过。'),
|
||||
CommandSpec('作物解锁', 'unlock', '仓库与出售', '作物解锁 作物',
|
||||
'解除指定作物的锁定。'),
|
||||
CommandSpec('偷菜', 'steal', '好友互动', '偷菜 @目标',
|
||||
'每日最多5次成功偷取,北京时间零点重置。'),
|
||||
CommandSpec('农场帮助', 'help', '使用帮助', '农场帮助 [文字]',
|
||||
'查看当前平台菜单;追加「文字」查看文字版。'),
|
||||
)
|
||||
|
||||
HELP_NOTES = (
|
||||
'[参数] 表示可选;作物支持名称或 #编号,同名作物请使用编号。',
|
||||
'示例:我的种子 2 文字;购买种子 #编号 5;播种 #编号 3。',
|
||||
'仅我的农场支持高清;其他图片使用统一图片质量配置。',
|
||||
'新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。',
|
||||
'每只虫降低生长速度5%;成熟时每只虫使本季减产5%。',
|
||||
'成熟后除虫不能恢复本季减产,请在生长期间及时除虫。',
|
||||
'每播种1块地或铲除1块枯萎地获得2经验;每清除1只虫获得1经验。',
|
||||
'收获经验按作物结算;农场背景按北京时间自动切换季节。',
|
||||
'旧版数据首次使用时迁移,绑定当前机器人和平台。',
|
||||
)
|
||||
|
||||
|
||||
def command_specs(platform, specs=None):
|
||||
if platform not in ('zhenxun', 'nonebot', 'astrbot'):
|
||||
raise ValueError('Unknown farm framework')
|
||||
return tuple(spec for spec in (
|
||||
COMMAND_SPECS if specs is None else specs
|
||||
) if not spec.platforms or platform in spec.platforms)
|
||||
|
||||
|
||||
def command_map(platform, specs=None):
|
||||
result = {}
|
||||
for spec in command_specs(platform, specs):
|
||||
for name in (spec.name, *spec.aliases):
|
||||
if name in result:
|
||||
raise ValueError('Duplicate farm command')
|
||||
result[name] = spec.action
|
||||
return result
|
||||
|
||||
|
||||
def help_groups(specs):
|
||||
groups = {}
|
||||
for spec in specs:
|
||||
description = spec.description
|
||||
if spec.aliases:
|
||||
description += ' 别名:' + '、'.join(spec.aliases) + '。'
|
||||
groups.setdefault(spec.group, []).extend((
|
||||
(spec.usage, description), *spec.extra,
|
||||
))
|
||||
return tuple((title, tuple(rows)) for title, rows in groups.items())
|
||||
|
||||
|
||||
def help_text(specs):
|
||||
lines = ['农场帮助']
|
||||
for title, rows in help_groups(specs):
|
||||
lines.append('\n【' + title + '】')
|
||||
for usage, description in rows:
|
||||
lines.extend((usage, ' ' + description))
|
||||
return '\n'.join((*lines, '\n【参数与玩法】', *HELP_NOTES))
|
||||
@@ -42,11 +42,12 @@ class ByteLRU:
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024):
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024, bundle_path=None):
|
||||
data = resources.files(__package__).joinpath("data")
|
||||
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
|
||||
self.cache = ByteLRU(cache_bytes)
|
||||
self._bundle = data.joinpath("assets.zip")
|
||||
self._bundle = (bundle_path if bundle_path is not None
|
||||
else data.joinpath('assets.zip'))
|
||||
|
||||
def image(self, name, size=None):
|
||||
if name not in self.metadata["images"]:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""固定摘要的外部素材缓存;下载后校验,原子替换,不解压到磁盘。"""
|
||||
|
||||
import hashlib
|
||||
from importlib import resources
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from zipfile import ZipFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_LOCK = threading.Lock()
|
||||
SOURCES = ('Github', 'Gitea', 'farm_service')
|
||||
MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def expected_manifest():
|
||||
return json.loads(resources.files(__package__).joinpath(
|
||||
'data', 'asset-manifest.json',
|
||||
).read_bytes())
|
||||
|
||||
|
||||
def validate_options(source, service_url):
|
||||
if source not in SOURCES:
|
||||
raise ValueError('Unknown resource source')
|
||||
if service_url:
|
||||
checked_url(service_url)
|
||||
if source == 'farm_service' and not service_url:
|
||||
raise ValueError('farm_service address required')
|
||||
|
||||
|
||||
def checked_url(url):
|
||||
parsed = urlsplit(url)
|
||||
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username
|
||||
or parsed.password or parsed.query or parsed.fragment):
|
||||
raise ValueError('Resource URL requires HTTPS without credentials')
|
||||
return url
|
||||
|
||||
|
||||
def _copy_remote(url, destination, limit):
|
||||
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
|
||||
started = time.monotonic()
|
||||
with urlopen(request, timeout=15) as response:
|
||||
checked_url(response.geturl())
|
||||
length = response.headers.get('Content-Length')
|
||||
if length is not None and int(length) > limit:
|
||||
raise ValueError('Resource download exceeds limit')
|
||||
total = 0
|
||||
while True:
|
||||
chunk = response.read(min(65536, limit-total+1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > limit or time.monotonic()-started > 60:
|
||||
raise ValueError('Resource download limit or timeout')
|
||||
destination.write(chunk)
|
||||
|
||||
|
||||
def verify_bundle(path, manifest):
|
||||
path = Path(path)
|
||||
if (not path.is_file() or path.stat().st_size != manifest['size_bytes']
|
||||
or not 0 < manifest['size_bytes'] <= MAX_BYTES):
|
||||
raise ValueError('Resource size mismatch')
|
||||
digest = hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for chunk in iter(lambda: stream.read(65536), b''):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != manifest['sha256']:
|
||||
raise ValueError('Resource SHA256 mismatch')
|
||||
metadata = json.loads(resources.files(__package__).joinpath(
|
||||
'data', 'metadata.json',
|
||||
).read_bytes())
|
||||
with ZipFile(path) as archive:
|
||||
entries = archive.infolist()
|
||||
names = [item.filename for item in entries]
|
||||
if (len(names) != len(set(names))
|
||||
or set(names) != set(metadata['images'])
|
||||
or sum(item.file_size for item in entries) > 256*1024*1024
|
||||
or any(item.is_dir() or item.flag_bits & 1
|
||||
for item in entries)):
|
||||
raise ValueError('Resource contents mismatch')
|
||||
return path
|
||||
|
||||
|
||||
def ensure_assets(data_dir, source='Github', service_url=''):
|
||||
"""每次启动校验缓存;仅缺失或不匹配时访问所选源。"""
|
||||
validate_options(source, service_url)
|
||||
expected = expected_manifest()
|
||||
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
|
||||
with _LOCK:
|
||||
try:
|
||||
return verify_bundle(target, expected)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 清单绑定在插件内;远端不能通过更换清单放行不兼容的素材。
|
||||
if source == 'farm_service':
|
||||
url = (service_url.rstrip('/') + '/v1/assets/'
|
||||
+ expected['sha256'] + '/assets.zip')
|
||||
else:
|
||||
url = expected['sources'][source.lower()]
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=target.parent, suffix='.download', delete=False,
|
||||
) as stream:
|
||||
temporary = Path(stream.name)
|
||||
_copy_remote(url, stream, expected['size_bytes'])
|
||||
verify_bundle(temporary, expected)
|
||||
temporary.replace(target)
|
||||
logger.info('farm assets ready source=%s sha256=%s',
|
||||
source, expected['sha256'])
|
||||
return target
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": "farm-assets-v1",
|
||||
"sha256": "78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9",
|
||||
"size_bytes": 21264561,
|
||||
"sources": {
|
||||
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/assets/assets.zip",
|
||||
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/assets/assets.zip",
|
||||
"farm_service": "https://farm.example/v1/assets/78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9/assets.zip"
|
||||
}
|
||||
}
|
||||
@@ -6484,9 +6484,9 @@
|
||||
"contact_alpha_threshold": 32,
|
||||
"contact_band_fraction": 0.2,
|
||||
"grid_fit": {
|
||||
"maximum": 9.082568081176591,
|
||||
"maximum": 9.08256808,
|
||||
"points": 30,
|
||||
"rms": 4.178633329197882
|
||||
"rms": 4.17863333
|
||||
},
|
||||
"growth_scale": 0.75,
|
||||
"mode": "visible_contact_center",
|
||||
@@ -6494,12 +6494,12 @@
|
||||
"reference_soil_width": 153,
|
||||
"scene_transform": [
|
||||
[
|
||||
1.467423053281315,
|
||||
0.07509807118862355
|
||||
1.46742305,
|
||||
0.07509807
|
||||
],
|
||||
[
|
||||
0.02736505575131576,
|
||||
-1.4715307877291894
|
||||
0.02736506,
|
||||
-1.47153079
|
||||
]
|
||||
],
|
||||
"schema": 4,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""单张动态帮助长图:实测换行、固定字号及内容驱动高度。"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from farm_core.commands import HELP_NOTES, help_groups
|
||||
|
||||
from .canvas import Canvas, font
|
||||
from .models import RenderOptions
|
||||
|
||||
|
||||
def wrap_text(value, font_path, size, width):
|
||||
"""按实际字宽换行,不截断命令、别名或说明。"""
|
||||
lines = []
|
||||
for paragraph in value.split('\n'):
|
||||
line = ''
|
||||
for char in paragraph:
|
||||
if line and Canvas.measure_text(
|
||||
line + char, font_path, size,
|
||||
) > width:
|
||||
lines.append(line)
|
||||
line = ''
|
||||
line += char
|
||||
lines.append(line)
|
||||
return tuple(lines)
|
||||
|
||||
|
||||
def help_layout(specs, font_path):
|
||||
groups = []
|
||||
for title, rows in help_groups(specs):
|
||||
title_lines = wrap_text(title, font_path, 30, 452)
|
||||
header = 26 + len(title_lines) * 42
|
||||
prepared = []
|
||||
height = header + 18
|
||||
for usage, description in rows:
|
||||
commands = wrap_text(usage, font_path, 28, 484)
|
||||
notes = wrap_text(description, font_path, 23, 484)
|
||||
row_height = len(commands)*40 + len(notes)*34 + 30
|
||||
prepared.append((commands, notes, row_height))
|
||||
height += row_height
|
||||
groups.append((title_lines, header, tuple(prepared), height))
|
||||
# 先分配较高分类,再恢复各列原有分类顺序,避免长列旁大块留白。
|
||||
buckets = [[], []]
|
||||
heights = [0, 0]
|
||||
for index in sorted(range(len(groups)), key=lambda i: -groups[i][3]):
|
||||
column = min(range(2), key=lambda i: heights[i])
|
||||
buckets[column].append(index)
|
||||
heights[column] += groups[index][3]+24
|
||||
columns = tuple(tuple(groups[i] for i in sorted(bucket))
|
||||
for bucket in buckets)
|
||||
body_height = max(sum(g[3]+24 for g in col) for col in columns)
|
||||
tips = tuple(line for note in HELP_NOTES for line in wrap_text(
|
||||
note, font_path, 23, 1072,
|
||||
))
|
||||
return columns, body_height, tips
|
||||
|
||||
|
||||
def render_help(renderer, specs, platform, image_format='AUTO', quality=75):
|
||||
options = RenderOptions(width=1200, format=image_format, quality=quality)
|
||||
columns, body_height, tips = help_layout(specs, renderer.font_path)
|
||||
footer_y = 236 + body_height
|
||||
canvas = Canvas.new((1200, footer_y + 90 + len(tips)*35 + 40), '#f5f3e6')
|
||||
|
||||
# 使用包内现有农场背景,仅作小面积页眉,不在正文后叠加纹理。
|
||||
landscape = renderer.assets.image(
|
||||
'background/decorateIcon/decorate_default_1.jpg',
|
||||
)
|
||||
crop = landscape.crop((0, 0, landscape.width, landscape.height//3))
|
||||
crop = crop.resize((1200, 200), Image.Resampling.LANCZOS)
|
||||
canvas.paste(crop, (0, 0))
|
||||
canvas.rectangle((340, 32, 860, 160), '#915d32', radius=18)
|
||||
canvas.rectangle((348, 38, 852, 154), '#f2d69b', radius=14)
|
||||
|
||||
def text(value, x, y, size=23, color='#504a3d'):
|
||||
box = font(renderer.font_path, size).getbbox(value)
|
||||
canvas.text((x, y-box[1]), value, renderer.font_path, size, color)
|
||||
|
||||
text('农场帮助', 478, 53, 52, '#382b19')
|
||||
label = {'zhenxun': '真寻', 'nonebot': 'NoneBot', 'astrbot': 'AstrBot'}
|
||||
subtitle = f'{label[platform]} · 常用指令与玩法'
|
||||
subtitle_width = canvas.measure_text(subtitle, renderer.font_path, 23)
|
||||
text(subtitle, round((1200-subtitle_width)/2), 118)
|
||||
for column, x in zip(columns, (36, 612)):
|
||||
y = 224
|
||||
for title_lines, header, rows, height in column:
|
||||
canvas.rectangle((x, y, x+552, y+height), '#d1d7bc', radius=16)
|
||||
canvas.rectangle((x+1, y+1, x+551, y+height-1),
|
||||
'#fffdf5', radius=15)
|
||||
canvas.rectangle((x, y, x+552, y+header), '#4c7542', radius=15)
|
||||
canvas.rectangle((x, y+header-15, x+552, y+header), '#4c7542')
|
||||
# 小芽标记;不依赖 emoji 字体或生成图片内的文字。
|
||||
canvas.rectangle((x+22, y+21, x+29, y+44), '#d7e8a0', radius=3)
|
||||
canvas.rectangle((x+10, y+13, x+26, y+28), '#bad571', radius=7)
|
||||
canvas.rectangle((x+28, y+9, x+46, y+24), '#e2ecac', radius=8)
|
||||
for index, line in enumerate(title_lines):
|
||||
text(line, x+62, y+16+index*42, 30, '#ffffff')
|
||||
yy = y+header+18
|
||||
for index, (commands, notes, row_height) in enumerate(rows):
|
||||
for line in commands:
|
||||
text(line, x+28, yy, 28, '#342c20')
|
||||
text(line, x+29, yy, 28, '#342c20')
|
||||
yy += 40
|
||||
for line in notes:
|
||||
text(line, x+28, yy+2)
|
||||
yy += 34
|
||||
yy += 30
|
||||
if index+1 < len(rows):
|
||||
canvas.rectangle((x+28, yy-15, x+524, yy-14), '#e4e3d3')
|
||||
y += height+24
|
||||
canvas.rectangle((36, footer_y, 1164, canvas.image.height-36),
|
||||
'#e4ecd5', radius=16)
|
||||
text('参数与玩法提示', 64, footer_y+20, 29, '#3f6437')
|
||||
for index, line in enumerate(tips):
|
||||
text(line, 64, footer_y+70+index*35)
|
||||
return canvas.encode(options.codec(), options.quality, options.method)
|
||||
@@ -26,12 +26,13 @@ class FarmRenderer:
|
||||
def __init__(
|
||||
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
||||
result_cache_bytes=8 * 1024 * 1024,
|
||||
bundle_path=None,
|
||||
):
|
||||
self.font_path = Path(font_path).resolve()
|
||||
if not self.font_path.is_file():
|
||||
raise ValueError("Configure an existing Chinese font path")
|
||||
font(self.font_path, 16)
|
||||
self.assets = Assets(asset_cache_bytes)
|
||||
self.assets = Assets(asset_cache_bytes, bundle_path=bundle_path)
|
||||
self.results = ByteLRU(result_cache_bytes)
|
||||
|
||||
def scene(self, identity, snapshot, now):
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
from .shop import render_shop
|
||||
from .details import render_details
|
||||
from .warehouse import render_warehouse
|
||||
from .help import render_help
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,6 +148,33 @@ class RenderService:
|
||||
self.renderer.results.put(key, content, len(content))
|
||||
return content
|
||||
|
||||
async def help_menu(self, specs, platform, image_format='AUTO', quality=75):
|
||||
if self._closed:
|
||||
raise RuntimeError('Render service closed')
|
||||
key = ('help', specs, platform, image_format, quality)
|
||||
cached = self.renderer.results.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
task = self._pending.get(key)
|
||||
if task is None:
|
||||
if len(self._pending) >= self._max_pending:
|
||||
raise RuntimeError('Render queue full')
|
||||
task = asyncio.create_task(self._run_function(
|
||||
render_help, self.renderer, specs, platform,
|
||||
image_format, quality,
|
||||
))
|
||||
self._pending[key] = task
|
||||
|
||||
def finished(done):
|
||||
self._pending.pop(key, None)
|
||||
if not done.cancelled():
|
||||
done.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
content = await asyncio.shield(task)
|
||||
self.renderer.results.put(key, content, len(content))
|
||||
return content
|
||||
|
||||
async def _run_function(self, function, *args):
|
||||
started = perf_counter()
|
||||
async with self._semaphore:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from farm_render import bundle
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ZIP = ROOT / 'src/farm_render/data/assets.zip'
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.expected = bundle.expected_manifest()
|
||||
|
||||
def download(self, url, destination, limit):
|
||||
content = ZIP.read_bytes()
|
||||
self.assertEqual(limit, len(content))
|
||||
destination.write(content)
|
||||
|
||||
def test_download_deduplicates_and_cache_hash_checked_each_start(self):
|
||||
with patch.object(bundle, '_copy_remote',
|
||||
side_effect=self.download) as fetch:
|
||||
with ThreadPoolExecutor(4) as pool:
|
||||
results = list(pool.map(
|
||||
lambda _: bundle.ensure_assets(self.root), range(4),
|
||||
))
|
||||
self.assertEqual(len(set(results)), 1)
|
||||
self.assertEqual(fetch.call_count, 1)
|
||||
path = results[0]
|
||||
content = bytearray(path.read_bytes())
|
||||
content[-1] ^= 1
|
||||
path.write_bytes(content)
|
||||
bundle.ensure_assets(self.root)
|
||||
self.assertEqual(fetch.call_count, 2)
|
||||
self.assertEqual(path.read_bytes(), ZIP.read_bytes())
|
||||
|
||||
def test_gitea_and_service_use_service_repository_and_configured_base(self):
|
||||
with patch.object(bundle, '_copy_remote',
|
||||
side_effect=self.download) as fetch:
|
||||
bundle.ensure_assets(self.root / 'gitea', 'Gitea')
|
||||
self.assertEqual(fetch.call_args.args[0],
|
||||
'https://gitea.diuse.work/Diuse/farm_service/'
|
||||
'raw/branch/main/assets/assets.zip')
|
||||
bundle.ensure_assets(self.root / 'service', 'farm_service',
|
||||
'https://farm.example/prefix/')
|
||||
self.assertEqual(fetch.call_args.args[0],
|
||||
'https://farm.example/prefix/v1/assets/'
|
||||
+ self.expected['sha256'] + '/assets.zip')
|
||||
|
||||
def test_failed_download_leaves_no_partial_and_wrong_hash_is_rejected(self):
|
||||
def corrupt(url, destination, limit):
|
||||
data = bytearray(ZIP.read_bytes())
|
||||
data[-1] ^= 1
|
||||
destination.write(data)
|
||||
with patch.object(bundle, '_copy_remote', side_effect=corrupt):
|
||||
with self.assertRaisesRegex(ValueError, 'SHA256'):
|
||||
bundle.ensure_assets(self.root)
|
||||
self.assertFalse(list(self.root.rglob('assets.zip')))
|
||||
self.assertFalse(list(self.root.rglob('*.download')))
|
||||
with patch.object(bundle, '_copy_remote', side_effect=OSError()):
|
||||
with self.assertRaises(OSError):
|
||||
bundle.ensure_assets(self.root)
|
||||
self.assertFalse(list(self.root.rglob('*.download')))
|
||||
|
||||
def test_invalid_source_and_service_configuration(self):
|
||||
for source, url in [('unknown', ''), ('farm_service', ''),
|
||||
('farm_service', 'http://farm.example'),
|
||||
('Github', 'https://user:pass@farm.example'),
|
||||
('Gitea', 'https://farm.example/?token=secret')]:
|
||||
with self.subTest(source=source, url=url):
|
||||
with self.assertRaises(ValueError):
|
||||
bundle.ensure_assets(self.root, source, url)
|
||||
|
||||
def test_download_enforces_response_limit_and_https_redirect(self):
|
||||
class Response(BytesIO):
|
||||
headers = {'Content-Length': '20'}
|
||||
def geturl(self):
|
||||
return 'https://farm.example/assets.zip'
|
||||
with patch.object(bundle, 'urlopen', return_value=Response(b'x'*20)):
|
||||
with self.assertRaisesRegex(ValueError, 'limit'):
|
||||
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
||||
response = Response(b'')
|
||||
response.geturl = lambda: 'http://farm.example/a'
|
||||
with patch.object(bundle, 'urlopen', return_value=response):
|
||||
with self.assertRaises(ValueError):
|
||||
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
||||
@@ -31,8 +31,41 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
||||
version = (ROOT / 'version.txt').read_text().strip()
|
||||
self.assertEqual(manifest['version'], version)
|
||||
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
|
||||
metadata = json.loads(files['metadata.yaml'])
|
||||
self.assertEqual(metadata['display_name'], '农场')
|
||||
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
|
||||
self.assertTrue(metadata['short_desc'])
|
||||
self.assertEqual(files['logo.png'],
|
||||
(ROOT / 'logo-astrbot.png').read_bytes())
|
||||
self.assertEqual(files['logo-readme.png'],
|
||||
(ROOT / 'logo.png').read_bytes())
|
||||
self.assertIn('农场', metadata['tags'])
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(files['logo.png'])) as logo:
|
||||
self.assertEqual(logo.format, 'PNG')
|
||||
self.assertEqual(logo.width, logo.height)
|
||||
self.assertEqual(logo.size, (256, 256))
|
||||
self.assertIn('A', logo.getbands())
|
||||
self.assertEqual(logo.getchannel('A').getextrema()[0], 0)
|
||||
self.assertEqual(files['CHANGELOG.md'], (
|
||||
ROOT / 'integrations/astrbot/CHANGELOG.md'
|
||||
).read_bytes())
|
||||
readme = files['README.md'].decode()
|
||||
self.assertIn('/main/logo-readme.png', readme)
|
||||
self.assertIn('width="400"', readme)
|
||||
self.assertNotIn('<!-- FARM_COMMANDS -->', readme)
|
||||
self.assertIn('/农场帮助 [文字]', readme)
|
||||
self.assertIn('/开垦 条件', readme)
|
||||
self.assertIn('raw.githubusercontent.com', readme)
|
||||
self.assertIn('images/farm-preview.png', files)
|
||||
self.assertFalse(any(name.endswith('/assets.zip') for name in files))
|
||||
from verify_astrbot import verify_package
|
||||
with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1):
|
||||
with self.assertRaisesRegex(ValueError, '16 MiB'):
|
||||
verify_package(first, marketplace=True)
|
||||
self.assertEqual(files['runtime.py'], (
|
||||
ROOT / 'integrations/zhenxun/runtime.py'
|
||||
ROOT / 'integrations/shared/runtime.py'
|
||||
).read_bytes())
|
||||
for name, content in files.items():
|
||||
self.assertNotIn('.git/', name)
|
||||
@@ -93,6 +126,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
||||
plugin = main.FarmPlugin(object(), {
|
||||
'font_path': str(choose_font()), 'image_quality': 55,
|
||||
})
|
||||
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
|
||||
manifest = json.loads((ROOT /
|
||||
'src/farm_render/data/asset-manifest.json').read_bytes())
|
||||
cached = (plugin.runtime.data_dir / 'assets'
|
||||
/ manifest['sha256'] / 'assets.zip')
|
||||
cached.parent.mkdir(parents=True, exist_ok=True)
|
||||
cached.write_bytes((ROOT /
|
||||
'src/farm_render/data/assets.zip').read_bytes())
|
||||
await plugin.initialize()
|
||||
self.assertEqual(set(commands), set(main.COMMANDS))
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ spec.loader.exec_module(migration)
|
||||
|
||||
|
||||
class ConfigMigrationTests(unittest.TestCase):
|
||||
def test_zhenxun_registers_global_quality_in_plugin_metadata(self):
|
||||
def test_zhenxun_registers_quality_and_resource_configuration(self):
|
||||
plugin = ModuleType('nonebot.plugin')
|
||||
plugin.PluginMetadata = lambda **values: SimpleNamespace(**values)
|
||||
utils = ModuleType('zhenxun.configs.utils')
|
||||
@@ -42,11 +42,20 @@ class ConfigMigrationTests(unittest.TestCase):
|
||||
exec(compile(source.read_text(encoding='utf-8'), str(source),
|
||||
'exec'), namespace)
|
||||
configs = namespace['__plugin_meta__'].extra['configs']
|
||||
self.assertEqual(len(configs), 1)
|
||||
self.assertEqual(configs[0].key, '农场图片质量')
|
||||
self.assertEqual(configs[0].value, 75)
|
||||
self.assertEqual(configs[0].default_value, 75)
|
||||
self.assertIs(configs[0].type, int)
|
||||
self.assertEqual(len(configs), 3)
|
||||
by_key = {config.key: config for config in configs}
|
||||
self.assertEqual(set(by_key), {
|
||||
'农场图片质量', '资源获取地址', 'farm_service 地址',
|
||||
})
|
||||
for key, expected, value_type in (
|
||||
('农场图片质量', 75, int),
|
||||
('资源获取地址', 'Github', str),
|
||||
('farm_service 地址', '', str),
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(by_key[key].value, expected)
|
||||
self.assertEqual(by_key[key].default_value, expected)
|
||||
self.assertIs(by_key[key].type, value_type)
|
||||
|
||||
@unittest.skipUnless(importlib.util.find_spec('pydantic'),
|
||||
'NoneBot host config requires Pydantic')
|
||||
@@ -67,6 +76,21 @@ class ConfigMigrationTests(unittest.TestCase):
|
||||
spec.loader.exec_module(module)
|
||||
config = module.Config
|
||||
self.assertEqual(config().farm_image_quality, 75)
|
||||
self.assertEqual(config().farm_resource_source, 'Github')
|
||||
self.assertEqual(config().farm_service_url, '')
|
||||
for source in ('Github', 'Gitea', 'farm_service'):
|
||||
selected = config(
|
||||
farm_resource_source=source,
|
||||
farm_service_url='https://farm.example/service',
|
||||
)
|
||||
self.assertEqual(selected.farm_resource_source, source)
|
||||
self.assertEqual(selected.farm_service_url,
|
||||
'https://farm.example/service')
|
||||
for source in ('github', 'unknown', '', None):
|
||||
with self.subTest(source=source), self.assertRaises(
|
||||
library.ValidationError,
|
||||
):
|
||||
config(farm_resource_source=source)
|
||||
for value in (1, 95):
|
||||
actual = config(farm_image_quality=value).farm_image_quality
|
||||
self.assertEqual(actual, value)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from farm_core.commands import (
|
||||
COMMAND_SPECS, CommandSpec, command_map, command_specs, help_groups,
|
||||
help_text,
|
||||
)
|
||||
from farm_render import FarmRenderer, RenderService
|
||||
from farm_render.help import help_layout, render_help, wrap_text
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from ci_font import choose_font
|
||||
|
||||
|
||||
class HelpMenuTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.renderer = FarmRenderer(choose_font())
|
||||
|
||||
def test_registration_and_help_share_platform_definitions(self):
|
||||
exchange = CommandSpec(
|
||||
'购买农场币', 'fixture_exchange', '金币兑换',
|
||||
'购买农场币 数量', '仅用于测试已接入的平台命令。',
|
||||
platforms=('zhenxun',),
|
||||
)
|
||||
specs = (*COMMAND_SPECS, exchange)
|
||||
for platform in ('zhenxun', 'nonebot', 'astrbot'):
|
||||
selected = command_specs(platform, specs)
|
||||
mapping = command_map(platform, specs)
|
||||
self.assertEqual('购买农场币' in mapping, platform == 'zhenxun')
|
||||
self.assertEqual('金币兑换' in help_text(selected),
|
||||
platform == 'zhenxun')
|
||||
for spec in selected:
|
||||
self.assertEqual(mapping[spec.name], spec.action)
|
||||
self.assertIn(spec.usage, help_text(selected))
|
||||
self.assertEqual(mapping['除虫'], mapping['农场除虫'])
|
||||
with self.assertRaises(ValueError):
|
||||
command_map('zhenxun', (*specs, exchange))
|
||||
|
||||
def test_long_future_commands_wrap_without_loss_and_grow(self):
|
||||
specs = command_specs('astrbot')
|
||||
long = '很长的新增指令参数与说明' * 8
|
||||
lines = wrap_text(long, self.renderer.font_path, 28, 484)
|
||||
self.assertEqual(''.join(lines), long)
|
||||
from farm_render.canvas import Canvas
|
||||
self.assertTrue(all(Canvas.measure_text(
|
||||
line, self.renderer.font_path, 28,
|
||||
) <= 484 for line in lines))
|
||||
extra = tuple(CommandSpec(
|
||||
f'新增{i}', f'action{i}', '新增功能', long, long,
|
||||
) for i in range(10))
|
||||
before = help_layout(specs, self.renderer.font_path)[1]
|
||||
after = help_layout((*specs, *extra), self.renderer.font_path)[1]
|
||||
self.assertGreater(after, before)
|
||||
grouped = help_groups((*specs, *extra))
|
||||
self.assertEqual(grouped[-1][0], '新增功能')
|
||||
|
||||
async def test_image_codec_quality_cache_and_concurrent_dedup(self):
|
||||
specs = command_specs('astrbot')
|
||||
service = RenderService(self.renderer)
|
||||
try:
|
||||
with patch('farm_render.service.render_help',
|
||||
wraps=render_help) as fn:
|
||||
results = await asyncio.gather(*(
|
||||
service.help_menu(specs, 'astrbot', 'JPEG', 75)
|
||||
for _ in range(4)
|
||||
))
|
||||
self.assertEqual(fn.call_count, 1)
|
||||
self.assertTrue(all(r == results[0] for r in results))
|
||||
self.assertEqual(await service.help_menu(
|
||||
specs, 'astrbot', 'JPEG', 75,
|
||||
), results[0])
|
||||
self.assertEqual(fn.call_count, 1)
|
||||
changed = (*specs, replace(specs[-1], name='扩展帮助',
|
||||
usage='扩展帮助'))
|
||||
await service.help_menu(changed, 'astrbot', 'JPEG', 75)
|
||||
await service.help_menu(specs, 'nonebot', 'JPEG', 75)
|
||||
await service.help_menu(specs, 'astrbot', 'JPEG', 90)
|
||||
self.assertEqual(fn.call_count, 4)
|
||||
with Image.open(BytesIO(results[0])) as image:
|
||||
self.assertEqual(image.width, 1200)
|
||||
self.assertGreater(image.height, 1200)
|
||||
self.assertEqual(image.format, 'JPEG')
|
||||
png = await service.help_menu(specs, 'astrbot', 'PNG', 75)
|
||||
with Image.open(BytesIO(png)) as image:
|
||||
self.assertEqual(image.format, 'PNG')
|
||||
finally:
|
||||
await service.close()
|
||||
with self.assertRaises(RuntimeError):
|
||||
await service.help_menu(specs, 'astrbot')
|
||||
@@ -26,15 +26,17 @@ class NoneBotPackageTests(unittest.TestCase):
|
||||
other = build(ROOT, self.root / "second")
|
||||
self.assertEqual(self.bundle.read_bytes(), other.read_bytes())
|
||||
self.assertEqual(self.files["nonebot_plugin_farm/runtime.py"], (
|
||||
ROOT / "integrations/zhenxun/runtime.py"
|
||||
ROOT / "integrations/shared/runtime.py"
|
||||
).read_bytes())
|
||||
for suffix in ("farm_core/data/plants.json",
|
||||
"farm_render/data/assets.zip"):
|
||||
"farm_render/data/asset-manifest.json"):
|
||||
self.assertTrue(any(name.endswith(suffix) for name in self.files))
|
||||
self.assertEqual(verify_files(self.files)["target"], "nonebot")
|
||||
self.assertIn("pyproject.toml", self.files)
|
||||
self.assertIn(".github/workflows/publish.yaml", self.files)
|
||||
self.assertFalse(any("legacy/" in name for name in self.files))
|
||||
self.assertFalse(any(name.endswith('/assets.zip')
|
||||
for name in self.files))
|
||||
with self.assertRaises(FileExistsError):
|
||||
build(ROOT, self.root / "built")
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ class ZhenxunPackageTests(unittest.TestCase):
|
||||
self.assertIn("dirty", manifest["source"])
|
||||
with ZipFile(self.bundle) as archive:
|
||||
paths = archive.namelist()
|
||||
self.assertTrue(any(p.endswith("assets.zip") for p in paths))
|
||||
self.assertFalse(any(p.endswith("assets.zip") for p in paths))
|
||||
self.assertTrue(any(p.endswith('asset-manifest.json')
|
||||
for p in paths))
|
||||
self.assertFalse(any("astrbot" in p or "legacy/" in p
|
||||
for p in paths))
|
||||
source = archive.read(next(
|
||||
|
||||
@@ -28,6 +28,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
sys.modules[self.package] = module
|
||||
self.module = importlib.import_module(self.package + ".runtime")
|
||||
self.runtime = self.module.Runtime(self.root / "data", choose_font())
|
||||
self.seed_assets(self.runtime)
|
||||
await self.runtime.start()
|
||||
self.context = dict(bot_id="bot", platform="OneBot V11", user_id="1",
|
||||
message_id="registration", display_name="离线玩家")
|
||||
@@ -39,6 +40,15 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
del sys.modules[name]
|
||||
self.temporary.cleanup()
|
||||
|
||||
def seed_assets(self, runtime):
|
||||
bundle = importlib.import_module(
|
||||
self.package + '._vendor.farm_render.bundle',
|
||||
)
|
||||
target = (runtime.data_dir / 'assets'
|
||||
/ bundle.expected_manifest()['sha256'] / 'assets.zip')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / 'src/farm_render/data/assets.zip', target)
|
||||
|
||||
async def call(self, action, arguments="", message="event", **changes):
|
||||
return await self.runtime.execute(
|
||||
action, arguments,
|
||||
@@ -50,7 +60,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
from PIL import Image
|
||||
from unittest.mock import AsyncMock
|
||||
self.assertEqual(self.module.COMMANDS['地块编号'], 'plot_ids')
|
||||
self.assertIn('地块编号', await self.call('help'))
|
||||
self.assertIn('地块编号', await self.call('help', '文字'))
|
||||
first = await self.call('register')
|
||||
self.assertIn('6块土地', first)
|
||||
self.assertIn('地块编号', first)
|
||||
@@ -71,9 +81,47 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn('参数有误', await self.call('plot_ids', '1'))
|
||||
self.assertIn('开通农场', await self.call('plot_ids', user_id='other'))
|
||||
|
||||
async def test_dynamic_help_image_text_fallback_and_no_player_access(self):
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from unittest.mock import AsyncMock
|
||||
with patch.object(self.runtime.store, 'migrate_legacy',
|
||||
new=AsyncMock()) as migrate:
|
||||
image = await self.call('help', message=None)
|
||||
migrate.assert_not_awaited()
|
||||
with Image.open(BytesIO(image)) as decoded:
|
||||
self.assertEqual(decoded.width, 1200)
|
||||
text = await self.call('help', '文字', message=None)
|
||||
for command in self.module.COMMANDS:
|
||||
self.assertIn(command, text)
|
||||
self.assertNotIn('购买农场币', text)
|
||||
self.assertIn('仅我的农场支持高清',
|
||||
await self.call('help', '高清'))
|
||||
self.assertIn('参数有误', await self.call('help', '2'))
|
||||
with patch.object(self.runtime.rendering, 'help_menu',
|
||||
new=AsyncMock(side_effect=OSError('fixture'))):
|
||||
self.assertEqual(await self.call('help'), text)
|
||||
rendering = self.runtime.rendering
|
||||
self.runtime.rendering = None
|
||||
try:
|
||||
self.assertEqual(await self.call('help'), text)
|
||||
finally:
|
||||
self.runtime.rendering = rendering
|
||||
|
||||
async def test_help_uses_live_global_quality(self):
|
||||
from unittest.mock import AsyncMock
|
||||
with patch.object(self.runtime.rendering, 'help_menu',
|
||||
new=AsyncMock(return_value=b'image')) as render:
|
||||
self.runtime.image_quality_provider = lambda: 91
|
||||
self.assertEqual(await self.call('help'), b'image')
|
||||
self.assertEqual(render.await_args.args[-1], 91)
|
||||
self.runtime.image_quality_provider = lambda: False
|
||||
self.assertIn('图片质量配置无效', await self.call('help'))
|
||||
self.assertIsInstance(await self.call('help', '文字'), str)
|
||||
|
||||
async def test_reclaim_conditions_charge_replay_and_arguments(self):
|
||||
self.assertEqual(self.module.COMMANDS['开垦'], 'reclaim')
|
||||
self.assertIn('开垦 条件', await self.call('help'))
|
||||
self.assertIn('开垦 条件', await self.call('help', '文字'))
|
||||
await self.call('register')
|
||||
who = self.runtime_identity()
|
||||
asset = importlib.import_module(
|
||||
@@ -226,7 +274,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
))
|
||||
for action in ('seeds', 'crops'):
|
||||
self.assertIsInstance(await self.call(action, '1 文字'), str)
|
||||
help_text = await self.call('help')
|
||||
help_text = await self.call('help', '文字')
|
||||
self.assertIn('仅我的农场支持高清', help_text)
|
||||
self.assertNotIn('地块编号 [高清]', help_text)
|
||||
self.assertNotIn('[高清/文字]', help_text)
|
||||
@@ -328,7 +376,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_pest_command_clears_pests_and_preserves_growth(self):
|
||||
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
|
||||
help_text = await self.call("help")
|
||||
help_text = await self.call("help", "文字")
|
||||
self.assertIn("每只虫", help_text)
|
||||
self.assertIn("间隔至少2小时", help_text)
|
||||
self.runtime.service.clock = lambda: 1000
|
||||
@@ -414,6 +462,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
other = self.module.Runtime(
|
||||
self.root / "without-font", self.root / "missing.ttf",
|
||||
)
|
||||
self.seed_assets(other)
|
||||
await other.start()
|
||||
try:
|
||||
response = await other.execute("farm", "", **self.context)
|
||||
@@ -427,6 +476,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
|
||||
shutil.copyfile(choose_font(), fonts / "msyh.ttf")
|
||||
other = self.module.Runtime(self.root / "default-font", font_dir=fonts)
|
||||
self.seed_assets(other)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertIsNotNone(other.rendering)
|
||||
@@ -446,6 +496,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.root / "explicit-font", explicit,
|
||||
font_dir=self.root / "not-present",
|
||||
)
|
||||
self.seed_assets(other)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertEqual(other.rendering.renderer.font_path, explicit)
|
||||
@@ -465,6 +516,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
old = self.root / 'directories/farm_preview'
|
||||
target = old.with_name('farm_db')
|
||||
previous = self.module.Runtime(old, choose_font())
|
||||
self.seed_assets(previous)
|
||||
await previous.start()
|
||||
try:
|
||||
await previous.execute('register', '旧农场', **self.context)
|
||||
@@ -474,6 +526,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
before = old_database.read_bytes()
|
||||
target.mkdir()
|
||||
current = self.module.Runtime(target, choose_font())
|
||||
self.seed_assets(current)
|
||||
await current.start()
|
||||
try:
|
||||
self.assertEqual(old_database.read_bytes(), before)
|
||||
@@ -557,12 +610,16 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
platform.PlatformUtils = SimpleNamespace()
|
||||
config = ModuleType("zhenxun.configs.config")
|
||||
quality_values = {'value': 49}
|
||||
|
||||
def configured_value(key, default, **kwargs):
|
||||
if key != '农场图片质量':
|
||||
return default
|
||||
value = quality_values['value']
|
||||
return value if not kwargs['build_model'] else int(value)
|
||||
|
||||
config.Config = SimpleNamespace(
|
||||
get=lambda module: SimpleNamespace(
|
||||
configs={}, get=lambda key, default, **kwargs: (
|
||||
quality_values['value'] if not kwargs['build_model']
|
||||
else int(quality_values['value'])
|
||||
),
|
||||
configs={}, get=configured_value,
|
||||
),
|
||||
get_config=lambda *args, **kwargs: int(quality_values['value']),
|
||||
_simple_data={},
|
||||
@@ -584,6 +641,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
(self.root / 'farm_db').resolve())
|
||||
self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS))
|
||||
self.assertEqual(len(hooks["start"]), 1)
|
||||
self.assertEqual(imported.resource_options(), ('Github', ''))
|
||||
self.seed_assets(imported.runtime)
|
||||
await hooks["start"][0]()
|
||||
try:
|
||||
bot = SimpleNamespace(self_id="bot", adapter=SimpleNamespace(
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.0.1
|
||||
2.0.2
|
||||
Reference in New Issue
Block a user