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。 +真寻农场 Logo -## 当前可用范围 +

+ + license + + + Python + + + ruff + + + Nonebot + + + Python + +

-第一批框架无关基础核心已实现,位于 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 @@ +

+ 真寻农场 Logo +

+ +# 农场 · AstrBot + +开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。 +农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。 + +![农场预览](https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/images/farm-preview.png) + +## 安装与快速开始 + +在 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 农场 + +

真寻农场 Logo

+ +从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的 +业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。 + +![农场预览](images/farm-preview.png) + +## 安装与配置 + +需要 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) + +真寻农场 Logo +

+ + license + + + Python + + + ruff + + + Nonebot + + + Python + +

+ +

+ +[![tencent-qq](https://img.shields.io/badge/%E7%BE%A4-%E7%9C%9F%E5%AF%BB%E5%86%9C%E5%9C%BA%E6%B5%8B%E8%AF%95-%23FF99CC +)](https://qm.qq.com/q/7hsOD4rOw2) + +

+ +你是说可以种地对吧🤔? + +
+ +--- +## 目录 +- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm) + - [目录](#目录) + - [农场界面](#农场界面) + - [如何安装](#如何安装) + - [使用指令](#使用指令) + - [更新日志(详细):](#更新日志详细) + - [用户方面](#用户方面) + - [代码方面](#代码方面) + - [待办事宜 `Todo` 列表](#待办事宜-todo-列表) + - [关于](#关于) + - [致谢](#致谢) + - [许可证](#许可证) + - [上游仓库](#上游仓库) + +--- + +## 农场界面 + +![农场界面](./images/farm-preview.png) + +--- + +## 如何安装 + +方法一(推荐):在小真寻后台的插件商店下载即可
+方法二:下载源码放在小真寻`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