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

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