From d238005561b2ed29c6f56f75aa6b39a62a55e75b Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Thu, 22 Oct 2026 18:33:42 +0800 Subject: [PATCH] =?UTF-8?q?fixed=20#=20=E6=96=B0=E5=A2=9E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=EF=BC=8C=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E7=94=9F=E6=88=90=E5=AF=B9=E5=BA=94=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=9A=84=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build_astrbot.cmd | 6 + build_nonebot.cmd | 6 + build_zhenxun.cmd | 6 + docs/12-nonebot.md | 7 + .../changes/farm-core-foundation/brief.md | 11 + .../farm-core-foundation/comet-state.yaml | 455 ++++-------------- .../specs/zhenxun-preview/spec.md | 14 + .../farm-core-foundation/verification.md | 93 ---- docs/land-rules.md | 2 +- integrations/astrbot/README.md | 41 +- integrations/astrbot/_conf_schema.json | 27 ++ integrations/astrbot/main.py | 87 ++++ integrations/astrbot/requirements.txt | 1 + integrations/nonebot/commands.py | 1 + integrations/nonebot/config.py | 6 +- integrations/zhenxun/README.md | 12 + integrations/zhenxun/__init__.py | 8 +- integrations/zhenxun/commands.py | 10 + integrations/zhenxun/runtime.py | 38 +- release/README.md | 4 + scripts/build_astrbot.py | 84 ++++ scripts/build_plugin.ps1 | 71 +++ scripts/build_plugin.py | 36 ++ scripts/build_zhenxun.py | 4 +- scripts/publish_nonebot.py | 8 +- scripts/publish_zhenxun.py | 8 +- scripts/release_metadata.py | 13 + scripts/verify_astrbot.py | 85 ++++ src/farm_core/pests.py | 3 +- src/farm_render/details.py | 10 +- src/farm_render/service.py | 19 +- src/farm_render/shop.py | 10 +- src/farm_render/warehouse.py | 5 +- tests/test_astrbot_package.py | 157 ++++++ tests/test_build_plugin.py | 48 ++ tests/test_config_migration.py | 63 ++- tests/test_nonebot_package.py | 5 + tests/test_pests.py | 92 ++-- tests/test_release_metadata.py | 44 ++ tests/test_zhenxun_package.py | 6 + tests/test_zhenxun_runtime.py | 97 +++- 41 files changed, 1158 insertions(+), 545 deletions(-) create mode 100644 build_astrbot.cmd create mode 100644 build_nonebot.cmd create mode 100644 build_zhenxun.cmd delete mode 100644 docs/comet/changes/farm-core-foundation/verification.md create mode 100644 integrations/astrbot/_conf_schema.json create mode 100644 integrations/astrbot/main.py create mode 100644 integrations/astrbot/requirements.txt create mode 100644 scripts/build_astrbot.py create mode 100644 scripts/build_plugin.ps1 create mode 100644 scripts/build_plugin.py create mode 100644 scripts/release_metadata.py create mode 100644 scripts/verify_astrbot.py create mode 100644 tests/test_astrbot_package.py create mode 100644 tests/test_build_plugin.py create mode 100644 tests/test_release_metadata.py diff --git a/build_astrbot.cmd b/build_astrbot.cmd new file mode 100644 index 0000000..e274d5a --- /dev/null +++ b/build_astrbot.cmd @@ -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% diff --git a/build_nonebot.cmd b/build_nonebot.cmd new file mode 100644 index 0000000..211f6c4 --- /dev/null +++ b/build_nonebot.cmd @@ -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% diff --git a/build_zhenxun.cmd b/build_zhenxun.cmd new file mode 100644 index 0000000..a6cf801 --- /dev/null +++ b/build_zhenxun.cmd @@ -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% diff --git a/docs/12-nonebot.md b/docs/12-nonebot.md index b0962ef..5f52302 100644 --- a/docs/12-nonebot.md +++ b/docs/12-nonebot.md @@ -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 文字”。 部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。 ## 当前命令 diff --git a/docs/comet/changes/farm-core-foundation/brief.md b/docs/comet/changes/farm-core-foundation/brief.md index 0986b52..4b154ae 100644 --- a/docs/comet/changes/farm-core-foundation/brief.md +++ b/docs/comet/changes/farm-core-foundation/brief.md @@ -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。 - 现有文档是设计参考,旧代码是实现参考;本轮仅覆盖上述第一批核心, diff --git a/docs/comet/changes/farm-core-foundation/comet-state.yaml b/docs/comet/changes/farm-core-foundation/comet-state.yaml index 571f3f3..bdba652 100644 --- a/docs/comet/changes/farm-core-foundation/comet-state.yaml +++ b/docs/comet/changes/farm-core-foundation/comet-state.yaml @@ -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 diff --git a/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md b/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md index 0670186..7d5d5f1 100644 --- a/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md +++ b/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md @@ -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别名。 diff --git a/docs/comet/changes/farm-core-foundation/verification.md b/docs/comet/changes/farm-core-foundation/verification.md deleted file mode 100644 index 260db12..0000000 --- a/docs/comet/changes/farm-core-foundation/verification.md +++ /dev/null @@ -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匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。 diff --git a/docs/land-rules.md b/docs/land-rules.md index 27b13fa..616f96a 100644 --- a/docs/land-rules.md +++ b/docs/land-rules.md @@ -37,7 +37,7 @@ ## 用户查询入口 -真寻接入新增「地块编号」,支持「地块编号 高清」。它复用正式渲染器, +真寻接入新增「地块编号」,使用统一图片质量配置。它复用正式渲染器, 按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。 图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。 该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。 diff --git a/integrations/astrbot/README.md b/integrations/astrbot/README.md index c3e8712..b6e9ae2 100644 --- a/integrations/astrbot/README.md +++ b/integrations/astrbot/README.md @@ -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)。 diff --git a/integrations/astrbot/_conf_schema.json b/integrations/astrbot/_conf_schema.json new file mode 100644 index 0000000..de0e4e9 --- /dev/null +++ b/integrations/astrbot/_conf_schema.json @@ -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不受影响。" + } +} diff --git a/integrations/astrbot/main.py b/integrations/astrbot/main.py new file mode 100644 index 0000000..5f28c44 --- /dev/null +++ b/integrations/astrbot/main.py @@ -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') diff --git a/integrations/astrbot/requirements.txt b/integrations/astrbot/requirements.txt new file mode 100644 index 0000000..1263d11 --- /dev/null +++ b/integrations/astrbot/requirements.txt @@ -0,0 +1 @@ +Pillow>=10.4 diff --git a/integrations/nonebot/commands.py b/integrations/nonebot/commands.py index a11b53c..d2e10b2 100644 --- a/integrations/nonebot/commands.py +++ b/integrations/nonebot/commands.py @@ -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) diff --git a/integrations/nonebot/config.py b/integrations/nonebot/config.py index ca4764d..dedd731 100644 --- a/integrations/nonebot/config.py +++ b/integrations/nonebot/config.py @@ -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", + ) diff --git a/integrations/zhenxun/README.md b/integrations/zhenxun/README.md index ef716ae..a5d4d9d 100644 --- a/integrations/zhenxun/README.md +++ b/integrations/zhenxun/README.md @@ -7,3 +7,15 @@ 生成时修正为插件私有相对导入,本目录本身不是完整安装包。 使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。 本轮没有拉取、推送或操作真实机器人。 + +## 图片质量 + +真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。 +它统一控制我的农场、地块编号、农场详述、种子商店、我的种子及 +我的作物图片的编码质量,修改后下一次图片请求生效。值越高通常 +越清晰、文件越大;不会改变默认图片尺寸,PNG无损格式不受影响。 +无效配置明确提示错误,不会自动截断或转换为合法值。 + +只有“我的农场 高清”接受高清,宽1536,质量取配置值与85的较大值。 +其他命令不接受高清。仓库继续支持“我的种子 2 文字”和 +“我的作物 2 文字”等分页文字查询。 diff --git a/integrations/zhenxun/__init__.py b/integrations/zhenxun/__init__.py index 4693637..a44ae37 100644 --- a/integrations/zhenxun/__init__.py +++ b/integrations/zhenxun/__init__.py @@ -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(), ) diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py index 7628f98..71b025f 100644 --- a/integrations/zhenxun/commands.py +++ b/integrations/zhenxun/commands.py @@ -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 diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 08891cc..f719c2c 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -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) diff --git a/release/README.md b/release/README.md index 737eda2..4622b1c 100644 --- a/release/README.md +++ b/release/README.md @@ -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。 diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py new file mode 100644 index 0000000..9339669 --- /dev/null +++ b/scripts/build_astrbot.py @@ -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() diff --git a/scripts/build_plugin.ps1 b/scripts/build_plugin.ps1 new file mode 100644 index 0000000..4301d5d --- /dev/null +++ b/scripts/build_plugin.ps1 @@ -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 diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py new file mode 100644 index 0000000..77d23e4 --- /dev/null +++ b/scripts/build_plugin.py @@ -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() diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index d42b574..f77a141 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -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")), } diff --git a/scripts/publish_nonebot.py b/scripts/publish_nonebot.py index b6b86da..8a40be0 100644 --- a/scripts/publish_nonebot.py +++ b/scripts/publish_nonebot.py @@ -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: diff --git a/scripts/publish_zhenxun.py b/scripts/publish_zhenxun.py index c00e614..dfed1bd 100644 --- a/scripts/publish_zhenxun.py +++ b/scripts/publish_zhenxun.py @@ -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: diff --git a/scripts/release_metadata.py b/scripts/release_metadata.py new file mode 100644 index 0000000..abe4c8c --- /dev/null +++ b/scripts/release_metadata.py @@ -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']}" + ) diff --git a/scripts/verify_astrbot.py b/scripts/verify_astrbot.py new file mode 100644 index 0000000..ece5623 --- /dev/null +++ b/scripts/verify_astrbot.py @@ -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() diff --git a/src/farm_core/pests.py b/src/farm_core/pests.py index 661b95c..6dc2178 100644 --- a/src/farm_core/pests.py +++ b/src/farm_core/pests.py @@ -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: diff --git a/src/farm_render/details.py b/src/farm_render/details.py index 6534c8e..5c96fbb 100644 --- a/src/farm_render/details.py +++ b/src/farm_render/details.py @@ -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 diff --git a/src/farm_render/service.py b/src/farm_render/service.py index 6255b12..15d2857 100644 --- a/src/farm_render/service.py +++ b/src/farm_render/service.py @@ -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 diff --git a/src/farm_render/shop.py b/src/farm_render/shop.py index a6f4717..3de06f6 100644 --- a/src/farm_render/shop.py +++ b/src/farm_render/shop.py @@ -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 diff --git a/src/farm_render/warehouse.py b/src/farm_render/warehouse.py index 6c672d3..15460a2 100644 --- a/src/farm_render/warehouse.py +++ b/src/farm_render/warehouse.py @@ -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) diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py new file mode 100644 index 0000000..8a48b3e --- /dev/null +++ b/tests/test_astrbot_package.py @@ -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] diff --git a/tests/test_build_plugin.py b/tests/test_build_plugin.py new file mode 100644 index 0000000..6a910b2 --- /dev/null +++ b/tests/test_build_plugin.py @@ -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) diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py index 0968456..8215c6e 100644 --- a/tests/test_config_migration.py +++ b/tests/test_config_migration.py @@ -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) diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py index 8e20e13..ba8d0e0 100644 --- a/tests/test_nonebot_package.py +++ b/tests/test_nonebot_package.py @@ -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) diff --git a/tests/test_pests.py b/tests/test_pests.py index b126dea..77abd22 100644 --- a/tests/test_pests.py +++ b/tests/test_pests.py @@ -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) diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..805621f --- /dev/null +++ b/tests/test_release_metadata.py @@ -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"}) diff --git a/tests/test_zhenxun_package.py b/tests/test_zhenxun_package.py index 774c67f..6855dcd 100644 --- a/tests/test_zhenxun_package.py +++ b/tests/test_zhenxun_package.py @@ -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) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index e102290..0069233 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -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))