Compare commits

..
11 Commits
Author SHA1 Message Date
Diuse e897bad506 fixed # 还是修复CI问题 2026-10-28 09:49:06 +08:00
Diuse b851e490be fixed # 修复CI问题 2026-10-28 09:28:33 +08:00
Diuse 8872ffe3be fixed # 2.0.6 2026-10-27 23:03:17 +08:00
Diuse b09d51c7c9 fixed # 修改进度条表现
add # 新增Ruff代码格式工作流
2026-10-27 23:02:43 +08:00
Diuse c6b504924e fixed # 修复部分工作流BUG 2026-10-27 18:43:42 +08:00
Diuse d05b2e5765 fixed # 修复astrbot日志未使用logging.basicConfig 2026-10-27 11:40:59 +08:00
Diuse b3455ded58 fixed # 修复工作流BUG 2026-10-27 09:47:48 +08:00
Diuse e7c6eac07f fixed # 将资源文件迁移到Service仓库中 2026-10-26 18:53:41 +08:00
Diuse e5ec38e9d9 docs: sync farm installation and maintenance guidance 2026-10-26 12:41:46 +08:00
Diuse ac0209f010 chore(native): archive farm-core-foundation 2026-10-26 12:40:54 +08:00
Diuse 22cfddcf06 add # 新增使用开源字体 2026-10-26 11:34:00 +08:00
119 changed files with 5368 additions and 1144 deletions
+14 -2
View File
@@ -33,7 +33,7 @@ jobs:
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -49,7 +49,19 @@ jobs:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_astrbot.py --output dist/astrbot-preview
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate AstrBot help menu
run: >-
python scripts/preview_help.py --platform astrbot
--output runtime/help-images/images
- run: >-
python scripts/build_astrbot.py --output dist/astrbot-preview
--help-image runtime/help-images/images/help-astrbot.png
- name: Verify AstrBot package
shell: bash
run: |
+17 -5
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
publish:
description: Sync main branch in NoneBot repository
description: Sync NoneBot repository and trigger PyPI version release
default: false
type: boolean
permissions:
@@ -33,7 +33,7 @@ jobs:
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -49,7 +49,19 @@ jobs:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_nonebot.py --output dist/nonebot-preview
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate NoneBot help menu
run: >-
python scripts/preview_help.py --platform nonebot
--output runtime/help-images/images
- run: >-
python scripts/build_nonebot.py --output dist/nonebot-preview
--help-image runtime/help-images/images/help-nonebot.png
- run: >-
python scripts/verify_nonebot.py
--directory dist/nonebot-preview/repository
@@ -90,9 +102,9 @@ jobs:
with:
name: nonebot-preview
path: incoming
- name: Sync validated NoneBot repository
- name: Sync NoneBot repository and trigger version release
env:
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
run: >-
python scripts/publish_nonebot.py
incoming/nonebot_plugin_farm-*.zip --publish
incoming/nonebot_plugin_farm-*.zip --publish --release-tag
+12 -1
View File
@@ -42,7 +42,7 @@ jobs:
run: >-
python -m pip install "Pillow==$PILLOW_VERSION"
setuptools wheel "PyYAML>=6,<7"
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -59,10 +59,21 @@ jobs:
python-version: '3.12'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate Zhenxun help menu
run: >-
python scripts/preview_help.py --platform zhenxun
--output runtime/help-images/images
- name: Build selected target only
run: >-
python scripts/build_zhenxun.py
--output dist/zhenxun-preview
--help-image runtime/help-images/images/help-zhenxun.png
- name: Copy standalone helper scripts
run: |
cp scripts/install_zhenxun.py dist/zhenxun-preview/
+24 -2
View File
@@ -35,16 +35,38 @@
- [真寻](https://github.com/Shu-Ying/zhenxun_plugin_farm)
- [Nonebot](https://github.com/Shu-Ying/nonebot_plugin_farm)
- [Astrbot](https://github.com/Shu-Ying/astrbot_plugin_farm)
`如果没有你需要的框架,那么请在本仓库提交issue吧,我会尽量迁移的(其实是用AI)`
`如果没有你需要的框架,那么请在本仓库提交issue吧,Codex会尽量迁移的`
## 本地生成插件
构建环境需要 Python 3.10+、Pillow 和固定版本的 Ruff:
```powershell
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
```
运行对应平台的 `build_*.cmd`,或使用
`python scripts/build_plugin.py nonebot`(也支持 `zhenxun`、`astrbot`)。
如需指定 Python,可设置 `FARM_PYTHON` 为已安装以上依赖的解释器路径。
三个平台都会先对生成的 Python 文件整理导入并使用 Ruff 格式化,检查通过后
再计算清单摘要和打包。配置沿用本仓库 `pyproject.toml`:Python 3.10、
80 字符行宽、4 空格缩进、双引号和 LF 换行。构建不修改开发源文件,
也不会将 Ruff 加入插件运行依赖;缺少 Ruff、版本不符或格式检查失败时停止构建。
这里只检查导入排序和格式,不自动修复其他代码规则;功能仍需单独测试。
## 开源协议
本项目采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./src/farm_render/data/OFL-Xiaolai.txt)。
## 致谢
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
+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)。
离线测试、模拟宿主与生产安装/远端发布分别记录,不互相替代。
+36 -28
View File
@@ -1,7 +1,7 @@
# 共享图片工具与“我的农场”
状态:共享渲染已实现并通过离线检查和独立只读验收;等待用户接受。
真实宿主命令与上传仍未接入。
更新日期:2026-10-26。共享渲染和宿主接入已有实现;实际验收结果
由Comet报告记录,模拟测试不能证明真实宿主消息发送或客户端显示。
Python 语法基线3.10;本机实测 Windows/Python3.12.14/Pillow12.3.0。
## 安装与职责
@@ -22,9 +22,11 @@ NoneBot、AstrBot、真寻均可调用,不导入任何宿主对象。
| src/farm_render/renderer.py | 底图、作物、账户面板、成图缓存 |
| src/farm_render/service.py | 一次查询、后台执行、并发和取消管理 |
字体由部署方提供可合法使用的中文 TrueType/OpenType 文件路径。
示例使用本机已有微软雅黑;不会复制或发布 Windows 字体。Linux
应配置包含中文的字体;测试可通过 FARM_TEST_FONT 指定路径。当前
默认使用由 farm_service 分发并缓存的小赖字体(lxgw/kose-font v3.126),原版字体与
完整 SIL OFL 1.1 许可一同分发,版权声明见
`src/farm_render/data/OFL-Xiaolai.txt`。插件固定使用小赖字体,不提供
字体路径网页配置;共享Runtime/绘图API支持显式路径,开发测试可通过
FARM_TEST_FONT指定路径。当前
实现校验字体可加载,不保证任意字体具备所有中文、Emoji 字形。
## 调用
@@ -32,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(
@@ -59,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,才会优先使用它。
@@ -80,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;零预算禁用对应缓存,超限
@@ -102,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及原始归属;历史素材分发权仍需在公开发布前核实。
@@ -112,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是历史
面板恢复测量,不代表当前面板或真实平台发送耗时。
## 测量与验证
@@ -136,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。
@@ -159,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矩阵与远端资源可用性
不属于本机绘图样例的证明范围。
+6 -3
View File
@@ -17,16 +17,19 @@ nonebot.load_plugin("nonebot_plugin_farm")
可在 `.env` 中设置:
```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO
FARM_IMAGE_QUALITY=75
FARM_DEPLOYMENT=nonebot
```
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
默认数据目录沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
日志写入该目录的 `farm-preview.log`。默认使用由 farm_service 分发并缓存的小赖字体;
字体不提供用户配置,旧的 `FARM_FONT` 不再读取;为避免旧存档被忽略,
继续兼容 `FARM_DATA_DIR`,未配置或为空时使用默认目录,不自动搬动数据库。
字体或素材下载失败时停止本次资源加载,保留文字功能,不自动重试;
重新加载插件后才会再次尝试。日志区分资源类别和安全错误原因。
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
+8 -1
View File
@@ -36,8 +36,15 @@
的 deployment、机器人 ID 和平台,不跨身份重复导入;转换规则、失败处理
和不支持字段的保留方式见 [旧数据迁移](05-migration.md)。
原有显式 `farm_preview_data_dir` 配置继续生效。仅从选定目录读取旧版
默认数据目录为真寻 `DATA_PATH/farm_db`;继续兼容已有的
`farm_preview_data_dir` 显式配置,避免升级后忽略旧存档。不自动搬动数据库。
仅从当前选用的数据目录读取旧版
`farm.db`;不会搜索其他目录。未发布预览版的 `farm-preview.sqlite3`
不自动改名或导入,预览数据由开发者手动处理。
素材或字体下载失败时停止本次资源加载,保留文字功能,不自动重试。
重新加载插件后才会再次尝试;关闭插件时清理已有资源。
日志区分配置、素材、字体与渲染器,记录 HTTP 状态、超时或校验失败等
安全分类,不记录 URL 或可能含凭据的异常正文。
本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。
+8 -4
View File
@@ -1,14 +1,18 @@
# 文档索引
记录日期:2026-10-15。状态:已形成设计草案,待关键产品决策确认;
第一批基础核心已实施;多框架接入、发布和真实数据迁移尚未实施。
更新日期:2026-10-26。共享核心、绘图和三框架接入/打包已有实现;
本轮仅做本地归档验收,不代表真实宿主、生产数据迁移或远端发布已验证。
长期设计草案及历史页面须与现行使用说明区分。
| 文档 | 用途 |
|---|---|
| [nonebot-release.md](nonebot-release.md) | NoneBot 同步、自动版本标签、PyPI发布及失败重跑 |
| [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 +38,4 @@
- 发布仓库是分发目标,不是 Git 子模块;各框架使用相互独立的插件包。
- 主仓库远程为 Shu-Ying/main_plugin_farm;本轮未执行远程操作。
- 用户已撤回拉取要求,且要求删除本地克隆;三个本地检出已删除,不再访问远程。
- 本轮仅修正迁移副本中的硬编码凭据;业务缺陷记录在审查报告,没有隐式修复。
- 历史迁移预检与后续实现分开记录;当前行为以源码、现行说明和正式验收为准。
+63
View File
@@ -0,0 +1,63 @@
# 作物数据维护与启动更新
作物的可编辑源已迁入独立 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 或版本无效时优先使用已经
验证的缓存,再使用插件基线。真寻和NoneBot日志位于同一数据目录的
`farm-preview.log`;AstrBot统一使用宿主日志,在AstrBot控制台或日志页查看,
插件不创建独立日志文件,也不修改宿主日志级别或处理器。
终端同时显示启动阶段、缓存复用、下载开始、完成及回退信息。交互终端中的
Rich 下载进度条在同一行刷新,最多每0.2秒一次;显示已下载量和下载速度,
服务器提供文件总大小时还显示百分比、总大小和预计剩余时间。
未提供总大小时不显示百分比和预计剩余时间。网页日志页、重定向输出和日志文件
不记录中间进度,避免刷屏或混入控制字符。文件下载完成后仍需校验,只有
整套校验通过才切换版本。插件优先
复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时
移除。AstrBot普通日志使用 `astrbot.api.logger`,输出级别和位置由宿主管理;
进度条只写入可原位刷新的交互终端,不进入宿主日志。
备份应包含完整数据目录及其 `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
工作流仍未实际执行,不宣称已经上线或已兼容全部平台。
+29 -1
View File
@@ -8,13 +8,41 @@
- 前 6 块由 `initial_plot_count: 6` 表示,不重复保存空条件记录。
新用户默认6块;可靠识别为初始3块的旧用户一次性补3块普通空地。
- 开垦时,`expansion.rows.ordinal` 对应补偿完成后的下一实际地块编号。
土地升级表仍只是规则数据,本次未接入土地升级。
土地升级已接入,具体行为见下文。
- 表外序号,以及材料、星钻替代支付、冷却和额外解锁条件没有在图中
给出。不能默认免费、默认通过,或套用最后一行。24 不是土地总上限。
- 已提供“开垦”(每次1块)和“开垦 条件”(查询等级及费用)。
等级检查、扣币及新增空地在同一事务内完成,重放同消息不重复扣款。
目前配置覆盖7~24号,25号起明确提示规则未配置,不扣币。
## 土地升级
- `土地升级 1 条件` 只查询1号地下一等级的条件;`土地升级 1` 扣费升级。
地块编号来自“地块编号”,支持各平台共用命令。
- 普通→红→黑→金,逐级升级。每种目标等级的费用序号为:
已达到该等级或更高等级的地块数加1,不等于地块编号。
升为更高等级不会使低等级费用重新降低;表外序号拒绝,不猜测费用。
- 红土地增产10%,黑土地增产20%,金土地增产28%、经验加成28%。
黑、金土地开始新一季时,生长时间取基础时长的80%,向上取整到秒。
沿用旧实现加成,不叠加到原加成上;普通地的新账号规则不变。
- 升级使正在生长的作物当季立即成熟,不发放收获物、不增加收获次数。
多季作物收获后才进入下一季。先结算升级时刻已发生的虫害,并冻结
当前虫数对应的减产;不清除已经偷取的数量或偷取记录。
- 已成熟作物保持成熟,不恢复虫害损失;枯萎的是作物而非土地,
升级不会使枯萎作物复活,仍需铲除。空地只改变土地等级。
- 扣币、土地加成、当前季成熟和操作回执在同一SQLite事务提交。
同一消息重复处理返回原收据;等级不足、余额不足或中途失败全部回滚。
不同消息各执行一次升级,因此不要重复发送不同的升级消息。
- schema v5 为地块增加land_level;v1~v4按顺序事务迁移。
旧版导入账号从已保存的legacy_rows.userSoil恢复明确等级;
没有旧版记录的地块默认普通,不按加成猜等级,不改资产与历史加成。
原始记录非法时中止并回滚迁移,保留旧库。
- 三种升级土地图从legacy原样导出为包内land-soils.zip,约57KiB。
保持既有外部assets.zip的内容、摘要和下载来源不变;绘图和底图缓存
包含土地等级,升级后不会复用普通地旧图。
实现和验证使用临时构造玩家;未迁移生产数据、部署或重启机器人。
## 新用户注册
注册事务创建 1~6 号空地,初始农场币仍为 500。重复注册不额外发地。
+34
View File
@@ -0,0 +1,34 @@
# NoneBot 发布与版本更新
主仓库 `version.txt` 是版本来源。NoneBot preview 工作流通过测试并生成插件后,
在主仓库 `main` 推送或手动勾选 `publish` 时,执行以下操作:
1. 校验生成包及来源工作区状态。
2. 同步 `Shu-Ying/nonebot_plugin_farm` 的 `main` 分支。
3. 在本次同步提交上创建 `v<version.txt>` 标签。
4. 标签触发独立仓库的“🚀 发布到 PyPI”工作流,构建、校验并上传发行包。
PR及未勾选publish的手动运行只构建测试,不同步、不打标签。脚本未传
`--publish` 时仍是离线预演;`--release-tag` 明确开启自动标签步骤。
标签创建成功只表示触发条件已满足,不表示下游PyPI发布或商店检测已成功。
## 凭据与发布前提
- 主仓库受保护环境中继续使用 `FARM_RELEASE_TOKEN`,必须是可写目标仓库
内容及工作流的PAT或GitHub App token;不能替换为普通 `GITHUB_TOKEN`。
- NoneBot独立仓库需允许Actions运行,并已配置有效的 `PYPI_TOKEN`。
- 目标仓库标签规则也必须允许该发布身份创建 `v*` 标签。
- 发布新内容前递增主仓库 `version.txt`;不要在发布仓库手工改生成的版本文件。
## 重跑和失败处理
已有同版本标签时,比较标签中生成清单的版本、包身份和全部文件摘要;内容
一致则跳过同步和重复触发。只有来源提交信息变化而插件文件相同也会跳过。
若内容不同则拒绝,并提示升级 `version.txt`;脚本不删除或移动旧标签。
同步main成功但创建标签失败时,工作流会报错,保留已同步代码;修复权限等
原因后重跑。若标签已创建而下游PyPI上传失败,应在独立仓库重新运行失败的
发布任务;上游重跑不会再次创建同名标签。PyPI已占用的版本不能靠移动标签
覆盖,应使用新版本。
本地测试使用模拟GitHub请求,不会创建远端标签或上传PyPI。
@@ -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发布均不由本地测试证明。
Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

+6
View File
@@ -1,5 +1,11 @@
# 更新日志
## 待发布
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
## 2.0.2 — 待发布
### 新增
+7 -4
View File
@@ -17,11 +17,14 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印
## 配置与使用
管理面板支持`font_path`(中文字体)、`data_dir`、`image_format`及
管理面板支持`resource_source`、`farm_service_url`、`image_format`及
`image_quality`(整数1~95,默认75)。质量修改后下一次请求生效;
其他配置修改后重载插件。默认数据目录为
`data/plugin_data/astrbot_plugin_farm`,数据库与日志位于该目录,卸载
插件前自行备份;更改目录不自动搬迁数据,旧数据不会被覆盖。
其他配置修改后重载插件。字体使用小赖字体,默认数据目录为
`data/plugin_data/astrbot_plugin_farm`。为兼容旧部署,继续读取已有的
`data_dir` 配置;留空使用默认目录。不会自动搬动数据库,卸载前自行备份。
素材或字体下载失败时停止本次资源加载,保留文字功能,不自动重试;
重新加载插件后才会再次尝试。日志按配置、素材、字体或渲染器区分失败阶段,
仅记录安全错误分类,不输出远程地址及异常正文。
按AstrBot配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
+6 -12
View File
@@ -1,4 +1,10 @@
{
"data_dir": {
"description": "兼容旧部署的数据目录",
"type": "string",
"default": "",
"hint": "保留原有自定义目录以继续读取旧存档;留空使用 AstrBot 默认目录。修改此项不会自动搬移数据库。"
},
"resource_source": {
"description": "资源获取地址",
"type": "string",
@@ -12,18 +18,6 @@
"default": "",
"hint": "始终独立配置,用于资源及后续活动服务;选择farm_service获取素材时必填HTTPS基础地址。"
},
"font_path": {
"description": "中文字体路径",
"type": "string",
"default": "",
"hint": "部署环境可访问的中文字体文件;留空时尝试系统字体,找不到则退回文字。"
},
"data_dir": {
"description": "农场数据目录",
"type": "string",
"default": "",
"hint": "留空使用data/plugin_data/astrbot_plugin_farm。修改后重载插件;不会自动搬迁数据。"
},
"image_format": {
"description": "图片格式",
"type": "string",
+1 -2
View File
@@ -2,14 +2,13 @@
import asyncio
from collections import OrderedDict
import logging
import re
from time import monotonic
import aiohttp
from astrbot.api import logger
logger = logging.getLogger(__name__)
MAX_BYTES = 2 * 1024 * 1024
MAX_ENTRIES = 16
FETCH_TIMEOUT = 2
+2 -6
View File
@@ -2,10 +2,10 @@
import asyncio
import json
import logging
from pathlib import Path
from astrbot.api import AstrBotConfig
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import At, Image
from astrbot.api.star import Context, Star, StarTools
@@ -16,9 +16,6 @@ from .runtime import Runtime, command_map
COMMANDS = command_map('astrbot')
logger = logging.getLogger(__name__)
class FarmPlugin(Star):
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
@@ -33,8 +30,7 @@ class FarmPlugin(Star):
if image_format not in ('AUTO', 'JPEG', 'PNG'):
raise ValueError('image_format must be AUTO, JPEG or PNG')
self.runtime = Runtime(
data_dir, config.get('font_path') or None, 'astrbot',
image_format, font_setting='font_path',
data_dir, deployment='astrbot', image_format=image_format,
image_quality=config.get('image_quality', 75),
image_quality_provider=lambda: self.config.get('image_quality', 75),
framework='astrbot',
+1
View File
@@ -1,2 +1,3 @@
Pillow>=10.4
aiohttp>=3.9,<4
rich>=13.9,<16
+9
View File
@@ -0,0 +1,9 @@
"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。"""
def open_logs(data_dir):
return None, None
def close_logs(file_handler, console_handler):
pass
+2 -2
View File
@@ -21,8 +21,8 @@ config = get_plugin_config(Config)
driver = get_driver()
runtime = Runtime(
data_directory(store.get_plugin_data_dir(), config.farm_data_dir),
config.farm_font, config.farm_deployment, config.farm_image_format,
font_setting="FARM_FONT",
deployment=config.farm_deployment,
image_format=config.farm_image_format,
image_quality=config.farm_image_quality,
framework='nonebot',
resource_source=config.farm_resource_source,
+9 -8
View File
@@ -1,23 +1,20 @@
"""普通 NoneBot 配置;不依赖真寻配置系统。"""
from typing import Literal
from pathlib import Path
from typing import Literal, Optional
from pydantic import BaseModel, Field, conint
from pydantic import BaseModel, Field, conint, validator
class Config(BaseModel):
"""农场启动配置。"""
farm_data_dir: Optional[Path] = Field(
None, description="数据目录;默认兼容旧版 localstore 农场目录",
)
farm_font: Optional[Path] = Field(
None, description="中文字体路径;不可用时退回文字响应",
)
farm_deployment: str = Field(
"nonebot", min_length=1, description="农场存档部署标识",
)
farm_data_dir: Path | None = Field(
None, description='兼容旧部署的数据目录;未设置时使用平台默认目录',
)
farm_image_format: Literal["AUTO", "JPEG", "PNG"] = Field(
"AUTO", description="图片格式,AUTO 优先使用压缩 JPEG",
)
@@ -31,3 +28,7 @@ class Config(BaseModel):
farm_service_url: str = Field(
'', description='farm_service 地址,独立供资源及活动服务使用',
)
@validator('farm_data_dir', pre=True)
def empty_data_directory(cls, value):
return None if value == '' else value
+2 -2
View File
@@ -4,6 +4,6 @@ from pathlib import Path
def data_directory(base, configured=None):
if configured is not None:
return Path(configured)
if configured:
return Path(configured).expanduser()
return Path(base) / "nonebot_plugin_farm" / "farm_db"
+175 -66
View File
@@ -7,7 +7,10 @@ import json
import logging
from pathlib import Path
import shlex
from urllib.error import HTTPError, URLError
from zipfile import BadZipFile
from .runtime_logging import open_logs, close_logs
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
from ._vendor.farm_core.domain import FarmError
from ._vendor.farm_core.commands import (
@@ -20,6 +23,8 @@ from ._vendor.farm_core.presentation import (
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 (
@@ -29,7 +34,36 @@ from ._vendor.farm_render.warehouse import (
logger = logging.getLogger(__name__)
COMMANDS = command_map('zhenxun')
HELP = help_text(command_specs('zhenxun'))
def resource_failure_reason(error):
"""只返回固定分类,不输出可能含 URL 或凭据的异常正文。"""
if isinstance(error, HTTPError):
return f'http_{error.code}'
if isinstance(error, TimeoutError):
return 'timeout'
if isinstance(error, URLError):
return ('timeout' if isinstance(error.reason, TimeoutError)
else 'network_error')
if isinstance(error, BadZipFile):
return 'invalid_archive'
if isinstance(error, ValueError):
known = {
'Resource SHA256 mismatch': 'integrity_mismatch',
'Resource size mismatch': 'integrity_mismatch',
'Font archive size or SHA256 mismatch': 'integrity_mismatch',
'Font member SHA256 mismatch': 'integrity_mismatch',
'Resource download limit or timeout': 'download_limit_or_timeout',
}
return known.get(str(error), 'validation_error')
if isinstance(error, OSError):
return 'io_error'
return 'unexpected_error'
ERRORS = {
'land_max_level': '该地块已是金土地,当前不支持继续升级。',
'unknown_plot': '地块不存在,请发送「地块编号」查询已开垦土地。',
'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,'
'请联系管理员核查注册或迁移记录。',
'land_compensation_capacity': '旧用户补3块后将超出当前30个绘图位置,'
@@ -76,7 +110,7 @@ def operation_id(bot_id, platform, user_id, message_id, action):
class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None,
font_setting="FARM_PREVIEW_FONT", image_quality=75,
image_quality=75,
image_quality_provider=None, framework='zhenxun',
resource_source='Github', service_url='',
resource_options_provider=None):
@@ -92,7 +126,6 @@ class Runtime:
self.image_quality_provider = image_quality_provider
self.data_dir = Path(data_dir).resolve()
self.font_path = font_path
self.font_setting = font_setting
self.font_dir = Path(font_dir or "resources/font")
self.deployment = deployment
self.image_format = image_format
@@ -101,79 +134,138 @@ class Runtime:
self.rendering = None
self.started = False
self.log_handler = None
self.console_handler = None
self.legacy_source = self.data_dir / 'farm.db'
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",
)
self.log_handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s",
))
logging.getLogger(__package__).setLevel(logging.INFO)
logging.getLogger(__package__).addHandler(self.log_handler)
try:
await self.store.initialize(catalogue=self.service.catalogue)
except Exception:
logger.error("farm preview database initialization failed")
await self.close()
raise
if self.font_path:
candidates = [Path(self.font_path)]
else:
candidates = [self.font_dir / name for name in (
"HYWenHei-85W.ttf", "msyh.ttf", "syht.otf", "syst.otf",
"AlibabaHealthFont2.0CN-85B.ttf",
)]
candidates.extend(Path(name) for name in (
"C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
))
self.log_handler, self.console_handler = open_logs(self.data_dir)
try:
logger.info('农场启动:正在检查作物版本和本地缓存')
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
)
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
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,
)
except Exception:
# 不将下载URL、服务地址或可能带凭据的网络异常写入日志。
logger.error('farm assets unavailable; using text; '
'check resource source and service address')
bundle_path = None
for candidate in candidates if bundle_path is not None else ():
if not candidate.is_file():
continue
try:
self.rendering = RenderService(FarmRenderer(
candidate, bundle_path=bundle_path,
))
logger.info("farm preview font selected file=%s", candidate.name)
break
except (ValueError, OSError):
logger.warning("farm preview font candidate is not loadable")
if self.rendering is None:
logger.warning("farm preview font unavailable; text mode only")
await self.store.initialize(catalogue=self.service.catalogue)
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('农场启动完成(%s)',
'图片可用' if self.rendering else '文字模式')
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
async def _prepare_resources(self):
async with self._resource_lock:
if self._closing or self.rendering is not None:
return
resource = 'configuration'
try:
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'
logger.info('农场启动:正在准备图片素材和字体')
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(
ensure_font, self.data_dir,
self.resource_source, self.service_url,
))
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:
source = (self.resource_source if self.resource_source in (
'Github', 'Gitea', 'farm_service',
) else 'invalid')
logger.warning(
'farm resource unavailable resource=%s source=%s '
'reason=%s; loading stopped until plugin reload; '
'using text',
resource, source, resource_failure_reason(error),
)
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:
if self.rendering:
await self.rendering.close()
# 调用方取消关闭等待时,清理仍独立运行。
await asyncio.shield(task)
finally:
if task.done():
self._close_task = None
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()
self.rendering = None
finally:
await self.store.close()
self.started = False
if self.log_handler:
logging.getLogger(__package__).removeHandler(self.log_handler)
self.log_handler.close()
self.log_handler = None
self._closing = False
close_logs(self.log_handler, self.console_handler)
self.log_handler = self.console_handler = None
async def execute(
self, action, arguments, *, bot_id, platform, user_id, message_id,
@@ -202,8 +294,7 @@ class Runtime:
args = shlex.split(arguments)
if action == "farm" and args:
aliases = {
"detail":"details", "my-point":"inventory",
"my-vipPoint":"inventory", "my-seed":"seeds",
"detail":"details", "my-seed":"seeds",
"my-plant":"crops", "seed-shop":"shop",
"buy-seed":"buy", "sowing":"sow", "harvest":"harvest",
"eradicate":"eradicate", "lock-plant":"lock",
@@ -229,14 +320,33 @@ class Runtime:
f"(当前{conditions['point']})\n"
'满足条件后发送:开垦'
)
if action == 'upgrade':
if len(args) not in (1, 2) or (len(args) == 2
and args[1] != '条件'):
return '请发送:土地升级 地块编号 或 土地升级 地块编号 条件'
slot = int(args[0])
if len(args) == 2:
condition = await self.service.upgrade_conditions(who, slot)
return (
f"地块{slot}:{condition['current_land']}→"
f"{condition['land_name']}(第{condition['ordinal']}块)\n"
f"所需等级:{condition['required_level']}"
f"(当前{condition['level']})\n"
f"所需农场币:{condition['coin_cost']}"
f"(当前{condition['point']})\n"
f'升级并使当前季成熟:土地升级 {slot}'
)
if op is None:
return '无法取得稳定消息标识,本次写操作未执行。'
return receipt_text(await self.service.upgrade(who, slot, op))
image_actions = ('farm', 'plot_ids', 'details', 'shop',
'seeds', 'crops')
if action in image_actions and action != 'farm' and '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
quality = self.image_quality
text_mode = (action in ('seeds', 'crops') and args
and args[-1] == '文字')
if action in image_actions and self.rendering and not text_mode:
if action in ('seeds', 'crops') and '文字' in args:
raise ValueError()
if action in image_actions and self.rendering:
try:
if self.image_quality_provider is not None:
quality = self.image_quality_provider()
@@ -294,7 +404,6 @@ class Runtime:
)
if action in ('crops', 'seeds'):
title = '我的作物' if action == 'crops' else '我的种子'
mode = args.pop() if args and args[-1] == '文字' else ''
if len(args) > 1:
raise ValueError()
try:
@@ -307,7 +416,7 @@ class Runtime:
_, pages = warehouse_page(rows, page)
except ValueError:
return f'页码无效,请发送:{title} 1'
if not self.rendering or mode == '文字':
if not self.rendering:
return warehouse_text(rows, snapshot, action, page, pages)
return await self.rendering.warehouse(
rows, snapshot, action, page, pages, self.image_format,
@@ -323,7 +432,7 @@ class Runtime:
if not self.rendering:
if action == "plot_ids":
return plot_ids_text(await self.service.snapshot(who))
return f"请配置 {self.font_setting} 为可用中文字体路径。"
return "农场图片资源暂不可用,请检查素材和小赖字体的下载日志。"
now = self.service.clock()
theme = season_for(now)
logger.info("farm preview seasonal theme=%s", theme)
@@ -396,10 +505,10 @@ class Runtime:
async def help_menu(self, args):
if '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
if args not in ([], ['文字']):
return '参数有误,请发送:农场帮助 或 农场帮助 文字'
if args:
return '参数有误,请发送:农场帮助'
fallback = help_text(self.command_specs)
if args or self.rendering is None:
if self.rendering is None:
return fallback
quality = self.image_quality
if self.image_quality_provider is not None:
+43
View File
@@ -0,0 +1,43 @@
"""真寻和NoneBot的日志生命周期;AstrBot构建使用宿主专用实现。"""
import logging
def has_console_handler(target):
while target is not None:
if any(isinstance(handler, logging.StreamHandler)
and not isinstance(handler, logging.FileHandler)
and handler.level <= logging.INFO
for handler in target.handlers):
return True
target = target.parent if target.propagate else None
return False
def open_logs(data_dir):
target = logging.getLogger(__package__)
file_handler = logging.FileHandler(
data_dir / 'farm-preview.log', encoding='utf-8',
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s %(name)s %(message)s',
))
target.setLevel(logging.INFO)
target.addHandler(file_handler)
console_handler = None
if not has_console_handler(target):
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
'%(asctime)s [农场] %(message)s', datefmt='%H:%M:%S',
))
target.addHandler(console_handler)
return file_handler, console_handler
def close_logs(file_handler, console_handler):
target = logging.getLogger(__package__)
for handler in (file_handler, console_handler):
if handler is not None:
target.removeHandler(handler)
handler.close()
+1 -2
View File
@@ -3,7 +3,6 @@
from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from ._version import __version__
from .runtime import COMMANDS, HELP
__plugin_meta__ = PluginMetadata(
@@ -12,7 +11,7 @@ __plugin_meta__ = PluginMetadata(
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
supported_adapters={"~onebot.v11", "~qq"},
extra=PluginExtraData(
author="Shu-Ying", version=__version__, menu_type="群内小游戏",
author="Shu-Ying", version="__FARM_VERSION__", menu_type="群内小游戏",
commands=[Command(command=name) for name in COMMANDS],
configs=[RegisterConfig(
key="农场图片质量", value=75, default_value=75, type=int,
+7 -5
View File
@@ -10,7 +10,7 @@ from nonebot.params import CommandArg
require("nonebot_plugin_uninfo")
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.path_config import DATA_PATH, FONT_PATH
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
@@ -42,10 +42,12 @@ data_dir = data_directory(
)
runtime = Runtime(
data_dir,
getattr(driver.config, "farm_preview_font", None),
getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"),
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
font_dir=FONT_PATH,
deployment=getattr(
driver.config, "farm_preview_deployment", "zhenxun-preview",
),
image_format=str(getattr(
driver.config, "farm_preview_image_format", "AUTO",
)).upper(),
image_quality_provider=image_quality,
resource_options_provider=resource_options,
)
+2 -2
View File
@@ -4,6 +4,6 @@ from pathlib import Path
def data_directory(base, configured=None):
if configured is not None:
return Path(configured)
if configured:
return Path(configured).expanduser()
return Path(base) / 'farm_db'
+1
View File
@@ -1,3 +1,4 @@
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
# 与真寻当前pillow<11的约束取交集,不升级到12。
Pillow>=10.4,<11
rich>=13.9,<16
+9 -2
View File
@@ -12,7 +12,7 @@ license = {file = "LICENSE"}
dependencies = []
[project.optional-dependencies]
render = ["Pillow>=10.4,<13"]
render = ["Pillow>=10.4,<13", "rich>=13.9,<16"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -22,9 +22,16 @@ version = {file = "version.txt"}
[tool.setuptools.package-data]
farm_core = ["data/*.json"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md", "data/*.txt"]
[tool.ruff]
required-version = "==0.8.6"
line-length = 80
target-version = "py310"
indent-width = 4
extend-exclude = ["legacy", ".agents", ".comet"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
+16
View File
@@ -10,4 +10,20 @@
在生成的发布仓库和安装包中写为根目录 `README.md`。
文中的相对资源路径以生成的发布仓库根目录为准。
帮助菜单由 `scripts/preview_help.py` 根据当前命令表与绘图代码生成。
三平台工作流先生成各自菜单,再打包到发布仓库根目录的 `images/`:
`help-zhenxun.png`、`help-nonebot.png`、`help-astrbot.png`。
各自 README 使用 `./images/help-对应平台.png` 引用,不依赖外部图片地址。
本地直接运行构建脚本也会自动调用生成脚本,不复用旧菜单。
手动运行 `python scripts/preview_help.py` 默认刷新主仓库 `images/` 的三张图。
生成需要 Pillow,默认使用由 farm_service 分发并缓存的小赖字体;
可用 `--font` 或 `FARM_TEST_FONT` 指定字体。
本地存在同级 `farm_service/fonts/Xiaolai-Regular.ttf` 且摘要匹配时直接
使用;否则从 service 仓库下载并缓存。应先发布 service 字体资源,再发布插件。
CI 在临时工作目录生成,避免生成图片使开发源被标记为未提交变更。
后续发布文案请修改本目录;原位置的文档保留供历史参考。
## 致谢
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
+77 -73
View File
@@ -1,102 +1,106 @@
<div align="center">
# 真寻农场(zhenxun_plugin_farm)
<img src="./logo.png" width="400" alt="真寻农场 Logo">
<p align="center">
<img src="https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/logo-readme.png" width="400" alt="真寻农场 Logo">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
</a>
<a href="https://www.python.org">
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
</p>
# 农场 · AstrBot
<p align="center">
开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
[![tencent-qq](https://img.shields.io/badge/%E7%BE%A4-%E7%9C%9F%E5%AF%BB%E5%86%9C%E5%9C%BA%E6%B5%8B%E8%AF%95-%23FF99CC
)](https://qm.qq.com/q/7hsOD4rOw2)
![农场预览](https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/images/farm-preview.png)
</p>
## 安装与快速开始
你是说可以种地对吧🤔?
在 AstrBot 插件市场搜索 `astrbot_plugin_farm`,或在插件管理中选择
「从文件安装」,上传作者提供的完整插件 ZIP。不要只上传 `main.py`。
</div>
安装依赖后重载插件。按你配置的唤醒前缀发送指令,下面以 `/` 为例:
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
1. `/开通农场`:创建农场,获得初始6块土地。
2. `/种子商店`:查看种子名称、编号与价格。
3. `/购买种子 白萝卜 1`,然后 `/播种 白萝卜 1`。
4. `/我的农场` 查看状态,生长期间用 `/农场除虫` 清除虫害。
5. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
---
发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
## 农场界面
## 指令
![农场界面](./images/farm-preview.png)
`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
仓库默认图片,例如 `/我的种子 2 文字`。偷菜目标必须通过消息中的 @ 指定。
---
<!-- FARM_COMMANDS -->
## 如何安装
在Astrbot的插件商店下载即可
## 配置
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `font_path` | 空 | 中文字体路径;留空尝试系统字体,无可用字体时退回文字 |
| `data_dir` | 空 | 留空使用 `data/plugin_data/astrbot_plugin_farm`;更改不会自动搬迁存档 |
| `image_format` | `AUTO` | `AUTO` 为 JPEG;也可选 `JPEG` 或 `PNG` |
| `image_quality` | `75` | 整数1~95,下一次图片请求生效;PNG不受影响 |
| `resource_source`(资源获取地址) | `Github` | `Github`、`Gitea`、`farm_service`,重载生效 |
| `farm_service_url`(farm_service 地址) | 空 | 独立HTTPS基础地址,供资源及后续活动服务共用;选farm_service下载时必填 |
---
除图片质量外,配置变更后请重载插件。高清农场的图片质量不低于85。
依赖 Pillow 和 aiohttp,由插件依赖声明安装;插件不捆绑中文字体。
当前重点适配 QQ/OneBot(`aiocqhttp`),其他平台尚未完成真实环境验证。
## 农场帮助
![农场帮助](./images/help-astrbot.png)
插件不携带大型`assets.zip`。启动时校验数据目录下缓存素材的SHA256,
缺失或不匹配时从所选源下载并校验后原子替换;GitHub/Gitea均从
`farm_service`仓库根目录`assets/`获取。默认Github,不自动跨源切换;
下载失败保留文字功能,调整来源后重载重试。两项配置始终同时存在,
活动功能尚未实现。已有正确缓存时不联网。
---
离线部署可将服务仓库的`assets/assets.zip`放到农场数据目录
`assets/<SHA256>/assets.zip`;SHA见插件的`_vendor/farm_render/data/asset-manifest.json`。
请先提交/发布服务仓库的素材,再发布依赖它的插件;不要把素材ZIP放回插件仓库。
## 待办事宜 `Todo` 列表
## 玩法与旧账号兼容
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
- 新账号初始6块土地。`/开垦 条件` 查询下一块地的等级和费用,
`/开垦` 每次购买1块普通空地;未配置的地块不会扣款或放行。
- 可靠识别为初始3块地的旧账号,一次性追加3块普通空地,保留已有开垦成果、
作物、加成与资产。来源不明、编号异常或补后超过当前30个显示位置时,
保留原状并提示管理员处理,不截断、不折现。
- 新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。
已排定的旧生虫事件仍按原计划执行一次。
- 每只虫降低生长速度5%;成熟时每只虫使本季减产5%。成熟后除虫无法恢复
本季减产,请在生长期间及时除虫。
- 每日最多5次成功偷菜,北京时间零点重置;无可偷作物不消耗成功次数。
- 出售时自动跳过已锁定作物。仓库可使用作物编号操作。
---
## 升级与数据备份
## 关于
升级前备份 `data/plugin_data/astrbot_plugin_farm`;若自定义 `data_dir`,
请备份实际目录。数据库为 `farm.sqlite3`,日志为 `farm-preview.log`。
旧库迁移前建议先在副本中验证。更换机器人、平台实例或部署不会自动合并账号。
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
新版当前没有签到、改名、土地升级、点券商店、外部金币兑换及旧远程服务功能。
请以当前帮助菜单为准,不要套用旧1.x版本指令。缺少稳定消息ID时,
插件会拒绝写操作以避免重复扣款。离线包校验及模拟宿主测试不代表线上验证。
素材来均源于互联网,侵权请联系我删除
QQ/OneBot 图片查询会请求发送者的腾讯头像用于农场及详情图片;
请求失败使用默认头像,成功缓存5分钟、失败缓存30秒,最多缓存16项。
其他平台使用默认头像。存档按部署、平台实例、机器人及用户隔离。
---
## 更新日志与反馈
## 致谢
- [更新日志](https://github.com/Shu-Ying/astrbot_plugin_farm/blob/main/CHANGELOG.md)
- [反馈问题](https://github.com/Shu-Ying/astrbot_plugin_farm/issues)
- [插件商店](https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm)
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
代码许可见插件所附 LICENSE。角色图标为非官方二次创作,不表示获得官方授权;
角色与原作相关权利不因代码许可证而转移。
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 开发者本地构建
## 许可证
开发源为 Farm 主仓库。Windows 双击 `build_astrbot.cmd`,或运行
`python scripts/build_plugin.py astrbot`,构建后生成完整 ZIP 和校验文件。
每次使用独立输出目录,不安装或启动机器人、不自动推送远端。
版本来自 `version.txt`;命令表、元数据与展示文件由构建生成。
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+89 -59
View File
@@ -1,76 +1,106 @@
# NoneBot 农场
<div align="center">
<p align="center"><img src="./logo.png" width="400" alt="真寻农场 Logo"></p>
# 真寻农场(zhenxun_plugin_farm)
从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的
业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。
<img src="./logo.png" width="400" alt="真寻农场 Logo">
![农场预览](images/farm-preview.png)
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
</a>
<a href="https://www.python.org">
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
</p>
## 安装与配置
<p align="center">
需要 Python 3.10 及以上。生成仓库中执行 `pip install .`,或安装经过
标签发布的 `nonebot_plugin_farm` 包,然后在 NoneBot 启动脚本中加入:
[![tencent-qq](https://img.shields.io/badge/%E7%BE%A4-%E7%9C%9F%E5%AF%BB%E5%86%9C%E5%9C%BA%E6%B5%8B%E8%AF%95-%23FF99CC
)](https://qm.qq.com/q/7hsOD4rOw2)
```python
nonebot.load_plugin("nonebot_plugin_farm")
```
</p>
可在 `.env` 中设置:
你是说可以种地对吧🤔?
```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO
FARM_IMAGE_QUALITY=75
FARM_DEPLOYMENT=nonebot
FARM_RESOURCE_SOURCE=Github
FARM_SERVICE_URL=
```
</div>
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
`FARM_RESOURCE_SOURCE`(资源获取地址)支持`Github`、`Gitea`、`farm_service`,
默认`Github`。`FARM_SERVICE_URL`(farm_service 地址)始终独立存在,
供资源及后续活动功能共用;选择farm_service下载时须填写HTTPS基础地址。
两项修改后重载。插件不包含素材ZIP:每次启动校验本地SHA256,缺失或不匹配
才从`farm_service`仓库根目录`assets/assets.zip`下载,校验成功后原子替换。
失败时保留文字功能,不自动跨源切换,重载后重试。活动功能尚未实施。
---
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
启动时校验配置。只有“我的农场 高清”允许高清,使用1536宽,质量
取配置值与85的较大值;其他命令不接受高清。种子/作物仓库保留
分页和“文字”选项,例如“我的种子 2 文字”。
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
## 农场界面
## 当前命令
![农场界面](./images/farm-preview.png)
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
尚未在共享核心中实现,此版本不提供这些命令。
---
## 生成与发布
## 如何安装
主仓库 `version.txt` 是版本的唯一来源,生成 ZIP、包版本和清单。
`nonebot-preview.yml` 在 PR 中测试和构建,在主仓库 `main` 推送后
使用 `FARM_RELEASE_TOKEN` 同步到 `Shu-Ying/nonebot_plugin_farm/main`。
同步任务沿用真寻的 `zhenxun-preview` environment,兼容其中已有密钥。
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
目标仓库保留 `v*` 标签发布 PyPI 的方式和 `PYPI_TOKEN` 密钥。
普通同步只校验、构建,不发布 PyPI。手动推送标签前应确认版本未在
PyPI 使用,且标签去掉 `v` 后与 `version.txt` 完全一致。
接入时远端旧版为 1.5.7,主仓库为 1.5.4;用户计划后续统一改为
2.0.0,本次未提前修改。统一后发布标签应为 `v2.0.0`。
---
生成仓库的 `verify_package.py --directory .` 校验文件完整性;
`python -m build` 生成 wheel/sdist。远端 CI 和实际机器人消息行为
需要在部署环境进一步验证。
## 农场帮助
![农场帮助](./images/help-nonebot.png)
---
## 待办事宜 `Todo` 列表
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
---
## 关于
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
素材来均源于互联网,侵权请联系我删除
---
## 致谢
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./nonebot_plugin_farm/_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+15 -55
View File
@@ -4,12 +4,6 @@
<img src="./logo.png" width="400" alt="真寻农场 Logo">
> 资源配置:「资源获取地址」默认Github,可选Github、Gitea、farm_service;
> 「farm_service 地址」始终独立存在,供资源及后续活动服务共用。
> 选择farm_service下载时填写HTTPS基础地址,两项修改后重载。
> 插件不携带assets.zip,启动校验缓存SHA256,缺失或不匹配时从所选源获取;
> Github/Gitea均使用farm_service仓库根目录assets/。失败保留文字功能,重载重试。
> 活动服务尚未实现,请先发布服务仓库素材再发布插件。
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
@@ -20,12 +14,6 @@
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
<a href="https://nonebot.dev/">
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
</a>
<a href="https://github.com/zhenxun-org/zhenxun_bot">
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
</a>
</p>
<p align="center">
@@ -45,10 +33,7 @@
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [使用指令](#使用指令)
- [更新日志(详细):](#更新日志详细)
- [用户方面](#用户方面)
- [代码方面](#代码方面)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
@@ -65,55 +50,25 @@
## 如何安装
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法一(推荐):在小真寻后台的插件商店下载即可
方法二:下载源码放在小真寻`plugin`目录下
---
## 使用指令
| 指令 | 描述 | Tip |
| --- | --- | --- |
| @小真寻 开通农场 | 首次开通农场 | |
| 我的农场 | 你的农场 | |
| 农场详述 | 农场详细信息 | |
| 我的农场币 | 查询农场币 | |
| 种子商店 [筛选关键字] [页数] or [页数] | 查看种子商店 | 当第一个参数为非整数时,会默认进入筛选状态。页数不填默认为1 |
| 购买种子 [种子名称] [数量] | 购买种子 | 数量不填默认为1 |
| 我的种子 | 查询仓库种子 | |
| 播种 [种子名称] [数量] | 播种种子 | 数量不填默认将最大可能播种 |
| 收获 | 收获成熟作物 | |
| 铲除 | 铲除荒废作物 | |
| 我的作物 | 你的作物 | |
| 出售作物 [作物名称] [数量] | 从仓库里向系统售卖作物 | 不填写作物名将售卖仓库种全部作物 填作物名不填数量将指定作物全部出售 |
| 偷菜 @美波理 | 偷别人的菜 | 每人每天只能偷5次 |
| 购买农场币 | 将真寻金币兑换成农场币 | 兑换比例默认为1:2 手续费默认20% |
| 更改农场名 [新的农场名] | 改名 | 农场名称无法存储特殊字符 |
| 农场签到 | 签到 | 需要注意,该项会从服务器拉取签到数据 |
| 土地升级 [地块ID] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
---
## 更新日志[(详细)](./log/log.md):
用户方面
---
- 新增土地升级指令,快来将土地升级至红土地、黑土地、金土地吧
- 新增查漏补缺作物资源功能,自动更新最新作物和作物资源文件
- 定时更新签到文件、作物资源从00:30调整至04:30
- 修正了部分土地资源错误的情况
- 修正了部分文本信息错误的情况
## 农场帮助
![农场帮助](./images/help-zhenxun.png)
代码方面
---
- 修正部分事件连接机制
- 修正网络请求端口
## 待办事宜 `Todo` 列表
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 签到功能
- [x] 作物信息启动时检查更新(Service维护,失败回退已验证缓存)
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
@@ -134,16 +89,21 @@
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) * 绝大多数的素材来源 *
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [Client](https://github.com/Shu-Ying/main_plugin_farm)
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+3 -1
View File
@@ -1,4 +1,6 @@
include version.txt LICENSE README.md FARM-MANIFEST.json verify_package.py
include images/farm-preview.png
recursive-include nonebot_plugin_farm *.py *.json *.md
include images/help-nonebot.png
recursive-include nonebot_plugin_farm land-soils.zip
recursive-include nonebot_plugin_farm *.py *.json *.md *.txt
global-exclude assets.zip
+3 -3
View File
@@ -31,12 +31,12 @@ jobs:
raise SystemExit('Tag differs from version.txt')
PY
- run: python -m pip install build twine
- run: python -m build --sdist --wheel --outdir ../packages
- run: python -m twine check ../packages/*
- run: python -m build --sdist --wheel --outdir dist
- run: python -m twine check dist/*
- uses: actions/upload-artifact@v7
with:
name: pypi-distributions
path: ../packages/*
path: dist/*
if-no-files-found: error
publish:
needs: build
+3 -1
View File
@@ -16,6 +16,7 @@ dependencies = [
"nonebot-plugin-uninfo>=0.6,<1",
"nonebot-plugin-localstore>=0.7,<1",
"Pillow>=10.4,<13",
"rich>=13.9,<16",
]
[project.urls]
@@ -31,5 +32,6 @@ include = ["nonebot_plugin_farm*"]
[tool.setuptools.package-data]
"nonebot_plugin_farm._vendor.farm_core" = ["data/*.json"]
"nonebot_plugin_farm._vendor.farm_render" = [
"data/*.json", "data/*.md",
"data/*.json", "data/*.md", "data/land-soils.zip",
"data/*.txt",
]
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
python-version: '3.10'
- run: python verify_package.py --directory .
- run: python -m pip install build
- run: python -m build --sdist --wheel --outdir ../packages
- run: python -m build --sdist --wheel --outdir dist
- uses: actions/upload-artifact@v7
with:
name: nonebot-distributions
path: ../packages/*
path: dist/*
if-no-files-found: error
+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
},
{
+2
View File
@@ -0,0 +1,2 @@
# Keep in sync with tool.ruff.required-version in pyproject.toml.
ruff==0.8.6
+157 -64
View File
@@ -5,113 +5,206 @@ import ast
import hashlib
import json
import runpy
from pathlib import Path
import tempfile
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import build as build_shared, read_version, source_identity
from build_zhenxun import build as build_shared
from build_zhenxun import read_version, source_identity
from help_image import build_help_image
from format_generated import format_generated
from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
from verify_zhenxun import read_package as read_shared
def host_logging(content):
"""仅替换日志入口,保留共享业务源码和注释;未知用法交给包校验拒绝。"""
lines = content.decode("utf-8").splitlines(keepends=True)
edits = []
for node in ast.parse(content).body:
if isinstance(node, ast.Import) and any(
alias.name == "logging" for alias in node.names
):
if len(node.names) != 1 or node.names[0].asname:
raise ValueError("Unsupported logging import")
edits.append((node.lineno, node.end_lineno, ""))
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Attribute)
and isinstance(node.value.func.value, ast.Name)
and node.value.func.value.id == "logging"
and node.value.func.attr == "getLogger"
):
alias = node.targets[0].id
statement = "from astrbot.api import logger"
if alias != "logger":
statement += " as " + alias
edits.append((node.lineno, node.end_lineno, statement + "\n"))
for start, end, replacement in sorted(edits, reverse=True):
lines[start - 1 : end] = [replacement]
return "".join(lines).encode("utf-8")
def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->'
template = (root / "readme/astrbot.md").read_text(encoding="utf-8")
marker = "<!-- FARM_COMMANDS -->"
if marker not in template:
if "./images/help-astrbot.png" not in template:
raise ValueError("AstrBot README requires its help menu image")
return template.encode("utf-8")
if template.count(marker) != 1:
raise ValueError('AstrBot README requires one command marker')
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
specs = definitions['command_specs']('astrbot')
for group, rows in definitions['help_groups'](specs):
raise ValueError("AstrBot README requires one command marker")
definitions = runpy.run_path(str(root / "src/farm_core/commands.py"))
lines = ["| 分类 | 指令 | 说明 |", "| --- | --- | --- |"]
specs = definitions["command_specs"]("astrbot")
for group, rows in definitions["help_groups"](specs):
for usage, description in rows:
values = (group, f'`/{usage}`', description)
lines.append('| ' + ' | '.join(
value.replace('|', r'\|').replace('\n', ' ')
for value in values
) + ' |')
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
values = (group, f"`/{usage}`", description)
lines.append(
"| "
+ " | ".join(
value.replace("|", r"\|").replace("\n", " ")
for value in values
)
+ " |"
)
return template.replace(marker, "\n".join(lines)).encode("utf-8")
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
raise FileExistsError('Use a new output directory')
if output.is_relative_to(root) and not output.is_relative_to(root / 'dist'):
raise ValueError('In-project output must be inside dist')
raise FileExistsError("Use a new output directory")
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
with tempfile.TemporaryDirectory() as temporary:
shared = read_shared(build_shared(root, Path(temporary) / 'shared'))
files = {name: content for name, content in shared.items()
if name.startswith('_vendor/') or name in (
'_version.py', 'version.txt', 'LICENSE',
)}
source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt', 'CHANGELOG.md'):
shared = read_shared(build_shared(root, Path(temporary) / "shared"))
files = {
name: content
for name, content in shared.items()
if name.startswith("_vendor/")
or name
in (
"_version.py",
"version.txt",
"LICENSE",
)
if not name.endswith("/__main__.py")
}
source = root / "integrations/astrbot"
for name in (
"main.py",
"avatars.py",
"_conf_schema.json",
"requirements.txt",
"CHANGELOG.md",
):
files[name] = (source / name).read_bytes()
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
files['images/farm-preview.png'] = shared['images/farm-preview.png']
files['README.md'] = render_readme(root)
files['runtime.py'] = (
root / 'integrations/shared/runtime.py'
).read_bytes()
files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py'
files["logo.png"] = (root / "logo-astrbot.png").read_bytes()
files["logo-readme.png"] = (root / "logo.png").read_bytes()
files["images/farm-preview.png"] = shared["images/farm-preview.png"]
files["images/help-astrbot.png"] = build_help_image(
root,
"astrbot",
help_image,
)
files["README.md"] = render_readme(root)
files["runtime.py"] = (root / "integrations/shared/runtime.py").read_bytes()
files["runtime_logging.py"] = (source / "runtime_logging.py").read_bytes()
files["verify_package.py"] = (
root / "scripts/verify_astrbot.py"
).read_bytes()
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
files['metadata.yaml'] = (json.dumps({
'name': ROOT_NAME, 'display_name': '农场',
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
'version': version, 'author': 'Shu-Ying',
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
'social_link': 'https://github.com/Shu-Ying',
'tags': ['农场', '种植', '小游戏'],
'support_platforms': ['aiocqhttp'],
}, ensure_ascii=False, indent=2) + '\n').encode()
files["metadata.yaml"] = (
json.dumps(
{
"name": ROOT_NAME,
"display_name": "真寻农场",
"short_desc": "开通农场,体验种植、收获、偷菜与土地开垦。",
"desc": "农场种植小游戏,支持种子商店、分类仓库、作物交易、"
"除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。",
"version": version,
"author": "Shu-Ying",
"repo": "https://github.com/Shu-Ying/astrbot_plugin_farm",
"social_link": "https://github.com/Shu-Ying",
"tags": ["农场", "种植", "小游戏"],
"support_platforms": ["aiocqhttp"],
},
ensure_ascii=False,
indent=2,
)
+ "\n"
).encode()
for name, content in files.items():
if name.endswith('.py'):
if name.endswith(".py"):
files[name] = host_logging(content)
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
manifest = {
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
'version': version, 'source': source_identity(root),
'files': {name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())},
"schema": 1,
"target": "astrbot",
"plugin": ROOT_NAME,
"version": version,
"source": source_identity(root),
"files": {
name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())
},
}
files[MANIFEST] = (json.dumps(
manifest, ensure_ascii=False, sort_keys=True, indent=2,
) + '\n').encode()
files[MANIFEST] = (
json.dumps(
manifest,
ensure_ascii=False,
sort_keys=True,
indent=2,
)
+ "\n"
).encode()
verify_files(files)
output.mkdir(parents=True)
for name, content in sorted(files.items()):
target = output / 'repository' / name
target = output / "repository" / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
archive_path = output / f'{ROOT_NAME}-{version}.zip'
with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive:
archive_path = output / f"{ROOT_NAME}-{version}.zip"
with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
for name, content in sorted(files.items()):
item = ZipInfo(f'{ROOT_NAME}/{name}', (2020, 1, 1, 0, 0, 0))
item = ZipInfo(f"{ROOT_NAME}/{name}", (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.compress_type = ZIP_DEFLATED
item.external_attr = 0o100644 << 16
archive.writestr(item, content)
verify_package(archive_path)
if archive_path.stat().st_size > 16_000_000:
raise ValueError("AstrBot plugin ZIP exceeds 16 MB")
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix('.zip.sha256').write_text(
f'{digest} {archive_path.name}\n', encoding='ascii',
archive_path.with_suffix(".zip.sha256").write_text(
f"{digest} {archive_path.name}\n",
encoding="ascii",
)
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--help-image", type=Path)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
print(
build(
Path(__file__).resolve().parents[1],
args.output,
help_image=args.help_image,
)
)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+15 -3
View File
@@ -10,9 +10,11 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import read_version, source_identity
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
from format_generated import format_generated
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
@@ -20,6 +22,9 @@ def build(root, output):
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
files = {}
files['images/help-nonebot.png'] = build_help_image(
root, 'nonebot', help_image,
)
prefix = ROOT_NAME + "/"
integration = root / "integrations/nonebot"
for name in ("__init__.py", "config.py", "commands.py", "data_paths.py"):
@@ -28,12 +33,16 @@ def build(root, output):
files[prefix + "runtime.py"] = (
root / "integrations/shared/runtime.py"
).read_bytes()
files[prefix + 'runtime_logging.py'] = (
root / 'integrations/shared/runtime_logging.py'
).read_bytes()
files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
path for path in (source / "data").iterdir()
if path.suffix in (".json", ".md")
if path.suffix in (".json", ".md", ".zip", ".txt")
and path.name != 'assets.zip'
]
for path in selected:
if path.is_symlink():
@@ -59,6 +68,7 @@ def build(root, output):
}.items():
files[name] = (root / source).read_bytes()
files[prefix + "_version.py"] = f'__version__ = "{version}"\n'.encode()
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
@@ -96,8 +106,10 @@ def build(root, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument('--help-image', type=Path)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
print(build(Path(__file__).resolve().parents[1], args.output,
help_image=args.help_image))
if __name__ == "__main__":
+10 -2
View File
@@ -41,7 +41,13 @@ foreach ($farmCandidate in $farmCandidates) {
try {
$farmExe = $farmCandidate.Exe
$farmPrefix = $farmCandidate.Prefix
& $farmExe @farmPrefix -c 'import sys; sys.exit(sys.version_info < (3, 10))'
$farmProbe = 'import sys, importlib.util; sys.exit(' +
'sys.version_info < (3, 10) or ' +
'importlib.util.find_spec("PIL") is None or ' +
'importlib.util.find_spec("ruff") is None)'
# Windows PowerShell 5.1 rewrites quotes in native arguments.
# Use stdin instead of -c; keep this script ASCII for PS 5.1.
$farmProbe | & $farmExe @farmPrefix -
if ($LASTEXITCODE -eq 0) {
$farmPython = $farmCandidate
break
@@ -51,7 +57,9 @@ foreach ($farmCandidate in $farmCandidates) {
}
}
if (-not $farmPython) {
Write-Error 'Python 3.10+ not found. Install Python or set FARM_PYTHON.'
Write-Error ('Python 3.10+ with Pillow and Ruff not found. ' +
'Install Pillow and requirements-build.txt in the same Python, ' +
'or set FARM_PYTHON.')
exit 1
}
+20 -3
View File
@@ -10,6 +10,8 @@ import subprocess
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
from format_generated import format_generated
def source_identity(root):
@@ -33,7 +35,7 @@ def read_version(root):
return version
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
@@ -44,21 +46,34 @@ def build(root, output):
)):
raise ValueError("Output must not be a source directory")
files = {}
files['images/help-zhenxun.png'] = build_help_image(
root, 'zhenxun', help_image,
)
integration = root / "integrations/zhenxun"
for name in (
"__init__.py", "commands.py", "data_paths.py",
"requirements.txt", "config_migration.py",
):
files[name] = (integration / name).read_bytes()
# 真寻市场静态读取版本字段,生成时写入字面量而非运行时变量。
marker = b'"__FARM_VERSION__"'
if files['__init__.py'].count(marker) != 1:
raise ValueError('Zhenxun entry requires one version placeholder')
files['__init__.py'] = files['__init__.py'].replace(
marker, json.dumps(version).encode('ascii'),
)
files["runtime.py"] = (
root / "integrations/shared/runtime.py"
).read_bytes()
files['runtime_logging.py'] = (
root / 'integrations/shared/runtime_logging.py'
).read_bytes()
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
p for p in (source / "data").iterdir() if p.suffix in (
".json", ".zip", ".md",
".json", ".zip", ".md", ".txt",
) and p.name != 'assets.zip'
]
for path in selected:
@@ -83,6 +98,7 @@ def build(root, output):
}.items():
files[name] = source.read_bytes()
files["_version.py"] = f'__version__ = "{version}"\n'.encode()
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
@@ -121,9 +137,10 @@ def build(root, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument('--help-image', type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
print(build(root, args.output))
print(build(root, args.output, help_image=args.help_image))
if __name__ == "__main__":
+12 -6
View File
@@ -3,8 +3,13 @@
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 ROOTS, parse_manifest
def check(root):
data = Path(root) / 'src/farm_render/data'
@@ -19,12 +24,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()
+32
View File
@@ -64,6 +64,21 @@ def main():
for tokens in install_commands
):
raise ValueError("Full test suite requires AstrBot dependencies")
for platform, workflow in (
('zhenxun', source), ('nonebot', nonebot), ('astrbot', astrbot),
):
steps = workflow['jobs']['build']['steps']
generation = next((i for i, step in enumerate(steps)
if 'scripts/preview_help.py' in step.get('run', '')),
None)
build = next((i for i, step in enumerate(steps)
if f'scripts/build_{platform}.py'
in step.get('run', '')), None)
if generation is None or build is None or generation >= build:
raise ValueError('Help menu must be generated before packaging')
if (f'--platform {platform}' not in steps[generation]['run']
or f'help-{platform}.png' not in steps[build]['run']):
raise ValueError('Workflow help image platform mismatch')
if nonebot["permissions"] != {"contents": "read"}:
raise ValueError("NoneBot source permissions differ")
trigger = nonebot["on"]
@@ -82,6 +97,8 @@ def main():
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
raise ValueError("NoneBot must reuse FARM_RELEASE_TOKEN")
if '--release-tag' not in sync['steps'][-1]['run'].split():
raise ValueError('NoneBot sync must create its version release tag')
generated = yaml.load((
root / "release/nonebot/verify.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
@@ -98,6 +115,21 @@ def main():
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
)):
raise ValueError("NoneBot PyPI boundary differs")
for workflow, job in ((generated, 'verify'), (publishing, 'build')):
steps = workflow['jobs'][job]['steps']
if not any(step.get('run') == (
'python -m build --sdist --wheel --outdir dist'
) for step in steps):
raise ValueError('NoneBot distributions must build inside dist')
upload = next(step for step in steps
if step.get('uses', '').startswith(
'actions/upload-artifact@'))
if (upload['with']['path'] != 'dist/*'
or upload['with'].get('if-no-files-found') != 'error'):
raise ValueError('NoneBot upload path must be dist/*')
if not any(step.get('run') == 'python -m twine check dist/*'
for step in publishing['jobs']['build']['steps']):
raise ValueError('NoneBot distribution check must use dist/*')
steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret")
+15 -11
View File
@@ -1,21 +1,25 @@
"""为CI选择现有字体;部署仍须显式配置中文字体。"""
"""预览与CI复用 service 字体缓存,不将大字体打入插件。"""
import os
from pathlib import Path
import sys
import tempfile
def choose_font():
configured = os.environ.get("FARM_TEST_FONT")
candidates = ([configured] if configured else []) + [
"C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
]
for name in candidates:
if Path(name).is_file():
return name
raise RuntimeError("Set FARM_TEST_FONT to an existing font")
if configured:
if not Path(configured).is_file():
raise RuntimeError("FARM_TEST_FONT does not exist")
return configured
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root / "src"))
from farm_render.font_bundle import FILES, _valid, ensure_font
local = root.parent / "farm_service/fonts/Xiaolai-Regular.ttf"
if _valid(local, *FILES[local.name]):
return str(local)
cache = Path(tempfile.gettempdir()) / "farm-preview-resources"
return str(ensure_font(cache))
if __name__ == "__main__":
+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:
+30 -5
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)],
@@ -170,13 +171,37 @@ def export(root):
).encode("utf-8")
def export_soils(root):
"""小型升级土地图随插件内置,不改变既有外部素材包摘要。"""
output = BytesIO()
source = root / 'legacy/zhenxun_plugin_farm/resource'
with ZipFile(output, 'w', compression=ZIP_STORED) as bundle:
for name in ('红土地', '黑土地', '金土地'):
path = f'soil/{name}.png'
item = ZipInfo(path, (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.external_attr = 0o100644 << 16
bundle.writestr(item, (source / path).read_bytes())
return output.getvalue()
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()
files = zip(("assets.zip", "metadata.json"), export(root))
destination = root / "src/farm_render/data"
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 = args.output
for name, content in files:
path = destination / name
if args.check:
+70
View File
@@ -0,0 +1,70 @@
"""在生成清单前统一格式化内存中的发布代码,不修改开发源文件。"""
import importlib.util
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
def format_generated(root, files):
"""仅整理导入和代码格式;成功后一次性更新文件集合。"""
root = Path(root).resolve()
if importlib.util.find_spec("ruff") is None:
raise RuntimeError(
"Ruff is required to build plugins. Install with: "
"python -m pip install -r requirements-build.txt"
)
selected = {}
for name, content in files.items():
if not name.endswith(".py"):
continue
path = PurePosixPath(name)
if (
path.is_absolute()
or "\\" in name
or ":" in name
or any(part in ("", ".", "..") for part in name.split("/"))
):
raise ValueError("Unsafe generated Python path")
selected[name] = content
if not selected:
return
with tempfile.TemporaryDirectory(prefix="farm-ruff-") as temporary:
staging = Path(temporary)
for name, content in selected.items():
target = staging / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
common = ["--config", str(root / "pyproject.toml"), "--no-cache"]
for arguments in (
["check", "--select", "I", "--fix"],
["format"],
["check", "--select", "I"],
["format", "--check"],
):
result = subprocess.run(
[
sys.executable,
"-m",
"ruff",
*arguments,
*common,
str(staging),
],
cwd=root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
if result.returncode:
raise RuntimeError(
"Generated Python failed Ruff; install the pinned "
"requirements-build.txt dependencies.\n"
+ result.stdout
+ result.stderr
)
formatted = {name: (staging / name).read_bytes() for name in selected}
files.update(formatted)
+30
View File
@@ -0,0 +1,30 @@
"""构建期间调用独立帮助菜单脚本;不改变开发源或源码身份。"""
from io import BytesIO
from pathlib import Path
import subprocess
import sys
import tempfile
def build_help_image(root, platform, prepared=None):
if platform not in ('zhenxun', 'nonebot', 'astrbot'):
raise ValueError('Unknown help platform')
if prepared is None:
with tempfile.TemporaryDirectory() as temporary:
subprocess.run([
sys.executable, str(Path(root) / 'scripts/preview_help.py'),
'--platform', platform, '--output', temporary,
], check=True, stdout=subprocess.PIPE, timeout=60)
content = (Path(temporary) / f'help-{platform}.png').read_bytes()
else:
path = Path(prepared)
if path.name != f'help-{platform}.png':
raise ValueError('Help image belongs to a different platform')
content = path.read_bytes()
from PIL import Image
with Image.open(BytesIO(content)) as image:
if image.format != 'PNG' or image.width != 1200:
raise ValueError('Expected generated PNG help menu at width 1200')
image.verify()
return content
+58
View File
@@ -0,0 +1,58 @@
"""从当前命令注册表离线生成帮助菜单图片,不访问玩家数据库。"""
import argparse
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from ci_font import choose_font
from farm_core.commands import command_specs
from farm_render import FarmRenderer
from farm_render.help import render_help
PLATFORMS = ('zhenxun', 'nonebot', 'astrbot')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'--platform', choices=(*PLATFORMS, 'all'), default='all',
help='目标平台;默认一次生成三个平台的菜单。',
)
parser.add_argument(
'--output', type=Path, default=ROOT / 'images',
help='图片输出目录;默认主仓库 images。',
)
parser.add_argument('--font', type=Path, help='中文字体文件路径。')
parser.add_argument(
'--format', choices=('PNG', 'JPEG', 'WEBP'), default='PNG',
type=str.upper, help='默认PNG无损图片。',
)
parser.add_argument('--quality', type=int, default=75,
help='JPEG/WebP质量,1~95;PNG不受影响。')
args = parser.parse_args(argv)
if not 1 <= args.quality <= 95:
parser.error('--quality 必须为1~95的整数')
try:
renderer = FarmRenderer(str(args.font) if args.font else choose_font())
except (OSError, ValueError, RuntimeError) as error:
parser.error(f'无法加载字体或素材,请用 --font 指定中文字体:{error}')
platforms = PLATFORMS if args.platform == 'all' else (args.platform,)
extension = {'PNG': 'png', 'JPEG': 'jpg', 'WEBP': 'webp'}[args.format]
args.output.mkdir(parents=True, exist_ok=True)
for platform in platforms:
picture = render_help(
renderer, command_specs(platform), platform,
image_format=args.format, quality=args.quality,
)
target = args.output / f'help-{platform}.{extension}'
target.write_bytes(picture)
print(f'{target.resolve()} ({len(picture)} bytes)')
if __name__ == '__main__':
main()
+34 -4
View File
@@ -4,6 +4,7 @@ import argparse
import base64
import json
import os
import re
from urllib.error import HTTPError
from urllib.request import Request, urlopen
@@ -37,13 +38,19 @@ def github(method, path, payload, token, allow_missing=False):
) from None
def publish(package, confirmed=False, request=None):
def publish(package, confirmed=False, request=None, *, release_tag=False):
files = read_package(package)
manifest = verify_files(files)
if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?',
manifest['version']):
raise ValueError('Invalid release version in version.txt')
message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "message": message,
"published": False}
tag = 'v' + manifest['version']
if release_tag:
result.update(tag=tag, tag_created=False)
if not confirmed:
return result
if manifest["source"]["dirty"]:
@@ -53,10 +60,23 @@ def publish(package, confirmed=False, request=None):
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
call = request or (lambda method, path, data: github(
method, path, data, token,
allow_missing=path.startswith(("git/ref/heads/", "contents/")),
allow_missing=path.startswith((
"git/ref/heads/", "git/ref/tags/", "contents/",
)),
))
if not call("GET", "", None):
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
if release_tag and call('GET', f'git/ref/tags/{tag}', None):
owned = call('GET', f'contents/FARM-MANIFEST.json?ref={tag}', None)
if owned is None or owned.get('encoding') != 'base64':
raise ValueError('Existing release is not managed; bump version.txt')
previous = json.loads(base64.b64decode(owned['content']))
if (previous.get('target') != 'nonebot'
or previous.get('plugin') != manifest['plugin']
or previous.get('version') != manifest['version']
or previous.get('files') != manifest['files']):
raise ValueError('Release tag has different files; bump version.txt')
return {**result, 'tag_reused': True}
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
parent = ref["object"]["sha"] if ref else None
if parent:
@@ -92,15 +112,25 @@ def publish(package, confirmed=False, request=None):
call("POST", "git/refs", {
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
})
return {**result, "published": True, "commit": commit["sha"]}
result.update(published=True, commit=commit['sha'])
if release_tag:
# 仅创建不可变标签;失败保留已同步main,重跑时重新检查,不移动旧标签。
call('POST', 'git/refs', {
'ref': f'refs/tags/{tag}', 'sha': commit['sha'],
})
result['tag_created'] = True
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package")
parser.add_argument("--publish", action="store_true")
parser.add_argument('--release-tag', action='store_true',
help='同步后创建v版本标签,触发目标仓库PyPI发布')
args = parser.parse_args()
print(json.dumps(publish(args.package, args.publish), ensure_ascii=False))
print(json.dumps(publish(args.package, args.publish,
release_tag=args.release_tag), ensure_ascii=False))
if __name__ == "__main__":
+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'])
+36 -2
View File
@@ -1,6 +1,7 @@
"""AstrBot插件ZIP的独立完整性校验;只使用标准库。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path, PurePosixPath
@@ -37,13 +38,46 @@ def verify_files(files):
safe_path(name)
if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError('Package checksum mismatch')
if name.endswith('/__main__.py'):
raise ValueError('Development CLI must not be in AstrBot plugin')
if name.endswith('.py'):
tree = ast.parse(files[name], filename=name)
host_loggers = {
alias.asname or alias.name
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module == 'astrbot.api'
for alias in node.names if alias.name == 'logger'
}
for node in ast.walk(tree):
if (isinstance(node, ast.Import) and any(
alias.name.split('.')[0] == 'logging'
for alias in node.names)) or (
isinstance(node, ast.ImportFrom)
and (node.module or '').split('.')[0] == 'logging'):
raise ValueError('AstrBot plugin must not import logging')
if isinstance(node, ast.Attribute) and node.attr in {
'basicConfig', 'FileHandler', 'StreamHandler',
'getLogger', 'setLevel', 'addHandler', 'removeHandler'}:
raise ValueError('AstrBot must own logging configuration')
if (isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id in {'logger', 'LOGGER'}
and node.func.value.id not in host_loggers):
raise ValueError(
'AstrBot logger must come from astrbot.api',
)
required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json',
'runtime.py', 'README.md', 'CHANGELOG.md', 'logo.png',
'runtime.py', 'runtime_logging.py', 'README.md',
'CHANGELOG.md', 'logo.png',
'logo-readme.png',
'images/farm-preview.png',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
'_vendor/farm_core/__init__.py',
'_vendor/farm_render/data/asset-manifest.json'}
'_vendor/farm_render/data/asset-manifest.json',
'_vendor/farm_render/data/font-manifest.json',
'_vendor/farm_render/data/OFL-Xiaolai.txt'}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
+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",
+2
View File
@@ -42,6 +42,8 @@ def verify_files(files):
"nonebot_plugin_farm/commands.py", "nonebot_plugin_farm/runtime.py",
"nonebot_plugin_farm/_vendor/farm_core/__init__.py",
"nonebot_plugin_farm/_vendor/farm_render/data/asset-manifest.json",
"nonebot_plugin_farm/_vendor/farm_render/data/font-manifest.json",
"nonebot_plugin_farm/_vendor/farm_render/data/OFL-Xiaolai.txt",
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
}
if not required <= set(files):
+14 -1
View File
@@ -1,6 +1,7 @@
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path, PurePosixPath
@@ -38,11 +39,23 @@ def verify_files(files):
raise ValueError("Package checksum mismatch")
required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt",
"LICENSE", "_vendor/farm_core/__init__.py",
"_vendor/farm_render/data/asset-manifest.json"}
"_vendor/farm_render/data/asset-manifest.json",
"_vendor/farm_render/data/font-manifest.json",
"_vendor/farm_render/data/OFL-Xiaolai.txt"}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
raise ValueError("Incomplete plugin")
tree = ast.parse(files['__init__.py'])
versions = [
keyword.value for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'PluginExtraData'
for keyword in node.keywords if keyword.arg == 'version'
]
if (len(versions) != 1 or not isinstance(versions[0], ast.Constant)
or versions[0].value != manifest['version']):
raise ValueError('PluginExtraData requires matching literal version')
return manifest
+43
View File
@@ -13,6 +13,7 @@ from .ports import Store
from .pests import advance_plot, start_growth
from .yield_rules import season_yield, remaining_yield
from .land import COMPENSATION_ID, SUPPORTED_PLOTS, next_rule, owned_count
from .land import LAND_NAMES, upgrade_rule
LOGGER = logging.getLogger(__name__)
@@ -115,6 +116,48 @@ class FarmService:
return await self._execute(identity, operation_id, 'reclaim', {},
operation)
async def upgrade_conditions(self, identity: Identity, slot: int):
snapshot = await self.snapshot(identity)
plot, level, rule = upgrade_rule(snapshot.plots, slot)
return {
'slot': slot, 'land_name': LAND_NAMES[level],
'current_land': LAND_NAMES[plot.land_level],
'ordinal': rule.ordinal, 'required_level': rule.required_level,
'coin_cost': rule.coin_cost, 'level': snapshot.account.level,
'point': snapshot.account.point,
}
async def upgrade(self, identity: Identity, slot: int, operation_id: str):
integer(slot, 1, SUPPORTED_PLOTS)
def operation(repository, now):
account = repository.account()
plot, level, rule = upgrade_rule(repository.plots(), slot)
if account.level < rule.required_level:
raise FarmError('level_required')
repository.asset(Asset.POINT, -rule.coin_cost)
plot = advance_plot(plot, now)
matured = (plot.plant is not None and not plot.withered
and plot.mature_at is not None and plot.mature_at > now)
if matured:
plot = replace(
plot, mature_at=now, growth_remaining=0,
growth_updated_at=now, next_pest_at=None,
maturity_pests=plot.pest_count,
)
repository.save_plot(replace(
plot, land_level=level, yield_bonus=(0, 10, 20, 28)[level],
experience_bonus=28 if level == 3 else 0,
))
return self._result(
repository, slot=slot, land_level=level,
land_name=LAND_NAMES[level], cost=rule.coin_cost,
ordinal=rule.ordinal, matured=matured,
)
return await self._execute(identity, operation_id, 'upgrade',
{'slot': slot}, operation)
async def register(
self, identity: Identity, name: str, operation_id: str,
):
+116
View File
@@ -0,0 +1,116 @@
"""作物发布协议的纯离线校验;不联网、不依赖绘图库。"""
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}')
ROOTS = {
'Github': 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/catalogue',
'Gitea': 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/catalogue',
}
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
+159 -67
View File
@@ -16,71 +16,160 @@ class CommandSpec:
COMMAND_SPECS = (
CommandSpec('开通农场', 'register', '开通与查看', '开通农场 [名称]',
'首次开通获得6块土地,可指定农场名称。'),
CommandSpec('我的农场', 'farm', '开通与查看', '我的农场 [高清]',
'查看农场全景;仅我的农场支持高清。'),
CommandSpec('农场详述', 'details', '开通与查看', '农场详述 [页码]',
'查看各地块的作物、生长和虫害信息。'),
CommandSpec('地块编号', 'plot_ids', '开通与查看', '地块编号',
'查看已开垦土地编号及待开垦位置。'),
CommandSpec('我的农场币', 'inventory', '开通与查看', '我的农场币',
'查看农场币、点券等资产。'),
CommandSpec('我的点券', 'inventory', '开通与查看', '我的点券',
'查看点券等资产。'),
CommandSpec('种子商店', 'shop', '种子与种植', '种子商店 [页码]',
'查看种子价格、等级要求与作物编号。'),
CommandSpec('购买种子', 'buy', '种子与种植', '购买种子 作物 数量',
'购买指定数量的种子。'),
CommandSpec('播种', 'sow', '种子与种植', '播种 作物 [数量]',
'使用库存种子播种到空地。'),
CommandSpec('收获', 'harvest', '田间管理与扩建', '收获',
'收获成熟作物,获得作物与经验。'),
CommandSpec('农场除虫', 'clear_pests', '田间管理与扩建', '农场除虫',
'清除虫害;生长期间及时除虫。', aliases=('除虫',)),
CommandSpec('铲除', 'eradicate', '田间管理与扩建', '铲除',
'清理枯萎作物,腾出土地。'),
CommandSpec('开垦', 'reclaim', '田间管理与扩建', '开垦',
'满足等级和费用后,新增1块普通空地。',
extra=(('开垦 条件', '查询下一块土地的等级及费用。'),)),
CommandSpec('我的种子', 'seeds', '仓库与出售',
'我的种子 [页码] [文字]',
'查看种子库存;默认图片,追加「文字」切换。'),
CommandSpec('我的作物', 'crops', '仓库与出售',
'我的作物 [页码] [文字]',
'查看作物库存;默认图片,追加「文字」切换。'),
CommandSpec('出售作物', 'sell', '仓库与出售',
'出售作物 [作物] [数量]',
'出售作物获得农场币;省略作物时出售全部未锁定作物。'),
CommandSpec('作物加锁', 'lock', '仓库与出售', '作物加锁 作物',
'锁定作物,出售时自动跳过。'),
CommandSpec('作物解锁', 'unlock', '仓库与出售', '作物解锁 作物',
'解除指定作物的锁定。'),
CommandSpec('偷菜', 'steal', '好友互动', '偷菜 @目标',
'每日最多5次成功偷取,北京时间零点重置。'),
CommandSpec('农场帮助', 'help', '使用帮助', '农场帮助 [文字]',
'查看当前平台菜单;追加「文字」查看文字版。'),
CommandSpec(
"开通农场",
"register",
"开通与查看",
"开通农场 [名称]",
"欢迎开通农场。",
),
CommandSpec(
"我的农场",
"farm",
"开通与查看",
"我的农场 [高清]",
"查看农场全景。",
),
CommandSpec(
"农场详述",
"details",
"开通与查看",
"农场详述 [页码]",
"查看各地块的作物、生长和虫害信息。",
),
CommandSpec(
"地块编号",
"plot_ids",
"开通与查看",
"地块编号",
"查看已开垦土地编号及待开垦位置。",
),
CommandSpec(
"种子商店",
"shop",
"种子与种植",
"种子商店 [页码]",
"查看种子价格、等级要求与作物编号。",
),
CommandSpec(
"购买种子",
"buy",
"种子与种植",
"购买种子 作物 数量",
"购买指定数量的种子。",
),
CommandSpec(
"播种",
"sow",
"种子与种植",
"播种 作物 [数量]",
"使用库存种子播种到空地。",
),
CommandSpec(
"收获",
"harvest",
"田间管理与扩建",
"收获",
"收获成熟作物,获得作物与经验。",
),
CommandSpec(
"农场除虫",
"clear_pests",
"田间管理与扩建",
"农场除虫",
"清除虫害;生长期间及时除虫。",
aliases=("除虫",),
),
CommandSpec(
"铲除",
"eradicate",
"田间管理与扩建",
"铲除",
"清理枯萎作物,腾出土地。",
),
CommandSpec(
"开垦",
"reclaim",
"田间管理与扩建",
"开垦",
"满足等级和费用后,新增1块普通空地。",
extra=(("开垦 条件", "查询下一块土地的等级及费用。"),),
),
CommandSpec(
"土地升级",
"upgrade",
"田间管理与扩建",
"土地升级 地块编号",
"逐级升为红、黑、金土地,并使当前季作物立即成熟。",
extra=(("土地升级 地块编号 条件", "查询该地块的升级等级与费用。"),),
),
CommandSpec(
"我的种子",
"seeds",
"仓库与出售",
"我的种子 [页码]",
"查看种子库存。",
),
CommandSpec(
"我的作物",
"crops",
"仓库与出售",
"我的作物 [页码]",
"查看作物库存。",
),
CommandSpec(
"出售作物",
"sell",
"仓库与出售",
"出售作物 [作物] [数量]",
"出售作物获得农场币;省略作物时出售全部未锁定作物。",
),
CommandSpec(
"作物加锁",
"lock",
"仓库与出售",
"作物加锁 作物",
"锁定作物,出售时自动跳过。",
),
CommandSpec(
"作物解锁",
"unlock",
"仓库与出售",
"作物解锁 作物",
"解除指定作物的锁定。",
),
CommandSpec(
"偷菜",
"steal",
"好友互动",
"偷菜 @目标",
"每日最多5次成功偷取。",
),
CommandSpec(
"农场帮助",
"help",
"使用帮助",
"农场帮助",
"查看当前平台菜单。",
),
)
HELP_NOTES = (
'[参数] 表示可选;作物支持名称或 #编号,同名作物请使用编号。',
'示例:我的种子 2 文字;购买种子 #编号 5;播种 #编号 3。',
'仅我的农场支持高清;其他图片使用统一图片质量配置。',
'新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。',
'每只虫降低生长速度5%;成熟时每只虫使本季减产5%。',
'成熟后除虫不能恢复本季减产,请在生长期间及时除虫。',
'每播种1块地或铲除1块枯萎地获得2经验;每清除1只虫获得1经验。',
'收获经验按作物结算;农场背景按北京时间自动切换季节。',
'旧版数据首次使用时迁移,绑定当前机器人和平台。',
"[参数] 表示可选;作物支持名称或 #编号,同名作物请使用编号。",
"示例:我的种子 2 文字;购买种子 #编号 5;播种 #编号 3。",
"每只虫降低生长速度5%;成熟时每只虫使本季减产5%。",
"成熟后除虫不能恢复本季减产,请在生长期间及时除虫。",
)
def command_specs(platform, specs=None):
if platform not in ('zhenxun', 'nonebot', 'astrbot'):
raise ValueError('Unknown farm framework')
return tuple(spec for spec in (
COMMAND_SPECS if specs is None else specs
) if not spec.platforms or platform in spec.platforms)
if platform not in ("zhenxun", "nonebot", "astrbot"):
raise ValueError("Unknown farm framework")
return tuple(
spec
for spec in (COMMAND_SPECS if specs is None else specs)
if not spec.platforms or platform in spec.platforms
)
def command_map(platform, specs=None):
@@ -88,7 +177,7 @@ def command_map(platform, specs=None):
for spec in command_specs(platform, specs):
for name in (spec.name, *spec.aliases):
if name in result:
raise ValueError('Duplicate farm command')
raise ValueError("Duplicate farm command")
result[name] = spec.action
return result
@@ -98,17 +187,20 @@ def help_groups(specs):
for spec in specs:
description = spec.description
if spec.aliases:
description += ' 别名:' + '、'.join(spec.aliases) + '。'
groups.setdefault(spec.group, []).extend((
(spec.usage, description), *spec.extra,
))
description += " 别名:" + "、".join(spec.aliases) + "。"
groups.setdefault(spec.group, []).extend(
(
(spec.usage, description),
*spec.extra,
)
)
return tuple((title, tuple(rows)) for title, rows in groups.items())
def help_text(specs):
lines = ['农场帮助']
lines = ["真寻农场帮助"]
for title, rows in help_groups(specs):
lines.append('\n【' + title + '】')
lines.append("\n【" + title + "】")
for usage, description in rows:
lines.extend((usage, ' ' + description))
return '\n'.join((*lines, '\n【参数与玩法】', *HELP_NOTES))
lines.extend((usage, " " + description))
return "\n".join((*lines, "\n【参数与玩法】", *HELP_NOTES))
@@ -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
}
+2
View File
@@ -214,8 +214,10 @@ class Plot:
next_pest_at: int | None = None # 下一次生虫时间,最多每季三次。
maturity_pests: int = 0 # 成熟瞬间冻结的虫数,决定本季减产。
stolen_count: int = 0 # 本季已被偷走数量;收获或铲除后归零。
land_level: int = 0 # 0普通、1红、2黑、3金;独立于作物生命周期。
def __post_init__(self):
integer(self.land_level, 0, 3)
integer(self.stolen_count)
integer(self.pest_count, 0, 3)
integer(self.pest_spawned, self.pest_count, 3)
+43
View File
@@ -11,6 +11,8 @@ from .domain import FarmError, integer
SUPPORTED_PLOTS = 30 # 当前布局支持的位置数,不代表永久土地总上限。
COMPENSATION_ID = 'initial-plots-3-to-6:v1' # 每个账号唯一的补偿操作。
LAND_NAMES = ('普通土地', '红土地', '黑土地', '金土地')
LAND_TYPES = ('normal', 'red', 'black', 'gold')
@dataclass(frozen=True)
@@ -58,3 +60,44 @@ def next_rule(plots):
if rule is None:
raise FarmError('land_rule_unavailable')
return rule
@lru_cache(maxsize=1)
def upgrade_rules():
data = json.loads(resources.files(__package__).joinpath(
'data', 'land_rules.json',
).read_bytes())
rules = {}
try:
for entry in data['upgrades']:
level = LAND_TYPES.index(entry['land_type'])
if level == 0 or entry['from_land_type'] != LAND_TYPES[level-1]:
raise ValueError('Invalid upgrade chain')
for row in entry['rows']:
rule = ExpansionRule(
integer(row['ordinal'], 1, SUPPORTED_PLOTS),
integer(row['required_level']),
integer(row['coin_cost'], 1),
)
key = (level, rule.ordinal)
if key in rules:
raise ValueError('Duplicate upgrade ordinal')
rules[key] = rule
return MappingProxyType(rules)
except (KeyError, TypeError, ValueError) as error:
raise FarmError('land_rules_invalid') from error
def upgrade_rule(plots, slot):
integer(slot, 1, SUPPORTED_PLOTS)
plot = next((p for p in plots if p.slot == slot), None)
if plot is None:
raise FarmError('unknown_plot')
level = plot.land_level + 1
if level >= len(LAND_TYPES):
raise FarmError('land_max_level')
ordinal = 1 + sum(p.land_level >= level for p in plots)
rule = upgrade_rules().get((level, ordinal))
if rule is None:
raise FarmError('land_rule_unavailable')
return plot, level, rule
+1 -1
View File
@@ -52,7 +52,7 @@ def legacy_plot(row, catalogue):
slot = integer(row['soilIndex'], 1, 10_000)
level = integer(row['soilLevel'], 0, 3)
bonuses = dict(yield_bonus=(10, 10, 20, 28)[level],
experience_bonus=28 if level == 3 else 0)
experience_bonus=28 if level == 3 else 0, land_level=level)
planted = row['isSoilPlanted']
if planted not in (None, 0, 1):
raise FarmError('legacy_data_invalid')
+2 -1
View File
@@ -6,8 +6,9 @@ from .domain import Plot, integer
def season_duration(plot: Plot) -> int:
return (plot.plant.growth_seconds if plot.harvest_count == 0
base = (plot.plant.growth_seconds if plot.harvest_count == 0
else plot.plant.regrowth_seconds)
return max(1, (base * (80 if plot.land_level >= 2 else 100) + 99) // 100)
def spawn_interval(plot: Plot) -> int:
+57 -34
View File
@@ -4,33 +4,49 @@ from .domain import Receipt, Snapshot
def receipt_text(receipt: Receipt) -> str:
if receipt.action == 'steal':
if receipt.action == "steal":
data = receipt.data
items = data['items']
lines = [f"偷菜成功:{len(items)}块地,共{data['quantity']}个。"
if items else '本次未偷到作物,不消耗次数。']
items = data["items"]
lines = [
f"偷菜成功:{len(items)}块地,共{data['quantity']}个。"
if items
else "本次未偷到作物,不消耗次数。"
]
grouped = {}
for item in items:
key = (item['plant_id'], item['name'])
grouped[key] = grouped.get(key, 0)+item['quantity']
lines.extend(f'{name}(#{pid})×{amount}'
for (pid, name), amount in grouped.items())
reasons = {'already_stolen': '本季已偷过',
'protected': '防偷保护', 'not_ripe': '无成熟作物'}
notes = [f'{reasons[key]} {count}块'
for key, count in data['skipped'].items() if count]
key = (item["plant_id"], item["name"])
grouped[key] = grouped.get(key, 0) + item["quantity"]
lines.extend(
f"{name}(#{pid})×{amount}"
for (pid, name), amount in grouped.items()
)
reasons = {
"already_stolen": "本季已偷过",
"protected": "防偷保护",
"not_ripe": "无成熟作物",
}
notes = [
f"{reasons[key]} {count}块"
for key, count in data["skipped"].items()
if count
]
if notes:
lines.append('跳过:'+';'.join(notes))
lines.append("跳过:" + ";".join(notes))
lines.append(f"今日剩余:{data['remaining_attempts']}/5次(北京时间)")
return '\n'.join(lines)
return "\n".join(lines)
labels = {
"register": "注册", "buy": "购买", "sow": "播种",
"harvest": "收获", "eradicate": "铲除",
"lock": "锁定设置", "sell": "出售",
"clear_pests": "除虫", "reclaim": "开垦",
"register": "注册",
"buy": "购买",
"sow": "播种",
"harvest": "收获",
"eradicate": "铲除",
"lock": "锁定设置",
"sell": "出售",
"clear_pests": "除虫",
"reclaim": "开垦",
"upgrade": "土地升级",
}
data = receipt.data
account = data["account"]
lines = [f"{labels[receipt.action]}已完成"]
if "created" in data:
lines.append("首次开通" if data["created"] else "农场已存在")
@@ -38,21 +54,23 @@ def receipt_text(receipt: Receipt) -> str:
lines.append(f"实际数量:{data['quantity']}")
if "proceeds" in data:
lines.append(f"本次收入:{data['proceeds']}")
if "experience" in data:
if data.get("experience", 0):
lines.append(f"本次经验:+{data['experience']}")
if receipt.action == 'reclaim':
if receipt.action == "reclaim":
lines.append(f"新增地块:{data['slot']}号普通空地")
lines.append(f"本次消耗:{data['cost']}农场币")
lines.append(f"当前土地:{data['plot_count']}块")
if receipt.action == "upgrade":
lines.append(f"地块{data['slot']}已升为{data['land_name']}")
lines.append(f"本次消耗:{data['cost']}农场币")
if data["matured"]:
lines.append("当前季作物已成熟,请发送「收获」。")
for item in data.get("harvested", ()):
if item.get("yield_loss_percent", 0):
lines.append(
f"地块{item['slot']}:成熟时虫子{item['pests']}只,"
f"本季减产{item['yield_loss_percent']}%"
)
lines.append(f"农场币:{account['point']}")
lines.append(f"点券:{account['vip_point']}")
lines.append(f"累计经验:{account['experience']}")
return "\n".join(lines)
@@ -60,21 +78,26 @@ def plot_ids_text(snapshot: Snapshot) -> str:
"""无图片能力时按实际快照返回地块编号。"""
owned = sorted(plot.slot for plot in snapshot.plots)
remaining = sorted(set(range(1, 31)) - set(owned))
return '\n'.join((
'地块编号',
f'已开垦({len(owned)}块):' + ('、'.join(map(str, owned)) or '无'),
f'待开垦({len(remaining)}块):'
+ ('、'.join(map(str, remaining)) or '无'),
'前两排交错编号,之后逐排推进。',
'发送「农场详述」查看各地块的作物信息。',
))
return "\n".join(
(
"地块编号",
f"已开垦({len(owned)}块):"
+ ("、".join(map(str, owned)) or "无"),
f"待开垦({len(remaining)}块):"
+ ("、".join(map(str, remaining)) or "无"),
"前两排交错编号,之后逐排推进。",
"发送「农场详述」查看各地块的作物信息。",
)
)
def snapshot_text(snapshot: Snapshot) -> str:
account = snapshot.account
lines = [
account.name, f"等级:{account.level}",
f"农场币:{account.point}", f"点券:{account.vip_point}",
account.name,
f"等级:{account.level}",
f"农场币:{account.point}",
f"点券:{account.vip_point}",
f"累计经验:{account.experience}",
]
for item in snapshot.inventory:
+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()
+4 -3
View File
@@ -170,7 +170,7 @@ class SQLiteRepository:
r["yield_bonus"], r["experience_bonus"],
r["pest_count"], r["pest_spawned"], r["growth_remaining"],
r["growth_updated_at"], r["next_pest_at"],
r["maturity_pests"], r['stolen_count'],
r["maturity_pests"], r['stolen_count'], r['land_level'],
)
for r in rows
)
@@ -186,13 +186,14 @@ class SQLiteRepository:
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
"yield_bonus=?, experience_bonus=?, pest_count=?, pest_spawned=?, "
"growth_remaining=?, growth_updated_at=?, next_pest_at=?, "
"maturity_pests=?, stolen_count=? WHERE identity=? AND slot=?",
"maturity_pests=?, stolen_count=?, land_level=? "
"WHERE identity=? AND slot=?",
(
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
plot.experience_bonus, plot.pest_count, plot.pest_spawned,
plot.growth_remaining, plot.growth_updated_at,
plot.next_pest_at, plot.maturity_pests, plot.stolen_count,
self._identity, plot.slot,
plot.land_level, self._identity, plot.slot,
),
)
if cursor.rowcount != 1:
+81 -4
View File
@@ -16,7 +16,7 @@ from .catalogue import Catalogue
LOGGER = logging.getLogger(__name__)
APPLICATION_ID = 0x4641524D
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
PEST_COLUMNS = {
"pest_count": (
@@ -73,6 +73,7 @@ SCHEMA = (
yield_bonus INTEGER NOT NULL DEFAULT 0,
experience_bonus INTEGER NOT NULL DEFAULT 0,
stolen_count INTEGER NOT NULL DEFAULT 0 CHECK(stolen_count>=0),
land_level INTEGER NOT NULL DEFAULT 0 CHECK(land_level BETWEEN 0 AND 3),
""" + ",\n ".join(
f"{name} {definition}" for name, definition in PEST_COLUMNS.items()
) + """,
@@ -102,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")
@@ -250,6 +295,38 @@ class SQLiteStore:
))
connection.execute('PRAGMA user_version=4')
LOGGER.info('farm schema migrated version=4 theft=enabled')
version = 4
if app == APPLICATION_ID and version == 4:
self._validate(connection, expected_version=4)
connection.execute(
'ALTER TABLE plots ADD COLUMN land_level '
'INTEGER NOT NULL DEFAULT 0 '
'CHECK(land_level BETWEEN 0 AND 3)'
)
# 从已保留的迁移原始记录恢复等级,不按收益加成猜测。
for row in connection.execute(
"SELECT identity, result FROM operations "
"WHERE action='legacy_import'"
).fetchall():
try:
archive = json.loads(row['result'])
soils = archive['legacy_rows'].get('userSoil', [])
for soil in soils:
level = soil['soilLevel']
slot = soil['soilIndex']
if (type(level) is not int
or not 0 <= level <= 3
or type(slot) is not int or slot < 1):
raise ValueError('Invalid legacy soil')
connection.execute(
'UPDATE plots SET land_level=? '
'WHERE identity=? AND slot=?',
(level, row['identity'], slot),
)
except (KeyError, TypeError, ValueError) as error:
raise SchemaError('legacy_land_invalid') from error
connection.execute('PRAGMA user_version=5')
LOGGER.info('farm schema migrated version=5 land=enabled')
self._validate(connection)
connection.commit()
if migrated_rows is not None:
@@ -357,9 +434,9 @@ class SQLiteStore:
connection.commit()
except BaseException as exc:
connection.rollback()
LOGGER.log(
logging.INFO if isinstance(exc, FarmError)
else logging.WARNING,
report = (LOGGER.info if isinstance(exc, FarmError)
else LOGGER.warning)
report(
"farm rollback action=%s operation=%s error=%s",
action, operation_id, type(exc).__name__,
)
+13 -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,21 +43,28 @@ 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'))
def image(self, name, size=None):
if name not in self.metadata["images"]:
upgrade = name in ('soil/红土地.png', 'soil/黑土地.png', 'soil/金土地.png')
if not upgrade and name not in self.metadata["images"]:
raise KeyError("Unknown packaged image")
size = tuple(size or self.metadata["images"][name]["size"])
size = tuple(size or (self.metadata['soil_size'] if upgrade
else self.metadata["images"][name]["size"]))
key = (name, size)
cached = self.cache.get(key)
if cached is None:
with self._bundle.open("rb") as stream, ZipFile(stream) as 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:
original.thumbnail(size, Image.Resampling.LANCZOS)
+105 -17
View File
@@ -5,6 +5,7 @@ from importlib import resources
import json
import logging
from pathlib import Path
import sys
import tempfile
import threading
import time
@@ -41,23 +42,104 @@ def checked_url(url):
return url
class _DownloadProgress:
"""仅在交互终端原位刷新;重定向输出不写控制字符。"""
def __init__(self, name):
self.name = name
self.stream = sys.stderr
self.enabled = bool(getattr(self.stream, 'isatty', lambda: False)())
self.last_report = float('-inf')
self.progress = None
self.task = None
def update(self, total, length, now, *, force=False):
if not self.enabled or (not force and now-self.last_report < 0.2):
return
try:
if self.progress is None:
from rich.console import Console
from rich.progress import (
BarColumn, DownloadColumn, Progress, TextColumn,
TimeRemainingColumn, TransferSpeedColumn,
)
console = Console(file=self.stream)
if not console.is_terminal:
self.enabled = False
return
self.progress = Progress(
TextColumn('{task.description}', markup=False),
BarColumn(bar_width=None),
TextColumn('{task.fields[percentage]}'),
DownloadColumn(), TransferSpeedColumn(),
TextColumn('eta'), TimeRemainingColumn(),
console=console, auto_refresh=False, expand=True,
redirect_stdout=False, redirect_stderr=False,
)
self.task = self.progress.add_task(
f'[农场] {self.name}', total=length if length > 0 else None,
percentage='',
)
self.progress.start()
percentage = f'{min(1, total / length):.1%}' if length > 0 else ''
self.progress.update(self.task, completed=total,
percentage=percentage)
self.progress.refresh()
except Exception:
# 终端或显示库故障不影响资源下载,也不接管宿主输出流。
self.close()
self.enabled = False
self.last_report = now
def close(self):
progress, self.progress = self.progress, None
self.task = None
if progress is not None:
try:
progress.stop()
except Exception:
pass
def _copy_remote(url, destination, limit):
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
parts = urlsplit(url).path.split('/')
name = {
'manifest.json': '作物版本清单', 'plants.json': '作物列表',
'metadata.json': '展示配置', 'land-soils.zip': '土地素材',
'assets.zip': '字体包' if 'fonts' in parts else '图片素材包',
}.get(parts[-1], '资源')
started = time.monotonic()
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
length = response.headers.get('Content-Length')
if length is not None and int(length) > limit:
raise ValueError('Resource download exceeds limit')
total = 0
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
if total > limit or time.monotonic()-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
progress = _DownloadProgress(name)
logger.info('农场下载开始:%s', name)
try:
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
header = response.headers.get('Content-Length')
length = int(header) if header is not None else 0
if length > limit:
raise ValueError('Resource download exceeds limit')
total = 0
progress.update(total, length, started)
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
now = time.monotonic()
if total > limit or now-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
progress.update(total, length, now)
progress.update(total, length, started, force=True)
progress.close()
logger.info('农场下载完成:%s 共 %.2f MiB(待校验)',
name, total / 1048576)
except Exception as error:
progress.close()
logger.warning('农场下载失败:%s(%s)', name, type(error).__name__)
raise
def verify_bundle(path, manifest):
@@ -93,14 +175,20 @@ def ensure_assets(data_dir, source='Github', service_url=''):
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
with _LOCK:
try:
return verify_bundle(target, expected)
cached = verify_bundle(target, expected)
logger.info('农场图片素材:复用已校验缓存')
return cached
except (OSError, ValueError):
pass
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
+206
View File
@@ -0,0 +1,206 @@
"""启动时选择完整作物版本;网络失败保留已验证缓存或初始目录。"""
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, ROOTS, 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)
@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')
logger.info('农场作物版本未变化:复用已校验缓存')
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')
logger.info('农场作物列表、配置和素材校验通过:正在切换版本')
_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')
logger.info('农场更新不可用:使用已校验的作物缓存版本')
return current
bootstrap = _bootstrap(required_ids)
logger.info('农场更新不可用:使用内置作物版本')
# 若旧方式图片缓存可用,离线初始版本也能继续绘图。
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
+12 -1
View File
@@ -5,4 +5,15 @@ assets.zip 原样复制自 Shu-Ying/zhenxun_plugin_farm 的本地保留基线,
仅选取农场季节背景、草地/普通地、作物及两个状态图标;metadata.json
来自旧布局和作物配置。沿用仓库 GPLv3 许可与原始素材归属,迁移不
增加第三方素材权利。后续对外发布前仍须核实历史素材来源及分发权限。
不包含字体;部署方配置可合法使用的中文字体路径。
## 小赖字体
默认字体由 farm_service 的 fonts/assets.zip 提供,插件不内置 TTF。
Xiaolai-Regular.ttf 原样取自 lxgw/kose-font 的 v3.126 发布,
项目地址:https://github.com/lxgw/kose-font 。
下载地址:https://github.com/lxgw/kose-font/releases/download/v3.126/Xiaolai-Regular.ttf
上游 SHA-256:e2f68daf0e72777a8cf58bc83de1b98634b251e537ddbfca24b0ae50d1802da2
字体采用 SIL Open Font License 1.1,完整版权声明与许可见同目录
OFL-Xiaolai.txt;字体不适用本项目代码的 GPLv3 许可。
随软件分发时须保留字体的版权声明和完整许可,不得单独销售字体。
插件缓存中同时保存原版字体和许可;下载摘要由 font-manifest.json 固定。
+94
View File
@@ -0,0 +1,94 @@
Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+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"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schema": 1,
"version": "xiaolai-v3.126",
"sha256": "c2d1c1a5247e05b766d12a8d8410594d78b1a8b86031624a05b76ee0515c1335",
"size_bytes": 14741741,
"sources": {
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/fonts/assets.zip",
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/fonts/assets.zip",
"farm_service": "https://farm.example/v1/fonts/c2d1c1a5247e05b766d12a8d8410594d78b1a8b86031624a05b76ee0515c1335/assets.zip"
}
}
Binary file not shown.
+110
View File
@@ -0,0 +1,110 @@
"""从 service 仓库下载原版小赖字体,校验后原子写入本地缓存。"""
import hashlib
from importlib import resources
import json
import logging
from pathlib import Path
import tempfile
from zipfile import ZipFile
from .bundle import _LOCK, _copy_remote, validate_options
logger = logging.getLogger(__name__)
FONT_NAME = "Xiaolai-Regular.ttf"
FILES = {
FONT_NAME: (
22220806,
"e2f68daf0e72777a8cf58bc83de1b98634b251e537ddbfca24b0ae50d1802da2",
),
"OFL-Xiaolai.txt": (
4432,
"0df7e09be4c2c850a48bd8beb9cd64b343aad49cd5d3f6cfb2ad2e3d28a56ca4",
),
}
def expected_manifest():
return json.loads(resources.files(__package__).joinpath(
"data", "font-manifest.json",
).read_bytes())
def _valid(path, size, digest):
if not path.is_file() or path.stat().st_size != size:
return False
checksum = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(65536), b""):
checksum.update(chunk)
return checksum.hexdigest() == digest
def _atomic_write(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 ensure_font(data_dir, source="Github", service_url=""):
"""校验全部缓存文件;无效时下载,始终同时保留完整 OFL 许可。"""
validate_options(source, service_url)
manifest = expected_manifest()
root = Path(data_dir) / "fonts" / manifest["sha256"]
with _LOCK:
if all(_valid(root / name, *checks)
for name, checks in FILES.items()):
logger.info('农场字体:复用已校验缓存')
return root / FONT_NAME
root.mkdir(parents=True, exist_ok=True)
archive_path = root / "assets.zip"
if not _valid(archive_path, manifest["size_bytes"],
manifest["sha256"]):
url = (
service_url.rstrip("/") + "/v1/fonts/"
+ manifest["sha256"] + "/assets.zip"
if source == "farm_service"
else manifest["sources"][source.lower()]
)
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=root, suffix=".download", delete=False,
) as stream:
temporary = Path(stream.name)
_copy_remote(url, stream, manifest["size_bytes"])
if not _valid(temporary, manifest["size_bytes"],
manifest["sha256"]):
raise ValueError("Font archive size or SHA256 mismatch")
temporary.replace(archive_path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
with ZipFile(archive_path) as archive:
entries = archive.infolist()
if (len(entries) != len(FILES)
or {item.filename for item in entries} != set(FILES)):
raise ValueError("Unexpected font archive contents")
content = {}
for item in entries:
size, digest = FILES[item.filename]
if item.file_size != size or item.flag_bits & 1:
raise ValueError("Invalid font archive member")
value = archive.read(item)
if hashlib.sha256(value).hexdigest() != digest:
raise ValueError("Font member SHA256 mismatch")
content[item.filename] = value
# 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。
for name in ("OFL-Xiaolai.txt", FONT_NAME):
_atomic_write(root / name, content[name])
logger.info('农场字体和许可证校验通过')
return root / FONT_NAME
+9 -4
View File
@@ -1,9 +1,10 @@
"""从一次快照与统一时间构造可缓存场景;不访问存储。"""
import hashlib
from dataclasses import replace
from farm_core.domain import Identity, Snapshot, integer
from farm_core.pests import advance_plot
from farm_core.pests import advance_plot, season_duration
from .models import PlotView, Scene
@@ -53,8 +54,7 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
))
continue
phases = tuple(dict.fromkeys(int(p) for p in info["phase"].split(",")))
duration = (plot.plant.growth_seconds if plot.harvest_count == 0
else plot.plant.regrowth_seconds)
duration = season_duration(plot)
elapsed = max(0, now - (plot.mature_at - duration))
work = (elapsed * 100 if plot.growth_remaining is None else
duration * 100 - plot.growth_remaining)
@@ -72,5 +72,10 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
pest_frames=frames,
))
return Scene(
scope, snapshot.account, tuple(views),
scope, snapshot.account, tuple(
replace(view, land_level=plot.land_level)
for view, plot in zip(views, sorted(
snapshot.plots, key=lambda p: p.slot,
))
),
)
+1
View File
@@ -57,6 +57,7 @@ class PlotView:
stage: int = 0 # 本季展示阶段。
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
land_level: int = 0 # 土地等级;影响底图和成图缓存。
@dataclass(frozen=True)
+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()
+11 -9
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):
@@ -133,8 +134,9 @@ class FarmRenderer:
)
def _base(self, scene, options):
slots = frozenset(p.slot for p in scene.plots)
key = ("base", options.theme, options.width, slots)
slots = {p.slot: p.land_level for p in scene.plots}
key = ("base", options.theme, options.width,
tuple(sorted(slots.items())))
cached = self.assets.cache.get(key)
if cached is not None:
return Canvas(cached.copy())
@@ -149,14 +151,14 @@ class FarmRenderer:
soil_size = tuple(max(1, s(n)) for n in (
self.assets.metadata["soil_size"]
))
tiles = {
True: self.assets.image("soil/普通土地.png", soil_size),
False: self.assets.image("soil/草土地.png", soil_size),
}
names = ('普通土地', '红土地', '黑土地', '金土地')
tiles = {level: self.assets.image(f'soil/{names[level]}.png', soil_size)
for level in set(slots.values())}
grass = self.assets.image('soil/草土地.png', soil_size)
expansion = False
for slot, pos in enumerate(self.assets.metadata["positions"], 1):
xy = (s(pos["x"]), s(pos["y"]))
canvas.paste(tiles[slot in slots], xy)
canvas.paste(tiles[slots[slot]] if slot in slots else grass, xy)
if slot not in slots and not expansion:
icon = self.assets.image(
"background/expansion.png", (max(1, s(90)),) * 2,

Some files were not shown because too many files have changed in this diff Show More