mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-29 05:29:58 +08:00
Compare commits
11
Commits
dc17f7f65c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e897bad506 | ||
|
|
b851e490be | ||
|
|
8872ffe3be | ||
|
|
b09d51c7c9 | ||
|
|
c6b504924e | ||
|
|
d05b2e5765 | ||
|
|
b3455ded58 | ||
|
|
e7c6eac07f | ||
|
|
e5ec38e9d9 | ||
|
|
ac0209f010 | ||
|
|
22cfddcf06 |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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无损格式不受此值影响。
|
||||
|
||||
@@ -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
@@ -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;本轮未执行远程操作。
|
||||
- 用户已撤回拉取要求,且要求删除本地克隆;三个本地检出已删除,不再访问远程。
|
||||
- 本轮仅修正迁移副本中的硬编码凭据;业务缺陷记录在审查报告,没有隐式修复。
|
||||
- 历史迁移预检与后续实现分开记录;当前行为以源码、现行说明和正式验收为准。
|
||||
|
||||
@@ -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
|
||||
+8
-3
@@ -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”指收获操作前已提交的经验。
|
||||
产物入库、经验变动、收获次数和下一季状态
|
||||
在同一事务提交。最后一季后枯萎;铲除只清理枯萎地块。
|
||||
再次重试不重复收获。展示结果不再次修改数据。
|
||||
|
||||
+12
-5
@@ -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 字段
|
||||
或逐阶段手调锚点。地块位置按当前稳定编号映射。缺失作物资源使用
|
||||
显式占位并记录警告。
|
||||
用户信息显示真实快照余额、点券和经验,头像仅接收字节,无头像时
|
||||
绘制默认图;拒绝超大或无效头像并降级。主题由调用方明确提供。
|
||||
|
||||
+13
-8
@@ -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
|
||||
@@ -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
|
||||
安装及离线测试命令可复现,使用说明清楚标明未实现的接入与发布部分。
|
||||
@@ -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
|
||||
离线样例可解码,性能报告真实可复现,文档提供接入调用和未验证边界。
|
||||
@@ -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
@@ -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。重复注册不额外发地。
|
||||
|
||||
@@ -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系统锁分支、
|
||||
其他依赖矩阵、真实宿主、生产数据库、远端下载、提交、推送或部署。
|
||||
字体及旧素材下载线程的取消等待属于既有实现边界,本次新作物版本加载线程
|
||||
已独立跟踪并在关闭时等待排空,不宣称旧资源线程也具备同等保证。
|
||||
@@ -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 |
@@ -1,5 +1,11 @@
|
||||
# 更新日志
|
||||
|
||||
## 待发布
|
||||
|
||||
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
|
||||
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
|
||||
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
|
||||
|
||||
## 2.0.2 — 待发布
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、
|
||||
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,2 +1,3 @@
|
||||
Pillow>=10.4
|
||||
aiohttp>=3.9,<4
|
||||
rich>=13.9,<16
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。"""
|
||||
|
||||
|
||||
def open_logs(data_dir):
|
||||
return None, None
|
||||
|
||||
|
||||
def close_logs(file_handler, console_handler):
|
||||
pass
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
|
||||
# 与真寻当前pillow<11的约束取交集,不升级到12。
|
||||
Pillow>=10.4,<11
|
||||
rich>=13.9,<16
|
||||
|
||||
+9
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
|
||||
农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
|
||||
[](https://qm.qq.com/q/7hsOD4rOw2)
|
||||
|
||||

|
||||
</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. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
|
||||
---
|
||||
|
||||
发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
|
||||
发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
|
||||
## 农场界面
|
||||
|
||||
## 指令
|
||||

|
||||
|
||||
`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
|
||||
仓库默认图片,例如 `/我的种子 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`),其他平台尚未完成真实环境验证。
|
||||
## 农场帮助
|
||||

|
||||
|
||||
插件不携带大型`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
@@ -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">
|
||||
|
||||

|
||||
<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 启动脚本中加入:
|
||||
[](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 文字”。
|
||||
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
|
||||
## 农场界面
|
||||
|
||||
## 当前命令
|
||||

|
||||
|
||||
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
|
||||
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
|
||||
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
|
||||
发送「农场帮助」查看参数。图片与提及由 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 和实际机器人消息行为
|
||||
需要在部署环境进一步验证。
|
||||
## 农场帮助
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 待办事宜 `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
@@ -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
|
||||
- 修正了部分土地资源错误的情况
|
||||
- 修正了部分文本信息错误的情况
|
||||
## 农场帮助
|
||||

|
||||
|
||||
代码方面
|
||||
---
|
||||
- 修正部分事件连接机制
|
||||
- 修正网络请求端口
|
||||
|
||||
## 待办事宜 `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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Keep in sync with tool.ruff.required-version in pyproject.toml.
|
||||
ruff==0.8.6
|
||||
+157
-64
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""显式从已验证Service发布目录刷新插件初始版本,不联网。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'src'))
|
||||
|
||||
from farm_core import Catalogue
|
||||
from farm_core.catalogue_release import canonical, parse_manifest
|
||||
from farm_render.catalogue_bundle import ROOTS, _snapshot
|
||||
|
||||
|
||||
def sync(source, root=ROOT):
|
||||
source, root = Path(source), Path(root)
|
||||
manifest = parse_manifest((source / 'manifest.json').read_bytes())
|
||||
release = source / 'releases' / manifest['release']
|
||||
snapshot = _snapshot(release, manifest, Catalogue.bundled())
|
||||
destinations = {
|
||||
'plants.json': root / 'src/farm_core/data/plants.json',
|
||||
**{name: root / 'src/farm_render/data' / name for name in (
|
||||
'metadata.json', 'assets.zip', 'land-soils.zip',
|
||||
)},
|
||||
}
|
||||
for name, path in destinations.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(release / name, path)
|
||||
data = root / 'src/farm_core/data'
|
||||
(data / 'catalogue-manifest.json').write_bytes(canonical(manifest) + b'\n')
|
||||
asset = {
|
||||
'schema': 1, 'version': manifest['version'],
|
||||
'sha256': manifest['files']['assets.zip']['sha256'],
|
||||
'size_bytes': manifest['files']['assets.zip']['size_bytes'],
|
||||
'catalogue_release': manifest['release'],
|
||||
'sources': {
|
||||
name.lower(): base + '/releases/' + manifest['release']
|
||||
+ '/assets.zip' for name, base in ROOTS.items()
|
||||
},
|
||||
}
|
||||
asset['sources']['farm_service'] = (
|
||||
'https://farm.example/v1/catalogue/releases/'
|
||||
+ manifest['release'] + '/assets.zip'
|
||||
)
|
||||
(root / 'src/farm_render/data/asset-manifest.json').write_text(
|
||||
json.dumps(asset, ensure_ascii=False, indent=2) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
return snapshot.manifest
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, required=True,
|
||||
help='Service catalogue发布目录,不是可编辑source目录')
|
||||
args = parser.parse_args()
|
||||
print('Bootstrap synced release=' + sync(args.source)['release'])
|
||||
@@ -1,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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
@@ -4445,6 +4445,5 @@
|
||||
"vipBuy": 0
|
||||
}
|
||||
],
|
||||
"schema": 1,
|
||||
"source_sha256": "21f11e818909dda4d60864374062c46e0c86e9c2f01d2759225aba466d9de9eb"
|
||||
"schema": 1
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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__,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 固定。
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -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,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ class PlotView:
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
|
||||
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
|
||||
land_level: int = 0 # 土地等级;影响底图和成图缓存。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user