mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-30 14:20:01 +08:00
fixed # 新增本地部署脚本,一键生成对应平台的压缩包内容
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform astrbot %*
|
||||
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
|
||||
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
|
||||
exit /b %FARM_BUILD_EXIT%
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform nonebot %*
|
||||
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
|
||||
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
|
||||
exit /b %FARM_BUILD_EXIT%
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_plugin.ps1" -Platform zhenxun %*
|
||||
set "FARM_BUILD_EXIT=%ERRORLEVEL%"
|
||||
if not "%FARM_BUILD_NO_PAUSE%"=="1" pause
|
||||
exit /b %FARM_BUILD_EXIT%
|
||||
@@ -19,6 +19,7 @@ nonebot.load_plugin("nonebot_plugin_farm")
|
||||
```dotenv
|
||||
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
@@ -37,7 +37,7 @@
|
||||
|
||||
## 用户查询入口
|
||||
|
||||
真寻接入新增「地块编号」,支持「地块编号 高清」。它复用正式渲染器,
|
||||
真寻接入新增「地块编号」,使用统一图片质量配置。它复用正式渲染器,
|
||||
按当前账号快照标注全部 30 个位置:浅色编号为已开垦,绿色为待开垦。
|
||||
图片附带编号顺序和「农场详述」入口说明,并保留当前季节、作物及头像。
|
||||
该查询无需消息写入标识,不更改作物、土地或资产。缺少字体时返回文字清单。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"font_path": {
|
||||
"description": "中文字体路径",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"hint": "部署环境可访问的中文字体文件;留空时尝试系统字体,找不到则退回文字。"
|
||||
},
|
||||
"data_dir": {
|
||||
"description": "农场数据目录",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"hint": "留空使用data/plugin_data/astrbot_plugin_farm。修改后重载插件;不会自动搬迁数据。"
|
||||
},
|
||||
"image_format": {
|
||||
"description": "图片格式",
|
||||
"type": "string",
|
||||
"default": "AUTO",
|
||||
"options": ["AUTO", "JPEG", "PNG"],
|
||||
"hint": "AUTO使用压缩JPEG;修改后重载插件。"
|
||||
},
|
||||
"image_quality": {
|
||||
"description": "农场图片质量",
|
||||
"type": "int",
|
||||
"default": 75,
|
||||
"hint": "整数1~95,下一次图片请求生效;仅我的农场允许高清,质量不低于85。PNG不受影响。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""AstrBot薄接入;业务、交易及绘图复用共享运行时。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import AstrBotConfig
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.message_components import At, Image
|
||||
from astrbot.api.star import Context, Star, StarTools
|
||||
|
||||
from .runtime import COMMANDS, Runtime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FarmPlugin(Star):
|
||||
def __init__(self, context: Context, config: AstrBotConfig):
|
||||
super().__init__(context)
|
||||
self.config = config
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
configured = config.get('data_dir', '')
|
||||
data_dir = (Path(configured).expanduser() if configured else
|
||||
StarTools.get_data_dir('astrbot_plugin_farm'))
|
||||
image_format = str(config.get('image_format', 'AUTO')).upper()
|
||||
if image_format not in ('AUTO', 'JPEG', 'PNG'):
|
||||
raise ValueError('image_format must be AUTO, JPEG or PNG')
|
||||
self.runtime = Runtime(
|
||||
data_dir, config.get('font_path') or None, 'astrbot',
|
||||
image_format, font_setting='font_path',
|
||||
image_quality=config.get('image_quality', 75),
|
||||
image_quality_provider=lambda: self.config.get('image_quality', 75),
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
async with self._lifecycle_lock:
|
||||
if not self._closed:
|
||||
await self.runtime.start()
|
||||
|
||||
@filter.command('农场帮助', alias=set(COMMANDS) - {'农场帮助'})
|
||||
async def farm(self, event: AstrMessageEvent):
|
||||
"""农场帮助、注册、种植、仓库、交易及图片查询。"""
|
||||
if self._closed:
|
||||
yield event.plain_result('农场插件已关闭。')
|
||||
return
|
||||
await self.initialize()
|
||||
parts = event.get_message_str().strip().split(maxsplit=1)
|
||||
if not parts or parts[0] not in COMMANDS:
|
||||
return
|
||||
action = COMMANDS[parts[0]]
|
||||
arguments = parts[1] if len(parts) == 2 else ''
|
||||
target = None
|
||||
if action == 'steal':
|
||||
mentions = [str(segment.qq) for segment in event.get_messages()
|
||||
if isinstance(segment, At)
|
||||
and str(segment.qq) != str(event.get_self_id())]
|
||||
if len(mentions) != 1 or mentions[0] in ('', 'all', '0'):
|
||||
yield event.plain_result('请@一个具体的偷菜目标。')
|
||||
return
|
||||
target = mentions[0]
|
||||
platform_id = event.get_platform_id()
|
||||
if not platform_id or not event.get_sender_id():
|
||||
yield event.plain_result('无法取得稳定的平台或用户标识,未执行。')
|
||||
return
|
||||
bot_id = json.dumps([str(platform_id), str(event.get_self_id())])
|
||||
response = await self.runtime.execute(
|
||||
action, arguments, bot_id=bot_id,
|
||||
platform=event.get_platform_name(), user_id=event.get_sender_id(),
|
||||
message_id=getattr(event.message_obj, 'message_id', None),
|
||||
display_name=event.get_sender_name() or '农场玩家',
|
||||
target_user_id=target,
|
||||
)
|
||||
if isinstance(response, bytes):
|
||||
yield event.chain_result([Image.fromBytes(response)])
|
||||
else:
|
||||
yield event.plain_result(response)
|
||||
|
||||
async def terminate(self):
|
||||
async with self._lifecycle_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
await self.runtime.close()
|
||||
logger.info('farm astrbot plugin terminated')
|
||||
@@ -0,0 +1 @@
|
||||
Pillow>=10.4
|
||||
@@ -21,6 +21,7 @@ runtime = Runtime(
|
||||
data_directory(store.get_plugin_data_dir(), config.farm_data_dir),
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -7,3 +7,15 @@
|
||||
生成时修正为插件私有相对导入,本目录本身不是完整安装包。
|
||||
使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。
|
||||
本轮没有拉取、推送或操作真实机器人。
|
||||
|
||||
## 图片质量
|
||||
|
||||
真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。
|
||||
它统一控制我的农场、地块编号、农场详述、种子商店、我的种子及
|
||||
我的作物图片的编码质量,修改后下一次图片请求生效。值越高通常
|
||||
越清晰、文件越大;不会改变默认图片尺寸,PNG无损格式不受影响。
|
||||
无效配置明确提示错误,不会自动截断或转换为合法值。
|
||||
|
||||
只有“我的农场 高清”接受高清,宽1536,质量取配置值与85的较大值。
|
||||
其他命令不接受高清。仓库继续支持“我的种子 2 文字”和
|
||||
“我的作物 2 文字”等分页文字查询。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""真寻农场独立预览插件入口。"""
|
||||
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from 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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""白名单生成AstrBot可安装包,复用已校验的共享模块打包路径。"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
|
||||
from build_zhenxun import build as build_shared, read_version, source_identity
|
||||
from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||
from verify_zhenxun import read_package as read_shared
|
||||
|
||||
|
||||
def build(root, output):
|
||||
root, output = Path(root).resolve(), Path(output).resolve()
|
||||
version = read_version(root)
|
||||
if output.exists():
|
||||
raise FileExistsError('Use a new output directory')
|
||||
if output.is_relative_to(root) and not output.is_relative_to(root / 'dist'):
|
||||
raise ValueError('In-project output must be inside dist')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
shared = read_shared(build_shared(root, Path(temporary) / 'shared'))
|
||||
files = {name: content for name, content in shared.items()
|
||||
if name.startswith('_vendor/') or name in (
|
||||
'_version.py', 'version.txt', 'LICENSE',
|
||||
)}
|
||||
source = root / 'integrations/astrbot'
|
||||
for name in ('main.py', '_conf_schema.json', 'requirements.txt'):
|
||||
files[name] = (source / name).read_bytes()
|
||||
files['README.md'] = (source / 'README.md').read_bytes()
|
||||
files['runtime.py'] = (root / 'integrations/zhenxun/runtime.py').read_bytes()
|
||||
files['verify_package.py'] = (root / 'scripts/verify_astrbot.py').read_bytes()
|
||||
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
||||
files['metadata.yaml'] = (json.dumps({
|
||||
'name': ROOT_NAME, 'display_name': '农场',
|
||||
'desc': '种植、收获、仓库、交易与农场图片,复用Farm共享核心。',
|
||||
'version': version, 'author': 'Shu-Ying',
|
||||
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
|
||||
}, ensure_ascii=False, indent=2) + '\n').encode()
|
||||
for name, content in files.items():
|
||||
if name.endswith('.py'):
|
||||
ast.parse(content, filename=name, feature_version=(3, 10))
|
||||
manifest = {
|
||||
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
|
||||
'version': version, 'source': source_identity(root),
|
||||
'files': {name: hashlib.sha256(content).hexdigest()
|
||||
for name, content in sorted(files.items())},
|
||||
}
|
||||
files[MANIFEST] = (json.dumps(
|
||||
manifest, ensure_ascii=False, sort_keys=True, indent=2,
|
||||
) + '\n').encode()
|
||||
verify_files(files)
|
||||
output.mkdir(parents=True)
|
||||
for name, content in sorted(files.items()):
|
||||
target = output / 'repository' / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
archive_path = output / f'{ROOT_NAME}-{version}.zip'
|
||||
with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive:
|
||||
for name, content in sorted(files.items()):
|
||||
item = ZipInfo(f'{ROOT_NAME}/{name}', (2020, 1, 1, 0, 0, 0))
|
||||
item.create_system = 3
|
||||
item.compress_type = ZIP_DEFLATED
|
||||
item.external_attr = 0o100644 << 16
|
||||
archive.writestr(item, content)
|
||||
verify_package(archive_path)
|
||||
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
||||
archive_path.with_suffix('.zip.sha256').write_text(
|
||||
f'{digest} {archive_path.name}\n', encoding='ascii',
|
||||
)
|
||||
return archive_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(build(Path(__file__).resolve().parents[1], args.output))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('nonebot', 'zhenxun', 'astrbot')]
|
||||
[string]$Platform,
|
||||
[string]$OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$farmProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$farmCandidates = @()
|
||||
if ($env:FARM_PYTHON) {
|
||||
$farmCandidates += @{ Exe = $env:FARM_PYTHON; Prefix = @() }
|
||||
} else {
|
||||
foreach ($farmLocal in @('.venv\Scripts\python.exe',
|
||||
'venv\Scripts\python.exe')) {
|
||||
$farmCandidate = Join-Path $farmProjectRoot $farmLocal
|
||||
if (Test-Path -LiteralPath $farmCandidate -PathType Leaf) {
|
||||
$farmCandidates += @{ Exe = $farmCandidate; Prefix = @() }
|
||||
}
|
||||
}
|
||||
foreach ($farmName in @('python', 'py')) {
|
||||
$farmCommand = Get-Command $farmName -ErrorAction SilentlyContinue
|
||||
if ($farmCommand) {
|
||||
$farmPrefix = @()
|
||||
if ($farmName -eq 'py') { $farmPrefix = @('-3') }
|
||||
$farmCandidates += @{ Exe = $farmCommand.Source;
|
||||
Prefix = $farmPrefix }
|
||||
}
|
||||
}
|
||||
if ($env:USERPROFILE) {
|
||||
$farmBundled = Join-Path $env:USERPROFILE ('.cache\codex-runtimes\' +
|
||||
'codex-primary-runtime\dependencies\python\python.exe')
|
||||
if (Test-Path -LiteralPath $farmBundled -PathType Leaf) {
|
||||
$farmCandidates += @{ Exe = $farmBundled; Prefix = @() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$farmPython = $null
|
||||
foreach ($farmCandidate in $farmCandidates) {
|
||||
try {
|
||||
$farmExe = $farmCandidate.Exe
|
||||
$farmPrefix = $farmCandidate.Prefix
|
||||
& $farmExe @farmPrefix -c 'import sys; sys.exit(sys.version_info < (3, 10))'
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$farmPython = $farmCandidate
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
Write-Host ('Python candidate unavailable: ' + $farmCandidate.Exe)
|
||||
}
|
||||
}
|
||||
if (-not $farmPython) {
|
||||
Write-Error 'Python 3.10+ not found. Install Python or set FARM_PYTHON.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
$farmGit = Join-Path $env:USERPROFILE ('.cache\codex-runtimes\' +
|
||||
'codex-primary-runtime\dependencies\native\git\cmd')
|
||||
if (Test-Path -LiteralPath (Join-Path $farmGit 'git.exe')) {
|
||||
$env:PATH = $farmGit + ';' + $env:PATH
|
||||
}
|
||||
}
|
||||
$farmExe = $farmPython.Exe
|
||||
$farmArguments = @($farmPython.Prefix) + @(
|
||||
(Join-Path $PSScriptRoot 'build_plugin.py'), $Platform
|
||||
)
|
||||
if ($OutputDirectory) { $farmArguments += @('--output', $OutputDirectory) }
|
||||
& $farmExe @farmArguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,36 @@
|
||||
"""三平台本地打包入口;每次使用独立新目录,不执行发布或安装。"""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
|
||||
PLATFORMS = ('nonebot', 'zhenxun', 'astrbot')
|
||||
|
||||
|
||||
def build_plugin(platform, output=None, *, root=None):
|
||||
if platform not in PLATFORMS:
|
||||
raise ValueError('Unsupported platform')
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
if output is None:
|
||||
parent = Path(root) if root else source / 'dist'
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')
|
||||
output = parent / f'{platform}-{timestamp}-{uuid.uuid4().hex[:8]}'
|
||||
module = importlib.import_module('build_' + platform)
|
||||
return module.build(source, Path(output).resolve())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('platform', choices=PLATFORMS)
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args()
|
||||
archive = build_plugin(args.platform, args.output)
|
||||
print(f'Package: {archive}')
|
||||
print(f'Checksum: {archive.with_suffix(".zip.sha256")}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -17,9 +17,11 @@ def source_identity(root):
|
||||
return subprocess.check_output(
|
||||
["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")),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""从构建清单生成发布提交说明,不查询发布时的工作目录。"""
|
||||
|
||||
|
||||
def commit_message(manifest):
|
||||
source = manifest["source"]
|
||||
message = source.get("message")
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
raise ValueError("Missing source commit message; rebuild the package")
|
||||
return (
|
||||
f"{message.rstrip()}\n\n"
|
||||
f"Farm-Source-Commit: {source['commit']}\n"
|
||||
f"Farm-Version: {manifest['version']}"
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""AstrBot插件ZIP的独立完整性校验;只使用标准库。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import stat
|
||||
from zipfile import ZipFile
|
||||
|
||||
|
||||
ROOT_NAME = 'astrbot_plugin_farm'
|
||||
MANIFEST = 'FARM-MANIFEST.json'
|
||||
MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def safe_path(name):
|
||||
path = PurePosixPath(name)
|
||||
if (not name or '\\' in name or ':' in name or path.is_absolute()
|
||||
or any(p in ('', '.', '..') for p in name.split('/'))):
|
||||
raise ValueError('Unsafe package path')
|
||||
return path
|
||||
|
||||
|
||||
def verify_files(files):
|
||||
if MANIFEST not in files or len(files[MANIFEST]) > 1024 * 1024:
|
||||
raise ValueError('Missing or oversized manifest')
|
||||
manifest = json.loads(files[MANIFEST])
|
||||
if (manifest.get('schema') != 1 or manifest.get('target') != 'astrbot'
|
||||
or manifest.get('plugin') != ROOT_NAME):
|
||||
raise ValueError('Invalid package identity')
|
||||
expected = manifest.get('files')
|
||||
if not isinstance(expected, dict) or set(files) != set(expected) | {MANIFEST}:
|
||||
raise ValueError('Package files differ from manifest')
|
||||
for name, digest in expected.items():
|
||||
safe_path(name)
|
||||
if hashlib.sha256(files[name]).hexdigest() != digest:
|
||||
raise ValueError('Package checksum mismatch')
|
||||
required = {'main.py', 'metadata.yaml', '_conf_schema.json', 'runtime.py',
|
||||
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
|
||||
'_vendor/farm_core/__init__.py',
|
||||
'_vendor/farm_render/data/assets.zip'}
|
||||
if not required <= set(files):
|
||||
raise ValueError('Incomplete plugin')
|
||||
metadata = json.loads(files['metadata.yaml'])
|
||||
if (metadata.get('name') != ROOT_NAME
|
||||
or metadata.get('version') != manifest['version']
|
||||
or files['version.txt'].decode().strip() != manifest['version']):
|
||||
raise ValueError('Package version mismatch')
|
||||
return manifest
|
||||
|
||||
|
||||
def read_package(path):
|
||||
with ZipFile(path) as archive:
|
||||
items = archive.infolist()
|
||||
names = [item.filename for item in items]
|
||||
if (len(names) != len(set(names)) or len(names) > 200
|
||||
or sum(item.file_size for item in items) > MAX_BYTES):
|
||||
raise ValueError('Duplicate paths or package too large')
|
||||
files = {}
|
||||
for item in items:
|
||||
name = safe_path(item.filename)
|
||||
if (len(name.parts) < 2 or name.parts[0] != ROOT_NAME
|
||||
or item.is_dir()
|
||||
or stat.S_ISLNK(item.external_attr >> 16)):
|
||||
raise ValueError('Unexpected package root or link')
|
||||
files[name.relative_to(ROOT_NAME).as_posix()] = archive.read(item)
|
||||
verify_files(files)
|
||||
return files
|
||||
|
||||
|
||||
def verify_package(path):
|
||||
return verify_files(read_package(path))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('package', type=Path)
|
||||
args = parser.parse_args()
|
||||
manifest = verify_package(args.package)
|
||||
print(f"Verified astrbot {manifest['version']}: "
|
||||
f"{len(manifest['files'])} files")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -11,7 +11,8 @@ def season_duration(plot: Plot) -> int:
|
||||
|
||||
|
||||
def spawn_interval(plot: Plot) -> int:
|
||||
return max(3600, (season_duration(plot) + 3) // 4)
|
||||
"""至少留出两小时,每半个基础生长周期才发生一次虫害。"""
|
||||
return max(7200, (season_duration(plot) + 1) // 2)
|
||||
|
||||
|
||||
def start_growth(plot: Plot, now: int) -> Plot:
|
||||
|
||||
@@ -98,9 +98,10 @@ def details_text(rows, page, pages):
|
||||
|
||||
|
||||
def render_details(renderer, identity, snapshot, now, page,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
|
||||
|
||||
class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
|
||||
async def test_reproducible_complete_package_and_tamper_rejection(self):
|
||||
from build_astrbot import build
|
||||
from verify_astrbot import verify_files, read_package
|
||||
first = build(ROOT, self.root / 'one')
|
||||
second = build(ROOT, self.root / 'two')
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
files = read_package(first)
|
||||
manifest = verify_files(files)
|
||||
version = (ROOT / 'version.txt').read_text().strip()
|
||||
self.assertEqual(manifest['version'], version)
|
||||
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
|
||||
self.assertEqual(files['runtime.py'], (
|
||||
ROOT / 'integrations/zhenxun/runtime.py'
|
||||
).read_bytes())
|
||||
for name, content in files.items():
|
||||
self.assertNotIn('.git/', name)
|
||||
self.assertNotIn('farm.sqlite3', name)
|
||||
if name.endswith('.py'):
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
self.assertFalse((node.module or '').startswith(
|
||||
('nonebot', 'zhenxun'),
|
||||
))
|
||||
files['main.py'] += b'\n# tampered\n'
|
||||
with self.assertRaisesRegex(ValueError, 'checksum'):
|
||||
verify_files(files)
|
||||
with self.assertRaises(FileExistsError):
|
||||
build(ROOT, self.root / 'one')
|
||||
expected = hashlib.sha256(first.read_bytes()).hexdigest()
|
||||
self.assertIn(expected, first.with_suffix('.zip.sha256').read_text())
|
||||
|
||||
async def test_mock_host_commands_images_identity_and_shutdown(self):
|
||||
from build_astrbot import build
|
||||
from ci_font import choose_font
|
||||
build(ROOT, self.root / 'host')
|
||||
package = ModuleType('astrbot_farm_fixture')
|
||||
package.__path__ = [str(self.root / 'host/repository')]
|
||||
api = ModuleType('astrbot.api')
|
||||
api.AstrBotConfig = dict
|
||||
events = ModuleType('astrbot.api.event')
|
||||
commands = {}
|
||||
|
||||
def command(name, alias=None):
|
||||
def decorate(handler):
|
||||
commands.update({c: handler for c in {name} | (alias or set())})
|
||||
return handler
|
||||
return decorate
|
||||
|
||||
events.filter = SimpleNamespace(command=command)
|
||||
events.AstrMessageEvent = type('Event', (), {})
|
||||
star = ModuleType('astrbot.api.star')
|
||||
|
||||
class Star:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
star.Star, star.Context = Star, object
|
||||
star.StarTools = SimpleNamespace(
|
||||
get_data_dir=lambda name: self.root / 'plugin-data',
|
||||
)
|
||||
components = ModuleType('astrbot.api.message_components')
|
||||
components.At = type('At', (), {})
|
||||
components.Image = SimpleNamespace(fromBytes=lambda content: content)
|
||||
modules = {m.__name__: m for m in (
|
||||
package, api, events, star, components,
|
||||
)}
|
||||
with patch.dict(sys.modules, modules):
|
||||
try:
|
||||
main = importlib.import_module(package.__name__ + '.main')
|
||||
plugin = main.FarmPlugin(object(), {
|
||||
'font_path': str(choose_font()), 'image_quality': 55,
|
||||
})
|
||||
await plugin.initialize()
|
||||
self.assertEqual(set(commands), set(main.COMMANDS))
|
||||
|
||||
class Event:
|
||||
message_obj = SimpleNamespace(message_id='one')
|
||||
|
||||
def __init__(self, text, platform_id='bot-a'):
|
||||
self.text, self.platform_id = text, platform_id
|
||||
|
||||
def get_message_str(self):
|
||||
return self.text
|
||||
|
||||
def get_self_id(self):
|
||||
return 'bot'
|
||||
|
||||
def get_platform_id(self):
|
||||
return self.platform_id
|
||||
|
||||
def get_platform_name(self):
|
||||
return 'aiocqhttp'
|
||||
|
||||
def get_sender_id(self):
|
||||
return 'player'
|
||||
|
||||
def get_sender_name(self):
|
||||
return '离线测试'
|
||||
|
||||
def get_messages(self):
|
||||
return []
|
||||
|
||||
def plain_result(self, text):
|
||||
return text
|
||||
|
||||
def chain_result(self, chain):
|
||||
return chain
|
||||
|
||||
async def call(text, platform_id='bot-a'):
|
||||
return [r async for r in plugin.farm(
|
||||
Event(text, platform_id),
|
||||
)][0]
|
||||
|
||||
first = await call('开通农场')
|
||||
self.assertIn('6块土地', first)
|
||||
again = await call('开通农场')
|
||||
self.assertIsInstance(again, str)
|
||||
self.assertIn('开通农场', await call('我的种子', 'bot-b'))
|
||||
result = await call('我的农场 高清')
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(result[0])) as image:
|
||||
self.assertEqual(image.width, 1536)
|
||||
self.assertIn('仅我的农场', await call('我的种子 高清'))
|
||||
self.assertIn('暂无种子', await call('我的种子 文字'))
|
||||
self.assertTrue((self.root / 'plugin-data/farm.sqlite3').is_file())
|
||||
await plugin.terminate()
|
||||
self.assertFalse(plugin.runtime.started)
|
||||
finally:
|
||||
if 'plugin' in locals() and plugin.runtime.started:
|
||||
await plugin.terminate()
|
||||
for name in tuple(sys.modules):
|
||||
if name.startswith(package.__name__ + '.'):
|
||||
del sys.modules[name]
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
|
||||
|
||||
class BuildPluginTests(unittest.TestCase):
|
||||
def test_all_platforms_from_another_directory_and_repeated_output(self):
|
||||
from build_plugin import build_plugin
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for platform in ('nonebot', 'zhenxun', 'astrbot'):
|
||||
first = build_plugin(platform, root=root / platform)
|
||||
before = first.read_bytes()
|
||||
second = build_plugin(platform, root=root / platform)
|
||||
self.assertNotEqual(first.parent, second.parent)
|
||||
self.assertEqual(first.read_bytes(), before)
|
||||
self.assertTrue(second.is_file())
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'Windows entry points')
|
||||
def test_cmd_entries_and_nonzero_failure(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
env = {**os.environ, 'FARM_PYTHON': sys.executable,
|
||||
'FARM_BUILD_NO_PAUSE': '1'}
|
||||
for platform in ('nonebot', 'zhenxun', 'astrbot'):
|
||||
script = ROOT / f'build_{platform}.cmd'
|
||||
result = subprocess.run(
|
||||
['cmd.exe', '/d', '/c', str(script), '-OutputDirectory',
|
||||
str(root / platform)], cwd=root, env=env,
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout.decode(
|
||||
errors='replace',
|
||||
) + result.stderr.decode(errors='replace'))
|
||||
self.assertEqual(len(list((root / platform).glob('*.zip'))), 1)
|
||||
env['FARM_PYTHON'] = str(root / 'not-a-python.exe')
|
||||
failed = subprocess.run(
|
||||
['cmd.exe', '/d', '/c', str(ROOT / 'build_astrbot.cmd')],
|
||||
cwd=root, env=env, capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertNotEqual(failed.returncode, 0)
|
||||
@@ -1,8 +1,10 @@
|
||||
import importlib.util
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from build_zhenxun import source_identity
|
||||
from release_metadata import commit_message
|
||||
|
||||
|
||||
class ReleaseMetadataTests(unittest.TestCase):
|
||||
def test_multiline_unicode_message_is_bound_to_build_commit(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
def git(*args, message=None):
|
||||
return subprocess.check_output(
|
||||
["git", "-c", "user.name=Farm Test",
|
||||
"-c", "user.email=farm-test@example.invalid", *args],
|
||||
cwd=temporary, input=message, text=True, encoding="utf-8",
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
|
||||
git("init", "--quiet")
|
||||
original = '修复农场显示\n\n保留正文、引号和 $() 字符。\n第二行。'
|
||||
git("commit", "--allow-empty", "--file=-", message=original)
|
||||
source = source_identity(temporary)
|
||||
self.assertEqual(source["message"], original)
|
||||
self.assertEqual(source["commit"], git("rev-parse", "HEAD"))
|
||||
git("commit", "--allow-empty", "--file=-", message="后续提交")
|
||||
manifest = {"source": source, "version": "2.0.1"}
|
||||
expected = (
|
||||
original + "\n\nFarm-Source-Commit: " + source["commit"]
|
||||
+ "\nFarm-Version: 2.0.1"
|
||||
)
|
||||
self.assertEqual(commit_message(manifest), expected)
|
||||
self.assertNotIn("后续提交", commit_message(manifest))
|
||||
|
||||
def test_old_or_invalid_metadata_requires_rebuilding(self):
|
||||
for source in ({"commit": "old"}, {"message": " "},
|
||||
{"message": None}, {"message": 123}):
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, "rebuild"):
|
||||
commit_message({"source": source, "version": "2.0.1"})
|
||||
@@ -256,6 +256,12 @@ class ZhenxunPackageTests(unittest.TestCase):
|
||||
commit = next(data for method, path, data in calls
|
||||
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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user