Compare commits

...
3 Commits
46 changed files with 2672 additions and 569 deletions
+15 -12
View File
@@ -1,6 +1,6 @@
# 第一批基础核心实现
日期:2026-10-15。对应 Comet change:farm-core-foundation。
更新日期:2026-10-26。对应Comet change:farm-core-foundation。
已确认规格位于 comet/changes/farm-core-foundation。
本文件记录实现与开发期测试,不替代 Runtime 的独立验收报告。
@@ -22,12 +22,15 @@
按职责先采用小模块;随着用例增加再拆分目录,
不为了贴合远期目录树生成大量空包。
核心只用标准库,不导入三框架,包内部相对导入。
wheel 随包携带 JSON,不包含 legacy、图片、玩家数据库或其他框架入口。
wheel随包携带JSON及小型升级土地素材,不包含legacy、玩家数据库或
其他框架入口;大型图片ZIP通过匹配的外部资源提供。
## 已迁移与修复
- 沿用500初始币、3地块、等级门槛及源作物价格/产量/经验/成熟时间。
- 旧作物库202条记录完整导出且保留源 SHA-256。
- 注册500初始币、6地块;可靠识别为初始3块的旧号一次性补3块,
保留已开垦成果。详见land-rules.md;价格/产量/成熟时间沿用目录。
- 初始作物库202条记录完整迁入Service;当前数据发布和启动更新见
[作物数据维护](catalogue-updates.md),旧库基线独立保留。
- 多季再生长间隔明确保留去重 phase[1:] 求和,未重平衡数值。
- F01/F02:余额和经验只增加本次增量。
- F03:每块地单独加成、向下取整,再求和。
@@ -66,8 +69,9 @@ SchemaError 表示外来或不兼容库。接入层不能把后两者当作未
## 持久化与取消语义
新库使用独立 application_id、schema_version=1:
accounts、inventory、plots、operations、ledger。
新库使用独立application_id,当前schema_version=5,包含accounts、
inventory、plots、operations、ledger及thefts。受支持旧schema按顺序
事务升级;迁移失败整体回滚,不手工改版本号。
未知表集、外来库或更高版本被拒绝,不把旧 farm.db 自动当新库打开。
每次写用例使用新连接与 BEGIN IMMEDIATE;提交前统一写幂等结果。
@@ -93,12 +97,11 @@ close 拒绝新请求并等待在途任务,无导入时线程或持久数据
- 首次事务测试有2项因测试代码未关闭连接而在Windows清理失败;
修复测试资源释放后通过,不隐藏该失败。
- 最终结果以 Comet 管理的 verification.md 为准。
- 真实框架、旧玩家库、联机、图片和远程发布没有测试或启用。
- 上述计数为初期历史检查,不是当前完整套件计数;图片、接入与迁移
已有后续源码及离线测试,不因此声称真实宿主或生产玩家库已验证。
## 正式验收结果
Runtime已执行完整离线检查并通过。
独立只读Verifier核对A1至A8后全部判定通过,结果已由Runtime接收;
正式报告为 comet/changes/farm-core-foundation/verification.md。
通用Skill桥接不能机器认证独立执行身份,当前等待用户接受结果,
尚未归档、提交或推送。此状态不等于三框架插件已发布。
正式结果以Comet生成报告为准,不在本说明预先宣称本轮验收通过。
当前真寻安装与维护见[zhenxun-preview-current.md](zhenxun-preview-current.md)。
离线测试、模拟宿主与生产安装/远端发布分别记录,不互相替代。
+33 -26
View File
@@ -1,7 +1,7 @@
# 共享图片工具与“我的农场”
状态:共享渲染已实现并通过离线检查和独立只读验收;等待用户接受。
真实宿主命令与上传仍未接入。
更新日期:2026-10-26。共享渲染和宿主接入已有实现;实际验收结果
由Comet报告记录,模拟测试不能证明真实宿主消息发送或客户端显示。
Python 语法基线3.10;本机实测 Windows/Python3.12.14/Pillow12.3.0。
## 安装与职责
@@ -25,7 +25,8 @@ NoneBot、AstrBot、真寻均可调用,不导入任何宿主对象。
默认使用由 farm_service 分发并缓存的小赖字体(lxgw/kose-font v3.126),原版字体与
完整 SIL OFL 1.1 许可一同分发,版权声明见
`src/farm_render/data/OFL-Xiaolai.txt`。插件固定使用小赖字体,不提供
字体路径配置;开发测试可通过 FARM_TEST_FONT 指定路径。当前
字体路径网页配置;共享Runtime/绘图API支持显式路径,开发测试可通过
FARM_TEST_FONT指定路径。当前
实现校验字体可加载,不保证任意字体具备所有中文、Emoji 字形。
## 调用
@@ -33,7 +34,10 @@ NoneBot、AstrBot、真寻均可调用,不导入任何宿主对象。
```python
from farm_render import FarmRenderer, RenderOptions, RenderService
renderer = FarmRenderer("/path/to/chinese-font.ttf")
# 素材准备在接入层完成;此处传入与包内清单匹配的本地缓存路径。
renderer = FarmRenderer(
"/path/to/chinese-font.ttf", bundle_path=verified_bundle_path,
)
rendering = RenderService(renderer, concurrency=1, max_pending=8)
try:
picture = await rendering.my_farm(
@@ -60,7 +64,8 @@ now, options),统一 now 使用UTC秒整数。正常未注册/存储故障仍
## 尺寸、格式和质量
- 普通:1024×624、quality75;RenderOptions.high() 为1536×936、85。
- 普通默认1024×624、质量75;质量配置整数1~95、默认75。
- 仅我的农场支持高清1536×936,质量max(配置值,85),其他查询拒绝高清。
- 编码格式独立:AUTO、WEBP、JPEG、PNG;WebP method默认2。
- AUTO仅根据接入层声明的 supported 和本机编码能力选择,默认
supported只有JPEG。确认平台支持后传入WEBP,才会优先使用它。
@@ -81,8 +86,9 @@ now, options),统一 now 使用UTC秒整数。正常未注册/存储故障仍
布局使用旧30块地坐标。新播种及新一季使用虫害工作量计算阶段,
未启用虫害的迁移前当前季仍从成熟时间和基础时长推导。
枯萎优先于成熟。作物采用完整画布底边中点统一公式,
不再使用人工阶段锚点和旧 offset 字段,详见 `15-crop-position-formula.md`。
枯萎优先于成熟。作物采用schema=4的visible_contact_center统一落点,
直立等比缩放、保留完整纹理,不再使用人工阶段锚点和旧offset字段。
当前规则见 `22-crop-scene-mapping.md`;15号文档记录公式演变历史。
虫子使用场景包中的固定坐标与稳定选帧,规则见 `16-pest-mechanics.md`。
素材LRU默认32MiB,成图LRU默认8MiB;零预算禁用对应缓存,超限
@@ -103,9 +109,10 @@ RenderService默认一个绘图任务,最多8个活跃/排队的不同请求
## 素材完整性与来源
scripts/export_render_assets.py将已有素材原样打包,附202种作物
展示配置、旧布局和统一定位规则;额外打包新提取的 40 张网页素材。
--check比对确定性输出与原图哈希,不修改旧基线。
现行素材及展示配置由Service的catalogue/source维护,作为完整版本发布,
插件启动时统一选择;见[作物数据维护](catalogue-updates.md)。
scripts/export_render_assets.py仅保留显式旧基线导出;默认--check核对
当前bootstrap,不再把日常配置绑定到旧库。
素材包约束为安装资源,不按用户输入路径访问任意文件、不解压到磁盘。
保留GPL及原始归属;历史素材分发权仍需在公开发布前核实。
@@ -113,21 +120,20 @@ scripts/export_render_assets.py将已有素材原样打包,附202种作物
缺失时显示问号占位并记录静态警告,避免整个命令失败;尚未补齐
缺失图片,也不推测相近作物素材替代。不会声称202种作物均有完整图片。
## 原版用户面板恢复(2026-10-16)
## 当前用户面板(2026-10-26核对)
使用旧版 `background/userInfo.png` 与 `background/frame/frame.png`,
按原有顺序叠放底板、140×150头像、头像框和昵称/经验/等级/货币。
不再用通用圆角信息卡,不额外显示地块数量。无效头像继续安全降级。
用户确认按原底图校正旧坐标:经验条位于原素材经验槽内,等级与两种
货币不越出底板。普通、高清图统一按3072基准坐标缩放。
面板使用metadata.json的panel配置及网页场景切片,按reference_canvas
映射位置、头像、字段与经验条,不再按旧底板固定坐标绘制。
头像无效时安全降级,普通与高清按同一面板配置缩放。
保留旧版经验计算口径:本级已获经验为累计经验减本级累计门槛,
分母仍为下一级累计经验门槛;等级门槛没有变化。点券取真实账户余额,
本级已获经验为累计经验减200*level*(level+1)//2,
分母为本级升级所需经验200*(level+1),不使用下一级累计门槛。
点券取真实账户余额,
不继承旧版硬编码0。素材按目标尺寸缓存,查询仍只读一次一致快照;
头像字节限制、缓存失效、后台限流和格式选项保留。
本次离线测量见 `render-panel-restored-benchmark.json`,
样图在 `runtime/panel-restored`;不代表真实平台发送耗时。
`render-panel-restored-benchmark.json`及runtime/panel-restored是历史
面板恢复测量,不代表当前面板或真实平台发送耗时。
## 测量与验证
@@ -137,7 +143,10 @@ python scripts/export_render_assets.py --check
python scripts/verify_foundation.py
```
本轮测量原始结果见 [render-benchmark.json](render-benchmark.json)。
下表为早期历史测量,原始结果见[render-benchmark.json](render-benchmark.json)。
2026-10-26新测位于runtime/archive-foundation-20261026/benchmark.json,
使用当前源码生成六幅1024/1536的WebP、JPEG和PNG图并验证解码与暖缓存;
本机环境为Windows/Python3.12.14/Pillow12.3。输出目录不进入Git。
样例为构造的18块解锁地,包含空地、生长、成熟、枯萎与锁定地。
输出和日志位于runtime/render-demo,不进入Git。
@@ -160,8 +169,6 @@ python scripts/verify_foundation.py
字节与成图命中,不记录真实姓名、作用域、头像或完整快照。
上传阶段由适配器单独记录,不能将发送接口返回当作客户端已显示。
正式检查结果:45项测试全部通过,827文件基线与两类可复现导出
检查通过。独立只读Verifier核对完整核心/渲染规格和源码后,A1-A13
全部通过。验收报告见 [Comet报告](
comet/changes/farm-core-foundation/verification.md)。通用Skill桥接不提供
机器独立身份认证,流程仍要求用户明确接受后才归档。
45项测试和早期A1-A13结论属于历史记录;本轮正式结果以Comet生成
报告为准,不由旧报告推定。真实平台、其他CI矩阵与远端资源可用性
不属于本机绘图样例的证明范围。
+7 -4
View File
@@ -1,14 +1,17 @@
# 文档索引
记录日期:2026-10-15。状态:已形成设计草案,待关键产品决策确认;
第一批基础核心已实施;多框架接入、发布和真实数据迁移尚未实施。
更新日期:2026-10-26。共享核心、绘图和三框架接入/打包已有实现;
本轮仅做本地归档验收,不代表真实宿主、生产数据迁移或远端发布已验证。
长期设计草案及历史页面须与现行使用说明区分。
| 文档 | 用途 |
|---|---|
| [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 |
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
| [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 |
| [11-zhenxun-preview.md](11-zhenxun-preview.md) | 真寻原指令预览、构建/校验/安装和可选同步 |
| [zhenxun-preview-current.md](zhenxun-preview-current.md) | 当前真寻构建、依赖、安装、配置、数据备份/回退/卸载及发布Secrets |
| [11-zhenxun-preview.md](11-zhenxun-preview.md) | 旧版页面留档,不作为当前功能和安装依据 |
| [zhenxun-preview-checks.json](zhenxun-preview-checks.json) | 最终测试包大小/哈希及实际验证边界 |
| [render-benchmark.json](render-benchmark.json) | 本机合成数据的冷暖缓存与编码测量 |
| [01-roadmap.md](01-roadmap.md) | 长期目标、实施顺序、里程碑与验收 |
@@ -34,4 +37,4 @@
- 发布仓库是分发目标,不是 Git 子模块;各框架使用相互独立的插件包。
- 主仓库远程为 Shu-Ying/main_plugin_farm;本轮未执行远程操作。
- 用户已撤回拉取要求,且要求删除本地克隆;三个本地检出已删除,不再访问远程。
- 本轮仅修正迁移副本中的硬编码凭据;业务缺陷记录在审查报告,没有隐式修复。
- 历史迁移预检与后续实现分开记录;当前行为以源码、现行说明和正式验收为准。
+51
View File
@@ -0,0 +1,51 @@
# 作物数据维护与启动更新
作物的可编辑源已迁入独立 Service 仓库的 `catalogue/source/`。
插件内只保留首次离线启动所需的轻量基线;日常改价、调整生长参数和新增作物
不需要重新发布插件。图片、展示配置、作物定义及升级土地图作为同一版本发布。
## 日常更新
1. 在 Service 修改 `catalogue/source/plants.json`。新增作物使用未占用的正整数
ID,在 `catalogue/source/images/` 添加对应图片;布局在 `metadata.json`。
2. 在 Service 根目录生成一个新的版本标签:
```text
python scripts/prepare_catalogue.py --source catalogue/source --output catalogue --version crops-20261026-01
python scripts/prepare_catalogue.py --source catalogue/source --output catalogue --version crops-20261026-01 --check
```
3. 审查并发布 Service 中的源文件、`catalogue/manifest.json` 和新生成的
`catalogue/releases/` 历史版本。Github/Gitea 源需将文件发布到相应仓库;
HTTP 源需配置 `FARM_CATALOGUE_DIR` 指向完整 `catalogue`,并重启 Service
以加载新快照。详细操作见 Service 的 `docs/catalogue.md`。
4. 重启或重载农场插件。三框架共用启动流程,按配置的资源源检查一次更新;
运行期间不轮询,不改变当前进程已加载的版本。改用 HTTP 源时使用有效的
HTTPS Service 基础地址。
不可删除或改名已发布的作物 ID,也不可删除既有图片路径。停售或停止播种应
调整对应开关。同一版本标签不可复用于不同内容。回滚应从旧配置生成新的兼容
版本,保留已经发布的 ID、名称和图片;不要直接退回缺少新增 ID 的旧版本。
## 缓存与玩家数据
启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换
数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经
验证的缓存,再使用插件基线。日志位于同一数据目录的 `farm-preview.log`。
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
丢失库存。不要通过删除玩家数据解决版本问题,应恢复缓存或提供兼容发布版本。
已播种作物继续按播种时的快照结算;出售使用当前加载版本的价格。
## 插件维护者刷新离线基线
只有准备发布新版插件时才需更新内置基线。在 Farm 根目录执行:
```text
python scripts/sync_catalogue_bootstrap.py --source D:/Work/farm_service/catalogue
python scripts/check_catalogue_bootstrap.py
```
同步前请确认 Service 发布版本已审查。CI 独立核对 Farm 内置产物,不依赖相邻
Service 工作区或在线下载。旧库导出工具只保留为显式迁移参考,不再是日常数据源。
@@ -0,0 +1,122 @@
# Outcome
在D:/Work/farm当前main工作区归档现有框架无关基础核心、共享绘图和
真寻预览交付。按用户2026-10-26确认,将旧规格同步为已经实现并记录
的规则,补齐离线验收;不借归档修改业务代码或实施作物Service迁移。
# Scope
- farm_core不依赖三种框架或Pillow;离线迁出旧基线202条作物,以稳定
本地ID为身份,运行时允许同名并拒绝名称歧义,不在运行时引用legacy。
- SQLite保存权威账户、库存、地块、交易流水和幂等结果;显式身份由
deployment/realm/platform/user构成,不推断跨群或跨平台绑定。
- 注册赠送500农场币、0点券、0经验及6块普通空地;可靠识别为初始
3块的旧用户仅补3块普通空地,保留已付费开垦成果,重试不重复补偿。
- 购买、播种、收获、铲除、加锁/解锁、出售和查询保持整数校验、
事务原子性、失败传播及可注入时钟。已播种作物保存不可变结算定义。
- 经验等级门槛200*k*(k+1)//2;播种每地+2、铲除每地+2经验,收获
逐地计算本次增量。首熟为time*3600,再生长沿用去重phase[1:]之和。
- farm_render提供Pillow同步工具、有界缓存、目标尺寸绘制、后台受控
执行、三格式编码及安全日志;普通宽1024,农场高清宽1536。
- 统一图片编码质量为整数1~95、默认75;仅我的农场接受高清,质量
为max(配置值,85),其他查询拒绝高清,保留分页与文字。
- 作物采用visible_contact_center统一定位及当前稳定地块编号映射,
不使用旧offset或逐阶段手调锚点;缺图明确占位并记录静态警告。
- 开发素材独立导出,插件包含轻量元数据、清单和小型升级土地图;
大型assets.zip及字体ZIP不进发布包,按Github/Gitea/farm_service
选择源校验下载缓存。服务地址独立保留,显式中文字体优先;默认使用
原版小赖字体和完整OFL许可,资源不可用则接入层保留文字功能。
- 真寻原指令、私有_vendor命名空间、稳定幂等与启动关闭生命周期;
白名单构建、独立验证及安全安装仅在临时目录测试。
- 工作流执行检查并上传附件;push main或显式publish输入可触发固定
真寻发布仓库main分支的普通同步,PR不发布,脚本默认dry-run且不force。
- 后续已存在的NoneBot/AstrBot、土地扩展/升级、偷菜、虫害及旧库迁移
保留现状,不加入本change的新需求;完整离线测试仍检查其回归。
## Directory structure
### Created
本轮不新建业务模块;Comet生成正式验收报告与归档卷宗。
### Modified
- 当前brief与specs/farm-core、farm-render、zhenxun-preview/spec.md。
- release/targets.json中的真寻目标分支记录与实际main规则对齐。
- 如需记录本轮测量,使用现有runtime下的独立样例目录。
### Deleted
仅由Comet归档事务迁移当前change文件,不删除业务代码或玩家数据。
### Not created
不创建作物远程目录、Service接口、新业务模块、真实玩家库或新框架接入。
# Non-goals
- 不实施作物Service迁移或启动在线更新;另一个仓库中的迁移设计不
纳入本次归档,不因此视为已获实施确认或已实现。
- 不改业务行为、重新平衡数值、修改legacy、重置或迁移真实玩家资产。
- 不clone/fetch/pull/push,不创建PR、Release或执行实际跨仓库发布。
- 不安装、启动或重启真实机器人,不部署Service,不访问真实玩家库。
- 不把本地检查、模拟宿主或静态工作流证据称为真实平台及远端CI通过。
# Acceptance examples
- A1: 旧基线827文件校验继续通过;核心不导入框架、无导入副作用,
从源码及安装包可独立运行,不在运行时引用legacy。
- A2: 可复现导出202条基线作物;ID及必要数值对应源静态数据库;
加载校验格式/schema,坏数据明确拒绝。运行时ID唯一,同名拒绝歧义。
- A3: 新注册得到500农场币、0点券、0经验、6空地;重复注册不增发,
不同作用域隔离;旧初始3块账号仅补3块,并发/重试/重启不重复补偿。
- A4: 购买、播种扣款/入库/耗种正确;0、负数、bool、超大数量、余额、
库存或等级不足及未知作物明确失败,没有部分写入。
- A5: 未成熟不能收获,成熟只增加本次产量/经验;收获前100、本次20
后为120,播种经验另算。逐地加成顺序无关,多季/枯萎/铲除及成熟
边界遵守保存定义及可注入时钟。
- A6: 余额100、出售收益20后为120;锁定限制指定和全部出售;
返回真实余额及安全文本,不出现模板参数缺失。
- A7: 故障注入使资产/地块/流水/结果整体回滚;重试返回原结果,
参数冲突拒绝;并发不透支/重复领奖,故障不伪装成功,重启后可读。
- A8: 离线测试、运行入口与说明可执行;文档区分源码、模拟、离线
产物与真实宿主/远程验证,准确记录当前实现与待办。
- A9: 图片工具支持透明合成、缩放、裁剪、测量/省略及三格式编码;
渲染不联网或导入框架,核心无Pillow依赖;安装后提供匹配外部资源可绘图。
- A10: 一次快照与统一时间绘制空地、生长、成熟、再生长、枯萎及
锁定状态,采用当前地块映射及统一定位,普通/高清输出可解码。
- A11: 小图从目标尺寸绘制,素材与成图缓存有界且不污染共享图片;
阶段/成熟边界、余额、头像及身份变化不会复用过期图。
- A12: 后台合成编码限制并发、相同请求合并;取消不提前释放名额,
关闭等待任务排空;日志仅记录耗时、尺寸、缓存等安全信息。
- A13: 离线样例和真实测量覆盖尺寸/编码、冷暖缓存及调用方式;
图片可解码,明确本机测量不等同真实平台上传性能。
- A14: 真寻原指令、路由、身份/幂等、异常反馈与启动关闭通过模拟验证;
字体显式路径优先、默认校验下载,资源失败文字可用,不操作真实宿主。
- A15: 白名单真寻插件包包含私有共享模块、轻量数据、许可和来源哈希,
不含其他适配、legacy、测试、玩家数据或大型资源ZIP;准确标识脏源码,
相同输入可复现,构建不改源码。
- A16: 离线验证内容、导入与路由;安装拒绝穿越、坏哈希和已有目标,
失败无半成品,不操作旧插件/玩家数据或启动真实机器人。
- A17: 工作流检查、构建、上传;push main或显式publish输入可触发固定
真寻仓库main普通同步,PR不发布;脚本默认dry-run、不force,冲突失败,
凭据不落盘。本次只检查与模拟,不执行真实发布。
- A18: 本机离线打包与安全边界检查通过;静态核对Windows/Linux与
Pillow10.4/12.3矩阵,明确本机环境及未运行矩阵;提供安装、依赖、
字体、数据库、回退/卸载与Secrets说明,不宣称真实平台已验证。
- A19: 图片质量默认75,整数1和95有效,0、96、bool及非整数拒绝;
配置覆盖图片编码并区分缓存。仅我的农场允许高清,质量不低于85与
配置值,其他查询拒绝高清,分页/文字可用,帮助和配置说明一致。
# Constraints and invariants
沿用当前main目录,不创建或合并分支,不改业务代码。保留许可与来源,
不记录令牌、凭据或真实玩家信息。正式状态和报告由Comet维护,不伪造
验收证据。本轮检查绑定当前候选与文档,旧报告不能代替本轮验收。
# Decisions
- 用户已确认按当前已实现并记录的规则同步旧规格、补齐验收后归档,
并再次要求“我已确认,请归档”;授权本地文档、验收与归档收尾。
- 原记录中的3块地、仅手动farm-preview分支发布、包内大素材及旧
offset定位已被后续实现替代。本规格记录当前行为,不恢复旧规则。
@@ -0,0 +1,617 @@
schema: comet.native.v4
name: farm-core-foundation
language: en
phase: archive
status: done
state_version: 50
brief: brief.md
document_constraints_version: 3
shape_confirmation_hash: 0c329cbbb25fef680c0862751441fb78bb7bd4d7a8a2f698edf5a20d17ab828e
spec_changes:
- capability: farm-core
operation: create
source: specs/farm-core/spec.md
- capability: farm-render
operation: create
source: specs/farm-render/spec.md
- capability: zhenxun-preview
operation: create
source: specs/zhenxun-preview/spec.md
workspace:
isolation: current
change_branch: main
target_branch: main
finish: null
loop:
stage: done
goal_cycle: 7
iteration: 2
attempt: 1
retry_epoch: 0
failed_iteration_count: 1
no_progress_count: 0
execution_failure_count: 0
previous_unresolved_ids: []
next_action: null
acceptance:
- id: A1
source: brief.md
text: "A1: 旧基线827文件校验继续通过;核心不导入框架、无导入副作用, 从源码及安装包可独立运行,不在运行时引用legacy。"
result: passed
reason:
text: 当前Runtime
foundation-offline核实827旧基线与源码/安装包隔离测试通过;test_packaging.py校验无宿主导入、无导入副作用及临时wheel独立运行。
truncated: false
- id: A2
source: brief.md
text: "A2: 可复现导出202条基线作物;ID及必要数值对应源静态数据库; 加载校验格式/schema,坏数据明确拒绝。运行时ID唯一,同名拒绝歧义。"
result: passed
reason:
text: export_catalogue.py只读静态库并排序确定性导出;Runtime导出比对、202条目录及坏schema/重复ID/同名歧义测试通过,catalogue.py显式拒绝无效输入。
truncated: false
- id: A3
source: brief.md
text: "A3: 新注册得到500农场币、0点券、0经验、6空地;重复注册不增发, 不同作用域隔离;旧初始3块账号仅补3块,并发/重试/重启不重复补偿。"
result: passed
reason:
text: repository.py:33注册500币和6空地;application.py:61从注册来源识别旧初始3块并以固定幂等事务补3块;正式注册/隔离/并发/故障/重启补地测试通过。
truncated: false
- id: A4
source: brief.md
text: "A4: 购买、播种扣款/入库/耗种正确;0、负数、bool、超大数量、余额、 库存或等级不足及未知作物明确失败,没有部分写入。"
result: passed
reason:
text: application.py:177购买与208播种校验目录、数量、余额、库存并在事务内修改;正式非法数量、未知作物、等级/余额不足、耗种与回滚测试通过。
truncated: false
- id: A5
source: brief.md
text: "A5: 未成熟不能收获,成熟只增加本次产量/经验;收获前100、本次20 后为120,播种经验另算。逐地加成顺序无关,多季/枯萎/铲除及成熟
边界遵守保存定义及可注入时钟。"
result: passed
reason:
text: application.py:251按保存的plot.plant逐地结算本次经验和产量、成熟边界与再生长;test_transactions中冻结定义、逐地加成、历史经验增量、多季和铲除测试通过。
truncated: false
- id: A6
source: brief.md
text: "A6: 余额100、出售收益20后为120;锁定限制指定和全部出售; 返回真实余额及安全文本,不出现模板参数缺失。"
result: passed
reason:
text: application.py:437统一保护锁定库存且只加本次收益;正式100余额出售20得120、两种出售入口锁定及安全文本测试通过。
truncated: false
- id: A7
source: brief.md
text: "A7: 故障注入使资产/地块/流水/结果整体回滚;重试返回原结果, 参数冲突拒绝;并发不透支/重复领奖,故障不伪装成功,重启后可读。"
result: passed
reason:
text: storage.py:353 BEGIN
IMMEDIATE将状态、流水与幂等结果一起提交,异常整体回滚;正式故障注入、参数冲突、取消重放、并发扣款/收获与重启测试通过。
truncated: false
- id: A8
source: brief.md
text: "A8: 离线测试、运行入口与说明可执行;文档区分源码、模拟、离线 产物与真实宿主/远程验证,准确记录当前实现与待办。"
result: passed
reason:
text: 新增docs/zhenxun-preview-current.md与实际构建、verify_foundation及安装入口一致;docs索引明确11为历史,09/10和发布README已说明6地、当前字体、内置作物与待办,并区分本机/模拟/真实验证。
truncated: false
- id: A9
source: brief.md
text: "A9: 图片工具支持透明合成、缩放、裁剪、测量/省略及三格式编码; 渲染不联网或导入框架,核心无Pillow依赖;安装后提供匹配外部资源可绘图。"
result: passed
reason:
text: canvas.py支持透明、缩裁、测量省略及三编码;assets/renderer不导入框架或联网,准备资源在接入层;正式Canvas及临时wheel绘图测试通过。
truncated: false
- id: A10
source: brief.md
text: "A10: 一次快照与统一时间绘制空地、生长、成熟、再生长、枯萎及 锁定状态,采用当前地块映射及统一定位,普通/高清输出可解码。"
result: passed
reason:
text: layout.py纯消费快照和now构造空/生长/成熟/再生长/枯萎状态,renderer使用当前映射;正式阶段、目标尺寸、作物统一落点回归通过,render-decode验证6幅普通/高清图片。
truncated: false
- id: A11
source: brief.md
text: "A11: 小图从目标尺寸绘制,素材与成图缓存有界且不污染共享图片; 阶段/成熟边界、余额、头像及身份变化不会复用过期图。"
result: passed
reason:
text: assets.py有界ByteLRU并返回图片副本;renderer请求键含场景、身份摘要、配置、格式和头像摘要;正式预算/共享图片安全/阶段成熟边界及余额头像身份失效测试通过。
truncated: false
- id: A12
source: brief.md
text: "A12: 后台合成编码限制并发、相同请求合并;取消不提前释放名额, 关闭等待任务排空;日志仅记录耗时、尺寸、缓存等安全信息。"
result: passed
reason:
text: service.py一次snapshot、to_thread、Semaphore及shield共享任务,关闭排空;正式事件循环不阻塞、并发上限、取消保留容量和失败重试测试通过;源码日志未输出身份或快照。
truncated: false
- id: A13
source: brief.md
text: "A13: 离线样例和真实测量覆盖尺寸/编码、冷暖缓存及调用方式; 图片可解码,明确本机测量不等同真实平台上传性能。"
result: passed
reason:
text: render_demo.py提供合成样例和冷暖缓存测量;本轮benchmark明确Windows/Python3.12.14/Pillow12.3及非上传范围,绑定Runtime验证1024/1536三格式六文件可解码且暖缓存全命中。
truncated: false
- id: A14
source: brief.md
text: "A14: 真寻原指令、路由、身份/幂等、异常反馈与启动关闭通过模拟验证; 字体显式路径优先、默认校验下载,资源失败文字可用,不操作真实宿主。"
result: passed
reason:
text: commands.py注册原指令与启动关闭、身份和稳定消息标识;runtime.py默认校验资源/字体、显式字体优先、失败文字保留;本候选模拟路由/生命周期/异常/幂等正式测试通过。
truncated: false
- id: A15
source: brief.md
text: "A15: 白名单真寻插件包包含私有共享模块、轻量数据、许可和来源哈希,
不含其他适配、legacy、测试、玩家数据或大型资源ZIP;准确标识脏源码, 相同输入可复现,构建不改源码。"
result: passed
reason:
text: build_zhenxun.py白名单生成私有_vendor、轻量数据许可与来源哈希,排除assets.zip并固定ZIP时间排序、记录dirty;正式可复现完整包及私有导入测试通过。
truncated: false
- id: A16
source: brief.md
text: "A16: 离线验证内容、导入与路由;安装拒绝穿越、坏哈希和已有目标, 失败无半成品,不操作旧插件/玩家数据或启动真实机器人。"
result: passed
reason:
text: verify_zhenxun.py拒绝穿越、重复、链接和哈希差异;install_zhenxun.py先校验、临时写入且拒绝已有目标;正式坏包/安装/目标保留及离线导入路由测试通过。
truncated: false
- id: A17
source: brief.md
text: "A17: 工作流检查、构建、上传;push main或显式publish输入可触发固定
真寻仓库main普通同步,PR不发布;脚本默认dry-run、不force,冲突失败, 凭据不落盘。本次只检查与模拟,不执行真实发布。"
result: passed
reason:
text: 工作流默认只读且PR无发布路径,main
push或明确publish输入经test/build触发固定目标;publish_zhenxun.py默认dry-run、不force、并发冲突失败,正式模拟和workflow-guards通过。
truncated: false
- id: A18
source: brief.md
text: "A18: 本机离线打包与安全边界检查通过;静态核对Windows/Linux与
Pillow10.4/12.3矩阵,明确本机环境及未运行矩阵;提供安装、依赖、
字体、数据库、回退/卸载与Secrets说明,不宣称真实平台已验证。"
result: passed
reason:
text: 当前说明覆盖生成包/requirements、停止旧插件、字体、数据目录、日志、备份回退卸载和Secret;与源码及Windows/Linux/Pillow10.4/12.3矩阵一致,明确1项跳过和未运行矩阵、真实宿主/远端边界;离线包安全测试通过。
truncated: false
- id: A19
source: brief.md
text: "A19: 图片质量默认75,整数1和95有效,0、96、bool及非整数拒绝; 配置覆盖图片编码并区分缓存。仅我的农场允许高清,质量不低于85与
配置值,其他查询拒绝高清,分页/文字可用,帮助和配置说明一致。"
result: passed
reason:
text: RenderOptions严格整数1至95、默认75;Runtime请求读取质量,仅farm高清1536且max(质量,85),其他查询拒绝高清;正式边界、逐编码器传递、质量缓存与帮助测试通过。
truncated: false
builder_handoff:
candidate_id: 2e7d9479-6e65-480f-b4df-85f937519b86
identity_provider: skill-coordinated
builder_execution_ref: skill-coordinated:builder:0eff5a20-cba2-445f-95de-405dd2ee7c01
iteration: 2
summary:
text: 修复独立验收A8/A18:新增docs/zhenxun-preview-current.md,补齐构建、部署依赖、安装配置、数据日志、备份回退卸载、Secrets及真实验证边界;docs索引明确旧docs/11为历史,更新09/10现行规则与发布README,取消签到/在线作物更新误报。无业务代码变更;重新核对全部19项,保留原17项证据。
truncated: false
addressed_acceptance_ids:
- A1
- A2
- A3
- A4
- A5
- A6
- A7
- A8
- A9
- A10
- A11
- A12
- A13
- A14
- A15
- A16
- A17
- A18
- A19
acceptance_review:
- id: A1
status: implemented-with-evidence
evidence:
- scripts/check_baseline.py
- tests/test_packaging.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A2
status: implemented-with-evidence
evidence:
- scripts/export_catalogue.py
- tests/test_domain.py
- tests/test_crop_identity.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A3
status: implemented-with-evidence
evidence:
- src/farm_core/repository.py:33
- tests/test_transactions.py
- tests/test_land.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A4
status: implemented-with-evidence
evidence:
- src/farm_core/application.py:177
- tests/test_transactions.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A5
status: implemented-with-evidence
evidence:
- src/farm_core/application.py:251
- tests/test_transactions.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A6
status: implemented-with-evidence
evidence:
- src/farm_core/application.py:437
- tests/test_transactions.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A7
status: implemented-with-evidence
evidence:
- src/farm_core/storage.py:353
- tests/test_transactions.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A8
status: implemented-with-evidence
evidence:
- docs/zhenxun-preview-current.md
- docs/README.md
- docs/09-foundation.md
- docs/10-rendering.md
- readme/zhenxun.md
- scripts/verify_foundation.py
note: 已对照源码修正历史描述,提供完整当前使用说明并清晰区分历史记录和本轮验证;原业务行为未变,供新的独立Verifier复核。
- id: A9
status: implemented-with-evidence
evidence:
- src/farm_render/canvas.py
- src/farm_render/assets.py
- tests/test_packaging.py
- tests/test_render.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A10
status: implemented-with-evidence
evidence:
- src/farm_render/renderer.py
- tests/test_crop_positions.py
- tests/test_render.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A11
status: implemented-with-evidence
evidence:
- src/farm_render/assets.py
- tests/test_render.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A12
status: implemented-with-evidence
evidence:
- src/farm_render/service.py
- tests/test_render.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A13
status: implemented-with-evidence
evidence:
- runtime/archive-foundation-20261026/benchmark.json
- scripts/render_demo.py
- docs/10-rendering.md
note: 本轮Windows/Python3.12.14/Pillow12.3实测六种输出,1024/1536三格式冷暖缓存均生成;正式Verifier复核解码与报告。
- id: A14
status: implemented-with-evidence
evidence:
- integrations/shared/runtime.py
- tests/test_zhenxun_runtime.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A15
status: implemented-with-evidence
evidence:
- scripts/build_zhenxun.py
- tests/test_zhenxun_package.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A16
status: implemented-with-evidence
evidence:
- scripts/verify_zhenxun.py
- scripts/install_zhenxun.py
- tests/test_zhenxun_package.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A17
status: implemented-with-evidence
evidence:
- .github/workflows/zhenxun-preview.yml
- scripts/publish_zhenxun.py
- tests/test_zhenxun_package.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
- id: A18
status: implemented-with-evidence
evidence:
- docs/zhenxun-preview-current.md
- docs/README.md
- docs/09-foundation.md
- docs/10-rendering.md
- readme/zhenxun.md
- .github/workflows/zhenxun-preview.yml
- tests/test_zhenxun_package.py
note: 已对照源码修正历史描述,提供完整当前使用说明并清晰区分历史记录和本轮验证;原业务行为未变,供新的独立Verifier复核。
- id: A19
status: implemented-with-evidence
evidence:
- src/farm_render/models.py
- integrations/shared/runtime.py
- tests/test_zhenxun_runtime.py
note: 已逐项核对当前源码与对应测试覆盖;此声明不是测试通过,正式结果由Runtime与独立Verifier给出。
checks:
- name:
text: documentation-diff
truncated: false
result: passed
note:
text: git diff --check退出0;更新内容仅文档/正式规格/发布目标记录。
truncated: false
checks_truncated: false
known_limits:
- text: 仅本地Windows/Python3.12.14/Pillow12.3;其他CI矩阵未执行。
truncated: false
- text: 未访问真实玩家库、未启动宿主、未推送或部署。
truncated: false
- text: 历史455个作物阶段缺图引用保留占位行为。
truncated: false
- text: 作物Service迁移尚未实施,另仓库设计不纳入归档。
truncated: false
known_limits_truncated: false
review: null
submitted_at: 2026-10-26T04:35:00.062Z
blockers: []
verification:
candidate_id: 2e7d9479-6e65-480f-b4df-85f937519b86
identity_provider: skill-coordinated
verifier_execution_ref: skill-coordinated:verifier:dbb1408c-96f4-4483-8af0-3b2fbe54af09
iteration: 2
attempt: 1
assurance: skill-coordinated
verdict: pass
checks:
- id: foundation-offline
name:
text: 完整离线核心/绘图/三端打包回归
truncated: false
argv_display:
- text: -NoProfile
truncated: false
- text: -Command
truncated: false
- text: $env:FARM_TEST_FONT='D:\Work\farm_service\fonts\Xiaolai-Regular.ttf';
$env:PYTHONIOENCODING='utf-8'; &
'D:\Work\farm\.release-cache\ci-aiohttp-20261023\Scripts\python.exe'
scripts/verify_foundation.py; exit $LASTEXITCODE
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 107377
- id: workflow-guards
name:
text: 工作流权限与发布边界静态检查
truncated: false
argv_display:
- text: scripts/check_workflows.py
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 110
- id: render-decode
name:
text: 本轮六种绘图样例与暖缓存验证
truncated: false
argv_display:
- text: -c
truncated: false
- text: import json; from pathlib import Path; from PIL import Image;
p=Path('runtime/archive-foundation-20261026');
d=json.loads((p/'benchmark.json').read_text(encoding='utf-8'));
assert len(d['measurements'])==6; assert all(r['warm_cache_hit'] for
r in d['measurements']); files=list(p.glob('farm-*')); assert
len(files)==6; [Image.open(f).verify() for f in files]; print('6
rendered files verified; all warm cache hits')
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 122
summary:
text: 新的独立只读Verifier完整核对brief四核心结构、三Spec与A1-A19,直接源码补齐过期图覆盖,并核查本candidate绑定Runtime三检查均exit0;复用219项离线记录(218通过、1项明确跳过)、工作流守卫和六图解码/暖缓存证据。A8/A18现行文档已与实现对齐,其余17项逐项核查通过;最后参考Builder交接,未重跑完整套件、修改业务/测试/文档/报告或操作玩家数据,未代用户接受或归档。
truncated: false
risks:
- text: 正式完整测试共219项,218通过;1项NoneBot宿主配置测试因本地缺Pydantic跳过,不计作通过。该项不属于本change真寻宿主配置验收。
truncated: false
- text: 仅本机Windows/Python3.12.14/Pillow12.3;工作流矩阵仅静态核对,Linux及Pillow10.4未在本轮运行。
truncated: false
- text: 未启动真实机器人、访问真实玩家库、验证公开下载、执行远端CI、发布或部署;历史455个缺图引用仍使用明确占位。
truncated: false
- text: 图索引为2026-10-15,相关路径过期或无覆盖;本轮以直接源码和正式日志补齐,未据旧图推断缺失。
truncated: false
- text: 作物Service迁移不属于本次归档范围且尚未实施。
truncated: false
risks_truncated: false
completed_at: 2026-10-26T04:40:15.168Z
history:
- goal_cycle: 1
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读验收完成:完整brief/Spec、实现、测试、配置和文档均已核对,最后参考Builder交接;绑定候选的Runtime
foundation-offline以退出码0完成,827基线文件、可复现目录导出与32项测试通过。A1-A8全部通过;未追加或自行重跑完整检查,未修改项目源文件。
truncated: false
completed_at: 2026-10-15T07:00:29.324Z
- goal_cycle: 1
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户明确要求在已确认的图片工具及绘图优化方案上执行开发;将共享农场渲染作为当前基础开发的范围补充,保留原业务验收项并追加图片验收。
truncated: false
completed_at: 2026-10-15T07:55:29.688Z
- goal_cycle: 2
iteration: 0
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: Native confirmed acceptance criteria changed
truncated: false
completed_at: 2026-10-15T08:23:15.134Z
- goal_cycle: 3
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。
truncated: false
completed_at: 2026-10-15T08:40:00.608Z
- goal_cycle: 3
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户选择补齐真寻测试版:命令接入、打包工作流和安装脚本;将现有核心与渲染扩展为可安装真寻预览包,保留独立测试库和旧基线,不执行远端操作。
truncated: false
completed_at: 2026-10-15T09:24:24.236Z
- goal_cycle: 4
iteration: 0
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: Native confirmed acceptance criteria changed
truncated: false
completed_at: 2026-10-15T09:28:23.268Z
- goal_cycle: 5
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对完整brief、三份规格及18项验收,审阅核心/渲染、真寻接入、白名单构建/校验/安装/固定发布边界、工作流和测试文档。复用本候选正式Runtime的55项全回归、Pillow10.4下23项兼容检查及工作流守卫,全部通过;未重复完整套件。图索引旧/新增缺覆盖,已直接源码补齐。未修改实现或正式规格,未访问远端、安装/启动机器人或代用户接受。
truncated: false
completed_at: 2026-10-15T09:51:26.860Z
- goal_cycle: 5
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 独立验收指出余额不足错误码映射缺少insufficient_funds;保持已确认范围,补中文提示及回归测试后重新交付。
truncated: false
completed_at: 2026-10-15T09:52:02.989Z
- goal_cycle: 5
iteration: 2
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
truncated: false
completed_at: 2026-10-15T09:58:23.989Z
- goal_cycle: 5
iteration: 2
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户实际测试要求默认字体:在真寻适配层默认复用宿主字体目录,保留显式字体覆盖和缺失时清楚提示;修复预览版默认不可绘图的接入遗漏。
truncated: false
completed_at: 2026-10-16T02:06:48.036Z
- goal_cycle: 5
iteration: 3
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读验收全部A1-A18:完整brief/三spec与当前字体实现、测试、文档已核对;复用此前未改变核心/渲染/发布的正式独立验收,并核实本候选绑定Runtime三检查exit0,实际58项完整与Pillow10.4下26项通过。真寻FONT_PATH为resources/font,接入层默认选宿主字体,坏默认跳过、显式覆盖优先,共享层未引入真寻路径;安装目录runtime/commands与主源SHA256一致。图索引不覆盖新文件或metadata_changed,已读源码补齐;未重跑完整测试、未修改项目文件、未启动宿主或访问远端。
truncated: false
completed_at: 2026-10-16T02:13:06.574Z
- goal_cycle: 5
iteration: 3
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户要求修复迁移遗漏:恢复旧版用户面板素材、布局与经验进度语义并保留性能优化。
truncated: false
completed_at: 2026-10-16T02:17:50.690Z
- goal_cycle: 5
iteration: 4
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: "Builder handoff Runtime checks failed: all-offline"
truncated: false
completed_at: 2026-10-16T02:26:17.238Z
- goal_cycle: 5
iteration: 5
attempt: 1
outcome: pass
unresolved_ids: []
summary:
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
- goal_cycle: 6
iteration: 1
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: "Native formal documents are invalid (brief-structure-missing: brief.md)"
truncated: false
completed_at: 2026-10-26T04:22:42.893Z
- goal_cycle: 7
iteration: 1
attempt: 1
outcome: fail
unresolved_ids:
- A8
- A18
summary:
text: 新的独立只读Verifier核对完整brief、三Spec及全部19项;brief四核心结构有效。复用当前candidate绑定Runtime三项退出0检查,未重跑完整套件。17通过,A8和A18因现行文档陈旧/缺失失败,修复说明后再验收;无需据此修改业务。独立核验完成后最后参考Builder交接,其文档证据不足。未修改业务、测试、规格、报告或玩家数据,未接受结果或归档。
truncated: false
completed_at: 2026-10-26T04:31:34.844Z
- goal_cycle: 7
iteration: 2
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 新的独立只读Verifier完整核对brief四核心结构、三Spec与A1-A19,直接源码补齐过期图覆盖,并核查本candidate绑定Runtime三检查均exit0;复用219项离线记录(218通过、1项明确跳过)、工作流守卫和六图解码/暖缓存证据。A8/A18现行文档已与实现对齐,其余17项逐项核查通过;最后参考Builder交接,未重跑完整套件、修改业务/测试/文档/报告或操作玩家数据,未代用户接受或归档。
truncated: false
completed_at: 2026-10-26T04:40:15.168Z
history_overflow:
dropped_entries: 0
first_dropped_at: null
last_dropped_at: null
outcome_counts:
pass: 0
fail: 0
blocked: 0
execution-error: 0
recovery: 0
verification_result: pass
verification_report: verification.md
archived: true
created_at: 2026-10-15T06:34:33.214Z
@@ -18,7 +18,8 @@ Acceptance: A1
## Catalogue
源静态目录 202 条作物名称唯一;映射 id/name/level/buy/isVip/vipBuy/
迁移基线含202条作物且当时名称唯一;运行时以稳定本地ID为身份,
允许同名,名称查询拒绝歧义,支持#编号。映射id/name/level/buy/isVip/vipBuy/
experience/harvest/price/time/crop/phase 等必要数据,规范整数数值,
异常的字段或版本错误明确拒绝。购买使用对应币种,按等级校验。
不下载远程目录,不将素材路径或绘图偏移当作业务依赖。
@@ -31,8 +32,10 @@ Acceptance: A2
## Registration and account model
调用方提供 deployment/realm/platform/user 字符串身份,核心不推断账号
等价性或跨群共享。首次注册赠送500 point、0 vipPoint、0 exp、3空地。
等价性或跨群共享。首次注册赠送500 point、0 vipPoint、0 exp、6空地。
唯一身份只能领取一次初始资产。查询总是基于已提交数据库状态。
可靠识别为初始3块的旧用户一次性追加3块普通空地,保留已开垦成果;
并发、重试和重启不得重复补偿,来源不明和数量异常明确拒绝。
经验等级门槛为 200*k*(k+1)//2,使用整数边界计算。
货币、数量和时间范围必须适合 SQLite 整数及应用限制。
bool 不作为合法整数参数,不允许负数归零后当作交易成功。
@@ -57,7 +60,9 @@ Acceptance: A4
首熟为播种时间加 time*3600;使用可注入时钟,当前时间等于成熟时可收。
每块地独立计算 floor(base*(100+bonus)/100) 后相加,不对累计值加成。
经验只增加本次增量。产物入库、经验变动、收获次数和下一季状态
经验只增加本次增量。播种每地另加2经验、铲除每地另加2经验;
“历史100”指收获操作前已提交的经验。
产物入库、经验变动、收获次数和下一季状态
在同一事务提交。最后一季后枯萎;铲除只清理枯萎地块。
再次重试不重复收获。展示结果不再次修改数据。
@@ -5,15 +5,19 @@
farm_render 使用 Pillow 实现同步画布操作;farm_core 不依赖 Pillow。
工具支持透明合成、复制、缩放、裁剪、文本测量与超长省略,以及
WebP/JPEG/PNG 编码。JPEG 合并透明背景,输出携带 MIME、尺寸与字节。
WebP 默认采用有损质量75、method=2;高清质量85。格式与尺寸独立,
WebP 默认采用有损质量75、method=2;高清质量为max(配置质量,85)。格式与尺寸独立,
auto 根据调用方声明的平台能力选择 WebP 或 JPEG,本机无 WebP 编码
支持时 auto 回退 JPEG,显式 WebP 则清楚报错。PNG 保留无损选项。
素材独立打入安装包,不在运行时引用 legacy 或宿主;字体显式配置,
不复制 Windows 商业字体。无字体时明确报错,不输出缺字占位图。
开发素材独立生成,不在运行时引用 legacy 或宿主。插件携带轻量元数据
及绑定摘要的素材清单,大型 assets.zip 从所选源校验下载并缓存,
不打入插件发布包;小型升级土地素材仍随包提供。中文字体支持显式
配置,或独立校验下载原版小赖字体及完整 OFL 许可,不复制 Windows
商业字体。无可用素材或字体时接入层降级文字,不输出缺字占位图。
Scenario: Framework-independent packaged renderer
Acceptance: A9
从 wheel 安装到临时目录,无 legacy 和框架仍能加载素材并生成图片。
从 wheel 安装到临时目录,无 legacy 和框架,显式提供已校验的外部
素材及字体后仍可生成图片;下载与校验止于接入/资源准备层。
## Snapshot and layout
@@ -22,7 +26,10 @@ Acceptance: A9
首季阶段锚点由保存的 mature_at-growth_seconds 推导,再季使用
mature_at-regrowth_seconds,按本季时长映射展示阶段,成熟优先、
枯萎优先于成熟。阶段仅影响外观,不修改交易规则和数据库。
读取实际 offsetX/Y/W/H 字段;缺失作物资源使用显式占位并记录警告。
作物定位采用 schema=4 的 visible_contact_center 统一公式,按本地
土地中心落点,保留完整纹理和直立等比缩放,不使用旧 offset 字段
或逐阶段手调锚点。地块位置按当前稳定编号映射。缺失作物资源使用
显式占位并记录警告。
用户信息显示真实快照余额、点券和经验,头像仅接收字节,无头像时
绘制默认图;拒绝超大或无效头像并降级。主题由调用方明确提供。
@@ -16,7 +16,8 @@ Acceptance: A19
不共享成图。农场高清为1536宽,质量至少85且不低于配置;其他命令高清
返回明确参数错误,仓库分页和文字仍可用,帮助不再宣称其他命令支持高清。
仅提供真寻接入;业务和图片仍来自主仓库共享源码。接入层加载插件
本 capability 验收真寻接入;NoneBot/AstrBot 已有独立接入与打包,
不将其当前存在误报为未实现。业务和图片仍来自主仓库共享源码。接入层加载插件
私有_vendor命名空间,渲染层对farm_core的引用在构建时改为私有相对
导入,不修改开发源码、不污染全局sys.path或sys.modules别名。
命令不增加测试前缀,沿用开通农场、我的农场、农场帮助、我的种子、
@@ -27,8 +28,10 @@ Acceptance: A19
绑定;测试数据位置与旧版不同。事件标识包含机器人、平台、用户及
原消息标识;无法获得稳定消息标识时写操作明确拒绝,不能随机重试。
在启动时初始化存储、渲染器,在关闭时等待任务排空后关闭存储;字体
必须配置为部署方拥有使用权的中文字体。无字体时文本功能仍可用,
我的农场返回明确配置提示。头像失败降级,不让头像下载无限等待。
支持显式配置合法中文字体,或校验下载原版小赖字体及完整许可。
资源源可选 Github、Gitea、farm_service,默认 Github;服务地址独立
保留。缺少资源或字体时保留文字功能,日志明确原因。头像失败降级,
不让头像下载无限等待。本次不包含作物目录远程更新。
Scenario: 真寻命令与生命周期
Acceptance: A14
@@ -38,7 +41,8 @@ Acceptance: A14
## 产物
scripts/build_zhenxun.py按白名单复制真寻接入、farm_core、farm_render、
对应数据/图片、LICENSE及NOTICE。ZIP含单一插件根目录,仓库导出目录
对应轻量数据、升级土地素材、LICENSE及NOTICE;大型图片及字体 ZIP
不进入插件包。ZIP含单一插件根目录,仓库导出目录
可作为真寻发布仓库根。生成来源manifest与独立SHA256附件,清单不
自包含哈希;本地工作区脏时标识dirty,不能冒充源HEAD的纯净构建。
输入版本严格校验,ZIP排序/时间固定,目标存在则拒绝或显式选择新路径。
@@ -64,10 +68,11 @@ Acceptance: A16
主仓库CI在PR/push及手动触发时执行检查,构建ZIP/清单/校验附件并上传
Actions artifact。工作流权限默认只读;不可信PR不触发跨仓库写入。
跨仓库同步是独立的手动选择,使用专用Secret/受保护environment,并
固定目标Shu-Ying/zhenxun_plugin_farm的farm-preview分支。
脚本默认dry-run;只有显式发布才联网。只创建或普通更新预览分支,
不force、不碰main/旧标签;并发分支改变时失败。发布仓库只含生成的
跨仓库同步依当前工作流,在 main 的 push 或显式 publish 手动输入时
执行,PR 不触发;使用专用 Secret/受保护 environment,固定目标为
Shu-Ying/zhenxun_plugin_farm 的 main 分支。脚本默认 dry-run,只有显式
发布才联网;只进行普通更新,不 force、不修改旧标签;并发分支改变
时失败。此次本地归档不执行发布。发布仓库只含生成的
可安装内容、来源和验证工具,以及验证工作流,不在那里维护业务源码。
下载ZIP测试不要求先同步仓库,不要求用户先给令牌。
@@ -0,0 +1,98 @@
---
generated_from_state_version: 50
---
# Verification
## Current result
- Result: **Archived**
- Verification status: **Checks completed; result confirmed**
- Goal cycle: 7
- Iteration: 2
- Verifier attempt: 1
- Completed: 2026-10-26T04:40:15.168Z
- Summary: 新的独立只读Verifier完整核对brief四核心结构、三Spec与A1-A19,直接源码补齐过期图覆盖,并核查本candidate绑定Runtime三检查均exit0;复用219项离线记录(218通过、1项明确跳过)、工作流守卫和六图解码/暖缓存证据。A8/A18现行文档已与实现对齐,其余17项逐项核查通过;最后参考Builder交接,未重跑完整套件、修改业务/测试/文档/报告或操作玩家数据,未代用户接受或归档。
## Acceptance
| ID | Result | Source | Criterion | Reason |
| --- | --- | --- | --- | --- |
| A1 | passed | brief.md | A1: 旧基线827文件校验继续通过;核心不导入框架、无导入副作用, 从源码及安装包可独立运行,不在运行时引用legacy。 | 当前Runtime foundation-offline核实827旧基线与源码/安装包隔离测试通过;test_packaging.py校验无宿主导入、无导入副作用及临时wheel独立运行。 |
| A2 | passed | brief.md | A2: 可复现导出202条基线作物;ID及必要数值对应源静态数据库; 加载校验格式/schema,坏数据明确拒绝。运行时ID唯一,同名拒绝歧义。 | export_catalogue.py只读静态库并排序确定性导出;Runtime导出比对、202条目录及坏schema/重复ID/同名歧义测试通过,catalogue.py显式拒绝无效输入。 |
| A3 | passed | brief.md | A3: 新注册得到500农场币、0点券、0经验、6空地;重复注册不增发, 不同作用域隔离;旧初始3块账号仅补3块,并发/重试/重启不重复补偿。 | repository.py:33注册500币和6空地;application.py:61从注册来源识别旧初始3块并以固定幂等事务补3块;正式注册/隔离/并发/故障/重启补地测试通过。 |
| A4 | passed | brief.md | A4: 购买、播种扣款/入库/耗种正确;0、负数、bool、超大数量、余额、 库存或等级不足及未知作物明确失败,没有部分写入。 | application.py:177购买与208播种校验目录、数量、余额、库存并在事务内修改;正式非法数量、未知作物、等级/余额不足、耗种与回滚测试通过。 |
| A5 | passed | brief.md | A5: 未成熟不能收获,成熟只增加本次产量/经验;收获前100、本次20 后为120,播种经验另算。逐地加成顺序无关,多季/枯萎/铲除及成熟 边界遵守保存定义及可注入时钟。 | application.py:251按保存的plot.plant逐地结算本次经验和产量、成熟边界与再生长;test_transactions中冻结定义、逐地加成、历史经验增量、多季和铲除测试通过。 |
| A6 | passed | brief.md | A6: 余额100、出售收益20后为120;锁定限制指定和全部出售; 返回真实余额及安全文本,不出现模板参数缺失。 | application.py:437统一保护锁定库存且只加本次收益;正式100余额出售20得120、两种出售入口锁定及安全文本测试通过。 |
| A7 | passed | brief.md | A7: 故障注入使资产/地块/流水/结果整体回滚;重试返回原结果, 参数冲突拒绝;并发不透支/重复领奖,故障不伪装成功,重启后可读。 | storage.py:353 BEGIN IMMEDIATE将状态、流水与幂等结果一起提交,异常整体回滚;正式故障注入、参数冲突、取消重放、并发扣款/收获与重启测试通过。 |
| A8 | passed | brief.md | A8: 离线测试、运行入口与说明可执行;文档区分源码、模拟、离线 产物与真实宿主/远程验证,准确记录当前实现与待办。 | 新增docs/zhenxun-preview-current.md与实际构建、verify_foundation及安装入口一致;docs索引明确11为历史,09/10和发布README已说明6地、当前字体、内置作物与待办,并区分本机/模拟/真实验证。 |
| A9 | passed | brief.md | A9: 图片工具支持透明合成、缩放、裁剪、测量/省略及三格式编码; 渲染不联网或导入框架,核心无Pillow依赖;安装后提供匹配外部资源可绘图。 | canvas.py支持透明、缩裁、测量省略及三编码;assets/renderer不导入框架或联网,准备资源在接入层;正式Canvas及临时wheel绘图测试通过。 |
| A10 | passed | brief.md | A10: 一次快照与统一时间绘制空地、生长、成熟、再生长、枯萎及 锁定状态,采用当前地块映射及统一定位,普通/高清输出可解码。 | layout.py纯消费快照和now构造空/生长/成熟/再生长/枯萎状态,renderer使用当前映射;正式阶段、目标尺寸、作物统一落点回归通过,render-decode验证6幅普通/高清图片。 |
| A11 | passed | brief.md | A11: 小图从目标尺寸绘制,素材与成图缓存有界且不污染共享图片; 阶段/成熟边界、余额、头像及身份变化不会复用过期图。 | assets.py有界ByteLRU并返回图片副本;renderer请求键含场景、身份摘要、配置、格式和头像摘要;正式预算/共享图片安全/阶段成熟边界及余额头像身份失效测试通过。 |
| A12 | passed | brief.md | A12: 后台合成编码限制并发、相同请求合并;取消不提前释放名额, 关闭等待任务排空;日志仅记录耗时、尺寸、缓存等安全信息。 | service.py一次snapshot、to_thread、Semaphore及shield共享任务,关闭排空;正式事件循环不阻塞、并发上限、取消保留容量和失败重试测试通过;源码日志未输出身份或快照。 |
| A13 | passed | brief.md | A13: 离线样例和真实测量覆盖尺寸/编码、冷暖缓存及调用方式; 图片可解码,明确本机测量不等同真实平台上传性能。 | render_demo.py提供合成样例和冷暖缓存测量;本轮benchmark明确Windows/Python3.12.14/Pillow12.3及非上传范围,绑定Runtime验证1024/1536三格式六文件可解码且暖缓存全命中。 |
| A14 | passed | brief.md | A14: 真寻原指令、路由、身份/幂等、异常反馈与启动关闭通过模拟验证; 字体显式路径优先、默认校验下载,资源失败文字可用,不操作真实宿主。 | commands.py注册原指令与启动关闭、身份和稳定消息标识;runtime.py默认校验资源/字体、显式字体优先、失败文字保留;本候选模拟路由/生命周期/异常/幂等正式测试通过。 |
| A15 | passed | brief.md | A15: 白名单真寻插件包包含私有共享模块、轻量数据、许可和来源哈希, 不含其他适配、legacy、测试、玩家数据或大型资源ZIP;准确标识脏源码, 相同输入可复现,构建不改源码。 | build_zhenxun.py白名单生成私有_vendor、轻量数据许可与来源哈希,排除assets.zip并固定ZIP时间排序、记录dirty;正式可复现完整包及私有导入测试通过。 |
| A16 | passed | brief.md | A16: 离线验证内容、导入与路由;安装拒绝穿越、坏哈希和已有目标, 失败无半成品,不操作旧插件/玩家数据或启动真实机器人。 | verify_zhenxun.py拒绝穿越、重复、链接和哈希差异;install_zhenxun.py先校验、临时写入且拒绝已有目标;正式坏包/安装/目标保留及离线导入路由测试通过。 |
| A17 | passed | brief.md | A17: 工作流检查、构建、上传;push main或显式publish输入可触发固定 真寻仓库main普通同步,PR不发布;脚本默认dry-run、不force,冲突失败, 凭据不落盘。本次只检查与模拟,不执行真实发布。 | 工作流默认只读且PR无发布路径,main push或明确publish输入经test/build触发固定目标;publish_zhenxun.py默认dry-run、不force、并发冲突失败,正式模拟和workflow-guards通过。 |
| A18 | passed | brief.md | A18: 本机离线打包与安全边界检查通过;静态核对Windows/Linux与 Pillow10.4/12.3矩阵,明确本机环境及未运行矩阵;提供安装、依赖、 字体、数据库、回退/卸载与Secrets说明,不宣称真实平台已验证。 | 当前说明覆盖生成包/requirements、停止旧插件、字体、数据目录、日志、备份回退卸载和Secret;与源码及Windows/Linux/Pillow10.4/12.3矩阵一致,明确1项跳过和未运行矩阵、真实宿主/远端边界;离线包安全测试通过。 |
| A19 | passed | brief.md | A19: 图片质量默认75,整数1和95有效,0、96、bool及非整数拒绝; 配置覆盖图片编码并区分缓存。仅我的农场允许高清,质量不低于85与 配置值,其他查询拒绝高清,分页/文字可用,帮助和配置说明一致。 | RenderOptions严格整数1至95、默认75;Runtime请求读取质量,仅farm高清1536且max(质量,85),其他查询拒绝高清;正式边界、逐编码器传递、质量缓存与帮助测试通过。 |
## Checks
| Check | Command | Working directory | Status | Exit | Duration |
| --- | --- | --- | --- | ---: | ---: |
| 完整离线核心/绘图/三端打包回归 | -NoProfile -Command $env:FARM_TEST_FONT='D:\Work\farm_service\fonts\Xiaolai-Regular.ttf'; $env:PYTHONIOENCODING='utf-8'; & 'D:\Work\farm\.release-cache\ci-aiohttp-20261023\Scripts\python.exe' scripts/verify_foundation.py; exit $LASTEXITCODE | . | passed | 0 | 107377 ms |
| 工作流权限与发布边界静态检查 | scripts/check_workflows.py | . | passed | 0 | 110 ms |
| 本轮六种绘图样例与暖缓存验证 | -c import json; from pathlib import Path; from PIL import Image; p=Path('runtime/archive-foundation-20261026'); d=json.loads((p/'benchmark.json').read_text(encoding='utf-8')); assert len(d['measurements'])==6; assert all(r['warm_cache_hit'] for r in d['measurements']); files=list(p.glob('farm-*')); assert len(files)==6; [Image.open(f).verify() for f in files]; print('6 rendered files verified; all warm cache hits') | . | passed | 0 | 122 ms |
### Builder-reported evidence
These are Builder reports, not Runtime check receipts or independent verification results.
- documentation-diff: passed — git diff --check退出0;更新内容仅文档/正式规格/发布目标记录。
- Known limitation: 仅本地Windows/Python3.12.14/Pillow12.3;其他CI矩阵未执行。
- Known limitation: 未访问真实玩家库、未启动宿主、未推送或部署。
- Known limitation: 历史455个作物阶段缺图引用保留占位行为。
- Known limitation: 作物Service迁移尚未实施,另仓库设计不纳入归档。
## Blockers
_None._
## Risks and skipped work
- 正式完整测试共219项,218通过;1项NoneBot宿主配置测试因本地缺Pydantic跳过,不计作通过。该项不属于本change真寻宿主配置验收。
- 仅本机Windows/Python3.12.14/Pillow12.3;工作流矩阵仅静态核对,Linux及Pillow10.4未在本轮运行。
- 未启动真实机器人、访问真实玩家库、验证公开下载、执行远端CI、发布或部署;历史455个缺图引用仍使用明确占位。
- 图索引为2026-10-15,相关路径过期或无覆盖;本轮以直接源码和正式日志补齐,未据旧图推断缺失。
- 作物Service迁移不属于本次归档范围且尚未实施。
## 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 |
| 5 | 5 | 1 | recovery | — | 用户明确要求执行统一图片质量配置,默认75、范围1至95,仅我的农场允许高清覆盖;其他图片禁止高清,保留仓库文字和分页。修订用户可见要求。 | 2026-10-22T09:37:42.325Z |
| 6 | 1 | 0 | recovery | — | Native formal documents are invalid (brief-structure-missing: brief.md) | 2026-10-26T04:22:42.893Z |
| 7 | 1 | 1 | fail | A8, A18 | 新的独立只读Verifier核对完整brief、三Spec及全部19项;brief四核心结构有效。复用当前candidate绑定Runtime三项退出0检查,未重跑完整套件。17通过,A8和A18因现行文档陈旧/缺失失败,修复说明后再验收;无需据此修改业务。独立核验完成后最后参考Builder交接,其文档证据不足。未修改业务、测试、规格、报告或玩家数据,未接受结果或归档。 | 2026-10-26T04:31:34.844Z |
| 7 | 2 | 1 | pass | — | 新的独立只读Verifier完整核对brief四核心结构、三Spec与A1-A19,直接源码补齐过期图覆盖,并核查本candidate绑定Runtime三检查均exit0;复用219项离线记录(218通过、1项明确跳过)、工作流守卫和六图解码/暖缓存证据。A8/A18现行文档已与实现对齐,其余17项逐项核查通过;最后参考Builder交接,未重跑完整套件、修改业务/测试/文档/报告或操作玩家数据,未代用户接受或归档。 | 2026-10-26T04:40:15.168Z |
## Conclusion
新的独立只读Verifier完整核对brief四核心结构、三Spec与A1-A19,直接源码补齐过期图覆盖,并核查本candidate绑定Runtime三检查均exit0;复用219项离线记录(218通过、1项明确跳过)、工作流守卫和六图解码/暖缓存证据。A8/A18现行文档已与实现对齐,其余17项逐项核查通过;最后参考Builder交接,未重跑完整套件、修改业务/测试/文档/报告或操作玩家数据,未代用户接受或归档。
@@ -1,164 +0,0 @@
# Outcome
在 D:/Work/farm 当前目录完成旧版可用规则的第一批迁移,建立不依赖
真寻、NoneBot 或 AstrBot 的可运行基础核心;使用 SQLite 验证完整交易
闭环,为后续框架接入提供稳定调用入口。Comet 初始化已完成,
AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续。
# Scope
- 本次追加统一图片编码质量配置,整数1~95,默认75,覆盖农场、
地块编号、详情、商店及种子/作物仓库。仅“我的农场”接受高清,
使用1536宽及max(配置质量,85);其他查询拒绝高清,保留分页与文字。
真寻网页配置提供“农场图片质量”,普通NoneBot提供FARM_IMAGE_QUALITY。
不发布、不安装、不重启机器人,不改变其他功能。
- 迁移基线只读。迁出旧作物目录 202 条唯一名称配置,保留必要数值、
购买币种、等级、成熟时间、产量、经验和多季次数;新核心运行不依赖
legacy 目录,不复制旧框架依赖。共享渲染素材按下述新增范围迁出。
- 建立明确的玩家作用域标识,由调用方提供部署、领域、消息平台与
用户 ID,不自行选择群共享或跨框架账号绑定政策。
- SQLite 新 schema、显式事务、操作流水和幂等操作记录;只处理新建
测试数据库,不导入真实玩家库。余额与库存以数据库为权威。
- 注册初始 500 农场币、0 点券、0 经验、3 块普通空地,沿用旧值。
- 提供账户/库存/地块查询、购买种子、播种、收获、铲除枯萎作物、
锁定/解锁作物、指定出售/全部出售及基础文本结果。
- 每级累计经验门槛沿用 200*k*(k+1)/2,以整数计算避免浮点边界。
普通币与点券库存独立;本轮不实现两者兑换或外部金币充值。
- 多季作物首熟时间沿用 time*3600;再生长间隔沿用旧实现去重后
phase[1:] 之和。该规则明确保留兼容,数值平衡后续另议。
- 纯规则支持每块地独立计算产量/经验加成再求和;首轮创建普通地,
不提供土地升级、星级自动升级或偷菜加成。
- 核心应用 API 为异步可调用,SQLite 阻塞操作不在事件循环线程执行;
不引入可变玩家余额缓存或导入时后台线程。日志记录事务操作标识、
操作类别与提交结果,不记录密钥或真实用户消息。
- 使用本地可重复测试覆盖规则、原子性、失败传播、重试幂等和并发;
更新 README、实现说明与现有路线状态,保留待确认决策。
- 根据用户对已讨论方案的明确执行指令,追加 farm_render:Pillow 同步
图片工具、离线素材包、有界缓存、按目标尺寸绘制、后台受控执行、
普通/高清预览、WebP/JPEG/PNG 编码和分阶段日志。
- 渲染接收一次核心快照和统一时间,不逐地块查库、不联网;头像字节
由接入方提供,失败使用默认头像。保留旧农场背景、地块和作物素材。
- 按可见状态缓存成图并在生长阶段边界失效;并发相同请求合并。
提供离线生成、性能记录、安装包独立运行和中文字体配置说明。
- 用户选择补齐真寻测试版:最小命令接入、离线白名单打包、GitHub
构建工作流、可选手动同步真寻发布仓库测试分支、安装/卸载说明。
独立测试库、沿用原指令;保留旧版和原业务/渲染验收A1-A13。
## Directory structure
### Created
- src/farm_render/:画布、素材、展示模型、布局、渲染器及异步入口。
- scripts/export_render_assets.py、scripts/render_demo.py。
- tests/test_render*.py、docs/10-rendering.md。
- integrations/zhenxun/ 的入口、命令和生命周期;scripts/ 下真寻构建、
产物验证、本地安装及手动发布脚本;.github/workflows/ 下构建工作流;
tests/test_zhenxun*.py、docs/11-zhenxun-preview.md。
### Modified
- pyproject.toml:render 可选依赖及渲染素材打包。
- scripts/verify_foundation.py:增加渲染资源可复现检查。
- README.md、docs/README.md、docs/task_plan.md、docs/progress.md。
- 当前 brief 与新增 farm-render 目标规格;原业务规格保留。
- release/targets.json 与真寻职责说明;新增真寻预览完整目标规格。
### Deleted
无。
### Not created
- 不创建AstrBot/通用NoneBot接入、真实玩家库或字体再分发包。
- 不修改 legacy 基线,不创建多个框架的业务副本。
# Non-goals
本轮不实现AstrBot/通用NoneBot宿主插件、签到、偷菜、活动、
外部金币兑换、点券兑换、土地升级、星级进阶、账号绑定或在线联机。
不读取或迁移真实玩家数据,不重置旧资产,不改旧基线业务。
不在本机 clone/fetch/pull/push 任何远程,不创建 PR 或远端Release。
生成工作流与发布脚本,但不执行跨仓库发布或安装/重启真实机器人。
不将设计占位目录报告为已支持三框架。
# Acceptance examples
- A19: 默认质量75,整数1和95有效,0、96、布尔值及非整数拒绝;
配置应用到所有图片编码,缓存区分质量。仅我的农场接受高清,
质量不低于85及配置值;其他查询拒绝高清,仓库分页、文字继续可用。
真寻注册网页配置并在请求时读取,NoneBot读取启动配置;帮助与说明一致。
- A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用,
可从安装包或源码独立运行,核心不通过路径引用 legacy。
- A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库;
本地加载支持格式校验,坏数据明确失败,不访问网络。
- A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地;
重复注册不重复赠送;不同显式作用域不串号。
- A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、
布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败,
不留下部分写入。
- A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、
本次获得20时为120;多地加成逐块计算且交换顺序不改变总和;
多季次数及枯萎/铲除遵守保存的规则与可注入时钟。
- A6: 出售收益只增加一次,例如余额100、收益20后为120;
锁定同时约束指定与全部出售;返回结构化真实余额及安全文本,
不发生旧版模板参数缺失。
- A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚;
同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、
不重复领取,不吞数据库故障伪装成功;重启后数据可读。
- A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、
真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。
- A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码;
渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。
- A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块,
使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。
- A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限,
共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。
- A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常
不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。
- A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和
接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。
- A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、
播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离,
独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。
- A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、
依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据,
源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。
- A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、
哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。
- A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向
固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据
由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。
- A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及
Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界;
提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。
# Constraints and invariants
源码使用 Python,4 空格、80 字符行长,明确说明数据模型字段。
保留 GPLv3 许可与源归属。共享核心内部相对导入。
运行数据和测试临时数据库不入 Git。新实现只使用必要依赖,
优先标准库 SQLite、异步线程执行和 unittest,不为框架引入依赖。
新数据库 schema 与旧库区分,未知或更高 schema 明确拒绝。
同一基础交易的存储和应用紧密耦合,使用单个 change,不拆分子任务。
# Decisions
- 2026-10-22用户确认统一质量方案,并修正为“除了我的农场允许带高清,
其他都不允许。请执行吧”。本次不接受或归档旧验收结果。
- 用户已授权初始化 Comet 并执行基础开发;工作区为当前目录 main。
- 主仓库地址已明确:https://github.com/Shu-Ying/main_plugin_farm.git。
- 现有文档是设计参考,旧代码是实现参考;本轮仅覆盖上述第一批核心,
不把整份长期计划视为一次必须全部实施的产品需求。
- 未决的农场共享、联机、存量资产、兑换与素材发布不由本轮擅自决定。
- 用户随后明确要求“根据上述方案和之前优化的绘制我的农场,请执行”,
授权图片层及绘图优化。沿用已选当前目录;该目录已有未提交核心,
Runtime 不允许第二个 current change,因此作为基础开发范围补充。
- 用户讨论后明确“确认并进入开发”,包含 WebP 优先候选及 JPEG/PNG
兼容输出;尺寸、质量、编码力度独立配置,平台能力由适配器声明。
- 素材保留原 GPL 归属,不捆绑未确认再分发许可的字体;部署显式配置
中文字体路径。真实宿主头像缓存和上传由后续适配器实现。
- 用户已明确选择“补齐真寻测试版:命令接入+打包工作流+安装脚本”。
由于当前目录未归档核心与新接入需联合验证,作为同一基础测试交付
扩展;不代用户接受上轮结果。用户明确不加测试前缀,沿用原指令;
测试库与旧库隔离,发布脚本只操作专用farm-preview分支,实际发布
不执行。测试时由用户停用旧插件,安装器不删除或覆盖旧插件。
@@ -1,296 +0,0 @@
schema: comet.native.v4
name: farm-core-foundation
language: en
phase: build
status: active
state_version: 39
brief: brief.md
document_constraints_version: 3
shape_confirmation_hash: 066f3825ae83e04c24cb7018a75d217a2aaae9a8a4cba1c435eecc776de4a90d
spec_changes:
- capability: farm-core
operation: create
source: specs/farm-core/spec.md
- capability: farm-render
operation: create
source: specs/farm-render/spec.md
- capability: zhenxun-preview
operation: create
source: specs/zhenxun-preview/spec.md
workspace:
isolation: current
change_branch: main
target_branch: main
finish: null
loop:
stage: building
goal_cycle: 6
iteration: 1
attempt: 0
retry_epoch: 0
failed_iteration_count: 0
no_progress_count: 0
execution_failure_count: 0
previous_unresolved_ids: []
next_action: submit-builder-candidate
acceptance:
- id: A1
source: brief.md
text: "A19: 默认质量75,整数1和95有效,0、96、布尔值及非整数拒绝; 配置应用到所有图片编码,缓存区分质量。仅我的农场接受高清,
质量不低于85及配置值;其他查询拒绝高清,仓库分页、文字继续可用。 真寻注册网页配置并在请求时读取,NoneBot读取启动配置;帮助与说明一致。"
result: pending
reason: null
- id: A2
source: brief.md
text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。"
result: pending
reason: null
- id: A3
source: brief.md
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: pending
reason: null
- id: A6
source: brief.md
text: "A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和;
多季次数及枯萎/铲除遵守保存的规则与可注入时钟。"
result: pending
reason: null
- id: A7
source: brief.md
text: "A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本,
不发生旧版模板参数缺失。"
result: pending
reason: null
- id: A8
source: brief.md
text: "A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、
不重复领取,不吞数据库故障伪装成功;重启后数据可读。"
result: pending
reason: null
- id: A9
source: brief.md
text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。"
result: pending
reason: null
- id: A10
source: brief.md
text: "A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow
依赖;安装包含所需素材。"
result: pending
reason: null
- id: A11
source: brief.md
text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。"
result: pending
reason: null
- id: A12
source: brief.md
text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。"
result: pending
reason: null
- id: A13
source: brief.md
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: pending
reason: null
- id: A16
source: brief.md
text: "A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据,
源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。"
result: pending
reason: null
- id: A17
source: brief.md
text: "A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。"
result: pending
reason: null
- id: A18
source: brief.md
text: "A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据
由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。"
result: pending
reason: null
- id: A19
source: brief.md
text: "A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及
Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界;
提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。"
result: pending
reason: null
builder_handoff: null
blockers: []
verification: null
history:
- goal_cycle: 1
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读验收完成:完整brief/Spec、实现、测试、配置和文档均已核对,最后参考Builder交接;绑定候选的Runtime
foundation-offline以退出码0完成,827基线文件、可复现目录导出与32项测试通过。A1-A8全部通过;未追加或自行重跑完整检查,未修改项目源文件。
truncated: false
completed_at: 2026-10-15T07:00:29.324Z
- goal_cycle: 1
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户明确要求在已确认的图片工具及绘图优化方案上执行开发;将共享农场渲染作为当前基础开发的范围补充,保留原业务验收项并追加图片验收。
truncated: false
completed_at: 2026-10-15T07:55:29.688Z
- goal_cycle: 2
iteration: 0
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: Native confirmed acceptance criteria changed
truncated: false
completed_at: 2026-10-15T08:23:15.134Z
- goal_cycle: 3
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。
truncated: false
completed_at: 2026-10-15T08:40:00.608Z
- goal_cycle: 3
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户选择补齐真寻测试版:命令接入、打包工作流和安装脚本;将现有核心与渲染扩展为可安装真寻预览包,保留独立测试库和旧基线,不执行远端操作。
truncated: false
completed_at: 2026-10-15T09:24:24.236Z
- goal_cycle: 4
iteration: 0
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: Native confirmed acceptance criteria changed
truncated: false
completed_at: 2026-10-15T09:28:23.268Z
- goal_cycle: 5
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对完整brief、三份规格及18项验收,审阅核心/渲染、真寻接入、白名单构建/校验/安装/固定发布边界、工作流和测试文档。复用本候选正式Runtime的55项全回归、Pillow10.4下23项兼容检查及工作流守卫,全部通过;未重复完整套件。图索引旧/新增缺覆盖,已直接源码补齐。未修改实现或正式规格,未访问远端、安装/启动机器人或代用户接受。
truncated: false
completed_at: 2026-10-15T09:51:26.860Z
- goal_cycle: 5
iteration: 1
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 独立验收指出余额不足错误码映射缺少insufficient_funds;保持已确认范围,补中文提示及回归测试后重新交付。
truncated: false
completed_at: 2026-10-15T09:52:02.989Z
- goal_cycle: 5
iteration: 2
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
truncated: false
completed_at: 2026-10-15T09:58:23.989Z
- goal_cycle: 5
iteration: 2
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户实际测试要求默认字体:在真寻适配层默认复用宿主字体目录,保留显式字体覆盖和缺失时清楚提示;修复预览版默认不可绘图的接入遗漏。
truncated: false
completed_at: 2026-10-16T02:06:48.036Z
- goal_cycle: 5
iteration: 3
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读验收全部A1-A18:完整brief/三spec与当前字体实现、测试、文档已核对;复用此前未改变核心/渲染/发布的正式独立验收,并核实本候选绑定Runtime三检查exit0,实际58项完整与Pillow10.4下26项通过。真寻FONT_PATH为resources/font,接入层默认选宿主字体,坏默认跳过、显式覆盖优先,共享层未引入真寻路径;安装目录runtime/commands与主源SHA256一致。图索引不覆盖新文件或metadata_changed,已读源码补齐;未重跑完整测试、未修改项目文件、未启动宿主或访问远端。
truncated: false
completed_at: 2026-10-16T02:13:06.574Z
- goal_cycle: 5
iteration: 3
attempt: 1
outcome: recovery
unresolved_ids: []
summary:
text: 用户要求修复迁移遗漏:恢复旧版用户面板素材、布局与经验进度语义并保留性能优化。
truncated: false
completed_at: 2026-10-16T02:17:50.690Z
- goal_cycle: 5
iteration: 4
attempt: 0
outcome: recovery
unresolved_ids: []
summary:
text: "Builder handoff Runtime checks failed: all-offline"
truncated: false
completed_at: 2026-10-16T02:26:17.238Z
- goal_cycle: 5
iteration: 5
attempt: 1
outcome: pass
unresolved_ids: []
summary:
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
last_dropped_at: null
outcome_counts:
pass: 0
fail: 0
blocked: 0
execution-error: 0
recovery: 0
verification_result: pending
verification_report: null
archived: false
created_at: 2026-10-15T06:34:33.214Z
+107
View File
@@ -0,0 +1,107 @@
# Farm Core Foundation
本规格定义第一批框架无关基础核心的完整行为,覆盖注册、配置、
资产库存、种植交易、文本展示和本地持久化。
真实机器人入口、联机和旧玩家数据迁移不在本 capability 范围内。
## Runtime and migration boundary
共享代码位于 src/farm_core,包内部相对导入;加载模块不执行网络、
创建数据库或启动线程。可注入显式配置、时钟与存储生命周期。
静态作物目录从保留的旧作物库离线导出,发布或安装后不依赖 legacy。
完整静态目录与玩家数据库分开保存,旧基线保持只读。
SQLite 工作在线程中执行且每个用例拥有完整事务,异步调用可等待结果。
Scenario: Independent core and preserved baseline
Acceptance: A1
基线离线校验通过,包独立导入、安装、调用不需要三种宿主或 legacy。
## Catalogue
迁移基线含202条作物且当时名称唯一;运行时以稳定本地ID为身份,
允许同名,名称查询拒绝歧义,支持#编号。映射id/name/level/buy/isVip/vipBuy/
experience/harvest/price/time/crop/phase 等必要数据,规范整数数值,
异常的字段或版本错误明确拒绝。购买使用对应币种,按等级校验。
不下载远程目录,不将素材路径或绘图偏移当作业务依赖。
多季再生长间隔保持旧去重 phase[1:] 求和规则,不在首轮重新平衡。
Scenario: Reproducible catalogue migration
Acceptance: A2
离线导出再生成内容一致;202 条与源值匹配;损坏输入被拒绝。
## Registration and account model
调用方提供 deployment/realm/platform/user 字符串身份,核心不推断账号
等价性或跨群共享。首次注册赠送500 point、0 vipPoint、0 exp、6空地。
唯一身份只能领取一次初始资产。查询总是基于已提交数据库状态。
可靠识别为初始3块的旧用户一次性追加3块普通空地,保留已开垦成果;
并发、重试和重启不得重复补偿,来源不明和数量异常明确拒绝。
经验等级门槛为 200*k*(k+1)//2,使用整数边界计算。
货币、数量和时间范围必须适合 SQLite 整数及应用限制。
bool 不作为合法整数参数,不允许负数归零后当作交易成功。
Scenario: Immediate registration and scope isolation
Acceptance: A3
首次注册立即查询得到默认值;重复注册无增发;不同作用域相互独立。
## Purchase and sowing
购买种子时在同一用例事务内检查注册、作物、数量、等级和余额,
扣款并增加库存,失败整体回滚。播种检查空地和库存,在同一事务内
消耗实际种子、创建作物实例,保存结算所需目录数据避免后续版本改变
已播种作物。显式数量超过可用资源返回明确结果,全部操作使用明确
参数而不是允许任意负数。返回真实操作数、余额及剩余库存。
Scenario: Purchase and sow atomically with validated inputs
Acceptance: A4
非法数量、余额不足、等级不足和库存不足不产生任何部分状态。
## Harvest and crop lifecycle
首熟为播种时间加 time*3600;使用可注入时钟,当前时间等于成熟时可收。
每块地独立计算 floor(base*(100+bonus)/100) 后相加,不对累计值加成。
经验只增加本次增量。播种每地另加2经验、铲除每地另加2经验;
“历史100”指收获操作前已提交的经验。
产物入库、经验变动、收获次数和下一季状态
在同一事务提交。最后一季后枯萎;铲除只清理枯萎地块。
再次重试不重复收获。展示结果不再次修改数据。
Scenario: Correct experience and multi-crop settlement
Acceptance: A5
历史100加本次20等于120;地块排序不影响总收益;成熟边界及多季状态正确。
## Inventory locking and selling
指定出售和全部出售均保护锁定物品。出售收入在事务内增加一次,
库存减少为实际成交数量。没有可出售物品时有明确结果。
查询及返回结构化账户/库存/地块数据,基础文本仅渲染结果,避免模板
占位不匹配;文本不依赖任何消息框架。
Scenario: Consistent locking and exact sale proceeds
Acceptance: A6
100余额出售收入20变为120;锁定物品无法通过任一出售入口消耗。
## Persistence, idempotency and failures
每个写用例有稳定 operation_id、动作和规范化参数指纹。
同作用域同标识相同请求返回已提交结果,不再次扣款或发奖;
不同参数冲突拒绝。状态改变、资产流水和结果持久化在同一事务。
数据库异常应回滚并传播为明确错误,不能转成不存在/无农场。
支持并发调用,通过事务、条件更新及唯一约束避免超卖和重复结算。
关闭后重开可读取已提交数据;未知/高版本 schema 拒绝。
调用取消不能无记录重复交易,已提交结果可通过相同操作标识恢复。
Scenario: Rollback, replay and concurrent safety
Acceptance: A7
故障注入无部分数据;重试幂等;冲突被拒绝;并发不透支;重启后状态一致。
## Verification and user documentation
离线测试采用构造数据,覆盖上述全部验收示例以及源目录一致性。
提供本地运行/演示入口,不宣称可放入三种框架插件目录加载。
记录本轮新实现与旧缺陷对应关系,不把历史设计文档误标为全面完成。
不安装或操作真实机器人,不访问远程仓库,不导入真实玩家数据库。
Scenario: Repeatable local verification and accurate status
Acceptance: A8
安装及离线测试命令可复现,使用说明清楚标明未实现的接入与发布部分。
+77
View File
@@ -0,0 +1,77 @@
# Farm Render
## Independent image layer
farm_render 使用 Pillow 实现同步画布操作;farm_core 不依赖 Pillow。
工具支持透明合成、复制、缩放、裁剪、文本测量与超长省略,以及
WebP/JPEG/PNG 编码。JPEG 合并透明背景,输出携带 MIME、尺寸与字节。
WebP 默认采用有损质量75、method=2;高清质量为max(配置质量,85)。格式与尺寸独立,
auto 根据调用方声明的平台能力选择 WebP 或 JPEG,本机无 WebP 编码
支持时 auto 回退 JPEG,显式 WebP 则清楚报错。PNG 保留无损选项。
开发素材独立生成,不在运行时引用 legacy 或宿主。插件携带轻量元数据
及绑定摘要的素材清单,大型 assets.zip 从所选源校验下载并缓存,
不打入插件发布包;小型升级土地素材仍随包提供。中文字体支持显式
配置,或独立校验下载原版小赖字体及完整 OFL 许可,不复制 Windows
商业字体。无可用素材或字体时接入层降级文字,不输出缺字占位图。
Scenario: Framework-independent packaged renderer
Acceptance: A9
从 wheel 安装到临时目录,无 legacy 和框架,显式提供已校验的外部
素材及字体后仍可生成图片;下载与校验止于接入/资源准备层。
## Snapshot and layout
入口从业务服务取得一次一致快照;绘图纯消费快照与统一 now,不查库。
复用旧30块地布局、季节静态背景、草地/普通地和作物阶段素材。
首季阶段锚点由保存的 mature_at-growth_seconds 推导,再季使用
mature_at-regrowth_seconds,按本季时长映射展示阶段,成熟优先、
枯萎优先于成熟。阶段仅影响外观,不修改交易规则和数据库。
作物定位采用 schema=4 的 visible_contact_center 统一公式,按本地
土地中心落点,保留完整纹理和直立等比缩放,不使用旧 offset 字段
或逐阶段手调锚点。地块位置按当前稳定编号映射。缺失作物资源使用
显式占位并记录警告。
用户信息显示真实快照余额、点券和经验,头像仅接收字节,无头像时
绘制默认图;拒绝超大或无效头像并降级。主题由调用方明确提供。
Scenario: Farm states and target profiles
Acceptance: A10
空地、生长、成熟、再生长、枯萎和未解锁地可生成图片,普通与高清
使用同一布局;统一时间下同一阶段稳定,边界切换正确。
## Resource and result cache
普通图默认宽1024,高清宽1536;画布与坐标从开始按比例处理。
解码后素材、底图和成图使用有界 LRU,超限条目不入缓存。
素材缓存键含路径及目标尺寸,缓存图片只读或复制后修改。
成图键含完整身份、可见地块阶段/账户/头像摘要、主题、质量与格式;
同阶段可复用,下一阶段或成熟时间到达必须改变键,静态素材版本
由渲染器实例绑定;资源更新后重新创建实例。默认缓存不落盘。
Scenario: Bounded and correct caching
Acceptance: A11
缓存总字节不超上限;跨账号不串图,阶段、余额、头像变化不命中旧图;
普通图绘制画布即为1024宽,无3072宽中间合成画布。
## Controlled asynchronous entry
提供异步入口,将整张图的合成与编码交给后台线程;默认最多一个
任务运行,等待队列有上限。相同身份/状态请求共享任务。调用取消
不取消正在运行的共享绘制,也不提前释放容量。关闭等待任务排空。
日志只记录查询/排队/合成/编码耗时、字节、尺寸和缓存结果,不记录
真实用户标识、姓名、头像、凭据或完整快照。
Scenario: Concurrency, cancellation and diagnostics
Acceptance: A12
慢绘图时事件循环仍可工作;同时运行数有界,重复请求共享任务,
失败可重试;取消后后台完成并正确释放容量,关闭无残留任务。
## Verification and usage
提供明确中文字体路径的离线示例,使用构造数据和临时数据库,
报告冷暖耗时、尺寸、三种编码字节及缓存占用。测量仅代表本机样例,
不宣称低配服务器耗时或真实平台上传性能。保留核心离线验证与
旧基线校验,不接入/重启真实机器人,不访问远端。
Scenario: Repeatable offline rendering
Acceptance: A13
离线样例可解码,性能报告真实可复现,文档提供接入调用和未验证边界。
+96
View File
@@ -0,0 +1,96 @@
# 真寻可安装预览包
## 接入边界
全部图片采用统一整数编码质量(1~95,默认75)。真寻网页配置名称为
“农场图片质量”,普通NoneBot接入使用FARM_IMAGE_QUALITY。真寻请求时
读取配置;无效值明确拒绝并保留已有有效配置,不静默夹取范围。
只有“我的农场 高清”可覆盖默认尺寸至1536宽,质量max(配置值,85)。
地块编号、详情、种子商店、我的种子和我的作物拒绝高清参数,保留既有
分页和仓库文字查询。编码质量随各类渲染输入传递并进入图片缓存键。
PNG继续无损输出,质量参数不改变PNG清晰度。默认其他图片尺寸不变。
Scenario: 全局图片质量及仅农场高清
Acceptance: A19
配置1和95可用;0、96、布尔及小数拒绝。所有图片按配置编码且不同质量
不共享成图。农场高清为1536宽,质量至少85且不低于配置;其他命令高清
返回明确参数错误,仓库分页和文字仍可用,帮助不再宣称其他命令支持高清。
本 capability 验收真寻接入;NoneBot/AstrBot 已有独立接入与打包,
不将其当前存在误报为未实现。业务和图片仍来自主仓库共享源码。接入层加载插件
私有_vendor命名空间,渲染层对farm_core的引用在构建时改为私有相对
导入,不修改开发源码、不污染全局sys.path或sys.modules别名。
命令不增加测试前缀,沿用开通农场、我的农场、农场帮助、我的种子、
我的作物、购买种子、播种、收获、铲除、作物加锁、作物解锁、出售作物
等原指令,覆盖帮助、注册、我的农场、仓库、购买、
播种、收获、铲除、锁定/解锁和出售;复用已有规则,不移植旧损坏模块。
部署标识由配置给出,同一平台玩家在本预览部署内共享农场,不跨平台
绑定;测试数据位置与旧版不同。事件标识包含机器人、平台、用户及
原消息标识;无法获得稳定消息标识时写操作明确拒绝,不能随机重试。
在启动时初始化存储、渲染器,在关闭时等待任务排空后关闭存储;字体
支持显式配置合法中文字体,或校验下载原版小赖字体及完整许可。
资源源可选 Github、Gitea、farm_service,默认 Github;服务地址独立
保留。缺少资源或字体时保留文字功能,日志明确原因。头像失败降级,
不让头像下载无限等待。本次不包含作物目录远程更新。
Scenario: 真寻命令与生命周期
Acceptance: A14
模拟宿主验证命令路由、身份/幂等标识、异常反馈与启动关闭,使用独立库;
不把模拟宿主测试报告为真实平台发图成功。
## 产物
scripts/build_zhenxun.py按白名单复制真寻接入、farm_core、farm_render、
对应轻量数据、升级土地素材、LICENSE及NOTICE;大型图片及字体 ZIP
不进入插件包。ZIP含单一插件根目录,仓库导出目录
可作为真寻发布仓库根。生成来源manifest与独立SHA256附件,清单不
自包含哈希;本地工作区脏时标识dirty,不能冒充源HEAD的纯净构建。
输入版本严格校验,ZIP排序/时间固定,目标存在则拒绝或显式选择新路径。
不打包其他适配器、运行库、凭据、缓存、legacy或全局字体。
Scenario: 可复现白名单打包
Acceptance: A15
重复构建内容相同,独立目录导入私有模块成功,无旧目录和其他框架依赖。
## 安装验证
独立verify脚本校验manifest中每个文件与ZIP精确一致,拒绝多余/重复项、
绝对路径、反斜线穿越、符号链接、不可信插件根名、哈希错误。
安装器只安装到用户显式提供的插件父目录,既有目标拒绝覆盖,不删旧
插件、不接触旧数据、不安装依赖、不启动/重启机器人;先校验再写入,
失败清理自己新建的临时目录。提供明确安装步骤和停止机器人提示。
Scenario: 安全校验与安装
Acceptance: A16
临时目录测试有效包可安装,坏包/穿越/已有目标均拒绝且不留下半成品。
## 工作流和发布
主仓库CI在PR/push及手动触发时执行检查,构建ZIP/清单/校验附件并上传
Actions artifact。工作流权限默认只读;不可信PR不触发跨仓库写入。
跨仓库同步依当前工作流,在 main 的 push 或显式 publish 手动输入时
执行,PR 不触发;使用专用 Secret/受保护 environment,固定目标为
Shu-Ying/zhenxun_plugin_farm 的 main 分支。脚本默认 dry-run,只有显式
发布才联网;只进行普通更新,不 force、不修改旧标签;并发分支改变
时失败。此次本地归档不执行发布。发布仓库只含生成的
可安装内容、来源和验证工具,以及验证工作流,不在那里维护业务源码。
下载ZIP测试不要求先同步仓库,不要求用户先给令牌。
Scenario: 测试附件与可控同步
Acceptance: A17
静态检查工作流触发/权限和输入边界;模拟服务验证固定目标、冲突保护、
令牌不落盘与默认dry-run。此任务不执行真实GitHub发布。
## 兼容与文档
真寻本地配置要求Pillow>=10,<11,本预览使用与其相交的>=10.4版本;
CI测试最低10.4和本项目支持当前版本,Windows/Linux。不得静默强制
升级宿主到12。文档列出本机实际检查、模拟与真实宿主的差别,并提供
主仓库生成、发布仓库布局、下载/依赖安装、字体、数据库、测试命令、
与旧版避免冲突、备份/卸载和可选同步的凭据最小权限步骤。沿用原
指令时必须由用户停用旧插件,不静默更名指令,不自动禁用旧插件。
Scenario: 可复现的真寻测试交付
Acceptance: A18
产物可离线验证,文档能按步骤启动人工测试;明确真实机器人与GitHub
工作流仍未实际执行,不宣称已经上线或已兼容全部平台。
@@ -0,0 +1,113 @@
# 作物目录Service与启动更新 Implementation Plan
> **For agentic workers:** 使用subagent-driven-development负责Service边界,
> 主代理负责紧密耦合的Farm加载、生命周期和发布产物;最后统一只读审查。
**Goal:** Service独立维护完整作物资源版本,三框架仅启动检查更新并安全回退。
**Architecture:** Service的catalogue/source为唯一可编辑源,生成不可变发布版本;
Farm离线核心只解析目录,共享接入层下载、校验和选择版本后初始化数据库及绘图。
**Tech Stack:** Python3.10+标准库/Pillow,Rust/Axum,SQLite,unittest/cargo test。
**Spec:** D:/Work/farm_service/docs/crop-catalogue-design.md;用户明确要求实施。
## Global Constraints
不修改legacy、不操作真实玩家数据、不推送部署、不创建定时更新;现有已播种
结算快照和当前价格出售规则保留。当前目录为用户此前选择的工作区;本轮不提交。
图索引过期,已知scripts excluded/src metadata_changed,所有结论回到FastCtx源文件。
## Protocol / Interfaces
发布根catalogue/,latest位于manifest.json;历史位于releases/<release>/。
manifest严格schema=1、version(1..64字母数字._-)、release(64位小写SHA256)、
files;files精确四项plants.json、metadata.json、assets.zip、land-soils.zip,
各为size_bytes正整数和sha256。release=SHA256(files对象的排序紧凑UTF8 JSON)。
manifest<=16KiB,plants<=2MiB,metadata<=4MiB,各ZIP<=64MiB,
解压总量<=256MiB。不得任意URL/路径,不自动跨源。
Github/Gitea根由现有固定Service仓库raw路径拼接catalogue/。
HTTP /v1/catalogue/manifest.json 和 /v1/catalogue/releases/{release}/{file}。
每历史版本含相同manifest.json。Service启动读完整目录快照,运行中不重读。
发布工具调用:python scripts/prepare_catalogue.py --source catalogue/source
--output catalogue --version <label>;支持--check只读核对当前源与latest。
源目录plants.json/metadata.json/images/<原ZIP成员路径>/land-soils.zip。
plants根schema1+plants,不再依赖旧库摘要。metadata.plants从目录重建,
images尺寸与摘要从实际图片计算,保留布局/定位/panel等现有配置。
原子生成、不可变历史,不允许删除/更名旧ID或删除既有图片路径。
Farm接口:farm_core.catalogue_release提供纯离线manifest与目录一致性校验;
farm_render.catalogue_bundle.load_catalogue_release(data_dir, source, service_url,
required_ids=()) -> ReleaseSnapshot(catalogue, metadata, bundle_path, soils_path,
manifest, origin),manifest/metadata可为dict,路径可在首次离线回退为空。
完整release校验后再更新current.json指针,失败用已验证current再用bootstrap;
所有候选必须覆盖required_ids,否则明确失败,不修改玩家库。
缓存路径catalogue/releases/<release>/;current.json只含完整manifest;
并发同进程锁与独立临时目录、原子替换,最终有效目录可复用不破坏。
网络仅接入/资源层,下载限额/超时/HTTPS复用现有模式,异常只记录分类。
bootstrap manifest位于farm_core/data/catalogue-manifest.json,其他文件沿用
现有core/data/plants与render/data/metadata、assets、land-soils;
三端不打包assets.zip,bootstrap无图时仍可文字启动。
## Review Focus
- 下载中断/错hash/错schema/文件混用:不切换不破坏current。
- 旧缓存丢失但玩家持有新ID:启动拒绝,不覆盖数据库或清空库存。
- 同时启动/取消:只采用完整版本,日志关闭后不遗留写缓存/任务。
- 新版本禁售/禁播而非删ID:旧库存可出售、旧种植继续快照结算。
- 公共HTTP只允许白名单文件/64位版本,保留资料鉴权与字体路由。
## Tasks
### 1. Service维护源、确定性发布与HTTP分发
Files: service scripts/prepare_catalogue.py、catalogue/source和releases;
src/catalogue.rs、config/lib/main与tests/catalogue.rs;docs/catalogue.md。
- [x] 先加发布工具/HTTP失败测试,记录RED。
- [x] 移出202条与768张现有图片及小型土地图,初始值/图不变;
本地生成初始正式版本,不访问远端。
- [x] Rust启动校验manifest/各文件与历史集合,公开只读快照路由,
新环境FARM_CATALOGUE_DIR,未配置503、未知404、支持ETag。
- [x] 运行Python工具测试、cargo fmt/test/clippy;不改Farm文件。
### 2. Farm纯校验、下载与bootstrap刷新
Files: core/catalogue_release.py、render/catalogue_bundle.py、
scripts/sync_catalogue_bootstrap.py、check_catalogue_bootstrap.py及新测试。
- [x] 先测成功版本、非法输入、缓存回退、原子性、ID兼容和三源路径RED。
- [x] 实现完整版本校验与缓存选择;Pillow解码只在render层。
- [x] 显式从Service本地发布目录刷新bootstrap,CI只自检独立产物,
不要求相邻Service或在线访问;旧导出仅作为显式迁移参考入口。
- [x] 定向测试GREEN,不将源码检查称为HTTP发布通过。
### 3. 接入生命周期、数据库兼容与动态元数据
Files: integrations/shared/runtime.py、core/storage.py、render/assets.py、
renderer.py和既有运行时测试fixture。
- [x] 先测启动采用新版/失败回退/旧库新ID拒绝/关闭与重复start RED。
- [x] 启动日志后读取只读required IDs,加载版本后初始化Store/Service;
resource_options_provider只在本次启动选择,运行中不再次检查目录。
- [x] Assets/FarmRenderer接受metadata与soils_path,渲染绑定同版。
- [x] 测试fixture显式mock网络边界,不能让默认单元测试访问公网。
### 4. 发布守卫、文档、整体审查
Files: verify_foundation.py、export脚本CLI、package tests/README/docs。
- [x] 替换旧库字节锁定检查为bootstrap一致性,旧基线检查独立保留。
- [x] 三端包包含新模块/轻量bootstrap清单,拒绝大型ZIP。
- [x] 新增新增/改价/重启/兼容回滚维护步骤;修正三端旧在线更新说明。
- [x] 完整219+Python回归、Rust静态/集成检查及新只读审查;仅交付本地。
## Ledger
2026-10-26:开始;Farm基线e5ec38e,Service基线72e54c3。
Comet resume-probe返回none/no-active-native-changes,按仓库约定不强建工作流。
Ruling: 采用当前目录,保留已有未提交设计;不提交合并推送。
Ruling: 发布四件套包含小型land-soils.zip,避免更新升级土地图后仍混用旧包。
2026-10-26完成:初始release为
4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b;
202条作物、768张原始图片已迁入Service,初始业务值和图片字节保留。
Farm完整verify_foundation退出0:238项,237通过、1项NoneBot/Pydantic
宿主配置依赖跳过;工作流守卫和两仓库git diff --check通过。
Service发布器18项、Rust集成16项通过,fmt/clippy通过,初始--check通过。
两侧独立定向审查完成;修复了完整展示契约、并发发布、名称契约、超大整数
溢出和启动关闭交错问题,新增回归先复现失败后通过。文档自检命令已补version。
Farm测试日志:runtime/catalogue-verification.log。
验证边界:Windows/Python3.12.14/Pillow12.3;未执行Linux系统锁分支、
其他依赖矩阵、真实宿主、生产数据库、远端下载、提交、推送或部署。
字体及旧素材下载线程的取消等待属于既有实现边界,本次新作物版本加载线程
已独立跟踪并在关闭时等待排空,不宣称旧资源线程也具备同等保证。
+121
View File
@@ -0,0 +1,121 @@
# 当前真寻农场:构建、安装与维护
更新日期:2026-10-26。本文按当前源码编写;`11-zhenxun-preview.md`
保留为旧版资料,不能作为当前版本的功能或安装依据。
## 支持范围
主仓库维护共享核心,真寻发布仓库接收生成内容。不要把主仓库的
`integrations/zhenxun` 直接复制到插件目录:其中没有构建生成的私有
共享模块和版本文件。使用生成的预览包或对应发布仓库完整内容。
当前支持注册、农场/地块/详情、种子商店、仓库、购买、播种、收获、
铲除、加锁/解锁、出售,以及已接入的土地操作、虫害和偷菜命令。
实际参数以“农场帮助”为准。新账号初始500农场币、6块普通空地。
签到和活动尚未实现。作物目录已迁入 Service,插件启动时检查更新;
维护与回滚见 [作物数据维护](catalogue-updates.md)。
## 构建和验证
开发或CI环境需要Python3.10及以上、Pillow、PyYAML、setuptools、wheel,
完整共享测试还需要 `integrations/astrbot/requirements.txt` 中的依赖。
真寻部署环境沿用宿主提供的NoneBot、真寻和Uninfo;仅安装插件的
`requirements.txt`,其Pillow约束为 `>=10.4,<11`,不要强制升级宿主到12。
主仓库根目录执行,输出目录必须尚不存在:
```text
python scripts/ci_font.py
python scripts/check_workflows.py
python scripts/verify_foundation.py
python scripts/build_zhenxun.py --output dist/zhenxun-manual
python scripts/verify_zhenxun.py dist/zhenxun-manual/zhenxun_plugin_farm_preview-<版本>.zip
```
将 `<版本>` 替换为构建实际输出的版本。字体脚本优先复用本地合法字体,
否则下载默认字体;全量测试可显式设置 `FARM_TEST_FONT` 为已准备字体。
源码方式调用渲染工具时设置 `PYTHONPATH=src`。构建帮助图和图片测试
需要可用中文字体。完全离线时应提前准备依赖、字体和所需图片缓存。
CI构建任务会上传 `zhenxun-preview` 附件,解包后包含插件ZIP、哈希文件、
生成仓库及辅助脚本。ZIP和 `FARM-MANIFEST.json` 用于内容一致性验证,
不能把它们当作发布者身份的密码学签名。
## 安装与升级
1. 停止机器人;备份插件代码和完整数据目录,停用会注册相同原指令的
旧农场插件。命令不加测试前缀,不能同时启用旧版和预览版。
2. 在宿主自己的Python环境安装预览包 `requirements.txt`,保留宿主
原有依赖约束。安装脚本不会代为安装依赖或启动机器人。
3. 使用主仓库辅助脚本,或附件中配套的同名脚本安装:
```text
python scripts/install_zhenxun.py <插件ZIP完整路径> --plugins-dir <现有插件父目录>
```
目标目录名为 `zhenxun_plugin_farm_preview`;父目录必须已存在。
安装器拒绝已有目标、坏哈希、路径穿越及符号链接,不覆盖旧插件或玩家数据。
升级时停止机器人,手工备份并移走上一版插件目录,再安装已验证的新包。
4. 配置宿主加载该插件,检查下述数据目录及资源配置,再手工启动。
5. 使用构造的测试账号检查“农场帮助”“开通农场”“我的农场”等命令,
查看日志;本仓库的模拟测试不代替该真实宿主验证。
## 配置、数据及日志
| 项目 | 当前行为 |
| --- | --- |
| 农场图片质量 | 真寻网页配置,整数1~95、默认75,请求时读取;PNG无损不受影响 |
| 高清 | 仅“我的农场 高清”,宽1536,质量max(配置值,85);其他查询拒绝高清 |
| 资源获取地址 | Github、Gitea、farm_service,默认Github,修改后重载 |
| farm_service 地址 | 独立HTTPS基础地址,选择服务源时必须有效,不使用示例占位域名 |
| FARM_PREVIEW_DATA_DIR | 可选宿主启动配置;未设置时使用真寻DATA_PATH下的farm_db目录 |
| FARM_PREVIEW_DEPLOYMENT | 默认zhenxun-preview;改变它会改变玩家身份作用域,不用于改显示名 |
| FARM_PREVIEW_IMAGE_FORMAT | 默认AUTO;编码与分辨率分别设置,按宿主能力选择 |
共享核心数据库为数据目录内的 `farm.sqlite3`,运行日志为
`farm-preview.log`;素材和字体缓存在同一数据目录的独立子目录。
旧 `farm.db` 与新库不是同一种格式,不得直接覆盖或改名冒充新库。
生产旧库迁移应使用独立迁移流程和副本预检,不是安装器的行为。
公开素材和小赖字体由Service仓库分发,默认下载后按大小及SHA256校验,
缓存完整OFL许可。网页不提供自定义字体路径配置;共享Runtime/绘图API
及开发测试支持显式字体路径。下载失败时保留文字业务并记录原因,
修改源后重载插件重试。插件包不包含大型素材及字体ZIP。
作物目录仍随插件内置,当前没有启动检查作物目录更新或定时更新。
已播种规则按快照保存;出售使用当前目录价格,旧库存也受调价影响。
## 备份、回退与卸载
- 备份:先停止机器人,复制完整插件目录与完整数据目录;若存在SQLite
的WAL/SHM文件,随数据一起保存,不能在运行中仅复制主数据库文件。
- 回退:先停止机器人,恢复匹配的代码与停机备份。旧程序可能拒绝新
schema,不把新数据库强行交给旧代码,也不删除或重置库来使它启动。
恢复旧数据会丢失备份之后的进度,先保留当前副本。
- 卸载:停止机器人并取消加载,备份后移走本插件代码即可。保留数据
和缓存供恢复,不要求删除玩家数据库或旧插件目录。
## 工作流发布与Secrets
主仓库 `main` 的push,或手动运行工作流且选择 `publish=true`,在test与
build通过后可触发同步;PR只测试构建,不执行跨仓库发布。
发布固定为 `Shu-Ying/zhenxun_plugin_farm` 的 `main`,并非旧farm-preview。
它会把目标分支内容更新为生成仓库,发布前应核对目标确为分发仓库。
发布任务使用受保护的 `zhenxun-preview` environment,从Secrets读取
`FARM_RELEASE_TOKEN`;按组织规则授予固定目标仓库所需的最小写权限。
不把令牌写进源码、包、命令示例或日志。缺令牌时发布失败,离线
构建和验证仍不需要发布凭据。
`scripts/publish_zhenxun.py <插件ZIP>` 默认离线dry-run;只有显式
`--publish` 才联网,并拒绝脏来源包。普通更新不force,检测分支并发
变化后失败。本轮归档不运行真实发布,不新增或读取任何令牌。
## 本轮证据边界
2026-10-26在Windows/Python3.12.14/Pillow12.3进行了本地离线检查,
实际命令和验收结论以Comet生成报告为准。NoneBot宿主配置的一项测试
因本地缺Pydantic而跳过;不能将其说成通过。CI声明Windows/Linux及
Pillow10.4/12.3矩阵,本机未运行全部矩阵。真实真寻启动、消息发送、
公开资源下载和GitHub发布均不由本地测试证明。
+50 -6
View File
@@ -23,6 +23,7 @@ from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
from ._vendor.farm_render.seasons import season_for
from ._vendor.farm_render.bundle import ensure_assets, validate_options
from ._vendor.farm_render.font_bundle import ensure_font
from ._vendor.farm_render.catalogue_bundle import load_catalogue_release
from ._vendor.farm_render.shop import shop_page
from ._vendor.farm_render.details import detail_rows, details_text
from ._vendor.farm_render.warehouse import (
@@ -136,12 +137,21 @@ class Runtime:
self._resource_lock = asyncio.Lock()
self._closing = False
self._close_task = None
self._start_lock = asyncio.Lock()
self._catalogue_task = None
self.catalogue_release = None
self._start_epoch = 0
async def start(self):
async with self._start_lock:
await self._start()
async def _start(self):
if self._closing:
return
if self.started:
return
epoch = self._start_epoch
self.data_dir.mkdir(parents=True, exist_ok=True)
self.log_handler = logging.FileHandler(
self.data_dir / "farm-preview.log", encoding="utf-8",
@@ -152,12 +162,34 @@ class Runtime:
logging.getLogger(__package__).setLevel(logging.INFO)
logging.getLogger(__package__).addHandler(self.log_handler)
try:
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
)
validate_options(self.resource_source, self.service_url)
required_ids = await self.store.catalogue_ids()
if self._closing or epoch != self._start_epoch:
return
self._catalogue_task = asyncio.create_task(asyncio.to_thread(
load_catalogue_release, self.data_dir, self.resource_source,
self.service_url, required_ids,
))
self.catalogue_release = await asyncio.shield(self._catalogue_task)
if self._closing or epoch != self._start_epoch:
return
self.service = FarmService(
self.store, self.catalogue_release.catalogue,
)
await self.store.initialize(catalogue=self.service.catalogue)
except Exception:
if self._closing or epoch != self._start_epoch:
return
except BaseException:
logger.error("farm preview database initialization failed")
await self.close()
raise
await self._prepare_resources()
if self._closing or epoch != self._start_epoch:
return
self.started = True
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
@@ -167,16 +199,20 @@ class Runtime:
return
resource = 'configuration'
try:
if self.resource_options_provider is not None:
if (self.catalogue_release is None
and self.resource_options_provider is not None):
self.resource_source, self.service_url = (
self.resource_options_provider()
)
validate_options(self.resource_source, self.service_url)
resource = 'assets'
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
)
selected = self.catalogue_release
bundle_path = selected.bundle_path if selected else None
if bundle_path is None:
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
)
resource = 'font'
candidate = (Path(self.font_path) if self.font_path else
await asyncio.to_thread(
@@ -186,6 +222,8 @@ class Runtime:
resource = 'renderer'
self.rendering = RenderService(FarmRenderer(
candidate, bundle_path=bundle_path,
metadata=selected.metadata if selected else None,
soils_path=selected.soils_path if selected else None,
))
logger.info('farm resources ready font=%s', candidate.name)
except Exception as error:
@@ -202,6 +240,7 @@ class Runtime:
async def close(self):
if self._close_task is None or self._close_task.done():
self._closing = True
self._start_epoch += 1
self._close_task = asyncio.create_task(self._finish_close())
task = self._close_task
try:
@@ -213,6 +252,11 @@ class Runtime:
async def _finish_close(self):
try:
if self._catalogue_task is not None:
try:
await asyncio.shield(self._catalogue_task)
except Exception:
pass
async with self._resource_lock:
if self.rendering:
await self.rendering.close()
+1 -1
View File
@@ -65,7 +65,7 @@
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
+1 -1
View File
@@ -65,7 +65,7 @@
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
+20 -4
View File
@@ -50,8 +50,24 @@
## 如何安装
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
使用真寻分发仓库的完整生成内容或Actions中的真寻预览包,不能直接
复制主仓库的integrations目录。先停止机器人,备份代码与数据,停用
会注册同名命令的旧插件,再在宿主环境安装本插件requirements.txt。
Pillow要求>=10.4,<11,不强制升级宿主到12。安装后手工启动并检查日志。
默认数据目录为真寻DATA_PATH下的farm_db,新库为farm.sqlite3,日志为
farm-preview.log;旧farm.db不得直接改名覆盖。资源源默认Github,可在
网页配置选择Gitea/farm_service;服务地址独立保留,修改资源源后重载。
图片质量默认75、整数1~95,仅“我的农场 高清”支持高清且质量至少85。
资源或字体下载失败时文字业务仍可用,详细原因见日志。
升级前停机备份完整代码与数据目录;安装器拒绝覆盖,先移走旧目录。
回退需匹配代码和数据库备份,不让旧代码强行打开新schema;恢复旧数据
会丢失备份后进度。卸载时停止加载并移走插件代码,保留数据供恢复。
开发者构建、包验证、安全安装、配置、备份卸载与Secrets详见
[当前维护说明](https://github.com/Shu-Ying/main_plugin_farm/blob/main/docs/zhenxun-preview-current.md)。
本地离线验证不代表真实真寻宿主、公开资源或远端发布已验证。
---
@@ -64,8 +80,8 @@
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 签到功能
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
+1 -1
View File
@@ -10,7 +10,7 @@
"observed_default_branch": "main",
"entry_directory": "integrations/zhenxun",
"enabled": true,
"preview_branch": "farm-preview",
"preview_branch": "main",
"publish_by_default": false
},
{
+13 -6
View File
@@ -3,8 +3,14 @@
import hashlib
import json
from pathlib import Path
import sys
from zipfile import ZipFile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core.catalogue_release import parse_manifest
from farm_render.catalogue_bundle import ROOTS
def check(root):
data = Path(root) / 'src/farm_render/data'
@@ -19,12 +25,13 @@ def check(root):
with ZipFile(bundle) as archive:
if set(archive.namelist()) != set(metadata['images']):
raise ValueError('Asset manifest and layout differ')
for source, prefix in (
('github', 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/assets/assets.zip'),
('gitea', 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/assets/assets.zip'),
):
bootstrap = parse_manifest((Path(root) / 'src/farm_core/data/'
'catalogue-manifest.json').read_bytes())
if manifest.get('catalogue_release') != bootstrap['release']:
raise ValueError('Bootstrap and asset release differ')
for name, base in ROOTS.items():
source = name.lower()
prefix = base + '/releases/' + bootstrap['release'] + '/assets.zip'
if manifest['sources'][source] != prefix:
raise ValueError('Asset source must use service repository')
print('Verified external asset manifest and development ZIP')
+27
View File
@@ -0,0 +1,27 @@
"""CI只核对插件初始版本自身,不访问远端或相邻Service开发仓库。"""
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core.catalogue_release import parse_manifest
from farm_render.catalogue_bundle import _validate
def check(root=ROOT):
root = Path(root)
core = root / 'src/farm_core/data'
render = root / 'src/farm_render/data'
manifest = parse_manifest((core / 'catalogue-manifest.json').read_bytes())
content = {'plants.json': (core / 'plants.json').read_bytes(),
**{name: (render / name).read_bytes() for name in (
'metadata.json', 'assets.zip', 'land-soils.zip',
)}}
_validate(content, manifest, None, ())
print('Verified independent catalogue bootstrap ' + manifest['release'])
if __name__ == '__main__':
check()
+12 -2
View File
@@ -37,14 +37,24 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--source", type=Path,
default=root / "legacy/zhenxun_plugin_farm/resource/db/plant.db",
default=None,
)
parser.add_argument(
"--output", type=Path,
default=root / "src/farm_core/data/plants.json",
default=None,
)
parser.add_argument("--check", action="store_true")
parser.add_argument('--legacy-reference', action='store_true')
args = parser.parse_args()
if args.check and args.source is None:
from check_catalogue_bootstrap import check
check(root)
return
if not args.legacy_reference or args.output is None:
parser.error('Daily updates use sync_catalogue_bootstrap.py; '
'legacy export requires --legacy-reference --output')
if args.source is None:
args.source = root / 'legacy/zhenxun_plugin_farm/resource/db/plant.db'
content = export(args.source)
if args.check:
if not args.output.is_file() or args.output.read_bytes() != content:
+14 -4
View File
@@ -119,9 +119,10 @@ def export(root):
) for theme in ("default", "spring", "summer", "autumn", "winter", "bee")]
paths += list((source / "background/decorateIcon").glob("decorate_*.png"))
layout = json.loads((legacy / "config/soil.json").read_bytes())
catalogue = json.loads((
root / "src/farm_core/data/plants.json"
).read_bytes())
from export_catalogue import export as export_legacy_catalogue
catalogue = json.loads(export_legacy_catalogue(
legacy / 'resource/db/plant.db',
))
metadata = {
"schema": 1, "canvas": [3072, 1872], "soil_size": layout["size"],
"positions": [layout["soil"][str(i)] for i in range(1, 31)],
@@ -188,10 +189,19 @@ def main():
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
parser.add_argument('--legacy-reference', action='store_true')
parser.add_argument('--output', type=Path)
args = parser.parse_args()
if args.check and not args.legacy_reference:
from check_catalogue_bootstrap import check
check(root)
return
if not args.legacy_reference or args.output is None:
parser.error('Daily updates use Service prepare_catalogue.py; '
'legacy export requires --legacy-reference --output')
files = (*zip(("assets.zip", "metadata.json"), export(root)),
('land-soils.zip', export_soils(root)))
destination = root / "src/farm_render/data"
destination = args.output
for name, content in files:
path = destination / name
if args.check:
+59
View File
@@ -0,0 +1,59 @@
"""显式从已验证Service发布目录刷新插件初始版本,不联网。"""
import argparse
import json
from pathlib import Path
import shutil
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core import Catalogue
from farm_core.catalogue_release import canonical, parse_manifest
from farm_render.catalogue_bundle import ROOTS, _snapshot
def sync(source, root=ROOT):
source, root = Path(source), Path(root)
manifest = parse_manifest((source / 'manifest.json').read_bytes())
release = source / 'releases' / manifest['release']
snapshot = _snapshot(release, manifest, Catalogue.bundled())
destinations = {
'plants.json': root / 'src/farm_core/data/plants.json',
**{name: root / 'src/farm_render/data' / name for name in (
'metadata.json', 'assets.zip', 'land-soils.zip',
)},
}
for name, path in destinations.items():
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(release / name, path)
data = root / 'src/farm_core/data'
(data / 'catalogue-manifest.json').write_bytes(canonical(manifest) + b'\n')
asset = {
'schema': 1, 'version': manifest['version'],
'sha256': manifest['files']['assets.zip']['sha256'],
'size_bytes': manifest['files']['assets.zip']['size_bytes'],
'catalogue_release': manifest['release'],
'sources': {
name.lower(): base + '/releases/' + manifest['release']
+ '/assets.zip' for name, base in ROOTS.items()
},
}
asset['sources']['farm_service'] = (
'https://farm.example/v1/catalogue/releases/'
+ manifest['release'] + '/assets.zip'
)
(root / 'src/farm_render/data/asset-manifest.json').write_text(
json.dumps(asset, ensure_ascii=False, indent=2) + '\n',
encoding='utf-8',
)
return snapshot.manifest
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True,
help='Service catalogue发布目录,不是可编辑source目录')
args = parser.parse_args()
print('Bootstrap synced release=' + sync(args.source)['release'])
+1 -2
View File
@@ -14,8 +14,7 @@ def main():
}
commands = (
[sys.executable, "scripts/check_baseline.py"],
[sys.executable, "scripts/export_catalogue.py", "--check"],
[sys.executable, "scripts/export_render_assets.py", "--check"],
[sys.executable, 'scripts/check_catalogue_bootstrap.py'],
[sys.executable, 'scripts/check_asset_manifest.py'],
[
sys.executable, "-m", "unittest", "discover",
+110
View File
@@ -0,0 +1,110 @@
"""作物发布协议的纯离线校验;不联网、不依赖绘图库。"""
import hashlib
import json
import re
from .catalogue import Catalogue
FILES = {
'plants.json': 2 * 1024 * 1024,
'metadata.json': 4 * 1024 * 1024,
'assets.zip': 64 * 1024 * 1024,
'land-soils.zip': 64 * 1024 * 1024,
}
MANIFEST_LIMIT = 16 * 1024
DIGEST = re.compile(r'[0-9a-f]{64}')
VERSION = re.compile(r'[A-Za-z0-9._-]{1,64}')
def _pairs(items):
result = {}
for key, value in items:
if key in result:
raise ValueError('Duplicate JSON key')
result[key] = value
return result
def decode(content):
def invalid(_value):
raise ValueError('Non-finite JSON value')
try:
return json.loads(content, object_pairs_hook=_pairs,
parse_constant=invalid)
except (UnicodeError, TypeError, RecursionError) as error:
raise ValueError('Invalid release JSON') from error
def canonical(value):
return json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(',', ':'), allow_nan=False).encode()
def digest(content):
return hashlib.sha256(content).hexdigest()
def make_manifest(files, version):
descriptors = {
name: {'size_bytes': len(content), 'sha256': digest(content)}
for name, content in files.items()
}
result = {'schema': 1, 'version': version,
'release': digest(canonical(descriptors)), 'files': descriptors}
return parse_manifest(canonical(result))
def parse_manifest(content):
if len(content) > MANIFEST_LIMIT:
raise ValueError('Catalogue manifest exceeds limit')
value = decode(content)
if (type(value) is not dict or set(value) != {
'schema', 'version', 'release', 'files'}
or type(value['schema']) is not int or value['schema'] != 1
or not isinstance(value['version'], str)
or VERSION.fullmatch(value['version']) is None
or not isinstance(value['release'], str)
or DIGEST.fullmatch(value['release']) is None
or type(value['files']) is not dict
or set(value['files']) != set(FILES)):
raise ValueError('Invalid catalogue manifest')
for name, limit in FILES.items():
item = value['files'][name]
if (type(item) is not dict or set(item) != {'size_bytes', 'sha256'}
or type(item['size_bytes']) is not int
or not 0 < item['size_bytes'] <= limit
or not isinstance(item['sha256'], str)
or DIGEST.fullmatch(item['sha256']) is None):
raise ValueError('Invalid catalogue file descriptor')
if digest(canonical(value['files'])) != value['release']:
raise ValueError('Catalogue release fingerprint differs')
return value
def check_file(content, descriptor):
if (len(content) != descriptor['size_bytes']
or digest(content) != descriptor['sha256']):
raise ValueError('Catalogue file size or SHA256 differs')
def load_catalogue(content, previous=None, required_ids=()):
value = decode(content)
if (type(value) is not dict or type(value.get('plants')) is not list
or not 0 < len(value['plants']) <= 10000):
raise ValueError('Invalid catalogue document')
catalogue = Catalogue.loads(content)
names = [plant.name for plant in catalogue]
if (len(set(names)) != len(names) or any(
name in ('.', '..') or '/' in name or '\\' in name
for name in names)):
raise ValueError('Invalid or duplicate catalogue name')
indexed = {plant.plant_id: plant for plant in catalogue}
if not set(required_ids).issubset(indexed):
raise ValueError('catalogue missing player crop ID')
if previous is not None:
for plant in previous:
current = indexed.get(plant.plant_id)
if current is None or current.name != plant.name:
raise ValueError('Catalogue removed or renamed crop ID')
return catalogue
@@ -0,0 +1 @@
{"files":{"assets.zip":{"sha256":"78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9","size_bytes":21264561},"land-soils.zip":{"sha256":"e27151f0b92c36842c23d9bf3d4855e786ef5a9db37c736e751b53be8d3ebda9","size_bytes":57889},"metadata.json":{"sha256":"6ba1c2134d1a853cd4c2bfd6489e68bd59a0c6068cd79289a1f4bcaa62f2444d","size_bytes":197449},"plants.json":{"sha256":"e05e4430643710ccbeeeb1c7c8640ec5962ee097de6b3d5fad0fb7860c596a2b","size_bytes":87259}},"release":"4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b","schema":1,"version":"catalogue-20261026-1"}
+1 -2
View File
@@ -4445,6 +4445,5 @@
"vipBuy": 0
}
],
"schema": 1,
"source_sha256": "21f11e818909dda4d60864374062c46e0c86e9c2f01d2759225aba466d9de9eb"
"schema": 1
}
+42
View File
@@ -0,0 +1,42 @@
"""跨进程发布/缓存锁;进程退出由操作系统释放,不使用永久锁标记。"""
from contextlib import contextmanager
import os
from pathlib import Path
import time
@contextmanager
def release_lock(path, timeout=180):
path = Path(path)
with path.open('a+b') as stream:
if stream.seek(0, os.SEEK_END) == 0:
stream.write(b'0')
stream.flush()
deadline = time.monotonic() + timeout
if os.name == 'nt':
import msvcrt
def acquire():
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
def unlock():
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
def acquire():
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
def unlock():
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
while True:
try:
acquire()
break
except OSError:
if time.monotonic() >= deadline:
raise TimeoutError('Catalogue cache is busy') from None
time.sleep(0.05)
try:
yield
finally:
unlock()
+44
View File
@@ -103,6 +103,50 @@ SCHEMA = (
class SQLiteStore:
async def catalogue_ids(self):
"""初始化前只读检查库存与地块ID;不创建文件或迁移数据库。"""
task = asyncio.create_task(asyncio.to_thread(self._catalogue_ids))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
try:
await task
except Exception:
pass
raise
def _catalogue_ids(self):
if not self.path.is_file():
return set()
with closing(sqlite3.connect(
self.path.as_uri() + '?mode=ro', uri=True)) as connection:
connection.execute('BEGIN')
app = connection.execute('PRAGMA application_id').fetchone()[0]
version = connection.execute('PRAGMA user_version').fetchone()[0]
tables = {row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'",
)}
if app == 0 and version == 0 and not tables:
return set()
if app != APPLICATION_ID or not 1 <= version <= SCHEMA_VERSION:
raise SchemaError('Unsupported farm database')
result = set()
if version >= 2:
result.update(row[0] for row in connection.execute(
'SELECT DISTINCT plant_id FROM inventory '
'WHERE seeds>0 OR crops>0',
))
for row in connection.execute(
'SELECT plant FROM plots WHERE plant IS NOT NULL'):
try:
result.add(json.loads(row[0])['plant_id'])
except (KeyError, TypeError, ValueError) as error:
raise SchemaError('Invalid saved crop definition') \
from error
if any(type(value) is not int or value <= 0 for value in result):
raise SchemaError('Invalid saved crop ID')
return result
def __init__(self, path: str | Path, timeout: float = 10):
if str(path) == ":memory:":
raise ValueError("Use a dedicated file for transaction connections")
+8 -5
View File
@@ -1,6 +1,7 @@
"""包内素材与有界 LRU;不下载、不解压到磁盘、不暴露共享图片。"""
from collections import OrderedDict
from copy import deepcopy
from importlib import resources
from io import BytesIO
import json
@@ -42,10 +43,14 @@ class ByteLRU:
class Assets:
def __init__(self, cache_bytes=32 * 1024 * 1024, bundle_path=None):
def __init__(self, cache_bytes=32 * 1024 * 1024, bundle_path=None,
metadata=None, soils_path=None):
data = resources.files(__package__).joinpath("data")
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
self.metadata = (deepcopy(metadata) if metadata is not None else
json.loads(data.joinpath('metadata.json')
.read_bytes()))
self.cache = ByteLRU(cache_bytes)
self._soils = soils_path or data.joinpath('land-soils.zip')
self._bundle = (bundle_path if bundle_path is not None
else data.joinpath('assets.zip'))
@@ -58,9 +63,7 @@ class Assets:
key = (name, size)
cached = self.cache.get(key)
if cached is None:
source = (resources.files(__package__).joinpath(
'data', 'land-soils.zip',
) if upgrade else self._bundle)
source = self._soils if upgrade else self._bundle
with source.open("rb") as stream, ZipFile(stream) as bundle:
content = bundle.read(name)
with Image.open(BytesIO(content)) as original:
+6 -2
View File
@@ -99,8 +99,12 @@ def ensure_assets(data_dir, source='Github', service_url=''):
target.parent.mkdir(parents=True, exist_ok=True)
# 清单绑定在插件内;远端不能通过更换清单放行不兼容的素材。
if source == 'farm_service':
url = (service_url.rstrip('/') + '/v1/assets/'
+ expected['sha256'] + '/assets.zip')
if 'catalogue_release' in expected:
url = (service_url.rstrip('/') + '/v1/catalogue/releases/'
+ expected['catalogue_release'] + '/assets.zip')
else:
url = (service_url.rstrip('/') + '/v1/assets/'
+ expected['sha256'] + '/assets.zip')
else:
url = expected['sources'][source.lower()]
temporary = None
+208
View File
@@ -0,0 +1,208 @@
"""启动时选择完整作物版本;网络失败保留已验证缓存或初始目录。"""
from dataclasses import dataclass, replace
from importlib import resources
from io import BytesIO
import logging
from pathlib import Path
import tempfile
import threading
import uuid
from zipfile import BadZipFile
from farm_core.catalogue_release import (
FILES, MANIFEST_LIMIT, canonical, check_file, decode, load_catalogue,
parse_manifest,
)
from farm_core.domain import FarmError
from farm_core.release_lock import release_lock
from .bundle import _copy_remote, validate_options
from .release_validation import validate_archive, validate_metadata
logger = logging.getLogger(__name__)
_LOCK = threading.RLock()
_FAILURES = (OSError, ValueError, FarmError, EOFError, BadZipFile)
ROOTS = {
'Github': 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/catalogue',
'Gitea': 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/catalogue',
}
@dataclass(frozen=True)
class ReleaseSnapshot:
catalogue: object # 完整且已校验的离线业务目录。
metadata: dict # 与目录和图片绑定的展示配置。
bundle_path: Path | None # 无图片缓存的离线初始启动可为空。
soils_path: Path # 小型升级土地资源所在路径。
manifest: dict # 本次采用的发布版本和四文件摘要。
origin: str # remote、cache或bootstrap,仅用于安全日志。
def _fetch(url, limit):
output = BytesIO()
_copy_remote(url, output, limit)
return output.getvalue()
def _read(path, limit):
with path.open('rb') as stream:
content = stream.read(limit + 1)
if len(content) > limit:
raise ValueError('Catalogue file exceeds limit')
return content
def _atomic(path, content):
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=path.parent, suffix='.download', delete=False) as stream:
temporary = Path(stream.name)
stream.write(content)
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def _validate(content, manifest, previous, required_ids):
for name, value in content.items():
check_file(value, manifest['files'][name])
catalogue = load_catalogue(content['plants.json'], previous, required_ids)
metadata = validate_metadata(content['metadata.json'], catalogue)
for row in decode(content['plants.json'])['plants']:
info = metadata['plants'][row['name']]
if (info['phase'] != row['phase']
or info.get('general') != row.get('general')):
raise ValueError('Catalogue and render growth definitions differ')
if 'assets.zip' in content:
validate_archive(content['assets.zip'], metadata['images'])
validate_archive(content['land-soils.zip'])
return catalogue, metadata
def _snapshot(root, manifest, previous=None, required_ids=(), origin='cache'):
content = {name: _read(root / name, checks['size_bytes'])
for name, checks in manifest['files'].items()}
catalogue, metadata = _validate(
content, manifest, previous, required_ids,
)
return ReleaseSnapshot(catalogue, metadata, root / 'assets.zip',
root / 'land-soils.zip', manifest, origin)
def _bootstrap(required_ids=()):
core_name = ('farm_core' if __package__ == 'farm_render' else
__package__.rsplit('.', 1)[0] + '.farm_core')
core = resources.files(core_name).joinpath('data')
render = resources.files(__package__).joinpath('data')
manifest = parse_manifest(core.joinpath(
'catalogue-manifest.json',
).read_bytes())
content = {
'plants.json': core.joinpath('plants.json').read_bytes(),
'metadata.json': render.joinpath('metadata.json').read_bytes(),
'land-soils.zip': render.joinpath('land-soils.zip').read_bytes(),
}
catalogue, metadata = _validate(content, manifest, None, required_ids)
return ReleaseSnapshot(catalogue, metadata, None,
Path(str(render.joinpath('land-soils.zip'))),
manifest, 'bootstrap')
def load_catalogue_release(data_dir, source='Github', service_url='',
required_ids=()):
"""每次调用只检查一次最新版本;调用方仅在启动时调用。"""
validate_options(source, service_url)
base = (service_url.rstrip('/') + '/v1/catalogue'
if source == 'farm_service' else ROOTS[source])
root = Path(data_dir) / 'catalogue'
root.mkdir(parents=True, exist_ok=True)
with _LOCK, release_lock(root / '.cache.lock'):
releases = root / 'releases'
releases.mkdir(exist_ok=True)
bootstrap = _bootstrap()
current = None
try:
manifest = parse_manifest(_read(root / 'current.json',
MANIFEST_LIMIT))
current = _snapshot(
releases / manifest['release'], manifest,
bootstrap.catalogue,
)
except _FAILURES:
pass
previous = (current or bootstrap).catalogue
try:
manifest = parse_manifest(_fetch(
base + '/manifest.json', MANIFEST_LIMIT,
))
target = releases / manifest['release']
if (current is not None
and current.manifest['release'] == manifest['release']):
if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID')
return replace(current, origin='remote')
try:
candidate = _snapshot(target, manifest, previous, required_ids,
'remote')
except _FAILURES:
with tempfile.TemporaryDirectory(
dir=releases, prefix='.download-') as temporary:
staging = Path(temporary)
for name, item in manifest['files'].items():
content = _fetch(
base + '/releases/' + manifest['release']
+ '/' + name, item['size_bytes'],
)
check_file(content, item)
(staging / name).write_bytes(content)
_snapshot(staging, manifest, previous, required_ids)
if target.exists():
try:
_snapshot(target, manifest, previous, required_ids)
except _FAILURES:
target.rename(releases / (
'.invalid-' + uuid.uuid4().hex
))
if not target.exists():
try:
staging.rename(target)
except OSError:
if not target.exists():
raise
candidate = _snapshot(target, manifest, previous,
required_ids, 'remote')
previous_images = (current or bootstrap).metadata['images']
if not set(previous_images).issubset(candidate.metadata['images']):
raise ValueError('Catalogue removed existing image paths')
_atomic(root / 'current.json', canonical(manifest))
logger.info('farm catalogue ready release=%s source=%s',
manifest['release'], source)
return candidate
except _FAILURES as error:
logger.warning('farm catalogue update unavailable source=%s '
'reason=%s; keeping verified fallback',
source, type(error).__name__)
if current is not None:
if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID')
return current
bootstrap = _bootstrap(required_ids)
# 若旧方式图片缓存可用,离线初始版本也能继续绘图。
cached = (Path(data_dir) / 'assets'
/ bootstrap.manifest['files']['assets.zip']['sha256']
/ 'assets.zip')
try:
content = _read(cached, FILES['assets.zip'])
check_file(content, bootstrap.manifest['files']['assets.zip'])
validate_archive(content, bootstrap.metadata['images'])
bootstrap = replace(bootstrap, bundle_path=cached)
except _FAILURES:
pass
return bootstrap
+6 -5
View File
@@ -1,11 +1,12 @@
{
"schema": 1,
"version": "farm-assets-v1",
"version": "catalogue-20261026-1",
"sha256": "78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9",
"size_bytes": 21264561,
"catalogue_release": "4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b",
"sources": {
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/assets/assets.zip",
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/assets/assets.zip",
"farm_service": "https://farm.example/v1/assets/78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9/assets.zip"
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/catalogue/releases/4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b/assets.zip",
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/catalogue/releases/4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b/assets.zip",
"farm_service": "https://farm.example/v1/catalogue/releases/4c88736c2e9b1b5eec4568e953152d59a6918242802da72a6eec1a615a17f18b/assets.zip"
}
}
}
+171
View File
@@ -0,0 +1,171 @@
"""校验同版本展示配置及ZIP图片,不将不可信路径解压到磁盘。"""
from io import BytesIO
import math
from pathlib import PurePosixPath
from zipfile import ZipFile
from PIL import Image
from farm_core.catalogue_release import decode, digest
def _numbers(values, length):
if (type(values) is not list or len(values) != length
or any(type(v) not in (int, float) or abs(v) > 10000000
or not math.isfinite(v) for v in values)):
raise ValueError('Invalid render coordinates')
return values
def _size(values):
_numbers(values, 2)
if (any(type(v) is not int or not 0 < v <= 8192 for v in values)
or values[0] * values[1] > 32 * 1024 * 1024):
raise ValueError('Invalid image dimensions')
def _rect(values):
_numbers(values, 4)
if values[2] <= 0 or values[3] <= 0:
raise ValueError('Invalid render rectangle')
def safe_member(name):
if (not isinstance(name, str) or not name or len(name) > 512
or '\\' in name or ':' in name):
raise ValueError('Invalid image path')
path = PurePosixPath(name)
if path.is_absolute() or any(p in ('', '.', '..') for p in name.split('/')):
raise ValueError('Invalid image path')
def validate_metadata(content, catalogue):
value = decode(content)
try:
if type(value['schema']) is not int or value['schema'] != 1:
raise ValueError('Unknown render metadata schema')
_size(value['canvas'])
_size(value['soil_size'])
if len(value['positions']) != 30:
raise ValueError('Expected 30 plot positions')
for pos in value['positions']:
_numbers([pos['x'], pos['y']], 2)
images = value['images']
if not isinstance(images, dict) or not 0 < len(images) <= 10000:
raise ValueError('Invalid image manifest')
for name, item in images.items():
safe_member(name)
_size(item['size'])
if (not isinstance(item['sha256'], str)
or len(item['sha256']) != 64
or any(c not in '0123456789abcdef'
for c in item['sha256'])):
raise ValueError('Invalid image checksum')
plants = value['plants']
if set(plants) != {plant.name for plant in catalogue}:
raise ValueError('Catalogue and render plants differ')
for plant in catalogue:
phases = plants[plant.name]['phase'].split(',')
numbers = list(dict.fromkeys(int(v) for v in phases))
if (len(numbers) < 2 or min(numbers) <= 0
or numbers != sorted(numbers)):
raise ValueError('Invalid render growth phases')
placement = value['placement']
if (type(placement['schema']) is not int
or placement['schema'] != 4
or placement['mode'] != 'visible_contact_center'):
raise ValueError('Unknown crop placement')
for key in ('common_scale', 'growth_scale', 'reference_soil_height',
'reference_soil_width'):
_numbers([placement[key]], 1)
if placement[key] <= 0:
raise ValueError('Invalid crop scale')
_numbers([placement['contact_alpha_threshold']], 1)
_numbers([placement['contact_band_fraction']], 1)
if (not 0 <= placement['contact_alpha_threshold'] <= 255
or not 0 < placement['contact_band_fraction'] <= 1):
raise ValueError('Invalid ground contact rule')
matrix = placement['scene_transform']
if len(matrix) != 2:
raise ValueError('Invalid scene transform')
a, b = _numbers(matrix[0], 2)
c, d = _numbers(matrix[1], 2)
if abs(a*d-b*c) < 1e-8:
raise ValueError('Degenerate scene transform')
pest = value['pest']
_size(pest['display_size'])
if (len(pest['frames']) < 3
or len(set(pest['frames'])) != len(pest['frames'])):
raise ValueError('Invalid pest frames')
for name in pest['frames']:
if name not in images:
raise ValueError('Missing pest image')
if len(pest['positions']) < 3:
raise ValueError('Missing pest positions')
for pos in pest['positions']:
_numbers(pos, 2)
panel = value['panel']
_size(panel['reference_canvas'])
_rect(panel['avatar'])
fields = {'name', 'experience', 'level', 'point', 'vip_point'}
if {entry['field'] for entry in panel['fields']} != fields:
raise ValueError('Invalid panel fields')
for entry in panel['fields']:
_rect(entry['rect'])
_numbers(entry['color'], 3)
if (any(not 0 <= v <= 255 for v in entry['color'])
or entry['alignment'] not in (3, 4)):
raise ValueError('Invalid panel text')
for entry in panel['sprites']:
_rect(entry['rect'])
if entry['sprite'] not in images:
raise ValueError('Missing panel sprite')
if set(value['decorations']) != {
'default', 'spring', 'summer', 'autumn', 'winter', 'bee'}:
raise ValueError('Invalid decoration themes')
for entries in value['decorations'].values():
for entry in entries:
_numbers(entry['position'], 2)
_numbers(entry['size'], 2)
if (min(entry['size']) <= 0
or entry['layer'] not in ('background', 'foreground')
or entry['sprite'] not in images):
raise ValueError('Invalid decoration')
for name in ('webgl/seed.png', 'webgl/wither.png',
'background/ripe.png', 'soil/草土地.png',
'soil/普通土地.png'):
if name not in images:
raise ValueError('Missing required farm sprite')
return value
except (KeyError, TypeError, IndexError, AttributeError) as error:
raise ValueError('Invalid render metadata') from error
def validate_archive(content, images=None):
with ZipFile(BytesIO(content)) as archive:
entries = archive.infolist()
names = [entry.filename for entry in entries]
expected = (set(images) if images is not None else {
'soil/红土地.png', 'soil/黑土地.png', 'soil/金土地.png',
})
if (len(names) != len(set(names)) or set(names) != expected
or sum(e.file_size for e in entries) > 256 * 1024 * 1024):
raise ValueError('Unexpected release ZIP contents')
for entry in entries:
safe_member(entry.filename)
if (entry.is_dir() or entry.flag_bits & 1
or entry.file_size > 32 * 1024 * 1024
or (entry.external_attr >> 16) & 0o170000 == 0o120000):
raise ValueError('Invalid release ZIP member')
data = archive.read(entry)
expected_image = images[entry.filename] if images else None
if (expected_image is not None
and digest(data) != expected_image['sha256']):
raise ValueError('Release image checksum differs')
with Image.open(BytesIO(data)) as image:
_size(list(image.size))
if (expected_image is not None
and list(image.size) != expected_image['size']):
raise ValueError('Release image dimensions differ')
image.verify()
+3 -2
View File
@@ -26,13 +26,14 @@ class FarmRenderer:
def __init__(
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
result_cache_bytes=8 * 1024 * 1024,
bundle_path=None,
bundle_path=None, metadata=None, soils_path=None,
):
self.font_path = Path(font_path).resolve()
if not self.font_path.is_file():
raise ValueError("Configure an existing Chinese font path")
font(self.font_path, 16)
self.assets = Assets(asset_cache_bytes, bundle_path=bundle_path)
self.assets = Assets(asset_cache_bytes, bundle_path=bundle_path,
metadata=metadata, soils_path=soils_path)
self.results = ByteLRU(result_cache_bytes)
def scene(self, identity, snapshot, now):
+4 -3
View File
@@ -46,12 +46,13 @@ class BundleTests(unittest.TestCase):
bundle.ensure_assets(self.root / 'gitea', 'Gitea')
self.assertEqual(fetch.call_args.args[0],
'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/assets/assets.zip')
'raw/branch/main/catalogue/releases/'
+ self.expected['catalogue_release'] + '/assets.zip')
bundle.ensure_assets(self.root / 'service', 'farm_service',
'https://farm.example/prefix/')
self.assertEqual(fetch.call_args.args[0],
'https://farm.example/prefix/v1/assets/'
+ self.expected['sha256'] + '/assets.zip')
'https://farm.example/prefix/v1/catalogue/releases/'
+ self.expected['catalogue_release'] + '/assets.zip')
def test_failed_download_leaves_no_partial_and_wrong_hash_is_rejected(self):
def corrupt(url, destination, limit):
+6 -1
View File
@@ -146,7 +146,12 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
Path(choose_font()).read_bytes())
(fonts / 'OFL-Xiaolai.txt').write_bytes((ROOT /
'src/farm_render/data/OFL-Xiaolai.txt').read_bytes())
await plugin.initialize()
with patch(
package.__name__ +
'._vendor.farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline fixture'),
):
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
class Event:
+195
View File
@@ -0,0 +1,195 @@
import copy
import hashlib
import json
from io import BytesIO
from zipfile import ZipFile
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from farm_core.catalogue_release import make_manifest, parse_manifest
from farm_render.catalogue_bundle import load_catalogue_release
ROOT = Path(__file__).resolve().parents[1]
def release(price=21, extra=False):
plants = json.loads((ROOT / 'src/farm_core/data/plants.json').read_bytes())
plants.pop('source_sha256', None)
plants['plants'][0]['price'] = price
metadata = json.loads(
(ROOT / 'src/farm_render/data/metadata.json').read_bytes()
)
if extra:
row = copy.deepcopy(plants['plants'][0])
row.update(id=9001, name='更新测试作物')
plants['plants'].append(row)
metadata['plants'][row['name']] = copy.deepcopy(
metadata['plants'][plants['plants'][0]['name']]
)
files = {
'plants.json': json.dumps(plants, ensure_ascii=False).encode(),
'metadata.json': json.dumps(metadata, ensure_ascii=False).encode(),
**{name: (ROOT / 'src/farm_render/data' / name).read_bytes()
for name in ('assets.zip', 'land-soils.zip')},
}
return make_manifest(files, 'test-v1'), files
class CatalogueReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.manifest, self.files = release(price=99)
def tearDown(self):
self.temp.cleanup()
def fetch(self, url, limit):
if url.endswith('/manifest.json'):
data = json.dumps(self.manifest).encode()
else:
self.assertIn('/releases/' + self.manifest['release'] + '/', url)
data = self.files[url.rsplit('/', 1)[-1]]
self.assertLessEqual(len(data), limit)
return data
def load(self, **kwargs):
return load_catalogue_release(self.root, **kwargs)
def test_new_catalogue_and_metadata_activate_as_one_version(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual(result.origin, 'remote')
self.assertTrue(result.bundle_path.is_file())
self.assertTrue(result.soils_path.is_file())
current = json.loads((self.root / 'catalogue/current.json').read_bytes())
self.assertEqual(current['release'], self.manifest['release'])
def test_404_reuses_verified_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('404')):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual(result.origin, 'cache')
def test_wrong_bytes_do_not_replace_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
before = (self.root / 'catalogue/current.json').read_bytes()
self.manifest, self.files = release(price=100)
self.files['plants.json'] = b'broken'
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual((self.root / 'catalogue/current.json').read_bytes(),
before)
def test_first_offline_start_uses_bootstrap_without_images(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline')):
result = self.load()
self.assertEqual(result.origin, 'bootstrap')
self.assertEqual(len(result.catalogue), 202)
self.assertFalse((self.root / 'catalogue/current.json').exists())
def test_cache_loss_with_unknown_player_id_refuses_start(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline')):
with self.assertRaisesRegex(ValueError, 'catalogue.*ID'):
self.load(required_ids={9001})
def test_incompatible_downgrade_keeps_added_crop(self):
self.manifest, self.files = release(extra=True)
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
self.manifest, self.files = release()
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(9001).name, '更新测试作物')
self.assertEqual(result.origin, 'cache')
def test_manifest_rejects_bool_unknown_schema_paths_and_fingerprint(self):
for key, value in [('schema', True), ('schema', 2),
('release', '0' * 64)]:
bad = copy.deepcopy(self.manifest)
bad[key] = value
with self.assertRaises(ValueError):
parse_manifest(json.dumps(bad).encode())
bad = copy.deepcopy(self.manifest)
bad['files']['../file'] = bad['files'].pop('assets.zip')
with self.assertRaises(ValueError):
parse_manifest(json.dumps(bad).encode())
def test_selected_service_prefix_is_respected(self):
seen = []
def fetch(url, limit):
seen.append(url)
return self.fetch(url, limit)
with patch('farm_render.catalogue_bundle._fetch', side_effect=fetch):
self.load(source='farm_service',
service_url='https://farm.test/prefix')
self.assertTrue(all(
url.startswith('https://farm.test/prefix/v1/catalogue/')
for url in seen
))
def test_current_matching_remote_still_checks_player_ids(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
with self.assertRaisesRegex(ValueError, 'catalogue.*ID'):
self.load(required_ids={9001})
def test_consistent_but_invalid_metadata_keeps_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
self.files['metadata.json'] = b'{"schema":1,"canvas":[0,0]}'
self.manifest = make_manifest(self.files, 'bad-layout')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertEqual(result.metadata['canvas'], [3072, 1872])
def test_huge_coordinate_keeps_verified_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
metadata = json.loads(self.files['metadata.json'])
metadata['positions'][0]['x'] = 10 ** 500
self.files['metadata.json'] = json.dumps(metadata).encode()
self.manifest = make_manifest(self.files, 'huge-coordinate')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertLess(result.metadata['positions'][0]['x'], 10000000)
def test_removing_old_crop_image_is_rejected_even_with_valid_hashes(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
metadata = json.loads(self.files['metadata.json'])
name = next(key for key in metadata['images']
if key.startswith('plant/'))
metadata['images'].pop(name)
output = BytesIO()
with ZipFile(BytesIO(self.files['assets.zip'])) as old, \
ZipFile(output, 'w') as new:
for item in old.infolist():
if item.filename != name:
new.writestr(item, old.read(item))
self.files['assets.zip'] = output.getvalue()
self.files['metadata.json'] = json.dumps(metadata).encode()
self.manifest = make_manifest(self.files, 'missing-image')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertIn(name, result.metadata['images'])
+34
View File
@@ -0,0 +1,34 @@
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
import unittest
from farm_core import SQLiteStore
class CatalogueStoreTests(unittest.IsolatedAsyncioTestCase):
async def test_missing_store_is_not_created_by_catalogue_check(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'not-created.sqlite3'
store = SQLiteStore(path)
self.assertEqual(await store.catalogue_ids(), set())
self.assertFalse(path.exists())
async def test_existing_ids_read_without_changing_database(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'farm.sqlite3'
store = SQLiteStore(path)
await store.initialize()
await store.close()
with closing(sqlite3.connect(path)) as db, db:
db.execute('INSERT INTO accounts(identity,name) VALUES (?,?)',
('fixture', 'synthetic'))
db.execute('INSERT INTO inventory(identity,plant_id,name,seeds)'
' VALUES (?,?,?,?)', ('fixture', 9001, 'test', 1))
db.execute('INSERT INTO plots(identity,slot,plant) VALUES (?,?,?)',
('fixture', 1, json.dumps({'plant_id': 9002})))
before = path.read_bytes()
self.assertEqual(await store.catalogue_ids(), {9001, 9002})
self.assertEqual(path.read_bytes(), before)
+5 -3
View File
@@ -11,6 +11,8 @@ from export_render_assets import export
class RenderExportTests(unittest.TestCase):
def test_export_preserves_published_land_numbering_and_calibration(self):
_, metadata = export(ROOT)
stored = (ROOT / "src/farm_render/data/metadata.json").read_bytes()
self.assertEqual(json.loads(metadata), json.loads(stored))
self.assertEqual(metadata, stored)
data = json.loads(metadata)
self.assertEqual(len(data['plants']), 202)
self.assertEqual(len(data['positions']), 30)
self.assertEqual(data['placement']['mode'], 'visible_contact_center')
self.assertEqual(metadata, export(ROOT)[1])
+138
View File
@@ -27,6 +27,12 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
module.__path__ = [str(self.root / "built/repository")]
sys.modules[self.package] = module
self.module = importlib.import_module(self.package + ".runtime")
offline = patch(
self.package + '._vendor.farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline fixture'),
)
offline.start()
self.addCleanup(offline.stop)
self.runtime = self.module.Runtime(self.root / "data", choose_font())
self.seed_assets(self.runtime)
await self.runtime.start()
@@ -78,6 +84,133 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1'), bytes)
async def test_catalogue_is_loaded_once_before_store_initialization(self):
import json
from unittest.mock import AsyncMock
await self.call('register')
await self.call('buy', '胡萝卜 1', message='seed')
await self.call('sow', '胡萝卜 1', message='sow')
before = await self.runtime.service.snapshot(self.runtime_identity())
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
original = bundle._bootstrap()
document = json.loads((ROOT / 'src/farm_core/data/plants.json')
.read_bytes())
document['plants'][0]['price'] = 99
document['plants'][0]['harvest'] = 999
updated = replace(original, catalogue=self.module.Catalogue.loads(
json.dumps(document),
))
self.runtime = self.module.Runtime(self.root / 'data', choose_font())
self.seed_assets(self.runtime)
with patch.object(self.module, 'load_catalogue_release',
return_value=updated) as load:
await asyncio.gather(self.runtime.start(), self.runtime.start())
await self.runtime.start()
self.assertEqual(load.call_count, 1)
self.assertEqual(self.runtime.service.catalogue.get_by_id(1).price, 99)
after = await self.runtime.service.snapshot(self.runtime_identity())
self.assertEqual(after.plots[0].plant, before.plots[0].plant)
self.assertNotEqual(after.plots[0].plant.harvest, 999)
async def test_close_during_catalogue_load_does_not_start_service(self):
import threading
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
snapshot = bundle._bootstrap()
entered, release = threading.Event(), threading.Event()
def slow(*args):
entered.set()
if not release.wait(5):
raise TimeoutError('fixture did not release')
return snapshot
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
with patch.object(self.module, 'load_catalogue_release',
side_effect=slow):
starting = asyncio.create_task(self.runtime.start())
await asyncio.wait_for(asyncio.to_thread(entered.wait), 2)
closing = asyncio.create_task(self.runtime.close())
await asyncio.sleep(0)
release.set()
await asyncio.gather(starting, closing)
self.assertFalse(self.runtime.started)
self.assertFalse((self.root / 'other/farm.sqlite3').exists())
async def test_close_during_id_read_prevents_later_start(self):
await self.runtime.close()
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
self.seed_assets(self.runtime)
entered, release = asyncio.Event(), asyncio.Event()
async def slow_ids():
entered.set()
await release.wait()
return set()
with patch.object(self.runtime.store, 'catalogue_ids',
side_effect=slow_ids):
starting = asyncio.create_task(self.runtime.start())
await entered.wait()
await self.runtime.close()
release.set()
await starting
self.assertFalse(self.runtime.started)
self.assertFalse((self.root / 'other/farm.sqlite3').exists())
async def test_cancelled_start_drains_catalogue_download(self):
import threading
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
snapshot = bundle._bootstrap()
entered, release, finished = (threading.Event() for _ in range(3))
def slow(*args):
entered.set()
if not release.wait(5):
raise TimeoutError('fixture did not release')
finished.set()
return snapshot
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
with patch.object(self.module, 'load_catalogue_release',
side_effect=slow):
starting = asyncio.create_task(self.runtime.start())
await asyncio.wait_for(asyncio.to_thread(entered.wait), 2)
starting.cancel()
await asyncio.sleep(0.01)
self.assertFalse(starting.done())
release.set()
with self.assertRaises(asyncio.CancelledError):
await starting
self.assertTrue(finished.is_set())
self.assertIsNone(self.runtime.log_handler)
self.assertFalse(self.runtime.started)
async def test_close_during_initialization_prevents_later_start(self):
await self.runtime.close()
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
self.seed_assets(self.runtime)
entered, release = asyncio.Event(), asyncio.Event()
initialize = self.runtime.store.initialize
async def slow_initialize(**kwargs):
await initialize(**kwargs)
entered.set()
await release.wait()
with patch.object(self.runtime.store, 'initialize',
side_effect=slow_initialize):
starting = asyncio.create_task(self.runtime.start())
await entered.wait()
await self.runtime.close()
release.set()
await starting
self.assertFalse(self.runtime.started)
self.assertIsNone(self.runtime.rendering)
async def test_land_upgrade_command_conditions_charge_and_replay(self):
self.assertEqual(self.module.COMMANDS['土地升级'], 'upgrade')
await self.call('register')
@@ -577,6 +710,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for resource in ('assets', 'font'):
other = self.module.Runtime(self.root / ('stop-' + resource))
self.seed_assets(other)
if resource == 'assets':
next((other.data_dir / 'assets').glob('*/assets.zip')).unlink()
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=TimeoutError()) as fetch:
@@ -598,6 +733,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
await asyncio.sleep(0)
self.assertEqual(fetch.call_count, 1)
await other.close()
self.seed_assets(other)
await other.start()
self.assertIsNotNone(other.rendering)
finally:
@@ -614,6 +750,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
):
other = self.module.Runtime(self.root / ('failed-' + resource))
self.seed_assets(other)
if resource == 'assets':
next((other.data_dir / 'assets').glob('*/assets.zip')).unlink()
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=failure), \
+1 -1
View File
@@ -1 +1 @@
2.0.2
2.0.3