mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 21:19:58 +08:00
fixed # 新增本地部署脚本,一键生成对应平台的压缩包内容
This commit is contained in:
@@ -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%
|
||||||
@@ -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%
|
||||||
@@ -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%
|
||||||
@@ -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
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
## 用户查询入口
|
## 用户查询入口
|
||||||
|
|
||||||
真寻接入新增「地块编号」,支持「地块编号 高清」。它复用正式渲染器,
|
真寻接入新增「地块编号」,使用统一图片质量配置。它复用正式渲染器,
|
||||||
按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。
|
按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。
|
||||||
图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。
|
图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。
|
||||||
该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。
|
该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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不受影响。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=10.4
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 文字”等分页文字查询。
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user