fixed # 新增本地部署脚本,一键生成对应平台的压缩包内容

This commit is contained in:
2026-10-22 18:33:42 +08:00
parent 37c933db0c
commit d238005561
41 changed files with 1158 additions and 545 deletions
+6
View File
@@ -0,0 +1,6 @@
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform astrbot %*
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
exit /b %FARM_BUILD_EXIT%
+6
View File
@@ -0,0 +1,6 @@
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform nonebot %*
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
exit /b %FARM_BUILD_EXIT%
+6
View File
@@ -0,0 +1,6 @@
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform zhenxun %*
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
exit /b %FARM_BUILD_EXIT%
+7
View File
@@ -19,6 +19,7 @@ nonebot.load_plugin("nonebot_plugin_farm")
```dotenv ```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO FARM_IMAGE_FORMAT=AUTO
FARM_IMAGE_QUALITY=75
FARM_DEPLOYMENT=nonebot FARM_DEPLOYMENT=nonebot
``` ```
@@ -26,6 +27,12 @@ FARM_DEPLOYMENT=nonebot
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前 `nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。 机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。 日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
启动时校验配置。只有“我的农场 高清”允许高清,使用1536宽,质量
取配置值与85的较大值;其他命令不接受高清。种子/作物仓库保留
分页和“文字”选项,例如“我的种子 2 文字”。
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。 部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
## 当前命令 ## 当前命令
@@ -7,6 +7,11 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续
# Scope # Scope
- 本次追加统一图片编码质量配置,整数1~95,默认75,覆盖农场、
地块编号、详情、商店及种子/作物仓库。仅“我的农场”接受高清,
使用1536宽及max(配置质量,85);其他查询拒绝高清,保留分页与文字。
真寻网页配置提供“农场图片质量”,普通NoneBot提供FARM_IMAGE_QUALITY。
不发布、不安装、不重启机器人,不改变其他功能。
- 迁移基线只读。迁出旧作物目录 202 条唯一名称配置,保留必要数值、 - 迁移基线只读。迁出旧作物目录 202 条唯一名称配置,保留必要数值、
购买币种、等级、成熟时间、产量、经验和多季次数;新核心运行不依赖 购买币种、等级、成熟时间、产量、经验和多季次数;新核心运行不依赖
legacy 目录,不复制旧框架依赖。共享渲染素材按下述新增范围迁出。 legacy 目录,不复制旧框架依赖。共享渲染素材按下述新增范围迁出。
@@ -78,6 +83,10 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续
# Acceptance examples # Acceptance examples
- A19: 默认质量75,整数1和95有效,0、96、布尔值及非整数拒绝;
配置应用到所有图片编码,缓存区分质量。仅我的农场接受高清,
质量不低于85及配置值;其他查询拒绝高清,仓库分页、文字继续可用。
真寻注册网页配置并在请求时读取,NoneBot读取启动配置;帮助与说明一致。
- A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, - A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用,
可从安装包或源码独立运行,核心不通过路径引用 legacy。 可从安装包或源码独立运行,核心不通过路径引用 legacy。
- A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; - A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库;
@@ -134,6 +143,8 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续
# Decisions # Decisions
- 2026-10-22用户确认统一质量方案,并修正为“除了我的农场允许带高清,
其他都不允许。请执行吧”。本次不接受或归档旧验收结果。
- 用户已授权初始化 Comet 并执行基础开发;工作区为当前目录 main。 - 用户已授权初始化 Comet 并执行基础开发;工作区为当前目录 main。
- 主仓库地址已明确:https://github.com/Shu-Ying/main_plugin_farm.git。 - 主仓库地址已明确:https://github.com/Shu-Ying/main_plugin_farm.git。
- 现有文档是设计参考,旧代码是实现参考;本轮仅覆盖上述第一批核心, - 现有文档是设计参考,旧代码是实现参考;本轮仅覆盖上述第一批核心,
@@ -1,12 +1,12 @@
schema: comet.native.v4 schema: comet.native.v4
name: farm-core-foundation name: farm-core-foundation
language: en language: en
phase: verify phase: build
status: await-user status: active
state_version: 36 state_version: 39
brief: brief.md brief: brief.md
document_constraints_version: 3 document_constraints_version: 3
shape_confirmation_hash: d1bc1489c0aa3d9a28ff7a24c62aa4bcb4e0a953955d265bcf06f802252cec36 shape_confirmation_hash: 066f3825ae83e04c24cb7018a75d217a2aaae9a8a4cba1c435eecc776de4a90d
spec_changes: spec_changes:
- capability: farm-core - capability: farm-core
operation: create operation: create
@@ -23,422 +23,126 @@ workspace:
target_branch: main target_branch: main
finish: null finish: null
loop: loop:
stage: await-user stage: building
goal_cycle: 5 goal_cycle: 6
iteration: 5 iteration: 1
attempt: 1 attempt: 0
retry_epoch: 0 retry_epoch: 0
failed_iteration_count: 1 failed_iteration_count: 0
no_progress_count: 0 no_progress_count: 0
execution_failure_count: 0 execution_failure_count: 0
previous_unresolved_ids: [] previous_unresolved_ids: []
next_action: confirm-skill-coordinated-pass next_action: submit-builder-candidate
acceptance: acceptance:
- id: A1 - id: A1
source: brief.md source: brief.md
text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。" text: "A19: 默认质量75,整数1和95有效,0、96、布尔值及非整数拒绝; 配置应用到所有图片编码,缓存区分质量。仅我的农场接受高清,
result: passed 质量不低于85及配置值;其他查询拒绝高清,仓库分页、文字继续可用。 真寻注册网页配置并在请求时读取,NoneBot读取启动配置;帮助与说明一致。"
reason: result: pending
text: 正式all-offline基线校验827文件通过,独立导入/安装测试通过;源码分层无框架依赖且不经legacy运行。 reason: null
truncated: false
- id: A2 - id: A2
source: brief.md source: brief.md
text: "A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。" text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。"
result: passed result: pending
reason: reason: null
text: 正式目录可复现校验与源202行对照、损坏输入拒绝测试通过。
truncated: false
- id: A3 - id: A3
source: brief.md source: brief.md
text: "A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。" text: "A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。"
result: passed result: pending
reason: reason: null
text: 注册默认资产、三空地、重复领取和作用域隔离正式测试通过;repository.register只首次赠送。
truncated: false
- id: A4 - id: A4
source: brief.md
text: "A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。"
result: pending
reason: null
- id: A5
source: brief.md source: brief.md
text: "A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, text: "A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败,
不留下部分写入。" 不留下部分写入。"
result: passed result: pending
reason: reason: null
text: 购买/播种在单事务中完成;非法整数、资源/等级不足、对象不存在和失败无部分写入正式测试通过。 - id: A6
truncated: false
- id: A5
source: brief.md source: brief.md
text: "A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; text: "A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和;
多季次数及枯萎/铲除遵守保存的规则与可注入时钟。" 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。"
result: passed result: pending
reason: reason: null
text: 成熟边界、多季、逐地加成及100+20=120正式测试通过;application.harvest只累加本次经验。 - id: A7
truncated: false
- id: A6
source: brief.md source: brief.md
text: "A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, text: "A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本,
不发生旧版模板参数缺失。" 不发生旧版模板参数缺失。"
result: passed result: pending
reason: reason: null
text: 指定/全部出售保护锁定且收益只增加一次;正式回归覆盖真实余额和安全文本。 - id: A8
truncated: false
- id: A7
source: brief.md source: brief.md
text: "A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 text: "A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、
不重复领取,不吞数据库故障伪装成功;重启后数据可读。" 不重复领取,不吞数据库故障伪装成功;重启后数据可读。"
result: passed result: pending
reason: reason: null
text: storage显式事务与幂等指纹;故障回滚、并发超卖/重复领取、重启恢复和取消重放正式检查通过。 - id: A9
truncated: false
- id: A8
source: brief.md source: brief.md
text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。" text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。"
result: passed result: pending
reason: reason: null
text: 完整离线入口与文档存在,真实宿主/旧库迁移/远端操作边界已记录;当前用户测试不扩大历史自动验证结论。 - id: A10
truncated: false
- id: A9
source: brief.md source: brief.md
text: "A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow text: "A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow
依赖;安装包含所需素材。" 依赖;安装包含所需素材。"
result: passed result: pending
reason: reason: null
text: canvas透明合成/裁剪/文本/三编码、wheel独立运行正式测试通过;新面板两素材离线导出并校验。
truncated: false
- id: A10
source: brief.md
text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。"
result: passed
reason:
text: 一次快照与统一now绘图、各阶段/尺寸正式测试通过;源码与旧drawUserInfo对照恢复userInfo/frame、140x150头像和信息内容,按用户选择校正底图槽位;样图目视确认无框外货币。
truncated: false
- id: A11 - id: A11
source: brief.md source: brief.md
text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。" text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。"
result: passed result: pending
reason: reason: null
text: 目标尺寸绘制、LRU预算/复制保护、阶段/账户/头像/身份缓存失效正式测试通过;新增经验边界和经验变化失效覆盖本轮面板。
truncated: false
- id: A12 - id: A12
source: brief.md source: brief.md
text: "A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。" text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。"
result: passed result: pending
reason: reason: null
text: RenderService后台线程/信号量/单任务合并/取消保护/关闭排空实现与正式并发测试一致,日志记录时长尺寸缓存。
truncated: false
- id: A13 - id: A13
source: brief.md source: brief.md
text: "A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。" text: "A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。"
result: passed result: pending
reason: reason: null
text: 离线样例与本机性能报告完整;恢复面板报告675素材、三编码冷暖数据,明确未测低配上传;全回归与基线通过。
truncated: false
- id: A14 - id: A14
source: brief.md
text: "A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。"
result: pending
reason: null
- id: A15
source: brief.md source: brief.md
text: "A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, text: "A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离,
独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。" 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。"
result: passed result: pending
reason: reason: null
text: 真寻原指令、稳定事件ID、身份隔离与独立库生命周期模拟测试通过;默认FONT_PATH在适配层,显式覆盖与缺失提示有测试。 - id: A16
truncated: false
- id: A15
source: brief.md source: brief.md
text: "A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, text: "A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据,
源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。" 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。"
result: passed result: pending
reason: reason: null
text: 白名单构建私有模块/素材/许可证/来源哈希,固定ZIP元数据,正式可复现测试通过;最终preview.3的30文件校验且renderer与源码一致。 - id: A17
truncated: false
- id: A16
source: brief.md source: brief.md
text: "A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。" text: "A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。"
result: passed result: pending
reason: reason: null
text: 安装先校验、已有目标拒绝、路径穿越/哈希/符号链接等正式边界测试通过;最终安装目录与ZIP逐文件一致,不启动宿主。 - id: A18
truncated: false
- id: A17
source: brief.md source: brief.md
text: "A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 text: "A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据
由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。" 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。"
result: passed result: pending
reason: reason: null
text: 工作流触发/只读权限/矩阵/附件与专用手动发布源码核对,正式守卫通过;固定farm-preview且非force,默认离线,冲突及默认分支拒绝模拟测试通过。 - id: A19
truncated: false
- id: A18
source: brief.md source: brief.md
text: "A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 text: "A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及
Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界;
提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。" 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。"
result: passed result: pending
reason: reason: null
text: 完整60项与Pillow10.4的28项本机正式检查通过,工作流包含Windows/Linux和10.4/12.3;说明记录字体/数据/安装/回退/Secrets及真实消息和远端运行限制。 builder_handoff: null
truncated: false blockers: []
builder_handoff: verification: null
candidate_id: f7644c08-b105-472c-9e92-4e28224fa080
identity_provider: skill-coordinated
builder_execution_ref: skill-coordinated:builder:90d9bf58-287b-4ac9-9c39-e97c23b3bc86
iteration: 5
summary:
text: 恢复旧版用户信息底图、头像框、头像尺寸及昵称/经验/等级/币种字段;用户确认按原底图修正旧坐标错位。经验口径兼容旧版、点券为真实余额。补齐两张原素材,目标尺寸LRU、一次快照与异步限流均保留。15项定向绘图测试通过,已目视核对最新样图并构建preview.3。
完整检查发现一处81字符行宽违规,已拆行;3项打包检查通过,运行完整正式复查。
truncated: false
addressed_acceptance_ids:
- A1
- A2
- A3
- A4
- A5
- A6
- A7
- A8
- A9
- A10
- A11
- A12
- A13
- A14
- A15
- A16
- A17
- A18
acceptance_review:
- id: A1
status: implemented-with-evidence
evidence:
- scripts/check_baseline.py
- tests/test_packaging.py
- src/farm_core/__init__.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A2
status: implemented-with-evidence
evidence:
- scripts/export_catalogue.py
- tests/test_domain.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A3
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/application.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A4
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/application.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A5
status: implemented-with-evidence
evidence:
- tests/test_domain.py
- tests/test_transactions.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A6
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/presentation.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A7
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/storage.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A8
status: implemented-with-evidence
evidence:
- README.md
- docs/09-foundation.md
- scripts/verify_foundation.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A9
status: implemented-with-evidence
evidence:
- src/farm_render/canvas.py
- tests/test_render.py
- tests/test_packaging.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A10
status: implemented-with-evidence
evidence:
- src/farm_render/layout.py
- src/farm_render/renderer.py
- tests/test_render.py
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
- docs/render-panel-restored-benchmark.json
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
- id: A11
status: implemented-with-evidence
evidence:
- src/farm_render/assets.py
- src/farm_render/renderer.py
- tests/test_render.py
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
- docs/render-panel-restored-benchmark.json
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
- id: A12
status: implemented-with-evidence
evidence:
- src/farm_render/service.py
- tests/test_render.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A13
status: implemented-with-evidence
evidence:
- scripts/render_demo.py
- docs/10-rendering.md
- docs/render-benchmark.json
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
- docs/render-panel-restored-benchmark.json
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
- id: A14
status: implemented-with-evidence
evidence:
- integrations/zhenxun/runtime.py
- integrations/zhenxun/commands.py
- tests/test_zhenxun_runtime.py
- tests/test_zhenxun_runtime.py::test_insufficient_funds_has_chinese_message_and_no_debit
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
- id: A15
status: implemented-with-evidence
evidence:
- scripts/build_zhenxun.py
- tests/test_zhenxun_package.py
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
- id: A16
status: implemented-with-evidence
evidence:
- scripts/verify_zhenxun.py
- scripts/install_zhenxun.py
- tests/test_zhenxun_package.py
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
- id: A17
status: implemented-with-evidence
evidence:
- .github/workflows/zhenxun-preview.yml
- release/zhenxun/verify.yml
- scripts/publish_zhenxun.py
- scripts/check_workflows.py
- tests/test_zhenxun_package.py
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
- id: A18
status: implemented-with-evidence
evidence:
- docs/11-zhenxun-preview.md
- scripts/check_pillow_compat.py
- tests/test_zhenxun_runtime.py
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
checks:
- name:
text: render-panel-focused
truncated: false
result: passed
note:
text: 15 tests including original asset layering, avatar geometry, level
boundaries and experience cache invalidation.
truncated: false
- name:
text: visual-panel-and-package
truncated: false
result: passed
note:
text: Latest 1536 JPEG visually inspected; preview.3 built and package verified.
truncated: false
- name:
text: packaging-focused
truncated: false
result: passed
note:
text: 3 packaging tests passed after wrapping overlong renderer line; runtime
full first attempt failed only line-width gate.
truncated: false
checks_truncated: false
known_limits:
- text: 真实聊天需重启后由用户测试;不自动重启,不访问真实玩家库。
truncated: false
- text: 菜单/网页不显示仍未解决;Linux/GitHub未实跑;旧缺图455项未补。
truncated: false
- text: 恢复旧经验分母为下一等级累计门槛,未改变数值规则。
truncated: false
known_limits_truncated: false
review: null
submitted_at: 2026-10-16T02:27:25.960Z
blockers:
- owner: user
reason:
text: The generic Skill bridge cannot prove an independent Verifier execution;
user confirmation is required before Archive.
truncated: false
acceptance_ids: []
resolution_action: await-user
verification:
candidate_id: f7644c08-b105-472c-9e92-4e28224fa080
identity_provider: skill-coordinated
verifier_execution_ref: skill-coordinated:verifier:1c253cb5-527d-407b-9273-2e206149f47d
iteration: 5
attempt: 1
assurance: skill-coordinated
verdict: pass
checks:
- id: all-offline
name:
text: Full offline
truncated: false
argv_display:
- text: scripts/verify_foundation.py
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 24375
- id: pillow-10-compat
name:
text: Pillow floor
truncated: false
argv_display:
- text: scripts/check_pillow_compat.py
truncated: false
- text: --dependency-path
truncated: false
- text: .release-cache/pillow10
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 15112
- id: workflow-guards
name:
text: Workflows
truncated: false
argv_display:
- text: scripts/check_workflows.py
truncated: false
- text: --dependency-path
truncated: false
- text: .release-cache/qa
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 95
summary:
text: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
truncated: false
risks:
- text: 真实聊天须用户重启后验证本轮面板,未自动启动机器人;菜单/网页不显示仍待独立排查。
truncated: false
- text: Linux和GitHub工作流/远端发布未实跑;455个旧作物阶段缺图仍按既定占位处理。
truncated: false
- text: 图索引为旧generation且新文件未跟踪;本轮以直接源读取与正式Runtime检查为准。
truncated: false
- text: 经验条沿用旧版当前级经验/下级累计门槛口径,仅校正坐标;不是重新设计升级规则。
truncated: false
risks_truncated: false
completed_at: 2026-10-16T02:31:02.032Z
history: history:
- goal_cycle: 1 - goal_cycle: 1
iteration: 1 iteration: 1
@@ -567,6 +271,15 @@ history:
text: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。 text: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
truncated: false truncated: false
completed_at: 2026-10-16T02:31:02.032Z completed_at: 2026-10-16T02:31:02.032Z
- goal_cycle: 5
iteration: 5
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户明确要求执行统一图片质量配置,默认75、范围1至95,仅我的农场允许高清覆盖;其他图片禁止高清,保留仓库文字和分页。修订用户可见要求。
truncated: false
completed_at: 2026-10-22T09:37:42.325Z
history_overflow: history_overflow:
dropped_entries: 0 dropped_entries: 0
first_dropped_at: null first_dropped_at: null
@@ -577,7 +290,7 @@ history_overflow:
blocked: 0 blocked: 0
execution-error: 0 execution-error: 0
recovery: 0 recovery: 0
verification_result: pass verification_result: pending
verification_report: verification.md verification_report: null
archived: false archived: false
created_at: 2026-10-15T06:34:33.214Z created_at: 2026-10-15T06:34:33.214Z
@@ -2,6 +2,20 @@
## 接入边界 ## 接入边界
全部图片采用统一整数编码质量(1~95,默认75)。真寻网页配置名称为
“农场图片质量”,普通NoneBot接入使用FARM_IMAGE_QUALITY。真寻请求时
读取配置;无效值明确拒绝并保留已有有效配置,不静默夹取范围。
只有“我的农场 高清”可覆盖默认尺寸至1536宽,质量max(配置值,85)。
地块编号、详情、种子商店、我的种子和我的作物拒绝高清参数,保留既有
分页和仓库文字查询。编码质量随各类渲染输入传递并进入图片缓存键。
PNG继续无损输出,质量参数不改变PNG清晰度。默认其他图片尺寸不变。
Scenario: 全局图片质量及仅农场高清
Acceptance: A19
配置1和95可用;0、96、布尔及小数拒绝。所有图片按配置编码且不同质量
不共享成图。农场高清为1536宽,质量至少85且不低于配置;其他命令高清
返回明确参数错误,仓库分页和文字仍可用,帮助不再宣称其他命令支持高清。
仅提供真寻接入;业务和图片仍来自主仓库共享源码。接入层加载插件 仅提供真寻接入;业务和图片仍来自主仓库共享源码。接入层加载插件
私有_vendor命名空间,渲染层对farm_core的引用在构建时改为私有相对 私有_vendor命名空间,渲染层对farm_core的引用在构建时改为私有相对
导入,不修改开发源码、不污染全局sys.path或sys.modules别名。 导入,不修改开发源码、不污染全局sys.path或sys.modules别名。
@@ -1,93 +0,0 @@
---
generated_from_state_version: 36
---
# Verification
## Current result
- Result: **Verification passed; your confirmation is required**
- Verification status: **Checks completed, but your confirmation is required**
- Goal cycle: 5
- Iteration: 5
- Verifier attempt: 1
- Completed: 2026-10-16T02:31:02.032Z
- Summary: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
## Acceptance
| ID | Result | Source | Criterion | Reason |
| --- | --- | --- | --- | --- |
| A1 | passed | brief.md | A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。 | 正式all-offline基线校验827文件通过,独立导入/安装测试通过;源码分层无框架依赖且不经legacy运行。 |
| A2 | passed | brief.md | A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。 | 正式目录可复现校验与源202行对照、损坏输入拒绝测试通过。 |
| A3 | passed | brief.md | A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。 | 注册默认资产、三空地、重复领取和作用域隔离正式测试通过;repository.register只首次赠送。 |
| A4 | passed | brief.md | A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, 不留下部分写入。 | 购买/播种在单事务中完成;非法整数、资源/等级不足、对象不存在和失败无部分写入正式测试通过。 |
| A5 | passed | brief.md | A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。 | 成熟边界、多季、逐地加成及100+20=120正式测试通过;application.harvest只累加本次经验。 |
| A6 | passed | brief.md | A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, 不发生旧版模板参数缺失。 | 指定/全部出售保护锁定且收益只增加一次;正式回归覆盖真实余额和安全文本。 |
| A7 | passed | brief.md | A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 不重复领取,不吞数据库故障伪装成功;重启后数据可读。 | storage显式事务与幂等指纹;故障回滚、并发超卖/重复领取、重启恢复和取消重放正式检查通过。 |
| A8 | passed | brief.md | A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。 | 完整离线入口与文档存在,真实宿主/旧库迁移/远端操作边界已记录;当前用户测试不扩大历史自动验证结论。 |
| A9 | passed | brief.md | A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。 | canvas透明合成/裁剪/文本/三编码、wheel独立运行正式测试通过;新面板两素材离线导出并校验。 |
| A10 | passed | brief.md | A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。 | 一次快照与统一now绘图、各阶段/尺寸正式测试通过;源码与旧drawUserInfo对照恢复userInfo/frame、140x150头像和信息内容,按用户选择校正底图槽位;样图目视确认无框外货币。 |
| A11 | passed | brief.md | A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。 | 目标尺寸绘制、LRU预算/复制保护、阶段/账户/头像/身份缓存失效正式测试通过;新增经验边界和经验变化失效覆盖本轮面板。 |
| A12 | passed | brief.md | A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 | RenderService后台线程/信号量/单任务合并/取消保护/关闭排空实现与正式并发测试一致,日志记录时长尺寸缓存。 |
| A13 | passed | brief.md | A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 | 离线样例与本机性能报告完整;恢复面板报告675素材、三编码冷暖数据,明确未测低配上传;全回归与基线通过。 |
| A14 | passed | brief.md | A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。 | 真寻原指令、稳定事件ID、身份隔离与独立库生命周期模拟测试通过;默认FONT_PATH在适配层,显式覆盖与缺失提示有测试。 |
| A15 | passed | brief.md | A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。 | 白名单构建私有模块/素材/许可证/来源哈希,固定ZIP元数据,正式可复现测试通过;最终preview.3的30文件校验且renderer与源码一致。 |
| A16 | passed | brief.md | A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。 | 安装先校验、已有目标拒绝、路径穿越/哈希/符号链接等正式边界测试通过;最终安装目录与ZIP逐文件一致,不启动宿主。 |
| A17 | passed | brief.md | A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。 | 工作流触发/只读权限/矩阵/附件与专用手动发布源码核对,正式守卫通过;固定farm-preview且非force,默认离线,冲突及默认分支拒绝模拟测试通过。 |
| A18 | passed | brief.md | A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。 | 完整60项与Pillow10.4的28项本机正式检查通过,工作流包含Windows/Linux和10.4/12.3;说明记录字体/数据/安装/回退/Secrets及真实消息和远端运行限制。 |
## Checks
| Check | Command | Working directory | Status | Exit | Duration |
| --- | --- | --- | --- | ---: | ---: |
| Full offline | scripts/verify_foundation.py | . | passed | 0 | 24375 ms |
| Pillow floor | scripts/check_pillow_compat.py --dependency-path .release-cache/pillow10 | . | passed | 0 | 15112 ms |
| Workflows | scripts/check_workflows.py --dependency-path .release-cache/qa | . | passed | 0 | 95 ms |
### Builder-reported evidence
These are Builder reports, not Runtime check receipts or independent verification results.
- render-panel-focused: passed — 15 tests including original asset layering, avatar geometry, level boundaries and experience cache invalidation.
- visual-panel-and-package: passed — Latest 1536 JPEG visually inspected; preview.3 built and package verified.
- packaging-focused: passed — 3 packaging tests passed after wrapping overlong renderer line; runtime full first attempt failed only line-width gate.
- Known limitation: 真实聊天需重启后由用户测试;不自动重启,不访问真实玩家库。
- Known limitation: 菜单/网页不显示仍未解决;Linux/GitHub未实跑;旧缺图455项未补。
- Known limitation: 恢复旧经验分母为下一等级累计门槛,未改变数值规则。
## Blockers
- **user**: The generic Skill bridge cannot prove an independent Verifier execution; user confirmation is required before Archive. — next: `await-user`
## Risks and skipped work
- 真实聊天须用户重启后验证本轮面板,未自动启动机器人;菜单/网页不显示仍待独立排查。
- Linux和GitHub工作流/远端发布未实跑;455个旧作物阶段缺图仍按既定占位处理。
- 图索引为旧generation且新文件未跟踪;本轮以直接源读取与正式Runtime检查为准。
- 经验条沿用旧版当前级经验/下级累计门槛口径,仅校正坐标;不是重新设计升级规则。
## Previous iterations
| Goal cycle | Iteration | Attempt | Outcome | Unresolved | Summary | Completed |
| ---: | ---: | ---: | --- | --- | --- | --- |
| 1 | 1 | 1 | pass | — | 独立只读验收完成:完整brief/Spec、实现、测试、配置和文档均已核对,最后参考Builder交接;绑定候选的Runtime foundation-offline以退出码0完成,827基线文件、可复现目录导出与32项测试通过。A1-A8全部通过;未追加或自行重跑完整检查,未修改项目源文件。 | 2026-10-15T07:00:29.324Z |
| 1 | 1 | 1 | recovery | — | 用户明确要求在已确认的图片工具及绘图优化方案上执行开发;将共享农场渲染作为当前基础开发的范围补充,保留原业务验收项并追加图片验收。 | 2026-10-15T07:55:29.688Z |
| 2 | 0 | 0 | recovery | — | Native confirmed acceptance criteria changed | 2026-10-15T08:23:15.134Z |
| 3 | 1 | 1 | pass | — | 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。 | 2026-10-15T08:40:00.608Z |
| 3 | 1 | 1 | recovery | — | 用户选择补齐真寻测试版:命令接入、打包工作流和安装脚本;将现有核心与渲染扩展为可安装真寻预览包,保留独立测试库和旧基线,不执行远端操作。 | 2026-10-15T09:24:24.236Z |
| 4 | 0 | 0 | recovery | — | Native confirmed acceptance criteria changed | 2026-10-15T09:28:23.268Z |
| 5 | 1 | 1 | pass | — | 独立只读核对完整brief、三份规格及18项验收,审阅核心/渲染、真寻接入、白名单构建/校验/安装/固定发布边界、工作流和测试文档。复用本候选正式Runtime的55项全回归、Pillow10.4下23项兼容检查及工作流守卫,全部通过;未重复完整套件。图索引旧/新增缺覆盖,已直接源码补齐。未修改实现或正式规格,未访问远端、安装/启动机器人或代用户接受。 | 2026-10-15T09:51:26.860Z |
| 5 | 1 | 1 | recovery | — | 独立验收指出余额不足错误码映射缺少insufficient_funds;保持已确认范围,补中文提示及回归测试后重新交付。 | 2026-10-15T09:52:02.989Z |
| 5 | 2 | 1 | pass | — | 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。 | 2026-10-15T09:58:23.989Z |
| 5 | 2 | 1 | recovery | — | 用户实际测试要求默认字体:在真寻适配层默认复用宿主字体目录,保留显式字体覆盖和缺失时清楚提示;修复预览版默认不可绘图的接入遗漏。 | 2026-10-16T02:06:48.036Z |
| 5 | 3 | 1 | pass | — | 独立只读验收全部A1-A18:完整brief/三spec与当前字体实现、测试、文档已核对;复用此前未改变核心/渲染/发布的正式独立验收,并核实本候选绑定Runtime三检查exit0,实际58项完整与Pillow10.4下26项通过。真寻FONT_PATH为resources/font,接入层默认选宿主字体,坏默认跳过、显式覆盖优先,共享层未引入真寻路径;安装目录runtime/commands与主源SHA256一致。图索引不覆盖新文件或metadata_changed,已读源码补齐;未重跑完整测试、未修改项目文件、未启动宿主或访问远端。 | 2026-10-16T02:13:06.574Z |
| 5 | 3 | 1 | recovery | — | 用户要求修复迁移遗漏:恢复旧版用户面板素材、布局与经验进度语义并保留性能优化。 | 2026-10-16T02:17:50.690Z |
| 5 | 4 | 0 | recovery | — | Builder handoff Runtime checks failed: all-offline | 2026-10-16T02:26:17.238Z |
| 5 | 5 | 1 | pass | — | 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。 | 2026-10-16T02:31:02.032Z |
## Conclusion
独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
+1 -1
View File
@@ -37,7 +37,7 @@
## 用户查询入口 ## 用户查询入口
真寻接入新增「地块编号」,支持「地块编号 高清」。它复用正式渲染器, 真寻接入新增「地块编号」,使用统一图片质量配置。它复用正式渲染器,
按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。 按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。
图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。 图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。
该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。 该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。
+36 -5
View File
@@ -1,7 +1,38 @@
# AstrBot 接入预留位置 # AstrBot 农场插件
发布目标:https://github.com/Shu-Ying/astrbot_plugin_farm.git 发布目标:https://github.com/Shu-Ying/astrbot_plugin_farm.git
负责 AstrBot 事件、消息、配置和生命周期,不导入 NoneBot 或真寻。 基于Star插件接口,命令、交易、数据与图片复用主仓库共享核心,
未来生成 main.py、metadata.yaml、配置描述、依赖和私有核心。 不导入NoneBot或真寻。此目录是接入源码,需构建生成完整插件包。
本目录尚不可安装;必须验证资源体积及实际安装。
远程仓库本轮不再拉取或推送。 ## 本地构建与安装
Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印ZIP路径。
亦可运行`python scripts/build_plugin.py astrbot`。每次输出独立新目录,
不会覆盖上次产物,不访问远端,不安装依赖、不启动或修改机器人。
版本由主仓库`version.txt`统一生成,不手工修改metadata.yaml版本。
在AstrBot管理面板的插件管理中选择本地ZIP安装,选择生成的
`astrbot_plugin_farm-版本.zip`。插件依赖Pillow>=10.4,不包含AstrBot本体。
插件源码及素材遵循所附GPLv3许可证;不捆绑字体。
## 配置与使用
管理面板支持`font_path`(中文字体)、`data_dir`、`image_format`及
`image_quality`(整数1~95,默认75)。质量修改后下一次请求生效;
其他配置修改后重载插件。默认数据目录为
`data/plugin_data/astrbot_plugin_farm`,数据库与日志位于该目录,卸载
插件前自行备份;更改目录不自动搬迁数据,旧数据不会被覆盖。
按AstrBot配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
仅`/我的农场 高清`支持高清;其他图片统一遵循质量配置。
仓库保留分页和文字;偷菜通过`/偷菜 @目标`,不接受手输QQ号。
不提供共享核心尚未实现的签到、开垦、改名、土地升级及外部货币兑换。
存档按AstrBot部署、平台实例、机器人及用户隔离,不自动合并NoneBot或
真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。
本地模拟和ZIP校验不等于真实平台发图成功,需要部署者实际验证。
官方接口参考:
[插件入口](https://docs.astrbot.app/en/dev/star/guides/simple.html)、
[插件配置](https://docs.astrbot.app/en/dev/star/guides/plugin-config.html)。
+27
View File
@@ -0,0 +1,27 @@
{
"font_path": {
"description": "中文字体路径",
"type": "string",
"default": "",
"hint": "部署环境可访问的中文字体文件;留空时尝试系统字体,找不到则退回文字。"
},
"data_dir": {
"description": "农场数据目录",
"type": "string",
"default": "",
"hint": "留空使用data/plugin_data/astrbot_plugin_farm。修改后重载插件;不会自动搬迁数据。"
},
"image_format": {
"description": "图片格式",
"type": "string",
"default": "AUTO",
"options": ["AUTO", "JPEG", "PNG"],
"hint": "AUTO使用压缩JPEG;修改后重载插件。"
},
"image_quality": {
"description": "农场图片质量",
"type": "int",
"default": 75,
"hint": "整数1~95,下一次图片请求生效;仅我的农场允许高清,质量不低于85。PNG不受影响。"
}
}
+87
View File
@@ -0,0 +1,87 @@
"""AstrBot薄接入;业务、交易及绘图复用共享运行时。"""
import asyncio
import json
import logging
from pathlib import Path
from astrbot.api import AstrBotConfig
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import At, Image
from astrbot.api.star import Context, Star, StarTools
from .runtime import COMMANDS, Runtime
logger = logging.getLogger(__name__)
class FarmPlugin(Star):
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
self.config = config
self._lifecycle_lock = asyncio.Lock()
self._closed = False
configured = config.get('data_dir', '')
data_dir = (Path(configured).expanduser() if configured else
StarTools.get_data_dir('astrbot_plugin_farm'))
image_format = str(config.get('image_format', 'AUTO')).upper()
if image_format not in ('AUTO', 'JPEG', 'PNG'):
raise ValueError('image_format must be AUTO, JPEG or PNG')
self.runtime = Runtime(
data_dir, config.get('font_path') or None, 'astrbot',
image_format, font_setting='font_path',
image_quality=config.get('image_quality', 75),
image_quality_provider=lambda: self.config.get('image_quality', 75),
)
async def initialize(self):
async with self._lifecycle_lock:
if not self._closed:
await self.runtime.start()
@filter.command('农场帮助', alias=set(COMMANDS) - {'农场帮助'})
async def farm(self, event: AstrMessageEvent):
"""农场帮助、注册、种植、仓库、交易及图片查询。"""
if self._closed:
yield event.plain_result('农场插件已关闭。')
return
await self.initialize()
parts = event.get_message_str().strip().split(maxsplit=1)
if not parts or parts[0] not in COMMANDS:
return
action = COMMANDS[parts[0]]
arguments = parts[1] if len(parts) == 2 else ''
target = None
if action == 'steal':
mentions = [str(segment.qq) for segment in event.get_messages()
if isinstance(segment, At)
and str(segment.qq) != str(event.get_self_id())]
if len(mentions) != 1 or mentions[0] in ('', 'all', '0'):
yield event.plain_result('请@一个具体的偷菜目标。')
return
target = mentions[0]
platform_id = event.get_platform_id()
if not platform_id or not event.get_sender_id():
yield event.plain_result('无法取得稳定的平台或用户标识,未执行。')
return
bot_id = json.dumps([str(platform_id), str(event.get_self_id())])
response = await self.runtime.execute(
action, arguments, bot_id=bot_id,
platform=event.get_platform_name(), user_id=event.get_sender_id(),
message_id=getattr(event.message_obj, 'message_id', None),
display_name=event.get_sender_name() or '农场玩家',
target_user_id=target,
)
if isinstance(response, bytes):
yield event.chain_result([Image.fromBytes(response)])
else:
yield event.plain_result(response)
async def terminate(self):
async with self._lifecycle_lock:
if self._closed:
return
self._closed = True
await self.runtime.close()
logger.info('farm astrbot plugin terminated')
+1
View File
@@ -0,0 +1 @@
Pillow>=10.4
+1
View File
@@ -21,6 +21,7 @@ runtime = Runtime(
data_directory(store.get_plugin_data_dir(), config.farm_data_dir), data_directory(store.get_plugin_data_dir(), config.farm_data_dir),
config.farm_font, config.farm_deployment, config.farm_image_format, config.farm_font, config.farm_deployment, config.farm_image_format,
font_setting="FARM_FONT", font_setting="FARM_FONT",
image_quality=config.farm_image_quality,
) )
driver.on_startup(runtime.start) driver.on_startup(runtime.start)
driver.on_shutdown(runtime.close) driver.on_shutdown(runtime.close)
+5 -1
View File
@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, conint
class Config(BaseModel): class Config(BaseModel):
@@ -21,3 +21,7 @@ class Config(BaseModel):
farm_image_format: Literal["AUTO", "JPEG", "PNG"] = Field( farm_image_format: Literal["AUTO", "JPEG", "PNG"] = Field(
"AUTO", description="图片格式,AUTO 优先使用压缩 JPEG", "AUTO", description="图片格式,AUTO 优先使用压缩 JPEG",
) )
farm_image_quality: conint(strict=True, ge=1, le=95) = Field(
75,
description="全部农场图片编码质量;仅我的农场高清时至少85",
)
+12
View File
@@ -7,3 +7,15 @@
生成时修正为插件私有相对导入,本目录本身不是完整安装包。 生成时修正为插件私有相对导入,本目录本身不是完整安装包。
使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。 使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。
本轮没有拉取、推送或操作真实机器人。 本轮没有拉取、推送或操作真实机器人。
## 图片质量
真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。
它统一控制我的农场、地块编号、农场详述、种子商店、我的种子及
我的作物图片的编码质量,修改后下一次图片请求生效。值越高通常
越清晰、文件越大;不会改变默认图片尺寸,PNG无损格式不受影响。
无效配置明确提示错误,不会自动截断或转换为合法值。
只有“我的农场 高清”接受高清,宽1536,质量取配置值与85的较大值。
其他命令不接受高清。仓库继续支持“我的种子 2 文字”和
“我的作物 2 文字”等分页文字查询。
+7 -1
View File
@@ -1,7 +1,7 @@
"""真寻农场独立预览插件入口。""" """真寻农场独立预览插件入口。"""
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import Command, PluginExtraData from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from ._version import __version__ from ._version import __version__
from .runtime import COMMANDS, HELP from .runtime import COMMANDS, HELP
@@ -14,6 +14,12 @@ __plugin_meta__ = PluginMetadata(
extra=PluginExtraData( extra=PluginExtraData(
author="Shu-Ying", version=__version__, menu_type="群内小游戏", author="Shu-Ying", version=__version__, menu_type="群内小游戏",
commands=[Command(command=name) for name in COMMANDS], commands=[Command(command=name) for name in COMMANDS],
configs=[RegisterConfig(
key="农场图片质量", value=75, default_value=75, type=int,
help="全部农场图片的编码质量,整数1~95,默认75;"
"数值越高通常越清晰、文件越大。仅我的农场支持高清,"
"高清质量不低于85。PNG为无损格式,不受此值影响。",
)],
).to_dict(), ).to_dict(),
) )
+10
View File
@@ -18,6 +18,15 @@ from .runtime import COMMANDS, Runtime
from .data_paths import data_directory from .data_paths import data_directory
from .config_migration import migrate_config from .config_migration import migrate_config
def image_quality():
from zhenxun.configs.config import Config
return Config.get(__package__.split('.')[-1]).get(
'农场图片质量', 75, build_model=False,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
driver = get_driver() driver = get_driver()
data_dir = data_directory( data_dir = data_directory(
@@ -29,6 +38,7 @@ runtime = Runtime(
getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"), getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"),
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(), str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
font_dir=FONT_PATH, font_dir=FONT_PATH,
image_quality_provider=image_quality,
) )
async def start_runtime(): async def start_runtime():
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
+31 -7
View File
@@ -34,15 +34,17 @@ COMMANDS = {
} }
HELP = ( HELP = (
"开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n" "开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n"
"地块编号 [高清]|查看当前土地编号及开垦位置\n" "地块编号|查看当前土地编号及开垦位置\n"
"偷菜 @目标|每日最多5次成功偷取\n" "偷菜 @目标|每日最多5次成功偷取\n"
"我的种子/我的作物 [页码] [高清/文字],默认标准压缩图。\n" "我的种子/我的作物 [页码] [文字],默认图片。\n"
"全部图片使用农场图片质量配置;仅我的农场支持高清。\n"
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
"收获|铲除|农场除虫|我的种子 [页码]|我的作物 [页码]\n" "收获|铲除|农场除虫|我的种子 [页码]|我的作物 [页码]\n"
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n" "作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
"作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n" "作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n"
"每成功播种1块地获得2经验;收获经验按作物结算。\n" "每成功播种1块地获得2经验;收获经验按作物结算。\n"
"每铲除1块枯萎地块获得2经验;每清除1只虫获得1经验。\n" "每铲除1块枯萎地块获得2经验;每清除1只虫获得1经验。\n"
"新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。\n"
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n" "每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n" "成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
"我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n" "我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n"
@@ -88,7 +90,11 @@ def operation_id(bot_id, platform, user_id, message_id, action):
class Runtime: class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview", def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None, image_format="AUTO", *, font_dir=None,
font_setting="FARM_PREVIEW_FONT"): font_setting="FARM_PREVIEW_FONT", image_quality=75,
image_quality_provider=None):
RenderOptions(quality=image_quality)
self.image_quality = image_quality
self.image_quality_provider = image_quality_provider
self.data_dir = Path(data_dir).resolve() self.data_dir = Path(data_dir).resolve()
self.font_path = font_path self.font_path = font_path
self.font_setting = font_setting self.font_setting = font_setting
@@ -198,6 +204,21 @@ class Runtime:
) )
if action == "help": if action == "help":
return HELP return HELP
image_actions = ('farm', 'plot_ids', 'details', 'shop',
'seeds', 'crops')
if action in image_actions and action != 'farm' and '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
quality = self.image_quality
text_mode = (action in ('seeds', 'crops') and args
and args[-1] == '文字')
if action in image_actions and self.rendering and not text_mode:
try:
if self.image_quality_provider is not None:
quality = self.image_quality_provider()
RenderOptions(quality=quality)
except (TypeError, ValueError):
logger.warning('farm image quality config invalid')
return '农场图片质量配置无效,请设置为1~95的整数。'
if action == "shop": if action == "shop":
page = int(args[0]) if args else 1 page = int(args[0]) if args else 1
if len(args) > 1: if len(args) > 1:
@@ -216,6 +237,7 @@ class Runtime:
return await self.rendering.seed_shop( return await self.rendering.seed_shop(
chunk, account, page, pages, chunk, account, page, pages,
image_format=self.image_format, image_format=self.image_format,
quality=quality,
) )
return f'种子商店 第{page}/{pages}页\n' + '\n'.join( return f'种子商店 第{page}/{pages}页\n' + '\n'.join(
f'{p.name}(#{p.plant_id}):' f'{p.name}(#{p.plant_id}):'
@@ -243,10 +265,11 @@ class Runtime:
return await self.rendering.farm_details( return await self.rendering.farm_details(
who, snapshot, now, page, season_for(now), who, snapshot, now, page, season_for(now),
image_format=self.image_format, avatar=avatar, image_format=self.image_format, avatar=avatar,
quality=quality,
) )
if action in ('crops', 'seeds'): if action in ('crops', 'seeds'):
title = '我的作物' if action == 'crops' else '我的种子' title = '我的作物' if action == 'crops' else '我的种子'
mode = args.pop() if args and args[-1] in ('高清', '文字') else '' mode = args.pop() if args and args[-1] == '文字' else ''
if len(args) > 1: if len(args) > 1:
raise ValueError() raise ValueError()
try: try:
@@ -263,7 +286,7 @@ class Runtime:
return warehouse_text(rows, snapshot, action, page, pages) return warehouse_text(rows, snapshot, action, page, pages)
return await self.rendering.warehouse( return await self.rendering.warehouse(
rows, snapshot, action, page, pages, self.image_format, rows, snapshot, action, page, pages, self.image_format,
mode == '高清', quality=quality,
) )
if action == "inventory": if action == "inventory":
if args: if args:
@@ -280,9 +303,10 @@ class Runtime:
theme = season_for(now) theme = season_for(now)
logger.info("farm preview seasonal theme=%s", theme) logger.info("farm preview seasonal theme=%s", theme)
options = ( options = (
RenderOptions.high(format=self.image_format, theme=theme) RenderOptions(width=1536, quality=max(quality, 85),
format=self.image_format, theme=theme)
if args else RenderOptions(format=self.image_format, if args else RenderOptions(format=self.image_format,
theme=theme) theme=theme, quality=quality)
) )
if action == "plot_ids": if action == "plot_ids":
options = replace(options, show_plot_ids=True) options = replace(options, show_plot_ids=True)
+4
View File
@@ -9,6 +9,10 @@
向固定仓库 `main` 普通提交完整生成树,保留历史并检查分支并发变更。 向固定仓库 `main` 普通提交完整生成树,保留历史并检查分支并发变更。
生成树会替换目标分支的当前文件集合,首次同步前应审查输出内容。 生成树会替换目标分支的当前文件集合,首次同步前应审查输出内容。
构建清单记录对应源提交的完整说明;两套同步脚本沿用该说明,并附加
`Farm-Source-Commit` 和 `Farm-Version` 追溯信息。发布时不读取新的 HEAD。
默认 dry-run 报告也会显示最终提交说明。旧产物缺少源说明时需重新构建。
NoneBot 既有 `publish.yaml` 已从远端读取,生成模板继续使用 `v*` NoneBot 既有 `publish.yaml` 已从远端读取,生成模板继续使用 `v*`
标签触发及 `PYPI_TOKEN`。主仓库的同步任务沿用真寻 environment 标签触发及 `PYPI_TOKEN`。主仓库的同步任务沿用真寻 environment
`zhenxun-preview` 和 `FARM_RELEASE_TOKEN`,普通同步不会发布 PyPI。 `zhenxun-preview` 和 `FARM_RELEASE_TOKEN`,普通同步不会发布 PyPI。
+84
View File
@@ -0,0 +1,84 @@
"""白名单生成AstrBot可安装包,复用已校验的共享模块打包路径。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path
import tempfile
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import build as build_shared, read_version, source_identity
from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
from verify_zhenxun import read_package as read_shared
def build(root, output):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
raise FileExistsError('Use a new output directory')
if output.is_relative_to(root) and not output.is_relative_to(root / 'dist'):
raise ValueError('In-project output must be inside dist')
with tempfile.TemporaryDirectory() as temporary:
shared = read_shared(build_shared(root, Path(temporary) / 'shared'))
files = {name: content for name, content in shared.items()
if name.startswith('_vendor/') or name in (
'_version.py', 'version.txt', 'LICENSE',
)}
source = root / 'integrations/astrbot'
for name in ('main.py', '_conf_schema.json', 'requirements.txt'):
files[name] = (source / name).read_bytes()
files['README.md'] = (source / 'README.md').read_bytes()
files['runtime.py'] = (root / 'integrations/zhenxun/runtime.py').read_bytes()
files['verify_package.py'] = (root / 'scripts/verify_astrbot.py').read_bytes()
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
files['metadata.yaml'] = (json.dumps({
'name': ROOT_NAME, 'display_name': '农场',
'desc': '种植、收获、仓库、交易与农场图片,复用Farm共享核心。',
'version': version, 'author': 'Shu-Ying',
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
}, ensure_ascii=False, indent=2) + '\n').encode()
for name, content in files.items():
if name.endswith('.py'):
ast.parse(content, filename=name, feature_version=(3, 10))
manifest = {
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
'version': version, 'source': source_identity(root),
'files': {name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())},
}
files[MANIFEST] = (json.dumps(
manifest, ensure_ascii=False, sort_keys=True, indent=2,
) + '\n').encode()
verify_files(files)
output.mkdir(parents=True)
for name, content in sorted(files.items()):
target = output / 'repository' / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
archive_path = output / f'{ROOT_NAME}-{version}.zip'
with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive:
for name, content in sorted(files.items()):
item = ZipInfo(f'{ROOT_NAME}/{name}', (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.compress_type = ZIP_DEFLATED
item.external_attr = 0o100644 << 16
archive.writestr(item, content)
verify_package(archive_path)
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix('.zip.sha256').write_text(
f'{digest} {archive_path.name}\n', encoding='ascii',
)
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
if __name__ == '__main__':
main()
+71
View File
@@ -0,0 +1,71 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet('nonebot', 'zhenxun', 'astrbot')]
[string]$Platform,
[string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
$farmProjectRoot = Split-Path -Parent $PSScriptRoot
$farmCandidates = @()
if ($env:FARM_PYTHON) {
$farmCandidates += @{ Exe = $env:FARM_PYTHON; Prefix = @() }
} else {
foreach ($farmLocal in @('.venv\Scripts\python.exe',
'venv\Scripts\python.exe')) {
$farmCandidate = Join-Path $farmProjectRoot $farmLocal
if (Test-Path -LiteralPath $farmCandidate -PathType Leaf) {
$farmCandidates += @{ Exe = $farmCandidate; Prefix = @() }
}
}
foreach ($farmName in @('python', 'py')) {
$farmCommand = Get-Command $farmName -ErrorAction SilentlyContinue
if ($farmCommand) {
$farmPrefix = @()
if ($farmName -eq 'py') { $farmPrefix = @('-3') }
$farmCandidates += @{ Exe = $farmCommand.Source;
Prefix = $farmPrefix }
}
}
if ($env:USERPROFILE) {
$farmBundled = Join-Path $env:USERPROFILE ('.cache\codex-runtimes\' +
'codex-primary-runtime\dependencies\python\python.exe')
if (Test-Path -LiteralPath $farmBundled -PathType Leaf) {
$farmCandidates += @{ Exe = $farmBundled; Prefix = @() }
}
}
}
$farmPython = $null
foreach ($farmCandidate in $farmCandidates) {
try {
$farmExe = $farmCandidate.Exe
$farmPrefix = $farmCandidate.Prefix
& $farmExe @farmPrefix -c 'import sys; sys.exit(sys.version_info < (3, 10))'
if ($LASTEXITCODE -eq 0) {
$farmPython = $farmCandidate
break
}
} catch {
Write-Host ('Python candidate unavailable: ' + $farmCandidate.Exe)
}
}
if (-not $farmPython) {
Write-Error 'Python 3.10+ not found. Install Python or set FARM_PYTHON.'
exit 1
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
$farmGit = Join-Path $env:USERPROFILE ('.cache\codex-runtimes\' +
'codex-primary-runtime\dependencies\native\git\cmd')
if (Test-Path -LiteralPath (Join-Path $farmGit 'git.exe')) {
$env:PATH = $farmGit + ';' + $env:PATH
}
}
$farmExe = $farmPython.Exe
$farmArguments = @($farmPython.Prefix) + @(
(Join-Path $PSScriptRoot 'build_plugin.py'), $Platform
)
if ($OutputDirectory) { $farmArguments += @('--output', $OutputDirectory) }
& $farmExe @farmArguments
exit $LASTEXITCODE
+36
View File
@@ -0,0 +1,36 @@
"""三平台本地打包入口;每次使用独立新目录,不执行发布或安装。"""
import argparse
from datetime import datetime, timezone
import importlib
from pathlib import Path
import uuid
PLATFORMS = ('nonebot', 'zhenxun', 'astrbot')
def build_plugin(platform, output=None, *, root=None):
if platform not in PLATFORMS:
raise ValueError('Unsupported platform')
source = Path(__file__).resolve().parents[1]
if output is None:
parent = Path(root) if root else source / 'dist'
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')
output = parent / f'{platform}-{timestamp}-{uuid.uuid4().hex[:8]}'
module = importlib.import_module('build_' + platform)
return module.build(source, Path(output).resolve())
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('platform', choices=PLATFORMS)
parser.add_argument('--output', type=Path)
args = parser.parse_args()
archive = build_plugin(args.platform, args.output)
print(f'Package: {archive}')
print(f'Checksum: {archive.with_suffix(".zip.sha256")}')
if __name__ == '__main__':
main()
+3 -1
View File
@@ -17,9 +17,11 @@ def source_identity(root):
return subprocess.check_output( return subprocess.check_output(
["git", *args], cwd=root, text=True, encoding="utf-8", ["git", *args], cwd=root, text=True, encoding="utf-8",
).strip() ).strip()
commit = git("rev-parse", "HEAD")
return { return {
"repository": "https://github.com/Shu-Ying/main_plugin_farm.git", "repository": "https://github.com/Shu-Ying/main_plugin_farm.git",
"commit": git("rev-parse", "HEAD"), "commit": commit,
"message": git("show", "-s", "--format=%B", commit),
"dirty": bool(git("status", "--porcelain", "--untracked-files=normal")), "dirty": bool(git("status", "--porcelain", "--untracked-files=normal")),
} }
+5 -3
View File
@@ -8,6 +8,7 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from verify_nonebot import read_package, verify_files from verify_nonebot import read_package, verify_files
from release_metadata import commit_message
REPOSITORY = "Shu-Ying/nonebot_plugin_farm" REPOSITORY = "Shu-Ying/nonebot_plugin_farm"
BRANCH = "main" BRANCH = "main"
@@ -39,8 +40,10 @@ def github(method, path, payload, token, allow_missing=False):
def publish(package, confirmed=False, request=None): def publish(package, confirmed=False, request=None):
files = read_package(package) files = read_package(package)
manifest = verify_files(files) manifest = verify_files(files)
message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH, result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "published": False} "version": manifest["version"], "message": message,
"published": False}
if not confirmed: if not confirmed:
return result return result
if manifest["source"]["dirty"]: if manifest["source"]["dirty"]:
@@ -75,8 +78,7 @@ def publish(package, confirmed=False, request=None):
"sha": blob["sha"]}) "sha": blob["sha"]})
generated = call("POST", "git/trees", {"tree": tree}) generated = call("POST", "git/trees", {"tree": tree})
commit = call("POST", "git/commits", { commit = call("POST", "git/commits", {
"message": f"Generate NoneBot farm {manifest['version']} " "message": message,
f"from {manifest['source']['commit']}",
"tree": generated["sha"], "parents": [parent] if parent else [], "tree": generated["sha"], "parents": [parent] if parent else [],
}) })
if parent: if parent:
+5 -3
View File
@@ -8,6 +8,7 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from verify_zhenxun import read_package, verify_files from verify_zhenxun import read_package, verify_files
from release_metadata import commit_message
REPOSITORY = "Shu-Ying/zhenxun_plugin_farm" REPOSITORY = "Shu-Ying/zhenxun_plugin_farm"
BRANCH = "main" BRANCH = "main"
@@ -39,8 +40,10 @@ def github(method, path, payload, token, allow_missing=False):
def publish(package, confirmed=False, request=None): def publish(package, confirmed=False, request=None):
files = read_package(package) files = read_package(package)
manifest = verify_files(files) manifest = verify_files(files)
message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH, result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "published": False} "version": manifest["version"], "message": message,
"published": False}
if not confirmed: if not confirmed:
return result return result
if manifest["source"]["dirty"]: if manifest["source"]["dirty"]:
@@ -75,8 +78,7 @@ def publish(package, confirmed=False, request=None):
"sha":blob["sha"]}) "sha":blob["sha"]})
generated = call("POST", "git/trees", {"tree":tree}) generated = call("POST", "git/trees", {"tree":tree})
commit = call("POST", "git/commits", { commit = call("POST", "git/commits", {
"message": f"Generate farm preview {manifest['version']} " "message": message,
f"from {manifest['source']['commit']}",
"tree": generated["sha"], "parents": [parent] if parent else [], "tree": generated["sha"], "parents": [parent] if parent else [],
}) })
if parent: if parent:
+13
View File
@@ -0,0 +1,13 @@
"""从构建清单生成发布提交说明,不查询发布时的工作目录。"""
def commit_message(manifest):
source = manifest["source"]
message = source.get("message")
if not isinstance(message, str) or not message.strip():
raise ValueError("Missing source commit message; rebuild the package")
return (
f"{message.rstrip()}\n\n"
f"Farm-Source-Commit: {source['commit']}\n"
f"Farm-Version: {manifest['version']}"
)
+85
View File
@@ -0,0 +1,85 @@
"""AstrBot插件ZIP的独立完整性校验;只使用标准库。"""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
from zipfile import ZipFile
ROOT_NAME = 'astrbot_plugin_farm'
MANIFEST = 'FARM-MANIFEST.json'
MAX_BYTES = 100 * 1024 * 1024
def safe_path(name):
path = PurePosixPath(name)
if (not name or '\\' in name or ':' in name or path.is_absolute()
or any(p in ('', '.', '..') for p in name.split('/'))):
raise ValueError('Unsafe package path')
return path
def verify_files(files):
if MANIFEST not in files or len(files[MANIFEST]) > 1024 * 1024:
raise ValueError('Missing or oversized manifest')
manifest = json.loads(files[MANIFEST])
if (manifest.get('schema') != 1 or manifest.get('target') != 'astrbot'
or manifest.get('plugin') != ROOT_NAME):
raise ValueError('Invalid package identity')
expected = manifest.get('files')
if not isinstance(expected, dict) or set(files) != set(expected) | {MANIFEST}:
raise ValueError('Package files differ from manifest')
for name, digest in expected.items():
safe_path(name)
if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError('Package checksum mismatch')
required = {'main.py', 'metadata.yaml', '_conf_schema.json', 'runtime.py',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
'_vendor/farm_core/__init__.py',
'_vendor/farm_render/data/assets.zip'}
if not required <= set(files):
raise ValueError('Incomplete plugin')
metadata = json.loads(files['metadata.yaml'])
if (metadata.get('name') != ROOT_NAME
or metadata.get('version') != manifest['version']
or files['version.txt'].decode().strip() != manifest['version']):
raise ValueError('Package version mismatch')
return manifest
def read_package(path):
with ZipFile(path) as archive:
items = archive.infolist()
names = [item.filename for item in items]
if (len(names) != len(set(names)) or len(names) > 200
or sum(item.file_size for item in items) > MAX_BYTES):
raise ValueError('Duplicate paths or package too large')
files = {}
for item in items:
name = safe_path(item.filename)
if (len(name.parts) < 2 or name.parts[0] != ROOT_NAME
or item.is_dir()
or stat.S_ISLNK(item.external_attr >> 16)):
raise ValueError('Unexpected package root or link')
files[name.relative_to(ROOT_NAME).as_posix()] = archive.read(item)
verify_files(files)
return files
def verify_package(path):
return verify_files(read_package(path))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('package', type=Path)
args = parser.parse_args()
manifest = verify_package(args.package)
print(f"Verified astrbot {manifest['version']}: "
f"{len(manifest['files'])} files")
if __name__ == '__main__':
main()
+2 -1
View File
@@ -11,7 +11,8 @@ def season_duration(plot: Plot) -> int:
def spawn_interval(plot: Plot) -> int: def spawn_interval(plot: Plot) -> int:
return max(3600, (season_duration(plot) + 3) // 4) """至少留出两小时,每半个基础生长周期才发生一次虫害。"""
return max(7200, (season_duration(plot) + 1) // 2)
def start_growth(plot: Plot, now: int) -> Plot: def start_growth(plot: Plot, now: int) -> Plot:
+6 -4
View File
@@ -98,9 +98,10 @@ def details_text(rows, page, pages):
def render_details(renderer, identity, snapshot, now, page, def render_details(renderer, identity, snapshot, now, page,
theme='default', image_format='AUTO', avatar=None): theme='default', image_format='AUTO', avatar=None,
quality=75):
rows, pages = detail_rows(renderer, identity, snapshot, now, page) rows, pages = detail_rows(renderer, identity, snapshot, now, page)
options = RenderOptions(format=image_format, quality=85, theme=theme) options = RenderOptions(format=image_format, quality=quality, theme=theme)
farm = renderer.render(identity, snapshot, now, options, avatar) farm = renderer.render(identity, snapshot, now, options, avatar)
height = 922 + max(1, len(rows))*92 height = 922 + max(1, len(rows))*92
canvas = Canvas.new((1024, height), '#ac713c') canvas = Canvas.new((1024, height), '#ac713c')
@@ -166,6 +167,7 @@ def render_details(renderer, identity, snapshot, now, page,
text(navigation, 40, footer, 22, width=940) text(navigation, 40, footer, 22, width=940)
codec = options.codec(('JPEG',)) codec = options.codec(('JPEG',))
content = canvas.encode(codec, options.quality, options.method) content = canvas.encode(codec, options.quality, options.method)
logger.info('farm details page=%s pages=%s rows=%s bytes=%s codec=%s', logger.info('farm details page=%s pages=%s rows=%s bytes=%s codec=%s '
page, pages, len(rows), len(content), codec) 'quality=%s', page, pages, len(rows), len(content), codec,
options.quality)
return content return content
+10 -9
View File
@@ -68,11 +68,12 @@ class RenderService:
return await self._run_function(self.renderer.render, *args) return await self._run_function(self.renderer.render, *args)
async def seed_shop(self, plants, account, page, pages, async def seed_shop(self, plants, account, page, pages,
image_format='AUTO'): image_format='AUTO', quality=75):
if self._closed: if self._closed:
raise RuntimeError('Render service closed') raise RuntimeError('Render service closed')
key = 'shop:' + hashlib.sha256( key = 'shop:' + hashlib.sha256(
repr((plants, account, page, pages, image_format)).encode(), repr((plants, account, page, pages, image_format,
quality)).encode(),
).hexdigest() ).hexdigest()
task = self._pending.get(key) task = self._pending.get(key)
if task is None: if task is None:
@@ -80,7 +81,7 @@ class RenderService:
raise RuntimeError('Render queue full') raise RuntimeError('Render queue full')
task = asyncio.create_task(self._run_function( task = asyncio.create_task(self._run_function(
render_shop, self.renderer, plants, account, page, pages, render_shop, self.renderer, plants, account, page, pages,
image_format, image_format, quality,
)) ))
self._pending[key] = task self._pending[key] = task
@@ -93,11 +94,11 @@ class RenderService:
return await asyncio.shield(task) return await asyncio.shield(task)
async def farm_details(self, identity, snapshot, now, page, theme, async def farm_details(self, identity, snapshot, now, page, theme,
image_format='AUTO', avatar=None): image_format='AUTO', avatar=None, quality=75):
if self._closed: if self._closed:
raise RuntimeError('Render service closed') raise RuntimeError('Render service closed')
key = 'details:' + hashlib.sha256(repr(( key = 'details:' + hashlib.sha256(repr((
identity, snapshot, now, page, theme, image_format, avatar, identity, snapshot, now, page, theme, image_format, avatar, quality,
)).encode()).hexdigest() )).encode()).hexdigest()
task = self._pending.get(key) task = self._pending.get(key)
if task is None: if task is None:
@@ -105,7 +106,7 @@ class RenderService:
raise RuntimeError('Render queue full') raise RuntimeError('Render queue full')
task = asyncio.create_task(self._run_function( task = asyncio.create_task(self._run_function(
render_details, self.renderer, identity, snapshot, now, render_details, self.renderer, identity, snapshot, now,
page, theme, image_format, avatar, page, theme, image_format, avatar, quality,
)) ))
self._pending[key] = task self._pending[key] = task
@@ -118,11 +119,11 @@ class RenderService:
return await asyncio.shield(task) return await asyncio.shield(task)
async def warehouse(self, rows, snapshot, kind, page, pages, async def warehouse(self, rows, snapshot, kind, page, pages,
image_format='AUTO', high=False): image_format='AUTO', high=False, quality=75):
if self._closed: if self._closed:
raise RuntimeError('Render service closed') raise RuntimeError('Render service closed')
key = ('warehouse', rows, snapshot, kind, page, pages, key = ('warehouse', rows, snapshot, kind, page, pages,
image_format, high) image_format, high, quality)
cached = self.renderer.results.get(key) cached = self.renderer.results.get(key)
if cached is not None: if cached is not None:
return cached return cached
@@ -132,7 +133,7 @@ class RenderService:
raise RuntimeError('Render queue full') raise RuntimeError('Render queue full')
task = asyncio.create_task(self._run_function( task = asyncio.create_task(self._run_function(
render_warehouse, self.renderer, rows, snapshot, kind, render_warehouse, self.renderer, rows, snapshot, kind,
page, pages, image_format, high, page, pages, image_format, high, quality,
)) ))
self._pending[key] = task self._pending[key] = task
+6 -4
View File
@@ -30,8 +30,9 @@ def duration(seconds):
) if value) or '0秒' ) if value) or '0秒'
def render_shop(renderer, plants, account, page, pages, image_format='AUTO'): def render_shop(renderer, plants, account, page, pages, image_format='AUTO',
options = RenderOptions(format=image_format, quality=80) quality=75):
options = RenderOptions(format=image_format, quality=quality)
codec = options.codec() codec = options.codec()
canvas = Canvas(renderer.assets.image('webgl/shop-board.png', (1024, 1536))) canvas = Canvas(renderer.assets.image('webgl/shop-board.png', (1024, 1536)))
@@ -94,6 +95,7 @@ def render_shop(renderer, plants, account, page, pages, image_format='AUTO'):
navigation += f' 上一页:种子商店 {page-1}' navigation += f' 上一页:种子商店 {page-1}'
text(navigation, (263, 1420), 23, width=510) text(navigation, (263, 1420), 23, width=510)
content = canvas.encode(codec, options.quality, options.method) content = canvas.encode(codec, options.quality, options.method)
logger.info('farm shop render width=%s height=%s bytes=%s codec=%s', logger.info('farm shop render width=%s height=%s bytes=%s codec=%s '
*canvas.image.size, len(content), codec) 'quality=%s', *canvas.image.size, len(content), codec,
options.quality)
return content return content
+3 -2
View File
@@ -110,9 +110,10 @@ def warehouse_text(rows, snapshot, kind, page, pages):
def render_warehouse(renderer, rows, snapshot, kind, page, pages, def render_warehouse(renderer, rows, snapshot, kind, page, pages,
image_format='AUTO', high=False): image_format='AUTO', high=False, quality=75):
options = RenderOptions(width=1024 if high else 768, options = RenderOptions(width=1024 if high else 768,
format=image_format, quality=85 if high else 70) format=image_format,
quality=max(85, quality) if high else quality)
codec = options.codec() codec = options.codec()
scale = options.width/1024 scale = options.width/1024
s = lambda n: round(n*scale) s = lambda n: round(n*scale)
+157
View File
@@ -0,0 +1,157 @@
import ast
import hashlib
import importlib
import json
from pathlib import Path
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
async def test_reproducible_complete_package_and_tamper_rejection(self):
from build_astrbot import build
from verify_astrbot import verify_files, read_package
first = build(ROOT, self.root / 'one')
second = build(ROOT, self.root / 'two')
self.assertEqual(first.read_bytes(), second.read_bytes())
files = read_package(first)
manifest = verify_files(files)
version = (ROOT / 'version.txt').read_text().strip()
self.assertEqual(manifest['version'], version)
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
self.assertEqual(files['runtime.py'], (
ROOT / 'integrations/zhenxun/runtime.py'
).read_bytes())
for name, content in files.items():
self.assertNotIn('.git/', name)
self.assertNotIn('farm.sqlite3', name)
if name.endswith('.py'):
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
self.assertFalse((node.module or '').startswith(
('nonebot', 'zhenxun'),
))
files['main.py'] += b'\n# tampered\n'
with self.assertRaisesRegex(ValueError, 'checksum'):
verify_files(files)
with self.assertRaises(FileExistsError):
build(ROOT, self.root / 'one')
expected = hashlib.sha256(first.read_bytes()).hexdigest()
self.assertIn(expected, first.with_suffix('.zip.sha256').read_text())
async def test_mock_host_commands_images_identity_and_shutdown(self):
from build_astrbot import build
from ci_font import choose_font
build(ROOT, self.root / 'host')
package = ModuleType('astrbot_farm_fixture')
package.__path__ = [str(self.root / 'host/repository')]
api = ModuleType('astrbot.api')
api.AstrBotConfig = dict
events = ModuleType('astrbot.api.event')
commands = {}
def command(name, alias=None):
def decorate(handler):
commands.update({c: handler for c in {name} | (alias or set())})
return handler
return decorate
events.filter = SimpleNamespace(command=command)
events.AstrMessageEvent = type('Event', (), {})
star = ModuleType('astrbot.api.star')
class Star:
def __init__(self, context):
self.context = context
star.Star, star.Context = Star, object
star.StarTools = SimpleNamespace(
get_data_dir=lambda name: self.root / 'plugin-data',
)
components = ModuleType('astrbot.api.message_components')
components.At = type('At', (), {})
components.Image = SimpleNamespace(fromBytes=lambda content: content)
modules = {m.__name__: m for m in (
package, api, events, star, components,
)}
with patch.dict(sys.modules, modules):
try:
main = importlib.import_module(package.__name__ + '.main')
plugin = main.FarmPlugin(object(), {
'font_path': str(choose_font()), 'image_quality': 55,
})
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
class Event:
message_obj = SimpleNamespace(message_id='one')
def __init__(self, text, platform_id='bot-a'):
self.text, self.platform_id = text, platform_id
def get_message_str(self):
return self.text
def get_self_id(self):
return 'bot'
def get_platform_id(self):
return self.platform_id
def get_platform_name(self):
return 'aiocqhttp'
def get_sender_id(self):
return 'player'
def get_sender_name(self):
return '离线测试'
def get_messages(self):
return []
def plain_result(self, text):
return text
def chain_result(self, chain):
return chain
async def call(text, platform_id='bot-a'):
return [r async for r in plugin.farm(
Event(text, platform_id),
)][0]
first = await call('开通农场')
self.assertIn('6块土地', first)
again = await call('开通农场')
self.assertIsInstance(again, str)
self.assertIn('开通农场', await call('我的种子', 'bot-b'))
result = await call('我的农场 高清')
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(result[0])) as image:
self.assertEqual(image.width, 1536)
self.assertIn('仅我的农场', await call('我的种子 高清'))
self.assertIn('暂无种子', await call('我的种子 文字'))
self.assertTrue((self.root / 'plugin-data/farm.sqlite3').is_file())
await plugin.terminate()
self.assertFalse(plugin.runtime.started)
finally:
if 'plugin' in locals() and plugin.runtime.started:
await plugin.terminate()
for name in tuple(sys.modules):
if name.startswith(package.__name__ + '.'):
del sys.modules[name]
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
class BuildPluginTests(unittest.TestCase):
def test_all_platforms_from_another_directory_and_repeated_output(self):
from build_plugin import build_plugin
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for platform in ('nonebot', 'zhenxun', 'astrbot'):
first = build_plugin(platform, root=root / platform)
before = first.read_bytes()
second = build_plugin(platform, root=root / platform)
self.assertNotEqual(first.parent, second.parent)
self.assertEqual(first.read_bytes(), before)
self.assertTrue(second.is_file())
@unittest.skipUnless(os.name == 'nt', 'Windows entry points')
def test_cmd_entries_and_nonzero_failure(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
env = {**os.environ, 'FARM_PYTHON': sys.executable,
'FARM_BUILD_NO_PAUSE': '1'}
for platform in ('nonebot', 'zhenxun', 'astrbot'):
script = ROOT / f'build_{platform}.cmd'
result = subprocess.run(
['cmd.exe', '/d', '/c', str(script), '-OutputDirectory',
str(root / platform)], cwd=root, env=env,
capture_output=True, timeout=120,
)
self.assertEqual(result.returncode, 0, result.stdout.decode(
errors='replace',
) + result.stderr.decode(errors='replace'))
self.assertEqual(len(list((root / platform).glob('*.zip'))), 1)
env['FARM_PYTHON'] = str(root / 'not-a-python.exe')
failed = subprocess.run(
['cmd.exe', '/d', '/c', str(ROOT / 'build_astrbot.cmd')],
cwd=root, env=env, capture_output=True, timeout=30,
)
self.assertNotEqual(failed.returncode, 0)
+62 -1
View File
@@ -1,8 +1,10 @@
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
from types import SimpleNamespace import sys
from types import ModuleType, SimpleNamespace
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -15,6 +17,65 @@ spec.loader.exec_module(migration)
class ConfigMigrationTests(unittest.TestCase): class ConfigMigrationTests(unittest.TestCase):
def test_zhenxun_registers_global_quality_in_plugin_metadata(self):
plugin = ModuleType('nonebot.plugin')
plugin.PluginMetadata = lambda **values: SimpleNamespace(**values)
utils = ModuleType('zhenxun.configs.utils')
utils.Command = lambda **values: SimpleNamespace(**values)
utils.RegisterConfig = lambda **values: SimpleNamespace(**values)
utils.PluginExtraData = lambda **values: SimpleNamespace(
to_dict=lambda: values,
)
package = ModuleType('quality_metadata_fixture')
package.__path__ = []
version = ModuleType(package.__name__ + '._version')
version.__version__ = '0.0.0'
runtime = ModuleType(package.__name__ + '.runtime')
runtime.COMMANDS, runtime.HELP = {}, ''
commands = ModuleType(package.__name__ + '.commands')
modules = {m.__name__: m for m in (
plugin, utils, package, version, runtime, commands,
)}
source = ROOT / 'integrations/zhenxun/__init__.py'
namespace = {'__package__': package.__name__}
with patch.dict(sys.modules, modules):
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)
@unittest.skipUnless(importlib.util.find_spec('pydantic'),
'NoneBot host config requires Pydantic')
def test_nonebot_quality_config_is_strict_and_bounded(self):
libraries = [importlib.import_module('pydantic')]
try:
libraries.append(importlib.import_module('pydantic.v1'))
except ImportError:
pass
for library in libraries:
with self.subTest(library=library.__name__):
spec = importlib.util.spec_from_file_location(
'farm_nonebot_quality_config',
ROOT / 'integrations/nonebot/config.py',
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'pydantic': library}):
spec.loader.exec_module(module)
config = module.Config
self.assertEqual(config().farm_image_quality, 75)
for value in (1, 95):
actual = config(farm_image_quality=value).farm_image_quality
self.assertEqual(actual, value)
for value in (0, 96, True, '75', 75.5):
with self.subTest(value=value), self.assertRaises(
library.ValidationError,
):
config(farm_image_quality=value)
def test_cleanup_backups_and_repeat_are_scoped(self): def test_cleanup_backups_and_repeat_are_scoped(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
+5
View File
@@ -117,6 +117,11 @@ class NoneBotPackageTests(unittest.TestCase):
commit = next(data for method, path, data in calls commit = next(data for method, path, data in calls
if path == "git/commits") if path == "git/commits")
self.assertEqual(commit["parents"], ["existing"]) self.assertEqual(commit["parents"], ["existing"])
self.assertEqual(commit["message"], (
manifest["source"]["message"] + "\n\n"
+ "Farm-Source-Commit: " + manifest["source"]["commit"] + "\n"
+ "Farm-Version: " + manifest["version"]
))
self.assertIn(("PATCH", "git/refs/heads/main", { self.assertIn(("PATCH", "git/refs/heads/main", {
"sha": "generated", "force": False, "sha": "generated", "force": False,
}), calls) }), calls)
+60 -32
View File
@@ -10,7 +10,7 @@ from unittest.mock import patch
from farm_core import Catalogue, FarmService, Identity, SQLiteStore from farm_core import Catalogue, FarmService, Identity, SQLiteStore
from farm_core.domain import FarmError, Plant from farm_core.domain import FarmError, Plant
from farm_core.repository import SQLiteRepository from farm_core.repository import SQLiteRepository
from farm_core.pests import advance_plot from farm_core.pests import advance_plot, start_growth
class PestTests(unittest.IsolatedAsyncioTestCase): class PestTests(unittest.IsolatedAsyncioTestCase):
@@ -37,41 +37,39 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
return (await self.service.snapshot(self.who)).plots[0] return (await self.service.snapshot(self.who)).plots[0]
async def test_speed_integrates_each_pest_interval_and_offline_maturity(self): async def test_speed_integrates_each_pest_interval_and_offline_maturity(self):
self.now = 4600 self.now = 8199
self.assertEqual((await self.plot()).pest_count, 0)
self.now = 8200
first = await self.plot() first = await self.plot()
self.assertEqual(first.pest_count, 1) self.assertEqual(first.pest_count, 1)
self.assertEqual(first.growth_remaining, 1080000) self.assertEqual(first.growth_remaining, 720000)
self.now = 8200 self.now = 15400
second = await self.plot() second = await self.plot()
self.assertEqual(second.pest_count, 2) self.assertEqual(second.pest_count, 2)
self.assertEqual(second.growth_remaining, 738000) self.assertEqual(second.growth_remaining, 36000)
self.now = 11800 self.now = 15799
third = await self.plot()
self.assertEqual(third.pest_count, 3)
self.assertEqual(third.growth_remaining, 414000)
self.now = 16670
self.assertGreater((await self.plot()).growth_remaining, 0) self.assertGreater((await self.plot()).growth_remaining, 0)
self.now = 16671 self.now = 15800
mature = await self.plot() mature = await self.plot()
self.assertEqual(mature.mature_at, 16671) self.assertEqual(mature.mature_at, 15800)
self.assertEqual(mature.maturity_pests, 3) self.assertEqual(mature.maturity_pests, 2)
receipt = await self.service.harvest(self.who, "harvest") receipt = await self.service.harvest(self.who, "harvest")
self.assertEqual(receipt.data["quantity"], 85) self.assertEqual(receipt.data["quantity"], 90)
self.assertEqual(receipt.data["experience"], 20) self.assertEqual(receipt.data["experience"], 20)
self.assertEqual(receipt.data["harvested"][0]["pests"], 3) self.assertEqual(receipt.data["harvested"][0]["pests"], 2)
again = await self.plot() again = await self.plot()
self.assertEqual(again.pest_count, 0) self.assertEqual(again.pest_count, 0)
self.assertEqual(again.maturity_pests, 0) self.assertEqual(again.maturity_pests, 0)
async def test_clear_before_maturity_restores_rate_but_not_lost_time(self): async def test_clear_before_maturity_restores_rate_but_not_lost_time(self):
self.now = 6400 self.now = 10000
receipt = await self.service.clear_pests(self.who, "clear") receipt = await self.service.clear_pests(self.who, "clear")
self.assertEqual(receipt.data["quantity"], 1) self.assertEqual(receipt.data["quantity"], 1)
cleared = await self.plot() cleared = await self.plot()
self.assertEqual(cleared.pest_count, 0) self.assertEqual(cleared.pest_count, 0)
self.assertEqual(cleared.growth_remaining, 909000) self.assertEqual(cleared.growth_remaining, 549000)
self.assertEqual(cleared.pest_spawned, 1) self.assertEqual(cleared.pest_spawned, 1)
self.now = 8200 self.now = 15400
self.assertEqual((await self.plot()).pest_count, 1) self.assertEqual((await self.plot()).pest_count, 1)
repeated = await self.service.clear_pests(self.who, "clear") repeated = await self.service.clear_pests(self.who, "clear")
self.assertEqual(repeated, receipt) self.assertEqual(repeated, receipt)
@@ -82,12 +80,12 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
await self.service.clear_pests(self.who, "late-clear") await self.service.clear_pests(self.who, "late-clear")
mature = await self.plot() mature = await self.plot()
self.assertEqual(mature.pest_count, 0) self.assertEqual(mature.pest_count, 0)
self.assertEqual(mature.maturity_pests, 3) self.assertEqual(mature.maturity_pests, 2)
receipt = await self.service.harvest(self.who, "harvest") receipt = await self.service.harvest(self.who, "harvest")
self.assertEqual(receipt.data["quantity"], 85) self.assertEqual(receipt.data["quantity"], 90)
async def test_reopen_preserves_cleared_progress_and_concurrent_clear(self): async def test_reopen_preserves_cleared_progress_and_concurrent_clear(self):
self.now = 6400 self.now = 10000
results = await asyncio.gather( results = await asyncio.gather(
self.service.clear_pests(self.who, "clear-1"), self.service.clear_pests(self.who, "clear-1"),
self.service.clear_pests(self.who, "clear-2"), self.service.clear_pests(self.who, "clear-2"),
@@ -105,7 +103,7 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
).fetchone(), (0, 1)) ).fetchone(), (0, 1))
async def test_failed_clear_rolls_back_all_plot_updates(self): async def test_failed_clear_rolls_back_all_plot_updates(self):
self.now = 6400 self.now = 10000
before = await self.store.snapshot(self.who) before = await self.store.snapshot(self.who)
original = SQLiteRepository.save_plot original = SQLiteRepository.save_plot
@@ -121,7 +119,7 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
async def test_empty_clear_has_no_reward_and_clear_awards_per_pest(self): async def test_empty_clear_has_no_reward_and_clear_awards_per_pest(self):
with self.assertRaisesRegex(FarmError, "nothing_to_clear"): with self.assertRaisesRegex(FarmError, "nothing_to_clear"):
await self.service.clear_pests(self.who, "empty-clear") await self.service.clear_pests(self.who, "empty-clear")
self.now = 4600 self.now = 8200
before = await self.service.snapshot(self.who) before = await self.service.snapshot(self.who)
await self.service.clear_pests(self.who, "clear") await self.service.clear_pests(self.who, "clear")
after = await self.service.snapshot(self.who) after = await self.service.snapshot(self.who)
@@ -132,23 +130,23 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
async def test_multi_plot_pest_rewards_replay_and_transaction_rollback(self): async def test_multi_plot_pest_rewards_replay_and_transaction_rollback(self):
await self.service.buy(self.who, '虫害测试菜', 1, 'buy-second') await self.service.buy(self.who, '虫害测试菜', 1, 'buy-second')
await self.service.sow(self.who, '虫害测试菜', 1, 'sow-second') await self.service.sow(self.who, '虫害测试菜', 1, 'sow-second')
self.now = 11800 self.now = 15400
before = await self.store.snapshot(self.who) before = await self.store.snapshot(self.who)
ledger = await self.store.ledger(self.who) ledger = await self.store.ledger(self.who)
with patch.object(SQLiteRepository, 'asset', with patch.object(SQLiteRepository, 'asset',
side_effect=RuntimeError('reward failure')): side_effect=RuntimeError('reward failure')):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
await self.service.clear_pests(self.who, 'clear-six') await self.service.clear_pests(self.who, 'clear-four')
self.assertEqual(await self.store.snapshot(self.who), before) self.assertEqual(await self.store.snapshot(self.who), before)
self.assertEqual(await self.store.ledger(self.who), ledger) self.assertEqual(await self.store.ledger(self.who), ledger)
receipts = await asyncio.gather(*( receipts = await asyncio.gather(*(
self.service.clear_pests(self.who, 'clear-six') for _ in range(3) self.service.clear_pests(self.who, 'clear-four') for _ in range(3)
)) ))
self.assertTrue(all(r == receipts[0] for r in receipts)) self.assertTrue(all(r == receipts[0] for r in receipts))
self.assertEqual(receipts[0].data['quantity'], 6) self.assertEqual(receipts[0].data['quantity'], 4)
self.assertEqual(receipts[0].data['experience'], 6) self.assertEqual(receipts[0].data['experience'], 4)
after = await self.service.snapshot(self.who) after = await self.service.snapshot(self.who)
self.assertEqual(after.account.experience, before.account.experience+6) self.assertEqual(after.account.experience, before.account.experience+4)
with self.assertRaisesRegex(FarmError, 'nothing_to_clear'): with self.assertRaisesRegex(FarmError, 'nothing_to_clear'):
await self.service.clear_pests(self.who, 'empty-after') await self.service.clear_pests(self.who, 'empty-after')
self.assertEqual((await self.service.snapshot(self.who)).account, self.assertEqual((await self.service.snapshot(self.who)).account,
@@ -158,10 +156,10 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
initial = (await self.store.snapshot(self.who)).plots[0] initial = (await self.store.snapshot(self.who)).plots[0]
direct = advance_plot(initial, 20000) direct = advance_plot(initial, 20000)
split = initial split = initial
for instant in (1001, 4599, 4600, 7000, 11800, 16671, 20000): for instant in (1001, 8199, 8200, 10000, 15400, 15800, 20000):
split = advance_plot(split, instant) split = advance_plot(split, instant)
self.assertEqual(split, direct) self.assertEqual(split, direct)
self.assertEqual(direct.maturity_pests, 3) self.assertEqual(direct.maturity_pests, 2)
async def test_growth_stage_uses_work_not_predicted_finish_time(self): async def test_growth_stage_uses_work_not_predicted_finish_time(self):
import sys import sys
@@ -180,4 +178,34 @@ class PestTests(unittest.IsolatedAsyncioTestCase):
plots=(projected,)) plots=(projected,))
view = renderer.scene(self.who, snapshot, self.now).plots[0] view = renderer.scene(self.who, snapshot, self.now).plots[0]
self.assertEqual(view.stage, 2) self.assertEqual(view.stage, 2)
self.assertEqual(view.pest_count, 2) self.assertEqual(view.pest_count, 1)
async def test_short_crops_mature_without_pests(self):
stored = (await self.store.snapshot(self.who)).plots[0]
for duration in (3600, 7200):
with self.subTest(duration=duration):
plant = replace(self.plant, growth_seconds=duration)
plot = start_growth(replace(stored, plant=plant), 1000)
mature = advance_plot(plot, 1000 + duration)
self.assertEqual(mature.growth_remaining, 0)
self.assertEqual(mature.mature_at, 1000 + duration)
self.assertEqual(mature.pest_count, 0)
self.assertEqual(mature.maturity_pests, 0)
async def test_regrowth_uses_its_own_longer_pest_interval(self):
stored = (await self.store.snapshot(self.who)).plots[0]
plant = replace(self.plant, regrowth_seconds=36001)
plot = start_growth(replace(
stored, plant=plant, harvest_count=1,
), 1000)
self.assertEqual(advance_plot(plot, 19000).pest_count, 0)
self.assertEqual(advance_plot(plot, 19001).pest_count, 1)
async def test_pending_pest_is_preserved_then_spacing_increases(self):
stored = (await self.store.snapshot(self.who)).plots[0]
old = replace(stored, next_pest_at=4600)
first = advance_plot(old, 4600)
self.assertEqual(first.pest_count, 1)
self.assertEqual(first.next_pest_at, 11800)
self.assertEqual(advance_plot(first, 11799).pest_count, 1)
self.assertEqual(advance_plot(first, 11800).pest_count, 2)
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from build_zhenxun import source_identity
from release_metadata import commit_message
class ReleaseMetadataTests(unittest.TestCase):
def test_multiline_unicode_message_is_bound_to_build_commit(self):
with tempfile.TemporaryDirectory() as temporary:
def git(*args, message=None):
return subprocess.check_output(
["git", "-c", "user.name=Farm Test",
"-c", "user.email=farm-test@example.invalid", *args],
cwd=temporary, input=message, text=True, encoding="utf-8",
stderr=subprocess.DEVNULL,
).strip()
git("init", "--quiet")
original = '修复农场显示\n\n保留正文、引号和 $() 字符。\n第二行。'
git("commit", "--allow-empty", "--file=-", message=original)
source = source_identity(temporary)
self.assertEqual(source["message"], original)
self.assertEqual(source["commit"], git("rev-parse", "HEAD"))
git("commit", "--allow-empty", "--file=-", message="后续提交")
manifest = {"source": source, "version": "2.0.1"}
expected = (
original + "\n\nFarm-Source-Commit: " + source["commit"]
+ "\nFarm-Version: 2.0.1"
)
self.assertEqual(commit_message(manifest), expected)
self.assertNotIn("后续提交", commit_message(manifest))
def test_old_or_invalid_metadata_requires_rebuilding(self):
for source in ({"commit": "old"}, {"message": " "},
{"message": None}, {"message": 123}):
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, "rebuild"):
commit_message({"source": source, "version": "2.0.1"})
+6
View File
@@ -256,6 +256,12 @@ class ZhenxunPackageTests(unittest.TestCase):
commit = next(data for method, path, data in calls commit = next(data for method, path, data in calls
if method == "POST" and path == "git/commits") if method == "POST" and path == "git/commits")
self.assertEqual(commit["parents"], ["existing"]) self.assertEqual(commit["parents"], ["existing"])
manifest = json.loads(read_package(package)["FARM-MANIFEST.json"])
self.assertEqual(commit["message"], (
manifest["source"]["message"] + "\n\n"
+ "Farm-Source-Commit: " + manifest["source"]["commit"] + "\n"
+ "Farm-Version: " + manifest["version"]
))
self.assertIn(("PATCH", "git/refs/heads/main", { self.assertIn(("PATCH", "git/refs/heads/main", {
"sha": "created", "force": False, "sha": "created", "force": False,
}), calls) }), calls)
+91 -6
View File
@@ -99,9 +99,9 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
with Image.open(BytesIO(content)) as image: with Image.open(BytesIO(content)) as image:
self.assertEqual(image.format, 'JPEG') self.assertEqual(image.format, 'JPEG')
self.assertEqual(image.size, (768, 1152)) self.assertEqual(image.size, (768, 1152))
high = await self.call('seeds', '1 高清', message=None) self.assertIn('仅我的农场支持高清', await self.call(
with Image.open(BytesIO(high)) as image: 'seeds', '1 高清', message=None,
self.assertEqual(image.size, (1024, 1536)) ))
self.assertIn('胡萝卜(#1) 2粒', await self.call( self.assertIn('胡萝卜(#1) 2粒', await self.call(
'seeds', '文字', message=None, 'seeds', '文字', message=None,
)) ))
@@ -153,6 +153,71 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
'steal', '2', target_user_id='2', message='invalid', 'steal', '2', target_user_id='2', message='invalid',
)) ))
async def test_image_quality_reaches_every_encoder_and_farm_high(self):
from io import BytesIO
from PIL import Image
canvas = importlib.import_module(
self.package + '._vendor.farm_render.canvas',
).Canvas
original = canvas.encode
encoded = []
def encode(instance, codec, quality=75, method=2):
encoded.append(quality)
return original(instance, codec, quality, method)
await self.call('register')
quality = 48
self.runtime.image_quality_provider = lambda: quality
with patch.object(canvas, 'encode', new=encode):
for configured in (48, 95):
quality = configured
for action in ('farm', 'plot_ids', 'details', 'shop',
'seeds', 'crops'):
with self.subTest(quality=quality, action=action):
encoded.clear()
content = await self.call(action, message=None)
self.assertIsInstance(content, bytes)
self.assertTrue(encoded)
self.assertEqual(set(encoded), {quality})
encoded.clear()
content = await self.call('farm', '高清', message=None)
self.assertEqual(set(encoded), {max(85, quality)})
with Image.open(BytesIO(content)) as image:
self.assertEqual(image.width, 1536)
async def test_only_farm_accepts_high_and_warehouse_keeps_text(self):
await self.call('register')
for action in ('plot_ids', 'details', 'shop', 'seeds', 'crops'):
for arguments in ('高清', '1 高清'):
with self.subTest(action=action, arguments=arguments):
self.assertIn('仅我的农场支持高清', await self.call(
action, arguments, message=None,
))
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1 文字'), str)
help_text = await self.call('help')
self.assertIn('仅我的农场支持高清', help_text)
self.assertNotIn('地块编号 [高清]', help_text)
self.assertNotIn('[高清/文字]', help_text)
async def test_image_quality_validation_and_live_recovery(self):
await self.call('register')
for invalid in (0, 96, True, 74.5, '75', None):
with self.subTest(invalid=invalid):
self.runtime.image_quality_provider = lambda: invalid
result = await self.call('farm')
self.assertIsInstance(result, str)
self.assertIn('图片质量配置无效', result)
self.assertIsInstance(await self.call('seeds', '文字'), str)
self.runtime.image_quality_provider = lambda: 1
self.assertIsInstance(await self.call('farm'), bytes)
self.runtime.image_quality_provider = lambda: 95
self.assertIsInstance(await self.call('farm'), bytes)
for invalid in (0, 96, True, 74.5, '75'):
with self.assertRaises(ValueError):
self.module.Runtime(self.root / 'unused', image_quality=invalid)
async def test_original_commands_transactions_and_png(self): async def test_original_commands_transactions_and_png(self):
self.assertNotIn("测试农场", self.module.COMMANDS) self.assertNotIn("测试农场", self.module.COMMANDS)
self.assertIn("开通农场", self.module.COMMANDS) self.assertIn("开通农场", self.module.COMMANDS)
@@ -233,14 +298,19 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_pest_command_clears_pests_and_preserves_growth(self): async def test_pest_command_clears_pests_and_preserves_growth(self):
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests") self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
self.assertIn("每只虫", 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 self.runtime.service.clock = lambda: 1000
await self.call("register") await self.call("register")
await self.call("buy", "白萝卜 1", "buy") await self.call("buy", "白萝卜 1", "buy")
await self.call("sow", "白萝卜 1", "sow") await self.call("sow", "白萝卜 1", "sow")
self.assertIn("没有虫", await self.call("clear_pests", message="empty")) self.assertIn("没有虫", await self.call("clear_pests", message="empty"))
duration = self.runtime.service.catalogue.get("白萝卜").growth_seconds duration = self.runtime.service.catalogue.get("白萝卜").growth_seconds
self.runtime.service.clock = lambda: 1000 + max(3600, (duration+3)//4) first_pest_at = 1000 + max(7200, (duration + 1) // 2)
self.runtime.service.clock = lambda: first_pest_at - 1
self.assertNotIn("虫子1只", await self.call("inventory"))
self.runtime.service.clock = lambda: first_pest_at
self.assertIn("虫子1只", await self.call("inventory")) self.assertIn("虫子1只", await self.call("inventory"))
first = await self.call("clear_pests", message="clear") first = await self.call("clear_pests", message="clear")
self.assertIn("除虫已完成", first) self.assertIn("除虫已完成", first)
@@ -456,8 +526,15 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
platform = ModuleType("zhenxun.utils.platform") platform = ModuleType("zhenxun.utils.platform")
platform.PlatformUtils = SimpleNamespace() platform.PlatformUtils = SimpleNamespace()
config = ModuleType("zhenxun.configs.config") config = ModuleType("zhenxun.configs.config")
quality_values = {'value': 49}
config.Config = SimpleNamespace( config.Config = SimpleNamespace(
get=lambda module: SimpleNamespace(configs={}), get=lambda module: SimpleNamespace(
configs={}, get=lambda key, default, **kwargs: (
quality_values['value'] if not kwargs['build_model']
else int(quality_values['value'])
),
),
get_config=lambda *args, **kwargs: int(quality_values['value']),
_simple_data={}, _simple_data={},
) )
modules = {m.__name__:m for m in ( modules = {m.__name__:m for m in (
@@ -465,6 +542,14 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
)} )}
with patch.dict(sys.modules, modules): with patch.dict(sys.modules, modules):
imported = importlib.import_module(self.package + ".commands") imported = importlib.import_module(self.package + ".commands")
self.assertEqual(imported.runtime.image_quality_provider(), 49)
quality_values['value'] = 91
self.assertEqual(imported.runtime.image_quality_provider(), 91)
quality_values['value'] = 74.5
self.assertEqual(imported.runtime.image_quality_provider(), 74.5)
quality_values['value'] = True
self.assertIs(imported.runtime.image_quality_provider(), True)
quality_values['value'] = 75
self.assertEqual(imported.runtime.data_dir, self.assertEqual(imported.runtime.data_dir,
(self.root / 'farm_db').resolve()) (self.root / 'farm_db').resolve())
self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS)) self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS))