add # 新增项目整体大纲

This commit is contained in:
2026-10-15 17:21:05 +08:00
parent ff03600ae9
commit ff4df9ad51
52 changed files with 15104 additions and 47 deletions
+38
View File
@@ -0,0 +1,38 @@
# Configuration schema used by Comet. Do not edit this value.
schema: comet.project.v1
# Default workflow entered by /comet. Must also appear in workflows.
default_workflow: native
# Workflows enabled in this project: native, classic, or both.
workflows:
- native
# Enables automatic recovery through the read-only Ambient Resume probe for both Native and Classic. Set false to disable it.
# ambient_resume: true | false
ambient_resume: true
# Project policy for automatic personal-memory learning and retrieval.
memory:
# Allows workflow events in this project to form new personal memories automatically.
# learning: true | false
learning: true
# Allows personal memories to be injected into Agent context for this project.
# retrieval: true | false
retrieval: true
# Project knowledge retrieval provider used by ordinary Comet tasks.
knowledge:
# Provider for project knowledge.
# provider: local | remote
provider: local
# Native workflow settings. They do not change Classic state or behavior.
native:
# Root directory where Native stores Comet specs and changes. Runtime data stays under .comet.
artifact_root: docs
# Artifact language used by Native workflow documents.
# language: en | zh-CN
language: en
# Controls how Native asks clarifying questions: batch asks every currently answerable question per round (default), sequential asks one at a time.
# clarification_mode: batch | sequential
clarification_mode: batch
# Controls whether Native archives automatically after a successful preview or waits for explicit user confirmation.
# archive_confirmation: automatic | required
archive_confirmation: automatic
# Maximum failed Verify outcomes allowed for one confirmed acceptance target before Native stops the completion loop.
max_verify_failures: 5
+4
View File
@@ -109,5 +109,9 @@ Desktop.ini
/.agents/
/.codex/
/.codebase-memory/
# >>> Comet managed project state >>>
!/.comet/
/.comet/*
!/.comet/config.yaml
# <<< Comet managed project state <<<
+4
View File
@@ -14,6 +14,10 @@
- .release-workspaces 不进入主仓库。本轮用户要求停止拉取,已删除本地克隆;不得自行重新克隆、fetch、pull 或推送。
- docs 中的框架接入和发布流水线属于设计,未实现部分不得报告为已完成。
<comet-ambient-resume>
<!-- Managed by Comet. Edits inside this block may be replaced by comet init/update. -->
<!-- Contract: comet.resume_probe.v2 -->
## Comet Ambient Resume
在这个仓库中,开始处理需要改动或调查的任务前,如果可能存在活跃 Comet workflow,把当前用户请求传入只读探针:`comet resume-probe . --stdin --json`。
+67 -24
View File
@@ -1,40 +1,83 @@
# Farm
一个源码主仓库、多框架发布仓库的农场插件翻新项目。
统一维护农场核心,面向真寻、普通 NoneBot 和 AstrBot 分包发布。
主仓库:https://github.com/Shu-Ying/main_plugin_farm.git。
当前阶段:初始化、旧代码保全与设计评审。新的跨框架农场尚未实现,
本目录不能直接作为 NoneBot、AstrBot 或真寻插件安装。
## 当前可用范围
## 当前已有内容
第一批框架无关基础核心已实现,位于 src/farm_core:
- docs/:长期规划、架构、业务约束、发布设计、迁移与源码审查。
- legacy/zhenxun_plugin_farm/:旧 Beta 分支代码与资源的迁移基线。
- src/、integrations/、tests/、release/:职责说明和未来实施位置。
- scripts/check_baseline.py:离线校验迁移清单、Python 语法及静态数据库。
- Git 已本地初始化为 main;没有配置主仓库远程,也未提交或推送。
- 离线迁移旧版 202 条作物配置。
- 注册、账户/库存/地块查询、购买、播种、收获、铲除。
- 作物锁定/解锁、指定出售与全部出售、基础文本展示。
- SQLite 事务、资产流水、幂等重放及并发保护。
- 初始 500 农场币、0 点券、0 经验、3 块普通地。
- 可选共享图片层:旧布局与素材、目标尺寸绘制、WebP/JPEG/PNG、
有界缓存和受控后台执行,参见 [绘图说明](docs/10-rendering.md)。
先读 [文档索引](docs/README.md),再读
[待确认决策](docs/07-decisions.md)。
本目录目前不是可直接放入真寻、NoneBot 或 AstrBot 插件目录的成品。
integrations 中仍是职责说明,框架接入与发布流水线尚未实现。
签到、偷菜、升级、兑换和真实玩家数据迁移不在本轮范围。
## 校验
## 安装与本地演示
使用 Python 3.10 或更高版本:
Python 语法基线为 3.10;本轮在 Windows、Python 3.12.14 上验证。
推荐在独立虚拟环境中使用。构建需要 setuptools>=68;
运行核心本身没有第三方依赖。
```text
python -m pip install --no-deps --no-build-isolation .
python -m farm_core --database runtime/demo.sqlite3
```
演示使用独立的 demo 身份及可控时钟,不需要真实等待作物成熟。
指定目录内生成数据库及同名 .log;请使用开发目录,不指向生产数据。
相同演示数据库重复运行会重放固定操作结果,不重复发放资产。
接入方调用示例:
```python
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
store = SQLiteStore("runtime/farm.sqlite3")
await store.initialize()
service = FarmService(store, Catalogue.bundled())
who = Identity("deployment-1", "realm-1", "qq", "user-1")
try:
receipt = await service.register(who, "我的农场", "event-register-1")
snapshot = await service.snapshot(who)
finally:
await store.close()
```
每个写操作传入稳定的 operation_id。调用方负责身份命名空间、权限、
会话及发送消息;核心不会自动关联跨平台账号。
## 离线验证与迁移
```text
python scripts/check_baseline.py
git status --short
python scripts/export_catalogue.py --check
python scripts/verify_foundation.py
```
脚本不启动机器人,不执行网络请求,不读真实玩家数据库。
语法通过不等于插件加载或玩法正确。
完整验证执行基线校验、可复现导出、规则/事务测试和离线 wheel 安装;
打包测试需要已安装的 pip、setuptools、wheel、Pillow和可加载字体,
禁止从网络补装;绘图测试可设置FARM_TEST_FONT为本机字体路径。
只跑业务测试时,可安装核心后使用:
```text
python -m unittest discover -s tests -p "test_transactions.py" -v
```
[文档索引](docs/README.md)、
[基础核心说明](docs/09-foundation.md)、
[待确认产品决策](docs/07-decisions.md)。
## 来源与许可证
旧代码来源为 Shu-Ying/zhenxun_plugin_farm 的 Beta 分支,
提交 b015c6c1997f146f651b677382980d338a306bb2。
保留原 GPLv3 LICENSE、原作者信息和旧 README。
素材权属尚需逐项核对,不因代码许可证而自动完成素材授权判断。
同步副本中的服务令牌已改为读取 FARM_SERVICE_TOKEN 环境变量,
其余源码与资源复制差异见 docs/legacy-manifest.json。
不要直接部署含已知业务缺陷的迁移基线。
legacy/zhenxun_plugin_farm 保存旧 Beta 基线,827 个文件保持校验。
来源提交 b015c6c1997f146f651b677382980d338a306bb2,保留 GPLv3 LICENSE。
基线副本的硬编码服务令牌已改为环境变量;原服务端轮换未完成。
新核心不使用该令牌、不访问旧远程服务;可选渲染包独立保存所需旧素材。
新核心 0.1.0 为本地基础包版本,不表示三个框架插件已发布。
+2 -2
View File
@@ -2,7 +2,7 @@
## 原则与当前状态
下列为建议设计,尚未实现。当前旧代码位于 legacy,未把其全局变量、
下列为远期目标设计;第一批核心已按职责实现为独立模块,见09-foundation.md。当前旧代码位于 legacy,未把其全局变量、
数据库管理器和服务定位器直接当作新核心继续复用。
采用一个共享 Python 包,内部按职责划分,避免先建多个互相依赖的
@@ -33,7 +33,7 @@ farm/
docs/
```
本轮只创建职责说明及迁移基线,不生成大量空模块或假实现。
初始化时只创建职责说明;当前已实现第一批核心,不生成大量空模块。
当一个用例实施时,才创建所需模块。
## 依赖方向
+1 -1
View File
@@ -4,7 +4,7 @@
| 角色 | 远程地址 | 建议职责 |
|---|---|---|
| 开发主仓库 | 尚未提供;本地 D:/Work/farm | 业务、接入、测试、文档、构建的唯一开发源 |
| 开发主仓库 | https://github.com/Shu-Ying/main_plugin_farm.git;本地 D:/Work/farm | 业务、接入、测试、文档、构建的唯一开发源 |
| 真寻发布仓库 | https://github.com/Shu-Ying/zhenxun_plugin_farm.git | 真寻完整插件 |
| AstrBot 发布仓库 | https://github.com/Shu-Ying/astrbot_plugin_farm.git | AstrBot 完整插件 |
| NoneBot 发布仓库 | https://github.com/Shu-Ying/nonebot_plugin_farm.git | NoneBot 包及 PyPI 构建资料 |
+1 -1
View File
@@ -17,7 +17,7 @@
| 兼容基线 | 保留旧指令清单;Python/宿主版本经矩阵实测确定 | 旧 README 的 3.10+ 不等于三框架共同最低版本 |
| 发布顺序 | 真寻先稳定,之后普通 NoneBot、AstrBot | 利用现有宿主验证闭环,控制变更范围 |
| 发布仓库 | 使用给出的三个现有仓库,接管前比较现有代码 | 三仓库不为空,不能直接覆盖 |
| 主仓库远程 | 用户后续提供独立 farm 地址 | 本轮只建本地 Git,不误用发布仓库 |
| 主仓库远程 | 已明确 Shu-Ying/main_plugin_farm | 本轮只建本地 Git,不误用发布仓库 |
| 发布授权 | 首次候选 PR 审查,稳定后再议自动发布 | 本轮未授权远程覆盖、推送或发布 |
| 跨平台 OS | 建议 Windows + Linux;macOS 有需求再验收 | 路径大小写、字体、浏览器和时区需分别测试 |
| 素材许可证 | 核对图片、字体、HTML 和借用工具来源 | 代码 GPLv3 不自动证明所有素材可分发 |
+3 -1
View File
@@ -1,4 +1,6 @@
# 本轮验证与交付边界
# 初始化阶段验证与交付边界
此页保留初始化阶段的历史记录;随后完成的基础核心与正式验证见 [09-foundation.md](09-foundation.md)。
日期:2026-10-15。执行 Python:3.12.14;未安装或启动三种宿主。
新工具还以 Python 3.10 语法规则解析旧代码;这不代表三框架共同支持
+104
View File
@@ -0,0 +1,104 @@
# 第一批基础核心实现
日期:2026-10-15。对应 Comet change:farm-core-foundation。
已确认规格位于 comet/changes/farm-core-foundation。
本文件记录实现与开发期测试,不替代 Runtime 的独立验收报告。
## 模块与职责
| 模块 | 责任 |
|---|---|
| domain.py | 不可变身份/作物/账户/库存/地块/结果,整数校验及纯规则 |
| catalogue.py、data/plants.json | 本地配置加载与校验,202 条原始作物配置 |
| ports.py | 应用层所需事务仓储接口 |
| application.py | 注册、购买、播种、收获、铲除、锁定、出售 |
| repository.py | 单个事务连接上的 SQL,不自行提交 |
| storage.py | schema、连接、事务、幂等、流水、线程执行和关闭等待 |
| presentation.py | 结构化结果转普通文本,不修改数据库 |
| __main__.py | 显式本地演示及日志文件 |
| scripts/export_catalogue.py | 只读静态作物库的可复现导出 |
| scripts/verify_foundation.py | 正式离线检查入口 |
按职责先采用小模块;随着用例增加再拆分目录,
不为了贴合远期目录树生成大量空包。
核心只用标准库,不导入三框架,包内部相对导入。
wheel 随包携带 JSON,不包含 legacy、图片、玩家数据库或其他框架入口。
## 已迁移与修复
- 沿用500初始币、3地块、等级门槛及源作物价格/产量/经验/成熟时间。
- 旧作物库202条记录完整导出且保留源 SHA-256。
- 多季再生长间隔明确保留去重 phase[1:] 求和,未重平衡数值。
- F01/F02:余额和经验只增加本次增量。
- F03:每块地单独加成、向下取整,再求和。
- F04/F06:数据库错误传播;扣款、发货、流水与结果在一个事务中。
- F05:不缓存不存在玩家,注册提交后即可读取。
- F08/F11:锁定统一约束两种出售入口;文本直接渲染结构化结果。
- F07签到与F09活动模块未迁移,不能算已修复。
- 保留旧基线只读,迁移工具不读取真实玩家库。
## API 约定
所有操作为异步方法;服务接受显式 Store、Catalogue 和可选秒级时钟。
| 方法 | 说明 |
|---|---|
| register(identity, name, operation_id) | 首次注册;重复注册不再赠送 |
| snapshot(identity) | 同一读事务的账户、库存、地块快照 |
| buy(identity, name, count, operation_id) | 按币种和等级购买 |
| sow(identity, name, count, operation_id) | count=None 尽可能播种;显式数量不足则失败 |
| harvest(identity, operation_id) | 收获全部已成熟地块,一笔事务结算 |
| eradicate(identity, operation_id) | 只清理枯萎实例 |
| lock(identity, name, locked, operation_id) | 锁定或解锁产物 |
| sell(identity, name, count, operation_id) | name=None 且 count=None 批量出售;锁定物品跳过 |
指定出售锁定物品返回 plant_locked,全部出售跳过锁定物品;
没有可出售物品返回 nothing_to_sell。指定数量不自动截断为可用库存。
显式数量接受1至1,000,000整数,拒绝 bool、负数、0、浮点和超大值。
库存/资产/时间不超 SQLite 有符号64位整数上界。
FarmError.code 表示业务失败;StorageError 表示数据库故障;
SchemaError 表示外来或不兼容库。接入层不能把后两者当作未注册。
同一个身份和 operation_id 只能对应同一动作与参数;
相同请求返回原已提交 Receipt,冲突请求返回 operation_conflict。
已提交的余额是该操作时的结果,重放收据不是账户的最新查询,
要显示当前状态需另调 snapshot。
## 持久化与取消语义
新库使用独立 application_id、schema_version=1:
accounts、inventory、plots、operations、ledger。
未知表集、外来库或更高版本被拒绝,不把旧 farm.db 自动当新库打开。
每次写用例使用新连接与 BEGIN IMMEDIATE;提交前统一写幂等结果。
数据库条件更新、非负整数约束和唯一键保护资产。
SQLite 工作在线程执行,事件循环不直接执行 SQL。
close 拒绝新请求并等待在途任务,无导入时线程或持久数据库连接。
异步调用取消不保证取消已进入线程的事务:事务可能提交,
但结果与 operation_id 一起保存;使用相同 ID 重试可确定结果。
这避免平台重试产生第二笔扣款;不得仅因发送消息失败而换新 ID。
并发测试在单进程 Windows 中完成,跨进程及 Linux 仍需后续矩阵验证。
播种冻结成熟时间、产量、经验与季数等规则;出售目前按服务加载的
目录价格结算,价格变更策略应在后续版本化经济规则中明确。
不支持核心对外随意充值;外部金币接入需独立对账和补偿设计。
生产数据备份、迁移、归档及账本保留策略仍待实施。
## 开发期证据
- 7项领域测试通过,包括整数边界、身份、源目录及坏数据。
- 22项事务测试通过,包括玩法、回滚、并发、取消与重启。
- 3项包装测试通过,包括无框架导入、无导入副作用和离线安装。
- 首次事务测试有2项因测试代码未关闭连接而在Windows清理失败;
修复测试资源释放后通过,不隐藏该失败。
- 最终结果以 Comet 管理的 verification.md 为准。
- 真实框架、旧玩家库、联机、图片和远程发布没有测试或启用。
## 正式验收结果
Runtime已执行完整离线检查并通过。
独立只读Verifier核对A1至A8后全部判定通过,结果已由Runtime接收;
正式报告为 comet/changes/farm-core-foundation/verification.md。
通用Skill桥接不能机器认证独立执行身份,当前等待用户接受结果,
尚未归档、提交或推送。此状态不等于三框架插件已发布。
+145
View File
@@ -0,0 +1,145 @@
# 共享图片工具与“我的农场”
状态:共享渲染已实现并通过离线检查和独立只读验收;等待用户接受。
真实宿主命令与上传仍未接入。
Python 语法基线3.10;本机实测 Windows/Python3.12.14/Pillow12.3.0。
## 安装与职责
```text
python -m pip install ".[render]"
```
核心 farm_core 仍无第三方运行依赖。可选 farm_render 使用 Pillow;
NoneBot、AstrBot、真寻均可调用,不导入任何宿主对象。
| 文件 | 职责 |
|---|---|
| src/farm_render/canvas.py | 同步透明合成、裁剪、缩放、文字和编码 |
| src/farm_render/assets.py | 包内素材读取、按目标尺寸缓存与字节上限 |
| src/farm_render/models.py | 输入配置、展示模型和编码结果 |
| src/farm_render/layout.py | 一次快照与统一时刻转换为可见状态 |
| src/farm_render/renderer.py | 底图、作物、账户面板、成图缓存 |
| src/farm_render/service.py | 一次查询、后台执行、并发和取消管理 |
字体由部署方提供可合法使用的中文 TrueType/OpenType 文件路径。
示例使用本机已有微软雅黑;不会复制或发布 Windows 字体。Linux
应配置包含中文的字体;测试可通过 FARM_TEST_FONT 指定路径。当前
实现校验字体可加载,不保证任意字体具备所有中文、Emoji 字形。
## 调用
```python
from farm_render import FarmRenderer, RenderOptions, RenderService
renderer = FarmRenderer("/path/to/chinese-font.ttf")
rendering = RenderService(renderer, concurrency=1, max_pending=8)
try:
picture = await rendering.my_farm(
service, who,
options=RenderOptions(theme="spring"),
avatar=avatar_bytes_or_none,
supported=("WEBP", "JPEG", "PNG"),
)
# 接入层按 picture.mime 发送 picture.content。
finally:
await rendering.close()
```
应用生命周期内复用一个 renderer/service,勿在每次命令中重新创建。
上面的 finally 仅演示生命周期结束时的清理;退出插件时还需关闭
业务 store。my_farm 每次调用只请求一次 service.snapshot,不按地块
访问数据库。若已有快照,可直接 await rendering.render(who, snapshot,
now, options),统一 now 使用UTC秒整数。正常未注册/存储故障仍由
业务层抛出,接入层负责转换为用户提示,不伪装成空农场。
同步 FarmRenderer.render 仅供离线或已有后台任务的场景;机器人
事件循环应调用 RenderService。实例绑定字体、静态配置和素材版本,
替换素材或字体后重建实例。RenderService 只供同一事件循环使用。
## 尺寸、格式和质量
- 普通:1024×624、quality75;RenderOptions.high() 为1536×936、85。
- 编码格式独立:AUTO、WEBP、JPEG、PNG;WebP method默认2。
- AUTO仅根据接入层声明的 supported 和本机编码能力选择,默认
supported只有JPEG。确认平台支持后传入WEBP,才会优先使用它。
- 显式 format=WEBP 可强制选择;本机不支持则明确报错。平台不支持
时应在调用前选JPEG,避免上传失败后再次合成和有损转码。
- PNG为无损,不使用quality控制画质;WebP/JPEG的quality不能视为
同等视觉质量标尺。本实现不使用WebP无损或动画。
原素材保持原格式。所有底图和坐标按目标尺寸处理,不先创建3072宽
的合成画布。图像解码仍可能产生原素材大小的临时缓冲,不能把
画布缩小等同于全部内存按同一比例下降。
## 阶段与缓存
布局使用旧30块地坐标。首季由保存成熟时间减首熟时长推导起点;
再生长由下一次成熟时间减再生长时长推导。阶段阈值映射到本季
时长,解决旧代码多季仍引用最初播种时间的问题;仅改变展示,
不修改成熟和收益规则。枯萎优先于成熟。原偏移字段为offsetX/Y/W/H,
新实现使用真实字段,不沿用旧绘图的offic拼写。
素材LRU默认32MiB,成图LRU默认8MiB;零预算禁用对应缓存,超限
条目不入缓存。素材返回副本,缓存底图不被玩家绘制污染。预算按
缓存图像像素/编码数据及成图元数据预留计算,不代表进程RSS上限;
还存在解码临时缓冲、Python对象、线程和字体缓存(最多32项)。
成图键含完整作用域摘要、可见账户、地块阶段、头像摘要和配置。
阶段/成熟边界直接改变展示键,不用固定TTL拖延成熟显示;同阶段
不绘制秒级倒计时,所以可以跨秒复用。账户、头像和主题变化也失效。
头像至多2MiB、400万像素;过大或损坏降级默认图。不下载头像,
头像长缓存、请求超时和平台媒体ID复用属于接入层职责。
RenderService默认一个绘图任务,最多8个活跃/排队的不同请求;
超过容量明确失败,由接入层提示稍后重试。同一展示请求共享任务。
调用方取消只取消自己的等待,后台仍占用名额直至结束;close等待
所有已接受任务排空。合并的是绘制,不合并调用前的快照查询。
## 素材完整性与来源
scripts/export_render_assets.py将673张已有素材原样打包,附202种作物
展示配置和旧布局。--check比对确定性输出,不修改旧基线。
素材包约束为安装资源,不按用户输入路径访问任意文件、不解压到磁盘。
保留GPL及原始归属;历史素材分发权仍需在公开发布前核实。
旧基线按当前阶段算法有455个缺失阶段引用;不是本次导出丢失。
缺失时显示问号占位并记录静态警告,避免整个命令失败;尚未补齐
缺失图片,也不推测相近作物素材替代。不会声称202种作物均有完整图片。
## 测量与验证
```text
python scripts/render_demo.py --font /path/to/chinese-font.ttf
python scripts/export_render_assets.py --check
python scripts/verify_foundation.py
```
本轮测量原始结果见 [render-benchmark.json](render-benchmark.json)。
样例为构造的18块解锁地,包含空地、生长、成熟、枯萎与锁定地。
输出和日志位于runtime/render-demo,不进入Git。
| 尺寸 | 编码 | 字节 | 冷请求毫秒 | 命中缓存毫秒 |
|---|---|---:|---:|---:|
| 1024×624 | WebP75/method2 | 85,820 | 192.195 | 0.559 |
| 1024×624 | JPEG75 | 112,309 | 119.514 | 0.319 |
| 1024×624 | PNG | 1,083,976 | 153.524 | 0.271 |
| 1536×936 | WebP85/method2 | 236,462 | 208.278 | 0.327 |
| 1536×936 | JPEG85 | 293,733 | 136.219 | 0.274 |
| 1536×936 | PNG | 2,168,696 | 208.414 | 0.285 |
这是一台本机的单次观测,不是旧版性能对照,也不是等画质比较。
每组使用新的渲染器测冷缓存,字体/操作系统文件缓存可能已有预热;
未测低配服务器、峰值RSS、Linux运行或任何真实平台上传。
普通样例WebP比JPEG小约24%,但编码约60ms,JPEG约4.5ms;
优先级取决于部署CPU与带宽,应保留独立格式配置。
日志记录query_ms、queue_ms、compose_ms、encode_ms、尺寸、编码、
字节与成图命中,不记录真实姓名、作用域、头像或完整快照。
上传阶段由适配器单独记录,不能将发送接口返回当作客户端已显示。
正式检查结果:45项测试全部通过,827文件基线与两类可复现导出
检查通过。独立只读Verifier核对完整核心/渲染规格和源码后,A1-A13
全部通过。验收报告见 [Comet报告](
comet/changes/farm-core-foundation/verification.md)。通用Skill桥接不提供
机器独立身份认证,流程仍要求用户明确接受后才归档。
+5 -2
View File
@@ -1,10 +1,13 @@
# 文档索引
记录日期:2026-10-15。状态:已形成设计草案,待关键产品决策确认;
业务重构、多框架接入、发布流水线和真实数据迁移尚未实施。
第一批基础核心已实施;多框架接入、发布和真实数据迁移尚未实施。
| 文档 | 用途 |
|---|---|
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
| [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 |
| [render-benchmark.json](render-benchmark.json) | 本机合成数据的冷暖缓存与编码测量 |
| [01-roadmap.md](01-roadmap.md) | 长期目标、实施顺序、里程碑与验收 |
| [02-architecture.md](02-architecture.md) | 分层、目录、依赖、接入与接口设计 |
| [03-business-rules.md](03-business-rules.md) | 身份、货币、库存、作物、事务与幂等 |
@@ -26,6 +29,6 @@
- “已确认”必须有源码、命令或文档来源;“建议”不能当作已接受需求。
- 文档中的目标结构是设计,不表示对应代码已实现。
- 发布仓库是分发目标,不是 Git 子模块;各框架使用相互独立的插件包。
- 主仓库远程地址尚未提供;不将任一插件发布仓库误设为主仓库 origin。
- 主仓库远程为 Shu-Ying/main_plugin_farm;本轮未执行远程操作。
- 用户已撤回拉取要求,且要求删除本地克隆;三个本地检出已删除,不再访问远程。
- 本轮仅修正迁移副本中的硬编码凭据;业务缺陷记录在审查报告,没有隐式修复。
@@ -0,0 +1,126 @@
# Outcome
在 D:/Work/farm 当前目录完成旧版可用规则的第一批迁移,建立不依赖
真寻、NoneBot 或 AstrBot 的可运行基础核心;使用 SQLite 验证完整交易
闭环,为后续框架接入提供稳定调用入口。Comet 初始化已完成,
AGENTS.md 缺失开始标记已修复。用户已明确选择当前目录继续。
# Scope
- 迁移基线只读。迁出旧作物目录 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 编码和分阶段日志。
- 渲染接收一次核心快照和统一时间,不逐地块查库、不联网;头像字节
由接入方提供,失败使用默认头像。保留旧农场背景、地块和作物素材。
- 按可见状态缓存成图并在生长阶段边界失效;并发相同请求合并。
提供离线生成、性能记录、安装包独立运行和中文字体配置说明。
## Directory structure
### Created
- src/farm_render/:画布、素材、展示模型、布局、渲染器及异步入口。
- scripts/export_render_assets.py、scripts/render_demo.py。
- tests/test_render*.py、docs/10-rendering.md。
### Modified
- pyproject.toml:render 可选依赖及渲染素材打包。
- scripts/verify_foundation.py:增加渲染资源可复现检查。
- README.md、docs/README.md、docs/task_plan.md、docs/progress.md。
- 当前 brief 与新增 farm-render 目标规格;原业务规格保留。
### Deleted
无。
### Not created
- 不创建真实平台插件、远端工作流、真实玩家库或字体再分发包。
- 不修改 legacy 基线,不创建多个框架的业务副本。
# Non-goals
本轮不实现真实宿主插件、消息命令、签到、偷菜、活动、
外部金币兑换、点券兑换、土地升级、星级进阶、账号绑定或在线联机。
不读取或迁移真实玩家数据,不重置旧资产,不改旧基线业务。
不 clone/fetch/pull/push 任何远程,不创建 PR、Release 或发布工作流。
不将设计占位目录报告为已支持三框架。
# Acceptance examples
- 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: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和
接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。
# Constraints and invariants
源码使用 Python,4 空格、80 字符行长,明确说明数据模型字段。
保留 GPLv3 许可与源归属。共享核心内部相对导入。
运行数据和测试临时数据库不入 Git。新实现只使用必要依赖,
优先标准库 SQLite、异步线程执行和 unittest,不为框架引入依赖。
新数据库 schema 与旧库区分,未知或更高 schema 明确拒绝。
同一基础交易的存储和应用紧密耦合,使用单个 change,不拆分子任务。
# Decisions
- 用户已授权初始化 Comet 并执行基础开发;工作区为当前目录 main。
- 主仓库地址已明确:https://github.com/Shu-Ying/main_plugin_farm.git。
- 现有文档是设计参考,旧代码是实现参考;本轮仅覆盖上述第一批核心,
不把整份长期计划视为一次必须全部实施的产品需求。
- 未决的农场共享、联机、存量资产、兑换与素材发布不由本轮擅自决定。
- 用户随后明确要求“根据上述方案和之前优化的绘制我的农场,请执行”,
授权图片层及绘图优化。沿用已选当前目录;该目录已有未提交核心,
Runtime 不允许第二个 current change,因此作为基础开发范围补充。
- 用户讨论后明确“确认并进入开发”,包含 WebP 优先候选及 JPEG/PNG
兼容输出;尺寸、质量、编码力度独立配置,平台能力由适配器声明。
- 素材保留原 GPL 归属,不捆绑未确认再分发许可的字体;部署显式配置
中文字体路径。真实宿主头像缓存和上传由后续适配器实现。
@@ -0,0 +1,377 @@
schema: comet.native.v4
name: farm-core-foundation
language: en
phase: verify
status: await-user
state_version: 14
brief: brief.md
document_constraints_version: 3
shape_confirmation_hash: 49a2a3fb4be0159531d63bcdd52c5895f2586e309c33be91fe4e38c7a159461d
spec_changes:
- capability: farm-core
operation: create
source: specs/farm-core/spec.md
- capability: farm-render
operation: create
source: specs/farm-render/spec.md
workspace:
isolation: current
change_branch: main
target_branch: main
finish: null
loop:
stage: await-user
goal_cycle: 3
iteration: 1
attempt: 1
retry_epoch: 0
failed_iteration_count: 0
no_progress_count: 0
execution_failure_count: 0
previous_unresolved_ids: []
next_action: confirm-skill-coordinated-pass
acceptance:
- id: A1
source: brief.md
text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。"
result: passed
reason:
text: 827文件基线正式检查通过;检查包导入无线程/文件副作用及wheel独立安装,核心无框架和legacy运行依赖。
truncated: false
- id: A2
source: brief.md
text: "A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。"
result: passed
reason:
text: 只读源库导出按id排序并带源hash;202项导出检查通过,加载器校验版本、整数、阶段和重复定义。
truncated: false
- id: A3
source: brief.md
text: "A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。"
result: passed
reason:
text: 账户注册与初始500资产、三空地在事务内完成;重复注册及身份作用域隔离测试正式通过。
truncated: false
- id: A4
source: brief.md
text: "A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败,
不留下部分写入。"
result: passed
reason:
text: 购买和播种在完整事务内扣款、入库、耗种、存图;数量、等级、余额和空地边界测试正式通过。
truncated: false
- id: A5
source: brief.md
text: "A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和;
多季次数及枯萎/铲除遵守保存的规则与可注入时钟。"
result: passed
reason:
text: 成熟边界、经验增量、多季再生长及枯萎清除源实现与规格一致,逐地块加成及顺序测试通过。
truncated: false
- id: A6
source: brief.md
text: "A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本,
不发生旧版模板参数缺失。"
result: passed
reason:
text: 锁定状态对指定及全部出售生效,出售只增本次收入,Receipt和文本使用真实余额;正式测试通过。
truncated: false
- id: A7
source: brief.md
text: "A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、
不重复领取,不吞数据库故障伪装成功;重启后数据可读。"
result: passed
reason:
text: BEGIN IMMEDIATE、操作指纹与结果同事务保存;异常回滚、并发超卖/重复收获、取消重放、重启与schema测试通过。
truncated: false
- id: A8
source: brief.md
text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。"
result: passed
reason:
text: README、核心说明、离线入口与测试可用,准确说明尚无真实宿主、真实库迁移和远程发布验证。
truncated: false
- id: A9
source: brief.md
text: "A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow
依赖;安装包含所需素材。"
result: passed
reason:
text: Pillow可选渲染模块支持透明合成、复制、缩放、裁剪、文字测量省略及三格式;核心无Pillow导入,wheel携带673素材并独立渲染通过。
truncated: false
- id: A10
source: brief.md
text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。"
result: passed
reason:
text: 纯场景转换消费一次快照和统一now,首季/再季锚点与枯萎优先正确;旧30块布局及真实offset字段已迁出,普通高清解码与阶段测试通过,样例已视觉检查。
truncated: false
- id: A11
source: brief.md
text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。"
result: passed
reason:
text: 目标尺寸直接合成;素材/底图与成图LRU有字节预算且返回副本;键含作用域、账户、阶段、头像、主题及编码配置,边界变化不复用旧图。
truncated: false
- id: A12
source: brief.md
text: "A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。"
result: passed
reason:
text: 整体合成编码由to_thread执行,semaphore限制并发、pending限制容量,shield维持取消后的任务容量并在关闭排空;合并、异常重试、事件循环和并发测试通过,日志不含用户标识。
truncated: false
- id: A13
source: brief.md
text: "A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。"
result: passed
reason:
text: 离线脚本生成两尺寸三格式冷暖测量及日志,报告与已查看样例一致;正式检查45项和基线/两类导出通过,文档明确本机单次、未测上传/低配/Linux及真实平台。
truncated: false
builder_handoff:
candidate_id: e88969d9-32c0-4321-a5a3-658023a60414
identity_provider: skill-coordinated
builder_execution_ref: skill-coordinated:builder:455faebd-84e1-49d0-8041-c6f44318a0ea
iteration: 1
summary:
text: 保留原框架无关业务核心,追加共享Pillow图片工具与农场快照渲染。673原素材独立打包,目标尺寸合成、三种编码、阶段变化失效、有界缓存、受控后台任务、取消排空和日志。13项绘图定向测试及3项打包检查通过,样图已目视检查。尚未执行本轮完整核心回归,由Runtime正式执行;真实框架与上传未接入,旧素材455个阶段引用缺图。
truncated: false
addressed_acceptance_ids:
- A1
- A2
- A3
- A4
- A5
- A6
- A7
- A8
- A9
- A10
- A11
- A12
- A13
acceptance_review:
- id: A1
status: implemented-with-evidence
evidence:
- scripts/check_baseline.py
- tests/test_packaging.py
- src/farm_core/__init__.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A2
status: implemented-with-evidence
evidence:
- scripts/export_catalogue.py
- tests/test_domain.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A3
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/application.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A4
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/application.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A5
status: implemented-with-evidence
evidence:
- tests/test_domain.py
- tests/test_transactions.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A6
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/presentation.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A7
status: implemented-with-evidence
evidence:
- tests/test_transactions.py
- src/farm_core/storage.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A8
status: implemented-with-evidence
evidence:
- README.md
- docs/09-foundation.md
- scripts/verify_foundation.py
note: 已有实现保留并检查关联源码,完整回归列入本轮Runtime计划。
- id: A9
status: implemented-with-evidence
evidence:
- src/farm_render/canvas.py
- tests/test_render.py
- tests/test_packaging.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A10
status: implemented-with-evidence
evidence:
- src/farm_render/layout.py
- src/farm_render/renderer.py
- tests/test_render.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A11
status: implemented-with-evidence
evidence:
- src/farm_render/assets.py
- src/farm_render/renderer.py
- tests/test_render.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A12
status: implemented-with-evidence
evidence:
- src/farm_render/service.py
- tests/test_render.py
note: 新实现对应已确认规格,定向测试和离线样例可复核。
- id: A13
status: implemented-with-evidence
evidence:
- scripts/render_demo.py
- docs/10-rendering.md
- docs/render-benchmark.json
note: 新实现对应已确认规格,定向测试和离线样例可复核。
checks:
- name:
text: render-behavior
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执行
truncated: false
checks_truncated: false
known_limits:
- text: 真实框架、上传、Linux和低配主机尚未运行验证。
truncated: false
- text: 中文字体需部署方合法提供;不捆绑字体。
truncated: false
- text: 旧基线455个阶段引用缺图,已占位降级。
truncated: false
- text: 测量为本机单次合成数据,不是旧版或等画质基准。
truncated: false
- text: 公开发布前仍需核实历史素材分发权。
truncated: false
known_limits_truncated: false
review: null
submitted_at: 2026-10-15T08:36:49.457Z
blockers:
- owner: user
reason:
text: The generic Skill bridge cannot prove an independent Verifier execution;
user confirmation is required before Archive.
truncated: false
acceptance_ids: []
resolution_action: await-user
verification:
candidate_id: e88969d9-32c0-4321-a5a3-658023a60414
identity_provider: skill-coordinated
verifier_execution_ref: skill-coordinated:verifier:853b70de-beda-44dd-b303-cf2b3b300676
iteration: 1
attempt: 1
assurance: skill-coordinated
verdict: pass
checks:
- id: offline-foundation-render
name:
text: Offline core and renderer
truncated: false
argv_display:
- text: scripts/verify_foundation.py
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 10608
summary:
text: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。
truncated: false
risks:
- text: 真实消息平台、上传链路、Linux及低配服务器尚未验证;性能报告仅本机单次构造样例,不等画质对照。
truncated: false
- text: 旧素材存在455个阶段引用缺失,已按规格显示问号并记录警告;不能宣称202种作物图片完整。
truncated: false
- text: 部署需自备有权使用且包含所需中文字形的字体;当前只验证字体可加载,任意Unicode/Emoji覆盖不保证。
truncated: false
- text: 历史素材公开分发权仍需在发布前核实。
truncated: false
- text: 缓存预算为像素/编码内容核算,含预留,不代表整个进程峰值RSS硬上限。
truncated: false
risks_truncated: false
completed_at: 2026-10-15T08:40:00.608Z
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
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: false
created_at: 2026-10-15T06:34:33.214Z
@@ -0,0 +1,102 @@
# 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/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、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) 后相加,不对累计值加成。
经验只增加本次增量。产物入库、经验变动、收获次数和下一季状态
在同一事务提交。最后一季后枯萎;铲除只清理枯萎地块。
再次重试不重复收获。展示结果不再次修改数据。
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,70 @@
# Farm Render
## Independent image layer
farm_render 使用 Pillow 实现同步画布操作;farm_core 不依赖 Pillow。
工具支持透明合成、复制、缩放、裁剪、文本测量与超长省略,以及
WebP/JPEG/PNG 编码。JPEG 合并透明背景,输出携带 MIME、尺寸与字节。
WebP 默认采用有损质量75、method=2;高清质量85。格式与尺寸独立,
auto 根据调用方声明的平台能力选择 WebP 或 JPEG,本机无 WebP 编码
支持时 auto 回退 JPEG,显式 WebP 则清楚报错。PNG 保留无损选项。
素材独立打入安装包,不在运行时引用 legacy 或宿主;字体显式配置,
不复制 Windows 商业字体。无字体时明确报错,不输出缺字占位图。
Scenario: Framework-independent packaged renderer
Acceptance: A9
从 wheel 安装到临时目录,无 legacy 和框架仍能加载素材并生成图片。
## Snapshot and layout
入口从业务服务取得一次一致快照;绘图纯消费快照与统一 now,不查库。
复用旧30块地布局、季节静态背景、草地/普通地和作物阶段素材。
首季阶段锚点由保存的 mature_at-growth_seconds 推导,再季使用
mature_at-regrowth_seconds,按本季时长映射展示阶段,成熟优先、
枯萎优先于成熟。阶段仅影响外观,不修改交易规则和数据库。
读取实际 offsetX/Y/W/H 字段;缺失作物资源使用显式占位并记录警告。
用户信息显示真实快照余额、点券和经验,头像仅接收字节,无头像时
绘制默认图;拒绝超大或无效头像并降级。主题由调用方明确提供。
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,80 @@
---
generated_from_state_version: 14
---
# Verification
## Current result
- Result: **Verification passed; your confirmation is required**
- Verification status: **Checks completed, but your confirmation is required**
- Goal cycle: 3
- Iteration: 1
- Verifier attempt: 1
- Completed: 2026-10-15T08:40:00.608Z
- Summary: 独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。
## 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及真实平台。 |
## Checks
| Check | Command | Working directory | Status | Exit | Duration |
| --- | --- | --- | --- | ---: | ---: |
| Offline core and renderer | scripts/verify_foundation.py | . | passed | 0 | 10608 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: 公开发布前仍需核实历史素材分发权。
## Blockers
- **user**: The generic Skill bridge cannot prove an independent Verifier execution; user confirmation is required before Archive. — next: `await-user`
## Risks and skipped work
- 真实消息平台、上传链路、Linux及低配服务器尚未验证;性能报告仅本机单次构造样例,不等画质对照。
- 旧素材存在455个阶段引用缺失,已按规格显示问号并记录警告;不能宣称202种作物图片完整。
- 部署需自备有权使用且包含所需中文字形的字体;当前只验证字体可加载,任意Unicode/Emoji覆盖不保证。
- 历史素材公开分发权仍需在发布前核实。
- 缓存预算为像素/编码内容核算,含预留,不代表整个进程峰值RSS硬上限。
## 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 |
## Conclusion
独立只读核对全部13项、完整规格、核心与渲染源码、测试/打包/导出脚本、正式Runtime日志和图片样例;复用匹配候选的45项正式检查,全部验收通过。图索引新增渲染未覆盖且旧文件metadata_changed,已直接源文件补齐,无需重复完整套件。
+34
View File
@@ -18,3 +18,37 @@
13. 文档链接、差异和工具错误路径在最后交付核对中记录到验证结果。
未实现新业务、未提交或推送、未配置远程发布权限。
## 基础开发(用户确认后)
- Comet change farm-core-foundation进入Build,当前目录main。
- 迁出202条作物目录,核心代码独立于legacy和三种框架。
- 7项领域、22项事务、3项打包测试分别通过。
- 覆盖事务故障、60路并发购买、20路同标识重放、并发收获、
线程中操作被调用方取消、重启与schema拒绝。
- 测试首轮出现2项Windows文件占用,已修复测试自己的连接关闭。
- 根README和09-foundation记录运行方法、缺陷对应与未完成范围。
- 候选即将提交Runtime检查,尚未宣称独立验收通过。
## 正式验证完成
- Runtime执行verify_foundation.py成功,基线、202条目录与32项测试通过。
- 新的只读Verifier逐项核对完整规格与源码,A1至A8全部通过。
- Runtime已接受结果,当前等待用户接受,不代用户归档。
- 通用Skill桥接不能机器认证独立执行,因此保留用户确认边界。
- 没有执行远程访问、提交、推送、发布或真实玩家数据迁移。
## 共享图片开发
- 用户明确确认进入开发,已有核心范围补充A9-A13并包含WebP。
- 先观察10项缺少新模块的行为测试失败,再实现图片层;随后13项通过。
- wheel在临时安装目录独立加载素材并生成PNG通过;格式检查发现一行
超80字符,已按定位修正,待正式检查复核。
- 673原图片打入独立素材包;旧基线未修改。发现455个原阶段缺图引用,
使用问号占位和静态警告,未声称素材已补齐。
- 生成并目视检查1024宽WebP样例;三格式测量写入render-benchmark.json。
- 当前候选待Comet完整检查和独立验收,真实框架及上传未验证。
- Runtime完整检查通过:45项测试、827文件基线、作物目录和素材导出
可重复检查全部成功。已启动独立只读Verifier检查A1-A13,尚待结论。
- 独立Verifier已完成完整规格/源码/测试和实际样图核对,A1-A13全部
通过,Runtime接受报告并等待用户接受;未执行归档、提交或发布。
+101
View File
@@ -0,0 +1,101 @@
{
"python": "3.12.14",
"os": "Windows",
"pillow": "12.3.0",
"synthetic_data": true,
"scope": "本机单次样例;非旧版对照,未测上传或低配服务器",
"asset_count": 673,
"legacy_missing_stage_references": 455,
"measurements": [
{
"width": 1024,
"height": 624,
"codec": "WEBP",
"quality": 75,
"method": 2,
"bytes": 85820,
"cold_ms": 192.195,
"compose_ms": 131.62,
"encode_ms": 59.955,
"warm_ms": 0.559,
"warm_cache_hit": true,
"asset_cache_bytes": 5248724,
"result_cache_bytes": 89916
},
{
"width": 1024,
"height": 624,
"codec": "JPEG",
"quality": 75,
"method": 2,
"bytes": 112309,
"cold_ms": 119.514,
"compose_ms": 114.538,
"encode_ms": 4.475,
"warm_ms": 0.319,
"warm_cache_hit": true,
"asset_cache_bytes": 5248724,
"result_cache_bytes": 116405
},
{
"width": 1024,
"height": 624,
"codec": "PNG",
"quality": 75,
"method": 2,
"bytes": 1083976,
"cold_ms": 153.524,
"compose_ms": 111.887,
"encode_ms": 41.111,
"warm_ms": 0.271,
"warm_cache_hit": true,
"asset_cache_bytes": 5248724,
"result_cache_bytes": 1088072
},
{
"width": 1536,
"height": 936,
"codec": "WEBP",
"quality": 85,
"method": 2,
"bytes": 236462,
"cold_ms": 208.278,
"compose_ms": 130.485,
"encode_ms": 76.9,
"warm_ms": 0.327,
"warm_cache_hit": true,
"asset_cache_bytes": 11809468,
"result_cache_bytes": 240558
},
{
"width": 1536,
"height": 936,
"codec": "JPEG",
"quality": 85,
"method": 2,
"bytes": 293733,
"cold_ms": 136.219,
"compose_ms": 124.821,
"encode_ms": 10.86,
"warm_ms": 0.274,
"warm_cache_hit": true,
"asset_cache_bytes": 11809468,
"result_cache_bytes": 297829
},
{
"width": 1536,
"height": 936,
"codec": "PNG",
"quality": 85,
"method": 2,
"bytes": 2168696,
"cold_ms": 208.414,
"compose_ms": 124.066,
"encode_ms": 83.863,
"warm_ms": 0.285,
"warm_cache_hit": true,
"asset_cache_bytes": 11809468,
"result_cache_bytes": 2172792
}
]
}
+34
View File
@@ -0,0 +1,34 @@
# 共享农场绘图实施计划
目标:按已确认 farm-render 规格恢复图片展示并减少生成和传输开销。
架构:核心快照 → 不可变展示数据 → Pillow 同步合成 → 受控异步入口。
技术:Python 3.10+、Pillow、unittest;核心零第三方运行依赖不变。
规格:docs/comet/changes/farm-core-foundation/specs/farm-render/spec.md。
## 约束与审查重点
- legacy 不改;运行时不访问 legacy、不联网、不复制未授权字体。
- 缓存按字节限制;共享素材复制后使用,跨作用域不串图。
- 关注成熟/再生长边界、极长昵称、坏头像、编码能力和调用取消。
- 不接真实平台、不发布;真实上传能力仍待适配器确认。
## 实施顺序
- [x] 先写 tests/test_render.py:透明合成、三种编码、状态转换、
缓存隔离/失效/上限、无效头像、异步取消/排队/错误/关闭。
运行并确认缺失新功能导致失败。
- [x] src/farm_render/canvas.py、models.py:图片原语和输入/输出模型。
- [x] scripts/export_render_assets.py:可重复打包选定旧素材及展示元数据;
src/farm_render/assets.py:受限读取与 LRU,layout.py:纯阶段转换。
- [x] renderer.py:目标尺寸底图、作物、真实账户面板、三格式编码。
- [x] service.py:一次快照、受控并发、重复请求合并、取消和关闭排空。
- [x] scripts/render_demo.py:生成普通/高清和编码对比;观察实际成图。
- [x] pyproject.toml 增加 render extra、资源打包;测试 wheel 独立渲染。
- [x] docs/10-rendering.md 保存用法、测量、边界,更新索引和进度。
- [x] 定向测试通过后提交 Comet 检查,由独立只读 Verifier 验收 A1-A13。
不自动提交、归档或推送;最终验收结果交用户接受。
正式Runtime检查:45项测试、827文件基线和两类导出检查通过;
独立只读Verifier核对全部A1-A13并全部判定通过,Runtime已接受报告。
当前等待用户接受结果,不自动归档。
+23
View File
@@ -45,3 +45,26 @@
| 用户要求停止拉取时三个 clone 已完成 | 如实说明,收到删除要求后逐个校验路径并删除 |
没有尝试绕过用户停止拉取要求,也没有执行远程发布。
## 第二轮:基础迁移开发
用户已确认范围并选择当前目录,Comet已进入Build。
- [x] 修复Comet管理区并完成Native初始化。
- [x] 202条配置离线迁移,建立独立核心包。
- [x] 注册、查询、购买、播种、收获、铲除、锁定及出售。
- [x] SQLite事务、流水、幂等、并发及取消恢复。
- [x] 32项定向测试通过;文档与打包入口已完成。
- [x] Runtime完整检查与独立Verifier验收:32项测试、8/8验收通过。
- [ ] 用户接受验收结果后归档;本轮不推送发布。
开发期问题:测试连接未关闭导致Windows清理失败,已修复并复测。
主仓库origin由用户设置为Shu-Ying/main_plugin_farm,未访问远程。
## 第三轮:共享农场图片
- [x] 用户确认方案并补充WebP,当前目录进入Build。
- [x] 独立Pillow工具、快照展示、673素材原样打包、缓存和受控任务。
- [x] 13项渲染定向测试通过,wheel独立图片生成通过。
- [x] 生成普通/高清样例并检查文字布局,保存三格式测量和使用文档。
- [x] 正式Runtime检查45项测试通过;独立Verifier判定全部A1-A13通过。
- [ ] 用户接受结果后归档。
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "farm-core"
version = "0.1.0"
description = "Framework-independent farm foundation"
readme = "README.md"
requires-python = ">=3.10"
license = {file = "LICENSE"}
dependencies = []
[project.optional-dependencies]
render = ["Pillow>=10.4,<13"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
farm_core = ["data/*.json"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md"]
[tool.ruff]
line-length = 80
target-version = "py310"
extend-exclude = ["legacy", ".agents", ".comet"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schema": "farm.release-targets.v1",
"status": "design-only",
"source_repository": null,
"source_repository": "https://github.com/Shu-Ying/main_plugin_farm.git",
"network_actions_enabled": false,
"targets": [
{
+59
View File
@@ -0,0 +1,59 @@
"""从只读旧作物库可复现导出独立目录,不访问玩家库或远程。"""
import argparse
from contextlib import closing
import hashlib
import json
from pathlib import Path
import sqlite3
def export(source: Path) -> bytes:
source = source.resolve()
with closing(sqlite3.connect(
source.as_uri() + "?mode=ro", uri=True,
)) as connection:
connection.row_factory = sqlite3.Row
rows = [
dict(row) for row in connection.execute(
"SELECT * FROM plant ORDER BY id"
)
]
if not rows or len({row["name"] for row in rows}) != len(rows):
raise ValueError("Invalid or duplicate catalogue")
document = {
"schema": 1,
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"plants": rows,
}
return (
json.dumps(document, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n"
).encode("utf-8")
def main():
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--source", type=Path,
default=root / "legacy/zhenxun_plugin_farm/resource/db/plant.db",
)
parser.add_argument(
"--output", type=Path,
default=root / "src/farm_core/data/plants.json",
)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
content = export(args.source)
if args.check:
if not args.output.is_file() or args.output.read_bytes() != content:
raise SystemExit("Catalogue differs; regenerate it explicitly")
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(content)
print("Catalogue checked" if args.check else "Catalogue exported")
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
"""可复现复制旧绘图素材;不修改图片、不包含字体或玩家数据。"""
import argparse
import hashlib
from io import BytesIO
import json
from pathlib import Path
from zipfile import ZIP_STORED, ZipFile, ZipInfo
from PIL import Image
def export(root):
legacy = root / "legacy/zhenxun_plugin_farm"
source = legacy / "resource"
paths = list((source / "plant").rglob("*.png"))
paths += [source / f"soil/{name}.png" for name in (
"草土地", "普通土地",
)]
paths += [source / "background/expansion.png"]
paths += [source / "background/ripe.png"]
paths += [source / (
f"background/decorateIcon/decorate_{theme}_1.jpg"
) for theme in ("default", "spring", "summer", "autumn", "winter")]
layout = json.loads((legacy / "config/soil.json").read_bytes())
catalogue = json.loads((
root / "src/farm_core/data/plants.json"
).read_bytes())
metadata = {
"schema": 1, "canvas": [3072, 1872], "soil_size": layout["size"],
"positions": [layout["soil"][str(i)] for i in range(1, 31)],
"plants": {row["name"]: {
key: row[key] for key in (
"phase", "general", "offsetX", "offsetY", "offsetW", "offsetH",
)
} for row in catalogue["plants"]}, "images": {},
}
output = BytesIO()
with ZipFile(output, "w", compression=ZIP_STORED) as bundle:
for path in sorted(paths):
name = path.relative_to(source).as_posix()
content = path.read_bytes()
with Image.open(BytesIO(content)) as image:
size = image.size
metadata["images"][name] = {
"size": size, "sha256": hashlib.sha256(content).hexdigest(),
}
bundle.writestr(ZipInfo(name, (2020, 1, 1, 0, 0, 0)), content)
return output.getvalue(), (
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n"
).encode("utf-8")
def main():
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
files = zip(("assets.zip", "metadata.json"), export(root))
destination = root / "src/farm_render/data"
for name, content in files:
path = destination / name
if args.check:
if not path.is_file() or path.read_bytes() != content:
raise SystemExit(f"Render asset differs: {name}")
else:
destination.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
print("Render assets checked" if args.check else "Render assets exported")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""离线样例与本机测量,不访问真实玩家库或平台。"""
import argparse
from dataclasses import replace
import json
import logging
from pathlib import Path
import platform
import sys
from time import perf_counter
from PIL import __version__ as pillow_version, features
from farm_core import Catalogue, Identity
from farm_core.domain import Account, Plot, Snapshot
from farm_render import FarmRenderer, RenderOptions
def demonstration():
catalogue = Catalogue.bundled()
plants = [p for p in catalogue if not p.name.startswith((
"一星", "二星", "三星", "四星", "五星",
))]
now = 36000
plots = []
for index in range(1, 19):
plant = plants[(index - 1) % min(len(plants), 20)]
if index % 6 == 0:
plots.append(Plot(index))
continue
mature_at = now - 10 if index % 3 == 0 else (
now + plant.growth_seconds // 3
)
plot = Plot(index, plant, mature_at)
if index == 11:
plot = replace(plot, harvest_count=plant.crop)
plots.append(plot)
return Snapshot(
Account("舒影的四季农场", 12580, 36, 3420), (), tuple(plots),
), now
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--font", type=Path, required=True)
parser.add_argument(
"--output", type=Path, default=Path("runtime/render-demo"),
)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=args.output / "render.log", encoding="utf-8",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
who = Identity("demo", "local", "local", "synthetic-farmer")
snapshot, now = demonstration()
rows = []
for width, quality in ((1024, 75), (1536, 85)):
for codec in ("WEBP", "JPEG", "PNG"):
if codec == "WEBP" and not features.check("webp"):
continue
renderer = FarmRenderer(args.font)
options = RenderOptions(
width=width, quality=quality, format=codec, theme="spring",
)
started = perf_counter()
result = renderer.render(who, snapshot, now, options)
cold_ms = (perf_counter() - started) * 1000
started = perf_counter()
warm = renderer.render(who, snapshot, now, options)
warm_ms = (perf_counter() - started) * 1000
suffix = {"WEBP":"webp", "JPEG":"jpg", "PNG":"png"}[codec]
output = args.output / f"farm-{width}.{suffix}"
output.write_bytes(result.content)
rows.append({
"width": width, "height": result.height, "codec": codec,
"quality": quality, "method": options.method,
"bytes": len(result.content), "cold_ms": round(cold_ms, 3),
"compose_ms": round(result.compose_ms, 3),
"encode_ms": round(result.encode_ms, 3),
"warm_ms": round(warm_ms, 3), "warm_cache_hit": warm.cache_hit,
"asset_cache_bytes": renderer.assets.cache.used_bytes,
"result_cache_bytes": renderer.results.used_bytes,
})
metadata = renderer.assets.metadata
missing = sum(
f"plant/{name}/{stage}.png" not in metadata["images"]
for name, info in metadata["plants"].items()
for stage in range(1, len(dict.fromkeys(info["phase"].split(",")))+1)
)
report = {
"python": platform.python_version(), "os": platform.system(),
"pillow": pillow_version, "synthetic_data": True,
"scope": "本机单次样例;非旧版对照,未测上传或低配服务器",
"asset_count": len(metadata["images"]),
"legacy_missing_stage_references": missing, "measurements": rows,
}
content = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
(args.report or args.output / "benchmark.json").write_text(
content, encoding="utf-8",
)
print(content)
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
"""最终离线检查入口,由 Comet 执行并保存正式检查结果。"""
import os
from pathlib import Path
import subprocess
import sys
def main():
root = Path(__file__).resolve().parents[1]
environment = {
**os.environ, "PYTHONPATH": str(root / "src"), "PIP_NO_INDEX": "1",
"PYTHONIOENCODING": "utf-8",
}
commands = (
[sys.executable, "scripts/check_baseline.py"],
[sys.executable, "scripts/export_catalogue.py", "--check"],
[sys.executable, "scripts/export_render_assets.py", "--check"],
[
sys.executable, "-m", "unittest", "discover",
"-s", "tests", "-p", "test_*.py", "-v",
],
)
for command in commands:
result = subprocess.run(command, cwd=root, env=environment)
if result.returncode:
return result.returncode
return 0
if __name__ == "__main__":
sys.exit(main())
+5 -5
View File
@@ -1,6 +1,6 @@
# 共享核心预留位置
# 共享基础核心
当前只有职责说明,未实现新核心。完整目标见 docs/02-architecture.md。
实施时创建 domain、application、ports、infrastructure、presentation。
禁止导入 nonebot、astrbot、zhenxun;内部模块采用相对导入。
先实现注册、库存、资产和一个完整交易,不直接复制旧全局管理器。
本轮已实现框架无关的作物目录、注册、交易及种植闭环。
模块责任与限制见 docs/09-foundation.md,运行方式见根 README。
运行无第三方依赖,导入不创建数据库、文件或后台线程。
本包不是机器人插件入口;integrations 下接入尚未实现。
+11
View File
@@ -0,0 +1,11 @@
"""框架无关的农场基础核心,导入时不启动服务。"""
from .application import FarmService
from .catalogue import Catalogue
from .domain import Asset, FarmError, Identity
from .storage import SQLiteStore
__all__ = [
"Asset", "Catalogue", "FarmError", "FarmService", "Identity",
"SQLiteStore",
]
+55
View File
@@ -0,0 +1,55 @@
"""离线演示;只有显式调用才创建指定数据库。"""
import argparse
import asyncio
import logging
from pathlib import Path
from . import Catalogue, FarmService, Identity, SQLiteStore
from .presentation import receipt_text, snapshot_text
async def demo(database):
clock = [0]
catalogue = Catalogue.bundled()
plant = min(
(p for p in catalogue if p.level == 0 and not p.is_vip),
key=lambda p: p.cost,
)
store = SQLiteStore(database)
await store.initialize()
service = FarmService(store, catalogue, lambda: clock[0])
identity = Identity("demo", "local", "local", "farmer")
try:
for receipt in (
await service.register(identity, "演示农场", "demo-register"),
await service.buy(identity, plant.name, 1, "demo-buy"),
await service.sow(identity, plant.name, 1, "demo-sow"),
):
print(receipt_text(receipt))
clock[0] = plant.growth_seconds
print(receipt_text(await service.harvest(identity, "demo-harvest")))
print(receipt_text(await service.sell(
identity, None, None, "demo-sell",
)))
print(snapshot_text(await service.snapshot(identity)))
finally:
await store.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", required=True)
args = parser.parse_args()
database = Path(args.database).resolve()
database.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=database.with_suffix(".log"), encoding="utf-8",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
asyncio.run(demo(database))
if __name__ == "__main__":
main()
+243
View File
@@ -0,0 +1,243 @@
"""应用用例:一次业务动作对应一次完整存储事务。"""
from dataclasses import asdict, replace
import time
from .catalogue import Catalogue
from .domain import (
Asset, FarmError, Identity, Plot, boosted, integer, quantity, text,
)
from .ports import Store
class FarmService:
def __init__(self, store: Store, catalogue: Catalogue, clock=None):
self.store = store
self.catalogue = catalogue
self.clock = clock if clock is not None else lambda: int(time.time())
@staticmethod
def _identity(identity):
if not isinstance(identity, Identity):
raise FarmError("invalid_identity")
async def _execute(
self, identity, operation_id, action, parameters, operation,
):
self._identity(identity)
text(operation_id)
now = integer(self.clock())
return await self.store.transact(
identity, operation_id, action, parameters, now,
lambda repository: operation(repository, now),
)
@staticmethod
def _result(repository, **details):
account = repository.account()
return {
**details, "account": asdict(account), "level": account.level,
}
async def snapshot(self, identity: Identity):
self._identity(identity)
return await self.store.snapshot(identity)
async def register(
self, identity: Identity, name: str, operation_id: str,
):
text(name, 64)
def operation(repository, now):
created = repository.register(name)
return self._result(repository, created=created)
return await self._execute(
identity, operation_id, "register", {"name": name}, operation,
)
async def buy(
self, identity: Identity, name: str, count: int, operation_id: str,
):
text(name)
quantity(count)
def operation(repository, now):
plant = self.catalogue.get(name)
if repository.account().level < plant.level:
raise FarmError("level_required")
cost = integer(plant.cost * count)
repository.asset(plant.asset, -cost)
repository.stock(name, seeds=count)
return self._result(
repository, name=name, quantity=count, cost=cost,
asset=plant.asset.value,
seeds=repository.item(name).seeds,
)
return await self._execute(
identity, operation_id, "buy",
{"name": name, "count": count}, operation,
)
async def sow(
self, identity: Identity, name: str, count: int | None,
operation_id: str,
):
text(name)
if count is not None:
quantity(count)
def operation(repository, now):
repository.account()
plant = self.catalogue.get(name)
available = repository.item(name).seeds
empty = [p for p in repository.plots() if p.plant is None]
amount = min(available, len(empty)) if count is None else count
if amount == 0 or amount > available:
raise FarmError("insufficient_stock")
if amount > len(empty):
raise FarmError("insufficient_empty_plots")
mature_at = integer(now + plant.growth_seconds)
repository.stock(name, seeds=-amount)
for plot in empty[:amount]:
repository.save_plot(replace(
plot, plant=plant, mature_at=mature_at, harvest_count=0,
))
return self._result(
repository, name=name, quantity=amount,
slots=[p.slot for p in empty[:amount]],
seeds=repository.item(name).seeds,
)
return await self._execute(
identity, operation_id, "sow",
{"name": name, "count": count}, operation,
)
async def harvest(self, identity: Identity, operation_id: str):
def operation(repository, now):
repository.account()
harvested = []
total_experience = 0
for plot in repository.plots():
plant = plot.plant
if (
plant is None or plot.withered or plot.mature_at is None
or plot.mature_at > now
):
continue
amount = boosted(plant.harvest, plot.yield_bonus)
experience = boosted(
plant.experience, plot.experience_bonus,
)
repository.stock(plant.name, crops=amount)
total_experience = integer(total_experience + experience)
count = plot.harvest_count + 1
mature_at = (
integer(now + plant.regrowth_seconds)
if count < plant.crop else None
)
repository.save_plot(replace(
plot, mature_at=mature_at, harvest_count=count,
))
harvested.append({
"slot": plot.slot, "name": plant.name,
"quantity": amount, "experience": experience,
})
if not harvested:
raise FarmError("nothing_to_harvest")
repository.asset(Asset.EXPERIENCE, total_experience)
return self._result(
repository, harvested=harvested,
experience=total_experience,
quantity=sum(item["quantity"] for item in harvested),
)
return await self._execute(
identity, operation_id, "harvest", {}, operation,
)
async def eradicate(self, identity: Identity, operation_id: str):
def operation(repository, now):
repository.account()
withered = [p for p in repository.plots() if p.withered]
if not withered:
raise FarmError("nothing_to_eradicate")
for plot in withered:
repository.save_plot(replace(
plot, plant=None, mature_at=None, harvest_count=0,
))
return self._result(
repository, slots=[p.slot for p in withered],
)
return await self._execute(
identity, operation_id, "eradicate", {}, operation,
)
async def lock(
self, identity: Identity, name: str, locked: bool,
operation_id: str,
):
text(name)
if type(locked) is not bool:
raise FarmError("invalid_lock")
def operation(repository, now):
repository.account()
self.catalogue.get(name)
repository.lock(name, locked)
return self._result(repository, name=name, locked=locked)
return await self._execute(
identity, operation_id, "lock",
{"name": name, "locked": locked}, operation,
)
async def sell(
self, identity: Identity, name: str | None, count: int | None,
operation_id: str,
):
if name is not None:
text(name)
if count is not None:
quantity(count)
if name is None:
raise FarmError("name_required_for_quantity")
def operation(repository, now):
repository.account()
if name is not None:
item = repository.item(name)
if item.locked:
raise FarmError("plant_locked")
amount = item.crops if count is None else count
if amount <= 0 or amount > item.crops:
raise FarmError("insufficient_stock")
items = [(name, amount)]
else:
items = [
(item.name, item.crops)
for item in repository.items()
if not item.locked and item.crops > 0
]
if not items:
raise FarmError("nothing_to_sell")
proceeds = 0
sold = []
for plant_name, amount in items:
plant = self.catalogue.get(plant_name)
proceeds = integer(proceeds + plant.price * amount)
repository.stock(plant_name, crops=-amount)
sold.append({"name": plant_name, "quantity": amount})
repository.asset(Asset.POINT, proceeds)
return self._result(
repository, sold=sold, proceeds=proceeds,
quantity=sum(item["quantity"] for item in sold),
)
return await self._execute(
identity, operation_id, "sell",
{"name": name, "count": count}, operation,
)
+52
View File
@@ -0,0 +1,52 @@
"""只读、离线作物目录加载。"""
from importlib import resources
import json
from types import MappingProxyType
from .domain import FarmError, Plant, text
class Catalogue:
def __init__(self, plants):
items = tuple(plants)
if not items or any(not isinstance(p, Plant) for p in items):
raise FarmError("invalid_catalogue")
names = {plant.name: plant for plant in items}
if len(names) != len(items):
raise FarmError("duplicate_plant")
if len({p.plant_id for p in items}) != len(items):
raise FarmError("duplicate_plant")
self._plants = MappingProxyType(names)
def get(self, name: str) -> Plant:
text(name)
try:
return self._plants[name]
except KeyError as exc:
raise FarmError("unknown_plant") from exc
def __iter__(self):
return iter(self._plants.values())
def __len__(self):
return len(self._plants)
@classmethod
def loads(cls, content: str | bytes) -> "Catalogue":
try:
data = json.loads(content)
if type(data["schema"]) is not int or data["schema"] != 1:
raise FarmError("invalid_catalogue")
if not isinstance(data["plants"], list):
raise FarmError("invalid_catalogue")
return cls(Plant.from_legacy(row) for row in data["plants"])
except (KeyError, TypeError, ValueError, UnicodeError) as exc:
raise FarmError("invalid_catalogue") from exc
@classmethod
def bundled(cls) -> "Catalogue":
content = resources.files(__package__).joinpath(
"data", "plants.json"
).read_bytes()
return cls.loads(content)
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
"""不可变领域数据及源自旧版的纯计算规则。"""
from dataclasses import asdict, dataclass
from enum import Enum
from math import isqrt
import json
from typing import Any
MAX_VALUE = 2**63 - 1
MAX_QUANTITY = 1_000_000
class FarmError(ValueError):
"""可由接入层区分的业务失败。"""
def __init__(self, code: str):
self.code = code
super().__init__(code)
class StorageError(RuntimeError):
"""数据库故障,不得作为无农场或业务成功处理。"""
class SchemaError(StorageError):
"""数据库不属于本核心或 schema 不兼容。"""
class Asset(str, Enum):
"""资产字段;经验与两种货币分别记账。"""
POINT = "point" # 农场币。
VIP_POINT = "vip_point" # 点券,不与农场币自动兑换。
EXPERIENCE = "experience" # 累计经验。
def integer(value: int, minimum: int = 0, maximum: int = MAX_VALUE) -> int:
"""拒绝布尔值、非整数及超出明确范围的数值。"""
if type(value) is not int or not minimum <= value <= maximum:
raise FarmError("invalid_integer")
return value
def text(value: str, maximum: int = 128) -> str:
"""身份和操作标识必须是有界的非空字符串。"""
if not isinstance(value, str) or not value.strip():
raise FarmError("invalid_text")
if len(value) > maximum or any(ord(char) < 32 for char in value):
raise FarmError("invalid_text")
return value
def quantity(value: int) -> int:
return integer(value, 1, MAX_QUANTITY)
def level_for(experience: int) -> int:
"""沿用 200*k*(k+1)//2 门槛,避免浮点误差。"""
return (isqrt(1 + integer(experience) // 25) - 1) // 2
def boosted(base: int, percent: int = 0) -> int:
"""单地块向下取整;调用方只对各地块结果求和。"""
integer(base)
integer(percent, -100, 10_000)
return integer(base * (100 + percent) // 100)
@dataclass(frozen=True)
class Identity:
"""调用方明确提供的身份,核心不猜测账号等价性。"""
deployment: str # 独立部署标识。
realm: str # 调用方决定的游戏共享范围。
platform: str # 消息平台命名空间,不是机器人框架名称。
user_id: str # 平台用户标识,始终保存为字符串。
def __post_init__(self):
for value in asdict(self).values():
text(value)
@property
def key(self) -> str:
return json.dumps(
list(asdict(self).values()), ensure_ascii=False,
separators=(",", ":"),
)
@dataclass(frozen=True)
class Plant:
"""播种时保存的不可变结算定义。"""
plant_id: int # 旧作物目录 ID。
name: str # 唯一作物名称。
level: int # 购买等级门槛。
buy: int # 普通种子农场币价格。
is_vip: bool # 是否使用点券购买。
vip_buy: int # 点券种子价格。
experience: int # 每季基础经验。
harvest: int # 每季基础产量。
price: int # 每个收获物的农场币售价。
growth_seconds: int # 首次成熟所需秒数。
crop: int # 最多可收获季数。
regrowth_seconds: int # 兼容旧版的多季再生长秒数。
def __post_init__(self):
text(self.name)
if type(self.is_vip) is not bool:
raise FarmError("invalid_catalogue")
for name in (
"plant_id", "growth_seconds", "crop", "regrowth_seconds",
"harvest",
):
integer(getattr(self, name), 1)
for name in ("level", "buy", "vip_buy", "experience", "price"):
integer(getattr(self, name))
@property
def asset(self) -> Asset:
return Asset.VIP_POINT if self.is_vip else Asset.POINT
@property
def cost(self) -> int:
return self.vip_buy if self.is_vip else self.buy
@classmethod
def from_legacy(cls, row: dict[str, Any]) -> "Plant":
try:
for key in (
"id", "level", "buy", "isVip", "vipBuy", "experience",
"harvest", "price", "time", "crop",
):
integer(row[key])
if row["isVip"] not in (0, 1):
raise FarmError("invalid_catalogue")
if not isinstance(row["phase"], str):
raise FarmError("invalid_catalogue")
parts = row["phase"].split(",")
if not parts or any(not p.isascii() or not p.isdigit()
for p in parts):
raise FarmError("invalid_catalogue")
phases = list(dict.fromkeys(int(part) for part in parts))
for phase in phases:
integer(phase, 1)
if len(phases) < 2 or phases != sorted(phases):
raise FarmError("invalid_catalogue")
return cls(
row["id"], row["name"], row["level"], row["buy"],
bool(row["isVip"]), row["vipBuy"], row["experience"],
row["harvest"], row["price"], row["time"] * 3600,
row["crop"], sum(phases[1:]),
)
except (KeyError, TypeError, ValueError, OverflowError) as exc:
raise FarmError("invalid_catalogue") from exc
@dataclass(frozen=True)
class Account:
"""已提交的账户查询快照。"""
name: str # 农场名称。
point: int # 当前农场币。
vip_point: int # 当前点券。
experience: int # 当前累计经验。
@property
def level(self) -> int:
return level_for(self.experience)
@dataclass(frozen=True)
class InventoryItem:
"""同一作物的种子、产物和锁定状态。"""
name: str # 作物名称。
seeds: int = 0 # 未播种种子数。
crops: int = 0 # 已收获产物数。
locked: bool = False # 是否禁止出售产物。
@dataclass(frozen=True)
class Plot:
"""地块状态;空地无 plant,最后一季后保留枯萎实例。"""
slot: int # 从 1 开始的地块编号。
plant: Plant | None = None # 播种时冻结的作物规则。
mature_at: int | None = None # 下一季成熟 UTC 秒数。
harvest_count: int = 0 # 当前实例已收获季数。
yield_bonus: int = 0 # 此地块产量加成百分比。
experience_bonus: int = 0 # 此地块经验加成百分比。
@property
def withered(self) -> bool:
return self.plant is not None and self.harvest_count >= self.plant.crop
@dataclass(frozen=True)
class Snapshot:
"""同一数据库读事务内取得的一致快照。"""
account: Account # 账户。
inventory: tuple[InventoryItem, ...] # 按名称排序的仓库。
plots: tuple[Plot, ...] # 按编号排序的地块。
@dataclass(frozen=True)
class Receipt:
"""可持久化、可重放的交易结果;data 只是脱离数据库的返回副本。"""
operation_id: str # 调用方稳定提供的幂等操作标识。
action: str # 已提交的业务动作。
data: dict[str, Any] # 实际成交数值与提交后的资产。
+32
View File
@@ -0,0 +1,32 @@
"""应用层需要的最小事务仓储契约。"""
from typing import Any, Callable, Protocol
from .domain import (
Account, Asset, Identity, InventoryItem, Plot, Receipt, Snapshot,
)
class Repository(Protocol):
def account(self) -> Account: ...
def register(self, name: str) -> bool: ...
def asset(self, asset: Asset, delta: int) -> None: ...
def item(self, name: str) -> InventoryItem: ...
def items(self) -> tuple[InventoryItem, ...]: ...
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None: ...
def lock(self, name: str, locked: bool) -> None: ...
def plots(self) -> tuple[Plot, ...]: ...
def save_plot(self, plot: Plot) -> None: ...
class Store(Protocol):
async def initialize(self) -> None: ...
async def close(self) -> None: ...
async def transact(
self, identity: Identity, operation_id: str, action: str,
parameters: dict[str, Any], now: int,
operation: Callable[[Repository], dict[str, Any]],
) -> Receipt: ...
async def snapshot(self, identity: Identity) -> Snapshot: ...
+44
View File
@@ -0,0 +1,44 @@
"""仅渲染已提交结果,不发送框架消息或修改业务状态。"""
from .domain import Receipt, Snapshot
def receipt_text(receipt: Receipt) -> str:
labels = {
"register": "注册", "buy": "购买", "sow": "播种",
"harvest": "收获", "eradicate": "铲除",
"lock": "锁定设置", "sell": "出售",
}
data = receipt.data
account = data["account"]
lines = [f"{labels[receipt.action]}已完成"]
if "created" in data:
lines.append("首次开通" if data["created"] else "农场已存在")
if "quantity" in data:
lines.append(f"实际数量:{data['quantity']}")
if "proceeds" in data:
lines.append(f"本次收入:{data['proceeds']}")
lines.append(f"农场币:{account['point']}")
lines.append(f"点券:{account['vip_point']}")
lines.append(f"累计经验:{account['experience']}")
return "\n".join(lines)
def snapshot_text(snapshot: Snapshot) -> str:
account = snapshot.account
lines = [
account.name, f"等级:{account.level}",
f"农场币:{account.point}", f"点券:{account.vip_point}",
f"累计经验:{account.experience}",
]
for item in snapshot.inventory:
lock = "锁定" if item.locked else "未锁定"
lines.append(
f"{item.name}:种子{item.seeds},作物{item.crops},{lock}"
)
for plot in snapshot.plots:
state = "空地" if plot.plant is None else plot.plant.name
if plot.withered:
state += "(枯萎)"
lines.append(f"地块{plot.slot}:{state}")
return "\n".join(lines)
+153
View File
@@ -0,0 +1,153 @@
"""单个 SQLite 事务内的仓储;不自行提交或开启事务。"""
from dataclasses import asdict
import json
import sqlite3
from .domain import (
Account, Asset, FarmError, InventoryItem, Plot, Plant, StorageError,
integer,
)
class SQLiteRepository:
def __init__(
self, connection: sqlite3.Connection, identity: str,
operation_id: str, action: str, now: int,
):
self._db = connection
self._identity = identity
self._operation_id = operation_id
self._action = action
self._now = now
def account(self) -> Account:
row = self._db.execute(
"SELECT name, point, vip_point, experience "
"FROM accounts WHERE identity=?", (self._identity,),
).fetchone()
if row is None:
raise FarmError("not_registered")
return Account(**dict(row))
def register(self, name: str) -> bool:
exists = self._db.execute(
"SELECT 1 FROM accounts WHERE identity=?", (self._identity,),
).fetchone()
if exists:
return False
self._db.execute(
"INSERT INTO accounts(identity, name) VALUES (?, ?)",
(self._identity, name),
)
self._db.executemany(
"INSERT INTO plots(identity, slot) VALUES (?, ?)",
[(self._identity, slot) for slot in range(1, 4)],
)
self.asset(Asset.POINT, 500)
return True
def asset(self, asset: Asset, delta: int) -> None:
if not isinstance(asset, Asset):
raise FarmError("invalid_asset")
integer(delta, -(2**63 - 1))
previous = getattr(self.account(), asset.value)
updated = previous + delta
if updated < 0:
raise FarmError("insufficient_funds")
integer(updated)
if delta == 0:
return
column = asset.value
cursor = self._db.execute(
f"UPDATE accounts SET {column}=? "
f"WHERE identity=? AND {column}=?",
(updated, self._identity, previous),
)
if cursor.rowcount != 1:
raise FarmError("state_conflict")
self._db.execute(
"INSERT INTO ledger("
"identity, operation_id, asset, delta, reason, created_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._identity, self._operation_id, asset.value, delta,
self._action, self._now,
),
)
def item(self, name: str) -> InventoryItem:
row = self._db.execute(
"SELECT name, seeds, crops, locked FROM inventory "
"WHERE identity=? AND name=?", (self._identity, name),
).fetchone()
if row is None:
return InventoryItem(name)
return InventoryItem(
row["name"], row["seeds"], row["crops"], bool(row["locked"]),
)
def items(self) -> tuple[InventoryItem, ...]:
rows = self._db.execute(
"SELECT name, seeds, crops, locked FROM inventory "
"WHERE identity=? ORDER BY name", (self._identity,),
).fetchall()
return tuple(
InventoryItem(r["name"], r["seeds"], r["crops"], bool(r["locked"]))
for r in rows
)
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None:
previous = self.item(name)
new_seeds, new_crops = previous.seeds + seeds, previous.crops + crops
if min(new_seeds, new_crops) < 0:
raise FarmError("insufficient_stock")
integer(new_seeds)
integer(new_crops)
self._db.execute(
"INSERT INTO inventory(identity, name, seeds, crops) "
"VALUES (?, ?, ?, ?) ON CONFLICT(identity, name) "
"DO UPDATE SET seeds=excluded.seeds, crops=excluded.crops",
(self._identity, name, new_seeds, new_crops),
)
def lock(self, name: str, locked: bool) -> None:
self.stock(name)
self._db.execute(
"UPDATE inventory SET locked=? WHERE identity=? AND name=?",
(int(locked), self._identity, name),
)
def plots(self) -> tuple[Plot, ...]:
rows = self._db.execute(
"SELECT * FROM plots WHERE identity=? ORDER BY slot",
(self._identity,),
).fetchall()
try:
return tuple(
Plot(
r["slot"],
Plant(**json.loads(r["plant"])) if r["plant"] else None,
r["mature_at"], r["harvest_count"],
r["yield_bonus"], r["experience_bonus"],
)
for r in rows
)
except (TypeError, ValueError) as exc:
raise StorageError("invalid_plot_snapshot") from exc
def save_plot(self, plot: Plot) -> None:
data = (
json.dumps(asdict(plot.plant), ensure_ascii=False)
if plot.plant else None
)
cursor = self._db.execute(
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
"yield_bonus=?, experience_bonus=? WHERE identity=? AND slot=?",
(
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
plot.experience_bonus, self._identity, plot.slot,
),
)
if cursor.rowcount != 1:
raise FarmError("unknown_plot")
+253
View File
@@ -0,0 +1,253 @@
"""显式初始化的 SQLite 存储;每个用例独占一条事务连接。"""
import asyncio
from contextlib import closing, contextmanager
import hashlib
import json
import logging
from pathlib import Path
import sqlite3
from .domain import (
FarmError, Identity, Receipt, SchemaError, Snapshot, StorageError,
)
from .repository import SQLiteRepository
LOGGER = logging.getLogger(__name__)
APPLICATION_ID = 0x4641524D
SCHEMA_VERSION = 1
SCHEMA = (
"""CREATE TABLE accounts(
identity TEXT PRIMARY KEY,
name TEXT NOT NULL,
point INTEGER NOT NULL DEFAULT 0
CHECK(typeof(point)='integer' AND point>=0),
vip_point INTEGER NOT NULL DEFAULT 0
CHECK(typeof(vip_point)='integer' AND vip_point>=0),
experience INTEGER NOT NULL DEFAULT 0
CHECK(typeof(experience)='integer' AND experience>=0)
)""",
"""CREATE TABLE operations(
identity TEXT NOT NULL REFERENCES accounts(identity),
operation_id TEXT NOT NULL,
action TEXT NOT NULL,
fingerprint TEXT NOT NULL,
result TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY(identity, operation_id)
)""",
"""CREATE TABLE inventory(
identity TEXT NOT NULL REFERENCES accounts(identity),
name TEXT NOT NULL,
seeds INTEGER NOT NULL DEFAULT 0
CHECK(typeof(seeds)='integer' AND seeds>=0),
crops INTEGER NOT NULL DEFAULT 0
CHECK(typeof(crops)='integer' AND crops>=0),
locked INTEGER NOT NULL DEFAULT 0 CHECK(locked IN (0,1)),
PRIMARY KEY(identity, name)
)""",
"""CREATE TABLE plots(
identity TEXT NOT NULL REFERENCES accounts(identity),
slot INTEGER NOT NULL CHECK(slot>0),
plant TEXT,
mature_at INTEGER,
harvest_count INTEGER NOT NULL DEFAULT 0 CHECK(harvest_count>=0),
yield_bonus INTEGER NOT NULL DEFAULT 0,
experience_bonus INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(identity, slot)
)""",
"""CREATE TABLE ledger(
id INTEGER PRIMARY KEY,
identity TEXT NOT NULL,
operation_id TEXT NOT NULL,
asset TEXT NOT NULL
CHECK(asset IN ('point','vip_point','experience')),
delta INTEGER NOT NULL CHECK(typeof(delta)='integer' AND delta!=0),
reason TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(identity, operation_id)
REFERENCES operations(identity, operation_id)
DEFERRABLE INITIALLY DEFERRED
)""",
)
class SQLiteStore:
def __init__(self, path: str | Path, timeout: float = 10):
if str(path) == ":memory:":
raise ValueError("Use a dedicated file for transaction connections")
self.path = Path(path).resolve()
self._timeout = timeout
self._ready = False
self._pending = set()
self._lifecycle = asyncio.Lock()
async def _offload(self, function):
task = asyncio.create_task(asyncio.to_thread(function))
self._pending.add(task)
def finished(completed):
self._pending.discard(completed)
if not completed.cancelled():
completed.exception()
task.add_done_callback(finished)
return await asyncio.shield(task)
@contextmanager
def _connection(self):
try:
with closing(sqlite3.connect(
self.path, timeout=self._timeout, isolation_level=None,
)) as connection:
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
yield connection
except sqlite3.Error as exc:
raise StorageError("sqlite_failure") from exc
@staticmethod
def _validate(connection):
app_id = connection.execute("PRAGMA application_id").fetchone()[0]
version = connection.execute("PRAGMA user_version").fetchone()[0]
tables = {
r[0] for r in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
expected = {"accounts", "operations", "inventory", "plots", "ledger"}
if (
app_id != APPLICATION_ID or version != SCHEMA_VERSION
or tables != expected
):
raise SchemaError("unsupported_database_schema")
async def initialize(self) -> None:
async with self._lifecycle:
await self._offload(self._initialize)
self._ready = True
def _initialize(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connection() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
app = connection.execute("PRAGMA application_id").fetchone()[0]
version = connection.execute(
"PRAGMA user_version"
).fetchone()[0]
tables = connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
if app == 0 and version == 0 and not tables:
for statement in SCHEMA:
connection.execute(statement)
connection.execute(
f"PRAGMA application_id={APPLICATION_ID}"
)
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
self._validate(connection)
connection.commit()
except BaseException:
connection.rollback()
raise
async def close(self) -> None:
async with self._lifecycle:
self._ready = False
pending = tuple(self._pending)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
def _ensure_ready(self):
if not self._ready:
raise StorageError("store_not_initialized_or_closed")
async def transact(
self, identity: Identity, operation_id: str, action: str,
parameters: dict, now: int, operation,
) -> Receipt:
self._ensure_ready()
request = json.dumps(
{"action": action, "parameters": parameters},
ensure_ascii=False, sort_keys=True, separators=(",", ":"),
)
fingerprint = hashlib.sha256(request.encode("utf-8")).hexdigest()
def execute():
with self._connection() as connection:
self._validate(connection)
connection.execute("BEGIN IMMEDIATE")
try:
existing = connection.execute(
"SELECT fingerprint, result FROM operations "
"WHERE identity=? AND operation_id=?",
(identity.key, operation_id),
).fetchone()
if existing:
if existing["fingerprint"] != fingerprint:
raise FarmError("operation_conflict")
result = json.loads(existing["result"])
else:
repository = SQLiteRepository(
connection, identity.key, operation_id, action, now,
)
result = operation(repository)
connection.execute(
"INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)",
(
identity.key, operation_id, action, fingerprint,
json.dumps(result, ensure_ascii=False), now,
),
)
connection.commit()
except BaseException as exc:
connection.rollback()
LOGGER.log(
logging.INFO if isinstance(exc, FarmError)
else logging.WARNING,
"farm rollback action=%s operation=%s error=%s",
action, operation_id, type(exc).__name__,
)
raise
LOGGER.info(
"farm committed action=%s operation=%s", action, operation_id,
)
return Receipt(operation_id, action, result)
return await self._offload(execute)
async def snapshot(self, identity: Identity) -> Snapshot:
self._ensure_ready()
def query():
with self._connection() as connection:
self._validate(connection)
connection.execute("BEGIN")
repository = SQLiteRepository(
connection, identity.key, "", "query", 0,
)
result = Snapshot(
repository.account(), repository.items(),
repository.plots(),
)
connection.commit()
return result
return await self._offload(query)
async def ledger(self, identity: Identity) -> tuple[dict, ...]:
self._ensure_ready()
def query():
with self._connection() as connection:
self._validate(connection)
rows = connection.execute(
"SELECT operation_id, asset, delta, reason, created_at "
"FROM ledger WHERE identity=? ORDER BY id",
(identity.key,),
).fetchall()
return tuple(dict(row) for row in rows)
return await self._offload(query)
+7
View File
@@ -0,0 +1,7 @@
"""可选共享图片层;核心模块无需导入本包。"""
from .models import RenderOptions, RenderResult
from .renderer import FarmRenderer
from .service import RenderService
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
+66
View File
@@ -0,0 +1,66 @@
"""包内素材与有界 LRU;不下载、不解压到磁盘、不暴露共享图片。"""
from collections import OrderedDict
from importlib import resources
from io import BytesIO
import json
from threading import RLock
from zipfile import ZipFile
from PIL import Image
class ByteLRU:
def __init__(self, limit):
if type(limit) is not int or limit < 0:
raise ValueError("Invalid cache budget")
self.limit = limit
self.used_bytes = 0
self._items = OrderedDict()
self._lock = RLock()
def get(self, key):
with self._lock:
item = self._items.get(key)
if item is None:
return None
self._items.move_to_end(key)
return item[0]
def put(self, key, value, size):
with self._lock:
old = self._items.pop(key, None)
if old is not None:
self.used_bytes -= old[1]
if size > self.limit or self.limit == 0:
return
while self._items and self.used_bytes + size > self.limit:
_, (_, removed) = self._items.popitem(last=False)
self.used_bytes -= removed
self._items[key] = (value, size)
self.used_bytes += size
class Assets:
def __init__(self, cache_bytes=32 * 1024 * 1024):
data = resources.files(__package__).joinpath("data")
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
self.cache = ByteLRU(cache_bytes)
self._bundle = data.joinpath("assets.zip")
def image(self, name, size=None):
if name not in self.metadata["images"]:
raise KeyError("Unknown packaged image")
size = tuple(size or 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:
content = bundle.read(name)
with Image.open(BytesIO(content)) as original:
original.thumbnail(size, Image.Resampling.LANCZOS)
cached = original.convert("RGBA")
if cached.size != size:
cached = cached.resize(size, Image.Resampling.LANCZOS)
self.cache.put(key, cached, size[0] * size[1] * 4)
return cached.copy()
+77
View File
@@ -0,0 +1,77 @@
"""同步 Pillow 图片原语;调用方负责整次绘制的异步调度。"""
from functools import lru_cache
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
@lru_cache(maxsize=32)
def font(path, size):
return ImageFont.truetype(str(path), size=size)
class Canvas:
def __init__(self, image):
self.image = image.convert("RGBA")
@classmethod
def new(cls, size, color=(0, 0, 0, 0)):
return cls(Image.new("RGBA", size, color))
def copy(self):
return Canvas(self.image.copy())
def resize(self, size):
return Canvas(self.image.resize(size, Image.Resampling.LANCZOS))
def crop(self, box):
return Canvas(self.image.crop(box))
def paste(self, image, position):
source = image.image if isinstance(image, Canvas) else image
self.image.alpha_composite(source.convert("RGBA"), position)
@staticmethod
def measure_text(text, font_path, size):
return font(font_path, size).getlength(text)
@classmethod
def fit_text(cls, text, font_path, size, width):
text = " ".join(str(text).split())[:256]
if cls.measure_text(text, font_path, size) <= width:
return text
suffix = "…"
while text and cls.measure_text(
text + suffix, font_path, size,
) > width:
text = text[:-1]
return text + suffix if cls.measure_text(
suffix, font_path, size,
) <= width else ""
def text(self, position, text, font_path, size, color):
ImageDraw.Draw(self.image).text(
position, text, font=font(font_path, size), fill=color,
)
def rectangle(self, box, color, radius=0):
ImageDraw.Draw(self.image).rounded_rectangle(
box, radius=radius, fill=color,
)
def encode(self, codec, quality=75, method=2):
output = BytesIO()
if codec == "JPEG":
flattened = Image.new("RGB", self.image.size, "white")
flattened.paste(self.image, mask=self.image.getchannel("A"))
flattened.save(output, "JPEG", quality=quality)
elif codec == "WEBP":
self.image.save(
output, "WEBP", quality=quality, method=method,
)
elif codec == "PNG":
self.image.save(output, "PNG", compress_level=3)
else:
raise ValueError("Unsupported output codec")
return output.getvalue()
+8
View File
@@ -0,0 +1,8 @@
# 绘图素材来源
assets.zip 原样复制自 Shu-Ying/zhenxun_plugin_farm 的本地保留基线,
对应源提交与完整校验见主仓库 docs/legacy-manifest.json。
仅选取农场季节背景、草地/普通地、作物及两个状态图标;metadata.json
来自旧布局和作物配置。沿用仓库 GPLv3 许可与原始素材归属,迁移不
增加第三方素材权利。后续对外发布前仍须核实历史素材来源及分发权限。
不包含字体;部署方配置可合法使用的中文字体路径。
Binary file not shown.
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
"""从一次快照与统一时间构造可缓存场景;不访问存储。"""
import hashlib
from farm_core.domain import Identity, Snapshot, integer
from .models import PlotView, Scene
def scene_from_snapshot(identity, snapshot, now, metadata):
if not isinstance(identity, Identity) or not isinstance(snapshot, Snapshot):
raise ValueError("Identity and snapshot are required")
integer(now)
slots = [p.slot for p in snapshot.plots]
if len(set(slots)) != len(slots) or any(
type(slot) is not int or not 1 <= slot <= 30 for slot in slots
):
raise ValueError("Invalid plot slots")
views = []
for plot in sorted(snapshot.plots, key=lambda p: p.slot):
if plot.plant is None:
views.append(PlotView(plot.slot, "empty"))
continue
if plot.withered:
views.append(PlotView(
plot.slot, "withered", "plant/basic/9.png", size=(150, 212),
))
continue
integer(plot.mature_at)
name = plot.plant.name
info = metadata["plants"].get(name)
if info is None:
views.append(PlotView(plot.slot, "growing", "missing"))
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)
elapsed = max(0, now - (plot.mature_at - duration))
ripe = now >= plot.mature_at
stage = len(phases) if ripe else sum(
elapsed * phases[-1] >= threshold * duration
for threshold in phases
)
sprite = f"plant/{name}/{stage}.png"
size = None
if stage == 0:
if info["general"]:
sprite = "plant/basic/0.png"
size = (max(1, 35 + info["offsetW"]),
max(1, 58 + info["offsetH"]))
views.append(PlotView(
plot.slot, "ripe" if ripe else "growing", sprite, stage,
(info["offsetX"], info["offsetY"]), size,
))
return Scene(
hashlib.sha256(identity.key.encode()).hexdigest(),
snapshot.account, tuple(views),
)
+74
View File
@@ -0,0 +1,74 @@
"""框架无关、不可变的渲染输入与输出。"""
from dataclasses import dataclass
from PIL import features
from farm_core.domain import Account
@dataclass(frozen=True)
class RenderOptions:
width: int = 1024 # 目标宽度;从此尺寸开始合成。
format: str = "AUTO" # AUTO/WEBP/JPEG/PNG。
quality: int = 75 # 有损编码质量,不代表分辨率。
method: int = 2 # WebP 编码力度,0最快,6最慢。
theme: str = "default" # 显式主题,不读取隐含季节或全局配置。
def __post_init__(self):
for value, low, high in (
(self.width, 256, 3072), (self.quality, 1, 95),
(self.method, 0, 6),
):
if type(value) is not int or not low <= value <= high:
raise ValueError("Invalid render option")
if self.format not in ("AUTO", "WEBP", "JPEG", "PNG"):
raise ValueError("Unsupported output format")
if self.theme not in ("default", "spring", "summer", "autumn",
"winter"):
raise ValueError("Unsupported theme")
@classmethod
def high(cls, **kwargs):
return cls(width=1536, quality=85, **kwargs)
def codec(self, supported=("JPEG",)):
if self.format == "AUTO":
if "WEBP" in supported and features.check("webp"):
return "WEBP"
if "JPEG" in supported:
return "JPEG"
if "PNG" in supported:
return "PNG"
raise ValueError("No supported image codec")
if self.format == "WEBP" and not features.check("webp"):
raise ValueError("Pillow WebP support is unavailable")
return self.format
@dataclass(frozen=True)
class PlotView:
slot: int # 旧布局的1到30地块编号。
state: str # empty/growing/ripe/withered。
sprite: str = "" # 包内受控素材名,不来自用户路径。
stage: int = 0 # 本季展示阶段。
offset: tuple[int, int] = (0, 0) # 旧素材中心偏移。
size: tuple[int, int] | None = None # 萌芽/枯萎原始尺寸覆盖。
@dataclass(frozen=True)
class Scene:
scope: str # 完整身份的摘要,只用于缓存隔离。
account: Account # 一致快照中的可见账户。
plots: tuple[PlotView, ...] # 已解锁地块及统一时刻的阶段。
@dataclass(frozen=True)
class RenderResult:
content: bytes # 已编码图片,不携带用户对象。
mime: str # 与实际编码一致的媒体类型。
width: int # 实际宽度。
height: int # 实际高度。
compose_ms: float # 本次合成毫秒。
encode_ms: float # 本次编码毫秒。
cache_hit: bool = False # 成图缓存命中。
+204
View File
@@ -0,0 +1,204 @@
"""纯快照农场合成:目标尺寸、独立素材、可见状态成图缓存。"""
from dataclasses import replace
import hashlib
from io import BytesIO
import logging
from pathlib import Path
from time import perf_counter
from PIL import Image, ImageDraw, UnidentifiedImageError
from .assets import Assets, ByteLRU
from .canvas import Canvas, font
from .layout import scene_from_snapshot
from .models import RenderOptions, RenderResult
logger = logging.getLogger(__name__)
MAX_AVATAR_BYTES = 2 * 1024 * 1024
MAX_AVATAR_PIXELS = 4_000_000
class FarmRenderer:
def __init__(
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
result_cache_bytes=8 * 1024 * 1024,
):
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)
self.results = ByteLRU(result_cache_bytes)
def scene(self, identity, snapshot, now):
return scene_from_snapshot(
identity, snapshot, now, self.assets.metadata,
)
def request_key(
self, identity, snapshot, now, options=None, avatar=None,
supported=("JPEG",),
):
options = options or RenderOptions()
if avatar is not None and not isinstance(avatar, bytes):
raise ValueError("Avatar must be bytes or None")
digest = hashlib.sha256(avatar).hexdigest() if (
avatar and len(avatar) <= MAX_AVATAR_BYTES
) else "default"
scene = self.scene(identity, snapshot, now)
return scene, options, options.codec(supported), digest
def render(
self, identity, snapshot, now, options=None, avatar=None,
supported=("JPEG",),
):
key = self.request_key(
identity, snapshot, now, options, avatar, supported,
)
cached = self.results.get(key)
if cached is not None:
logger.info("farm render cache=hit bytes=%s", len(cached.content))
return replace(cached, cache_hit=True, compose_ms=0, encode_ms=0)
scene, options, codec, _ = key
started = perf_counter()
canvas = self._base(scene, options)
self._plants(canvas, scene, options)
self._panel(canvas, scene, options, avatar)
composed = perf_counter()
content = canvas.encode(codec, options.quality, options.method)
encoded = perf_counter()
result = RenderResult(
content, {"JPEG":"image/jpeg", "PNG":"image/png",
"WEBP":"image/webp"}[codec],
*canvas.image.size, (composed - started) * 1000,
(encoded - composed) * 1000,
)
self.results.put(key, result, len(content) + 4096)
logger.info(
"farm render cache=miss compose_ms=%.2f encode_ms=%.2f "
"width=%s height=%s bytes=%s codec=%s",
result.compose_ms, result.encode_ms, result.width,
result.height, len(content), codec,
)
return result
@staticmethod
def _scale(value, options):
return round(value * options.width / 3072)
def _base(self, scene, options):
slots = frozenset(p.slot for p in scene.plots)
key = ("base", options.theme, options.width, slots)
cached = self.assets.cache.get(key)
if cached is not None:
return Canvas(cached.copy())
s = lambda n: self._scale(n, options)
size = (options.width, s(1872))
background = self.assets.image(
"background/decorateIcon/"
f"decorate_{options.theme}_1.jpg", size,
)
canvas = Canvas(background)
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),
}
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)
if slot not in slots and not expansion:
icon = self.assets.image(
"background/expansion.png", (max(1, s(90)),) * 2,
)
canvas.paste(icon, (xy[0] + s(70), xy[1] + s(10)))
expansion = True
self.assets.cache.put(key, canvas.image, size[0] * size[1] * 4)
return canvas.copy()
def _plants(self, canvas, scene, options):
s = lambda n: self._scale(n, options)
soil_w, soil_h = self.assets.metadata["soil_size"]
first_ripe = True
for plot in scene.plots:
if not plot.sprite:
continue
pos = self.assets.metadata["positions"][plot.slot - 1]
metadata = self.assets.metadata["images"].get(plot.sprite)
if metadata is None:
logger.warning("farm render missing packaged crop sprite")
plant = Image.new("RGBA", (max(1, s(60)),) * 2, "#597b45")
ImageDraw.Draw(plant).text(
(s(10), 0), "?", fill="white",
font=font(self.font_path, max(10, s(48))),
)
else:
size = plot.size or metadata["size"]
plant = self.assets.image(
plot.sprite, tuple(max(1, s(n)) for n in size),
)
offset_x, offset_y = plot.offset
xy = (
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
)
canvas.paste(plant, xy)
if plot.state == "ripe" and first_ripe:
icon = self.assets.image(
"background/ripe.png", (max(1, s(100)),) * 2,
)
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
first_ripe = False
def _avatar(self, content, size):
if content and len(content) <= MAX_AVATAR_BYTES:
try:
with Image.open(BytesIO(content)) as image:
if image.width * image.height > MAX_AVATAR_PIXELS:
raise ValueError("Avatar dimensions exceed limit")
image.thumbnail(size, Image.Resampling.LANCZOS)
avatar = Image.new("RGBA", size, "#cce3c1")
avatar.alpha_composite(image.convert("RGBA"), (
(size[0] - image.width) // 2,
(size[1] - image.height) // 2,
))
return avatar
except (UnidentifiedImageError, OSError, ValueError,
Image.DecompressionBombError):
logger.warning("farm render avatar rejected; using default")
avatar = Image.new("RGBA", size, "#dce8cf")
draw = ImageDraw.Draw(avatar)
w, h = size
draw.ellipse((w*.3, h*.12, w*.7, h*.52), fill="#6f8a54")
draw.ellipse((w*.15, h*.55, w*.85, h*1.2), fill="#6f8a54")
return avatar
def _panel(self, canvas, scene, options, avatar):
s = lambda n: self._scale(n, options)
canvas.rectangle(
tuple(s(n) for n in (90, 75, 1160, 425)),
"#fff9e8", s(24),
)
canvas.paste(self._avatar(avatar, (s(170), s(170))), (s(125), s(125)))
size = max(12, s(55))
name = canvas.fit_text(
scene.account.name, self.font_path, size, s(790),
)
canvas.text((s(330), s(105)), name, self.font_path, size, "#493525")
account = scene.account
lines = (
f"等级 {account.level} 经验 {account.experience}",
f"农场币 {account.point} 点券 {account.vip_point}",
f"已解锁 {len(scene.plots)}/30 块地",
)
for index, line in enumerate(lines):
size = max(10, s(43))
text = canvas.fit_text(line, self.font_path, size, s(790))
canvas.text(
(s(330), s(195 + index * 65)), text,
self.font_path, size, "#674d34",
)
+89
View File
@@ -0,0 +1,89 @@
"""异步入口:一次快照、有限任务、共享绘制与关闭排空。"""
import asyncio
import logging
from time import perf_counter
import time
logger = logging.getLogger(__name__)
class RenderService:
def __init__(self, renderer, concurrency=1, max_pending=8):
if (type(concurrency) is not int or type(max_pending) is not int
or concurrency < 1 or max_pending < concurrency):
raise ValueError("Invalid render capacity")
self.renderer = renderer
self._semaphore = asyncio.Semaphore(concurrency)
self._max_pending = max_pending
self._pending = {}
self._closed = False
async def my_farm(
self, source, identity, *, now=None, options=None, avatar=None,
supported=("JPEG",),
):
if self._closed:
raise RuntimeError("Render service closed")
started = perf_counter()
snapshot = await source.snapshot(identity)
logger.info("farm render query_ms=%.2f", (perf_counter()-started)*1000)
return await self.render(
identity, snapshot, int(time.time()) if now is None else now,
options, avatar, supported,
)
async def render(
self, identity, snapshot, now, options=None, avatar=None,
supported=("JPEG",),
):
if self._closed:
raise RuntimeError("Render service closed")
key = self.renderer.request_key(
identity, snapshot, now, options, avatar, supported,
)
task = self._pending.get(key)
if task is None:
if len(self._pending) >= self._max_pending:
raise RuntimeError("Render queue full")
task = asyncio.create_task(self._run(
identity, snapshot, now, options, avatar, supported,
))
self._pending[key] = task
def finished(done):
self._pending.pop(key, None)
if not done.cancelled():
done.exception()
task.add_done_callback(finished)
return await asyncio.shield(task)
async def _run(self, *args):
started = perf_counter()
async with self._semaphore:
logger.info(
"farm render queue_ms=%.2f", (perf_counter()-started)*1000,
)
worker = asyncio.create_task(asyncio.to_thread(
self.renderer.render, *args,
))
try:
return await asyncio.shield(worker)
except asyncio.CancelledError:
await worker
raise
async def close(self):
self._closed = True
pending = tuple(self._pending.values())
if pending:
await asyncio.shield(asyncio.gather(
*pending, return_exceptions=True,
))
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.close()
+9 -10
View File
@@ -1,12 +1,11 @@
# 测试实施边界
# 基础核心测试
当前没有新核心或业务自动化测试,不将空目录算作测试通过。
迁移完整性可运行 python scripts/check_baseline.py。
- test_domain.py:整数规则、202条目录、身份和错误配置。
- test_transactions.py:完整玩法、边界、回滚、幂等、并发、取消及schema。
- test_packaging.py:框架隔离、导入副作用、离线wheel安装。
后续按 docs/01-roadmap.md 和 docs/06-code-review.md 建立:
unit(纯规则与数值)、integration(SQLite 事务与并发)、
contracts(框架输入输出)、migrations(迁移与恢复)、
packaging(独立安装包及真实框架加载)。
首批必须覆盖旧缺陷 F01–F08/F11 和提交前取消、重复事件、跨日、
同一玩家并发操作。测试使用脱敏构造数据,不提交真实玩家数据库。
python scripts/verify_foundation.py 为统一离线验证入口。
包装测试需本机已有 pip、setuptools、wheel,不访问网络安装依赖。
测试只使用带明确前缀的临时目录和构造玩家,不读取真实玩家库。
语法基线3.10;本轮执行环境为Windows/Python3.12.14。
真实宿主合同、旧数据迁移和Linux运行矩阵尚待后续实现。
+72
View File
@@ -0,0 +1,72 @@
import json
from pathlib import Path
import unittest
from farm_core import Catalogue, FarmError, Identity
from farm_core.domain import (
MAX_VALUE, Plant, boosted, level_for,
)
class DomainTests(unittest.TestCase):
def test_integer_level_boundaries(self):
for level in (0, 1, 2, 50, 1000000):
threshold = 100 * level * (level + 1)
self.assertEqual(level_for(threshold), level)
if level:
self.assertEqual(level_for(threshold - 1), level - 1)
self.assertGreater(level_for(MAX_VALUE), 0)
def test_per_plot_bonus_is_order_independent(self):
plots = [(20, 100), (30, 50), (17, 10)]
first = sum(boosted(*plot) for plot in plots)
second = sum(boosted(*plot) for plot in reversed(plots))
self.assertEqual(first, 103)
self.assertEqual(first, second)
def test_bool_and_out_of_range_values_are_not_integers(self):
for value in (True, False, -1, 1.5, MAX_VALUE + 1):
with self.subTest(value=value):
with self.assertRaises(FarmError):
level_for(value)
def test_explicit_identity_encoding_does_not_collide(self):
first = Identity("a", "b:c", "d", "e")
second = Identity("a:b", "c", "d", "e")
self.assertNotEqual(first.key, second.key)
with self.assertRaises(FarmError):
Identity("a", "", "qq", "1")
def test_bundled_catalogue_matches_all_source_rows(self):
root = Path(__file__).resolve().parents[1]
document = json.loads(
(root / "src/farm_core/data/plants.json").read_bytes()
)
catalogue = Catalogue.bundled()
self.assertEqual(len(catalogue), 202)
for row in document["plants"]:
expected = Plant.from_legacy(row)
self.assertEqual(catalogue.get(row["name"]), expected)
self.assertEqual(catalogue.get("胡萝卜").growth_seconds, 13 * 3600)
def test_corrupt_catalogue_is_rejected(self):
for content in (
b"not json", b"{}", b'{"schema":true,"plants":[]}',
b'{"schema":2,"plants":[]}',
):
with self.subTest(content=content):
with self.assertRaises(FarmError):
Catalogue.loads(content)
def test_invalid_and_duplicate_plants_are_rejected(self):
plant = next(iter(Catalogue.bundled()))
with self.assertRaises(FarmError):
Catalogue([plant, plant])
with self.assertRaises(FarmError):
Catalogue.bundled().get("不存在")
with self.assertRaises(FarmError):
Plant(1, "bad", 0, -1, False, 0, 1, 1, 1, 1, 1, 1)
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
import ast
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class PackagingTests(unittest.TestCase):
def test_core_has_no_framework_imports_and_uses_python310_syntax(self):
for path in (
* (ROOT / "src/farm_core").glob("*.py"),
* (ROOT / "src/farm_render").glob("*.py"),
):
self.assertTrue(all(
len(line) <= 80
for line in path.read_text(encoding="utf-8").splitlines()
), path.name)
tree = ast.parse(
path.read_text(encoding="utf-8"), feature_version=(3, 10),
)
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
if isinstance(node, ast.ImportFrom) and node.level == 0:
names = [node.module or ""]
self.assertFalse(
any(name.split(".")[0] in (
"nonebot", "astrbot", "zhenxun",
) for name in names),
path.name,
)
def test_import_has_no_runtime_files_or_background_threads(self):
code = (
"import os,sys,threading; "
"before=set(os.listdir('.')); "
"threads=len(threading.enumerate()); "
"sys.path.insert(0,sys.argv[1]); import farm_core; "
"assert set(os.listdir('.'))==before; "
"assert len(threading.enumerate())==threads"
)
with tempfile.TemporaryDirectory(
prefix="farm-import-test-",
) as temporary:
subprocess.run(
[sys.executable, "-I", "-B", "-c", code, str(ROOT / "src")],
cwd=temporary, check=True, timeout=30,
)
def test_wheel_runs_without_source_legacy_or_frameworks(self):
with tempfile.TemporaryDirectory(
prefix="farm-wheel-test-",
) as temporary:
temporary = Path(temporary).resolve()
self.assertTrue(temporary.is_relative_to(
Path(tempfile.gettempdir()).resolve()
))
wheels = temporary / "wheels"
installed = temporary / "installed"
self.run_checked([
sys.executable, "-m", "pip", "--disable-pip-version-check",
"wheel", "--no-index", "--no-deps", "--no-build-isolation",
"--wheel-dir", str(wheels), str(ROOT),
])
wheel, = wheels.glob("farm_core-*.whl")
self.run_checked([
sys.executable, "-m", "pip", "--disable-pip-version-check",
"install", "--no-index", "--no-deps", "--no-compile",
"--target", str(installed), str(wheel),
])
code = """
import asyncio
from pathlib import Path
import sys
target = Path(sys.argv[1])
sys.path.insert(0, str(target))
import farm_core
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
from farm_render import FarmRenderer, RenderOptions
from PIL import Image
from io import BytesIO
assert Path(farm_core.__file__).is_relative_to(target)
assert len(Catalogue.bundled()) == 202
async def check():
store = SQLiteStore(sys.argv[2])
await store.initialize()
try:
service = FarmService(store, Catalogue.bundled(), lambda: 0)
who = Identity("installed", "local", "local", "user")
registered = await service.register(who, "installed", "register")
assert registered.data["account"]["point"] == 500
plant = next(
p for p in service.catalogue
if p.level == 0 and not p.is_vip and p.cost <= 500
)
await service.buy(who, plant.name, 1, "buy")
assert (await service.snapshot(who)).inventory[0].seeds == 1
font = Path(sys.argv[3])
renderer = FarmRenderer(font)
assert len(renderer.assets.metadata["images"]) > 600
result = renderer.render(
who, await service.snapshot(who), 0,
RenderOptions(format="PNG"),
)
assert Image.open(BytesIO(result.content)).size == (1024, 624)
finally:
await store.close()
asyncio.run(check())
"""
self.run_checked(
[
sys.executable, "-I", "-B", "-c", code, str(installed),
str(temporary / "installed.sqlite3"),
str(Path(os.environ.get(
"FARM_TEST_FONT", (
"C:/Windows/Fonts/msyh.ttc" if os.name == "nt"
else "/usr/share/fonts/truetype/"
"dejavu/DejaVuSans.ttf"
),
))),
],
cwd=temporary,
)
def run_checked(self, command, cwd=ROOT):
result = subprocess.run(
command, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=90,
env={**os.environ, "PIP_NO_INDEX": "1"},
)
self.assertEqual(
result.returncode, 0, result.stdout + result.stderr,
)
if __name__ == "__main__":
unittest.main()
+296
View File
@@ -0,0 +1,296 @@
import asyncio
from dataclasses import replace
from io import BytesIO
from pathlib import Path
import os
import threading
import time
import unittest
from unittest.mock import patch
from PIL import Image, features
from farm_core import Catalogue, Identity
from farm_core.domain import Account, Plot, Snapshot
try:
from farm_render import FarmRenderer, RenderOptions, RenderService
from farm_render.assets import ByteLRU
from farm_render.canvas import Canvas
except ImportError:
FarmRenderer = None
FONT = Path("C:/Windows/Fonts/msyh.ttc")
if not FONT.exists():
FONT = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
FONT = Path(os.environ.get("FARM_TEST_FONT", FONT))
def snapshot(plots=None, **account):
values = dict(name="演示农场", point=500, vip_point=0, experience=0)
values.update(account)
return Snapshot(Account(**values), (), tuple(
plots if plots is not None else [Plot(i) for i in range(1, 4)]
))
class RenderTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(FarmRenderer, "共享图片模块尚未实现")
self.renderer = FarmRenderer(FONT)
self.who = Identity("test", "local", "qq", "one")
self.plant = next(iter(Catalogue.bundled()))
def test_canvas_alpha_crop_text_and_formats(self):
canvas = Canvas.new((100, 80), "white")
overlay = Image.new("RGBA", (20, 20), (255, 0, 0, 128))
canvas.paste(overlay, (0, 0))
self.assertEqual(canvas.image.getpixel((5, 5))[:3], (255, 127, 127))
self.assertEqual(canvas.crop((0, 0, 10, 10)).image.size, (10, 10))
self.assertEqual(canvas.resize((50, 40)).image.size, (50, 40))
text = canvas.fit_text("long farm name" * 20, FONT, 16, 80)
self.assertLessEqual(canvas.measure_text(text, FONT, 16), 80)
canvas.text((1, 30), text, FONT, 16, "black")
for fmt in ("PNG", "JPEG", "WEBP"):
if fmt == "WEBP" and not features.check("webp"):
continue
content = canvas.encode(fmt, 75, 2)
with Image.open(BytesIO(content)) as image:
self.assertEqual(image.format, fmt)
self.assertEqual(image.size, (100, 80))
def test_profiles_formats_and_bad_avatar_fallback(self):
for width in (1024, 1536):
for fmt in ("JPEG", "PNG", "WEBP"):
if fmt == "WEBP" and not features.check("webp"):
continue
result = self.renderer.render(
self.who, snapshot(), 0,
RenderOptions(width=width, format=fmt), b"broken avatar",
)
with Image.open(BytesIO(result.content)) as image:
self.assertEqual(image.width, width)
self.assertEqual(image.format, fmt)
self.assertEqual(result.width, width)
self.assertGreater(result.compose_ms, 0)
def test_stage_boundaries_multiseason_and_withered(self):
plant = self.plant
plot = Plot(1, plant, plant.growth_seconds)
view = lambda p, now: self.renderer.scene(
self.who, snapshot([p]), now,
).plots[0]
self.assertEqual(view(plot, 0).state, "growing")
self.assertEqual(view(plot, plant.growth_seconds).state, "ripe")
self.assertNotEqual(view(plot, 0).sprite, view(plot, 20000).sprite)
multi = replace(plant, crop=3)
again = Plot(1, multi, multi.regrowth_seconds + 100, 1)
self.assertEqual(view(again, 100).stage, 0)
self.assertGreater(view(again, again.mature_at - 1).stage, 0)
dead = replace(again, harvest_count=3)
self.assertEqual(view(dead, 999999).state, "withered")
self.assertEqual(view(dead, 999999).sprite, "plant/basic/9.png")
def test_result_cache_visible_state_scope_and_time(self):
plant = self.plant
data = snapshot([Plot(1, plant, plant.growth_seconds)])
first = self.renderer.render(self.who, data, 0)
warm = self.renderer.render(self.who, data, 1)
self.assertFalse(first.cache_hit)
self.assertTrue(warm.cache_hit)
self.assertEqual(first.content, warm.content)
ripe = self.renderer.render(self.who, data, plant.growth_seconds)
self.assertFalse(ripe.cache_hit)
self.assertNotEqual(first.content, ripe.content)
changed = replace(data, account=replace(data.account, point=501))
self.assertFalse(self.renderer.render(
self.who, changed, 0,
).cache_hit)
other = replace(self.who, realm="other")
self.assertFalse(self.renderer.render(other, data, 0).cache_hit)
self.assertFalse(self.renderer.render(
self.who, data, 0, avatar=b"changed",
).cache_hit)
def test_lru_budget_and_shared_image_safety(self):
cache = ByteLRU(10)
cache.put("a", b"123456", 6)
cache.put("b", b"abcdef", 6)
self.assertIsNone(cache.get("a"))
self.assertLessEqual(cache.used_bytes, 10)
cache.put("huge", bytes(20), 20)
self.assertIsNone(cache.get("huge"))
image = self.renderer.assets.image("soil/草土地.png", (80, 50))
before = image.tobytes()
image.paste("red", (0, 0, 80, 50))
fresh = self.renderer.assets.image("soil/草土地.png", (80, 50))
self.assertEqual(before, fresh.tobytes())
self.assertLessEqual(
self.renderer.assets.cache.used_bytes,
self.renderer.assets.cache.limit,
)
def test_auto_codec_fallback_and_invalid_options(self):
with patch("farm_render.models.features.check", return_value=False):
self.assertEqual(RenderOptions().codec(("WEBP", "JPEG")), "JPEG")
with self.assertRaises(ValueError):
RenderOptions(format="WEBP").codec(("WEBP",))
for kwargs in ({"width":True}, {"width":99999}, {"quality":101},
{"method":7}, {"theme":"../oops"}):
with self.assertRaises(ValueError):
RenderOptions(**kwargs)
def test_first_stage_exact_boundary_and_target_canvas(self):
plot = Plot(1, self.plant, self.plant.growth_seconds)
metadata = self.renderer.assets.metadata["plants"][self.plant.name]
threshold = int(metadata["phase"].split(",")[0])
data = snapshot([plot])
self.assertEqual(self.renderer.scene(
self.who, data, threshold - 1,
).plots[0].stage, 0)
self.assertEqual(self.renderer.scene(
self.who, data, threshold,
).plots[0].stage, 1)
widths = []
original = Canvas.__init__
def capture(canvas, image):
widths.append(image.width)
original(canvas, image)
with patch.object(Canvas, "__init__", capture):
self.renderer.render(self.who, data, 0)
self.assertTrue(widths)
self.assertEqual(set(widths), {1024})
def test_missing_sprite_and_private_logs(self):
metadata = self.renderer.assets.metadata
plant = next(p for p in Catalogue.bundled() if (
f'plant/{p.name}/1.png' not in metadata["images"]
))
data = snapshot([Plot(1, plant, 0)], name="PRIVATE FARM")
with self.assertLogs("farm_render.renderer", "INFO") as recorded:
result = self.renderer.render(self.who, data, 0)
self.assertTrue(result.content)
logs = " ".join(recorded.output)
self.assertIn("missing packaged crop sprite", logs)
self.assertNotIn("PRIVATE FARM", logs)
self.assertNotIn(self.who.key, logs)
def test_oversized_avatar_is_not_decoded(self):
content = BytesIO()
Image.new("RGB", (3000, 3000), "white").save(content, "PNG")
result = self.renderer.render(
self.who, snapshot(), 0, avatar=content.getvalue(),
)
self.assertTrue(result.content)
with self.assertRaises(ValueError):
FarmRenderer(Path("missing-font.ttf"))
class AsyncRenderTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.assertIsNotNone(FarmRenderer, "异步渲染模块尚未实现")
self.renderer = FarmRenderer(FONT)
self.who = Identity("test", "local", "qq", "async")
async def test_snapshot_once_and_loop_not_blocked(self):
class Source:
calls = 0
async def snapshot(inner, who):
inner.calls += 1
return snapshot()
source = Source()
service = RenderService(self.renderer)
original = self.renderer.render
reached = threading.Event()
def slow(*args, **kwargs):
reached.set()
time.sleep(0.1)
return original(*args, **kwargs)
self.renderer.render = slow
task = asyncio.create_task(service.my_farm(source, self.who, now=0))
await asyncio.to_thread(reached.wait, 2)
self.assertFalse(task.done())
self.assertTrue((await task).content)
self.assertEqual(source.calls, 1)
await service.close()
async def test_singleflight_cancel_capacity_and_close(self):
service = RenderService(self.renderer, concurrency=1, max_pending=1)
entered = threading.Event()
release = threading.Event()
original = self.renderer.render
calls = []
def slow(*args, **kwargs):
calls.append(1)
entered.set()
release.wait(3)
return original(*args, **kwargs)
self.renderer.render = slow
data = snapshot()
first = asyncio.create_task(service.render(self.who, data, 0))
await asyncio.to_thread(entered.wait, 2)
same = asyncio.create_task(service.render(self.who, data, 0))
await asyncio.sleep(0)
first.cancel()
with self.assertRaises(asyncio.CancelledError):
await first
with self.assertRaises(RuntimeError):
await service.render(replace(self.who, user_id="two"), data, 0)
closing = asyncio.create_task(service.close())
await asyncio.sleep(0.02)
self.assertFalse(closing.done())
release.set()
self.assertTrue((await same).content)
await closing
self.assertEqual(len(calls), 1)
with self.assertRaises(RuntimeError):
await service.render(self.who, data, 0)
async def test_failure_frees_capacity_for_retry(self):
service = RenderService(self.renderer)
original = self.renderer.render
with patch.object(self.renderer, "render", side_effect=ValueError):
with self.assertRaises(ValueError):
await service.render(self.who, snapshot(), 0)
self.renderer.render = original
self.assertTrue((await service.render(
self.who, snapshot(), 0,
)).content)
await service.close()
async def test_limit_bounds_distinct_parallel_jobs(self):
service = RenderService(self.renderer, concurrency=2, max_pending=4)
lock = threading.Lock()
active = 0
maximum = 0
original = self.renderer.render
def observed(*args, **kwargs):
nonlocal active, maximum
with lock:
active += 1
maximum = max(maximum, active)
try:
time.sleep(0.03)
return original(*args, **kwargs)
finally:
with lock:
active -= 1
self.renderer.render = observed
await asyncio.gather(*(
service.render(replace(self.who, user_id=str(i)), snapshot(), 0)
for i in range(4)
))
self.assertEqual(maximum, 2)
self.assertEqual(active, 0)
await service.close()
+400
View File
@@ -0,0 +1,400 @@
import asyncio
from contextlib import closing
from dataclasses import replace
from pathlib import Path
import sqlite3
import tempfile
import threading
import unittest
from unittest.mock import patch
from farm_core import Asset, Catalogue, FarmError, FarmService, Identity
from farm_core import SQLiteStore
from farm_core.domain import MAX_QUANTITY, Plant, SchemaError, StorageError
from farm_core.presentation import receipt_text, snapshot_text
from farm_core.repository import SQLiteRepository
def catalogue():
return Catalogue([
Plant(1, "萝卜", 0, 10, False, 0, 20, 2, 10, 2, 1, 1),
Plant(2, "番茄", 0, 5, False, 0, 30, 3, 7, 3, 2, 2),
Plant(3, "点券菜", 0, 999, True, 4, 8, 2, 3, 2, 1, 1),
Plant(4, "高级菜", 2, 10, False, 0, 20, 2, 10, 2, 1, 1),
])
class TransactionTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="farm-core-test-")
self.path = Path(self.temporary.name) / "farm.sqlite3"
self.store = SQLiteStore(self.path)
await self.store.initialize()
self.now = 1000
self.service = FarmService(
self.store, catalogue(), lambda: self.now,
)
self.identity = Identity("deployment", "realm", "qq", "user")
await self.service.register(self.identity, "测试农场", "register")
async def asyncTearDown(self):
await self.store.close()
temporary_path = Path(self.temporary.name).resolve()
self.assertTrue(temporary_path.is_relative_to(
Path(tempfile.gettempdir()).resolve()
))
self.temporary.cleanup()
async def seed(self, action, callback):
return await self.store.transact(
self.identity, action, "test-fixture", {}, self.now, callback,
)
async def mature_radish(self, count=1):
await self.service.buy(self.identity, "萝卜", count, "buy")
await self.service.sow(self.identity, "萝卜", count, "sow")
self.now += 2
return await self.service.harvest(self.identity, "harvest")
async def test_registration_is_immediately_visible_and_not_repeated(self):
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.account.point, 500)
self.assertEqual(snapshot.account.vip_point, 0)
self.assertEqual(snapshot.account.experience, 0)
self.assertEqual(len(snapshot.plots), 3)
self.assertTrue(all(p.plant is None for p in snapshot.plots))
repeated = await self.service.register(
self.identity, "另一个名称", "register-again",
)
self.assertFalse(repeated.data["created"])
self.assertEqual(repeated.data["account"]["point"], 500)
self.assertEqual(len(await self.store.ledger(self.identity)), 1)
async def test_scopes_are_independent(self):
other = replace(self.identity, realm="different")
with self.assertRaisesRegex(FarmError, "not_registered"):
await self.service.snapshot(other)
await self.service.register(other, "另一农场", "register")
await self.service.buy(self.identity, "萝卜", 1, "buy")
self.assertEqual(
(await self.service.snapshot(other)).account.point, 500,
)
async def test_full_purchase_to_sale_round_trip(self):
harvest = await self.mature_radish()
self.assertEqual(harvest.data["experience"], 20)
sale = await self.service.sell(self.identity, None, None, "sell")
self.assertEqual(sale.data["proceeds"], 20)
self.assertEqual(sale.data["account"]["point"], 510)
self.assertEqual(sale.data["account"]["experience"], 20)
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.inventory[0].seeds, 0)
self.assertEqual(snapshot.inventory[0].crops, 0)
self.assertTrue(snapshot.plots[0].withered)
self.assertIn("本次收入:20", receipt_text(sale))
self.assertIn("等级:", snapshot_text(snapshot))
await self.service.eradicate(self.identity, "eradicate")
self.assertTrue(all(
p.plant is None
for p in (await self.service.snapshot(self.identity)).plots
))
async def test_existing_balance_and_experience_are_not_added_twice(self):
await self.seed("existing-exp", lambda repo: (
repo.asset(Asset.EXPERIENCE, 100) or {}
))
await self.mature_radish()
self.assertEqual(
(await self.service.snapshot(self.identity)).account.experience,
120,
)
await self.seed("balance-100", lambda repo: (
repo.asset(Asset.POINT, -390) or {}
))
sale = await self.service.sell(self.identity, "萝卜", None, "sell")
self.assertEqual(sale.data["account"]["point"], 120)
async def test_invalid_inputs_do_not_change_assets(self):
before = await self.service.snapshot(self.identity)
for count in (0, -1, True, False, 1.5, MAX_QUANTITY + 1, 10**30):
with self.subTest(count=count):
with self.assertRaises(FarmError):
await self.service.buy(
self.identity, "萝卜", count, "invalid",
)
with self.assertRaises(FarmError):
await self.service.sow(
self.identity, "萝卜", count, "invalid",
)
with self.assertRaises(FarmError):
await self.service.sell(
self.identity, "萝卜", count, "invalid",
)
self.assertEqual(await self.service.snapshot(self.identity), before)
async def test_unaffordable_unknown_and_high_level_purchase_fail(self):
for name, count, code in (
("萝卜", 51, "insufficient_funds"),
("高级菜", 1, "level_required"),
("不存在", 1, "unknown_plant"),
):
with self.subTest(name=name):
with self.assertRaisesRegex(FarmError, code):
await self.service.buy(
self.identity, name, count, "denied",
)
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.account.point, 500)
self.assertEqual(snapshot.inventory, ())
async def test_vip_purchase_only_uses_vip_currency(self):
with self.assertRaisesRegex(FarmError, "insufficient_funds"):
await self.service.buy(self.identity, "点券菜", 1, "vip")
await self.seed("vip-fixture", lambda repo: (
repo.asset(Asset.VIP_POINT, 10) or {}
))
result = await self.service.buy(self.identity, "点券菜", 1, "vip")
self.assertEqual(result.data["account"]["point"], 500)
self.assertEqual(result.data["account"]["vip_point"], 6)
async def test_sow_all_and_explicit_overflow(self):
await self.service.buy(self.identity, "萝卜", 5, "buy")
before = await self.service.snapshot(self.identity)
with self.assertRaisesRegex(FarmError, "insufficient_empty_plots"):
await self.service.sow(self.identity, "萝卜", 4, "too-many")
self.assertEqual(await self.service.snapshot(self.identity), before)
result = await self.service.sow(self.identity, "萝卜", None, "all")
self.assertEqual(result.data["quantity"], 3)
self.assertEqual(result.data["seeds"], 2)
async def test_maturity_boundary_and_multi_crop_lifecycle(self):
await self.service.buy(self.identity, "番茄", 1, "buy")
await self.service.sow(self.identity, "番茄", 1, "sow")
self.now += 2
with self.assertRaisesRegex(FarmError, "nothing_to_harvest"):
await self.service.harvest(self.identity, "early")
with self.assertRaisesRegex(FarmError, "nothing_to_eradicate"):
await self.service.eradicate(self.identity, "early-clear")
self.now += 1
await self.service.harvest(self.identity, "first")
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.plots[0].mature_at, self.now + 2)
self.assertFalse(snapshot.plots[0].withered)
self.now += 2
await self.service.harvest(self.identity, "second")
snapshot = await self.service.snapshot(self.identity)
self.assertTrue(snapshot.plots[0].withered)
self.assertEqual(snapshot.inventory[0].crops, 6)
self.assertEqual(snapshot.account.experience, 60)
with self.assertRaisesRegex(FarmError, "nothing_to_harvest"):
await self.service.harvest(self.identity, "third")
async def test_planted_rules_are_frozen(self):
await self.service.buy(self.identity, "萝卜", 1, "buy")
await self.service.sow(self.identity, "萝卜", 1, "sow")
changed = replace(
self.service.catalogue.get("萝卜"), harvest=999, experience=999,
growth_seconds=100, crop=5,
)
self.service.catalogue = Catalogue([changed])
self.now += 2
result = await self.service.harvest(self.identity, "harvest")
self.assertEqual(result.data["quantity"], 2)
self.assertEqual(result.data["experience"], 20)
self.assertTrue(
(await self.service.snapshot(self.identity)).plots[0].withered
)
async def test_per_plot_bonuses_are_independent_of_iteration_order(self):
await self.service.buy(self.identity, "萝卜", 2, "buy")
await self.service.sow(self.identity, "萝卜", 2, "sow")
def bonuses(repo):
plots = repo.plots()
repo.save_plot(replace(plots[0], experience_bonus=100))
repo.save_plot(replace(plots[1], experience_bonus=50))
return {}
await self.seed("bonuses", bonuses)
self.now += 2
original = SQLiteRepository.plots
with patch.object(
SQLiteRepository, "plots",
lambda repo: tuple(reversed(original(repo))),
):
result = await self.service.harvest(self.identity, "harvest")
self.assertEqual(result.data["experience"], 70)
async def test_locks_protect_both_sale_paths_and_survive_restart(self):
await self.mature_radish()
await self.service.lock(self.identity, "萝卜", True, "lock")
for name, code in (
("萝卜", "plant_locked"), (None, "nothing_to_sell"),
):
with self.assertRaisesRegex(FarmError, code):
await self.service.sell(self.identity, name, None, "locked")
await self.store.close()
await self.store.initialize()
self.assertTrue(
(await self.service.snapshot(self.identity)).inventory[0].locked
)
await self.service.lock(self.identity, "萝卜", False, "unlock")
result = await self.service.sell(self.identity, None, None, "sale")
self.assertEqual(result.data["proceeds"], 20)
async def test_failed_purchase_rolls_back_money_stock_and_ledger(self):
before = await self.service.snapshot(self.identity)
ledger = await self.store.ledger(self.identity)
with patch.object(
SQLiteRepository, "stock",
side_effect=sqlite3.OperationalError("injected"),
):
with self.assertRaises(StorageError):
await self.service.buy(self.identity, "萝卜", 1, "buy")
self.assertEqual(await self.service.snapshot(self.identity), before)
self.assertEqual(await self.store.ledger(self.identity), ledger)
await self.service.buy(self.identity, "萝卜", 1, "buy")
self.assertEqual(
(await self.service.snapshot(self.identity)).account.point, 490,
)
async def test_failed_harvest_rolls_back_crop_and_plot(self):
await self.service.buy(self.identity, "萝卜", 1, "buy")
await self.service.sow(self.identity, "萝卜", 1, "sow")
self.now += 2
before = await self.service.snapshot(self.identity)
original = SQLiteRepository.asset
def fail_exp(repo, asset, delta):
if asset == Asset.EXPERIENCE:
raise sqlite3.OperationalError("injected")
original(repo, asset, delta)
with patch.object(SQLiteRepository, "asset", fail_exp):
with self.assertRaises(StorageError):
await self.service.harvest(self.identity, "harvest")
self.assertEqual(await self.service.snapshot(self.identity), before)
result = await self.service.harvest(self.identity, "harvest")
self.assertEqual(result.data["quantity"], 2)
async def test_failed_sale_rolls_back_inventory(self):
await self.mature_radish()
before = await self.service.snapshot(self.identity)
with patch.object(
SQLiteRepository, "asset",
side_effect=sqlite3.OperationalError("injected"),
):
with self.assertRaises(StorageError):
await self.service.sell(self.identity, None, None, "sale")
self.assertEqual(await self.service.snapshot(self.identity), before)
async def test_replay_and_parameter_conflict(self):
first = await self.service.buy(self.identity, "萝卜", 1, "buy")
self.now += 100
second = await self.service.buy(self.identity, "萝卜", 1, "buy")
self.assertEqual(first, second)
with self.assertRaisesRegex(FarmError, "operation_conflict"):
await self.service.buy(self.identity, "萝卜", 2, "buy")
with self.assertRaisesRegex(FarmError, "operation_conflict"):
await self.service.sow(self.identity, "萝卜", 1, "buy")
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.account.point, 490)
self.assertEqual(snapshot.inventory[0].seeds, 1)
async def test_same_operation_concurrently_commits_once(self):
results = await asyncio.gather(*(
self.service.buy(self.identity, "萝卜", 1, "same")
for _ in range(20)
))
self.assertTrue(all(result == results[0] for result in results))
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.account.point, 490)
self.assertEqual(snapshot.inventory[0].seeds, 1)
self.assertEqual(len(await self.store.ledger(self.identity)), 2)
async def test_concurrent_buyers_cannot_overspend(self):
results = await asyncio.gather(*(
self.service.buy(self.identity, "萝卜", 1, f"buy-{index}")
for index in range(60)
), return_exceptions=True)
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 50)
failures = [r for r in results if isinstance(r, Exception)]
self.assertTrue(all(
isinstance(r, FarmError) and r.code == "insufficient_funds"
for r in failures
))
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.account.point, 0)
self.assertEqual(snapshot.inventory[0].seeds, 50)
async def test_concurrent_harvest_cannot_claim_same_season_twice(self):
await self.service.buy(self.identity, "萝卜", 1, "buy")
await self.service.sow(self.identity, "萝卜", 1, "sow")
self.now += 2
results = await asyncio.gather(*(
self.service.harvest(self.identity, f"harvest-{index}")
for index in range(10)
), return_exceptions=True)
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 1)
snapshot = await self.service.snapshot(self.identity)
self.assertEqual(snapshot.inventory[0].crops, 2)
self.assertEqual(snapshot.account.experience, 20)
async def test_restart_and_cancelled_request_can_replay_result(self):
entered = threading.Event()
release = threading.Event()
original = SQLiteRepository.stock
def blocked(repo, *args, **kwargs):
entered.set()
if not release.wait(5):
raise RuntimeError("Test worker was not released")
original(repo, *args, **kwargs)
with patch.object(SQLiteRepository, "stock", blocked):
task = asyncio.create_task(
self.service.buy(self.identity, "萝卜", 1, "cancelled-buy")
)
try:
self.assertTrue(await asyncio.to_thread(entered.wait, 3))
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
finally:
release.set()
await self.store.close()
with self.assertRaises(StorageError):
await self.service.snapshot(self.identity)
await self.store.initialize()
result = await self.service.buy(
self.identity, "萝卜", 1, "cancelled-buy",
)
self.assertEqual(result.data["account"]["point"], 490)
self.assertEqual(
(await self.service.snapshot(self.identity)).inventory[0].seeds, 1,
)
async def test_schema_version_is_not_silently_adopted(self):
await self.store.close()
with closing(sqlite3.connect(self.path)) as connection:
connection.execute("PRAGMA user_version=99")
with self.assertRaises(SchemaError):
await self.store.initialize()
async def test_foreign_database_is_rejected_without_conversion(self):
path = Path(self.temporary.name) / "old.sqlite3"
with closing(sqlite3.connect(path)) as connection:
connection.execute("CREATE TABLE user(uid TEXT)")
other = SQLiteStore(path)
with self.assertRaises(SchemaError):
await other.initialize()
with closing(sqlite3.connect(path)) as connection:
self.assertEqual(
connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall(), [("user",)],
)
if __name__ == "__main__":
unittest.main()