diff --git a/.gitattributes b/.gitattributes index d81de8d..0b5e788 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ *.toml text eol=lf *.yaml text eol=lf *.yml text eol=lf +*.txt text eol=lf +LICENSE text eol=lf *.html text eol=lf *.sh text eol=lf *.ps1 text eol=lf @@ -14,6 +16,8 @@ *.db binary *.woff binary *.woff2 binary +*.zip binary +*.webp binary # Preserve exact legacy bytes so manifest hashes survive checkout legacy/** -text diff --git a/.github/workflows/zhenxun-preview.yml b/.github/workflows/zhenxun-preview.yml new file mode 100644 index 0000000..3648ae4 --- /dev/null +++ b/.github/workflows/zhenxun-preview.yml @@ -0,0 +1,106 @@ +name: Zhenxun preview + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + version: + description: Preview version + default: 0.1.0-preview.1 + required: true + type: string + publish: + description: Sync fixed farm-preview branch in zhenxun repository + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: farm-preview-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + pillow: ['10.4.0', '12.3.0'] + runs-on: ${{ matrix.os }} + env: + PYTHONPATH: src + PYTHONIOENCODING: utf-8 + PILLOW_VERSION: ${{ matrix.pillow }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Install test dependencies + shell: bash + run: >- + python -m pip install "Pillow==$PILLOW_VERSION" + setuptools wheel "PyYAML>=6,<7" + - run: python scripts/ci_font.py + - run: python scripts/check_workflows.py + - run: python scripts/verify_foundation.py + + build: + needs: test + runs-on: ubuntu-latest + env: + PREVIEW_VERSION: ${{ inputs.version || '0.1.0-preview.1' }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Build selected target only + run: >- + python scripts/build_zhenxun.py + --output dist/zhenxun-preview --version "$PREVIEW_VERSION" + - name: Copy standalone helper scripts + run: | + cp scripts/install_zhenxun.py dist/zhenxun-preview/ + cp scripts/verify_zhenxun.py dist/zhenxun-preview/ + - uses: actions/upload-artifact@v7 + with: + name: zhenxun-preview + path: dist/zhenxun-preview/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + + sync-preview: + if: >- + github.event_name == 'workflow_dispatch' && inputs.publish && + github.ref == 'refs/heads/main' && + github.repository == 'Shu-Ying/main_plugin_farm' + needs: [test, build] + runs-on: ubuntu-latest + environment: zhenxun-preview + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - uses: actions/download-artifact@v8 + with: + name: zhenxun-preview + path: incoming + - name: Publish validated preview only + env: + FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }} + run: >- + python scripts/publish_zhenxun.py + incoming/zhenxun_plugin_farm_preview-*.zip --publish diff --git a/README.md b/README.md index d0173c9..038ec92 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ - 可选共享图片层:旧布局与素材、目标尺寸绘制、WebP/JPEG/PNG、 有界缓存和受控后台执行,参见 [绘图说明](docs/10-rendering.md)。 -本目录目前不是可直接放入真寻、NoneBot 或 AstrBot 插件目录的成品。 -integrations 中仍是职责说明,框架接入与发布流水线尚未实现。 +主仓库不能整体放入插件目录。真寻预览包由脚本白名单生成,参见 +[真寻测试说明](docs/11-zhenxun-preview.md);AstrBot/通用NoneBot接入 +仍未实现。工作流和同步脚本已准备,真实机器人和远端运行尚未验证。 签到、偷菜、升级、兑换和真实玩家数据迁移不在本轮范围。 ## 安装与本地演示 diff --git a/docs/04-release-design.md b/docs/04-release-design.md index 462b322..8096351 100644 --- a/docs/04-release-design.md +++ b/docs/04-release-design.md @@ -46,7 +46,9 @@ NoneBot wheel 明确包含资源;不能假设 zip 的相对目录在 wheel 内 8. 提交安装内容和来源清单后,再创建指向同一提交的发布标签。 9. 按目标发布到仓库/Release/PyPI,并记录各目标结果。 -未来可使用 GitHub Actions;本轮未编写可执行发布工作流、未配置凭据。 +初始设计时没有工作流;后续真寻预览开发已补充主仓库构建附件、 +固定预览分支的可选手动同步和发布仓库校验工作流,见11-zhenxun-preview。 +尚未配置凭据或执行真实GitHub工作流。其他目标仍是设计。 发布工作区使用隔离的临时目录;清理只允许精确列出的生成路径。 保留目标仓库中的治理资料和人工维护文件,禁止无条件清空仓库。 diff --git a/docs/11-zhenxun-preview.md b/docs/11-zhenxun-preview.md new file mode 100644 index 0000000..42100cd --- /dev/null +++ b/docs/11-zhenxun-preview.md @@ -0,0 +1,109 @@ +# 真寻农场预览版 + +本文件随生成包分发;业务源码只在主仓库main_plugin_farm维护。 +生成目标为Shu-Ying/zhenxun_plugin_farm的farm-preview分支,保留main。 + +## 安装前 + +此版沿用“开通农场”“我的农场”等原指令,没有测试前缀。请先停止 +机器人,并停用旧农场插件,避免同名指令被旧版抢先处理。仅在菜单 +禁用旧版不一定阻止其导入;建议把旧插件目录备份到插件扫描目录之外。 +安装脚本不会自动禁用、删除或覆盖旧版。 + +预览数据默认位于真寻DATA_PATH/farm_preview/farm-preview.sqlite3。 +不读取旧版数据库,不迁移真实玩家资产;同平台、同机器人内共享农场, +不同机器人/平台分开。不要将预览数据路径指向旧版数据库。 + +## 主仓库本地生成 + +```text +python scripts/build_zhenxun.py --output dist/zhenxun-preview +python scripts/verify_zhenxun.py dist/zhenxun-preview/zhenxun_plugin_farm_preview-0.1.0-preview.1.zip +python scripts/install_zhenxun.py dist/zhenxun-preview/zhenxun_plugin_farm_preview-0.1.0-preview.1.zip --plugins-dir /path/to/zhenxun_bot/zhenxun/plugins +``` + +必须使用真寻的Python环境安装包内requirements.txt;该依赖约束为 +Pillow>=10.4,<11,不静默升级宿主到12。NoneBot、真寻和Uninfo由 +现有宿主提供,不把通用NoneBot适配器或AstrBot代码一起分发。 + +如果安装目录已存在,脚本会拒绝覆盖。更新前停机器人、备份插件目录, +手动移走旧预览目录后再安装;数据目录位于插件外,不随插件删除。 + +## 配置与指令 + +在真寻使用的.env配置中文字体路径,例如Windows: + +```dotenv +FARM_PREVIEW_FONT=C:/Windows/Fonts/msyh.ttc +FARM_PREVIEW_DEPLOYMENT=zhenxun-preview +FARM_PREVIEW_IMAGE_FORMAT=AUTO +``` + +Linux请提供合法可用的中文字体。没有字体时文本交易可用,图片命令 +会提示配置;不捆绑Windows字体。可选FARM_PREVIEW_DATA_DIR显式设置 +独立预览数据目录。AUTO默认JPEG以保守兼容真实平台,确认WebP支持后 +可设WEBP,也支持JPEG/PNG。 + +测试顺序(是否需要/前缀遵循宿主COMMAND_START配置): + +```text +农场帮助 +开通农场 +我的农场 +种子商店 +购买种子 白萝卜 1 +我的种子 +播种 白萝卜 1 +我的农场 高清 +农场详述 +收获 +``` + +作物未成熟时收获会明确失败,不提供管理员快进命令或加币后门。 +其他入口:铲除、我的作物、我的农场币、我的点券、作物加锁 作物、 +作物解锁 作物、出售作物 作物 数量;不带参数出售全部未锁定产物。 +也可发送“我的农场 buy-seed 白萝卜 1”等旧子命令(支持范围见帮助)。 +不实现签到、偷菜、充值、兑换、升级和旧数据迁移,不能替代旧版全部功能。 + +重复事件通过稳定消息标识幂等;无法获取消息标识时写操作拒绝。 +头像最多等待2秒,失败使用默认图;图片缓存与后台任务均有限额。 +关键日志写入数据目录farm-preview.log,同时沿用宿主日志传播配置; +不记录真实玩家数据。 + +## GitHub工作流 + +先由你提交主仓库代码后,主仓库的Zhenxun preview工作流会检查并上传 +Actions artifact。下载并解压附件,取其中插件ZIP安装;不需要令牌。 +Windows/Linux、Pillow10.4/当前支持版属于CI矩阵,不代表本机已跑Linux。 + +若还需将生成内容写入发布仓库,主仓库手动Run workflow时勾选publish, +并配置受保护environment zhenxun-preview。该环境Secret名为 +FARM_RELEASE_TOKEN,仅授权目标仓库Contents写入;生成内容含工作流, +相应令牌也须允许Workflows写入(GitHub App使用对应权限)。配置 +环境审批及仅允许main分支部署。不把Token填进脚本、URL或输入参数。 +脚本默认dry-run,只有--publish才访问GitHub;固定写farm-preview, +不更新main、不force、不创建Release。第一次创建预览分支,之后普通 +提交更新。专用预览分支只放生成内容,不在那里手改业务或存储治理资料。 +已有预览分支必须带本项目来源清单;若它已成为默认分支则拒绝发布, +并发期间分支发生变化也拒绝更新,避免覆盖其他提交。 +用ZIP安装最直接,不能假设插件管理器支持任意分支地址。 + +产物内FARM-MANIFEST.json记录版本、源提交、dirty和每文件SHA256; +源有未提交修改会标dirty,禁止发布这种产物。本地测试允许dirty。 +哈希用于内容一致性,不是签名或来源身份保证,请使用你信任的附件。 + +## 验证边界与回退 + +本轮不会在真实机器人中安装/启动,也不会执行真实GitHub同步。真实 +登录、权限、适配器消息格式和发图需要你部署后验证,不能把离线模拟 +报告为线上成功。旧素材455个阶段引用缺图时显示问号,不会整图失败。 + +源码中ZIP时间、系统类型和文件权限固定;文本统一LF,以便跨平台 +复现。构建后repository目录是发布仓库根,外层插件ZIP用于本地安装; +二者不要再叠套一层主仓库目录。 + +回退:停机器人,移走预览插件,恢复旧插件目录,再由你启动;旧版库 +没有被本版更改。保留预览数据备份,不尝试让旧版打开新SQLite schema。 + +官方依据:[Actions附件](https://docs.github.com/en/actions/tutorials/store-and-share-data)、 +[手动触发](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow)。 diff --git a/docs/README.md b/docs/README.md index 07c3529..72abc3a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ |---|---| | [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 | | [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 | +| [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) | 长期目标、实施顺序、里程碑与验收 | | [02-architecture.md](02-architecture.md) | 分层、目录、依赖、接入与接口设计 | diff --git a/docs/comet/changes/farm-core-foundation/brief.md b/docs/comet/changes/farm-core-foundation/brief.md index f9794c5..0986b52 100644 --- a/docs/comet/changes/farm-core-foundation/brief.md +++ b/docs/comet/changes/farm-core-foundation/brief.md @@ -35,6 +35,9 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 由接入方提供,失败使用默认头像。保留旧农场背景、地块和作物素材。 - 按可见状态缓存成图并在生长阶段边界失效;并发相同请求合并。 提供离线生成、性能记录、安装包独立运行和中文字体配置说明。 +- 用户选择补齐真寻测试版:最小命令接入、离线白名单打包、GitHub + 构建工作流、可选手动同步真寻发布仓库测试分支、安装/卸载说明。 + 独立测试库、沿用原指令;保留旧版和原业务/渲染验收A1-A13。 ## Directory structure @@ -43,6 +46,9 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 - 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 @@ -50,6 +56,7 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 - scripts/verify_foundation.py:增加渲染资源可复现检查。 - README.md、docs/README.md、docs/task_plan.md、docs/progress.md。 - 当前 brief 与新增 farm-render 目标规格;原业务规格保留。 +- release/targets.json 与真寻职责说明;新增真寻预览完整目标规格。 ### Deleted @@ -57,15 +64,16 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 ### Not created -- 不创建真实平台插件、远端工作流、真实玩家库或字体再分发包。 +- 不创建AstrBot/通用NoneBot接入、真实玩家库或字体再分发包。 - 不修改 legacy 基线,不创建多个框架的业务副本。 # Non-goals -本轮不实现真实宿主插件、消息命令、签到、偷菜、活动、 +本轮不实现AstrBot/通用NoneBot宿主插件、签到、偷菜、活动、 外部金币兑换、点券兑换、土地升级、星级进阶、账号绑定或在线联机。 不读取或迁移真实玩家数据,不重置旧资产,不改旧基线业务。 -不 clone/fetch/pull/push 任何远程,不创建 PR、Release 或发布工作流。 +不在本机 clone/fetch/pull/push 任何远程,不创建 PR 或远端Release。 +生成工作流与发布脚本,但不执行跨仓库发布或安装/重启真实机器人。 不将设计占位目录报告为已支持三框架。 # Acceptance examples @@ -100,6 +108,20 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 - A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 +- A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 + 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, + 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。 +- A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 + 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, + 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。 +- A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 + 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。 +- A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 + 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 + 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。 +- A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 + Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; + 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。 # Constraints and invariants @@ -124,3 +146,8 @@ AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续 兼容输出;尺寸、质量、编码力度独立配置,平台能力由适配器声明。 - 素材保留原 GPL 归属,不捆绑未确认再分发许可的字体;部署显式配置 中文字体路径。真实宿主头像缓存和上传由后续适配器实现。 +- 用户已明确选择“补齐真寻测试版:命令接入+打包工作流+安装脚本”。 + 由于当前目录未归档核心与新接入需联合验证,作为同一基础测试交付 + 扩展;不代用户接受上轮结果。用户明确不加测试前缀,沿用原指令; + 测试库与旧库隔离,发布脚本只操作专用farm-preview分支,实际发布 + 不执行。测试时由用户停用旧插件,安装器不删除或覆盖旧插件。 diff --git a/docs/comet/changes/farm-core-foundation/comet-state.yaml b/docs/comet/changes/farm-core-foundation/comet-state.yaml index 2559175..d95181f 100644 --- a/docs/comet/changes/farm-core-foundation/comet-state.yaml +++ b/docs/comet/changes/farm-core-foundation/comet-state.yaml @@ -3,10 +3,10 @@ name: farm-core-foundation language: en phase: verify status: await-user -state_version: 14 +state_version: 26 brief: brief.md document_constraints_version: 3 -shape_confirmation_hash: 49a2a3fb4be0159531d63bcdd52c5895f2586e309c33be91fe4e38c7a159461d +shape_confirmation_hash: d1bc1489c0aa3d9a28ff7a24c62aa4bcb4e0a953955d265bcf06f802252cec36 spec_changes: - capability: farm-core operation: create @@ -14,6 +14,9 @@ spec_changes: - 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 @@ -21,8 +24,8 @@ workspace: finish: null loop: stage: await-user - goal_cycle: 3 - iteration: 1 + goal_cycle: 5 + iteration: 2 attempt: 1 retry_epoch: 0 failed_iteration_count: 0 @@ -36,21 +39,21 @@ acceptance: text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。" result: passed reason: - text: 827文件基线正式检查通过;检查包导入无线程/文件副作用及wheel独立安装,核心无框架和legacy运行依赖。 + text: 827文件基线正式检查通过;核心源码仅相对导入,安装及导入副作用测试通过,不依赖legacy或框架。 truncated: false - id: A2 source: brief.md text: "A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。" result: passed reason: - text: 只读源库导出按id排序并带源hash;202项导出检查通过,加载器校验版本、整数、阶段和重复定义。 + text: 202条目录源值及导出复现、损坏目录拒绝测试通过;Catalogue本地读取且验证schema与重复对象。 truncated: false - id: A3 source: brief.md text: "A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。" result: passed reason: - text: 账户注册与初始500资产、三空地在事务内完成;重复注册及身份作用域隔离测试正式通过。 + text: repository.register在事务内发放500币和3地;重复注册与四段身份隔离正式测试通过。 truncated: false - id: A4 source: brief.md @@ -58,7 +61,7 @@ acceptance: 不留下部分写入。" result: passed reason: - text: 购买和播种在完整事务内扣款、入库、耗种、存图;数量、等级、余额和空地边界测试正式通过。 + text: 购买播种均通过同一事务调用,整数/等级/资产/空地检查明确;失败无部分写入测试通过。 truncated: false - id: A5 source: brief.md @@ -66,7 +69,7 @@ acceptance: 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。" result: passed reason: - text: 成熟边界、经验增量、多季再生长及枯萎清除源实现与规格一致,逐地块加成及顺序测试通过。 + text: 收获按各地块单独计算增量、保存多季状态;成熟边界、加成排序、冻结规则与枯萎测试通过。 truncated: false - id: A6 source: brief.md @@ -74,7 +77,7 @@ acceptance: 不发生旧版模板参数缺失。" result: passed reason: - text: 锁定状态对指定及全部出售生效,出售只增本次收入,Receipt和文本使用真实余额;正式测试通过。 + text: 指定和全部出售均保护锁定状态,只加本次收入;文本及结构化回执返回真实账户,回归通过。 truncated: false - id: A7 source: brief.md @@ -82,14 +85,14 @@ acceptance: 不重复领取,不吞数据库故障伪装成功;重启后数据可读。" result: passed reason: - text: BEGIN IMMEDIATE、操作指纹与结果同事务保存;异常回滚、并发超卖/重复收获、取消重放、重启与schema测试通过。 + text: BEGIN IMMEDIATE和操作指纹/持久结果覆盖原子回滚、幂等冲突、并发及取消重放;重启/模式拒绝测试通过。 truncated: false - id: A8 source: brief.md text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。" result: passed reason: - text: README、核心说明、离线入口与测试可用,准确说明尚无真实宿主、真实库迁移和远程发布验证。 + text: 离线入口、完整56项回归及实现说明可复核;真实宿主、迁库及远程发布边界有说明。 truncated: false - id: A9 source: brief.md @@ -97,43 +100,83 @@ acceptance: 依赖;安装包含所需素材。" result: passed reason: - text: Pillow可选渲染模块支持透明合成、复制、缩放、裁剪、文字测量省略及三格式;核心无Pillow导入,wheel携带673素材并独立渲染通过。 + text: Canvas支持透明合成/复制/缩放/裁剪/文字/三格式编码;核心无Pillow运行依赖,wheel独立渲染测试通过。 truncated: false - id: A10 source: brief.md text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。" result: passed reason: - text: 纯场景转换消费一次快照和统一now,首季/再季锚点与枯萎优先正确;旧30块布局及真实offset字段已迁出,普通高清解码与阶段测试通过,样例已视觉检查。 + text: 统一快照和now生成场景,阶段按首熟/再生长锚点映射,枯萎优先并使用实际偏移;普通高清解码及边界测试通过。 truncated: false - id: A11 source: brief.md text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。" result: passed reason: - text: 目标尺寸直接合成;素材/底图与成图LRU有字节预算且返回副本;键含作用域、账户、阶段、头像、主题及编码配置,边界变化不复用旧图。 + text: 底图直接为目标宽度;素材返回副本,LRU预算有界;身份、可见账户/地块阶段、头像和配置纳入键,正式缓存测试通过。 truncated: false - id: A12 source: brief.md text: "A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。" result: passed reason: - text: 整体合成编码由to_thread执行,semaphore限制并发、pending限制容量,shield维持取消后的任务容量并在关闭排空;合并、异常重试、事件循环和并发测试通过,日志不含用户标识。 + text: RenderService整图to_thread,有限排队及并发、相同请求合并,shield取消和关闭排空;并发/取消/失败重试测试通过。 truncated: false - id: A13 source: brief.md text: "A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。" result: passed reason: - text: 离线脚本生成两尺寸三格式冷暖测量及日志,报告与已查看样例一致;正式检查45项和基线/两类导出通过,文档明确本机单次、未测上传/低配/Linux及真实平台。 + text: 离线样例报告标注本机单次及未测上传;三格式冷暖数据、布局及文档保留,旧基线和原核心回归继续通过。 + truncated: false + - id: A14 + source: brief.md + text: "A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, + 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。" + result: passed + reason: + text: 沿用原中文指令及旧子命令,独立预览库、稳定事件哈希、完整身份、生命周期/字体/头像降级已核对;余额不足映射修正并通过无扣款新测试。 + truncated: false + - id: A15 + source: brief.md + text: "A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, + 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。" + result: passed + reason: + text: 构建白名单包含私有模块、素材/许可证NOTICE及来源哈希,固定ZIP元数据,不修改开发导入;复现测试通过;最终修复包校验30文件且runtime匹配源码。 + truncated: false + - id: A16 + source: brief.md + text: "A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。" + result: passed + reason: + text: 校验器拒绝重复/额外/穿越/符号链接/哈希错误,先验再安装且独占创建不覆盖;正式边界测试通过。Windows临时目录实测尾点空格变体未复现逃逸。 + truncated: false + - id: A17 + source: brief.md + text: "A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 + 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。" + result: passed + reason: + text: CI默认只读/附件输出;同步需手动main触发与受保护环境Secret,脚本默认离线且固定仓库farm-preview、拒绝默认/非生成分支、无force并保护竞态;模拟/守卫通过。 + truncated: false + - id: A18 + source: brief.md + text: "A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 + Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; + 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。" + result: passed + reason: + text: Windows本机56项及隔离Pillow10.4下24项均正式通过;CI矩阵明确Windows/Linux和10.4/12.3,文档含安装/字体/数据/原指令冲突/卸载/Secrets及未验证边界。 truncated: false builder_handoff: - candidate_id: e88969d9-32c0-4321-a5a3-658023a60414 + candidate_id: 24c9df3f-4c07-4b34-a21a-8f2ccf9f4adb identity_provider: skill-coordinated - builder_execution_ref: skill-coordinated:builder:455faebd-84e1-49d0-8041-c6f44318a0ea - iteration: 1 + builder_execution_ref: skill-coordinated:builder:e3b4b544-7ef0-4d3f-bd5f-fb269d4455c2 + iteration: 2 summary: - text: 保留原框架无关业务核心,追加共享Pillow图片工具与农场快照渲染。673原素材独立打包,目标尺寸合成、三种编码、阶段变化失效、有界缓存、受控后台任务、取消排空和日志。13项绘图定向测试及3项打包检查通过,样图已目视检查。尚未执行本轮完整核心回归,由Runtime正式执行;真实框架与上传未接入,旧素材455个阶段引用缺图。 + text: 按独立验收建议修正余额不足的中文提示映射,增加无扣款回归测试;其余已确认A1-A18实现不变。此前55项/最低23项/工作流检查及独立验收通过,本轮受影响检查由Runtime重验,未执行远端或真实宿主。 truncated: false addressed_acceptance_ids: - A1 @@ -149,6 +192,11 @@ builder_handoff: - A11 - A12 - A13 + - A14 + - A15 + - A16 + - A17 + - A18 acceptance_review: - id: A1 status: implemented-with-evidence @@ -234,50 +282,71 @@ builder_handoff: - docs/10-rendering.md - docs/render-benchmark.json note: 新实现对应已确认规格,定向测试和离线样例可复核。 + - id: A14 + status: implemented-with-evidence + evidence: + - integrations/zhenxun/runtime.py + - integrations/zhenxun/commands.py + - tests/test_zhenxun_runtime.py + - tests/test_zhenxun_runtime.py::test_insufficient_funds_has_chinese_message_and_no_debit + note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。 + - id: A15 + status: implemented-with-evidence + evidence: + - scripts/build_zhenxun.py + - tests/test_zhenxun_package.py + note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。 + - id: A16 + status: implemented-with-evidence + evidence: + - scripts/verify_zhenxun.py + - scripts/install_zhenxun.py + - tests/test_zhenxun_package.py + note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。 + - id: A17 + status: implemented-with-evidence + evidence: + - .github/workflows/zhenxun-preview.yml + - release/zhenxun/verify.yml + - scripts/publish_zhenxun.py + - scripts/check_workflows.py + - tests/test_zhenxun_package.py + note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。 + - id: A18 + status: implemented-with-evidence + evidence: + - docs/11-zhenxun-preview.md + - scripts/check_pillow_compat.py + - tests/test_zhenxun_runtime.py + note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。 checks: - name: - text: render-behavior + text: insufficient-funds-message truncated: false result: passed note: - text: 13 tests passed - truncated: false - - name: - text: wheel-packaging - truncated: false - result: passed - note: - text: 3 tests passed; installed renderer generated PNG - truncated: false - - name: - text: visual-demo - truncated: false - result: passed - note: - text: 1024 WebP sample reviewed; three formats measured - truncated: false - - name: - text: full-regression - truncated: false - result: not-run - note: - text: 本轮完整检查交给Runtime执行 + text: Observed failing test, fixed mapping, targeted test passed; balance + unchanged truncated: false checks_truncated: false known_limits: - - text: 真实框架、上传、Linux和低配主机尚未运行验证。 + - text: 真实真寻/NoneBot依赖注入与平台发图尚未实际运行;模拟宿主不是线上验证。 truncated: false - - text: 中文字体需部署方合法提供;不捆绑字体。 + - text: 本机Windows已测,Linux仅配置CI尚未执行。 truncated: false - - text: 旧基线455个阶段引用缺图,已占位降级。 + - text: 工作流与跨仓库API只离线检查/模拟,未推送未运行GitHub。 truncated: false - - text: 测量为本机单次合成数据,不是旧版或等画质基准。 + - text: 独立预览库不迁移旧数据;同名指令测试需用户手动停用旧版。 truncated: false - - text: 公开发布前仍需核实历史素材分发权。 + - text: 默认JPEG;WebP需要用户确认消息平台能力后配置。 + truncated: false + - text: 缺失455个旧阶段素材引用以占位降级;字体及历史素材发布权需部署/发布前核实。 + truncated: false + - text: 本地源码dirty故产物会标记dirty,发布脚本拒绝发布dirty产物。 truncated: false known_limits_truncated: false review: null - submitted_at: 2026-10-15T08:36:49.457Z + submitted_at: 2026-10-15T09:52:39.181Z blockers: - owner: user reason: @@ -287,17 +356,17 @@ blockers: acceptance_ids: [] resolution_action: await-user verification: - candidate_id: e88969d9-32c0-4321-a5a3-658023a60414 + candidate_id: 24c9df3f-4c07-4b34-a21a-8f2ccf9f4adb identity_provider: skill-coordinated - verifier_execution_ref: skill-coordinated:verifier:853b70de-beda-44dd-b303-cf2b3b300676 - iteration: 1 + verifier_execution_ref: skill-coordinated:verifier:d68f2636-d977-4a84-bc40-0dec8d89c753 + iteration: 2 attempt: 1 assurance: skill-coordinated verdict: pass checks: - - id: offline-foundation-render + - id: all-offline name: - text: Offline core and renderer + text: Core render and zhenxun regression truncated: false argv_display: - text: scripts/verify_foundation.py @@ -306,23 +375,51 @@ verification: cwd_ref: . status: passed exit_code: 0 - duration_ms: 10608 + duration_ms: 22011 + - id: pillow-10-compat + name: + text: Pillow10.4 render and zhenxun + truncated: false + argv_display: + - text: scripts/check_pillow_compat.py + truncated: false + - text: --dependency-path + truncated: false + - text: .release-cache/pillow10 + truncated: false + argv_truncated: false + cwd_ref: . + status: passed + exit_code: 0 + duration_ms: 12697 + - id: workflow-guards + name: + text: Workflow YAML and boundaries + truncated: false + argv_display: + - text: scripts/check_workflows.py + truncated: false + - text: --dependency-path + truncated: false + - text: .release-cache/qa + truncated: false + argv_truncated: false + cwd_ref: . + status: passed + exit_code: 0 + duration_ms: 95 summary: - text: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。 + text: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。 truncated: false risks: - - text: 真实消息平台、上传链路、Linux及低配服务器尚未验证;性能报告仅本机单次构造样例,不等画质对照。 + - text: 真实真寻依赖注入、登录及平台发图未运行;Linux与GitHub工作流/跨仓库API仅配置或模拟,未宣称上线。 truncated: false - - text: 旧素材存在455个阶段引用缺失,已按规格显示问号并记录警告;不能宣称202种作物图片完整。 + - text: 沿用原指令需用户先停用旧插件;独立新库不迁移旧玩家数据。AUTO保守默认JPEG,WebP实际平台能力待用户测试。 truncated: false - - text: 部署需自备有权使用且包含所需中文字形的字体;当前只验证字体可加载,任意Unicode/Emoji覆盖不保证。 - truncated: false - - text: 历史素材公开分发权仍需在发布前核实。 - truncated: false - - text: 缓存预算为像素/编码内容核算,含预留,不代表整个进程峰值RSS硬上限。 + - text: 旧素材455个阶段引用缺图以占位降级;公开发布前需核实素材及字体使用权;当前dirty本地产物只用于测试,发布器拒绝dirty。 truncated: false risks_truncated: false - completed_at: 2026-10-15T08:40:00.608Z + completed_at: 2026-10-15T09:58:23.989Z history: - goal_cycle: 1 iteration: 1 @@ -361,6 +458,51 @@ history: 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 history_overflow: dropped_entries: 0 first_dropped_at: null diff --git a/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md b/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md new file mode 100644 index 0000000..0670186 --- /dev/null +++ b/docs/comet/changes/farm-core-foundation/specs/zhenxun-preview/spec.md @@ -0,0 +1,77 @@ +# 真寻可安装预览包 + +## 接入边界 + +仅提供真寻接入;业务和图片仍来自主仓库共享源码。接入层加载插件 +私有_vendor命名空间,渲染层对farm_core的引用在构建时改为私有相对 +导入,不修改开发源码、不污染全局sys.path或sys.modules别名。 +命令不增加测试前缀,沿用开通农场、我的农场、农场帮助、我的种子、 +我的作物、购买种子、播种、收获、铲除、作物加锁、作物解锁、出售作物 +等原指令,覆盖帮助、注册、我的农场、仓库、购买、 +播种、收获、铲除、锁定/解锁和出售;复用已有规则,不移植旧损坏模块。 +部署标识由配置给出,同一平台玩家在本预览部署内共享农场,不跨平台 +绑定;测试数据位置与旧版不同。事件标识包含机器人、平台、用户及 +原消息标识;无法获得稳定消息标识时写操作明确拒绝,不能随机重试。 +在启动时初始化存储、渲染器,在关闭时等待任务排空后关闭存储;字体 +必须配置为部署方拥有使用权的中文字体。无字体时文本功能仍可用, +我的农场返回明确配置提示。头像失败降级,不让头像下载无限等待。 + +Scenario: 真寻命令与生命周期 +Acceptance: A14 +模拟宿主验证命令路由、身份/幂等标识、异常反馈与启动关闭,使用独立库; +不把模拟宿主测试报告为真实平台发图成功。 + +## 产物 + +scripts/build_zhenxun.py按白名单复制真寻接入、farm_core、farm_render、 +对应数据/图片、LICENSE及NOTICE。ZIP含单一插件根目录,仓库导出目录 +可作为真寻发布仓库根。生成来源manifest与独立SHA256附件,清单不 +自包含哈希;本地工作区脏时标识dirty,不能冒充源HEAD的纯净构建。 +输入版本严格校验,ZIP排序/时间固定,目标存在则拒绝或显式选择新路径。 +不打包其他适配器、运行库、凭据、缓存、legacy或全局字体。 + +Scenario: 可复现白名单打包 +Acceptance: A15 +重复构建内容相同,独立目录导入私有模块成功,无旧目录和其他框架依赖。 + +## 安装验证 + +独立verify脚本校验manifest中每个文件与ZIP精确一致,拒绝多余/重复项、 +绝对路径、反斜线穿越、符号链接、不可信插件根名、哈希错误。 +安装器只安装到用户显式提供的插件父目录,既有目标拒绝覆盖,不删旧 +插件、不接触旧数据、不安装依赖、不启动/重启机器人;先校验再写入, +失败清理自己新建的临时目录。提供明确安装步骤和停止机器人提示。 + +Scenario: 安全校验与安装 +Acceptance: A16 +临时目录测试有效包可安装,坏包/穿越/已有目标均拒绝且不留下半成品。 + +## 工作流和发布 + +主仓库CI在PR/push及手动触发时执行检查,构建ZIP/清单/校验附件并上传 +Actions artifact。工作流权限默认只读;不可信PR不触发跨仓库写入。 +跨仓库同步是独立的手动选择,使用专用Secret/受保护environment,并 +固定目标Shu-Ying/zhenxun_plugin_farm的farm-preview分支。 +脚本默认dry-run;只有显式发布才联网。只创建或普通更新预览分支, +不force、不碰main/旧标签;并发分支改变时失败。发布仓库只含生成的 +可安装内容、来源和验证工具,以及验证工作流,不在那里维护业务源码。 +下载ZIP测试不要求先同步仓库,不要求用户先给令牌。 + +Scenario: 测试附件与可控同步 +Acceptance: A17 +静态检查工作流触发/权限和输入边界;模拟服务验证固定目标、冲突保护、 +令牌不落盘与默认dry-run。此任务不执行真实GitHub发布。 + +## 兼容与文档 + +真寻本地配置要求Pillow>=10,<11,本预览使用与其相交的>=10.4版本; +CI测试最低10.4和本项目支持当前版本,Windows/Linux。不得静默强制 +升级宿主到12。文档列出本机实际检查、模拟与真实宿主的差别,并提供 +主仓库生成、发布仓库布局、下载/依赖安装、字体、数据库、测试命令、 +与旧版避免冲突、备份/卸载和可选同步的凭据最小权限步骤。沿用原 +指令时必须由用户停用旧插件,不静默更名指令,不自动禁用旧插件。 + +Scenario: 可复现的真寻测试交付 +Acceptance: A18 +产物可离线验证,文档能按步骤启动人工测试;明确真实机器人与GitHub +工作流仍未实际执行,不宣称已经上线或已兼容全部平台。 diff --git a/docs/comet/changes/farm-core-foundation/verification.md b/docs/comet/changes/farm-core-foundation/verification.md index ba6cd92..4a64727 100644 --- a/docs/comet/changes/farm-core-foundation/verification.md +++ b/docs/comet/changes/farm-core-foundation/verification.md @@ -1,5 +1,5 @@ --- -generated_from_state_version: 14 +generated_from_state_version: 26 --- # Verification @@ -8,49 +8,55 @@ generated_from_state_version: 14 - Result: **Verification passed; your confirmation is required** - Verification status: **Checks completed, but your confirmation is required** -- Goal cycle: 3 -- Iteration: 1 +- Goal cycle: 5 +- Iteration: 2 - Verifier attempt: 1 -- Completed: 2026-10-15T08:40:00.608Z -- Summary: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。 +- Completed: 2026-10-15T09:58:23.989Z +- Summary: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。 ## Acceptance | ID | Result | Source | Criterion | Reason | | --- | --- | --- | --- | --- | -| A1 | passed | brief.md | A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。 | 827文件基线正式检查通过;检查包导入无线程/文件副作用及wheel独立安装,核心无框架和legacy运行依赖。 | -| A2 | passed | brief.md | A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。 | 只读源库导出按id排序并带源hash;202项导出检查通过,加载器校验版本、整数、阶段和重复定义。 | -| A3 | passed | brief.md | A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。 | 账户注册与初始500资产、三空地在事务内完成;重复注册及身份作用域隔离测试正式通过。 | -| A4 | passed | brief.md | A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, 不留下部分写入。 | 购买和播种在完整事务内扣款、入库、耗种、存图;数量、等级、余额和空地边界测试正式通过。 | -| A5 | passed | brief.md | A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。 | 成熟边界、经验增量、多季再生长及枯萎清除源实现与规格一致,逐地块加成及顺序测试通过。 | -| A6 | passed | brief.md | A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, 不发生旧版模板参数缺失。 | 锁定状态对指定及全部出售生效,出售只增本次收入,Receipt和文本使用真实余额;正式测试通过。 | -| A7 | passed | brief.md | A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 不重复领取,不吞数据库故障伪装成功;重启后数据可读。 | BEGIN IMMEDIATE、操作指纹与结果同事务保存;异常回滚、并发超卖/重复收获、取消重放、重启与schema测试通过。 | -| A8 | passed | brief.md | A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。 | README、核心说明、离线入口与测试可用,准确说明尚无真实宿主、真实库迁移和远程发布验证。 | -| A9 | passed | brief.md | A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。 | Pillow可选渲染模块支持透明合成、复制、缩放、裁剪、文字测量省略及三格式;核心无Pillow导入,wheel携带673素材并独立渲染通过。 | -| A10 | passed | brief.md | A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。 | 纯场景转换消费一次快照和统一now,首季/再季锚点与枯萎优先正确;旧30块布局及真实offset字段已迁出,普通高清解码与阶段测试通过,样例已视觉检查。 | -| A11 | passed | brief.md | A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。 | 目标尺寸直接合成;素材/底图与成图LRU有字节预算且返回副本;键含作用域、账户、阶段、头像、主题及编码配置,边界变化不复用旧图。 | -| A12 | passed | brief.md | A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 | 整体合成编码由to_thread执行,semaphore限制并发、pending限制容量,shield维持取消后的任务容量并在关闭排空;合并、异常重试、事件循环和并发测试通过,日志不含用户标识。 | -| A13 | passed | brief.md | A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 | 离线脚本生成两尺寸三格式冷暖测量及日志,报告与已查看样例一致;正式检查45项和基线/两类导出通过,文档明确本机单次、未测上传/低配/Linux及真实平台。 | +| A1 | passed | brief.md | A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。 | 827文件基线正式检查通过;核心源码仅相对导入,安装及导入副作用测试通过,不依赖legacy或框架。 | +| A2 | passed | brief.md | A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。 | 202条目录源值及导出复现、损坏目录拒绝测试通过;Catalogue本地读取且验证schema与重复对象。 | +| A3 | passed | brief.md | A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。 | repository.register在事务内发放500币和3地;重复注册与四段身份隔离正式测试通过。 | +| A4 | passed | brief.md | A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, 不留下部分写入。 | 购买播种均通过同一事务调用,整数/等级/资产/空地检查明确;失败无部分写入测试通过。 | +| A5 | passed | brief.md | A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。 | 收获按各地块单独计算增量、保存多季状态;成熟边界、加成排序、冻结规则与枯萎测试通过。 | +| A6 | passed | brief.md | A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, 不发生旧版模板参数缺失。 | 指定和全部出售均保护锁定状态,只加本次收入;文本及结构化回执返回真实账户,回归通过。 | +| A7 | passed | brief.md | A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 不重复领取,不吞数据库故障伪装成功;重启后数据可读。 | BEGIN IMMEDIATE和操作指纹/持久结果覆盖原子回滚、幂等冲突、并发及取消重放;重启/模式拒绝测试通过。 | +| A8 | passed | brief.md | A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。 | 离线入口、完整56项回归及实现说明可复核;真实宿主、迁库及远程发布边界有说明。 | +| A9 | passed | brief.md | A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。 | Canvas支持透明合成/复制/缩放/裁剪/文字/三格式编码;核心无Pillow运行依赖,wheel独立渲染测试通过。 | +| A10 | passed | brief.md | A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。 | 统一快照和now生成场景,阶段按首熟/再生长锚点映射,枯萎优先并使用实际偏移;普通高清解码及边界测试通过。 | +| A11 | passed | brief.md | A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。 | 底图直接为目标宽度;素材返回副本,LRU预算有界;身份、可见账户/地块阶段、头像和配置纳入键,正式缓存测试通过。 | +| A12 | passed | brief.md | A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 | RenderService整图to_thread,有限排队及并发、相同请求合并,shield取消和关闭排空;并发/取消/失败重试测试通过。 | +| A13 | passed | brief.md | A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 | 离线样例报告标注本机单次及未测上传;三格式冷暖数据、布局及文档保留,旧基线和原核心回归继续通过。 | +| A14 | passed | brief.md | A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。 | 沿用原中文指令及旧子命令,独立预览库、稳定事件哈希、完整身份、生命周期/字体/头像降级已核对;余额不足映射修正并通过无扣款新测试。 | +| A15 | passed | brief.md | A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。 | 构建白名单包含私有模块、素材/许可证NOTICE及来源哈希,固定ZIP元数据,不修改开发导入;复现测试通过;最终修复包校验30文件且runtime匹配源码。 | +| A16 | passed | brief.md | A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。 | 校验器拒绝重复/额外/穿越/符号链接/哈希错误,先验再安装且独占创建不覆盖;正式边界测试通过。Windows临时目录实测尾点空格变体未复现逃逸。 | +| A17 | passed | brief.md | A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。 | CI默认只读/附件输出;同步需手动main触发与受保护环境Secret,脚本默认离线且固定仓库farm-preview、拒绝默认/非生成分支、无force并保护竞态;模拟/守卫通过。 | +| A18 | passed | brief.md | A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。 | Windows本机56项及隔离Pillow10.4下24项均正式通过;CI矩阵明确Windows/Linux和10.4/12.3,文档含安装/字体/数据/原指令冲突/卸载/Secrets及未验证边界。 | ## Checks | Check | Command | Working directory | Status | Exit | Duration | | --- | --- | --- | --- | ---: | ---: | -| Offline core and renderer | scripts/verify_foundation.py | . | passed | 0 | 10608 ms | +| Core render and zhenxun regression | scripts/verify_foundation.py | . | passed | 0 | 22011 ms | +| Pillow10.4 render and zhenxun | scripts/check_pillow_compat.py --dependency-path .release-cache/pillow10 | . | passed | 0 | 12697 ms | +| Workflow YAML and boundaries | scripts/check_workflows.py --dependency-path .release-cache/qa | . | passed | 0 | 95 ms | ### Builder-reported evidence These are Builder reports, not Runtime check receipts or independent verification results. -- render-behavior: passed — 13 tests passed -- wheel-packaging: passed — 3 tests passed; installed renderer generated PNG -- visual-demo: passed — 1024 WebP sample reviewed; three formats measured -- full-regression: not-run — 本轮完整检查交给Runtime执行 -- Known limitation: 真实框架、上传、Linux和低配主机尚未运行验证。 -- Known limitation: 中文字体需部署方合法提供;不捆绑字体。 -- Known limitation: 旧基线455个阶段引用缺图,已占位降级。 -- Known limitation: 测量为本机单次合成数据,不是旧版或等画质基准。 -- Known limitation: 公开发布前仍需核实历史素材分发权。 +- insufficient-funds-message: passed — Observed failing test, fixed mapping, targeted test passed; balance unchanged +- Known limitation: 真实真寻/NoneBot依赖注入与平台发图尚未实际运行;模拟宿主不是线上验证。 +- Known limitation: 本机Windows已测,Linux仅配置CI尚未执行。 +- Known limitation: 工作流与跨仓库API只离线检查/模拟,未推送未运行GitHub。 +- Known limitation: 独立预览库不迁移旧数据;同名指令测试需用户手动停用旧版。 +- Known limitation: 默认JPEG;WebP需要用户确认消息平台能力后配置。 +- Known limitation: 缺失455个旧阶段素材引用以占位降级;字体及历史素材发布权需部署/发布前核实。 +- Known limitation: 本地源码dirty故产物会标记dirty,发布脚本拒绝发布dirty产物。 ## Blockers @@ -58,11 +64,9 @@ These are Builder reports, not Runtime check receipts or independent verificatio ## Risks and skipped work -- 真实消息平台、上传链路、Linux及低配服务器尚未验证;性能报告仅本机单次构造样例,不等画质对照。 -- 旧素材存在455个阶段引用缺失,已按规格显示问号并记录警告;不能宣称202种作物图片完整。 -- 部署需自备有权使用且包含所需中文字形的字体;当前只验证字体可加载,任意Unicode/Emoji覆盖不保证。 -- 历史素材公开分发权仍需在发布前核实。 -- 缓存预算为像素/编码内容核算,含预留,不代表整个进程峰值RSS硬上限。 +- 真实真寻依赖注入、登录及平台发图未运行;Linux与GitHub工作流/跨仓库API仅配置或模拟,未宣称上线。 +- 沿用原指令需用户先停用旧插件;独立新库不迁移旧玩家数据。AUTO保守默认JPEG,WebP实际平台能力待用户测试。 +- 旧素材455个阶段引用缺图以占位降级;公开发布前需核实素材及字体使用权;当前dirty本地产物只用于测试,发布器拒绝dirty。 ## Previous iterations @@ -72,9 +76,14 @@ These are Builder reports, not Runtime check receipts or independent verificatio | 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 | ## Conclusion -独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。 +独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。 diff --git a/docs/progress.md b/docs/progress.md index 9546b13..4b0e2c3 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -52,3 +52,24 @@ 可重复检查全部成功。已启动独立只读Verifier检查A1-A13,尚待结论。 - 独立Verifier已完成完整规格/源码/测试和实际样图核对,A1-A13全部 通过,Runtime接受报告并等待用户接受;未执行归档、提交或发布。 + +## 真寻预览交付 + +- 用户选择接入+工作流+安装脚本,并明确不增加测试前缀,沿用原指令。 +- 独立预览数据库和日志、稳定事件幂等、启动/关闭、头像超时及图片接入。 +- 白名单私有模块包、源清单和哈希、拒绝覆盖的安装器、固定测试分支 + 的默认dry-run同步脚本已实现;未执行远端或真实机器人操作。 +- 6项打包/安装/模拟发布测试和4项模拟宿主/核心联合测试通过。 +- Pillow10.4独立测试目录的13项绘图测试通过;不改宿主依赖。 +- 工作流YAML及发布守卫检查通过;主CI覆盖Windows/Linux和两版Pillow, + 真实GitHub运行仍未验证。 +- 固定ZIP创建系统与权限,解决Windows资源到Linux CI复现的问题。 +- 代码图连接本轮不可用,相关宿主接口已直接阅读本地源码核对。 +- 当前候选准备进入Runtime全量回归和独立只读验收。 +- 首轮55项回归、最低依赖23项和工作流守卫通过,独立A1-A18通过。 +- 按审查建议补正余额不足中文提示,先观察定向回归失败再修复; + 第二轮56项回归、最低依赖24项和工作流守卫全部通过。 +- 新的独立只读Verifier确认A1-A18全部通过,最终ZIP30文件校验通过, + 包内runtime.py与修复源码一致。已生成dist/zhenxun-preview-ready交付。 +- 最终大小/哈希和验证边界保存zhenxun-preview-checks.json。 +- 当前等待用户接受验收;没有提交、归档、推送、发布或真实宿主安装。 diff --git a/docs/task_plan.md b/docs/task_plan.md index 945a227..ed7d2b0 100644 --- a/docs/task_plan.md +++ b/docs/task_plan.md @@ -68,3 +68,13 @@ - [x] 生成普通/高清样例并检查文字布局,保存三格式测量和使用文档。 - [x] 正式Runtime检查45项测试通过;独立Verifier判定全部A1-A13通过。 - [ ] 用户接受结果后归档。 + +## 第四轮:真寻原指令预览包 + +- [x] 用户确认接入、打包工作流和安装脚本;原指令,不加测试前缀。 +- [x] 真寻命令、独立库、日志、生命周期和图片接入。 +- [x] 白名单ZIP、校验/安装、固定预览分支dry-run发布脚本。 +- [x] 主仓库CI附件与发布仓库校验工作流;未执行远端操作。 +- [x] 定向打包与宿主模拟测试、最低Pillow绘图和YAML检查。 +- [x] Runtime56项、Pillow10.4下24项及工作流检查通过;独立A1-A18通过。 +- [ ] 用户接受后归档;真实机器人测试由用户部署后进行。 diff --git a/docs/zhenxun-implementation-plan.md b/docs/zhenxun-implementation-plan.md new file mode 100644 index 0000000..96d452e --- /dev/null +++ b/docs/zhenxun-implementation-plan.md @@ -0,0 +1,15 @@ +# 真寻测试包实施计划 + +沿用已确认A1-A13;新增验收A14-A18,正式目标见Comet zhenxun-preview规格。 +用户明确使用原指令,不增加测试前缀;独立测试库不迁移旧玩家数据。 + +- [x] 先写打包/安装/发布边界测试并观察缺功能失败。 +- [x] integrations/zhenxun:原指令、身份及事件幂等、独立存储、生命周期。 +- [x] scripts/build_zhenxun.py:白名单完整插件与来源清单、可复现ZIP。 +- [x] scripts/verify_zhenxun.py、install_zhenxun.py:离线校验与拒绝覆盖。 +- [x] scripts/publish_zhenxun.py:固定预览分支、默认dry-run、普通更新。 +- [x] 主仓库工作流构建附件;生成仓库校验工作流;最小权限与手动同步。 +- [x] 原指令路由与实际私有核心联合测试;Pillow10.4与当前版本检查。 +- [x] 构建可下载本地ZIP和文档,提交Runtime检查与独立只读验收。 + +不执行远端操作,不安装/重启真实机器人,不复制无授权字体。 diff --git a/docs/zhenxun-preview-checks.json b/docs/zhenxun-preview-checks.json new file mode 100644 index 0000000..1b87af0 --- /dev/null +++ b/docs/zhenxun-preview-checks.json @@ -0,0 +1,29 @@ +{ + "schema": "farm.zhenxun-preview-checks.v1", + "date": "2026-10-15", + "target": "Shu-Ying/zhenxun_plugin_farm", + "preview_branch": "farm-preview", + "package": { + "path": "dist/zhenxun-preview-ready/zhenxun_plugin_farm_preview-0.1.0-preview.1.zip", + "bytes": 12386909, + "sha256": "8fe66569a886111ce85187d5ecfe9ec68cc9aee9cd8311bd3fcff43239a61f85", + "files": 30, + "source_dirty": true + }, + "checks": { + "runtime_regression_tests": 56, + "pillow_10_4_tests": 24, + "workflow_yaml_guards": "passed", + "independent_acceptance": "A1-A18 passed", + "final_zip_checksum": "passed" + }, + "remote_operations_executed": false, + "real_bot_installed_or_started": false, + "limitations": [ + "真实NoneBot依赖注入、权限及平台发图未运行验证", + "本机Windows;Linux和GitHub工作流仅配置,未实际运行", + "独立测试库不读取旧资产;沿用原指令,需停用旧插件避免冲突", + "缺少历史455阶段图片仍占位;字体需自行合法提供", + "本地dirty产物仅供测试,正式同步需提交后的干净源码" + ] +} diff --git a/integrations/zhenxun/README.md b/integrations/zhenxun/README.md index 7978cd6..f7456ba 100644 --- a/integrations/zhenxun/README.md +++ b/integrations/zhenxun/README.md @@ -1,6 +1,8 @@ -# 真寻接入预留位置 +# 真寻预览接入 发布目标:https://github.com/Shu-Ying/zhenxun_plugin_farm.git -负责命令、配置、真寻金币、数据目录、权限和生命周期。 -共享核心通过插件私有路径加载。旧真寻入口仍在 legacy 中, -本目录尚不可安装。远程仓库本轮不再拉取或推送。 +原指令、独立预览数据目录、身份和事件幂等、启动/关闭及发图接入。 +不接入真寻金币兑换,不迁移旧数据。核心/渲染由主仓库白名单打包, +生成时修正为插件私有相对导入,本目录本身不是完整安装包。 +使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。 +本轮没有拉取、推送或操作真实机器人。 diff --git a/integrations/zhenxun/__init__.py b/integrations/zhenxun/__init__.py new file mode 100644 index 0000000..b4808ee --- /dev/null +++ b/integrations/zhenxun/__init__.py @@ -0,0 +1,20 @@ +"""真寻农场独立预览插件入口。""" + +from nonebot.plugin import PluginMetadata +from zhenxun.configs.utils import Command, PluginExtraData + +from ._version import __version__ +from .runtime import COMMANDS, HELP + +__plugin_meta__ = PluginMetadata( + name="农场预览", description="共享农场核心的真寻预览版", + usage=HELP, type="application", + homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm", + supported_adapters={"~onebot.v11", "~qq"}, + extra=PluginExtraData( + author="Shu-Ying", version=__version__, menu_type="群内小游戏", + commands=[Command(command=name) for name in COMMANDS], + ).to_dict(), +) + +from . import commands diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py new file mode 100644 index 0000000..54feacc --- /dev/null +++ b/integrations/zhenxun/commands.py @@ -0,0 +1,84 @@ +"""宿主依赖止于接入层;保持旧版中文入口,不新增测试前缀。""" + +import asyncio +import logging +import time + +from nonebot import get_driver, on_command, require +from nonebot.adapters import Bot, Event, Message +from nonebot.params import CommandArg + +require("nonebot_plugin_uninfo") +from nonebot_plugin_uninfo import Uninfo +from zhenxun.configs.path_config import DATA_PATH +from zhenxun.utils.message import MessageUtils +from zhenxun.utils.platform import PlatformUtils + +from .runtime import COMMANDS, Runtime + +logger = logging.getLogger(__name__) +driver = get_driver() +runtime = Runtime( + getattr(driver.config, "farm_preview_data_dir", DATA_PATH / "farm_preview"), + 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(), +) +driver.on_startup(runtime.start) +driver.on_shutdown(runtime.close) +_avatars = {} + + +async def avatar_for(user_id, platform): + if platform not in ("OneBot V11", "QQ"): + return None + key = (platform, user_id) + now = time.monotonic() + cached = _avatars.get(key) + if cached and cached[0] > now: + return cached[1] + try: + content = await asyncio.wait_for( + PlatformUtils.get_user_avatar(user_id, "qq"), timeout=2, + ) + if content and len(content) > 2 * 1024 * 1024: + content = None + except Exception: + content = None + logger.warning("farm preview avatar unavailable; using default") + while len(_avatars) >= 16: + _avatars.pop(next(iter(_avatars))) + _avatars[key] = (now + (300 if content else 30), content) + return content + + +def handler_for(action): + async def handle( + bot: Bot, event: Event, session: Uninfo, + args: Message = CommandArg(), + ): + platform = bot.adapter.get_name() + user_id = str(session.user.id) + message_id = getattr(event, "message_id", None) + if message_id is None: + message_id = getattr(event, "id", None) + try: + avatar = (await avatar_for(user_id, platform) + if action == "farm" else None) + response = await runtime.execute( + action, args.extract_plain_text(), bot_id=str(bot.self_id), + platform=platform, user_id=user_id, message_id=message_id, + display_name=session.user.name or "农场玩家", avatar=avatar, + ) + except Exception as error: + logger.error( + "farm preview request failed type=%s", type(error).__name__, + ) + response = "农场服务异常,请查看服务器日志。" + await MessageUtils.build_message(response).send(reply_to=True) + return handle + + +for command, action in COMMANDS.items(): + matcher = on_command(command, priority=5, block=True) + matcher.handle()(handler_for(action)) diff --git a/integrations/zhenxun/requirements.txt b/integrations/zhenxun/requirements.txt new file mode 100644 index 0000000..4d506e2 --- /dev/null +++ b/integrations/zhenxun/requirements.txt @@ -0,0 +1,3 @@ +# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。 +# 与真寻当前pillow<11的约束取交集,不升级到12。 +Pillow>=10.4,<11 diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py new file mode 100644 index 0000000..326bde4 --- /dev/null +++ b/integrations/zhenxun/runtime.py @@ -0,0 +1,188 @@ +"""真寻接入编排;核心私有导入,测试数据与旧版隔离。""" + +import hashlib +import json +import logging +from pathlib import Path +import shlex + +from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore +from ._vendor.farm_core.domain import FarmError +from ._vendor.farm_core.presentation import receipt_text, snapshot_text +from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService + +logger = logging.getLogger(__name__) +COMMANDS = { + "开通农场":"register", "我的农场":"farm", "农场详述":"inventory", + "我的农场币":"inventory", "我的点券":"inventory", + "我的种子":"inventory", "我的作物":"inventory", + "种子商店":"shop", "农场帮助":"help", "购买种子":"buy", + "播种":"sow", "收获":"harvest", "铲除":"eradicate", + "作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell", +} +HELP = ( + "开通农场 [名称]|我的农场 [高清]|农场详述\n" + "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" + "收获|铲除|我的种子|我的作物\n" + "作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n" + "本版使用独立预览数据库,不会读取旧版资产。" +) +ERRORS = { + "not_registered":"请先发送:开通农场", + "unknown_plant":"没有这种作物,请查看种子商店。", + "invalid_integer":"数量必须为有效的正整数。", + "insufficient_funds":"余额不足。", + "insufficient_stock":"库存不足。", + "insufficient_empty_plots":"空地不足。", + "level_required":"等级不足。", "plant_locked":"该作物已锁定。", + "nothing_to_harvest":"当前没有成熟作物。", + "nothing_to_eradicate":"当前没有枯萎作物。", + "nothing_to_sell":"当前没有可出售作物。", + "operation_conflict":"该事件已处理但参数不一致,请发送新消息。", +} + + +def operation_id(bot_id, platform, user_id, message_id, action): + if message_id is None or str(message_id) == "": + return None + return hashlib.sha256(json.dumps( + [str(bot_id), platform, user_id, str(message_id), action], + ensure_ascii=False, + ).encode()).hexdigest() + + +class Runtime: + def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview", + image_format="AUTO"): + self.data_dir = Path(data_dir).resolve() + self.font_path = font_path + self.deployment = deployment + self.image_format = image_format + self.store = SQLiteStore(self.data_dir / "farm-preview.sqlite3") + self.service = FarmService(self.store, Catalogue.bundled()) + self.rendering = None + self.started = False + self.log_handler = None + + async def start(self): + if self.started: + return + self.data_dir.mkdir(parents=True, exist_ok=True) + await self.store.initialize() + 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) + if self.font_path: + try: + self.rendering = RenderService(FarmRenderer(self.font_path)) + except (ValueError, OSError): + logger.warning("farm preview font unavailable; text mode only") + self.started = True + logger.info("farm preview initialized schema=1") + + async def close(self): + try: + if self.rendering: + await self.rendering.close() + 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 + + async def execute( + self, action, arguments, *, bot_id, platform, user_id, message_id, + display_name="农场玩家", avatar=None, + ): + if not self.started: + return "农场尚未初始化,请检查启动日志。" + try: + who = Identity(self.deployment, str(bot_id), platform, str(user_id)) + args = shlex.split(arguments) + if action == "farm" and args: + aliases = { + "detail":"inventory", "my-point":"inventory", + "my-vipPoint":"inventory", "my-seed":"inventory", + "my-plant":"inventory", "seed-shop":"shop", + "buy-seed":"buy", "sowing":"sow", "harvest":"harvest", + "eradicate":"eradicate", "lock-plant":"lock", + "unlock-plant":"unlock", "sell-plant":"sell", + "farm-help":"help", + } + if args[0] in aliases: + action = aliases[args[0]] + args = args[1:] + op = operation_id( + bot_id, platform, str(user_id), message_id, action, + ) + if action == "help": + return HELP + if action == "shop": + page = int(args[0]) if args else 1 + if not 1 <= page <= 100 or len(args) > 1: + raise ValueError() + plants = sorted( + self.service.catalogue, key=lambda p: p.plant_id, + ) + chunk = plants[(page-1)*12:page*12] + return "种子商店 " + str(page) + "页\n" + "\n".join( + f"{p.name}:{p.cost}{'点券' if p.is_vip else '农场币'}" + f" / 等级{p.level}" for p in chunk + ) + if action == "inventory": + if args: + raise ValueError() + return snapshot_text(await self.service.snapshot(who)) + if action == "farm": + if args not in ([], ["高清"]): + raise ValueError() + if not self.rendering: + return "请配置 FARM_PREVIEW_FONT 为可用中文字体路径。" + options = (RenderOptions.high(format=self.image_format) + if args else RenderOptions(format=self.image_format)) + picture = await self.rendering.my_farm( + self.service, who, options=options, avatar=avatar, + supported=("JPEG",), + ) + return picture.content + if op is None: + return "无法取得稳定消息标识,本次写操作未执行。" + if action == "register": + name = " ".join(args) if args else display_name + name = " ".join(str(name).split())[:64] or "农场玩家" + receipt = await self.service.register(who, name, op) + elif action in ("buy", "sow", "sell"): + if action == "sell" and not args: + receipt = await self.service.sell(who, None, None, op) + else: + if len(args) not in (1, 2) or (action == "buy" + and len(args) != 2): + raise ValueError() + count = int(args[1]) if len(args) == 2 else None + receipt = await getattr(self.service, action)( + who, args[0], count, op, + ) + elif action in ("lock", "unlock"): + if len(args) != 1: + raise ValueError() + receipt = await self.service.lock( + who, args[0], action == "lock", op, + ) + elif action in ("harvest", "eradicate"): + if args: + raise ValueError() + receipt = await getattr(self.service, action)(who, op) + else: + return HELP + return receipt_text(receipt) + except FarmError as error: + return ERRORS.get(error.code, "操作未完成:" + error.code) + except ValueError: + return "参数有误,请发送:农场帮助" diff --git a/release/targets.json b/release/targets.json index 9e0d0a2..f5ed88e 100644 --- a/release/targets.json +++ b/release/targets.json @@ -1,6 +1,6 @@ { "schema": "farm.release-targets.v1", - "status": "design-only", + "status": "zhenxun-preview", "source_repository": "https://github.com/Shu-Ying/main_plugin_farm.git", "network_actions_enabled": false, "targets": [ @@ -9,7 +9,9 @@ "repository": "https://github.com/Shu-Ying/zhenxun_plugin_farm.git", "observed_default_branch": "main", "entry_directory": "integrations/zhenxun", - "enabled": false + "enabled": true, + "preview_branch": "farm-preview", + "publish_by_default": false }, { "name": "astrbot", diff --git a/release/zhenxun/verify.yml b/release/zhenxun/verify.yml new file mode 100644 index 0000000..c6ce026 --- /dev/null +++ b/release/zhenxun/verify.yml @@ -0,0 +1,18 @@ +name: Verify generated farm preview +on: + push: + branches: [farm-preview] + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: python verify_package.py --directory . diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py new file mode 100644 index 0000000..faac53f --- /dev/null +++ b/scripts/build_zhenxun.py @@ -0,0 +1,116 @@ +"""从主仓库白名单构建真寻独立预览包,无远端操作。""" + +import argparse +import ast +import hashlib +import json +from pathlib import Path +import re +import subprocess +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package + + +def source_identity(root): + def git(*args): + return subprocess.check_output( + ["git", *args], cwd=root, text=True, encoding="utf-8", + ).strip() + return { + "repository": "https://github.com/Shu-Ying/main_plugin_farm.git", + "commit": git("rev-parse", "HEAD"), + "dirty": bool(git("status", "--porcelain", "--untracked-files=normal")), + } + + +def build(root, output, version): + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?", version): + raise ValueError("Invalid preview version") + root, output = Path(root).resolve(), Path(output).resolve() + if output.exists(): + raise FileExistsError("Use a new output directory") + if not output.is_relative_to(root / "dist") and output.is_relative_to(root): + if any(output.is_relative_to(root / part) for part in ( + "src", "integrations", "scripts", "legacy", ".git", + )): + raise ValueError("Output must not be a source directory") + files = {} + integration = root / "integrations/zhenxun" + for name in ( + "__init__.py", "runtime.py", "commands.py", "requirements.txt", + ): + files[name] = (integration / name).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", + ) + ] + for path in selected: + if path.is_symlink(): + raise ValueError("Source links are not allowed") + content = path.read_bytes() + if package == "farm_render" and path.suffix == ".py": + content = re.sub( + rb"(?m)^from farm_core(?=[. ])", b"from ..farm_core", + content, + ) + relative = path.relative_to(source).as_posix() + files[f"_vendor/{package}/{relative}"] = content + for name, source in { + "LICENSE": root / "LICENSE", + "README.md": root / "docs/11-zhenxun-preview.md", + "verify_package.py": root / "scripts/verify_zhenxun.py", + ".github/workflows/verify.yml": root / "release/zhenxun/verify.yml", + }.items(): + files[name] = source.read_bytes() + files["_version.py"] = f'__version__ = "{version}"\n'.encode() + for name, content in files.items(): + if name.endswith(".py"): + ast.parse(content, filename=name, feature_version=(3, 10)) + manifest = { + "schema": 1, "target": "zhenxun", "plugin": ROOT_NAME, + "version": version, "source": source_identity(root), + "files": {name: hashlib.sha256(content).hexdigest() + for name, content in sorted(files.items())}, + } + files[MANIFEST] = (json.dumps( + manifest, ensure_ascii=False, sort_keys=True, indent=2, + ) + "\n").encode() + verify_files(files) + output.mkdir(parents=True) + repository = output / "repository" + for name, content in sorted(files.items()): + path = repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + archive_path = output / f"{ROOT_NAME}-{version}.zip" + with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive: + for name, content in sorted(files.items()): + item = ZipInfo(f"{ROOT_NAME}/{name}", (2020, 1, 1, 0, 0, 0)) + item.create_system = 3 + item.compress_type = ZIP_DEFLATED + item.external_attr = 0o100644 << 16 + archive.writestr(item, content) + verify_package(archive_path) + checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest() + archive_path.with_suffix(".zip.sha256").write_text( + f"{checksum} {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("--version", default="0.1.0-preview.1") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + print(build(root, args.output, args.version)) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_pillow_compat.py b/scripts/check_pillow_compat.py new file mode 100644 index 0000000..6c7209d --- /dev/null +++ b/scripts/check_pillow_compat.py @@ -0,0 +1,32 @@ +"""在显式隔离依赖路径下检查绘图及真寻测试,不修改宿主环境。""" + +import argparse +from pathlib import Path +import sys +import unittest + + +def main(): + root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dependency-path", type=Path, required=True) + parser.add_argument("--expected-version", default="10.4.0") + parser.add_argument("--pattern", action="append") + args = parser.parse_args() + sys.path.insert(0, str(root / "src")) + sys.path.insert(0, str(args.dependency_path.resolve(strict=True))) + import PIL + if PIL.__version__ != args.expected_version: + raise SystemExit("Unexpected Pillow version") + print(f"Pillow compatibility check: {PIL.__version__}", flush=True) + suite = unittest.TestSuite() + for pattern in args.pattern or ("test_render.py", "test_zhenxun*.py"): + suite.addTests(unittest.defaultTestLoader.discover( + str(root / "tests"), pattern=pattern, + )) + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py new file mode 100644 index 0000000..bbb3fe2 --- /dev/null +++ b/scripts/check_workflows.py @@ -0,0 +1,44 @@ +"""检查两个生成工作流的YAML及发布权限;依赖仅用于开发检查。""" + +import argparse +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dependency-path", type=Path) + args = parser.parse_args() + if args.dependency_path: + sys.path.insert(0, str(args.dependency_path.resolve(strict=True))) + import yaml + root = Path(__file__).resolve().parents[1] + source = yaml.load(( + root / ".github/workflows/zhenxun-preview.yml" + ).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + generated = yaml.load(( + root / "release/zhenxun/verify.yml" + ).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + if source["permissions"] != {"contents":"read"}: + raise ValueError("Source workflow permissions differ") + trigger = source["on"] + if set(trigger) != {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unsafe or missing workflow trigger") + inputs = trigger["workflow_dispatch"]["inputs"] + if inputs["publish"]["default"] != "false": + raise ValueError("Publish must be opt-in") + sync = source["jobs"]["sync-preview"] + if (sync["environment"] != "zhenxun-preview" + or "refs/heads/main" not in sync["if"] + or "workflow_dispatch" not in sync["if"] + or sync["needs"] != ["test", "build"]): + raise ValueError("Publish boundary differs") + if generated["permissions"] != {"contents":"read"}: + raise ValueError("Generated workflow permissions differ") + if generated["on"]["push"]["branches"] != ["farm-preview"]: + raise ValueError("Generated branch differs") + print("Workflow YAML and publication guards checked") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_font.py b/scripts/ci_font.py new file mode 100644 index 0000000..e9b24a8 --- /dev/null +++ b/scripts/ci_font.py @@ -0,0 +1,27 @@ +"""为CI选择现有字体;部署仍须显式配置中文字体。""" + +import os +from pathlib import Path + + +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 __name__ == "__main__": + path = choose_font() + environment_file = os.environ.get("GITHUB_ENV") + if environment_file: + with open(environment_file, "a", encoding="utf-8") as output: + output.write(f"FARM_TEST_FONT={path}\n") + print(path) diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index 7fd519c..250a215 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -45,7 +45,10 @@ def export(root): metadata["images"][name] = { "size": size, "sha256": hashlib.sha256(content).hexdigest(), } - bundle.writestr(ZipInfo(name, (2020, 1, 1, 0, 0, 0)), content) + item = ZipInfo(name, (2020, 1, 1, 0, 0, 0)) + item.create_system = 3 + item.external_attr = 0o100644 << 16 + bundle.writestr(item, content) return output.getvalue(), ( json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) + "\n" diff --git a/scripts/install_zhenxun.py b/scripts/install_zhenxun.py new file mode 100644 index 0000000..1ccb23d --- /dev/null +++ b/scripts/install_zhenxun.py @@ -0,0 +1,53 @@ +"""安装到显式指定的插件父目录;拒绝覆盖,不安装依赖或重启宿主。""" + +import argparse +from pathlib import Path +import shutil +import tempfile + +from verify_zhenxun import ROOT_NAME, read_package + + +def install(package, plugins): + files = read_package(package) + plugins = Path(plugins).resolve(strict=True) + if not plugins.is_dir(): + raise ValueError("Plugin parent must be an existing directory") + target = plugins / ROOT_NAME + if target.exists() or target.is_symlink(): + raise FileExistsError( + "Target already exists; backup/uninstall manually", + ) + with tempfile.TemporaryDirectory( + prefix=".farm-install-", dir=plugins, + ) as temporary: + staging = Path(temporary) / ROOT_NAME + staging.mkdir() + for name, content in files.items(): + path = staging / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + # 独占创建避免并发覆盖;失败仅清理本次新建目标。 + target.mkdir() + try: + for item in staging.iterdir(): + shutil.move(str(item), str(target / item.name)) + except BaseException: + if target.resolve().parent == plugins: + shutil.rmtree(target) + raise + return target + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument("--plugins-dir", type=Path, required=True) + args = parser.parse_args() + print(install(args.package, args.plugins_dir)) + print("Installed files only. Configure font/data, disable old plugin, " + "then start the bot manually.") + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_zhenxun.py b/scripts/publish_zhenxun.py new file mode 100644 index 0000000..42d6a0b --- /dev/null +++ b/scripts/publish_zhenxun.py @@ -0,0 +1,100 @@ +"""只向固定真寻预览分支普通更新;默认离线dry-run。""" + +import argparse +import base64 +import json +import os +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from verify_zhenxun import read_package, verify_files + +REPOSITORY = "Shu-Ying/zhenxun_plugin_farm" +BRANCH = "farm-preview" + + +def github(method, path, payload, token): + request = Request( + f"https://api.github.com/repos/{REPOSITORY}/{path}", + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "farm-preview-publisher"}, method=method, + ) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as error: + if error.code == 404 and method == "GET": + return None + raise RuntimeError( + f"GitHub request failed: HTTP {error.code}", + ) from None + + +def publish(package, confirmed=False, request=None): + files = read_package(package) + manifest = verify_files(files) + result = {"repository": REPOSITORY, "branch": BRANCH, + "version": manifest["version"], "published": False} + if not confirmed: + return result + if manifest["source"]["dirty"]: + raise ValueError("Publish requires a clean source checkout") + token = os.environ.get("FARM_RELEASE_TOKEN") + if not token: + raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job") + call = request or (lambda method, path, data: github( + method, path, data, token, + )) + repository = call("GET", "", None) + if not repository or repository.get("default_branch") == BRANCH: + raise ValueError("Preview must not be the repository default branch") + ref = call("GET", f"git/ref/heads/{BRANCH}", None) + parent = ref["object"]["sha"] if ref else None + if parent: + owned = call("GET", f"contents/FARM-MANIFEST.json?ref={BRANCH}", None) + if not owned or owned.get("encoding") != "base64": + raise ValueError("Existing preview branch is not generated by Farm") + previous = json.loads(base64.b64decode(owned["content"])) + if (previous.get("target") != "zhenxun" + or previous.get("plugin") != manifest["plugin"]): + raise ValueError("Existing preview branch ownership differs") + tree = [] + for name, content in sorted(files.items()): + blob = call("POST", "git/blobs", { + "content": base64.b64encode(content).decode(), "encoding":"base64", + }) + tree.append({"path":name, "mode":"100644", "type":"blob", + "sha":blob["sha"]}) + generated = call("POST", "git/trees", {"tree":tree}) + commit = call("POST", "git/commits", { + "message": f"Generate farm preview {manifest['version']} " + f"from {manifest['source']['commit']}", + "tree": generated["sha"], "parents": [parent] if parent else [], + }) + if parent: + current = call("GET", f"git/ref/heads/{BRANCH}", None) + if not current or current["object"]["sha"] != parent: + raise RuntimeError("Preview branch changed; retry explicitly") + call("PATCH", f"git/refs/heads/{BRANCH}", { + "sha": commit["sha"], "force": False, + }) + else: + call("POST", "git/refs", { + "ref": f"refs/heads/{BRANCH}", "sha": commit["sha"], + }) + return {**result, "published": True, "commit": commit["sha"]} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package") + parser.add_argument("--publish", action="store_true") + args = parser.parse_args() + print(json.dumps(publish(args.package, args.publish), ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_zhenxun.py b/scripts/verify_zhenxun.py new file mode 100644 index 0000000..94a32c4 --- /dev/null +++ b/scripts/verify_zhenxun.py @@ -0,0 +1,98 @@ +"""独立验证生成的真寻包;仅标准库,不导入或启动插件。""" + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import stat +from zipfile import ZipFile + +ROOT_NAME = "zhenxun_plugin_farm_preview" +MANIFEST = "FARM-MANIFEST.json" +MAX_BYTES = 100 * 1024 * 1024 + + +def safe_path(name): + path = PurePosixPath(name) + if (not name or "\\" in name or ":" in name or path.is_absolute() + or any(part in (".", "..", "") for part in name.split("/")) + or path.as_posix() != name): + raise ValueError("Unsafe package path") + return path + + +def verify_files(files): + if MANIFEST not in files or len(files[MANIFEST]) > 1024 * 1024: + raise ValueError("Missing or oversized manifest") + manifest = json.loads(files[MANIFEST]) + if (manifest.get("schema") != 1 or manifest.get("target") != "zhenxun" + or manifest.get("plugin") != ROOT_NAME): + raise ValueError("Invalid package identity") + expected = manifest["files"] + if (not isinstance(expected, dict) + or set(files) != set(expected) | {MANIFEST}): + raise ValueError("Package files differ from manifest") + for name, digest in expected.items(): + safe_path(name) + if hashlib.sha256(files[name]).hexdigest() != digest: + raise ValueError("Package checksum mismatch") + required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt", + "LICENSE", "_vendor/farm_core/__init__.py", + "_vendor/farm_render/data/assets.zip"} + if not required <= set(files): + raise ValueError("Incomplete plugin") + return manifest + + +def read_package(path): + with ZipFile(path) as archive: + items = archive.infolist() + names = [item.filename for item in items] + if (len(names) != len(set(names)) or len(names) > 200 + or sum(item.file_size for item in items) > MAX_BYTES): + raise ValueError("Duplicate paths or package too large") + files = {} + for item in items: + name = safe_path(item.filename) + if (len(name.parts) < 2 or name.parts[0] != ROOT_NAME + or item.is_dir() + or stat.S_ISLNK(item.external_attr >> 16)): + raise ValueError("Unexpected package root or link") + files[name.relative_to(ROOT_NAME).as_posix()] = archive.read(item) + verify_files(files) + return files + + +def verify_package(path): + return verify_files(read_package(path)) + + +def verify_directory(directory): + directory = Path(directory).resolve() + files = {} + for path in directory.rglob("*"): + relative = path.relative_to(directory) + if any(part in (".git", "__pycache__") for part in relative.parts): + continue + if path.is_symlink(): + raise ValueError("Unexpected symbolic link") + if path.is_file(): + files[relative.as_posix()] = path.read_bytes() + return verify_files(files) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", nargs="?", type=Path) + parser.add_argument("--directory", type=Path) + args = parser.parse_args() + if bool(args.package) == bool(args.directory): + parser.error("Choose a ZIP or --directory") + manifest = (verify_directory(args.directory) if args.directory + else verify_package(args.package)) + print(f"Verified zhenxun {manifest['version']}: " + f"{len(manifest['files'])} files") + + +if __name__ == "__main__": + main() diff --git a/src/farm_render/data/assets.zip b/src/farm_render/data/assets.zip index a144c02..d2e42f5 100644 Binary files a/src/farm_render/data/assets.zip and b/src/farm_render/data/assets.zip differ diff --git a/tests/test_zhenxun_package.py b/tests/test_zhenxun_package.py new file mode 100644 index 0000000..5351398 --- /dev/null +++ b/tests/test_zhenxun_package.py @@ -0,0 +1,160 @@ +import importlib.util +import base64 +import json +from pathlib import Path +import sys +import tempfile +import unittest +from zipfile import ZipFile +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +try: + from build_zhenxun import build + from install_zhenxun import install + from verify_zhenxun import verify_package +except ImportError: + build = None + + +class ZhenxunPackageTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(build, "真寻打包功能未实现") + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.output = self.root / "bundle" + self.bundle = build(ROOT, self.output, "0.1.0-preview.1") + + def test_reproducible_private_complete_package(self): + other = build(ROOT, self.root / "second", "0.1.0-preview.1") + self.assertEqual(self.bundle.read_bytes(), other.read_bytes()) + manifest = verify_package(self.bundle) + self.assertEqual(manifest["target"], "zhenxun") + self.assertEqual(manifest["version"], "0.1.0-preview.1") + self.assertIn("dirty", manifest["source"]) + with ZipFile(self.bundle) as archive: + paths = archive.namelist() + self.assertTrue(any(p.endswith("assets.zip") for p in paths)) + self.assertFalse(any("astrbot" in p or "legacy/" in p + for p in paths)) + source = archive.read(next( + p for p in paths if p.endswith("farm_render/models.py") + )).decode() + self.assertIn("from ..farm_core.domain", source) + self.assertNotIn("from farm_core.domain", source) + with self.assertRaises(FileExistsError): + build(ROOT, self.output, "0.1.0-preview.1") + + def test_install_and_existing_target_are_safe(self): + plugins = self.root / "plugins" + plugins.mkdir() + target = install(self.bundle, plugins) + sentinel = target / "do-not-overwrite.txt" + sentinel.write_text("preserved") + with self.assertRaises(FileExistsError): + install(self.bundle, plugins) + self.assertEqual(sentinel.read_text(), "preserved") + self.assertTrue((target / "__init__.py").is_file()) + + def test_tampering_extra_path_and_traversal_rejected(self): + for name in ("../outside.txt", "C:/escape.txt", "extra.txt", + "..\\outside.txt"): + with self.subTest(name=name): + bad = self.root / (str(len(name)) + ".zip") + bad.write_bytes(self.bundle.read_bytes()) + with ZipFile(bad, "a") as archive: + archive.writestr(name, "bad") + with self.assertRaises(ValueError): + verify_package(bad) + bad = self.root / "corrupt.zip" + with ZipFile(self.bundle) as original, ZipFile(bad, "w") as changed: + for item in original.infolist(): + content = original.read(item.filename) + if item.filename.endswith("requirements.txt"): + content += b"bad\n" + changed.writestr(item, content) + with self.assertRaises(ValueError): + install(bad, self.root) + self.assertFalse((self.root / "zhenxun_plugin_farm_preview").exists()) + + def test_publisher_is_fixed_target_and_dry_by_default(self): + from publish_zhenxun import publish + report = publish(self.bundle) + self.assertFalse(report["published"]) + self.assertEqual(report["branch"], "farm-preview") + self.assertEqual(report["repository"], "Shu-Ying/zhenxun_plugin_farm") + self.assertNotIn("token", json.dumps(report)) + with self.assertRaises(ValueError): + build(ROOT, self.root / "bad", "bad; echo secret") + + def clean_package(self): + from verify_zhenxun import read_package, MANIFEST, ROOT_NAME + files = read_package(self.bundle) + manifest = json.loads(files[MANIFEST]) + manifest["source"]["dirty"] = False + files[MANIFEST] = json.dumps(manifest).encode() + path = self.root / "clean.zip" + with ZipFile(path, "w") as archive: + for name, content in files.items(): + archive.writestr(f"{ROOT_NAME}/{name}", content) + return path, manifest + + def test_publish_uses_nonforced_managed_branch_and_rejects_race(self): + from publish_zhenxun import publish + package, manifest = self.clean_package() + for race in (False, True): + calls = [] + reads = [] + + def request(method, path, data): + calls.append((method, path, data)) + if path == "": + return {"default_branch":"main"} + if path.startswith("contents/"): + return {"encoding":"base64", "content":base64.b64encode( + json.dumps(manifest).encode(), + ).decode()} + if method == "GET": + reads.append(1) + return {"object":{"sha":( + "raced" if race and len(reads) > 1 else "parent" + )}} + return {"sha":"created"} + + with patch.dict("os.environ", {"FARM_RELEASE_TOKEN":"unit-test"}): + if race: + with self.assertRaises(RuntimeError): + publish(package, True, request) + self.assertFalse(any(c[0] == "PATCH" for c in calls)) + else: + self.assertTrue(publish( + package, True, request, + )["published"]) + self.assertIn(("PATCH", "git/refs/heads/farm-preview", { + "sha":"created", "force":False, + }), calls) + + def test_symlink_duplicate_and_default_branch_rejected(self): + from publish_zhenxun import publish + from zipfile import ZipInfo + package, _ = self.clean_package() + with patch.dict("os.environ", {"FARM_RELEASE_TOKEN":"unit-test"}): + with self.assertRaises(ValueError): + publish(package, True, lambda *args: { + "default_branch":"farm-preview", + }) + for kind in ("duplicate", "link"): + bad = self.root / (kind + ".zip") + bad.write_bytes(self.bundle.read_bytes()) + with ZipFile(bad, "a") as archive: + item = ZipInfo("zhenxun_plugin_farm_preview/extra") + if kind == "duplicate": + item.filename = archive.namelist()[0] + else: + item.create_system = 3 + item.external_attr = 0o120777 << 16 + archive.writestr(item, b"outside") + with self.assertRaises(ValueError): + verify_package(bad) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py new file mode 100644 index 0000000..8677aa8 --- /dev/null +++ b/tests/test_zhenxun_runtime.py @@ -0,0 +1,153 @@ +import asyncio +import importlib +import importlib.util +from pathlib import Path +import sys +import tempfile +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from build_zhenxun import build +from ci_font import choose_font + + +class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + build(ROOT, self.root / "built", "0.1.0-preview.1") + self.package = "farm_fixture" + module = ModuleType(self.package) + module.__path__ = [str(self.root / "built/repository")] + sys.modules[self.package] = module + self.module = importlib.import_module(self.package + ".runtime") + self.runtime = self.module.Runtime(self.root / "data", choose_font()) + await self.runtime.start() + self.context = dict(bot_id="bot", platform="OneBot V11", user_id="1", + message_id="registration", display_name="离线玩家") + + async def asyncTearDown(self): + await self.runtime.close() + for name in tuple(sys.modules): + if name == self.package or name.startswith(self.package + "."): + del sys.modules[name] + self.temporary.cleanup() + + async def call(self, action, arguments="", message="event", **changes): + return await self.runtime.execute( + action, arguments, + **{**self.context, "message_id":message, **changes}, + ) + + async def test_original_commands_transactions_and_png(self): + self.assertNotIn("测试农场", self.module.COMMANDS) + self.assertIn("开通农场", self.module.COMMANDS) + self.assertIn("我的农场", self.module.COMMANDS) + self.assertIsInstance(await self.call("register"), str) + await self.call("buy", "白萝卜 1", "buy") + before = await self.call("inventory") + await self.call("buy", "白萝卜 1", "buy") + self.assertEqual(before, await self.call("inventory")) + await self.call("sow", "白萝卜 1", "sow") + result = await self.call("farm", "高清") + self.assertIsInstance(result, bytes) + await self.call("farm", "my-seed") + self.assertIn("成熟", await self.call("harvest", message="harvest")) + self.assertTrue((self.root / "data/farm-preview.log").exists()) + + async def test_missing_message_id_and_other_identity(self): + self.assertIn("未执行", await self.call("register", message=None)) + await self.call("register") + response = await self.call("inventory", user_id="other") + self.assertIsInstance(response, str) + self.assertTrue("注册" in response or "开通" in response) + self.assertNotEqual( + self.module.operation_id("b", "p", "u", "1", "buy"), + self.module.operation_id("b", "p", "u", "2", "buy"), + ) + + async def test_invalid_arguments_and_missing_font_are_explicit(self): + await self.call("register") + for action, arguments in (("buy", "白萝卜 nope"), + ("sow", "白萝卜 -2"), + ("lock", ""), ("farm", "unknown")): + self.assertIsInstance(await self.call(action, arguments), str) + other = self.module.Runtime(self.root / "without-font") + await other.start() + try: + response = await other.execute("farm", "", **self.context) + self.assertIn("FARM_PREVIEW_FONT", response) + finally: + await other.close() + + async def test_insufficient_funds_has_chinese_message_and_no_debit(self): + await self.call("register") + before = await self.call("inventory") + self.assertEqual( + await self.call("buy", "白萝卜 100000", "too-expensive"), + "余额不足。", + ) + self.assertEqual(before, await self.call("inventory")) + + async def test_mock_host_load_registers_hooks_and_original_matchers(self): + hooks = {"start":[], "close":[], "commands":{}} + driver = SimpleNamespace( + config=SimpleNamespace(farm_preview_data_dir=self.root / "host"), + on_startup=hooks["start"].append, + on_shutdown=hooks["close"].append, + ) + nonebot = ModuleType("nonebot") + nonebot.get_driver = lambda: driver + nonebot.require = lambda _: None + + def register(name, **kwargs): + return SimpleNamespace(handle=lambda: ( + lambda handler: hooks["commands"].update({name:handler}) + )) + + nonebot.on_command = register + adapters = ModuleType("nonebot.adapters") + adapters.Bot = adapters.Event = adapters.Message = type( + "HostType", (), {}, + ) + params = ModuleType("nonebot.params") + params.CommandArg = lambda: None + uninfo = ModuleType("nonebot_plugin_uninfo") + uninfo.Uninfo = type("Uninfo", (), {}) + paths = ModuleType("zhenxun.configs.path_config") + paths.DATA_PATH = self.root + messages = ModuleType("zhenxun.utils.message") + sent = [] + + async def send(**kwargs): + return None + + messages.MessageUtils = SimpleNamespace(build_message=lambda content: ( + sent.append(content) or SimpleNamespace(send=send) + )) + platform = ModuleType("zhenxun.utils.platform") + platform.PlatformUtils = SimpleNamespace() + modules = {m.__name__:m for m in ( + nonebot, adapters, params, uninfo, paths, messages, platform, + )} + with patch.dict(sys.modules, modules): + imported = importlib.import_module(self.package + ".commands") + self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS)) + self.assertEqual(len(hooks["start"]), 1) + await hooks["start"][0]() + try: + bot = SimpleNamespace(self_id="bot", adapter=SimpleNamespace( + get_name=lambda:"OneBot V11", + )) + await hooks["commands"]["开通农场"]( + bot, SimpleNamespace(message_id="m1"), + SimpleNamespace(user=SimpleNamespace(id="u", name="用户")), + SimpleNamespace(extract_plain_text=lambda:""), + ) + self.assertTrue(sent) + finally: + await hooks["close"][0]() + self.assertFalse(imported.runtime.started)