mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 05:00:01 +08:00
add # 新增项目整体大纲
This commit is contained in:
@@ -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
|
||||
@@ -109,5 +109,9 @@ Desktop.ini
|
||||
/.agents/
|
||||
/.codex/
|
||||
/.codebase-memory/
|
||||
|
||||
# >>> Comet managed project state >>>
|
||||
!/.comet/
|
||||
/.comet/*
|
||||
!/.comet/config.yaml
|
||||
# <<< Comet managed project state <<<
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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,7 +2,7 @@
|
||||
|
||||
## 原则与当前状态
|
||||
|
||||
下列为建议设计,尚未实现。当前旧代码位于 legacy,未把其全局变量、
|
||||
下列为远期目标设计;第一批核心已按职责实现为独立模块,见09-foundation.md。当前旧代码位于 legacy,未把其全局变量、
|
||||
数据库管理器和服务定位器直接当作新核心继续复用。
|
||||
|
||||
采用一个共享 Python 包,内部按职责划分,避免先建多个互相依赖的
|
||||
@@ -33,7 +33,7 @@ farm/
|
||||
docs/
|
||||
```
|
||||
|
||||
本轮只创建职责说明及迁移基线,不生成大量空模块或假实现。
|
||||
初始化时只创建职责说明;当前已实现第一批核心,不生成大量空模块。
|
||||
当一个用例实施时,才创建所需模块。
|
||||
|
||||
## 依赖方向
|
||||
|
||||
@@ -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 构建资料 |
|
||||
|
||||
@@ -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 不自动证明所有素材可分发 |
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 本轮验证与交付边界
|
||||
# 初始化阶段验证与交付边界
|
||||
|
||||
此页保留初始化阶段的历史记录;随后完成的基础核心与正式验证见 [09-foundation.md](09-foundation.md)。
|
||||
|
||||
日期:2026-10-15。执行 Python:3.12.14;未安装或启动三种宿主。
|
||||
新工具还以 Python 3.10 语法规则解析旧代码;这不代表三框架共同支持
|
||||
|
||||
@@ -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桥接不能机器认证独立执行身份,当前等待用户接受结果,
|
||||
尚未归档、提交或推送。此状态不等于三框架插件已发布。
|
||||
@@ -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
@@ -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,已直接源文件补齐,无需重复完整套件。
|
||||
@@ -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接受报告并等待用户接受;未执行归档、提交或发布。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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已接受报告。
|
||||
当前等待用户接受结果,不自动归档。
|
||||
@@ -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通过。
|
||||
- [ ] 用户接受结果后归档。
|
||||
|
||||
@@ -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,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": [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -1,6 +1,6 @@
|
||||
# 共享核心预留位置
|
||||
# 共享基础核心
|
||||
|
||||
当前只有职责说明,未实现新核心。完整目标见 docs/02-architecture.md。
|
||||
实施时创建 domain、application、ports、infrastructure、presentation。
|
||||
禁止导入 nonebot、astrbot、zhenxun;内部模块采用相对导入。
|
||||
先实现注册、库存、资产和一个完整交易,不直接复制旧全局管理器。
|
||||
本轮已实现框架无关的作物目录、注册、交易及种植闭环。
|
||||
模块责任与限制见 docs/09-foundation.md,运行方式见根 README。
|
||||
运行无第三方依赖,导入不创建数据库、文件或后台线程。
|
||||
本包不是机器人插件入口;integrations 下接入尚未实现。
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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] # 实际成交数值与提交后的资产。
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""可选共享图片层;核心模块无需导入本包。"""
|
||||
|
||||
from .models import RenderOptions, RenderResult
|
||||
from .renderer import FarmRenderer
|
||||
from .service import RenderService
|
||||
|
||||
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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 # 成图缓存命中。
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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运行矩阵尚待后续实现。
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user