diff --git a/.github/workflows/astrbot-preview.yml b/.github/workflows/astrbot-preview.yml
new file mode 100644
index 0000000..6e71553
--- /dev/null
+++ b/.github/workflows/astrbot-preview.yml
@@ -0,0 +1,88 @@
+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'
+ - 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
diff --git a/README.md b/README.md
index 038ec92..e3f8324 100644
--- a/README.md
+++ b/README.md
@@ -1,84 +1,52 @@
-# Farm
+
-统一维护农场核心,面向真寻、普通 NoneBot 和 AstrBot 分包发布。
-主仓库:https://github.com/Shu-Ying/main_plugin_farm.git。
+

-## 当前可用范围
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-第一批框架无关基础核心已实现,位于 src/farm_core:
+农场核心,其他Bot框架进行分包发布
-- 离线迁移旧版 202 条作物配置。
-- 注册、账户/库存/地块查询、购买、播种、收获、铲除。
-- 作物锁定/解锁、指定出售与全部出售、基础文本展示。
-- SQLite 事务、资产流水、幂等重放及并发保护。
-- 初始 500 农场币、0 点券、0 经验、3 块普通地。
-- 可选共享图片层:旧布局与素材、目标尺寸绘制、WebP/JPEG/PNG、
- 有界缓存和受控后台执行,参见 [绘图说明](docs/10-rendering.md)。
+
-主仓库不能整体放入插件目录。真寻预览包由脚本白名单生成,参见
-[真寻测试说明](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)
-## 来源与许可证
+本仓库不是主要插件仓库,安装插件请去各自对应框架的应用商店或者仓库寻找:
+
+- [真寻](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神力*
diff --git a/docs/astrbot-store-preparation.md b/docs/astrbot-store-preparation.md
new file mode 100644
index 0000000..844f256
--- /dev/null
+++ b/docs/astrbot-store-preparation.md
@@ -0,0 +1,66 @@
+# 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中的
+ ``标记,不再手写一份独立指令表。
+- 更新日志:`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。
+
+## 待确认与已知限制
+
+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
diff --git a/docs/help-menu.md b/docs/help-menu.md
new file mode 100644
index 0000000..3157842
--- /dev/null
+++ b/docs/help-menu.md
@@ -0,0 +1,35 @@
+# 动态农场帮助菜单
+
+发送 `农场帮助` 返回单张帮助长图,发送 `农场帮助 文字` 返回完整文字版。
+未开通农场也可使用;帮助查询不触发旧玩家数据迁移。
+缺少可用字体或绘图失败时自动返回文字版,并在运行日志记录原因。
+
+菜单使用包内农场背景、绿色分类栏和浅米色卡片。命令与说明采用固定字号,
+按实测字宽换行,不省略长指令;分类自动分配到两列并随内容增加图片高度。
+说明区包含参数、编号示例、虫害、经验及迁移提示。
+
+图片遵循现有农场图片格式与质量配置;帮助菜单不支持高清参数。
+同内容、平台、格式及质量的请求复用成图缓存,并发绘制复用同一任务。
+
+## 增加或调整指令
+
+唯一的中文命令定义位于 `src/farm_core/commands.py` 的 `COMMAND_SPECS`。
+每条定义包含注册名称、运行时动作、分类、语法、说明、别名、适用框架,
+同一主命令的附加语法使用 `extra`,例如 `开垦 条件`。
+
+新增实际功能时,先实现对应运行时动作,再添加定义。三个框架的中文命令注册、
+帮助图片及文字版共同读取该定义,无需再修改独立的帮助文案列表。
+插件加载时读取定义,修改后随正常的插件重新加载生效,不支持在线编辑文件热更新。
+
+`platforms` 为空时三平台共用;限制为 `('zhenxun',)` 的已接入指令仅在真寻注册
+并显示。NoneBot 和 AstrBot 不显示该分类的空卡片。框架类型由接入层明确指定,
+不使用用户可配置的 deployment 名称判断。
+
+当前新版尚未接入金币兑换,因此不会显示旧版的购买农场币或点券兑换指令。
+以后接入真寻兑换时,将真实动作及其平台限制一同加入定义即可。
+
+## 验证边界
+
+离线测试覆盖平台筛选、注册与帮助一致性、别名、超长命令换行、内容增长、
+图片格式、质量配置、缓存及并发合并,以及文字兜底和帮助查询无玩家迁移。
+离线图片和模拟宿主验证不代表已安装到真实机器人。
diff --git a/integrations/astrbot/CHANGELOG.md b/integrations/astrbot/CHANGELOG.md
new file mode 100644
index 0000000..551d3d4
--- /dev/null
+++ b/integrations/astrbot/CHANGELOG.md
@@ -0,0 +1,28 @@
+# 更新日志
+
+## 2.0.2 — 待发布
+
+### 新增
+
+- 单张动态帮助图片,按当前平台的指令生成;支持「农场帮助 文字」。
+- 种子与作物分类仓库,支持分页、作物编号及文字显示。
+- 开垦与开垦条件查询,按下一实际地块编号校验等级和费用。
+- 插件图标、市场短介绍、标签及独立更新日志。
+
+### 调整与兼容
+
+- 新账号初始6块土地;可可靠识别的旧三地账号一次性追加3块普通空地,
+ 保留已开垦成果、原作物及资产。来源不明、地块异常或超出当前布局容量
+ 时保留原状并提示管理员处理。
+- 新播种及下一季的生虫间隔至少2小时;已排定的旧事件保留原计划一次。
+- 全部农场图片遵循统一质量配置,仅「我的农场」允许追加「高清」。
+- 提供本地独立存档、交易流水、重复消息保护和失败回滚。
+- QQ/OneBot图片支持头像缓存;头像不可用时使用默认图像。
+
+### 升级前请阅读
+
+- 本条记录描述待发布代码,不代表已更新 AstrBot Cloud 或真实机器人。
+- 新版没有签到、改名、土地升级、外部金币兑换及旧远程服务功能。
+- 旧存档请先备份,再在副本中验证迁移;不要直接删除或覆盖真实玩家数据。
+- 当前重点适配 QQ/OneBot(aiocqhttp),其他平台尚未完成真实环境验证。
+- 本文件未编造旧1.x版本的发布日期和变更历史。
diff --git a/integrations/astrbot/_conf_schema.json b/integrations/astrbot/_conf_schema.json
index de0e4e9..d7713f0 100644
--- a/integrations/astrbot/_conf_schema.json
+++ b/integrations/astrbot/_conf_schema.json
@@ -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",
diff --git a/integrations/astrbot/logo-original.png b/integrations/astrbot/logo-original.png
new file mode 100644
index 0000000..3788259
Binary files /dev/null and b/integrations/astrbot/logo-original.png differ
diff --git a/integrations/astrbot/logo.png b/integrations/astrbot/logo.png
new file mode 100644
index 0000000..8b4136d
Binary files /dev/null and b/integrations/astrbot/logo.png differ
diff --git a/integrations/astrbot/main.py b/integrations/astrbot/main.py
index 57047b0..d0dfdf5 100644
--- a/integrations/astrbot/main.py
+++ b/integrations/astrbot/main.py
@@ -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):
diff --git a/integrations/nonebot/README.md b/integrations/nonebot/README.md
index 42931e4..1e8a7ab 100644
--- a/integrations/nonebot/README.md
+++ b/integrations/nonebot/README.md
@@ -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` 生成标准
diff --git a/integrations/nonebot/__init__.py b/integrations/nonebot/__init__.py
index 39607f6..88e6516 100644
--- a/integrations/nonebot/__init__.py
+++ b/integrations/nonebot/__init__.py
@@ -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(
diff --git a/integrations/nonebot/commands.py b/integrations/nonebot/commands.py
index d2e10b2..c41add2 100644
--- a/integrations/nonebot/commands.py
+++ b/integrations/nonebot/commands.py
@@ -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)
diff --git a/integrations/nonebot/config.py b/integrations/nonebot/config.py
index dedd731..66e81dd 100644
--- a/integrations/nonebot/config.py
+++ b/integrations/nonebot/config.py
@@ -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 地址,独立供资源及活动服务使用',
+ )
diff --git a/integrations/zhenxun/runtime.py b/integrations/shared/runtime.py
similarity index 84%
rename from integrations/zhenxun/runtime.py
rename to integrations/shared/runtime.py
index 5a2f255..e284d06 100644
--- a/integrations/zhenxun/runtime.py
+++ b/integrations/shared/runtime.py
@@ -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
diff --git a/integrations/zhenxun/__init__.py b/integrations/zhenxun/__init__.py
index a44ae37..40bd377 100644
--- a/integrations/zhenxun/__init__.py
+++ b/integrations/zhenxun/__init__.py
@@ -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(),
)
diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py
index 71b025f..0a8b2d8 100644
--- a/integrations/zhenxun/commands.py
+++ b/integrations/zhenxun/commands.py
@@ -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
diff --git a/logo-astrbot.png b/logo-astrbot.png
new file mode 100644
index 0000000..4b9e4e9
Binary files /dev/null and b/logo-astrbot.png differ
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..8b4136d
Binary files /dev/null and b/logo.png differ
diff --git a/readme/README.md b/readme/README.md
new file mode 100644
index 0000000..0d4bf44
--- /dev/null
+++ b/readme/README.md
@@ -0,0 +1,13 @@
+# 各平台发布说明
+
+各平台发布包的 README 统一在此维护:
+
+- `nonebot.md`:NoneBot。
+- `zhenxun.md`:真寻。
+- `astrbot.md`:AstrBot。
+
+对应工作流通过 `scripts/build_<平台>.py` 读取上述文件,
+在生成的发布仓库和安装包中写为根目录 `README.md`。
+文中的相对资源路径以生成的发布仓库根目录为准。
+
+后续发布文案请修改本目录;原位置的文档保留供历史参考。
diff --git a/readme/astrbot.md b/readme/astrbot.md
new file mode 100644
index 0000000..91595b9
--- /dev/null
+++ b/readme/astrbot.md
@@ -0,0 +1,90 @@
+
+
+
+
+# 农场 · AstrBot
+
+开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
+农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
+
+
+
+## 安装与快速开始
+
+在 AstrBot 插件市场搜索 `astrbot_plugin_farm`,或在插件管理中选择
+「从文件安装」,上传作者提供的完整插件 ZIP。不要只上传 `main.py`。
+
+安装依赖后重载插件。按你配置的唤醒前缀发送指令,下面以 `/` 为例:
+
+1. `/开通农场`:创建农场,获得初始6块土地。
+2. `/种子商店`:查看种子名称、编号与价格。
+3. `/购买种子 白萝卜 1`,然后 `/播种 白萝卜 1`。
+4. `/我的农场` 查看状态,生长期间用 `/农场除虫` 清除虫害。
+5. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
+
+发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
+发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
+
+## 指令
+
+`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
+仓库默认图片,例如 `/我的种子 2 文字`。偷菜目标必须通过消息中的 @ 指定。
+
+
+
+## 配置
+
+| 配置项 | 默认值 | 说明 |
+| --- | --- | --- |
+| `font_path` | 空 | 中文字体路径;留空尝试系统字体,无可用字体时退回文字 |
+| `data_dir` | 空 | 留空使用 `data/plugin_data/astrbot_plugin_farm`;更改不会自动搬迁存档 |
+| `image_format` | `AUTO` | `AUTO` 为 JPEG;也可选 `JPEG` 或 `PNG` |
+| `image_quality` | `75` | 整数1~95,下一次图片请求生效;PNG不受影响 |
+
+除图片质量外,配置变更后请重载插件。高清农场的图片质量不低于85。
+依赖 Pillow 和 aiohttp,由插件依赖声明安装;插件不捆绑中文字体。
+当前重点适配 QQ/OneBot(`aiocqhttp`),其他平台尚未完成真实环境验证。
+
+## 玩法与旧账号兼容
+
+- 新账号初始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`;命令表、元数据与展示文件由构建生成。
diff --git a/readme/nonebot.md b/readme/nonebot.md
new file mode 100644
index 0000000..5c6a518
--- /dev/null
+++ b/readme/nonebot.md
@@ -0,0 +1,67 @@
+# NoneBot 农场
+
+
+
+从 `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_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
+`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
+机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
+日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
+
+`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 和实际机器人消息行为
+需要在部署环境进一步验证。
diff --git a/readme/zhenxun.md b/readme/zhenxun.md
new file mode 100644
index 0000000..fd1af4f
--- /dev/null
+++ b/readme/zhenxun.md
@@ -0,0 +1,142 @@
+
+
+# 真寻农场(zhenxun_plugin_farm)
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[](https://qm.qq.com/q/7hsOD4rOw2)
+
+
+
+你是说可以种地对吧🤔?
+
+
+
+---
+## 目录
+- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
+ - [目录](#目录)
+ - [农场界面](#农场界面)
+ - [如何安装](#如何安装)
+ - [使用指令](#使用指令)
+ - [更新日志(详细):](#更新日志详细)
+ - [用户方面](#用户方面)
+ - [代码方面](#代码方面)
+ - [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
+ - [关于](#关于)
+ - [致谢](#致谢)
+ - [许可证](#许可证)
+ - [上游仓库](#上游仓库)
+
+---
+
+## 农场界面
+
+
+
+---
+
+## 如何安装
+
+方法一(推荐):在小真寻后台的插件商店下载即可
+方法二:下载源码放在小真寻`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)
diff --git a/release/nonebot/MANIFEST.in b/release/nonebot/MANIFEST.in
index 1746cb0..b1316a2 100644
--- a/release/nonebot/MANIFEST.in
+++ b/release/nonebot/MANIFEST.in
@@ -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
diff --git a/release/nonebot/pyproject.toml b/release/nonebot/pyproject.toml
index f9b69da..c00fa75 100644
--- a/release/nonebot/pyproject.toml
+++ b/release/nonebot/pyproject.toml
@@ -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",
]
diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py
index 3d904ea..60aae35 100644
--- a/scripts/build_astrbot.py
+++ b/scripts/build_astrbot.py
@@ -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 = ''
+ 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'):
diff --git a/scripts/build_nonebot.py b/scripts/build_nonebot.py
index 4a1cbce..fbe1b48 100644
--- a/scripts/build_nonebot.py
+++ b/scripts/build_nonebot.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",
diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py
index f77a141..6093546 100644
--- a/scripts/build_zhenxun.py
+++ b/scripts/build_zhenxun.py
@@ -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",
diff --git a/scripts/check_asset_manifest.py b/scripts/check_asset_manifest.py
new file mode 100644
index 0000000..8ce05fc
--- /dev/null
+++ b/scripts/check_asset_manifest.py
@@ -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])
diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py
index a2cb583..b2ef6c4 100644
--- a/scripts/check_workflows.py
+++ b/scripts/check_workflows.py
@@ -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")
diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py
index 46f396c..c5a19b6 100644
--- a/scripts/export_render_assets.py
+++ b/scripts/export_render_assets.py
@@ -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),
}
diff --git a/scripts/publish_astrbot.py b/scripts/publish_astrbot.py
new file mode 100644
index 0000000..0958533
--- /dev/null
+++ b/scripts/publish_astrbot.py
@@ -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()
diff --git a/scripts/verify_astrbot.py b/scripts/verify_astrbot.py
index f09ad1c..f40b99e 100644
--- a/scripts/verify_astrbot.py
+++ b/scripts/verify_astrbot.py
@@ -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")
diff --git a/scripts/verify_foundation.py b/scripts/verify_foundation.py
index b2509a3..910243d 100644
--- a/scripts/verify_foundation.py
+++ b/scripts/verify_foundation.py
@@ -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",
diff --git a/scripts/verify_nonebot.py b/scripts/verify_nonebot.py
index da73e81..d9281e2 100644
--- a/scripts/verify_nonebot.py
+++ b/scripts/verify_nonebot.py
@@ -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
diff --git a/scripts/verify_zhenxun.py b/scripts/verify_zhenxun.py
index 94a32c4..979c283 100644
--- a/scripts/verify_zhenxun.py
+++ b/scripts/verify_zhenxun.py
@@ -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
diff --git a/src/farm_core/commands.py b/src/farm_core/commands.py
new file mode 100644
index 0000000..fb62d90
--- /dev/null
+++ b/src/farm_core/commands.py
@@ -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))
diff --git a/src/farm_render/assets.py b/src/farm_render/assets.py
index 0b2781f..71c6b78 100644
--- a/src/farm_render/assets.py
+++ b/src/farm_render/assets.py
@@ -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"]:
diff --git a/src/farm_render/bundle.py b/src/farm_render/bundle.py
new file mode 100644
index 0000000..947f7a6
--- /dev/null
+++ b/src/farm_render/bundle.py
@@ -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 public 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)
diff --git a/src/farm_render/data/asset-manifest.json b/src/farm_render/data/asset-manifest.json
new file mode 100644
index 0000000..4af8074
--- /dev/null
+++ b/src/farm_render/data/asset-manifest.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/src/farm_render/data/metadata.json b/src/farm_render/data/metadata.json
index 184509a..e8ffdc8 100644
--- a/src/farm_render/data/metadata.json
+++ b/src/farm_render/data/metadata.json
@@ -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,
diff --git a/src/farm_render/help.py b/src/farm_render/help.py
new file mode 100644
index 0000000..bdba464
--- /dev/null
+++ b/src/farm_render/help.py
@@ -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)
diff --git a/src/farm_render/renderer.py b/src/farm_render/renderer.py
index f30d7a1..3d890f7 100644
--- a/src/farm_render/renderer.py
+++ b/src/farm_render/renderer.py
@@ -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):
diff --git a/src/farm_render/service.py b/src/farm_render/service.py
index 15d2857..7802aee 100644
--- a/src/farm_render/service.py
+++ b/src/farm_render/service.py
@@ -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:
diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py
index 06b2696..0ec8e66 100644
--- a/tests/test_astrbot_package.py
+++ b/tests/test_astrbot_package.py
@@ -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('', 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))
diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py
index 8215c6e..ec42c63 100644
--- a/tests/test_config_migration.py
+++ b/tests/test_config_migration.py
@@ -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)
diff --git a/tests/test_help_menu.py b/tests/test_help_menu.py
new file mode 100644
index 0000000..7758c7e
--- /dev/null
+++ b/tests/test_help_menu.py
@@ -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')
diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py
index ba8d0e0..524d4bf 100644
--- a/tests/test_nonebot_package.py
+++ b/tests/test_nonebot_package.py
@@ -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")
diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py
index cc986f6..b2277b4 100644
--- a/tests/test_zhenxun_runtime.py
+++ b/tests/test_zhenxun_runtime.py
@@ -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(
diff --git a/version.txt b/version.txt
index 10bf840..f93ea0c 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-2.0.1
\ No newline at end of file
+2.0.2
\ No newline at end of file