Compare commits

...
2 Commits
Author SHA1 Message Date
Diuse dc17f7f65c fixed # 修复工作流 2026-10-23 19:19:01 +08:00
Diuse 685cff9293 fixed # 适配AstrBot 2026-10-23 19:12:16 +08:00
53 changed files with 1691 additions and 166 deletions
+90
View File
@@ -0,0 +1,90 @@
name: AstrBot preview
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
publish:
description: Sync main branch in AstrBot repository
default: false
type: boolean
permissions:
contents: read
concurrency:
group: farm-preview-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ['3.10', '3.12']
env:
PYTHONPATH: src
PYTHONIOENCODING: utf-8
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
-r integrations/astrbot/requirements.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_astrbot.py --output dist/astrbot-preview
- name: Verify AstrBot package
shell: bash
run: |
python scripts/verify_astrbot.py dist/astrbot-preview/astrbot_plugin_farm-*.zip
- uses: actions/upload-artifact@v7
with:
name: astrbot-preview
path: dist/astrbot-preview/
include-hidden-files: true
if-no-files-found: error
retention-days: 14
sync-preview:
if: >-
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish)) &&
github.ref == 'refs/heads/main' &&
github.repository == 'Shu-Ying/main_plugin_farm'
needs: [test, build]
runs-on: ubuntu-latest
# 沿用真寻环境,使 environment 中的 FARM_RELEASE_TOKEN 同样可用。
environment: zhenxun-preview
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- uses: actions/download-artifact@v8
with:
name: astrbot-preview
path: incoming
- name: Sync validated AstrBot repository
env:
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
run: >-
python scripts/publish_astrbot.py
incoming/astrbot_plugin_farm-*.zip --publish
+2
View File
@@ -47,6 +47,8 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_nonebot.py --output dist/nonebot-preview
- run: >-
python scripts/verify_nonebot.py
+2
View File
@@ -57,6 +57,8 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- name: Build selected target only
run: >-
python scripts/build_zhenxun.py
+42 -74
View File
@@ -1,84 +1,52 @@
# Farm
<div align="center">
统一维护农场核心,面向真寻、普通 NoneBot 和 AstrBot 分包发布。
主仓库:https://github.com/Shu-Ying/main_plugin_farm.git。
<img src="./logo.png" width="400" alt="真寻农场 Logo">
## 当前可用范围
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
</a>
<a href="https://www.python.org">
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
<a href="https://nonebot.dev/">
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
</a>
<a href="https://github.com/zhenxun-org/zhenxun_bot">
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
</a>
</p>
第一批框架无关基础核心已实现,位于 src/farm_core:
农场核心,其他Bot框架进行分包发布
- 离线迁移旧版 202 条作物配置。
- 注册、账户/库存/地块查询、购买、播种、收获、铲除。
- 作物锁定/解锁、指定出售与全部出售、基础文本展示。
- SQLite 事务、资产流水、幂等重放及并发保护。
- 初始 500 农场币、0 点券、0 经验、3 块普通地。
- 可选共享图片层:旧布局与素材、目标尺寸绘制、WebP/JPEG/PNG、
有界缓存和受控后台执行,参见 [绘图说明](docs/10-rendering.md)。
</div align="center">
主仓库不能整体放入插件目录。真寻预览包由脚本白名单生成,参见
[真寻测试说明](docs/11-zhenxun-preview.md);AstrBot/通用NoneBot接入
仍未实现。工作流和同步脚本已准备,真实机器人和远端运行尚未验证。
签到、偷菜、升级、兑换和真实玩家数据迁移不在本轮范围。
## 安装与本地演示
Python 语法基线为 3.10;本轮在 Windows、Python 3.12.14 上验证。
推荐在独立虚拟环境中使用。构建需要 setuptools>=68;
运行核心本身没有第三方依赖。
```text
python -m pip install --no-deps --no-build-isolation .
python -m farm_core --database runtime/demo.sqlite3
```
演示使用独立的 demo 身份及可控时钟,不需要真实等待作物成熟。
指定目录内生成数据库及同名 .log;请使用开发目录,不指向生产数据。
相同演示数据库重复运行会重放固定操作结果,不重复发放资产。
接入方调用示例:
```python
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
store = SQLiteStore("runtime/farm.sqlite3")
await store.initialize()
service = FarmService(store, Catalogue.bundled())
who = Identity("deployment-1", "realm-1", "qq", "user-1")
try:
receipt = await service.register(who, "我的农场", "event-register-1")
snapshot = await service.snapshot(who)
finally:
await store.close()
```
每个写操作传入稳定的 operation_id。调用方负责身份命名空间、权限、
会话及发送消息;核心不会自动关联跨平台账号。
## 离线验证与迁移
```text
python scripts/check_baseline.py
python scripts/export_catalogue.py --check
python scripts/verify_foundation.py
```
完整验证执行基线校验、可复现导出、规则/事务测试和离线 wheel 安装;
打包测试需要已安装的 pip、setuptools、wheel、Pillow和可加载字体,
禁止从网络补装;绘图测试可设置FARM_TEST_FONT为本机字体路径。
只跑业务测试时,可安装核心后使用:
```text
python -m unittest discover -s tests -p "test_transactions.py" -v
```
## 简单说明
[文档索引](docs/README.md)、
[基础核心说明](docs/09-foundation.md)、
[待确认产品决策](docs/07-decisions.md)。
[待确认产品决策](docs/07-decisions.md)
## 来源与许可证
本仓库不是主要插件仓库,安装插件请去各自对应框架的应用商店或者仓库寻找:
</br>
- [真寻](https://github.com/Shu-Ying/zhenxun_plugin_farm)
- [Nonebot](https://github.com/Shu-Ying/nonebot_plugin_farm)
- [Astrbot](https://github.com/Shu-Ying/astrbot_plugin_farm)
`如果没有你需要的框架,那么请在本仓库提交issue吧,我会尽量迁移的(其实是用AI)`
legacy/zhenxun_plugin_farm 保存旧 Beta 基线,827 个文件保持校验。
来源提交 b015c6c1997f146f651b677382980d338a306bb2,保留 GPLv3 LICENSE。
基线副本的硬编码服务令牌已改为环境变量;原服务端轮换未完成。
新核心不使用该令牌、不访问旧远程服务;可选渲染包独立保存所需旧素材。
新核心 0.1.0 为本地基础包版本,不表示三个框架插件已发布。
## 开源协议
本项目采用的是`GPLv3`许可证进行开源
## 致谢
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
- [ChatGPT](https://chatgpt.com/) *AI神力*
+71
View File
@@ -0,0 +1,71 @@
# AstrBot 商店展示适配 — 2026-10-23
## 已完成的本地修改
- 图标发布源:主仓库根目录`logo.png`,1254×1254 RGBA透明PNG。
使用内置图片生成工具制作,并清理边缘和留白;未覆盖其他图标。
后续添加浅粉文字「真寻农场」。主仓库、NoneBot和真寻README使用高清
`logo.png`,显示宽度400像素。AstrBot构建将高清图复制为`logo-readme.png`
供README引用,将根目录`logo-astrbot.png`(256×256)复制为商店识别的
`logo.png`。两个用途独立;AstrBot原目录中的图片不再作为打包源。
- 元数据:保持插件名、作者与仓库身份;添加短介绍、标签、主页及
`support_platforms: [aiocqhttp]`。版本继续读取 `version.txt`,本次为2.0.2。
- 文档:`readme/astrbot.md`改为用户使用说明,截图和图标采用发布仓库main分支
的公开绝对链接;发布前尚未在远端存在的新图片暂时无法通过该链接显示。
- 构建时从`src/farm_core/commands.py`读取AstrBot命令,替换README中的
`<!-- FARM_COMMANDS -->`标记,不再手写一份独立指令表。
- 更新日志:`integrations/astrbot/CHANGELOG.md`,2.0.2标记待发布,
未编造1.x历史,不表示已更新线上商店。
- 发布包收录README、CHANGELOG、logo和预览图,加入完整性清单。
- `verify_astrbot.py --marketplace`额外检查16 MiB大小限制;
普通本地安装包校验保留原有完整性边界。
## 本地交付及检查
- ZIP:`dist/astrbot-store-assets-20261023/astrbot_plugin_farm-2.0.2.zip`。
- 精确大小:25,248,453字节(24.08 MiB)。
- 安装目录预览:同目录`repository/`,版本2.0.2。
- 两项AstrBot包测试通过:重复构建一致、校验与篡改拒绝、模拟宿主命令、
图片及关闭流程,并验证展示资源、透明图标和自动指令表。
- 图标为正方形,Alpha范围0~255。未把生成的角色图声明为官方授权素材。
- 本轮未安装、重启、提交Git、推送、创建Release或更新Cloud。
## 待确认与已知限制
2026-10-23后续进展:已将三端发行包的assets.zip移至farm_service仓库
`assets/`,本地新包`dist/astrbot-external-assets-20261023/`大小4,599,804字节
(4.39 MiB),通过`--marketplace`大小及完整性检查;以下24.08 MiB为旧产物记录。
配置及服务说明以`farm_service/docs/assets.md`为准;尚未推送或验证远端素材URL。
1. **商店容量限制尚未满足**:官方普通投稿限制16MB,现有素材包
`src/farm_render/data/assets.zip`本身已21,264,561字节;完整ZIP24.08 MiB。
`--marketplace`已实际执行并拒绝。不能把本地构建成功当作市场发布就绪。
后续需决定素材压缩/拆分策略,或与AstrBot维护者确认容量例外;本轮未删减
或重新压缩用户现有农场素材以避免画质和布局变化。
2. **最低AstrBot版本**:未凭推测加入`astrbot_version`。需要选择最低支持版本
并在真实宿主验证后声明;模拟宿主通过不等于真实宿主通过。
3. **支持平台**:仅声明当前重点适配的aiocqhttp;其他平台是否加入须验证,
不因共享核心不依赖框架就宣称全部平台可用。
4. **Cloud同步**:发布页需登录。尚未核实Cloud独立更新日志是否自动同步
CHANGELOG或GitHub Release;宿主读取根CHANGELOG已有官方源码依据。
后续发布时应检查Cloud的文档、更新日志、信息、版本四个栏目。
5. **图标选择**:当前采用真寻抱趴胡萝卜的构图。正方形符合图标比例要求,
保留生成原分辨率;256×256为官方推荐而非本轮输出尺寸。
如用户希望改坐姿、简化轮廓或调整服装,可基于原图继续修改。
6. **角色呈现**:图标为生成式非官方二次创作,识别度及是否最终采用由用户审阅;
不声称已获得角色官方授权。代码许可证不能作为角色图像授权的证明。
7. **模型请求**:当前工具不能切换或核实主对话模型,未声称已切到GPT-6.1 Sol高。
## 图标生成说明
内置图片生成工具;首稿提示:Mahiro Oyama / 緒山まひろ,银白偏粉长发、
呆毛和红棕眼,穿不透明宽松淡黄色连帽衫,完整着装、无文字,Q版角色轻松
抱趴在一根巨大橙色胡萝卜上,绿色叶片、奶油色贴纸轮廓,透明正方形背景,
优先小图辨识。第二轮保留角色与姿势,要求清理游离像素、平滑轮廓和增加留白。
## 参考
- https://docs.astrbot.app/dev/star/plugin-new.html
- https://docs.astrbot.app/dev/star/plugin-publish.html
- https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm
- https://onimai.jp/character/mahiro.html
+35
View File
@@ -0,0 +1,35 @@
# 动态农场帮助菜单
发送 `农场帮助` 返回单张帮助长图,发送 `农场帮助 文字` 返回完整文字版。
未开通农场也可使用;帮助查询不触发旧玩家数据迁移。
缺少可用字体或绘图失败时自动返回文字版,并在运行日志记录原因。
菜单使用包内农场背景、绿色分类栏和浅米色卡片。命令与说明采用固定字号,
按实测字宽换行,不省略长指令;分类自动分配到两列并随内容增加图片高度。
说明区包含参数、编号示例、虫害、经验及迁移提示。
图片遵循现有农场图片格式与质量配置;帮助菜单不支持高清参数。
同内容、平台、格式及质量的请求复用成图缓存,并发绘制复用同一任务。
## 增加或调整指令
唯一的中文命令定义位于 `src/farm_core/commands.py` 的 `COMMAND_SPECS`。
每条定义包含注册名称、运行时动作、分类、语法、说明、别名、适用框架,
同一主命令的附加语法使用 `extra`,例如 `开垦 条件`。
新增实际功能时,先实现对应运行时动作,再添加定义。三个框架的中文命令注册、
帮助图片及文字版共同读取该定义,无需再修改独立的帮助文案列表。
插件加载时读取定义,修改后随正常的插件重新加载生效,不支持在线编辑文件热更新。
`platforms` 为空时三平台共用;限制为 `('zhenxun',)` 的已接入指令仅在真寻注册
并显示。NoneBot 和 AstrBot 不显示该分类的空卡片。框架类型由接入层明确指定,
不使用用户可配置的 deployment 名称判断。
当前新版尚未接入金币兑换,因此不会显示旧版的购买农场币或点券兑换指令。
以后接入真寻兑换时,将真实动作及其平台限制一同加入定义即可。
## 验证边界
离线测试覆盖平台筛选、注册与帮助一致性、别名、超长命令换行、内容增长、
图片格式、质量配置、缓存及并发合并,以及文字兜底和帮助查询无玩家迁移。
离线图片和模拟宿主验证不代表已安装到真实机器人。
+28
View File
@@ -0,0 +1,28 @@
# 更新日志
## 2.0.2 — 待发布
### 新增
- 单张动态帮助图片,按当前平台的指令生成;支持「农场帮助 文字」。
- 种子与作物分类仓库,支持分页、作物编号及文字显示。
- 开垦与开垦条件查询,按下一实际地块编号校验等级和费用。
- 插件图标、市场短介绍、标签及独立更新日志。
### 调整与兼容
- 新账号初始6块土地;可可靠识别的旧三地账号一次性追加3块普通空地,
保留已开垦成果、原作物及资产。来源不明、地块异常或超出当前布局容量
时保留原状并提示管理员处理。
- 新播种及下一季的生虫间隔至少2小时;已排定的旧事件保留原计划一次。
- 全部农场图片遵循统一质量配置,仅「我的农场」允许追加「高清」。
- 提供本地独立存档、交易流水、重复消息保护和失败回滚。
- QQ/OneBot图片支持头像缓存;头像不可用时使用默认图像。
### 升级前请阅读
- 本条记录描述待发布代码,不代表已更新 AstrBot Cloud 或真实机器人。
- 新版没有签到、改名、土地升级、外部金币兑换及旧远程服务功能。
- 旧存档请先备份,再在副本中验证迁移;不要直接删除或覆盖真实玩家数据。
- 当前重点适配 QQ/OneBot(aiocqhttp),其他平台尚未完成真实环境验证。
- 本文件未编造旧1.x版本的发布日期和变更历史。
+13
View File
@@ -1,4 +1,17 @@
{
"resource_source": {
"description": "资源获取地址",
"type": "string",
"default": "Github",
"options": ["Github", "Gitea", "farm_service"],
"hint": "启动时校验本地素材SHA256,缺失或不符从所选地址下载;修改后重载。"
},
"farm_service_url": {
"description": "farm_service 地址",
"type": "string",
"default": "",
"hint": "始终独立配置,用于资源及后续活动服务;选择farm_service获取素材时必填HTTPS基础地址。"
},
"font_path": {
"description": "中文字体路径",
"type": "string",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+6 -1
View File
@@ -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):
+1 -1
View File
@@ -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` 生成标准
+3 -2
View File
@@ -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(
+6 -1
View File
@@ -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)
+6
View File
@@ -25,3 +25,9 @@ class Config(BaseModel):
75,
description="全部农场图片编码质量;仅我的农场高清时至少85",
)
farm_resource_source: Literal['Github', 'Gitea', 'farm_service'] = Field(
'Github', description='资源获取地址,修改后重载插件',
)
farm_service_url: str = Field(
'', description='farm_service 地址,独立供资源及活动服务使用',
)
@@ -1,6 +1,7 @@
"""真寻与 NoneBot 共用的运行编排;不导入宿主框架。"""
"""各平台适配器共用的运行编排;不导入宿主框架。"""
import hashlib
import asyncio
from dataclasses import replace
import json
import logging
@@ -9,12 +10,16 @@ import shlex
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
from ._vendor.farm_core.domain import FarmError
from ._vendor.farm_core.commands import (
command_map, command_specs, help_text,
)
from ._vendor.farm_core.storage import SCHEMA_VERSION
from ._vendor.farm_core.presentation import (
plot_ids_text, receipt_text, snapshot_text,
)
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
from ._vendor.farm_render.seasons import season_for
from ._vendor.farm_render.bundle import ensure_assets, validate_options
from ._vendor.farm_render.shop import shop_page
from ._vendor.farm_render.details import detail_rows, details_text
from ._vendor.farm_render.warehouse import (
@@ -22,36 +27,8 @@ from ._vendor.farm_render.warehouse import (
)
logger = logging.getLogger(__name__)
COMMANDS = {
"开通农场":"register", "我的农场":"farm", "农场详述":"details",
"我的农场币":"inventory", "我的点券":"inventory",
"我的种子":"seeds", "我的作物":"crops",
"种子商店":"shop", "农场帮助":"help", "购买种子":"buy",
"播种":"sow", "收获":"harvest", "铲除":"eradicate",
"作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell",
"农场除虫":"clear_pests", "除虫":"clear_pests", "地块编号":"plot_ids",
"偷菜":"steal",
"开垦":"reclaim",
}
HELP = (
"开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n"
"地块编号|查看当前土地编号及开垦位置\n"
"偷菜 @目标|每日最多5次成功偷取\n"
"开垦|每次增加1块普通空地;开垦 条件|查看等级和费用\n"
"我的种子/我的作物 [页码] [文字],默认图片。\n"
"全部图片使用农场图片质量配置;仅我的农场支持高清。\n"
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
"收获|铲除|农场除虫|我的种子 [页码]|我的作物 [页码]\n"
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
"作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n"
"每成功播种1块地获得2经验;收获经验按作物结算。\n"
"每铲除1块枯萎地块获得2经验;每清除1只虫获得1经验。\n"
"新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。\n"
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
"我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n"
"旧版农场数据在首次使用时迁移,绑定当前机器人和平台。"
)
COMMANDS = command_map('zhenxun')
HELP = help_text(command_specs('zhenxun'))
ERRORS = {
'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,'
'请联系管理员核查注册或迁移记录。',
@@ -100,8 +77,17 @@ class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None,
font_setting="FARM_PREVIEW_FONT", image_quality=75,
image_quality_provider=None):
image_quality_provider=None, framework='zhenxun',
resource_source='Github', service_url='',
resource_options_provider=None):
RenderOptions(quality=image_quality)
validate_options(resource_source, service_url)
self.resource_source = resource_source
self.service_url = service_url
self.resource_options_provider = resource_options_provider
self.framework = framework
self.command_specs = command_specs(framework)
self.commands = command_map(framework)
self.image_quality = image_quality
self.image_quality_provider = image_quality_provider
self.data_dir = Path(data_dir).resolve()
@@ -147,11 +133,27 @@ class Runtime:
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
))
for candidate in candidates:
try:
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
)
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
)
except Exception:
# 不将下载URL、服务地址或可能带凭据的网络异常写入日志。
logger.error('farm assets unavailable; using text; '
'check resource source and service address')
bundle_path = None
for candidate in candidates if bundle_path is not None else ():
if not candidate.is_file():
continue
try:
self.rendering = RenderService(FarmRenderer(candidate))
self.rendering = RenderService(FarmRenderer(
candidate, bundle_path=bundle_path,
))
logger.info("farm preview font selected file=%s", candidate.name)
break
except (ValueError, OSError):
@@ -180,6 +182,10 @@ class Runtime:
if not self.started:
return "农场尚未初始化,请检查启动日志。"
try:
args = shlex.split(arguments)
# 帮助无需注册或迁移玩家数据,绘图失败可直接阅读文字。
if action == 'help':
return await self.help_menu(args)
who = Identity(self.deployment, str(bot_id), platform, str(user_id))
try:
await self.store.migrate_legacy(
@@ -212,7 +218,7 @@ class Runtime:
bot_id, platform, str(user_id), message_id, action,
)
if action == "help":
return HELP
return await self.help_menu(args)
if action == 'reclaim' and args == ['条件']:
conditions = await self.service.reclaim_conditions(who)
return (
@@ -371,7 +377,7 @@ class Runtime:
raise ValueError()
receipt = await getattr(self.service, action)(who, op)
else:
return HELP
return help_text(self.command_specs)
text = receipt_text(receipt)
if action == 'register':
if receipt.data['created']:
@@ -386,3 +392,27 @@ class Runtime:
return ERRORS.get(error.code, "操作未完成:" + error.code)
except ValueError:
return "参数有误,请发送:农场帮助"
async def help_menu(self, args):
if '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
if args not in ([], ['文字']):
return '参数有误,请发送:农场帮助 或 农场帮助 文字'
fallback = help_text(self.command_specs)
if args or self.rendering is None:
return fallback
quality = self.image_quality
if self.image_quality_provider is not None:
quality = self.image_quality_provider()
try:
RenderOptions(quality=quality)
except (ValueError, TypeError):
logger.warning('farm help image quality config invalid')
return '农场图片质量配置无效,请设置为1~95的整数。'
try:
return await self.rendering.help_menu(
self.command_specs, self.framework, self.image_format, quality,
)
except (OSError, ValueError, RuntimeError):
logger.exception('farm help rendering failed; using text')
return fallback
+6
View File
@@ -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(),
)
+9
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+13
View File
@@ -0,0 +1,13 @@
# 各平台发布说明
各平台发布包的 README 统一在此维护:
- `nonebot.md`:NoneBot。
- `zhenxun.md`:真寻。
- `astrbot.md`:AstrBot。
对应工作流通过 `scripts/build_<平台>.py` 读取上述文件,
在生成的发布仓库和安装包中写为根目录 `README.md`。
文中的相对资源路径以生成的发布仓库根目录为准。
后续发布文案请修改本目录;原位置的文档保留供历史参考。
+102
View File
@@ -0,0 +1,102 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/logo-readme.png" width="400" alt="真寻农场 Logo">
</p>
# 农场 · AstrBot
开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
![农场预览](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 文字`。偷菜目标必须通过消息中的 @ 指定。
<!-- FARM_COMMANDS -->
## 配置
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `font_path` | 空 | 中文字体路径;留空尝试系统字体,无可用字体时退回文字 |
| `data_dir` | 空 | 留空使用 `data/plugin_data/astrbot_plugin_farm`;更改不会自动搬迁存档 |
| `image_format` | `AUTO` | `AUTO` 为 JPEG;也可选 `JPEG` 或 `PNG` |
| `image_quality` | `75` | 整数1~95,下一次图片请求生效;PNG不受影响 |
| `resource_source`(资源获取地址) | `Github` | `Github`、`Gitea`、`farm_service`,重载生效 |
| `farm_service_url`(farm_service 地址) | 空 | 独立HTTPS基础地址,供资源及后续活动服务共用;选farm_service下载时必填 |
除图片质量外,配置变更后请重载插件。高清农场的图片质量不低于85。
依赖 Pillow 和 aiohttp,由插件依赖声明安装;插件不捆绑中文字体。
当前重点适配 QQ/OneBot(`aiocqhttp`),其他平台尚未完成真实环境验证。
插件不携带大型`assets.zip`。启动时校验数据目录下缓存素材的SHA256,
缺失或不匹配时从所选源下载并校验后原子替换;GitHub/Gitea均从
`farm_service`仓库根目录`assets/`获取。默认Github,不自动跨源切换;
下载失败保留文字功能,调整来源后重载重试。两项配置始终同时存在,
活动功能尚未实现。已有正确缓存时不联网。
离线部署可将服务仓库的`assets/assets.zip`放到农场数据目录
`assets/<SHA256>/assets.zip`;SHA见插件的`_vendor/farm_render/data/asset-manifest.json`。
请先提交/发布服务仓库的素材,再发布依赖它的插件;不要把素材ZIP放回插件仓库。
## 玩法与旧账号兼容
- 新账号初始6块土地。`/开垦 条件` 查询下一块地的等级和费用,
`/开垦` 每次购买1块普通空地;未配置的地块不会扣款或放行。
- 可靠识别为初始3块地的旧账号,一次性追加3块普通空地,保留已有开垦成果、
作物、加成与资产。来源不明、编号异常或补后超过当前30个显示位置时,
保留原状并提示管理员处理,不截断、不折现。
- 新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。
已排定的旧生虫事件仍按原计划执行一次。
- 每只虫降低生长速度5%;成熟时每只虫使本季减产5%。成熟后除虫无法恢复
本季减产,请在生长期间及时除虫。
- 每日最多5次成功偷菜,北京时间零点重置;无可偷作物不消耗成功次数。
- 出售时自动跳过已锁定作物。仓库可使用作物编号操作。
## 升级与数据备份
升级前备份 `data/plugin_data/astrbot_plugin_farm`;若自定义 `data_dir`,
请备份实际目录。数据库为 `farm.sqlite3`,日志为 `farm-preview.log`。
旧库迁移前建议先在副本中验证。更换机器人、平台实例或部署不会自动合并账号。
新版当前没有签到、改名、土地升级、点券商店、外部金币兑换及旧远程服务功能。
请以当前帮助菜单为准,不要套用旧1.x版本指令。缺少稳定消息ID时,
插件会拒绝写操作以避免重复扣款。离线包校验及模拟宿主测试不代表线上验证。
QQ/OneBot 图片查询会请求发送者的腾讯头像用于农场及详情图片;
请求失败使用默认头像,成功缓存5分钟、失败缓存30秒,最多缓存16项。
其他平台使用默认头像。存档按部署、平台实例、机器人及用户隔离。
## 更新日志与反馈
- [更新日志](https://github.com/Shu-Ying/astrbot_plugin_farm/blob/main/CHANGELOG.md)
- [反馈问题](https://github.com/Shu-Ying/astrbot_plugin_farm/issues)
- [插件商店](https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm)
代码许可见插件所附 LICENSE。角色图标为非官方二次创作,不表示获得官方授权;
角色与原作相关权利不因代码许可证而转移。
## 开发者本地构建
开发源为 Farm 主仓库。Windows 双击 `build_astrbot.cmd`,或运行
`python scripts/build_plugin.py astrbot`,构建后生成完整 ZIP 和校验文件。
每次使用独立输出目录,不安装或启动机器人、不自动推送远端。
版本来自 `version.txt`;命令表、元数据与展示文件由构建生成。
+76
View File
@@ -0,0 +1,76 @@
# NoneBot 农场
<p align="center"><img src="./logo.png" width="400" alt="真寻农场 Logo"></p>
从 `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_RESOURCE_SOURCE=Github
FARM_SERVICE_URL=
```
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
`FARM_RESOURCE_SOURCE`(资源获取地址)支持`Github`、`Gitea`、`farm_service`,
默认`Github`。`FARM_SERVICE_URL`(farm_service 地址)始终独立存在,
供资源及后续活动功能共用;选择farm_service下载时须填写HTTPS基础地址。
两项修改后重载。插件不包含素材ZIP:每次启动校验本地SHA256,缺失或不匹配
才从`farm_service`仓库根目录`assets/assets.zip`下载,校验成功后原子替换。
失败时保留文字功能,不自动跨源切换,重载后重试。活动功能尚未实施。
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
启动时校验配置。只有“我的农场 高清”允许高清,使用1536宽,质量
取配置值与85的较大值;其他命令不接受高清。种子/作物仓库保留
分页和“文字”选项,例如“我的种子 2 文字”。
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
## 当前命令
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
尚未在共享核心中实现,此版本不提供这些命令。
## 生成与发布
主仓库 `version.txt` 是版本的唯一来源,生成 ZIP、包版本和清单。
`nonebot-preview.yml` 在 PR 中测试和构建,在主仓库 `main` 推送后
使用 `FARM_RELEASE_TOKEN` 同步到 `Shu-Ying/nonebot_plugin_farm/main`。
同步任务沿用真寻的 `zhenxun-preview` environment,兼容其中已有密钥。
目标仓库保留 `v*` 标签发布 PyPI 的方式和 `PYPI_TOKEN` 密钥。
普通同步只校验、构建,不发布 PyPI。手动推送标签前应确认版本未在
PyPI 使用,且标签去掉 `v` 后与 `version.txt` 完全一致。
接入时远端旧版为 1.5.7,主仓库为 1.5.4;用户计划后续统一改为
2.0.0,本次未提前修改。统一后发布标签应为 `v2.0.0`。
生成仓库的 `verify_package.py --directory .` 校验文件完整性;
`python -m build` 生成 wheel/sdist。远端 CI 和实际机器人消息行为
需要在部署环境进一步验证。
+149
View File
@@ -0,0 +1,149 @@
<div align="center">
# 真寻农场(zhenxun_plugin_farm)
<img src="./logo.png" width="400" alt="真寻农场 Logo">
> 资源配置:「资源获取地址」默认Github,可选Github、Gitea、farm_service;
> 「farm_service 地址」始终独立存在,供资源及后续活动服务共用。
> 选择farm_service下载时填写HTTPS基础地址,两项修改后重载。
> 插件不携带assets.zip,启动校验缓存SHA256,缺失或不匹配时从所选源获取;
> Github/Gitea均使用farm_service仓库根目录assets/。失败保留文字功能,重载重试。
> 活动服务尚未实现,请先发布服务仓库素材再发布插件。
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
</a>
<a href="https://www.python.org">
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
<a href="https://nonebot.dev/">
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
</a>
<a href="https://github.com/zhenxun-org/zhenxun_bot">
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
</a>
</p>
<p align="center">
[![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)
</p>
你是说可以种地对吧🤔?
</div>
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [使用指令](#使用指令)
- [更新日志(详细):](#更新日志详细)
- [用户方面](#用户方面)
- [代码方面](#代码方面)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
---
## 农场界面
![农场界面](./images/farm-preview.png)
---
## 如何安装
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
---
## 使用指令
| 指令 | 描述 | Tip |
| --- | --- | --- |
| @小真寻 开通农场 | 首次开通农场 | |
| 我的农场 | 你的农场 | |
| 农场详述 | 农场详细信息 | |
| 我的农场币 | 查询农场币 | |
| 种子商店 [筛选关键字] [页数] or [页数] | 查看种子商店 | 当第一个参数为非整数时,会默认进入筛选状态。页数不填默认为1 |
| 购买种子 [种子名称] [数量] | 购买种子 | 数量不填默认为1 |
| 我的种子 | 查询仓库种子 | |
| 播种 [种子名称] [数量] | 播种种子 | 数量不填默认将最大可能播种 |
| 收获 | 收获成熟作物 | |
| 铲除 | 铲除荒废作物 | |
| 我的作物 | 你的作物 | |
| 出售作物 [作物名称] [数量] | 从仓库里向系统售卖作物 | 不填写作物名将售卖仓库种全部作物 填作物名不填数量将指定作物全部出售 |
| 偷菜 @美波理 | 偷别人的菜 | 每人每天只能偷5次 |
| 购买农场币 | 将真寻金币兑换成农场币 | 兑换比例默认为1:2 手续费默认20% |
| 更改农场名 [新的农场名] | 改名 | 农场名称无法存储特殊字符 |
| 农场签到 | 签到 | 需要注意,该项会从服务器拉取签到数据 |
| 土地升级 [地块ID] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
---
## 更新日志[(详细)](./log/log.md):
用户方面
---
- 新增土地升级指令,快来将土地升级至红土地、黑土地、金土地吧
- 新增查漏补缺作物资源功能,自动更新最新作物和作物资源文件
- 定时更新签到文件、作物资源从00:30调整至04:30
- 修正了部分土地资源错误的情况
- 修正了部分文本信息错误的情况
代码方面
---
- 修正部分事件连接机制
- 修正网络请求端口
## 待办事宜 `Todo` 列表
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
---
## 关于
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
素材来均源于互联网,侵权请联系我删除
---
## 致谢
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) * 绝大多数的素材来源 *
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
## 上游仓库
- [Client](https://github.com/Shu-Ying/main_plugin_farm)
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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",
]
+32 -4
View File
@@ -4,6 +4,7 @@ import argparse
import ast
import hashlib
import json
import runpy
from pathlib import Path
import tempfile
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
@@ -13,6 +14,24 @@ from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
from verify_zhenxun import read_package as read_shared
def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->'
if template.count(marker) != 1:
raise ValueError('AstrBot README requires one command marker')
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
specs = definitions['command_specs']('astrbot')
for group, rows in definitions['help_groups'](specs):
for usage, description in rows:
values = (group, f'`/{usage}`', description)
lines.append('| ' + ' | '.join(
value.replace('|', r'\|').replace('\n', ' ')
for value in values
) + ' |')
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
def build(root, output):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
@@ -28,11 +47,15 @@ def build(root, output):
)}
source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt'):
'requirements.txt', 'CHANGELOG.md'):
files[name] = (source / name).read_bytes()
files['README.md'] = (source / 'README.md').read_bytes()
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
files['images/farm-preview.png'] = shared['images/farm-preview.png']
files['README.md'] = render_readme(root)
files['runtime.py'] = (
root / 'integrations/zhenxun/runtime.py'
root / 'integrations/shared/runtime.py'
).read_bytes()
files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py'
@@ -40,9 +63,14 @@ def build(root, output):
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
files['metadata.yaml'] = (json.dumps({
'name': ROOT_NAME, 'display_name': '农场',
'desc': '种植、收获、仓库、交易与农场图片,复用Farm共享核心。',
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
'version': version, 'author': 'Shu-Ying',
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
'social_link': 'https://github.com/Shu-Ying',
'tags': ['农场', '种植', '小游戏'],
'support_platforms': ['aiocqhttp'],
}, ensure_ascii=False, indent=2) + '\n').encode()
for name, content in files.items():
if name.endswith('.py'):
+4 -3
View File
@@ -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",
+7 -3
View File
@@ -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",
+34
View File
@@ -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])
+20 -2
View File
@@ -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")
+5 -3
View File
@@ -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),
}
+107
View File
@@ -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()
+13 -4
View File
@@ -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")
+1
View File
@@ -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",
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+114
View File
@@ -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))
+3 -2
View File
@@ -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"]:
+120
View File
@@ -0,0 +1,120 @@
"""固定摘要的外部素材缓存;下载后校验,原子替换,不解压到磁盘。"""
import hashlib
from importlib import resources
import json
import logging
from pathlib import Path
import tempfile
import threading
import time
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from zipfile import ZipFile
logger = logging.getLogger(__name__)
_LOCK = threading.Lock()
SOURCES = ('Github', 'Gitea', 'farm_service')
MAX_BYTES = 64 * 1024 * 1024
def expected_manifest():
return json.loads(resources.files(__package__).joinpath(
'data', 'asset-manifest.json',
).read_bytes())
def validate_options(source, service_url):
if source not in SOURCES:
raise ValueError('Unknown resource source')
if service_url:
checked_url(service_url)
if source == 'farm_service' and not service_url:
raise ValueError('farm_service address required')
def checked_url(url):
parsed = urlsplit(url)
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username
or parsed.password or parsed.query or parsed.fragment):
raise ValueError('Resource URL requires HTTPS without credentials')
return url
def _copy_remote(url, destination, limit):
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
started = time.monotonic()
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
length = response.headers.get('Content-Length')
if length is not None and int(length) > limit:
raise ValueError('Resource download exceeds limit')
total = 0
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
if total > limit or time.monotonic()-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
def verify_bundle(path, manifest):
path = Path(path)
if (not path.is_file() or path.stat().st_size != manifest['size_bytes']
or not 0 < manifest['size_bytes'] <= MAX_BYTES):
raise ValueError('Resource size mismatch')
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(65536), b''):
digest.update(chunk)
if digest.hexdigest() != manifest['sha256']:
raise ValueError('Resource SHA256 mismatch')
metadata = json.loads(resources.files(__package__).joinpath(
'data', 'metadata.json',
).read_bytes())
with ZipFile(path) as archive:
entries = archive.infolist()
names = [item.filename for item in entries]
if (len(names) != len(set(names))
or set(names) != set(metadata['images'])
or sum(item.file_size for item in entries) > 256*1024*1024
or any(item.is_dir() or item.flag_bits & 1
for item in entries)):
raise ValueError('Resource contents mismatch')
return path
def ensure_assets(data_dir, source='Github', service_url=''):
"""每次启动校验缓存;仅缺失或不匹配时访问所选源。"""
validate_options(source, service_url)
expected = expected_manifest()
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
with _LOCK:
try:
return verify_bundle(target, expected)
except (OSError, ValueError):
pass
target.parent.mkdir(parents=True, exist_ok=True)
# 清单绑定在插件内;远端不能通过更换清单放行不兼容的素材。
if source == 'farm_service':
url = (service_url.rstrip('/') + '/v1/assets/'
+ expected['sha256'] + '/assets.zip')
else:
url = expected['sources'][source.lower()]
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=target.parent, suffix='.download', delete=False,
) as stream:
temporary = Path(stream.name)
_copy_remote(url, stream, expected['size_bytes'])
verify_bundle(temporary, expected)
temporary.replace(target)
logger.info('farm assets ready source=%s sha256=%s',
source, expected['sha256'])
return target
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
+11
View File
@@ -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"
}
}
+6 -6
View File
@@ -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,
+114
View File
@@ -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)
+2 -1
View File
@@ -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):
+28
View File
@@ -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:
+92
View File
@@ -0,0 +1,92 @@
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from farm_render import bundle
ROOT = Path(__file__).resolve().parents[1]
ZIP = ROOT / 'src/farm_render/data/assets.zip'
class BundleTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.expected = bundle.expected_manifest()
def download(self, url, destination, limit):
content = ZIP.read_bytes()
self.assertEqual(limit, len(content))
destination.write(content)
def test_download_deduplicates_and_cache_hash_checked_each_start(self):
with patch.object(bundle, '_copy_remote',
side_effect=self.download) as fetch:
with ThreadPoolExecutor(4) as pool:
results = list(pool.map(
lambda _: bundle.ensure_assets(self.root), range(4),
))
self.assertEqual(len(set(results)), 1)
self.assertEqual(fetch.call_count, 1)
path = results[0]
content = bytearray(path.read_bytes())
content[-1] ^= 1
path.write_bytes(content)
bundle.ensure_assets(self.root)
self.assertEqual(fetch.call_count, 2)
self.assertEqual(path.read_bytes(), ZIP.read_bytes())
def test_gitea_and_service_use_service_repository_and_configured_base(self):
with patch.object(bundle, '_copy_remote',
side_effect=self.download) as fetch:
bundle.ensure_assets(self.root / 'gitea', 'Gitea')
self.assertEqual(fetch.call_args.args[0],
'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/assets/assets.zip')
bundle.ensure_assets(self.root / 'service', 'farm_service',
'https://farm.example/prefix/')
self.assertEqual(fetch.call_args.args[0],
'https://farm.example/prefix/v1/assets/'
+ self.expected['sha256'] + '/assets.zip')
def test_failed_download_leaves_no_partial_and_wrong_hash_is_rejected(self):
def corrupt(url, destination, limit):
data = bytearray(ZIP.read_bytes())
data[-1] ^= 1
destination.write(data)
with patch.object(bundle, '_copy_remote', side_effect=corrupt):
with self.assertRaisesRegex(ValueError, 'SHA256'):
bundle.ensure_assets(self.root)
self.assertFalse(list(self.root.rglob('assets.zip')))
self.assertFalse(list(self.root.rglob('*.download')))
with patch.object(bundle, '_copy_remote', side_effect=OSError()):
with self.assertRaises(OSError):
bundle.ensure_assets(self.root)
self.assertFalse(list(self.root.rglob('*.download')))
def test_invalid_source_and_service_configuration(self):
for source, url in [('unknown', ''), ('farm_service', ''),
('farm_service', 'http://farm.example'),
('Github', 'https://user:pass@farm.example'),
('Gitea', 'https://farm.example/?token=secret')]:
with self.subTest(source=source, url=url):
with self.assertRaises(ValueError):
bundle.ensure_assets(self.root, source, url)
def test_download_enforces_response_limit_and_https_redirect(self):
class Response(BytesIO):
headers = {'Content-Length': '20'}
def geturl(self):
return 'https://farm.example/assets.zip'
with patch.object(bundle, 'urlopen', return_value=Response(b'x'*20)):
with self.assertRaisesRegex(ValueError, 'limit'):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
response = Response(b'')
response.geturl = lambda: 'http://farm.example/a'
with patch.object(bundle, 'urlopen', return_value=response):
with self.assertRaises(ValueError):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
+42 -1
View File
@@ -31,8 +31,41 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
version = (ROOT / 'version.txt').read_text().strip()
self.assertEqual(manifest['version'], version)
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
metadata = json.loads(files['metadata.yaml'])
self.assertEqual(metadata['display_name'], '农场')
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
self.assertTrue(metadata['short_desc'])
self.assertEqual(files['logo.png'],
(ROOT / 'logo-astrbot.png').read_bytes())
self.assertEqual(files['logo-readme.png'],
(ROOT / 'logo.png').read_bytes())
self.assertIn('农场', metadata['tags'])
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(files['logo.png'])) as logo:
self.assertEqual(logo.format, 'PNG')
self.assertEqual(logo.width, logo.height)
self.assertEqual(logo.size, (256, 256))
self.assertIn('A', logo.getbands())
self.assertEqual(logo.getchannel('A').getextrema()[0], 0)
self.assertEqual(files['CHANGELOG.md'], (
ROOT / 'integrations/astrbot/CHANGELOG.md'
).read_bytes())
readme = files['README.md'].decode()
self.assertIn('/main/logo-readme.png', readme)
self.assertIn('width="400"', readme)
self.assertNotIn('<!-- FARM_COMMANDS -->', readme)
self.assertIn('/农场帮助 [文字]', readme)
self.assertIn('/开垦 条件', readme)
self.assertIn('raw.githubusercontent.com', readme)
self.assertIn('images/farm-preview.png', files)
self.assertFalse(any(name.endswith('/assets.zip') for name in files))
from verify_astrbot import verify_package
with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1):
with self.assertRaisesRegex(ValueError, '16 MiB'):
verify_package(first, marketplace=True)
self.assertEqual(files['runtime.py'], (
ROOT / 'integrations/zhenxun/runtime.py'
ROOT / 'integrations/shared/runtime.py'
).read_bytes())
for name, content in files.items():
self.assertNotIn('.git/', name)
@@ -93,6 +126,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
plugin = main.FarmPlugin(object(), {
'font_path': str(choose_font()), 'image_quality': 55,
})
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
manifest = json.loads((ROOT /
'src/farm_render/data/asset-manifest.json').read_bytes())
cached = (plugin.runtime.data_dir / 'assets'
/ manifest['sha256'] / 'assets.zip')
cached.parent.mkdir(parents=True, exist_ok=True)
cached.write_bytes((ROOT /
'src/farm_render/data/assets.zip').read_bytes())
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
+30 -6
View File
@@ -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)
+96
View File
@@ -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')
+4 -2
View File
@@ -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")
+3 -1
View File
@@ -72,7 +72,9 @@ class ZhenxunPackageTests(unittest.TestCase):
self.assertIn("dirty", manifest["source"])
with ZipFile(self.bundle) as archive:
paths = archive.namelist()
self.assertTrue(any(p.endswith("assets.zip") for p in paths))
self.assertFalse(any(p.endswith("assets.zip") for p in paths))
self.assertTrue(any(p.endswith('asset-manifest.json')
for p in paths))
self.assertFalse(any("astrbot" in p or "legacy/" in p
for p in paths))
source = archive.read(next(
+67 -8
View File
@@ -28,6 +28,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
sys.modules[self.package] = module
self.module = importlib.import_module(self.package + ".runtime")
self.runtime = self.module.Runtime(self.root / "data", choose_font())
self.seed_assets(self.runtime)
await self.runtime.start()
self.context = dict(bot_id="bot", platform="OneBot V11", user_id="1",
message_id="registration", display_name="离线玩家")
@@ -39,6 +40,15 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
del sys.modules[name]
self.temporary.cleanup()
def seed_assets(self, runtime):
bundle = importlib.import_module(
self.package + '._vendor.farm_render.bundle',
)
target = (runtime.data_dir / 'assets'
/ bundle.expected_manifest()['sha256'] / 'assets.zip')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / 'src/farm_render/data/assets.zip', target)
async def call(self, action, arguments="", message="event", **changes):
return await self.runtime.execute(
action, arguments,
@@ -50,7 +60,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
from PIL import Image
from unittest.mock import AsyncMock
self.assertEqual(self.module.COMMANDS['地块编号'], 'plot_ids')
self.assertIn('地块编号', await self.call('help'))
self.assertIn('地块编号', await self.call('help', '文字'))
first = await self.call('register')
self.assertIn('6块土地', first)
self.assertIn('地块编号', first)
@@ -71,9 +81,47 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('参数有误', await self.call('plot_ids', '1'))
self.assertIn('开通农场', await self.call('plot_ids', user_id='other'))
async def test_dynamic_help_image_text_fallback_and_no_player_access(self):
from io import BytesIO
from PIL import Image
from unittest.mock import AsyncMock
with patch.object(self.runtime.store, 'migrate_legacy',
new=AsyncMock()) as migrate:
image = await self.call('help', message=None)
migrate.assert_not_awaited()
with Image.open(BytesIO(image)) as decoded:
self.assertEqual(decoded.width, 1200)
text = await self.call('help', '文字', message=None)
for command in self.module.COMMANDS:
self.assertIn(command, text)
self.assertNotIn('购买农场币', text)
self.assertIn('仅我的农场支持高清',
await self.call('help', '高清'))
self.assertIn('参数有误', await self.call('help', '2'))
with patch.object(self.runtime.rendering, 'help_menu',
new=AsyncMock(side_effect=OSError('fixture'))):
self.assertEqual(await self.call('help'), text)
rendering = self.runtime.rendering
self.runtime.rendering = None
try:
self.assertEqual(await self.call('help'), text)
finally:
self.runtime.rendering = rendering
async def test_help_uses_live_global_quality(self):
from unittest.mock import AsyncMock
with patch.object(self.runtime.rendering, 'help_menu',
new=AsyncMock(return_value=b'image')) as render:
self.runtime.image_quality_provider = lambda: 91
self.assertEqual(await self.call('help'), b'image')
self.assertEqual(render.await_args.args[-1], 91)
self.runtime.image_quality_provider = lambda: False
self.assertIn('图片质量配置无效', await self.call('help'))
self.assertIsInstance(await self.call('help', '文字'), str)
async def test_reclaim_conditions_charge_replay_and_arguments(self):
self.assertEqual(self.module.COMMANDS['开垦'], 'reclaim')
self.assertIn('开垦 条件', await self.call('help'))
self.assertIn('开垦 条件', await self.call('help', '文字'))
await self.call('register')
who = self.runtime_identity()
asset = importlib.import_module(
@@ -226,7 +274,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
))
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1 文字'), str)
help_text = await self.call('help')
help_text = await self.call('help', '文字')
self.assertIn('仅我的农场支持高清', help_text)
self.assertNotIn('地块编号 [高清]', help_text)
self.assertNotIn('[高清/文字]', help_text)
@@ -328,7 +376,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_pest_command_clears_pests_and_preserves_growth(self):
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
help_text = await self.call("help")
help_text = await self.call("help", "文字")
self.assertIn("每只虫", help_text)
self.assertIn("间隔至少2小时", help_text)
self.runtime.service.clock = lambda: 1000
@@ -414,6 +462,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
other = self.module.Runtime(
self.root / "without-font", self.root / "missing.ttf",
)
self.seed_assets(other)
await other.start()
try:
response = await other.execute("farm", "", **self.context)
@@ -427,6 +476,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
shutil.copyfile(choose_font(), fonts / "msyh.ttf")
other = self.module.Runtime(self.root / "default-font", font_dir=fonts)
self.seed_assets(other)
await other.start()
try:
self.assertIsNotNone(other.rendering)
@@ -446,6 +496,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.root / "explicit-font", explicit,
font_dir=self.root / "not-present",
)
self.seed_assets(other)
await other.start()
try:
self.assertEqual(other.rendering.renderer.font_path, explicit)
@@ -465,6 +516,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
old = self.root / 'directories/farm_preview'
target = old.with_name('farm_db')
previous = self.module.Runtime(old, choose_font())
self.seed_assets(previous)
await previous.start()
try:
await previous.execute('register', '旧农场', **self.context)
@@ -474,6 +526,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
before = old_database.read_bytes()
target.mkdir()
current = self.module.Runtime(target, choose_font())
self.seed_assets(current)
await current.start()
try:
self.assertEqual(old_database.read_bytes(), before)
@@ -557,12 +610,16 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
platform.PlatformUtils = SimpleNamespace()
config = ModuleType("zhenxun.configs.config")
quality_values = {'value': 49}
def configured_value(key, default, **kwargs):
if key != '农场图片质量':
return default
value = quality_values['value']
return value if not kwargs['build_model'] else int(value)
config.Config = SimpleNamespace(
get=lambda module: SimpleNamespace(
configs={}, get=lambda key, default, **kwargs: (
quality_values['value'] if not kwargs['build_model']
else int(quality_values['value'])
),
configs={}, get=configured_value,
),
get_config=lambda *args, **kwargs: int(quality_values['value']),
_simple_data={},
@@ -584,6 +641,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
(self.root / 'farm_db').resolve())
self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS))
self.assertEqual(len(hooks["start"]), 1)
self.assertEqual(imported.resource_options(), ('Github', ''))
self.seed_assets(imported.runtime)
await hooks["start"][0]()
try:
bot = SimpleNamespace(self_id="bot", adapter=SimpleNamespace(
+1 -1
View File
@@ -1 +1 @@
2.0.1
2.0.2