fixed # 修复作物资源位置不对的历史遗留BUG
add # 新增作物会生虫的逻辑
@@ -74,11 +74,11 @@ now, options),统一 now 使用UTC秒整数。正常未注册/存储故障仍
|
||||
|
||||
## 阶段与缓存
|
||||
|
||||
布局使用旧30块地坐标。首季由保存成熟时间减首熟时长推导起点;
|
||||
再生长由下一次成熟时间减再生长时长推导。阶段阈值映射到本季
|
||||
时长,解决旧代码多季仍引用最初播种时间的问题;仅改变展示,
|
||||
不修改成熟和收益规则。枯萎优先于成熟。原偏移字段为offsetX/Y/W/H,
|
||||
新实现使用真实字段,不沿用旧绘图的offic拼写。
|
||||
布局使用旧30块地坐标。新播种及新一季使用虫害工作量计算阶段,
|
||||
未启用虫害的迁移前当前季仍从成熟时间和基础时长推导。
|
||||
枯萎优先于成熟。作物采用完整画布底边中点统一公式,
|
||||
不再使用人工阶段锚点和旧 offset 字段,详见 `15-crop-position-formula.md`。
|
||||
虫子使用场景包中的固定坐标与稳定选帧,规则见 `16-pest-mechanics.md`。
|
||||
|
||||
素材LRU默认32MiB,成图LRU默认8MiB;零预算禁用对应缓存,超限
|
||||
条目不入缓存。素材返回副本,缓存底图不被玩家绘制污染。预算按
|
||||
@@ -98,8 +98,9 @@ RenderService默认一个绘图任务,最多8个活跃/排队的不同请求
|
||||
|
||||
## 素材完整性与来源
|
||||
|
||||
scripts/export_render_assets.py将673张已有素材原样打包,附202种作物
|
||||
展示配置和旧布局。--check比对确定性输出,不修改旧基线。
|
||||
scripts/export_render_assets.py将已有素材原样打包,附202种作物
|
||||
展示配置、旧布局和统一定位规则;额外打包新提取的 40 张网页素材。
|
||||
--check比对确定性输出与原图哈希,不修改旧基线。
|
||||
素材包约束为安装资源,不按用户输入路径访问任意文件、不解压到磁盘。
|
||||
保留GPL及原始归属;历史素材分发权仍需在公开发布前核实。
|
||||
|
||||
@@ -107,6 +108,22 @@ scripts/export_render_assets.py将673张已有素材原样打包,附202种作
|
||||
缺失时显示问号占位并记录静态警告,避免整个命令失败;尚未补齐
|
||||
缺失图片,也不推测相近作物素材替代。不会声称202种作物均有完整图片。
|
||||
|
||||
## 原版用户面板恢复(2026-10-16)
|
||||
|
||||
使用旧版 `background/userInfo.png` 与 `background/frame/frame.png`,
|
||||
按原有顺序叠放底板、140×150头像、头像框和昵称/经验/等级/货币。
|
||||
不再用通用圆角信息卡,不额外显示地块数量。无效头像继续安全降级。
|
||||
用户确认按原底图校正旧坐标:经验条位于原素材经验槽内,等级与两种
|
||||
货币不越出底板。普通、高清图统一按3072基准坐标缩放。
|
||||
|
||||
保留旧版经验计算口径:本级已获经验为累计经验减本级累计门槛,
|
||||
分母仍为下一级累计经验门槛;等级门槛没有变化。点券取真实账户余额,
|
||||
不继承旧版硬编码0。素材按目标尺寸缓存,查询仍只读一次一致快照;
|
||||
头像字节限制、缓存失效、后台限流和格式选项保留。
|
||||
|
||||
本次离线测量见 `render-panel-restored-benchmark.json`,
|
||||
样图在 `runtime/panel-restored`;不代表真实平台发送耗时。
|
||||
|
||||
## 测量与验证
|
||||
|
||||
```text
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
本文件随生成包分发;业务源码只在主仓库main_plugin_farm维护。
|
||||
生成目标为Shu-Ying/zhenxun_plugin_farm的farm-preview分支,保留main。
|
||||
|
||||
preview.3恢复旧版信息底板和头像框、昵称、经验进度、等级及两种货币。
|
||||
按用户确认校正原素材上的字段错位,保留旧版经验计算口径;点券显示
|
||||
真实余额。仍使用目标尺寸绘制、一次快照查询及有界缓存。
|
||||
|
||||
## 安装前
|
||||
|
||||
此版沿用“开通农场”“我的农场”等原指令,没有测试前缀。请先停止
|
||||
@@ -31,7 +35,17 @@ Pillow>=10.4,<11,不静默升级宿主到12。NoneBot、真寻和Uninfo由
|
||||
|
||||
## 配置与指令
|
||||
|
||||
在真寻使用的.env配置中文字体路径,例如Windows:
|
||||
默认优先使用真寻FONT_PATH目录下已有的中文字体,依次尝试
|
||||
HYWenHei-85W.ttf、msyh.ttf、syht.otf、syst.otf、
|
||||
AlibabaHealthFont2.0CN-85B.ttf;文件损坏会继续尝试下一个。
|
||||
宿主目录均不可用时,再尝试Windows微软雅黑/宋体或Linux常用
|
||||
Noto Sans CJK/文泉驿路径。不会下载或打包额外字体。
|
||||
|
||||
上述默认路径仅属于真寻适配层,不作为其他机器人宿主的默认配置。
|
||||
NoneBot/AstrBot接入时分别解析各自字体资源;共享farm_render只接收
|
||||
最终文件路径,不依赖真寻FONT_PATH或固定resources/font目录。
|
||||
|
||||
通常不必额外配置;需要覆盖默认字体时,在真寻使用的.env指定路径:
|
||||
|
||||
```dotenv
|
||||
FARM_PREVIEW_FONT=C:/Windows/Fonts/msyh.ttc
|
||||
@@ -39,7 +53,8 @@ FARM_PREVIEW_DEPLOYMENT=zhenxun-preview
|
||||
FARM_PREVIEW_IMAGE_FORMAT=AUTO
|
||||
```
|
||||
|
||||
Linux请提供合法可用的中文字体。没有字体时文本交易可用,图片命令
|
||||
Linux请提供合法可用的中文字体。显式配置优先,配置错误时不会静默
|
||||
替换成其他字体。所有默认候选都不可用时,文本交易可用,图片命令
|
||||
会提示配置;不捆绑Windows字体。可选FARM_PREVIEW_DATA_DIR显式设置
|
||||
独立预览数据目录。AUTO默认JPEG以保守兼容真实平台,确认WebP支持后
|
||||
可设WEBP,也支持JPEG/PNG。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 作物字段与文字指令
|
||||
|
||||
本次只补充现有玩法所需的规则与身份字段,不导入网页全量目录,
|
||||
不保存网页作物 ID,也不更改旧版参考数据库。
|
||||
|
||||
## 作物规则
|
||||
|
||||
`Plant` 新增字段:
|
||||
|
||||
| 字段 | 默认值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `purchasable` | `True` | 控制商店展示与购买,旧目录从 `isBuy` 读取 |
|
||||
| `enabled` | `True` | 关闭时禁止新购买与播种,不影响已种作物收获和出售 |
|
||||
| `crop_type` | `1` | 当前只允许类型 1 购买和播种 |
|
||||
|
||||
旧目录可选字段 `enabled` 使用整数 0/1,`crop_type` 使用正整数。
|
||||
未提供时按上表默认值读取,不将网页 `show` 或 `isLock` 当作购买权限。
|
||||
`isVip`、金币和点券价格、成熟时间、再生长时间继续沿用原规则。
|
||||
|
||||
## 本地 ID
|
||||
|
||||
库存使用本地 `plant_id` 作为业务标识。名称仅用于展示与指令查找,
|
||||
目录内允许同名作物,但每个本地 ID 必须唯一且不能改给另一种作物。
|
||||
目录改名不改变库存归属;已经种下的作物继续按播种快照结算。
|
||||
|
||||
文字指令可使用名称或 `#本地编号`,例如 `购买种子 #1 2`。
|
||||
同名时拒绝按名称执行,要求使用编号;商店与仓库显示本地编号。
|
||||
`#编号` 建议保留为指令语法,不作为作物名称。
|
||||
|
||||
## 数据库升级
|
||||
|
||||
新数据库版本为 2,`inventory` 的主键从 `(identity, name)` 改为
|
||||
`(identity, plant_id)`;保留名称、种子数、产物数和锁定状态。
|
||||
|
||||
版本 1 在 `SQLiteStore.initialize()` 时自动迁移,整个过程在同一事务内。
|
||||
迁移通过本地目录与已种作物快照解析旧名称;不存在唯一对应 ID 时中止,
|
||||
保留版本 1 的全部数据,不猜测 ID、不丢弃库存。
|
||||
自定义目录应传入 `initialize(catalogue=自定义目录)`,真寻接入已传入目录。
|
||||
账户、流水、历史操作回执和地块成熟时间不改写。
|
||||
迁移提交后记录版本和行数日志,不记录玩家身份或库存明细。
|
||||
|
||||
仅在临时测试库验证迁移,不代表已升级部署中的玩家库。
|
||||
本次不包含生长阶段、浇水虫害或网页数据导入器。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 虫子坐标与资源
|
||||
|
||||
状态:2026-10-19 用户已授权接入。已从下载的主场景包提取虫子
|
||||
38 帧及公共种子、枯萎图,共 40 张图片,保存于
|
||||
`src/farm_render/source_assets`。manifest 保留源 URL、文件哈希、
|
||||
原图尺寸及统一位置,不保存网页作物 ID。正式静态渲染按虫序稳定选帧,
|
||||
资源导出脚本将全部帧打包;核心虫害规则见 `16-pest-mechanics.md`。
|
||||
|
||||
## 新确认的普通 WebGL 绘制规则
|
||||
|
||||
`WebGLMainSceneLandController.lua` 第 279~282 行定义:
|
||||
|
||||
```text
|
||||
PestPos = [(-26, 13), (-5, 42), (24, 24)]
|
||||
```
|
||||
|
||||
第 320~330 行按照 `landData.pest` 数量创建 `mainscene#Pest`,
|
||||
以 `PestPos[i]` 设置虫子对象的 `localPosition`。
|
||||
父节点是 `LandsPestAndWeedContent/LandN/PestAndWeedContent`;
|
||||
对应土地父容器与土地矩形中心对齐。Unity 坐标 x 向右、y 向上。
|
||||
普通控制器在 step < 6 时显示虫草;上述坐标与作物种类无关。
|
||||
|
||||
主场景包存在 `land/webgl/pest.prefab`,其根节点为 Pest,
|
||||
矩形尺寸 22 × 30,pivot=(0.5,0.5),localScale=(1,1,1),
|
||||
带 Image 与 Animator;初始 Image 引用精灵 pest1。
|
||||
预制体初始位置会被运行时的 PestPos 覆盖,不能当作最终位置使用。
|
||||
|
||||
若使用原版单位并设统一场景倍率为 s,地块中心是 (Cx,Cy),
|
||||
虫子对应坐标为 (u,v),则输出画布中的虫子矩形左上角是:
|
||||
|
||||
```text
|
||||
left = Cx + u × s - 22 × s / 2
|
||||
top = Cy - v × s - 30 × s / 2
|
||||
```
|
||||
|
||||
这是预制体矩形定位;38 帧完整画布均为 44×60,显示尺寸为 22×30。
|
||||
动画包的采样率为 24,开启循环;本次聊天图片输出不播放动画。
|
||||
虫子不应套用作物的 0.75 缩放,需遵循其自身预制体尺寸。
|
||||
|
||||
已搜索 Lua 包内 2178 个 TextAsset:未发现 `.insect` 或
|
||||
`["insect"]` 的直接字段读取;普通及时间农场控制器存在 PestPos。
|
||||
这不能证明 C# 或其他未分析路径从未使用 insect 字段,
|
||||
但已确认普通 WebGL 控制器本身采用上述固定位置。
|
||||
|
||||
## 已保存的证据
|
||||
|
||||
原始数据保留在 `captures/qq-farm-2026-10-19/crops.json` 的 `insect` 字段。
|
||||
这是离线捕获文件,不进入业务数据库,不引入网页作物 ID。
|
||||
|
||||
草莓原始值:
|
||||
|
||||
```text
|
||||
0,0,0,0,0,0|50,10,65,-30,110,-2|50,10,65,-30,110,-2|0,0,0,0,0,0
|
||||
```
|
||||
|
||||
字符串包含 4 组数据;每组 6 个数字,可按 3 对坐标进行语法解析。
|
||||
组与阶段的关系、全零的含义、坐标原点和缩放方式均未核验。
|
||||
不能直接将这 4 组映射为本地 6 个生长阶段,也不能当作作物位置。
|
||||
|
||||
## 剩余验证事项
|
||||
|
||||
1. 如需动画消息,另做动画编码、平台能力和上传验证。
|
||||
2. 网页虫子实际画面与本地绘制对照仍未进行在线像素级验收。
|
||||
3. 如需使用旧 insect 字段,另查其消费者,不能覆盖已确认的普通 WebGL 规则。
|
||||
|
||||
绘制层读取渲染配置,不污染交易状态,不引入网页作物 ID。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 作物阶段位置校准
|
||||
|
||||
> 历史试验:本页人工锚点方案已被
|
||||
> `15-crop-position-formula.md` 的统一定位规则取代。
|
||||
> 后续绘制使用统一公式;以下保留为试验记录,不再扩展。
|
||||
|
||||
## 本轮范围
|
||||
|
||||
只校准现有本地草莓素材的种子、发芽、小叶、大叶、开花、成熟阶段。
|
||||
其他作物和通用枯萎图保持原定位;没有自动套用草莓的数据。
|
||||
不改旧版参考素材,不裁透明留白,不保存网页作物 ID。
|
||||
|
||||
## 参考点与证据边界
|
||||
|
||||
采用本地美术基准:茎与地面相接处;种子使用种子簇的落地点。
|
||||
这是一组人工观察后的本地标注,并非 QQ 网页原始 pivot 的复原结果。
|
||||
低分辨率边缘存在约 1~2 个原始像素的人工标注误差,
|
||||
后续可以在同一配置中微调,不能宣称已核验所有作物。
|
||||
|
||||
坐标原点是完整参考画布的左上角,x 向右,y 向下。
|
||||
`reference_size` 是标注时的画布宽高,`anchor` 是其内部落地坐标。
|
||||
数字均为本地参考画布像素,不是屏幕坐标。
|
||||
|
||||
| 本地阶段 | 参考画布 | 落地锚点 |
|
||||
| --- | --- | --- |
|
||||
| 0 种子 | 35 × 58 | 17, 20 |
|
||||
| 1 发芽 | 32 × 81 | 19, 27 |
|
||||
| 2 小叶 | 61 × 99 | 33, 44 |
|
||||
| 3 大叶 | 102 × 121 | 58, 72 |
|
||||
| 4 开花 | 107 × 129 | 62, 75 |
|
||||
| 5 成熟 | 112 × 139 | 64, 78 |
|
||||
|
||||
种子图原始文件较大,参考画布采用当前运行时的 35 × 58 显示尺寸。
|
||||
其他阶段采用原始 PNG 尺寸;全部保留透明区域。
|
||||
|
||||
## 拼接公式
|
||||
|
||||
```text
|
||||
地面目标点 = 地块中心 + 原有作物级 offsetX/offsetY
|
||||
目标锚点 = anchor × 实际绘制图片尺寸 / reference_size
|
||||
图片左上角 = 地面目标点 − 目标锚点
|
||||
```
|
||||
|
||||
先缩放图片,再按实际宽高换算锚点,避免不同分辨率取整累计漂移。
|
||||
普通和高清图采用同一参考坐标,不再分别维护偏移。
|
||||
|
||||
## 配置与发布
|
||||
|
||||
源配置:`src/farm_render/data/crop_positions.json`。
|
||||
每个阶段保存 `sprite`、`reference_size`、`anchor`、图片 `sha256`。
|
||||
`basis` 说明人工标注依据。
|
||||
导出脚本将标注写入渲染 metadata,并检查图片哈希、阶段和画布尺寸。
|
||||
更换素材或改变种子尺寸后必须重新标注,不能静默套用旧锚点。
|
||||
这些是绘图数据,保存在包内配置;不需要修改玩家 SQLite 结构。
|
||||
|
||||
复现对照图:
|
||||
|
||||
```text
|
||||
python scripts/preview_crop_positions.py --output <输出目录>
|
||||
```
|
||||
|
||||
图中红十字为地块目标点;阶段 0~5 在相同地块和相同缩放比例下比较。
|
||||
预览同时给出旧居中算法与新落地锚点算法,观察茎根而非叶片或果实边缘。
|
||||
自动测试校验跨阶段、跨分辨率的坐标变换;美术位置还需结合预览检查。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 作物绘制统一定位规则
|
||||
|
||||
2026-10-19 经用户确认,后续普通地块作物绘制采用本文公式。
|
||||
本文取代 `14-crop-position-calibration.md` 中的人工逐阶段定位方案。
|
||||
不再扩展人工锚点,不按非透明包围盒裁剪图片,不保存网页作物 ID。
|
||||
|
||||
## 已确认的来源
|
||||
|
||||
网页公开 Lua 包的 `WebGLMainSceneLandController:SetCropTexture`:
|
||||
先调用 `Image.SetNativeSize()`,生长阶段宽高乘 0.75,
|
||||
再将 `RectTransform.anchoredPosition` 设为零。
|
||||
`WebGLMainSceneLandsController:InitLands` 使用
|
||||
`LandsCrops/LandN/crop` 作为普通地块作物节点。
|
||||
|
||||
主场景包中对应的 90 个普通作物节点(含不同预制体副本)
|
||||
统一采用 `pivot = anchorMin = anchorMax = (0.5, 0)`。
|
||||
这是完整画布底边中点,不是可见茎根的人工标注点。
|
||||
普通土地与作物父容器中心一致;参考矩形为 153 × 79.5。
|
||||
|
||||
## 计算公式
|
||||
|
||||
输出画布左上角为原点,x 向右、y 向下。土地矩形中心为
|
||||
`(Cx, Cy)`,显示高度为 `D`,作物完整画布最终显示宽高为 `w, h`:
|
||||
|
||||
```text
|
||||
left = Cx - w / 2
|
||||
top = Cy + D / 2 - h
|
||||
```
|
||||
|
||||
原版原生像素单位一致时,PNG 完整画布为 `W, H`,场景倍率为 `s`:
|
||||
|
||||
```text
|
||||
D = 79.5 × s
|
||||
w = 0.75 × W × s
|
||||
h = 0.75 × H × s
|
||||
left = Cx - 0.375 × W × s
|
||||
top = Cy + 39.75 × s - 0.75 × H × s
|
||||
```
|
||||
|
||||
先确定统一场景单位,不能把 0.75 重复叠加到已有图片缩放。
|
||||
以实际最终显示宽高计算位置,仅在落到输出像素时取整。
|
||||
必须保留透明留白;参考土地矩形,不能使用土壤可见像素包围盒。
|
||||
|
||||
## 阶段边界
|
||||
|
||||
- 原版 step 2~6:上述生长图片公式。
|
||||
- step 1:公共种子图,原生尺寸乘 0.5,localPosition=(0,0,0)。
|
||||
- step 7/8:公共枯萎图,原生尺寸乘 0.5,localPosition=(0,-8,0)。
|
||||
- 本地阶段编号需正确映射到原版资源阶段,不能直接混用。
|
||||
- 特殊、动画和其他场景的作物须分别核验其控制器。
|
||||
|
||||
## 验证与接入状态
|
||||
|
||||
随机抽取本地数据库的茄子、油菜、黄瓜、迎春花、桂圆,
|
||||
共绘制 24 个生长阶段;保留完整透明画布,不使用人工锚点。
|
||||
场景倍率为 2,底边对齐的计算误差不超过 0.5 输出像素。
|
||||
这验证了预览公式的执行与图片效果,不代表所有素材已逐一核对原图。
|
||||
|
||||
正式渲染器已切换到本文公式。`crop_positions.json` schema=2 保存统一规则,
|
||||
不再包含人工逐阶段锚点;导出 metadata 也不再写入人工标注。
|
||||
种子和枯萎使用场景包原始公共精灵,分别为 69×30 和 209×92。
|
||||
|
||||
现有 3072 基准场景沿用旧土地布局;以其土地显示高度 `D` 除以 79.5
|
||||
得到原版单位到当前布局的统一倍率,再换算作物与虫子尺寸和坐标。
|
||||
跨输出分辨率只追加 `width/3072`,不重复应用 0.75。
|
||||
旧土地的宽高比例仍沿用本地布局,因此本次是定位规则移植,
|
||||
不是对整个网页场景像素级复刻。
|
||||
|
||||
`scripts/preview_crop_positions.py` 已改为调用正式渲染器,生成
|
||||
1024、1536、3072 三种输出档位的作物阶段及虫子预览。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 虫害机制
|
||||
|
||||
## 规则与来源
|
||||
|
||||
用户明确规定:每只虫使生长速度降低 5%;成熟瞬间每只虫使本季
|
||||
产量减少 5%。最多三只,速度最低为正常速度的 85%。
|
||||
采用线性相加,不是逐只复乘。减产在地块加成后的整数产量上
|
||||
再乘 `(100 - 5 × 成熟虫数) / 100`,向下取整;经验不变。
|
||||
|
||||
生虫时机是本项目新增规则,不宣称来自网页:每季间隔为
|
||||
`max(3600, ceil(基础生长秒数/4))`,最多发生三次生虫事件。
|
||||
每次一只;除虫后已发生事件不重放,尚未到来的事件仍可继续发生。
|
||||
成熟时停止生虫;如果生虫与成熟恰好同秒,优先成熟。
|
||||
|
||||
## 时间与结算
|
||||
|
||||
`farm_core/pests.py` 按生虫事件分段计算,剩余工作量以百分之一秒
|
||||
为整数单位。无虫时每秒完成 100 单位,有 n 只虫时每秒完成
|
||||
`100-5n` 单位,成熟秒数向上取整。
|
||||
除虫恢复后续速度,不补回此前已经损失的时间。
|
||||
|
||||
不创建定时后台任务;查询以保存的状态和统一 UTC 时间投影,
|
||||
收获、除虫在既有 SQLite 业务事务中结算并保存。
|
||||
因此不需要在线挂机,也不会按“看了几次农场”重复生虫。
|
||||
阶段展示使用已完成生长工作量,不使用减速后的预计成熟时间倒推阶段。
|
||||
|
||||
成熟虫数冻结为 `maturity_pests`。成熟后再除虫会清除可见虫子,
|
||||
但不会恢复本季产量。下一季及重新播种清空虫数、事件数和减产记录,
|
||||
重新开始生长;枯萎、铲除后没有虫子。
|
||||
|
||||
## 状态与迁移
|
||||
|
||||
schema v3 的 plots 增加六个字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| pest_count | 当前虫数 |
|
||||
| pest_spawned | 本季已经发生的生虫次数 |
|
||||
| growth_remaining | 剩余工作量,百分之一秒 |
|
||||
| growth_updated_at | 已结算到的 UTC 秒 |
|
||||
| next_pest_at | 下一次生虫 UTC 秒;无后续事件时为空 |
|
||||
| maturity_pests | 成熟瞬间冻结的虫数 |
|
||||
|
||||
v1→v2→v3、v2→v3 都在初始化事务内迁移,保留已有资产、流水与地块。
|
||||
迁移前已经种植的当前季不追溯增加虫害和成熟时间,仍按原规则结算;
|
||||
下一季或重新播种后启用虫害。底层原始快照保留计算基准,
|
||||
业务服务的 snapshot 提供指定时刻投影后的虫数、进度和预计成熟时间。
|
||||
|
||||
## QQ 指令与绘制
|
||||
|
||||
发送 `农场除虫` 或 `除虫`,一次清除自己所有地块现存虫子,
|
||||
不收费、不奖励经验;无虫时返回明确提示。重复事件返回原结果,
|
||||
事务失败全部回滚。`农场详述` 显示虫数、当前减速及已冻结减产比例,
|
||||
收获结果说明发生减产的地块。
|
||||
|
||||
正式渲染器采用 `13-insect-coordinates-deferred.md` 中的固定位置,
|
||||
以地块中心定位,显示 0~3 只虫,成熟后未除虫时仍显示虫子。
|
||||
这是为展示用户所要求的成熟虫害状态而保留,区别于网页 step<6 的隐藏条件。
|
||||
静态图片从完整 38 帧中稳定选帧:依据农场作用域摘要、地块、
|
||||
本地作物编号、当前季数、虫序,以 SHA-256 确定选帧顺序。
|
||||
同一地块的多只虫不重复选帧;跨地块允许偶然相同。
|
||||
不依赖进程随机种子,刷新、重启、切换画质、除虫调整成熟时间均不改帧。
|
||||
下一季重新计算;同一作物重新播种且其他组合相同时会复用该组合的选帧。
|
||||
无需增加业务数据库字段。虫数与选帧都进入可见场景缓存键,
|
||||
除虫后不会复用带虫旧图。完整 38 帧均已归档并进入资源包。
|
||||
|
||||
## 验证范围
|
||||
|
||||
离线测试覆盖分段减速、离线成熟、成熟后除虫仍减产、多季重置、
|
||||
重复消息、并发除虫、失败回滚、数据库重新打开、旧版本迁移、
|
||||
QQ 指令编排和渲染缓存失效。未验证真实 QQ 发送或正在运行的宿主部署。
|
||||
@@ -3,7 +3,7 @@ name: farm-core-foundation
|
||||
language: en
|
||||
phase: verify
|
||||
status: await-user
|
||||
state_version: 26
|
||||
state_version: 36
|
||||
brief: brief.md
|
||||
document_constraints_version: 3
|
||||
shape_confirmation_hash: d1bc1489c0aa3d9a28ff7a24c62aa4bcb4e0a953955d265bcf06f802252cec36
|
||||
@@ -25,10 +25,10 @@ workspace:
|
||||
loop:
|
||||
stage: await-user
|
||||
goal_cycle: 5
|
||||
iteration: 2
|
||||
iteration: 5
|
||||
attempt: 1
|
||||
retry_epoch: 0
|
||||
failed_iteration_count: 0
|
||||
failed_iteration_count: 1
|
||||
no_progress_count: 0
|
||||
execution_failure_count: 0
|
||||
previous_unresolved_ids: []
|
||||
@@ -39,21 +39,21 @@ acceptance:
|
||||
text: "A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 827文件基线正式检查通过;核心源码仅相对导入,安装及导入副作用测试通过,不依赖legacy或框架。
|
||||
text: 正式all-offline基线校验827文件通过,独立导入/安装测试通过;源码分层无框架依赖且不经legacy运行。
|
||||
truncated: false
|
||||
- id: A2
|
||||
source: brief.md
|
||||
text: "A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 202条目录源值及导出复现、损坏目录拒绝测试通过;Catalogue本地读取且验证schema与重复对象。
|
||||
text: 正式目录可复现校验与源202行对照、损坏输入拒绝测试通过。
|
||||
truncated: false
|
||||
- id: A3
|
||||
source: brief.md
|
||||
text: "A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。"
|
||||
result: passed
|
||||
reason:
|
||||
text: repository.register在事务内发放500币和3地;重复注册与四段身份隔离正式测试通过。
|
||||
text: 注册默认资产、三空地、重复领取和作用域隔离正式测试通过;repository.register只首次赠送。
|
||||
truncated: false
|
||||
- id: A4
|
||||
source: brief.md
|
||||
@@ -61,7 +61,7 @@ acceptance:
|
||||
不留下部分写入。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 购买播种均通过同一事务调用,整数/等级/资产/空地检查明确;失败无部分写入测试通过。
|
||||
text: 购买/播种在单事务中完成;非法整数、资源/等级不足、对象不存在和失败无部分写入正式测试通过。
|
||||
truncated: false
|
||||
- id: A5
|
||||
source: brief.md
|
||||
@@ -69,7 +69,7 @@ acceptance:
|
||||
多季次数及枯萎/铲除遵守保存的规则与可注入时钟。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 收获按各地块单独计算增量、保存多季状态;成熟边界、加成排序、冻结规则与枯萎测试通过。
|
||||
text: 成熟边界、多季、逐地加成及100+20=120正式测试通过;application.harvest只累加本次经验。
|
||||
truncated: false
|
||||
- id: A6
|
||||
source: brief.md
|
||||
@@ -77,7 +77,7 @@ acceptance:
|
||||
不发生旧版模板参数缺失。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 指定和全部出售均保护锁定状态,只加本次收入;文本及结构化回执返回真实账户,回归通过。
|
||||
text: 指定/全部出售保护锁定且收益只增加一次;正式回归覆盖真实余额和安全文本。
|
||||
truncated: false
|
||||
- id: A7
|
||||
source: brief.md
|
||||
@@ -85,14 +85,14 @@ acceptance:
|
||||
不重复领取,不吞数据库故障伪装成功;重启后数据可读。"
|
||||
result: passed
|
||||
reason:
|
||||
text: BEGIN IMMEDIATE和操作指纹/持久结果覆盖原子回滚、幂等冲突、并发及取消重放;重启/模式拒绝测试通过。
|
||||
text: storage显式事务与幂等指纹;故障回滚、并发超卖/重复领取、重启恢复和取消重放正式检查通过。
|
||||
truncated: false
|
||||
- id: A8
|
||||
source: brief.md
|
||||
text: "A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 离线入口、完整56项回归及实现说明可复核;真实宿主、迁库及远程发布边界有说明。
|
||||
text: 完整离线入口与文档存在,真实宿主/旧库迁移/远端操作边界已记录;当前用户测试不扩大历史自动验证结论。
|
||||
truncated: false
|
||||
- id: A9
|
||||
source: brief.md
|
||||
@@ -100,35 +100,35 @@ acceptance:
|
||||
依赖;安装包含所需素材。"
|
||||
result: passed
|
||||
reason:
|
||||
text: Canvas支持透明合成/复制/缩放/裁剪/文字/三格式编码;核心无Pillow运行依赖,wheel独立渲染测试通过。
|
||||
text: canvas透明合成/裁剪/文本/三编码、wheel独立运行正式测试通过;新面板两素材离线导出并校验。
|
||||
truncated: false
|
||||
- id: A10
|
||||
source: brief.md
|
||||
text: "A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 统一快照和now生成场景,阶段按首熟/再生长锚点映射,枯萎优先并使用实际偏移;普通高清解码及边界测试通过。
|
||||
text: 一次快照与统一now绘图、各阶段/尺寸正式测试通过;源码与旧drawUserInfo对照恢复userInfo/frame、140x150头像和信息内容,按用户选择校正底图槽位;样图目视确认无框外货币。
|
||||
truncated: false
|
||||
- id: A11
|
||||
source: brief.md
|
||||
text: "A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 底图直接为目标宽度;素材返回副本,LRU预算有界;身份、可见账户/地块阶段、头像和配置纳入键,正式缓存测试通过。
|
||||
text: 目标尺寸绘制、LRU预算/复制保护、阶段/账户/头像/身份缓存失效正式测试通过;新增经验边界和经验变化失效覆盖本轮面板。
|
||||
truncated: false
|
||||
- id: A12
|
||||
source: brief.md
|
||||
text: "A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。"
|
||||
result: passed
|
||||
reason:
|
||||
text: RenderService整图to_thread,有限排队及并发、相同请求合并,shield取消和关闭排空;并发/取消/失败重试测试通过。
|
||||
text: RenderService后台线程/信号量/单任务合并/取消保护/关闭排空实现与正式并发测试一致,日志记录时长尺寸缓存。
|
||||
truncated: false
|
||||
- id: A13
|
||||
source: brief.md
|
||||
text: "A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 离线样例报告标注本机单次及未测上传;三格式冷暖数据、布局及文档保留,旧基线和原核心回归继续通过。
|
||||
text: 离线样例与本机性能报告完整;恢复面板报告675素材、三编码冷暖数据,明确未测低配上传;全回归与基线通过。
|
||||
truncated: false
|
||||
- id: A14
|
||||
source: brief.md
|
||||
@@ -136,7 +136,7 @@ acceptance:
|
||||
独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 沿用原中文指令及旧子命令,独立预览库、稳定事件哈希、完整身份、生命周期/字体/头像降级已核对;余额不足映射修正并通过无扣款新测试。
|
||||
text: 真寻原指令、稳定事件ID、身份隔离与独立库生命周期模拟测试通过;默认FONT_PATH在适配层,显式覆盖与缺失提示有测试。
|
||||
truncated: false
|
||||
- id: A15
|
||||
source: brief.md
|
||||
@@ -144,14 +144,14 @@ acceptance:
|
||||
源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 构建白名单包含私有模块、素材/许可证NOTICE及来源哈希,固定ZIP元数据,不修改开发导入;复现测试通过;最终修复包校验30文件且runtime匹配源码。
|
||||
text: 白名单构建私有模块/素材/许可证/来源哈希,固定ZIP元数据,正式可复现测试通过;最终preview.3的30文件校验且renderer与源码一致。
|
||||
truncated: false
|
||||
- id: A16
|
||||
source: brief.md
|
||||
text: "A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。"
|
||||
result: passed
|
||||
reason:
|
||||
text: 校验器拒绝重复/额外/穿越/符号链接/哈希错误,先验再安装且独占创建不覆盖;正式边界测试通过。Windows临时目录实测尾点空格变体未复现逃逸。
|
||||
text: 安装先校验、已有目标拒绝、路径穿越/哈希/符号链接等正式边界测试通过;最终安装目录与ZIP逐文件一致,不启动宿主。
|
||||
truncated: false
|
||||
- id: A17
|
||||
source: brief.md
|
||||
@@ -159,7 +159,7 @@ acceptance:
|
||||
由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。"
|
||||
result: passed
|
||||
reason:
|
||||
text: CI默认只读/附件输出;同步需手动main触发与受保护环境Secret,脚本默认离线且固定仓库farm-preview、拒绝默认/非生成分支、无force并保护竞态;模拟/守卫通过。
|
||||
text: 工作流触发/只读权限/矩阵/附件与专用手动发布源码核对,正式守卫通过;固定farm-preview且非force,默认离线,冲突及默认分支拒绝模拟测试通过。
|
||||
truncated: false
|
||||
- id: A18
|
||||
source: brief.md
|
||||
@@ -168,15 +168,16 @@ acceptance:
|
||||
提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。"
|
||||
result: passed
|
||||
reason:
|
||||
text: Windows本机56项及隔离Pillow10.4下24项均正式通过;CI矩阵明确Windows/Linux和10.4/12.3,文档含安装/字体/数据/原指令冲突/卸载/Secrets及未验证边界。
|
||||
text: 完整60项与Pillow10.4的28项本机正式检查通过,工作流包含Windows/Linux和10.4/12.3;说明记录字体/数据/安装/回退/Secrets及真实消息和远端运行限制。
|
||||
truncated: false
|
||||
builder_handoff:
|
||||
candidate_id: 24c9df3f-4c07-4b34-a21a-8f2ccf9f4adb
|
||||
candidate_id: f7644c08-b105-472c-9e92-4e28224fa080
|
||||
identity_provider: skill-coordinated
|
||||
builder_execution_ref: skill-coordinated:builder:e3b4b544-7ef0-4d3f-bd5f-fb269d4455c2
|
||||
iteration: 2
|
||||
builder_execution_ref: skill-coordinated:builder:90d9bf58-287b-4ac9-9c39-e97c23b3bc86
|
||||
iteration: 5
|
||||
summary:
|
||||
text: 按独立验收建议修正余额不足的中文提示映射,增加无扣款回归测试;其余已确认A1-A18实现不变。此前55项/最低23项/工作流检查及独立验收通过,本轮受影响检查由Runtime重验,未执行远端或真实宿主。
|
||||
text: 恢复旧版用户信息底图、头像框、头像尺寸及昵称/经验/等级/币种字段;用户确认按原底图修正旧坐标错位。经验口径兼容旧版、点券为真实余额。补齐两张原素材,目标尺寸LRU、一次快照与异步限流均保留。15项定向绘图测试通过,已目视核对最新样图并构建preview.3。
|
||||
完整检查发现一处81字符行宽违规,已拆行;3项打包检查通过,运行完整正式复查。
|
||||
truncated: false
|
||||
addressed_acceptance_ids:
|
||||
- A1
|
||||
@@ -261,14 +262,20 @@ builder_handoff:
|
||||
- src/farm_render/layout.py
|
||||
- src/farm_render/renderer.py
|
||||
- tests/test_render.py
|
||||
note: 新实现对应已确认规格,定向测试和离线样例可复核。
|
||||
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
|
||||
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
|
||||
- docs/render-panel-restored-benchmark.json
|
||||
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
|
||||
- id: A11
|
||||
status: implemented-with-evidence
|
||||
evidence:
|
||||
- src/farm_render/assets.py
|
||||
- src/farm_render/renderer.py
|
||||
- tests/test_render.py
|
||||
note: 新实现对应已确认规格,定向测试和离线样例可复核。
|
||||
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
|
||||
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
|
||||
- docs/render-panel-restored-benchmark.json
|
||||
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
|
||||
- id: A12
|
||||
status: implemented-with-evidence
|
||||
evidence:
|
||||
@@ -281,7 +288,10 @@ builder_handoff:
|
||||
- scripts/render_demo.py
|
||||
- docs/10-rendering.md
|
||||
- docs/render-benchmark.json
|
||||
note: 新实现对应已确认规格,定向测试和离线样例可复核。
|
||||
- tests/test_render.py::test_legacy_panel_assets_avatar_size_and_layer_order
|
||||
- tests/test_render.py::test_legacy_panel_fields_progress_and_level_boundaries
|
||||
- docs/render-panel-restored-benchmark.json
|
||||
note: 恢复原版面板素材和信息,按用户确认校正旧坐标;15项渲染定向测试与样图核对通过,完整回归交Runtime。
|
||||
- id: A14
|
||||
status: implemented-with-evidence
|
||||
evidence:
|
||||
@@ -321,32 +331,39 @@ builder_handoff:
|
||||
note: 按用户最终选择沿用原指令并保留独立测试数据;定向检查通过,完整与最低依赖检查纳入Runtime计划。
|
||||
checks:
|
||||
- name:
|
||||
text: insufficient-funds-message
|
||||
text: render-panel-focused
|
||||
truncated: false
|
||||
result: passed
|
||||
note:
|
||||
text: Observed failing test, fixed mapping, targeted test passed; balance
|
||||
unchanged
|
||||
text: 15 tests including original asset layering, avatar geometry, level
|
||||
boundaries and experience cache invalidation.
|
||||
truncated: false
|
||||
- name:
|
||||
text: visual-panel-and-package
|
||||
truncated: false
|
||||
result: passed
|
||||
note:
|
||||
text: Latest 1536 JPEG visually inspected; preview.3 built and package verified.
|
||||
truncated: false
|
||||
- name:
|
||||
text: packaging-focused
|
||||
truncated: false
|
||||
result: passed
|
||||
note:
|
||||
text: 3 packaging tests passed after wrapping overlong renderer line; runtime
|
||||
full first attempt failed only line-width gate.
|
||||
truncated: false
|
||||
checks_truncated: false
|
||||
known_limits:
|
||||
- text: 真实真寻/NoneBot依赖注入与平台发图尚未实际运行;模拟宿主不是线上验证。
|
||||
- text: 真实聊天需重启后由用户测试;不自动重启,不访问真实玩家库。
|
||||
truncated: false
|
||||
- text: 本机Windows已测,Linux仅配置CI尚未执行。
|
||||
- text: 菜单/网页不显示仍未解决;Linux/GitHub未实跑;旧缺图455项未补。
|
||||
truncated: false
|
||||
- text: 工作流与跨仓库API只离线检查/模拟,未推送未运行GitHub。
|
||||
truncated: false
|
||||
- text: 独立预览库不迁移旧数据;同名指令测试需用户手动停用旧版。
|
||||
truncated: false
|
||||
- text: 默认JPEG;WebP需要用户确认消息平台能力后配置。
|
||||
truncated: false
|
||||
- text: 缺失455个旧阶段素材引用以占位降级;字体及历史素材发布权需部署/发布前核实。
|
||||
truncated: false
|
||||
- text: 本地源码dirty故产物会标记dirty,发布脚本拒绝发布dirty产物。
|
||||
- text: 恢复旧经验分母为下一等级累计门槛,未改变数值规则。
|
||||
truncated: false
|
||||
known_limits_truncated: false
|
||||
review: null
|
||||
submitted_at: 2026-10-15T09:52:39.181Z
|
||||
submitted_at: 2026-10-16T02:27:25.960Z
|
||||
blockers:
|
||||
- owner: user
|
||||
reason:
|
||||
@@ -356,17 +373,17 @@ blockers:
|
||||
acceptance_ids: []
|
||||
resolution_action: await-user
|
||||
verification:
|
||||
candidate_id: 24c9df3f-4c07-4b34-a21a-8f2ccf9f4adb
|
||||
candidate_id: f7644c08-b105-472c-9e92-4e28224fa080
|
||||
identity_provider: skill-coordinated
|
||||
verifier_execution_ref: skill-coordinated:verifier:d68f2636-d977-4a84-bc40-0dec8d89c753
|
||||
iteration: 2
|
||||
verifier_execution_ref: skill-coordinated:verifier:1c253cb5-527d-407b-9273-2e206149f47d
|
||||
iteration: 5
|
||||
attempt: 1
|
||||
assurance: skill-coordinated
|
||||
verdict: pass
|
||||
checks:
|
||||
- id: all-offline
|
||||
name:
|
||||
text: Core render and zhenxun regression
|
||||
text: Full offline
|
||||
truncated: false
|
||||
argv_display:
|
||||
- text: scripts/verify_foundation.py
|
||||
@@ -375,10 +392,10 @@ verification:
|
||||
cwd_ref: .
|
||||
status: passed
|
||||
exit_code: 0
|
||||
duration_ms: 22011
|
||||
duration_ms: 24375
|
||||
- id: pillow-10-compat
|
||||
name:
|
||||
text: Pillow10.4 render and zhenxun
|
||||
text: Pillow floor
|
||||
truncated: false
|
||||
argv_display:
|
||||
- text: scripts/check_pillow_compat.py
|
||||
@@ -391,10 +408,10 @@ verification:
|
||||
cwd_ref: .
|
||||
status: passed
|
||||
exit_code: 0
|
||||
duration_ms: 12697
|
||||
duration_ms: 15112
|
||||
- id: workflow-guards
|
||||
name:
|
||||
text: Workflow YAML and boundaries
|
||||
text: Workflows
|
||||
truncated: false
|
||||
argv_display:
|
||||
- text: scripts/check_workflows.py
|
||||
@@ -409,17 +426,19 @@ verification:
|
||||
exit_code: 0
|
||||
duration_ms: 95
|
||||
summary:
|
||||
text: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
|
||||
text: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
|
||||
truncated: false
|
||||
risks:
|
||||
- text: 真实真寻依赖注入、登录及平台发图未运行;Linux与GitHub工作流/跨仓库API仅配置或模拟,未宣称上线。
|
||||
- text: 真实聊天须用户重启后验证本轮面板,未自动启动机器人;菜单/网页不显示仍待独立排查。
|
||||
truncated: false
|
||||
- text: 沿用原指令需用户先停用旧插件;独立新库不迁移旧玩家数据。AUTO保守默认JPEG,WebP实际平台能力待用户测试。
|
||||
- text: Linux和GitHub工作流/远端发布未实跑;455个旧作物阶段缺图仍按既定占位处理。
|
||||
truncated: false
|
||||
- text: 旧素材455个阶段引用缺图以占位降级;公开发布前需核实素材及字体使用权;当前dirty本地产物只用于测试,发布器拒绝dirty。
|
||||
- text: 图索引为旧generation且新文件未跟踪;本轮以直接源读取与正式Runtime检查为准。
|
||||
truncated: false
|
||||
- text: 经验条沿用旧版当前级经验/下级累计门槛口径,仅校正坐标;不是重新设计升级规则。
|
||||
truncated: false
|
||||
risks_truncated: false
|
||||
completed_at: 2026-10-15T09:58:23.989Z
|
||||
completed_at: 2026-10-16T02:31:02.032Z
|
||||
history:
|
||||
- goal_cycle: 1
|
||||
iteration: 1
|
||||
@@ -503,6 +522,51 @@ history:
|
||||
text: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
|
||||
truncated: false
|
||||
completed_at: 2026-10-15T09:58:23.989Z
|
||||
- goal_cycle: 5
|
||||
iteration: 2
|
||||
attempt: 1
|
||||
outcome: recovery
|
||||
unresolved_ids: []
|
||||
summary:
|
||||
text: 用户实际测试要求默认字体:在真寻适配层默认复用宿主字体目录,保留显式字体覆盖和缺失时清楚提示;修复预览版默认不可绘图的接入遗漏。
|
||||
truncated: false
|
||||
completed_at: 2026-10-16T02:06:48.036Z
|
||||
- goal_cycle: 5
|
||||
iteration: 3
|
||||
attempt: 1
|
||||
outcome: pass
|
||||
unresolved_ids: []
|
||||
summary:
|
||||
text: 独立只读验收全部A1-A18:完整brief/三spec与当前字体实现、测试、文档已核对;复用此前未改变核心/渲染/发布的正式独立验收,并核实本候选绑定Runtime三检查exit0,实际58项完整与Pillow10.4下26项通过。真寻FONT_PATH为resources/font,接入层默认选宿主字体,坏默认跳过、显式覆盖优先,共享层未引入真寻路径;安装目录runtime/commands与主源SHA256一致。图索引不覆盖新文件或metadata_changed,已读源码补齐;未重跑完整测试、未修改项目文件、未启动宿主或访问远端。
|
||||
truncated: false
|
||||
completed_at: 2026-10-16T02:13:06.574Z
|
||||
- goal_cycle: 5
|
||||
iteration: 3
|
||||
attempt: 1
|
||||
outcome: recovery
|
||||
unresolved_ids: []
|
||||
summary:
|
||||
text: 用户要求修复迁移遗漏:恢复旧版用户面板素材、布局与经验进度语义并保留性能优化。
|
||||
truncated: false
|
||||
completed_at: 2026-10-16T02:17:50.690Z
|
||||
- goal_cycle: 5
|
||||
iteration: 4
|
||||
attempt: 0
|
||||
outcome: recovery
|
||||
unresolved_ids: []
|
||||
summary:
|
||||
text: "Builder handoff Runtime checks failed: all-offline"
|
||||
truncated: false
|
||||
completed_at: 2026-10-16T02:26:17.238Z
|
||||
- goal_cycle: 5
|
||||
iteration: 5
|
||||
attempt: 1
|
||||
outcome: pass
|
||||
unresolved_ids: []
|
||||
summary:
|
||||
text: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
|
||||
truncated: false
|
||||
completed_at: 2026-10-16T02:31:02.032Z
|
||||
history_overflow:
|
||||
dropped_entries: 0
|
||||
first_dropped_at: null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
generated_from_state_version: 26
|
||||
generated_from_state_version: 36
|
||||
---
|
||||
|
||||
# Verification
|
||||
@@ -9,54 +9,52 @@ generated_from_state_version: 26
|
||||
- Result: **Verification passed; your confirmation is required**
|
||||
- Verification status: **Checks completed, but your confirmation is required**
|
||||
- Goal cycle: 5
|
||||
- Iteration: 2
|
||||
- Iteration: 5
|
||||
- Verifier attempt: 1
|
||||
- Completed: 2026-10-15T09:58:23.989Z
|
||||
- Summary: 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
|
||||
- Completed: 2026-10-16T02:31:02.032Z
|
||||
- Summary: 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
|
||||
|
||||
## Acceptance
|
||||
|
||||
| ID | Result | Source | Criterion | Reason |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A1 | passed | brief.md | A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。 | 827文件基线正式检查通过;核心源码仅相对导入,安装及导入副作用测试通过,不依赖legacy或框架。 |
|
||||
| A2 | passed | brief.md | A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。 | 202条目录源值及导出复现、损坏目录拒绝测试通过;Catalogue本地读取且验证schema与重复对象。 |
|
||||
| A3 | passed | brief.md | A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。 | repository.register在事务内发放500币和3地;重复注册与四段身份隔离正式测试通过。 |
|
||||
| A4 | passed | brief.md | A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, 不留下部分写入。 | 购买播种均通过同一事务调用,整数/等级/资产/空地检查明确;失败无部分写入测试通过。 |
|
||||
| A5 | passed | brief.md | A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。 | 收获按各地块单独计算增量、保存多季状态;成熟边界、加成排序、冻结规则与枯萎测试通过。 |
|
||||
| A6 | passed | brief.md | A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, 不发生旧版模板参数缺失。 | 指定和全部出售均保护锁定状态,只加本次收入;文本及结构化回执返回真实账户,回归通过。 |
|
||||
| A7 | passed | brief.md | A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 不重复领取,不吞数据库故障伪装成功;重启后数据可读。 | BEGIN IMMEDIATE和操作指纹/持久结果覆盖原子回滚、幂等冲突、并发及取消重放;重启/模式拒绝测试通过。 |
|
||||
| A8 | passed | brief.md | A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。 | 离线入口、完整56项回归及实现说明可复核;真实宿主、迁库及远程发布边界有说明。 |
|
||||
| A9 | passed | brief.md | A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。 | Canvas支持透明合成/复制/缩放/裁剪/文字/三格式编码;核心无Pillow运行依赖,wheel独立渲染测试通过。 |
|
||||
| A10 | passed | brief.md | A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。 | 统一快照和now生成场景,阶段按首熟/再生长锚点映射,枯萎优先并使用实际偏移;普通高清解码及边界测试通过。 |
|
||||
| A11 | passed | brief.md | A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。 | 底图直接为目标宽度;素材返回副本,LRU预算有界;身份、可见账户/地块阶段、头像和配置纳入键,正式缓存测试通过。 |
|
||||
| A12 | passed | brief.md | A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 | RenderService整图to_thread,有限排队及并发、相同请求合并,shield取消和关闭排空;并发/取消/失败重试测试通过。 |
|
||||
| A13 | passed | brief.md | A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 | 离线样例报告标注本机单次及未测上传;三格式冷暖数据、布局及文档保留,旧基线和原核心回归继续通过。 |
|
||||
| A14 | passed | brief.md | A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。 | 沿用原中文指令及旧子命令,独立预览库、稳定事件哈希、完整身份、生命周期/字体/头像降级已核对;余额不足映射修正并通过无扣款新测试。 |
|
||||
| A15 | passed | brief.md | A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。 | 构建白名单包含私有模块、素材/许可证NOTICE及来源哈希,固定ZIP元数据,不修改开发导入;复现测试通过;最终修复包校验30文件且runtime匹配源码。 |
|
||||
| A16 | passed | brief.md | A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。 | 校验器拒绝重复/额外/穿越/符号链接/哈希错误,先验再安装且独占创建不覆盖;正式边界测试通过。Windows临时目录实测尾点空格变体未复现逃逸。 |
|
||||
| A17 | passed | brief.md | A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。 | CI默认只读/附件输出;同步需手动main触发与受保护环境Secret,脚本默认离线且固定仓库farm-preview、拒绝默认/非生成分支、无force并保护竞态;模拟/守卫通过。 |
|
||||
| A18 | passed | brief.md | A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。 | Windows本机56项及隔离Pillow10.4下24项均正式通过;CI矩阵明确Windows/Linux和10.4/12.3,文档含安装/字体/数据/原指令冲突/卸载/Secrets及未验证边界。 |
|
||||
| A1 | passed | brief.md | A1: 旧基线 827 文件校验继续通过;新核心不导入三框架、无导入副作用, 可从安装包或源码独立运行,核心不通过路径引用 legacy。 | 正式all-offline基线校验827文件通过,独立导入/安装测试通过;源码分层无框架依赖且不经legacy运行。 |
|
||||
| A2 | passed | brief.md | A2: 可复现导出 202 条作物配置,名称和必要数值对应源静态数据库; 本地加载支持格式校验,坏数据明确失败,不访问网络。 | 正式目录可复现校验与源202行对照、损坏输入拒绝测试通过。 |
|
||||
| A3 | passed | brief.md | A3: 注册立即可查询 500 农场币、0 点券、0 经验、3 块空地; 重复注册不重复赠送;不同显式作用域不串号。 | 注册默认资产、三空地、重复领取和作用域隔离正式测试通过;repository.register只首次赠送。 |
|
||||
| A4 | passed | brief.md | A4: 购买到播种全过程扣款、入库、耗种和地块改变正确;0、负数、 布尔值、超大数量、余额/库存不足、等级不足和不存在对象明确失败, 不留下部分写入。 | 购买/播种在单事务中完成;非法整数、资源/等级不足、对象不存在和失败无部分写入正式测试通过。 |
|
||||
| A5 | passed | brief.md | A5: 未成熟不能收获;成熟后只增加本次产量/经验;历史经验 100、 本次获得20时为120;多地加成逐块计算且交换顺序不改变总和; 多季次数及枯萎/铲除遵守保存的规则与可注入时钟。 | 成熟边界、多季、逐地加成及100+20=120正式测试通过;application.harvest只累加本次经验。 |
|
||||
| A6 | passed | brief.md | A6: 出售收益只增加一次,例如余额100、收益20后为120; 锁定同时约束指定与全部出售;返回结构化真实余额及安全文本, 不发生旧版模板参数缺失。 | 指定/全部出售保护锁定且收益只增加一次;正式回归覆盖真实余额和安全文本。 |
|
||||
| A7 | passed | brief.md | A7: 模拟交易中途异常时余额、库存、地块及流水整体回滚; 同一操作标识重试返回原结果、参数冲突拒绝;并发写入不超卖、 不重复领取,不吞数据库故障伪装成功;重启后数据可读。 | storage显式事务与幂等指纹;故障回滚、并发超卖/重复领取、重启恢复和取消重放正式检查通过。 |
|
||||
| A8 | passed | brief.md | A8: 提供可执行的离线测试、运行入口和使用说明,保留真实宿主、 真实库迁移及远程发布未验证的说明;文档记录已实现范围和剩余项。 | 完整离线入口与文档存在,真实宿主/旧库迁移/远端操作边界已记录;当前用户测试不扩大历史自动验证结论。 |
|
||||
| A9 | passed | brief.md | A9: 独立图片工具支持透明合成、缩放、裁剪、文本测量/省略和三格式编码; 渲染不导入框架、不联网,核心保持无 Pillow 依赖;安装包含所需素材。 | canvas透明合成/裁剪/文本/三编码、wheel独立运行正式测试通过;新面板两素材离线导出并校验。 |
|
||||
| A10 | passed | brief.md | A10: 从一次快照绘制空地、生长、成熟、再生长、枯萎及锁定地块, 使用统一时间、旧布局和正确偏移;输出普通/高清可解码图片。 | 一次快照与统一now绘图、各阶段/尺寸正式测试通过;源码与旧drawUserInfo对照恢复userInfo/frame、140x150头像和信息内容,按用户选择校正底图槽位;样图目视确认无框外货币。 |
|
||||
| A11 | passed | brief.md | A11: 小画布从开始按目标尺寸绘制;素材和结果缓存有内存上限, 共享素材不被污染;阶段/成熟边界、账户、头像及作用域变化不复用旧图。 | 目标尺寸绘制、LRU预算/复制保护、阶段/账户/头像/身份缓存失效正式测试通过;新增经验边界和经验变化失效覆盖本轮面板。 |
|
||||
| A12 | passed | brief.md | A12: 合成和编码在后台执行且限制并发,相同请求合并;取消和异常 不提前释放运行名额、不泄漏后台任务;记录耗时、尺寸和缓存状态。 | RenderService后台线程/信号量/单任务合并/取消保护/关闭排空实现与正式并发测试一致,日志记录时长尺寸缓存。 |
|
||||
| A13 | passed | brief.md | A13: 离线生成农场样例、对比尺寸/编码与冷暖缓存,保存测量报告和 接入文档;验证旧基线及原有核心测试,明确尚未接入真实消息平台。 | 离线样例与本机性能报告完整;恢复面板报告675素材、三编码冷暖数据,明确未测低配上传;全回归与基线通过。 |
|
||||
| A14 | passed | brief.md | A14: 真寻接入沿用原指令提供帮助、注册、我的农场、仓库、购买、 播种、收获、铲除、锁定/解锁、出售;稳定事件幂等标识、身份隔离, 独立测试数据库与启动/关闭生命周期;缺字体时给出配置提示。 | 真寻原指令、稳定事件ID、身份隔离与独立库生命周期模拟测试通过;默认FONT_PATH在适配层,显式覆盖与缺失提示有测试。 |
|
||||
| A15 | passed | brief.md | A15: 白名单构建完整真寻插件ZIP,包含私有共享模块、静态素材、 依赖、许可证及来源/文件哈希;不含其他适配、legacy、测试或玩家数据, 源提交/脏工作区明确记录,相同输入可复现,构建不修改开发源码。 | 白名单构建私有模块/素材/许可证/来源哈希,固定ZIP元数据,正式可复现测试通过;最终preview.3的30文件校验且renderer与源码一致。 |
|
||||
| A16 | passed | brief.md | A16: 离线校验ZIP内容、导入和命令路由;安装脚本拒绝路径穿越、 哈希错误和已有目标,不覆盖旧插件或玩家数据;不自动启动真实机器人。 | 安装先校验、已有目标拒绝、路径穿越/哈希/符号链接等正式边界测试通过;最终安装目录与ZIP逐文件一致,不启动宿主。 |
|
||||
| A17 | passed | brief.md | A17: 主仓库工作流完成检查/构建/上传测试附件;可选手动发布只面向 固定真寻仓库的专用测试分支,默认不发布、不改main、不force,凭据 由Secrets提供且不进入产物;发布仓库收到生成内容后可独立校验。 | 工作流触发/只读权限/矩阵/附件与专用手动发布源码核对,正式守卫通过;固定farm-preview且非force,默认离线,冲突及默认分支拒绝模拟测试通过。 |
|
||||
| A18 | passed | brief.md | A18: 本机离线验证打包、边界与安装行为;CI覆盖Windows/Linux及 Pillow10.4和当前支持版本,记录真实宿主、GitHub运行未验证边界; 提供下载、字体/数据路径、测试命令、回退/卸载和Secrets配置说明。 | 完整60项与Pillow10.4的28项本机正式检查通过,工作流包含Windows/Linux和10.4/12.3;说明记录字体/数据/安装/回退/Secrets及真实消息和远端运行限制。 |
|
||||
|
||||
## Checks
|
||||
|
||||
| Check | Command | Working directory | Status | Exit | Duration |
|
||||
| --- | --- | --- | --- | ---: | ---: |
|
||||
| Core render and zhenxun regression | scripts/verify_foundation.py | . | passed | 0 | 22011 ms |
|
||||
| Pillow10.4 render and zhenxun | scripts/check_pillow_compat.py --dependency-path .release-cache/pillow10 | . | passed | 0 | 12697 ms |
|
||||
| Workflow YAML and boundaries | scripts/check_workflows.py --dependency-path .release-cache/qa | . | passed | 0 | 95 ms |
|
||||
| Full offline | scripts/verify_foundation.py | . | passed | 0 | 24375 ms |
|
||||
| Pillow floor | scripts/check_pillow_compat.py --dependency-path .release-cache/pillow10 | . | passed | 0 | 15112 ms |
|
||||
| Workflows | scripts/check_workflows.py --dependency-path .release-cache/qa | . | passed | 0 | 95 ms |
|
||||
|
||||
### Builder-reported evidence
|
||||
|
||||
These are Builder reports, not Runtime check receipts or independent verification results.
|
||||
|
||||
- insufficient-funds-message: passed — Observed failing test, fixed mapping, targeted test passed; balance unchanged
|
||||
- Known limitation: 真实真寻/NoneBot依赖注入与平台发图尚未实际运行;模拟宿主不是线上验证。
|
||||
- Known limitation: 本机Windows已测,Linux仅配置CI尚未执行。
|
||||
- Known limitation: 工作流与跨仓库API只离线检查/模拟,未推送未运行GitHub。
|
||||
- Known limitation: 独立预览库不迁移旧数据;同名指令测试需用户手动停用旧版。
|
||||
- Known limitation: 默认JPEG;WebP需要用户确认消息平台能力后配置。
|
||||
- Known limitation: 缺失455个旧阶段素材引用以占位降级;字体及历史素材发布权需部署/发布前核实。
|
||||
- Known limitation: 本地源码dirty故产物会标记dirty,发布脚本拒绝发布dirty产物。
|
||||
- render-panel-focused: passed — 15 tests including original asset layering, avatar geometry, level boundaries and experience cache invalidation.
|
||||
- visual-panel-and-package: passed — Latest 1536 JPEG visually inspected; preview.3 built and package verified.
|
||||
- packaging-focused: passed — 3 packaging tests passed after wrapping overlong renderer line; runtime full first attempt failed only line-width gate.
|
||||
- Known limitation: 真实聊天需重启后由用户测试;不自动重启,不访问真实玩家库。
|
||||
- Known limitation: 菜单/网页不显示仍未解决;Linux/GitHub未实跑;旧缺图455项未补。
|
||||
- Known limitation: 恢复旧经验分母为下一等级累计门槛,未改变数值规则。
|
||||
|
||||
## Blockers
|
||||
|
||||
@@ -64,9 +62,10 @@ These are Builder reports, not Runtime check receipts or independent verificatio
|
||||
|
||||
## Risks and skipped work
|
||||
|
||||
- 真实真寻依赖注入、登录及平台发图未运行;Linux与GitHub工作流/跨仓库API仅配置或模拟,未宣称上线。
|
||||
- 沿用原指令需用户先停用旧插件;独立新库不迁移旧玩家数据。AUTO保守默认JPEG,WebP实际平台能力待用户测试。
|
||||
- 旧素材455个阶段引用缺图以占位降级;公开发布前需核实素材及字体使用权;当前dirty本地产物只用于测试,发布器拒绝dirty。
|
||||
- 真实聊天须用户重启后验证本轮面板,未自动启动机器人;菜单/网页不显示仍待独立排查。
|
||||
- Linux和GitHub工作流/远端发布未实跑;455个旧作物阶段缺图仍按既定占位处理。
|
||||
- 图索引为旧generation且新文件未跟踪;本轮以直接源读取与正式Runtime检查为准。
|
||||
- 经验条沿用旧版当前级经验/下级累计门槛口径,仅校正坐标;不是重新设计升级规则。
|
||||
|
||||
## Previous iterations
|
||||
|
||||
@@ -81,9 +80,14 @@ These are Builder reports, not Runtime check receipts or independent verificatio
|
||||
| 5 | 1 | 1 | pass | — | 独立只读核对完整brief、三份规格及18项验收,审阅核心/渲染、真寻接入、白名单构建/校验/安装/固定发布边界、工作流和测试文档。复用本候选正式Runtime的55项全回归、Pillow10.4下23项兼容检查及工作流守卫,全部通过;未重复完整套件。图索引旧/新增缺覆盖,已直接源码补齐。未修改实现或正式规格,未访问远端、安装/启动机器人或代用户接受。 | 2026-10-15T09:51:26.860Z |
|
||||
| 5 | 1 | 1 | recovery | — | 独立验收指出余额不足错误码映射缺少insufficient_funds;保持已确认范围,补中文提示及回归测试后重新交付。 | 2026-10-15T09:52:02.989Z |
|
||||
| 5 | 2 | 1 | pass | — | 独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。 | 2026-10-15T09:58:23.989Z |
|
||||
| 5 | 2 | 1 | recovery | — | 用户实际测试要求默认字体:在真寻适配层默认复用宿主字体目录,保留显式字体覆盖和缺失时清楚提示;修复预览版默认不可绘图的接入遗漏。 | 2026-10-16T02:06:48.036Z |
|
||||
| 5 | 3 | 1 | pass | — | 独立只读验收全部A1-A18:完整brief/三spec与当前字体实现、测试、文档已核对;复用此前未改变核心/渲染/发布的正式独立验收,并核实本候选绑定Runtime三检查exit0,实际58项完整与Pillow10.4下26项通过。真寻FONT_PATH为resources/font,接入层默认选宿主字体,坏默认跳过、显式覆盖优先,共享层未引入真寻路径;安装目录runtime/commands与主源SHA256一致。图索引不覆盖新文件或metadata_changed,已读源码补齐;未重跑完整测试、未修改项目文件、未启动宿主或访问远端。 | 2026-10-16T02:13:06.574Z |
|
||||
| 5 | 3 | 1 | recovery | — | 用户要求修复迁移遗漏:恢复旧版用户面板素材、布局与经验进度语义并保留性能优化。 | 2026-10-16T02:17:50.690Z |
|
||||
| 5 | 4 | 0 | recovery | — | Builder handoff Runtime checks failed: all-offline | 2026-10-16T02:26:17.238Z |
|
||||
| 5 | 5 | 1 | pass | — | 独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。 | 2026-10-16T02:31:02.032Z |
|
||||
|
||||
|
||||
|
||||
## Conclusion
|
||||
|
||||
独立只读核对完整brief、三份规格和18项,审阅核心/渲染/真寻接入、白名单构建/校验/安装/固定发布边界、测试工作流与文档。当前candidate绑定Runtime的56项完整回归、Pillow10.4下24项及工作流守卫均退出码0;日志前缀658580e0与当前cedb操作绑定已核实。额外只读确认最终ZIP30文件且runtime匹配修复源码,余额不足中文提示与无扣款回归通过。图索引过期/缺覆盖已直接源码补齐,未重复完整套件、修改实现或正式文件、远端操作、安装启动宿主或代用户接受。
|
||||
独立只读核对全部A1-A18、完整brief/三份Spec、相关实现与测试及本候选Runtime绑定。复用60项完整检查、Pillow10.4下28项及工作流守卫,均退出码0;未重跑完整套件。原面板底图/头像框/头像尺寸和信息已恢复,用户授权的槽位坐标校正经源码与1536样图核验;preview.3最终ZIP30文件与当前安装目录逐文件相同,renderer匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 执行记录
|
||||
|
||||
## 2026-10-16 原版信息面板恢复
|
||||
|
||||
- 用户要求恢复旧头像框和信息内容,并确认按原底图校正已有坐标错位。
|
||||
- 对照旧drawUserInfo恢复userInfo.png/frame.png、头像尺寸与叠放顺序;
|
||||
经验保留旧版本级已获/下一级累计门槛口径,点券读取真实余额。
|
||||
- 素材导出补齐两张旧图(共675张),不修改legacy基线;按目标尺寸
|
||||
缓存素材,保留一次快照、后台绘制、限流、合并与成图缓存。
|
||||
- 15项绘图定向测试通过,包括像素叠放对照、等级边界、经验变化失效;
|
||||
离线样图已目视检查。Runtime正式检查:完整60项、Pillow10.4下28项
|
||||
和工作流守卫全部通过;首轮行宽失败已修正并完成正式复查。
|
||||
- preview.3最终包在dist/zhenxun-preview-panel-restored-final;当前真寻
|
||||
插件已同步六个生成文件并校验通过,原文件备份位于
|
||||
runtime/panel-restore-backup-20261016,未重启机器人或修改玩家数据。
|
||||
- 样例和本机性能见runtime/panel-restored与
|
||||
docs/render-panel-restored-benchmark.json;实际聊天发送待用户验证。
|
||||
|
||||
## 2026-10-15
|
||||
|
||||
1. 定位空 farm 目录,核对旧项目分支与源提交。
|
||||
@@ -73,3 +89,14 @@
|
||||
包内runtime.py与修复源码一致。已生成dist/zhenxun-preview-ready交付。
|
||||
- 最终大小/哈希和验证边界保存zhenxun-preview-checks.json。
|
||||
- 当前等待用户接受验收;没有提交、归档、推送、发布或真实宿主安装。
|
||||
|
||||
## 2026-10-16 默认字体修复
|
||||
|
||||
- 用户实际测试提示FARM_PREVIEW_FONT,要求有默认配置。
|
||||
- 真寻适配层使用宿主FONT_PATH,优先HYWenHei-85W.ttf,保留显式覆盖,
|
||||
可跳过损坏的默认候选;不把此默认路径推广到其他框架或共享渲染层。
|
||||
- 7项接入测试通过。已备份并同步已安装预览的runtime.py/commands.py
|
||||
及清单;未改数据库、未重启宿主。
|
||||
- 使用真寻实际虚拟环境及临时合成玩家库生成JPEG1024×624成功,
|
||||
自动选择现有HYWenHei-85W.ttf。不是通过真实聊天消息的发图验证。
|
||||
- 菜单/网页插件管理不显示问题尚未定位,不能报告为已解决。
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"python": "3.12.14",
|
||||
"os": "Windows",
|
||||
"pillow": "12.3.0",
|
||||
"synthetic_data": true,
|
||||
"scope": "本机单次样例;非旧版对照,未测上传或低配服务器",
|
||||
"asset_count": 675,
|
||||
"legacy_missing_stage_references": 455,
|
||||
"measurements": [
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 624,
|
||||
"codec": "WEBP",
|
||||
"quality": 75,
|
||||
"method": 2,
|
||||
"bytes": 86594,
|
||||
"cold_ms": 204.684,
|
||||
"compose_ms": 140.895,
|
||||
"encode_ms": 63.156,
|
||||
"warm_ms": 0.478,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 5343464,
|
||||
"result_cache_bytes": 90690
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 624,
|
||||
"codec": "JPEG",
|
||||
"quality": 75,
|
||||
"method": 2,
|
||||
"bytes": 112862,
|
||||
"cold_ms": 122.716,
|
||||
"compose_ms": 117.766,
|
||||
"encode_ms": 4.495,
|
||||
"warm_ms": 0.24,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 5343464,
|
||||
"result_cache_bytes": 116958
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 624,
|
||||
"codec": "PNG",
|
||||
"quality": 75,
|
||||
"method": 2,
|
||||
"bytes": 1123597,
|
||||
"cold_ms": 163.744,
|
||||
"compose_ms": 124.098,
|
||||
"encode_ms": 39.237,
|
||||
"warm_ms": 0.345,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 5343464,
|
||||
"result_cache_bytes": 1127693
|
||||
},
|
||||
{
|
||||
"width": 1536,
|
||||
"height": 936,
|
||||
"codec": "WEBP",
|
||||
"quality": 85,
|
||||
"method": 2,
|
||||
"bytes": 234716,
|
||||
"cold_ms": 222.895,
|
||||
"compose_ms": 138.411,
|
||||
"encode_ms": 83.824,
|
||||
"warm_ms": 0.374,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 12022448,
|
||||
"result_cache_bytes": 238812
|
||||
},
|
||||
{
|
||||
"width": 1536,
|
||||
"height": 936,
|
||||
"codec": "JPEG",
|
||||
"quality": 85,
|
||||
"method": 2,
|
||||
"bytes": 296080,
|
||||
"cold_ms": 143.138,
|
||||
"compose_ms": 132.595,
|
||||
"encode_ms": 10.014,
|
||||
"warm_ms": 0.21,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 12022448,
|
||||
"result_cache_bytes": 300176
|
||||
},
|
||||
{
|
||||
"width": 1536,
|
||||
"height": 936,
|
||||
"codec": "PNG",
|
||||
"quality": 85,
|
||||
"method": 2,
|
||||
"bytes": 2243287,
|
||||
"cold_ms": 235.929,
|
||||
"compose_ms": 146.362,
|
||||
"encode_ms": 89.006,
|
||||
"warm_ms": 0.386,
|
||||
"warm_cache_hit": true,
|
||||
"asset_cache_bytes": 12022448,
|
||||
"result_cache_bytes": 2247383
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ from nonebot.params import CommandArg
|
||||
|
||||
require("nonebot_plugin_uninfo")
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
from zhenxun.configs.path_config import DATA_PATH, FONT_PATH
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
@@ -23,6 +23,7 @@ runtime = Runtime(
|
||||
getattr(driver.config, "farm_preview_font", None),
|
||||
getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"),
|
||||
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
|
||||
font_dir=FONT_PATH,
|
||||
)
|
||||
driver.on_startup(runtime.start)
|
||||
driver.on_shutdown(runtime.close)
|
||||
|
||||
@@ -8,6 +8,7 @@ import shlex
|
||||
|
||||
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
||||
from ._vendor.farm_core.domain import FarmError
|
||||
from ._vendor.farm_core.storage import SCHEMA_VERSION
|
||||
from ._vendor.farm_core.presentation import receipt_text, snapshot_text
|
||||
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
|
||||
|
||||
@@ -19,17 +20,25 @@ COMMANDS = {
|
||||
"种子商店":"shop", "农场帮助":"help", "购买种子":"buy",
|
||||
"播种":"sow", "收获":"harvest", "铲除":"eradicate",
|
||||
"作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell",
|
||||
"农场除虫":"clear_pests", "除虫":"clear_pests",
|
||||
}
|
||||
HELP = (
|
||||
"开通农场 [名称]|我的农场 [高清]|农场详述\n"
|
||||
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
|
||||
"收获|铲除|我的种子|我的作物\n"
|
||||
"收获|铲除|农场除虫|我的种子|我的作物\n"
|
||||
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
|
||||
"作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n"
|
||||
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
|
||||
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
|
||||
"本版使用独立预览数据库,不会读取旧版资产。"
|
||||
)
|
||||
ERRORS = {
|
||||
"not_registered":"请先发送:开通农场",
|
||||
"unknown_plant":"没有这种作物,请查看种子商店。",
|
||||
"ambiguous_plant":"存在同名作物,请使用商店或仓库中的 #编号。",
|
||||
"plant_not_purchasable":"该种子当前不出售,已有种子仍可播种。",
|
||||
"plant_disabled":"该作物已停用,暂不能购买或播种。",
|
||||
"unsupported_crop_type":"该条目不支持作为普通作物种植。",
|
||||
"invalid_integer":"数量必须为有效的正整数。",
|
||||
"insufficient_funds":"余额不足。",
|
||||
"insufficient_stock":"库存不足。",
|
||||
@@ -38,6 +47,7 @@ ERRORS = {
|
||||
"nothing_to_harvest":"当前没有成熟作物。",
|
||||
"nothing_to_eradicate":"当前没有枯萎作物。",
|
||||
"nothing_to_sell":"当前没有可出售作物。",
|
||||
"nothing_to_clear":"当前没有虫子需要清除。",
|
||||
"operation_conflict":"该事件已处理但参数不一致,请发送新消息。",
|
||||
}
|
||||
|
||||
@@ -53,9 +63,10 @@ def operation_id(bot_id, platform, user_id, message_id, action):
|
||||
|
||||
class Runtime:
|
||||
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
|
||||
image_format="AUTO"):
|
||||
image_format="AUTO", *, font_dir=None):
|
||||
self.data_dir = Path(data_dir).resolve()
|
||||
self.font_path = font_path
|
||||
self.font_dir = Path(font_dir or "resources/font")
|
||||
self.deployment = deployment
|
||||
self.image_format = image_format
|
||||
self.store = SQLiteStore(self.data_dir / "farm-preview.sqlite3")
|
||||
@@ -68,7 +79,6 @@ class Runtime:
|
||||
if self.started:
|
||||
return
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
await self.store.initialize()
|
||||
self.log_handler = logging.FileHandler(
|
||||
self.data_dir / "farm-preview.log", encoding="utf-8",
|
||||
)
|
||||
@@ -77,13 +87,37 @@ class Runtime:
|
||||
))
|
||||
logging.getLogger(__package__).setLevel(logging.INFO)
|
||||
logging.getLogger(__package__).addHandler(self.log_handler)
|
||||
try:
|
||||
await self.store.initialize(catalogue=self.service.catalogue)
|
||||
except Exception:
|
||||
logger.error("farm preview database initialization failed")
|
||||
await self.close()
|
||||
raise
|
||||
if self.font_path:
|
||||
candidates = [Path(self.font_path)]
|
||||
else:
|
||||
candidates = [self.font_dir / name for name in (
|
||||
"HYWenHei-85W.ttf", "msyh.ttf", "syht.otf", "syst.otf",
|
||||
"AlibabaHealthFont2.0CN-85B.ttf",
|
||||
)]
|
||||
candidates.extend(Path(name) for name in (
|
||||
"C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
))
|
||||
for candidate in candidates:
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
self.rendering = RenderService(FarmRenderer(self.font_path))
|
||||
self.rendering = RenderService(FarmRenderer(candidate))
|
||||
logger.info("farm preview font selected file=%s", candidate.name)
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
logger.warning("farm preview font unavailable; text mode only")
|
||||
logger.warning("farm preview font candidate is not loadable")
|
||||
if self.rendering is None:
|
||||
logger.warning("farm preview font unavailable; text mode only")
|
||||
self.started = True
|
||||
logger.info("farm preview initialized schema=1")
|
||||
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
@@ -115,6 +149,7 @@ class Runtime:
|
||||
"eradicate":"eradicate", "lock-plant":"lock",
|
||||
"unlock-plant":"unlock", "sell-plant":"sell",
|
||||
"farm-help":"help",
|
||||
"clear-pests":"clear_pests",
|
||||
}
|
||||
if args[0] in aliases:
|
||||
action = aliases[args[0]]
|
||||
@@ -129,11 +164,13 @@ class Runtime:
|
||||
if not 1 <= page <= 100 or len(args) > 1:
|
||||
raise ValueError()
|
||||
plants = sorted(
|
||||
self.service.catalogue, key=lambda p: p.plant_id,
|
||||
(p for p in self.service.catalogue if p.buyable),
|
||||
key=lambda p: p.plant_id,
|
||||
)
|
||||
chunk = plants[(page-1)*12:page*12]
|
||||
return "种子商店 " + str(page) + "页\n" + "\n".join(
|
||||
f"{p.name}:{p.cost}{'点券' if p.is_vip else '农场币'}"
|
||||
f"{p.name}(#{p.plant_id}):"
|
||||
f"{p.cost}{'点券' if p.is_vip else '农场币'}"
|
||||
f" / 等级{p.level}" for p in chunk
|
||||
)
|
||||
if action == "inventory":
|
||||
@@ -175,7 +212,7 @@ class Runtime:
|
||||
receipt = await self.service.lock(
|
||||
who, args[0], action == "lock", op,
|
||||
)
|
||||
elif action in ("harvest", "eradicate"):
|
||||
elif action in ("harvest", "eradicate", "clear_pests"):
|
||||
if args:
|
||||
raise ValueError()
|
||||
receipt = await getattr(self.service, action)(who, op)
|
||||
|
||||
@@ -10,6 +10,17 @@ from zipfile import ZIP_STORED, ZipFile, ZipInfo
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def apply_positions(root, metadata):
|
||||
document = json.loads((
|
||||
root / "src/farm_render/data/crop_positions.json"
|
||||
).read_bytes())
|
||||
if document.get("schema") != 2:
|
||||
raise ValueError("Unsupported crop position schema")
|
||||
if document.get("mode") != "full_canvas_bottom_center":
|
||||
raise ValueError("Unsupported crop placement mode")
|
||||
metadata["placement"] = document
|
||||
|
||||
|
||||
def export(root):
|
||||
legacy = root / "legacy/zhenxun_plugin_farm"
|
||||
source = legacy / "resource"
|
||||
@@ -19,6 +30,8 @@ def export(root):
|
||||
)]
|
||||
paths += [source / "background/expansion.png"]
|
||||
paths += [source / "background/ripe.png"]
|
||||
paths += [source / "background/userInfo.png"]
|
||||
paths += [source / "background/frame/frame.png"]
|
||||
paths += [source / (
|
||||
f"background/decorateIcon/decorate_{theme}_1.jpg"
|
||||
) for theme in ("default", "spring", "summer", "autumn", "winter")]
|
||||
@@ -36,19 +49,29 @@ def export(root):
|
||||
} for row in catalogue["plants"]}, "images": {},
|
||||
}
|
||||
output = BytesIO()
|
||||
webgl_source = root / "src/farm_render/source_assets"
|
||||
manifest = json.loads((webgl_source / "manifest.json").read_bytes())
|
||||
metadata["pest"] = manifest["pest"]
|
||||
entries = {path.relative_to(source).as_posix(): path for path in paths}
|
||||
entries.update({name: webgl_source / name for name in manifest["images"]})
|
||||
with ZipFile(output, "w", compression=ZIP_STORED) as bundle:
|
||||
for path in sorted(paths):
|
||||
name = path.relative_to(source).as_posix()
|
||||
for name, path in sorted(entries.items()):
|
||||
content = path.read_bytes()
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
size = image.size
|
||||
metadata["images"][name] = {
|
||||
"size": size, "sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
if name in manifest["images"]:
|
||||
expected = manifest["images"][name]
|
||||
if (list(size) != expected["size"] or
|
||||
metadata["images"][name]["sha256"] != expected["sha256"]):
|
||||
raise ValueError("Captured WebGL sprite changed")
|
||||
item = ZipInfo(name, (2020, 1, 1, 0, 0, 0))
|
||||
item.create_system = 3
|
||||
item.external_attr = 0o100644 << 16
|
||||
bundle.writestr(item, content)
|
||||
apply_positions(root, metadata)
|
||||
return output.getvalue(), (
|
||||
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
+ "\n"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""从已下载的公开 Unity 场景包提取图片;不执行游戏脚本。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def extract(bundle, destination):
|
||||
import UnityPy
|
||||
|
||||
environment = UnityPy.load(str(bundle))
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
images = {}
|
||||
for obj in environment.objects:
|
||||
if obj.type.name != "Sprite":
|
||||
continue
|
||||
sprite = obj.read()
|
||||
name = sprite.m_Name
|
||||
if re.fullmatch(r"pest\d+", name):
|
||||
path = f"pest/{name}.png"
|
||||
elif name in ("Icon_PlantSeed", "Icon_PlantWither"):
|
||||
path = "webgl/" + (
|
||||
"seed.png" if name == "Icon_PlantSeed" else "wither.png"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
image = sprite.image
|
||||
if image.size != (round(sprite.m_Rect.width),
|
||||
round(sprite.m_Rect.height)):
|
||||
raise ValueError("Sprite extraction lost its full canvas")
|
||||
target = destination / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(target)
|
||||
images[path] = {
|
||||
"size": list(image.size),
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
}
|
||||
if len(images) != 40:
|
||||
raise ValueError("Expected 38 pest frames and two common crop sprites")
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"source_url": (
|
||||
"https://game.qqnc.qq.com/happyfarm/unity/Assets/AssetBundles/"
|
||||
"WebGL/mainscene_053483f584f07436ac0569e2f4f7ed01"
|
||||
),
|
||||
"source_sha256": hashlib.sha256(bundle.read_bytes()).hexdigest(),
|
||||
"extractor": "UnityPy 1.25.4 Sprite.image; full sprite rect checked",
|
||||
"images": images,
|
||||
"pest": {
|
||||
"frames": [f"pest/pest{i}.png" for i in range(1, 76, 2)],
|
||||
"sample_rate": 24,
|
||||
"loop": True,
|
||||
"display_size": [22, 30],
|
||||
"pivot": [0.5, 0.5],
|
||||
"positions": [[-26, 13], [-5, 42], [24, 24]],
|
||||
},
|
||||
}
|
||||
(destination / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Extracted {len(images)} images")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("bundle", type=Path)
|
||||
parser.add_argument("--dependencies", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=(
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src/farm_render/source_assets"
|
||||
))
|
||||
args = parser.parse_args()
|
||||
if args.dependencies:
|
||||
sys.path.insert(0, str(args.dependencies))
|
||||
extract(args.bundle, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""使用正式渲染器离线绘制作物统一公式与虫子预览。"""
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from ci_font import choose_font
|
||||
from farm_core import Catalogue, Identity
|
||||
from farm_core.domain import Account, Plot, Snapshot
|
||||
from farm_render import FarmRenderer, RenderOptions
|
||||
from farm_render.canvas import Canvas
|
||||
|
||||
|
||||
def preview(renderer, output, width, names):
|
||||
scale = width / 3072
|
||||
native = lambda n: max(1, round(n * scale))
|
||||
cell_w, cell_h = 360, 440
|
||||
sheet = Image.new("RGB", (cell_w * 6, 95 + len(names) * (cell_h+45)),
|
||||
"#edf0eb")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
title_font = ImageFont.truetype(str(renderer.font_path), 28)
|
||||
text_font = ImageFont.truetype(str(renderer.font_path), 21)
|
||||
draw.text((20, 12), f"正式渲染器 · 统一定位与虫子 / 输出档位 {width}",
|
||||
font=title_font, fill="#172b20")
|
||||
draw.text((20, 53), "使用包内完整图片;红线为生长图片对齐基线;"
|
||||
"虫子使用原版固定坐标。", font=text_font, fill="#344c3f")
|
||||
who = Identity("preview", "local", "qq", "formula")
|
||||
old_position = renderer.assets.metadata["positions"][0]
|
||||
renderer.assets.metadata["positions"][0] = {"x": 63, "y": 220}
|
||||
try:
|
||||
for row, name in enumerate(names):
|
||||
plant = Catalogue.bundled().get(name)
|
||||
info = renderer.assets.metadata["plants"][name]
|
||||
phases = tuple(dict.fromkeys(map(int, info["phase"].split(","))))
|
||||
times = (0,) + phases
|
||||
top = 95 + row * (cell_h + 45)
|
||||
draw.text((20, top), name, font=text_font, fill="#172b20")
|
||||
data = Snapshot(Account("公式预览", 0, 0, 0), (), (
|
||||
Plot(1, plant, plant.growth_seconds,
|
||||
pest_count=3, pest_spawned=3),
|
||||
))
|
||||
for stage, now in enumerate(times):
|
||||
scene = renderer.scene(who, data, now)
|
||||
view = scene.plots[0]
|
||||
if stage == 0:
|
||||
view = replace(view, pest_count=0, pest_frames=())
|
||||
scene = replace(scene, plots=(view,))
|
||||
canvas = Canvas.new((native(cell_w), native(cell_h)), "#edf0eb")
|
||||
soil = renderer.assets.image(
|
||||
"soil/普通土地.png", (native(234), native(156)),
|
||||
)
|
||||
canvas.paste(soil, (native(63), native(220)))
|
||||
renderer._plants(canvas, scene, RenderOptions(width=width))
|
||||
renderer._pests(canvas, scene, RenderOptions(width=width))
|
||||
painter = ImageDraw.Draw(canvas.image)
|
||||
painter.line((0, native(376), native(360), native(376)),
|
||||
fill="#bd4545", width=1)
|
||||
left = stage * cell_w
|
||||
draw.text((left+12, top+32), "公共种子" if stage == 0 else
|
||||
f"生长阶段{stage} · 3只虫", font=text_font,
|
||||
fill="#172b20")
|
||||
sheet.paste(canvas.image, (
|
||||
left + (cell_w-canvas.image.width)//2, top+65,
|
||||
))
|
||||
finally:
|
||||
renderer.assets.metadata["positions"][0] = old_position
|
||||
path = output / f"crop-formula-{width}.png"
|
||||
sheet.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--font", type=Path)
|
||||
parser.add_argument("--plants", nargs="+", default=(
|
||||
"草莓", "橙子", "樱桃", "黄瓜", "桂圆",
|
||||
))
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
renderer = FarmRenderer(args.font or choose_font())
|
||||
for width in (1024, 1536, 3072):
|
||||
print(preview(renderer, args.output, width, args.plants))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,7 @@ from .domain import (
|
||||
Asset, FarmError, Identity, Plot, boosted, integer, quantity, text,
|
||||
)
|
||||
from .ports import Store
|
||||
from .pests import advance_plot, start_growth
|
||||
|
||||
|
||||
class FarmService:
|
||||
@@ -41,7 +42,11 @@ class FarmService:
|
||||
|
||||
async def snapshot(self, identity: Identity):
|
||||
self._identity(identity)
|
||||
return await self.store.snapshot(identity)
|
||||
now = integer(self.clock())
|
||||
snapshot = await self.store.snapshot(identity)
|
||||
return replace(snapshot, plots=tuple(
|
||||
advance_plot(plot, now) for plot in snapshot.plots
|
||||
))
|
||||
|
||||
async def register(
|
||||
self, identity: Identity, name: str, operation_id: str,
|
||||
@@ -64,15 +69,22 @@ class FarmService:
|
||||
|
||||
def operation(repository, now):
|
||||
plant = self.catalogue.get(name)
|
||||
if not plant.enabled:
|
||||
raise FarmError("plant_disabled")
|
||||
if plant.crop_type != 1:
|
||||
raise FarmError("unsupported_crop_type")
|
||||
if not plant.purchasable:
|
||||
raise FarmError("plant_not_purchasable")
|
||||
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)
|
||||
repository.stock(plant, seeds=count)
|
||||
return self._result(
|
||||
repository, name=name, quantity=count, cost=cost,
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
quantity=count, cost=cost,
|
||||
asset=plant.asset.value,
|
||||
seeds=repository.item(name).seeds,
|
||||
seeds=repository.item(plant).seeds,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
@@ -91,7 +103,11 @@ class FarmService:
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
plant = self.catalogue.get(name)
|
||||
available = repository.item(name).seeds
|
||||
if not plant.enabled:
|
||||
raise FarmError("plant_disabled")
|
||||
if plant.crop_type != 1:
|
||||
raise FarmError("unsupported_crop_type")
|
||||
available = repository.item(plant).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:
|
||||
@@ -99,15 +115,16 @@ class FarmService:
|
||||
if amount > len(empty):
|
||||
raise FarmError("insufficient_empty_plots")
|
||||
mature_at = integer(now + plant.growth_seconds)
|
||||
repository.stock(name, seeds=-amount)
|
||||
repository.stock(plant, seeds=-amount)
|
||||
for plot in empty[:amount]:
|
||||
repository.save_plot(replace(
|
||||
repository.save_plot(start_growth(replace(
|
||||
plot, plant=plant, mature_at=mature_at, harvest_count=0,
|
||||
))
|
||||
), now))
|
||||
return self._result(
|
||||
repository, name=name, quantity=amount,
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
quantity=amount,
|
||||
slots=[p.slot for p in empty[:amount]],
|
||||
seeds=repository.item(name).seeds,
|
||||
seeds=repository.item(plant).seeds,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
@@ -121,6 +138,7 @@ class FarmService:
|
||||
harvested = []
|
||||
total_experience = 0
|
||||
for plot in repository.plots():
|
||||
plot = advance_plot(plot, now)
|
||||
plant = plot.plant
|
||||
if (
|
||||
plant is None or plot.withered or plot.mature_at is None
|
||||
@@ -128,22 +146,33 @@ class FarmService:
|
||||
):
|
||||
continue
|
||||
amount = boosted(plant.harvest, plot.yield_bonus)
|
||||
amount = amount * (100 - 5 * plot.maturity_pests) // 100
|
||||
experience = boosted(
|
||||
plant.experience, plot.experience_bonus,
|
||||
)
|
||||
repository.stock(plant.name, crops=amount)
|
||||
repository.stock(plant, 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(
|
||||
updated = replace(
|
||||
plot, mature_at=mature_at, harvest_count=count,
|
||||
))
|
||||
pest_count=0, pest_spawned=0, growth_remaining=None,
|
||||
growth_updated_at=None, next_pest_at=None,
|
||||
maturity_pests=0,
|
||||
)
|
||||
repository.save_plot(
|
||||
start_growth(updated, now) if count < plant.crop
|
||||
else updated
|
||||
)
|
||||
harvested.append({
|
||||
"slot": plot.slot, "name": plant.name,
|
||||
"plant_id": plant.plant_id,
|
||||
"quantity": amount, "experience": experience,
|
||||
"pests": plot.maturity_pests,
|
||||
"yield_loss_percent": plot.maturity_pests * 5,
|
||||
})
|
||||
if not harvested:
|
||||
raise FarmError("nothing_to_harvest")
|
||||
@@ -167,6 +196,9 @@ class FarmService:
|
||||
for plot in withered:
|
||||
repository.save_plot(replace(
|
||||
plot, plant=None, mature_at=None, harvest_count=0,
|
||||
pest_count=0, pest_spawned=0, growth_remaining=None,
|
||||
growth_updated_at=None, next_pest_at=None,
|
||||
maturity_pests=0,
|
||||
))
|
||||
return self._result(
|
||||
repository, slots=[p.slot for p in withered],
|
||||
@@ -176,6 +208,28 @@ class FarmService:
|
||||
identity, operation_id, "eradicate", {}, operation,
|
||||
)
|
||||
|
||||
async def clear_pests(self, identity: Identity, operation_id: str):
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
cleared = []
|
||||
for stored in repository.plots():
|
||||
plot = advance_plot(stored, now)
|
||||
if plot.pest_count == 0:
|
||||
continue
|
||||
cleared.append({"slot": plot.slot, "pests": plot.pest_count})
|
||||
updated = replace(plot, pest_count=0)
|
||||
repository.save_plot(advance_plot(updated, now))
|
||||
if not cleared:
|
||||
raise FarmError("nothing_to_clear")
|
||||
return self._result(
|
||||
repository, cleared=cleared,
|
||||
quantity=sum(item["pests"] for item in cleared),
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
identity, operation_id, "clear_pests", {}, operation,
|
||||
)
|
||||
|
||||
async def lock(
|
||||
self, identity: Identity, name: str, locked: bool,
|
||||
operation_id: str,
|
||||
@@ -186,9 +240,12 @@ class FarmService:
|
||||
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
self.catalogue.get(name)
|
||||
repository.lock(name, locked)
|
||||
return self._result(repository, name=name, locked=locked)
|
||||
plant = self.catalogue.get(name)
|
||||
repository.lock(plant, locked)
|
||||
return self._result(
|
||||
repository, name=plant.name, plant_id=plant.plant_id,
|
||||
locked=locked,
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
identity, operation_id, "lock",
|
||||
@@ -209,16 +266,17 @@ class FarmService:
|
||||
def operation(repository, now):
|
||||
repository.account()
|
||||
if name is not None:
|
||||
item = repository.item(name)
|
||||
plant = self.catalogue.get(name)
|
||||
item = repository.item(plant)
|
||||
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)]
|
||||
items = [(plant.plant_id, amount)]
|
||||
else:
|
||||
items = [
|
||||
(item.name, item.crops)
|
||||
(item.plant_id, item.crops)
|
||||
for item in repository.items()
|
||||
if not item.locked and item.crops > 0
|
||||
]
|
||||
@@ -226,11 +284,14 @@ class FarmService:
|
||||
raise FarmError("nothing_to_sell")
|
||||
proceeds = 0
|
||||
sold = []
|
||||
for plant_name, amount in items:
|
||||
plant = self.catalogue.get(plant_name)
|
||||
for plant_id, amount in items:
|
||||
plant = self.catalogue.get_by_id(plant_id)
|
||||
proceeds = integer(proceeds + plant.price * amount)
|
||||
repository.stock(plant_name, crops=-amount)
|
||||
sold.append({"name": plant_name, "quantity": amount})
|
||||
repository.stock(plant, crops=-amount)
|
||||
sold.append({
|
||||
"name": plant.name, "plant_id": plant.plant_id,
|
||||
"quantity": amount,
|
||||
})
|
||||
repository.asset(Asset.POINT, proceeds)
|
||||
return self._result(
|
||||
repository, sold=sold, proceeds=proceeds,
|
||||
|
||||
@@ -12,19 +12,29 @@ class Catalogue:
|
||||
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)
|
||||
self._plants = MappingProxyType({p.plant_id: p for p in items})
|
||||
names = {}
|
||||
for plant in items:
|
||||
names.setdefault(plant.name, []).append(plant)
|
||||
self._names = MappingProxyType({k: tuple(v) for k, v in names.items()})
|
||||
|
||||
def get(self, name: str) -> Plant:
|
||||
text(name)
|
||||
try:
|
||||
return self._plants[name]
|
||||
except KeyError as exc:
|
||||
raise FarmError("unknown_plant") from exc
|
||||
if name.startswith("#") and name[1:].isascii() and name[1:].isdigit():
|
||||
return self.get_by_id(int(name[1:]))
|
||||
matches = self._names.get(name, ())
|
||||
if not matches:
|
||||
raise FarmError("unknown_plant")
|
||||
if len(matches) != 1:
|
||||
raise FarmError("ambiguous_plant")
|
||||
return matches[0]
|
||||
|
||||
def get_by_id(self, plant_id: int) -> Plant:
|
||||
if type(plant_id) is not int or plant_id not in self._plants:
|
||||
raise FarmError("unknown_plant")
|
||||
return self._plants[plant_id]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._plants.values())
|
||||
|
||||
@@ -91,8 +91,8 @@ class Identity:
|
||||
class Plant:
|
||||
"""播种时保存的不可变结算定义。"""
|
||||
|
||||
plant_id: int # 旧作物目录 ID。
|
||||
name: str # 唯一作物名称。
|
||||
plant_id: int # 稳定的本地作物 ID,不得重新分配。
|
||||
name: str # 显示名称;同名时使用本地编号区分。
|
||||
level: int # 购买等级门槛。
|
||||
buy: int # 普通种子农场币价格。
|
||||
is_vip: bool # 是否使用点券购买。
|
||||
@@ -103,11 +103,17 @@ class Plant:
|
||||
growth_seconds: int # 首次成熟所需秒数。
|
||||
crop: int # 最多可收获季数。
|
||||
regrowth_seconds: int # 兼容旧版的多季再生长秒数。
|
||||
purchasable: bool = True # 是否允许在种子商店购买。
|
||||
enabled: bool = True # 是否允许新购买和播种;不阻止存量结算。
|
||||
crop_type: int = 1 # 来源条目类型;当前仅允许类型 1 种植。
|
||||
|
||||
def __post_init__(self):
|
||||
text(self.name)
|
||||
if type(self.is_vip) is not bool:
|
||||
raise FarmError("invalid_catalogue")
|
||||
if type(self.purchasable) is not bool or type(self.enabled) is not bool:
|
||||
raise FarmError("invalid_catalogue")
|
||||
integer(self.crop_type, 1)
|
||||
for name in (
|
||||
"plant_id", "growth_seconds", "crop", "regrowth_seconds",
|
||||
"harvest",
|
||||
@@ -124,6 +130,10 @@ class Plant:
|
||||
def cost(self) -> int:
|
||||
return self.vip_buy if self.is_vip else self.buy
|
||||
|
||||
@property
|
||||
def buyable(self) -> bool:
|
||||
return self.enabled and self.purchasable and self.crop_type == 1
|
||||
|
||||
@classmethod
|
||||
def from_legacy(cls, row: dict[str, Any]) -> "Plant":
|
||||
try:
|
||||
@@ -134,6 +144,10 @@ class Plant:
|
||||
integer(row[key])
|
||||
if row["isVip"] not in (0, 1):
|
||||
raise FarmError("invalid_catalogue")
|
||||
for key in ("isBuy", "enabled"):
|
||||
value = row.get(key, 1)
|
||||
if type(value) is not int or value not in (0, 1):
|
||||
raise FarmError("invalid_catalogue")
|
||||
if not isinstance(row["phase"], str):
|
||||
raise FarmError("invalid_catalogue")
|
||||
parts = row["phase"].split(",")
|
||||
@@ -150,6 +164,9 @@ class Plant:
|
||||
bool(row["isVip"]), row["vipBuy"], row["experience"],
|
||||
row["harvest"], row["price"], row["time"] * 3600,
|
||||
row["crop"], sum(phases[1:]),
|
||||
purchasable=bool(row.get("isBuy", 1)),
|
||||
enabled=bool(row.get("enabled", 1)),
|
||||
crop_type=row.get("crop_type", 1),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, OverflowError) as exc:
|
||||
raise FarmError("invalid_catalogue") from exc
|
||||
@@ -177,6 +194,7 @@ class InventoryItem:
|
||||
seeds: int = 0 # 未播种种子数。
|
||||
crops: int = 0 # 已收获产物数。
|
||||
locked: bool = False # 是否禁止出售产物。
|
||||
plant_id: int = 0 # 稳定本地作物 ID;0 仅兼容旧快照构造。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -189,6 +207,29 @@ class Plot:
|
||||
harvest_count: int = 0 # 当前实例已收获季数。
|
||||
yield_bonus: int = 0 # 此地块产量加成百分比。
|
||||
experience_bonus: int = 0 # 此地块经验加成百分比。
|
||||
pest_count: int = 0 # 当前虫数,0~3;每只降低生长速度 5%。
|
||||
pest_spawned: int = 0 # 本季已发生的生虫事件数,除虫后不重放。
|
||||
growth_remaining: int | None = None # 剩余生长工作量,单位为百分之一秒。
|
||||
growth_updated_at: int | None = None # 上次工作量结算的 UTC 整数秒。
|
||||
next_pest_at: int | None = None # 下一次生虫时间,最多每季三次。
|
||||
maturity_pests: int = 0 # 成熟瞬间冻结的虫数,决定本季减产。
|
||||
|
||||
def __post_init__(self):
|
||||
integer(self.pest_count, 0, 3)
|
||||
integer(self.pest_spawned, self.pest_count, 3)
|
||||
integer(self.maturity_pests, 0, 3)
|
||||
for value in (self.growth_remaining, self.growth_updated_at,
|
||||
self.next_pest_at):
|
||||
if value is not None:
|
||||
integer(value)
|
||||
if (self.growth_remaining is None) != (
|
||||
self.growth_updated_at is None
|
||||
):
|
||||
raise FarmError("invalid_growth_state")
|
||||
if (self.growth_updated_at is not None
|
||||
and self.next_pest_at is not None
|
||||
and self.next_pest_at < self.growth_updated_at):
|
||||
raise FarmError("invalid_growth_state")
|
||||
|
||||
@property
|
||||
def withered(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""离线虫害时间结算;只投影状态,不读写数据库或使用随机数。"""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from .domain import Plot, integer
|
||||
|
||||
|
||||
def season_duration(plot: Plot) -> int:
|
||||
return (plot.plant.growth_seconds if plot.harvest_count == 0
|
||||
else plot.plant.regrowth_seconds)
|
||||
|
||||
|
||||
def spawn_interval(plot: Plot) -> int:
|
||||
return max(3600, (season_duration(plot) + 3) // 4)
|
||||
|
||||
|
||||
def start_growth(plot: Plot, now: int) -> Plot:
|
||||
duration = season_duration(plot)
|
||||
return advance_plot(replace(
|
||||
plot, mature_at=integer(now + duration), pest_count=0, pest_spawned=0,
|
||||
growth_remaining=integer(duration * 100), growth_updated_at=now,
|
||||
next_pest_at=integer(now + spawn_interval(plot)), maturity_pests=0,
|
||||
), now)
|
||||
|
||||
|
||||
def _advance(remaining, cursor, pests, spawned, next_at, interval, now):
|
||||
while remaining:
|
||||
rate = 100 - pests * 5
|
||||
finish = integer(cursor + (remaining + rate - 1) // rate)
|
||||
if finish <= now and (next_at is None or finish <= next_at):
|
||||
return 0, finish, pests, spawned, None, finish
|
||||
end = min(now, next_at) if next_at is not None else now
|
||||
remaining -= (end - cursor) * rate
|
||||
cursor = end
|
||||
if next_at is None or cursor < next_at:
|
||||
break
|
||||
pests += 1
|
||||
spawned += 1
|
||||
next_at = integer(next_at + interval) if spawned < 3 else None
|
||||
if cursor == now:
|
||||
break
|
||||
return remaining, cursor, pests, spawned, next_at, None
|
||||
|
||||
|
||||
def advance_plot(plot: Plot, now: int) -> Plot:
|
||||
integer(now)
|
||||
if (plot.plant is None or plot.withered
|
||||
or plot.growth_remaining is None or not plot.growth_remaining):
|
||||
return plot
|
||||
if now < plot.growth_updated_at:
|
||||
return plot
|
||||
interval = spawn_interval(plot)
|
||||
remaining, cursor, pests, spawned, next_at, finish = _advance(
|
||||
plot.growth_remaining, plot.growth_updated_at, plot.pest_count,
|
||||
plot.pest_spawned, plot.next_pest_at, interval, now,
|
||||
)
|
||||
if finish is None:
|
||||
finish = _advance(
|
||||
remaining, cursor, pests, spawned, next_at, interval,
|
||||
integer(cursor + (remaining + 84) // 85),
|
||||
)[-1]
|
||||
return replace(
|
||||
plot, mature_at=finish, pest_count=pests, pest_spawned=spawned,
|
||||
growth_remaining=remaining, growth_updated_at=cursor,
|
||||
next_pest_at=next_at,
|
||||
maturity_pests=pests if remaining == 0 else 0,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from .domain import (
|
||||
Account, Asset, Identity, InventoryItem, Plot, Receipt, Snapshot,
|
||||
Account, Asset, Identity, InventoryItem, Plot, Plant, Receipt, Snapshot,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ 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 item(self, plant: Plant) -> 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 stock(self, plant: Plant, seeds: int = 0, crops: int = 0) -> None: ...
|
||||
def lock(self, plant: Plant, locked: bool) -> None: ...
|
||||
def plots(self) -> tuple[Plot, ...]: ...
|
||||
def save_plot(self, plot: Plot) -> None: ...
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ def receipt_text(receipt: Receipt) -> str:
|
||||
"register": "注册", "buy": "购买", "sow": "播种",
|
||||
"harvest": "收获", "eradicate": "铲除",
|
||||
"lock": "锁定设置", "sell": "出售",
|
||||
"clear_pests": "除虫",
|
||||
}
|
||||
data = receipt.data
|
||||
account = data["account"]
|
||||
@@ -18,6 +19,12 @@ def receipt_text(receipt: Receipt) -> str:
|
||||
lines.append(f"实际数量:{data['quantity']}")
|
||||
if "proceeds" in data:
|
||||
lines.append(f"本次收入:{data['proceeds']}")
|
||||
for item in data.get("harvested", ()):
|
||||
if item.get("yield_loss_percent", 0):
|
||||
lines.append(
|
||||
f"地块{item['slot']}:成熟时虫子{item['pests']}只,"
|
||||
f"本季减产{item['yield_loss_percent']}%"
|
||||
)
|
||||
lines.append(f"农场币:{account['point']}")
|
||||
lines.append(f"点券:{account['vip_point']}")
|
||||
lines.append(f"累计经验:{account['experience']}")
|
||||
@@ -34,11 +41,18 @@ def snapshot_text(snapshot: Snapshot) -> str:
|
||||
for item in snapshot.inventory:
|
||||
lock = "锁定" if item.locked else "未锁定"
|
||||
lines.append(
|
||||
f"{item.name}:种子{item.seeds},作物{item.crops},{lock}"
|
||||
f"{item.name}(#{item.plant_id}):种子{item.seeds},"
|
||||
f"作物{item.crops},{lock}"
|
||||
)
|
||||
for plot in snapshot.plots:
|
||||
state = "空地" if plot.plant is None else plot.plant.name
|
||||
if plot.withered:
|
||||
state += "(枯萎)"
|
||||
if plot.pest_count:
|
||||
state += f",虫子{plot.pest_count}只"
|
||||
if plot.growth_remaining:
|
||||
state += f",生长速度降低{plot.pest_count * 5}%"
|
||||
if plot.maturity_pests:
|
||||
state += f",本季已确定减产{plot.maturity_pests * 5}%"
|
||||
lines.append(f"地块{plot.slot}:{state}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -76,46 +76,52 @@ class SQLiteRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def item(self, name: str) -> InventoryItem:
|
||||
def item(self, plant: Plant) -> InventoryItem:
|
||||
row = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? AND name=?", (self._identity, name),
|
||||
"WHERE identity=? AND plant_id=?",
|
||||
(self._identity, plant.plant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return InventoryItem(name)
|
||||
return InventoryItem(plant.name, plant_id=plant.plant_id)
|
||||
return InventoryItem(
|
||||
row["name"], row["seeds"], row["crops"], bool(row["locked"]),
|
||||
plant.name, row["seeds"], row["crops"], bool(row["locked"]),
|
||||
plant.plant_id,
|
||||
)
|
||||
|
||||
def items(self) -> tuple[InventoryItem, ...]:
|
||||
rows = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? ORDER BY name", (self._identity,),
|
||||
"SELECT plant_id, name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? ORDER BY name, plant_id", (self._identity,),
|
||||
).fetchall()
|
||||
return tuple(
|
||||
InventoryItem(r["name"], r["seeds"], r["crops"], bool(r["locked"]))
|
||||
InventoryItem(
|
||||
r["name"], r["seeds"], r["crops"], bool(r["locked"]),
|
||||
r["plant_id"],
|
||||
)
|
||||
for r in rows
|
||||
)
|
||||
|
||||
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None:
|
||||
previous = self.item(name)
|
||||
def stock(self, plant: Plant, seeds: int = 0, crops: int = 0) -> None:
|
||||
previous = self.item(plant)
|
||||
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),
|
||||
"INSERT INTO inventory(identity, plant_id, name, seeds, crops) "
|
||||
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(identity, plant_id) "
|
||||
"DO UPDATE SET name=excluded.name, seeds=excluded.seeds, "
|
||||
"crops=excluded.crops",
|
||||
(self._identity, plant.plant_id, plant.name, new_seeds, new_crops),
|
||||
)
|
||||
|
||||
def lock(self, name: str, locked: bool) -> None:
|
||||
self.stock(name)
|
||||
def lock(self, plant: Plant, locked: bool) -> None:
|
||||
self.stock(plant)
|
||||
self._db.execute(
|
||||
"UPDATE inventory SET locked=? WHERE identity=? AND name=?",
|
||||
(int(locked), self._identity, name),
|
||||
"UPDATE inventory SET locked=? WHERE identity=? AND plant_id=?",
|
||||
(int(locked), self._identity, plant.plant_id),
|
||||
)
|
||||
|
||||
def plots(self) -> tuple[Plot, ...]:
|
||||
@@ -130,6 +136,9 @@ class SQLiteRepository:
|
||||
Plant(**json.loads(r["plant"])) if r["plant"] else None,
|
||||
r["mature_at"], r["harvest_count"],
|
||||
r["yield_bonus"], r["experience_bonus"],
|
||||
r["pest_count"], r["pest_spawned"], r["growth_remaining"],
|
||||
r["growth_updated_at"], r["next_pest_at"],
|
||||
r["maturity_pests"],
|
||||
)
|
||||
for r in rows
|
||||
)
|
||||
@@ -143,10 +152,15 @@ class SQLiteRepository:
|
||||
)
|
||||
cursor = self._db.execute(
|
||||
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
|
||||
"yield_bonus=?, experience_bonus=? WHERE identity=? AND slot=?",
|
||||
"yield_bonus=?, experience_bonus=?, pest_count=?, pest_spawned=?, "
|
||||
"growth_remaining=?, growth_updated_at=?, next_pest_at=?, "
|
||||
"maturity_pests=? WHERE identity=? AND slot=?",
|
||||
(
|
||||
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
|
||||
plot.experience_bonus, self._identity, plot.slot,
|
||||
plot.experience_bonus, plot.pest_count, plot.pest_spawned,
|
||||
plot.growth_remaining, plot.growth_updated_at,
|
||||
plot.next_pest_at, plot.maturity_pests,
|
||||
self._identity, plot.slot,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
|
||||
@@ -12,10 +12,26 @@ from .domain import (
|
||||
FarmError, Identity, Receipt, SchemaError, Snapshot, StorageError,
|
||||
)
|
||||
from .repository import SQLiteRepository
|
||||
from .catalogue import Catalogue
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
APPLICATION_ID = 0x4641524D
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
PEST_COLUMNS = {
|
||||
"pest_count": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(pest_count BETWEEN 0 AND 3)"
|
||||
),
|
||||
"pest_spawned": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(pest_spawned BETWEEN 0 AND 3)"
|
||||
),
|
||||
"growth_remaining": "INTEGER CHECK(growth_remaining>=0)",
|
||||
"growth_updated_at": "INTEGER",
|
||||
"next_pest_at": "INTEGER",
|
||||
"maturity_pests": (
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK(maturity_pests BETWEEN 0 AND 3)"
|
||||
),
|
||||
}
|
||||
|
||||
SCHEMA = (
|
||||
"""CREATE TABLE accounts(
|
||||
@@ -39,13 +55,14 @@ SCHEMA = (
|
||||
)""",
|
||||
"""CREATE TABLE inventory(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
plant_id INTEGER NOT NULL CHECK(plant_id>0),
|
||||
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)
|
||||
PRIMARY KEY(identity, plant_id)
|
||||
)""",
|
||||
"""CREATE TABLE plots(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
@@ -55,6 +72,9 @@ SCHEMA = (
|
||||
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,
|
||||
""" + ",\n ".join(
|
||||
f"{name} {definition}" for name, definition in PEST_COLUMNS.items()
|
||||
) + """,
|
||||
PRIMARY KEY(identity, slot)
|
||||
)""",
|
||||
"""CREATE TABLE ledger(
|
||||
@@ -108,7 +128,7 @@ class SQLiteStore:
|
||||
raise StorageError("sqlite_failure") from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate(connection):
|
||||
def _validate(connection, expected_version=SCHEMA_VERSION):
|
||||
app_id = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
tables = {
|
||||
@@ -118,20 +138,61 @@ class SQLiteStore:
|
||||
}
|
||||
expected = {"accounts", "operations", "inventory", "plots", "ledger"}
|
||||
if (
|
||||
app_id != APPLICATION_ID or version != SCHEMA_VERSION
|
||||
app_id != APPLICATION_ID or version != expected_version
|
||||
or tables != expected
|
||||
):
|
||||
raise SchemaError("unsupported_database_schema")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
async def initialize(self, catalogue: Catalogue | None = None) -> None:
|
||||
async with self._lifecycle:
|
||||
await self._offload(self._initialize)
|
||||
await self._offload(lambda: self._initialize(catalogue))
|
||||
self._ready = True
|
||||
|
||||
def _initialize(self):
|
||||
@staticmethod
|
||||
def _migrate_inventory(connection, catalogue):
|
||||
names = {}
|
||||
for plant in catalogue:
|
||||
names.setdefault(plant.name, set()).add(plant.plant_id)
|
||||
for row in connection.execute(
|
||||
"SELECT plant FROM plots WHERE plant IS NOT NULL"
|
||||
):
|
||||
try:
|
||||
plant = json.loads(row[0])
|
||||
name, plant_id = plant["name"], plant["plant_id"]
|
||||
if (not isinstance(name, str) or type(plant_id) is not int
|
||||
or plant_id < 1):
|
||||
raise ValueError()
|
||||
names.setdefault(name, set()).add(plant_id)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise SchemaError("invalid_plot_snapshot") from exc
|
||||
rows = connection.execute("SELECT * FROM inventory").fetchall()
|
||||
migrated = []
|
||||
for row in rows:
|
||||
ids = names.get(row["name"], set())
|
||||
if len(ids) != 1:
|
||||
raise SchemaError("inventory_identity_unresolved")
|
||||
migrated.append((
|
||||
row["identity"], next(iter(ids)), row["name"],
|
||||
row["seeds"], row["crops"], row["locked"],
|
||||
))
|
||||
statement = next(
|
||||
sql for sql in SCHEMA if sql.startswith("CREATE TABLE inventory(")
|
||||
)
|
||||
connection.execute(statement.replace("inventory(", "inventory_v2(", 1))
|
||||
connection.executemany(
|
||||
"INSERT INTO inventory_v2 VALUES (?, ?, ?, ?, ?, ?)", migrated,
|
||||
)
|
||||
connection.execute("DROP TABLE inventory")
|
||||
connection.execute("ALTER TABLE inventory_v2 RENAME TO inventory")
|
||||
connection.execute("PRAGMA user_version=2")
|
||||
return len(rows)
|
||||
|
||||
def _initialize(self, catalogue=None):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
migrated_rows = None
|
||||
migrated_pests = False
|
||||
try:
|
||||
app = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute(
|
||||
@@ -147,10 +208,37 @@ class SQLiteStore:
|
||||
f"PRAGMA application_id={APPLICATION_ID}"
|
||||
)
|
||||
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||
elif app == APPLICATION_ID and version == 1:
|
||||
self._validate(connection, expected_version=1)
|
||||
migration_catalogue = (
|
||||
catalogue if catalogue is not None
|
||||
else Catalogue.bundled()
|
||||
)
|
||||
migrated_rows = self._migrate_inventory(
|
||||
connection,
|
||||
migration_catalogue,
|
||||
)
|
||||
version = 2
|
||||
if app == APPLICATION_ID and version == 2:
|
||||
self._validate(connection, expected_version=2)
|
||||
for name, definition in PEST_COLUMNS.items():
|
||||
connection.execute(
|
||||
f"ALTER TABLE plots ADD COLUMN {name} {definition}"
|
||||
)
|
||||
connection.execute("PRAGMA user_version=3")
|
||||
migrated_pests = True
|
||||
self._validate(connection)
|
||||
connection.commit()
|
||||
if migrated_rows is not None:
|
||||
LOGGER.info(
|
||||
"farm schema migrated version=3 inventory_rows=%s",
|
||||
migrated_rows,
|
||||
)
|
||||
elif migrated_pests:
|
||||
LOGGER.info("farm schema migrated version=3 pests=enabled")
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
LOGGER.warning("farm schema initialization rolled back")
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"mode": "full_canvas_bottom_center",
|
||||
"reference_soil_height": 79.5,
|
||||
"growth_scale": 0.75,
|
||||
"common_scale": 0.5,
|
||||
"wither_down": 8,
|
||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform"
|
||||
}
|
||||
@@ -46,6 +46,13 @@
|
||||
92
|
||||
]
|
||||
},
|
||||
"background/frame/frame.png": {
|
||||
"sha256": "93148c78e15295b623a857ee016768531854696ef28557bb28e044e32a5ce6ac",
|
||||
"size": [
|
||||
230,
|
||||
230
|
||||
]
|
||||
},
|
||||
"background/ripe.png": {
|
||||
"sha256": "1c94af3e0b923defc85d200bd25538bf1ee9a2e744406266cdaf7103c1ea2cbd",
|
||||
"size": [
|
||||
@@ -53,6 +60,279 @@
|
||||
92
|
||||
]
|
||||
},
|
||||
"background/userInfo.png": {
|
||||
"sha256": "09e906c85d12491c297489ee6bcc31cd7f9e0c1527808c9c64d1783e8ae3cff3",
|
||||
"size": [
|
||||
580,
|
||||
275
|
||||
]
|
||||
},
|
||||
"pest/pest1.png": {
|
||||
"sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest11.png": {
|
||||
"sha256": "ea87a9e6ede36b36d492d641abe9c881ce57aecf130ebb5e9928276588923a52",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest13.png": {
|
||||
"sha256": "063d58763c1c06bf5cf88e73b946dbe9d35dc52709057911247d29528bd80346",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest15.png": {
|
||||
"sha256": "2927464be009a916862457a9dd1cf272e2face4809c8489496b8ca63f1064036",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest17.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest19.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest21.png": {
|
||||
"sha256": "863e604de7a63a6bc82d240b72fc0d16964a3536557400fd7e9e4251179efc0b",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest23.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest25.png": {
|
||||
"sha256": "17752162847dd22b45b041072dde51d9761f24179682794c14db4fe3a21b7843",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest27.png": {
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest29.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest3.png": {
|
||||
"sha256": "a88d9a45472167ad34af98e4a3e6db0bc02c3b7f3c441aef44908a97c136e37f",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest31.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest33.png": {
|
||||
"sha256": "eca7d05f269a417553ae38da5289caea9a8f4ba5bbc592a9477bc5dfa6a6ec1d",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest35.png": {
|
||||
"sha256": "d26b7c64ed82266c23ac90b5a8e7fd5996b15a6a65a9d4893f0f4a01c1facb16",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest37.png": {
|
||||
"sha256": "bb92d5687eb73be0a8bf1d9ae8c2aac5e67dcab6e5184d19e212b5931705c6b5",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest39.png": {
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest41.png": {
|
||||
"sha256": "fa5b7979e0d905454ddd515751e53646614680e6d29f850bdea40957bc6fbeb1",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest43.png": {
|
||||
"sha256": "d7f1eea15102154c623d82cd148126f34e82ef40602d7cae78a6e3ee94970b93",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest45.png": {
|
||||
"sha256": "c84fbc9a11d7e4a0e7ddff301d4dd79f75cc1882c76776fa617d189eec8f5eb4",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest47.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest49.png": {
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest5.png": {
|
||||
"sha256": "5ce899cd3c42a7919dea0b43686f261a94e80af9d122337b3c01c01df920a9b0",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest51.png": {
|
||||
"sha256": "3eeb2ef89514543ff616a51db2ad4a54da6fa67d3e573f3cda2b63d376d91d04",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest53.png": {
|
||||
"sha256": "d823df2c2b1d9b721a2727e8e60f496aa7791942b20ff894569819b47dfe7ce4",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest55.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest57.png": {
|
||||
"sha256": "526eca9a538b3a6acd8f9ef9a358a8df98d2c6cf29667e647b6fb3df5110f5d6",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest59.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest61.png": {
|
||||
"sha256": "6603a10d50bd88bb1032fd22fe482b0c9ba433ba25967408d64e07b47a6c9b64",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest63.png": {
|
||||
"sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest65.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest67.png": {
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest69.png": {
|
||||
"sha256": "cc1fb7b27adcdab8bbb3872ab28dab66aee832b02ed36e588f214e03d19cbc8b",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest7.png": {
|
||||
"sha256": "e600a52be8e2aeb9ba01ad3b595fe9badb05a1987c2ec98b8d8bd6fe6fd73a65",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest71.png": {
|
||||
"sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest73.png": {
|
||||
"sha256": "d0d22707ce214b6c78b74315daa33f93c97e5c20368a002a094cd12964437726",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest75.png": {
|
||||
"sha256": "51e1142e2f4dd489cf392b0b25d2d8c04c3801b6d149254e7b7df7c86155b5e2",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"pest/pest9.png": {
|
||||
"sha256": "cefc8172fd7f0a6137dd5c67c43ba6b165ee37faf58893ff8f37dbff2cc08d22",
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
]
|
||||
},
|
||||
"plant/ATTomato/1.png": {
|
||||
"sha256": "48de2a54c2868852f70e981a0ef8228ad0bb09499e6c9e84057aaf82f16f7270",
|
||||
"size": [
|
||||
@@ -4714,8 +4994,97 @@
|
||||
180,
|
||||
120
|
||||
]
|
||||
},
|
||||
"webgl/seed.png": {
|
||||
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609",
|
||||
"size": [
|
||||
69,
|
||||
30
|
||||
]
|
||||
},
|
||||
"webgl/wither.png": {
|
||||
"sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472",
|
||||
"size": [
|
||||
209,
|
||||
92
|
||||
]
|
||||
}
|
||||
},
|
||||
"pest": {
|
||||
"display_size": [
|
||||
22,
|
||||
30
|
||||
],
|
||||
"frames": [
|
||||
"pest/pest1.png",
|
||||
"pest/pest3.png",
|
||||
"pest/pest5.png",
|
||||
"pest/pest7.png",
|
||||
"pest/pest9.png",
|
||||
"pest/pest11.png",
|
||||
"pest/pest13.png",
|
||||
"pest/pest15.png",
|
||||
"pest/pest17.png",
|
||||
"pest/pest19.png",
|
||||
"pest/pest21.png",
|
||||
"pest/pest23.png",
|
||||
"pest/pest25.png",
|
||||
"pest/pest27.png",
|
||||
"pest/pest29.png",
|
||||
"pest/pest31.png",
|
||||
"pest/pest33.png",
|
||||
"pest/pest35.png",
|
||||
"pest/pest37.png",
|
||||
"pest/pest39.png",
|
||||
"pest/pest41.png",
|
||||
"pest/pest43.png",
|
||||
"pest/pest45.png",
|
||||
"pest/pest47.png",
|
||||
"pest/pest49.png",
|
||||
"pest/pest51.png",
|
||||
"pest/pest53.png",
|
||||
"pest/pest55.png",
|
||||
"pest/pest57.png",
|
||||
"pest/pest59.png",
|
||||
"pest/pest61.png",
|
||||
"pest/pest63.png",
|
||||
"pest/pest65.png",
|
||||
"pest/pest67.png",
|
||||
"pest/pest69.png",
|
||||
"pest/pest71.png",
|
||||
"pest/pest73.png",
|
||||
"pest/pest75.png"
|
||||
],
|
||||
"loop": true,
|
||||
"pivot": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"positions": [
|
||||
[
|
||||
-26,
|
||||
13
|
||||
],
|
||||
[
|
||||
-5,
|
||||
42
|
||||
],
|
||||
[
|
||||
24,
|
||||
24
|
||||
]
|
||||
],
|
||||
"sample_rate": 24
|
||||
},
|
||||
"placement": {
|
||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform",
|
||||
"common_scale": 0.5,
|
||||
"growth_scale": 0.75,
|
||||
"mode": "full_canvas_bottom_center",
|
||||
"reference_soil_height": 79.5,
|
||||
"schema": 2,
|
||||
"wither_down": 8
|
||||
},
|
||||
"plants": {
|
||||
"ATTomato": {
|
||||
"general": 1,
|
||||
|
||||
@@ -3,10 +3,24 @@
|
||||
import hashlib
|
||||
|
||||
from farm_core.domain import Identity, Snapshot, integer
|
||||
from farm_core.pests import advance_plot
|
||||
|
||||
from .models import PlotView, Scene
|
||||
|
||||
|
||||
def pest_frames(scope, plot, frames):
|
||||
available = list(frames)
|
||||
chosen = []
|
||||
for index in range(plot.pest_count):
|
||||
seed = (
|
||||
f"{scope}:{plot.slot}:{plot.plant.plant_id}:"
|
||||
f"{plot.harvest_count}:{index}"
|
||||
)
|
||||
number = int.from_bytes(hashlib.sha256(seed.encode()).digest(), "big")
|
||||
chosen.append(available.pop(number % len(available)))
|
||||
return tuple(chosen)
|
||||
|
||||
|
||||
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")
|
||||
@@ -17,42 +31,46 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
|
||||
):
|
||||
raise ValueError("Invalid plot slots")
|
||||
views = []
|
||||
scope = hashlib.sha256(identity.key.encode()).hexdigest()
|
||||
for plot in sorted(snapshot.plots, key=lambda p: p.slot):
|
||||
plot = advance_plot(plot, now)
|
||||
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),
|
||||
plot.slot, "withered", "webgl/wither.png",
|
||||
))
|
||||
continue
|
||||
integer(plot.mature_at)
|
||||
frames = pest_frames(scope, plot, metadata["pest"]["frames"])
|
||||
name = plot.plant.name
|
||||
info = metadata["plants"].get(name)
|
||||
if info is None:
|
||||
views.append(PlotView(plot.slot, "growing", "missing"))
|
||||
views.append(PlotView(
|
||||
plot.slot, "growing", "missing", pest_count=plot.pest_count,
|
||||
pest_frames=frames,
|
||||
))
|
||||
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))
|
||||
work = (elapsed * 100 if plot.growth_remaining is None else
|
||||
duration * 100 - plot.growth_remaining)
|
||||
ripe = now >= plot.mature_at
|
||||
stage = len(phases) if ripe else sum(
|
||||
elapsed * phases[-1] >= threshold * duration
|
||||
work * phases[-1] >= threshold * duration * 100
|
||||
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"]))
|
||||
sprite = "webgl/seed.png"
|
||||
views.append(PlotView(
|
||||
plot.slot, "ripe" if ripe else "growing", sprite, stage,
|
||||
(info["offsetX"], info["offsetY"]), size,
|
||||
pest_count=plot.pest_count,
|
||||
pest_frames=frames,
|
||||
))
|
||||
return Scene(
|
||||
hashlib.sha256(identity.key.encode()).hexdigest(),
|
||||
snapshot.account, tuple(views),
|
||||
scope, snapshot.account, tuple(views),
|
||||
)
|
||||
|
||||
@@ -52,8 +52,8 @@ class PlotView:
|
||||
state: str # empty/growing/ripe/withered。
|
||||
sprite: str = "" # 包内受控素材名,不来自用户路径。
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
offset: tuple[int, int] = (0, 0) # 旧素材中心偏移。
|
||||
size: tuple[int, int] | None = None # 萌芽/枯萎原始尺寸覆盖。
|
||||
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
|
||||
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -64,6 +64,7 @@ class FarmRenderer:
|
||||
started = perf_counter()
|
||||
canvas = self._base(scene, options)
|
||||
self._plants(canvas, scene, options)
|
||||
self._pests(canvas, scene, options)
|
||||
self._panel(canvas, scene, options, avatar)
|
||||
composed = perf_counter()
|
||||
content = canvas.encode(codec, options.quality, options.method)
|
||||
@@ -123,6 +124,8 @@ class FarmRenderer:
|
||||
def _plants(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
rules = self.assets.metadata["placement"]
|
||||
unit = soil_h / rules["reference_soil_height"]
|
||||
first_ripe = True
|
||||
for plot in scene.plots:
|
||||
if not plot.sprite:
|
||||
@@ -137,14 +140,22 @@ class FarmRenderer:
|
||||
font=font(self.font_path, max(10, s(48))),
|
||||
)
|
||||
else:
|
||||
size = plot.size or metadata["size"]
|
||||
factor = rules["common_scale"] if (
|
||||
plot.stage == 0 or plot.state == "withered"
|
||||
) else rules["growth_scale"]
|
||||
size = [n * factor * unit for n in metadata["size"]]
|
||||
plant = self.assets.image(
|
||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
||||
)
|
||||
offset_x, offset_y = plot.offset
|
||||
center_x = pos["x"] + soil_w / 2
|
||||
bottom = pos["y"] + soil_h
|
||||
if plot.stage == 0 or plot.state == "withered":
|
||||
bottom = pos["y"] + soil_h / 2
|
||||
if plot.state == "withered":
|
||||
bottom += rules["wither_down"] * unit
|
||||
xy = (
|
||||
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
|
||||
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
|
||||
round(center_x * options.width / 3072 - plant.width / 2),
|
||||
s(bottom) - plant.height,
|
||||
)
|
||||
canvas.paste(plant, xy)
|
||||
if plot.state == "ripe" and first_ripe:
|
||||
@@ -154,19 +165,36 @@ class FarmRenderer:
|
||||
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
|
||||
first_ripe = False
|
||||
|
||||
def _pests(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
unit = soil_h / self.assets.metadata["placement"][
|
||||
"reference_soil_height"
|
||||
]
|
||||
config = self.assets.metadata["pest"]
|
||||
size = tuple(max(1, s(n * unit)) for n in config["display_size"])
|
||||
for plot in scene.plots:
|
||||
if plot.state not in ("growing", "ripe"):
|
||||
continue
|
||||
pos = self.assets.metadata["positions"][plot.slot - 1]
|
||||
for frame, (x, y) in zip(plot.pest_frames, config["positions"]):
|
||||
image = self.assets.image(frame, size)
|
||||
center_x = pos["x"] + soil_w / 2 + x * unit
|
||||
center_y = pos["y"] + soil_h / 2 - y * unit
|
||||
canvas.paste(image, (
|
||||
round(center_x * options.width / 3072 - image.width / 2),
|
||||
round(center_y * options.width / 3072 - image.height / 2),
|
||||
))
|
||||
|
||||
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
|
||||
return image.convert("RGBA").resize(
|
||||
size, Image.Resampling.LANCZOS,
|
||||
)
|
||||
except (UnidentifiedImageError, OSError, ValueError,
|
||||
Image.DecompressionBombError):
|
||||
logger.warning("farm render avatar rejected; using default")
|
||||
@@ -179,26 +207,44 @@ class FarmRenderer:
|
||||
|
||||
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",
|
||||
|
||||
def paste_asset(name, position):
|
||||
original = self.assets.metadata["images"][name]["size"]
|
||||
image = self.assets.image(
|
||||
name, tuple(max(1, s(n)) for n in original),
|
||||
)
|
||||
canvas.paste(image, tuple(s(n) for n in position))
|
||||
|
||||
paste_asset("background/userInfo.png", (100, 30))
|
||||
canvas.paste(
|
||||
self._avatar(avatar, (max(1, s(140)), max(1, s(150)))),
|
||||
(s(125), s(85)),
|
||||
)
|
||||
paste_asset("background/frame/frame.png", (75, 44))
|
||||
account = scene.account
|
||||
level = account.level
|
||||
current = account.experience - 200 * level * (level + 1) // 2
|
||||
next_total = 200 * (level + 1) * (level + 2) // 2
|
||||
progress = min(247, max(0, current * 247 // next_total))
|
||||
if progress:
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (292, 164, 292 + progress, 189)),
|
||||
(171, 194, 41),
|
||||
)
|
||||
lines = (
|
||||
(account.name, (300, 92), 24, (77, 35, 4), 330),
|
||||
(f"{current} / {next_total}", (365, 164), 20,
|
||||
(102, 120, 19), 165),
|
||||
(str(level), (570, 158), 32, (214, 111, 1), 60),
|
||||
(str(account.point), (330, 211), 24, (253, 253, 253), 100),
|
||||
(str(account.vip_point), (570, 211), 24, (253, 253, 253), 40),
|
||||
)
|
||||
for value, position, original_size, color, width in lines:
|
||||
size = max(1, s(original_size))
|
||||
text = canvas.fit_text(
|
||||
value, self.font_path, size, max(1, s(width)),
|
||||
)
|
||||
canvas.text(
|
||||
tuple(s(n) for n in position), text,
|
||||
self.font_path, size, color,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"source_url": "https://game.qqnc.qq.com/happyfarm/unity/Assets/AssetBundles/WebGL/mainscene_053483f584f07436ac0569e2f4f7ed01",
|
||||
"source_sha256": "3766696845401ddcc877b304db34c9c3ccf5f8e92cad1a47ddbf7defcedf6362",
|
||||
"extractor": "UnityPy 1.25.4 Sprite.image; full sprite rect checked",
|
||||
"images": {
|
||||
"pest/pest23.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest53.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d823df2c2b1d9b721a2727e8e60f496aa7791942b20ff894569819b47dfe7ce4"
|
||||
},
|
||||
"pest/pest63.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14"
|
||||
},
|
||||
"pest/pest37.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "bb92d5687eb73be0a8bf1d9ae8c2aac5e67dcab6e5184d19e212b5931705c6b5"
|
||||
},
|
||||
"pest/pest75.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "51e1142e2f4dd489cf392b0b25d2d8c04c3801b6d149254e7b7df7c86155b5e2"
|
||||
},
|
||||
"pest/pest15.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "2927464be009a916862457a9dd1cf272e2face4809c8489496b8ca63f1064036"
|
||||
},
|
||||
"pest/pest13.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "063d58763c1c06bf5cf88e73b946dbe9d35dc52709057911247d29528bd80346"
|
||||
},
|
||||
"pest/pest41.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fa5b7979e0d905454ddd515751e53646614680e6d29f850bdea40957bc6fbeb1"
|
||||
},
|
||||
"pest/pest55.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest7.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "e600a52be8e2aeb9ba01ad3b595fe9badb05a1987c2ec98b8d8bd6fe6fd73a65"
|
||||
},
|
||||
"pest/pest35.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d26b7c64ed82266c23ac90b5a8e7fd5996b15a6a65a9d4893f0f4a01c1facb16"
|
||||
},
|
||||
"pest/pest51.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "3eeb2ef89514543ff616a51db2ad4a54da6fa67d3e573f3cda2b63d376d91d04"
|
||||
},
|
||||
"pest/pest39.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376"
|
||||
},
|
||||
"pest/pest25.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "17752162847dd22b45b041072dde51d9761f24179682794c14db4fe3a21b7843"
|
||||
},
|
||||
"pest/pest57.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "526eca9a538b3a6acd8f9ef9a358a8df98d2c6cf29667e647b6fb3df5110f5d6"
|
||||
},
|
||||
"pest/pest1.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642"
|
||||
},
|
||||
"pest/pest69.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "cc1fb7b27adcdab8bbb3872ab28dab66aee832b02ed36e588f214e03d19cbc8b"
|
||||
},
|
||||
"pest/pest5.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5ce899cd3c42a7919dea0b43686f261a94e80af9d122337b3c01c01df920a9b0"
|
||||
},
|
||||
"webgl/wither.png": {
|
||||
"size": [
|
||||
209,
|
||||
92
|
||||
],
|
||||
"sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472"
|
||||
},
|
||||
"pest/pest9.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "cefc8172fd7f0a6137dd5c67c43ba6b165ee37faf58893ff8f37dbff2cc08d22"
|
||||
},
|
||||
"pest/pest33.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "eca7d05f269a417553ae38da5289caea9a8f4ba5bbc592a9477bc5dfa6a6ec1d"
|
||||
},
|
||||
"pest/pest3.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "a88d9a45472167ad34af98e4a3e6db0bc02c3b7f3c441aef44908a97c136e37f"
|
||||
},
|
||||
"pest/pest47.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest67.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest65.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest29.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest31.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest59.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
|
||||
},
|
||||
"pest/pest43.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d7f1eea15102154c623d82cd148126f34e82ef40602d7cae78a6e3ee94970b93"
|
||||
},
|
||||
"pest/pest21.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "863e604de7a63a6bc82d240b72fc0d16964a3536557400fd7e9e4251179efc0b"
|
||||
},
|
||||
"pest/pest11.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "ea87a9e6ede36b36d492d641abe9c881ce57aecf130ebb5e9928276588923a52"
|
||||
},
|
||||
"pest/pest17.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest45.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "c84fbc9a11d7e4a0e7ddff301d4dd79f75cc1882c76776fa617d189eec8f5eb4"
|
||||
},
|
||||
"pest/pest73.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "d0d22707ce214b6c78b74315daa33f93c97e5c20368a002a094cd12964437726"
|
||||
},
|
||||
"webgl/seed.png": {
|
||||
"size": [
|
||||
69,
|
||||
30
|
||||
],
|
||||
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609"
|
||||
},
|
||||
"pest/pest27.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5827f8338358373507351449a8e82063c1683c3a0c4caaadf6e679c20306f376"
|
||||
},
|
||||
"pest/pest19.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest61.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "6603a10d50bd88bb1032fd22fe482b0c9ba433ba25967408d64e07b47a6c9b64"
|
||||
},
|
||||
"pest/pest49.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
|
||||
},
|
||||
"pest/pest71.png": {
|
||||
"size": [
|
||||
44,
|
||||
60
|
||||
],
|
||||
"sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6"
|
||||
}
|
||||
},
|
||||
"pest": {
|
||||
"frames": [
|
||||
"pest/pest1.png",
|
||||
"pest/pest3.png",
|
||||
"pest/pest5.png",
|
||||
"pest/pest7.png",
|
||||
"pest/pest9.png",
|
||||
"pest/pest11.png",
|
||||
"pest/pest13.png",
|
||||
"pest/pest15.png",
|
||||
"pest/pest17.png",
|
||||
"pest/pest19.png",
|
||||
"pest/pest21.png",
|
||||
"pest/pest23.png",
|
||||
"pest/pest25.png",
|
||||
"pest/pest27.png",
|
||||
"pest/pest29.png",
|
||||
"pest/pest31.png",
|
||||
"pest/pest33.png",
|
||||
"pest/pest35.png",
|
||||
"pest/pest37.png",
|
||||
"pest/pest39.png",
|
||||
"pest/pest41.png",
|
||||
"pest/pest43.png",
|
||||
"pest/pest45.png",
|
||||
"pest/pest47.png",
|
||||
"pest/pest49.png",
|
||||
"pest/pest51.png",
|
||||
"pest/pest53.png",
|
||||
"pest/pest55.png",
|
||||
"pest/pest57.png",
|
||||
"pest/pest59.png",
|
||||
"pest/pest61.png",
|
||||
"pest/pest63.png",
|
||||
"pest/pest65.png",
|
||||
"pest/pest67.png",
|
||||
"pest/pest69.png",
|
||||
"pest/pest71.png",
|
||||
"pest/pest73.png",
|
||||
"pest/pest75.png"
|
||||
],
|
||||
"sample_rate": 24,
|
||||
"loop": true,
|
||||
"display_size": [
|
||||
22,
|
||||
30
|
||||
],
|
||||
"pivot": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"positions": [
|
||||
[
|
||||
-26,
|
||||
13
|
||||
],
|
||||
[
|
||||
-5,
|
||||
42
|
||||
],
|
||||
[
|
||||
24,
|
||||
24
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,238 @@
|
||||
import json
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, replace
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from farm_core import Catalogue, FarmError, FarmService, Identity, SQLiteStore
|
||||
from farm_core.domain import Plant, SchemaError
|
||||
from farm_core.storage import APPLICATION_ID, SCHEMA, SCHEMA_VERSION
|
||||
|
||||
|
||||
def plant(plant_id=1, name="萝卜", **changes):
|
||||
return replace(
|
||||
Plant(plant_id, name, 0, 10, False, 0, 2, 3, 4, 2, 1, 1),
|
||||
**changes,
|
||||
)
|
||||
|
||||
|
||||
class CropIdentityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.temp.name) / "farm.db"
|
||||
self.store = SQLiteStore(self.path)
|
||||
await self.store.initialize()
|
||||
self.now = 1000
|
||||
self.identity = Identity("test", "realm", "qq", "user")
|
||||
self.service = FarmService(
|
||||
self.store, Catalogue([plant()]), lambda: self.now,
|
||||
)
|
||||
await self.service.register(self.identity, "农场", "register")
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.store.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
async def test_same_name_requires_id_and_keeps_inventory_separate(self):
|
||||
self.service.catalogue = Catalogue([plant(), plant(2)])
|
||||
with self.assertRaisesRegex(FarmError, "ambiguous_plant"):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "ambiguous")
|
||||
await self.service.buy(self.identity, "#1", 1, "first")
|
||||
await self.service.buy(self.identity, "#2", 2, "second")
|
||||
items = (await self.service.snapshot(self.identity)).inventory
|
||||
self.assertEqual(
|
||||
[(i.plant_id, i.seeds) for i in items], [(1, 1), (2, 2)],
|
||||
)
|
||||
await self.service.sow(self.identity, "#1", 1, "sow")
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.lock(self.identity, "#1", True, "lock")
|
||||
with self.assertRaisesRegex(FarmError, "plant_locked"):
|
||||
await self.service.sell(self.identity, "#1", None, "locked")
|
||||
await self.service.lock(self.identity, "#1", False, "unlock")
|
||||
sale = await self.service.sell(self.identity, None, None, "sale")
|
||||
self.assertEqual(sale.data["proceeds"], 12)
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(self.identity)).inventory[1].seeds, 2,
|
||||
)
|
||||
|
||||
async def test_rename_preserves_seeds_crops_and_lock(self):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.service.catalogue = Catalogue([plant(name="新萝卜")])
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.lock(self.identity, "新萝卜", True, "lock")
|
||||
item = (await self.service.snapshot(self.identity)).inventory[0]
|
||||
self.assertEqual((item.plant_id, item.seeds, item.crops), (1, 1, 3))
|
||||
self.assertTrue(item.locked)
|
||||
await self.service.lock(self.identity, "新萝卜", False, "unlock")
|
||||
sale = await self.service.sell(self.identity, "新萝卜", None, "sale")
|
||||
self.assertEqual(sale.data["proceeds"], 12)
|
||||
|
||||
async def test_purchase_rules_reject_without_debit(self):
|
||||
for index, changes in enumerate((
|
||||
{"purchasable": False}, {"enabled": False}, {"crop_type": 11},
|
||||
)):
|
||||
self.service.catalogue = Catalogue([plant(**changes)])
|
||||
with self.assertRaises(FarmError):
|
||||
await self.service.buy(self.identity, "萝卜", 1, str(index))
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 500)
|
||||
self.assertEqual(snapshot.inventory, ())
|
||||
|
||||
async def test_delisting_allows_sowing_but_disabled_blocks_it(self):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
self.service.catalogue = Catalogue([plant(purchasable=False)])
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.service.catalogue = Catalogue([plant(enabled=False)])
|
||||
with self.assertRaisesRegex(FarmError, "plant_disabled"):
|
||||
await self.service.sow(self.identity, "萝卜", 1, "disabled")
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.sell(self.identity, None, None, "sell")
|
||||
|
||||
|
||||
class CatalogueMetadataTests(unittest.TestCase):
|
||||
def test_local_identity_has_no_web_id(self):
|
||||
carrot = Catalogue.bundled().get("胡萝卜")
|
||||
self.assertEqual(carrot.plant_id, 1)
|
||||
self.assertNotIn("source_crop_id", asdict(carrot))
|
||||
|
||||
def test_legacy_purchase_flag_is_preserved(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
data = json.loads(
|
||||
(root / "src/farm_core/data/plants.json").read_bytes()
|
||||
)
|
||||
for row in data["plants"]:
|
||||
self.assertEqual(
|
||||
Plant.from_legacy(row).purchasable, bool(row["isBuy"]),
|
||||
)
|
||||
|
||||
|
||||
class MigrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def create_v1(self, path, unknown=False, version=1):
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
for statement in SCHEMA:
|
||||
if version == 1 and statement.startswith("CREATE TABLE inventory("):
|
||||
statement = """CREATE TABLE inventory(
|
||||
identity TEXT, name TEXT, seeds INTEGER,
|
||||
crops INTEGER, locked INTEGER,
|
||||
PRIMARY KEY(identity, name))"""
|
||||
if statement.startswith("CREATE TABLE plots("):
|
||||
statement = """CREATE TABLE plots(
|
||||
identity TEXT, slot INTEGER, plant TEXT,
|
||||
mature_at INTEGER, harvest_count INTEGER,
|
||||
yield_bonus INTEGER, experience_bonus INTEGER,
|
||||
PRIMARY KEY(identity, slot))"""
|
||||
db.execute(statement)
|
||||
db.execute(f"PRAGMA application_id={APPLICATION_ID}")
|
||||
db.execute(f"PRAGMA user_version={version}")
|
||||
db.execute("INSERT INTO accounts VALUES ('user', '农场', 123, 4, 5)")
|
||||
name = "不认识的作物" if unknown else "萝卜"
|
||||
if version == 1:
|
||||
db.execute(
|
||||
"INSERT INTO inventory VALUES ('user', ?, 7, 9, 1)",
|
||||
(name,),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"INSERT INTO inventory VALUES ('user', 1, ?, 7, 9, 1)",
|
||||
(name,),
|
||||
)
|
||||
snapshot = asdict(plant())
|
||||
for key in ("purchasable", "enabled", "crop_type"):
|
||||
snapshot.pop(key)
|
||||
db.execute(
|
||||
"INSERT INTO plots VALUES ('user', 1, ?, 1234, 0, 0, 0)",
|
||||
(json.dumps(snapshot),),
|
||||
)
|
||||
|
||||
async def test_v1_migration_preserves_inventory_and_is_repeatable(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path)
|
||||
store = SQLiteStore(path)
|
||||
await store.initialize(catalogue=Catalogue([plant()]))
|
||||
await store.close()
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0],
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT plant_id, name, seeds, crops, locked FROM inventory"
|
||||
).fetchone()
|
||||
self.assertEqual(row, (1, "萝卜", 7, 9, 1))
|
||||
self.assertEqual(
|
||||
db.execute("SELECT point FROM accounts").fetchone()[0], 123,
|
||||
)
|
||||
self.assertEqual(
|
||||
db.execute("SELECT mature_at FROM plots").fetchone()[0],
|
||||
1234,
|
||||
)
|
||||
snapshot = json.loads(db.execute(
|
||||
"SELECT plant FROM plots"
|
||||
).fetchone()[0])
|
||||
self.assertEqual(Plant(**snapshot).plant_id, 1)
|
||||
|
||||
async def test_v2_migration_keeps_existing_season_without_retroactive_pests(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path, version=2)
|
||||
store = SQLiteStore(path)
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(db.execute(
|
||||
"PRAGMA user_version"
|
||||
).fetchone()[0], SCHEMA_VERSION)
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT mature_at, pest_count, pest_spawned, "
|
||||
"growth_remaining, maturity_pests FROM plots"
|
||||
).fetchone(), (1234, 0, 0, None, 0))
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT point FROM accounts"
|
||||
).fetchone()[0], 123)
|
||||
|
||||
async def test_unknown_v1_crop_rolls_back_instead_of_losing_stock(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path, unknown=True)
|
||||
store = SQLiteStore(path)
|
||||
with self.assertRaises(SchemaError):
|
||||
await store.initialize(catalogue=Catalogue([plant()]))
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0], 1,
|
||||
)
|
||||
self.assertEqual(
|
||||
db.execute("SELECT seeds, crops FROM inventory").fetchone(),
|
||||
(7, 9),
|
||||
)
|
||||
|
||||
async def test_ambiguous_v1_names_abort_without_partial_migration(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path)
|
||||
store = SQLiteStore(path)
|
||||
with self.assertRaisesRegex(
|
||||
SchemaError, "inventory_identity_unresolved",
|
||||
):
|
||||
await store.initialize(
|
||||
catalogue=Catalogue([plant(), plant(2)]),
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0], 1,
|
||||
)
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE name='inventory_v2'"
|
||||
).fetchall(), [])
|
||||
@@ -0,0 +1,160 @@
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from farm_core import Catalogue, Identity
|
||||
from farm_core.domain import Account, Plot, Snapshot
|
||||
from farm_render import FarmRenderer, RenderOptions
|
||||
from farm_render.canvas import Canvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from ci_font import choose_font
|
||||
|
||||
|
||||
class CropPositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.renderer = FarmRenderer(choose_font())
|
||||
self.who = Identity("test", "local", "qq", "one")
|
||||
|
||||
def scene(self, name, now):
|
||||
plant = Catalogue.bundled().get(name)
|
||||
return self.renderer.scene(self.who, Snapshot(
|
||||
Account("公式预览", 500, 0, 0), (),
|
||||
(Plot(1, plant, plant.growth_seconds),),
|
||||
), now)
|
||||
|
||||
def test_growth_stages_use_full_canvas_bottom_for_every_crop(self):
|
||||
for name in ("草莓", "橙子", "樱桃", "茄子", "黄瓜"):
|
||||
phases = self.renderer.assets.metadata["plants"][name]["phase"]
|
||||
for now in dict.fromkeys(map(int, phases.split(","))):
|
||||
for width in (1024, 1536, 3072):
|
||||
with self.subTest(name=name, now=now, width=width):
|
||||
scene = self.scene(name, now)
|
||||
canvas = Canvas.new((width, round(1872*width/3072)))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._plants(
|
||||
canvas, scene, RenderOptions(width=width),
|
||||
)
|
||||
image, xy = paste.call_args_list[0].args
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
sw, sh = meta["soil_size"]
|
||||
scale = width / 3072
|
||||
self.assertLessEqual(abs(
|
||||
xy[0] + image.width / 2
|
||||
- (pos["x"] + sw/2) * scale
|
||||
), 1)
|
||||
self.assertLessEqual(abs(
|
||||
xy[1] + image.height
|
||||
- (pos["y"] + sh) * scale
|
||||
), .5)
|
||||
native = meta["images"][scene.plots[0].sprite]["size"]
|
||||
self.assertEqual(image.size, tuple(
|
||||
max(1, round(n * .75 * sh / 79.5 * scale))
|
||||
for n in native
|
||||
))
|
||||
|
||||
def test_seed_and_wither_use_public_sprites_and_distinct_targets(self):
|
||||
seed = self.scene("草莓", 0)
|
||||
self.assertEqual(seed.plots[0].sprite, "webgl/seed.png")
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
dead = self.renderer.scene(self.who, Snapshot(
|
||||
seed.account, (), (Plot(1, plant, None, plant.crop),),
|
||||
), 999999)
|
||||
self.assertEqual(dead.plots[0].sprite, "webgl/wither.png")
|
||||
for scene, shift in ((seed, 0), (dead, 8)):
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._plants(canvas, scene, RenderOptions(width=3072))
|
||||
image, xy = paste.call_args_list[0].args
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
height = meta["soil_size"][1]
|
||||
self.assertLessEqual(abs(
|
||||
xy[1] + image.height - (pos["y"] + height/2
|
||||
+ shift*height/79.5)
|
||||
), .5)
|
||||
|
||||
def test_missing_sprite_keeps_safe_placeholder(self):
|
||||
scene = self.scene("草莓", 14400)
|
||||
self.renderer.assets.metadata["images"].pop(scene.plots[0].sprite)
|
||||
result = self.renderer.render(
|
||||
self.who, Snapshot(scene.account, (), (
|
||||
Plot(1, Catalogue.bundled().get("草莓"), 86400),
|
||||
)), 14400,
|
||||
)
|
||||
self.assertTrue(result.content)
|
||||
|
||||
def test_exported_configuration_has_no_manual_stage_anchors(self):
|
||||
meta = self.renderer.assets.metadata
|
||||
self.assertEqual(meta["placement"]["schema"], 2)
|
||||
self.assertTrue(all("stages" not in p for p in meta["plants"].values()))
|
||||
|
||||
def test_pest_count_changes_cache_and_pest_positions_use_land_center(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
clean = Snapshot(Account("虫害预览", 0, 0, 0), (), (
|
||||
Plot(1, plant, 86400),
|
||||
))
|
||||
infested = replace(clean, plots=(replace(
|
||||
clean.plots[0], pest_count=3, pest_spawned=3,
|
||||
),))
|
||||
first = self.renderer.render(self.who, clean, 40000)
|
||||
second = self.renderer.render(self.who, infested, 40000)
|
||||
self.assertFalse(second.cache_hit)
|
||||
self.assertNotEqual(first.content, second.content)
|
||||
scene = self.renderer.scene(self.who, infested, 40000)
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._pests(canvas, scene, RenderOptions(width=3072))
|
||||
self.assertEqual(paste.call_count, 3)
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
sw, sh = meta["soil_size"]
|
||||
unit = sh / 79.5
|
||||
for call, (x, y) in zip(paste.call_args_list,
|
||||
((-26, 13), (-5, 42), (24, 24))):
|
||||
image, xy = call.args
|
||||
self.assertLessEqual(abs(xy[0] + image.width/2
|
||||
- (pos["x"]+sw/2+x*unit)), .5)
|
||||
self.assertLessEqual(abs(xy[1] + image.height/2
|
||||
- (pos["y"]+sh/2-y*unit)), .5)
|
||||
self.assertEqual(len(meta["pest"]["frames"]), 38)
|
||||
|
||||
def test_pest_frames_are_varied_and_stable_across_renderers_and_sizes(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
data = Snapshot(Account("随机帧测试", 0, 0, 0), (), tuple(
|
||||
Plot(slot, plant, 86400, pest_count=3, pest_spawned=3)
|
||||
for slot in range(1, 6)
|
||||
))
|
||||
scene = self.renderer.scene(self.who, data, 40000)
|
||||
restarted = FarmRenderer(choose_font())
|
||||
self.assertEqual(scene, restarted.scene(self.who, data, 40001))
|
||||
frames = [frame for view in scene.plots for frame in view.pest_frames]
|
||||
self.assertGreater(len(set(frames)), 3)
|
||||
for view in scene.plots:
|
||||
self.assertEqual(len(set(view.pest_frames)), 3)
|
||||
for width in (1024, 1536):
|
||||
options = RenderOptions(width=width, format="PNG")
|
||||
first = self.renderer.render(self.who, data, 40000, options)
|
||||
cached = self.renderer.render(self.who, data, 40001, options)
|
||||
cold = restarted.render(self.who, data, 40001, options)
|
||||
self.assertTrue(cached.cache_hit)
|
||||
self.assertEqual(first.content, cached.content)
|
||||
self.assertEqual(first.content, cold.content)
|
||||
|
||||
def test_pest_frames_survive_partial_clear_and_change_for_next_season(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
plot = Plot(1, plant, 86400, pest_count=3, pest_spawned=3)
|
||||
data = Snapshot(Account("随机帧测试", 0, 0, 0), (), (plot,))
|
||||
initial = self.renderer.scene(self.who, data, 40000).plots[0]
|
||||
fewer = replace(data, plots=(replace(plot, pest_count=1),))
|
||||
remaining = self.renderer.scene(self.who, fewer, 40000).plots[0]
|
||||
self.assertEqual(remaining.pest_frames, initial.pest_frames[:1])
|
||||
next_season = replace(data, plots=(replace(
|
||||
plot, harvest_count=1, mature_at=plant.regrowth_seconds+100,
|
||||
),))
|
||||
changed = self.renderer.scene(self.who, next_season, 40000).plots[0]
|
||||
self.assertNotEqual(changed.pest_frames, initial.pest_frames)
|
||||
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
||||
from farm_core.domain import FarmError, Plant
|
||||
from farm_core.repository import SQLiteRepository
|
||||
from farm_core.pests import advance_plot
|
||||
|
||||
|
||||
class PestTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.temp.name) / "farm.db"
|
||||
self.store = SQLiteStore(self.path)
|
||||
await self.store.initialize()
|
||||
self.now = 1000
|
||||
self.plant = Plant(1, "虫害测试菜", 0, 1, False, 0, 20, 100,
|
||||
1, 14400, 2, 14400)
|
||||
self.service = FarmService(self.store, Catalogue([self.plant]),
|
||||
lambda: self.now)
|
||||
self.who = Identity("test", "local", "qq", "pests")
|
||||
await self.service.register(self.who, "测试农场", "register")
|
||||
await self.service.buy(self.who, "虫害测试菜", 1, "buy")
|
||||
await self.service.sow(self.who, "虫害测试菜", 1, "sow")
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.store.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
async def plot(self):
|
||||
return (await self.service.snapshot(self.who)).plots[0]
|
||||
|
||||
async def test_speed_integrates_each_pest_interval_and_offline_maturity(self):
|
||||
self.now = 4600
|
||||
first = await self.plot()
|
||||
self.assertEqual(first.pest_count, 1)
|
||||
self.assertEqual(first.growth_remaining, 1080000)
|
||||
self.now = 8200
|
||||
second = await self.plot()
|
||||
self.assertEqual(second.pest_count, 2)
|
||||
self.assertEqual(second.growth_remaining, 738000)
|
||||
self.now = 11800
|
||||
third = await self.plot()
|
||||
self.assertEqual(third.pest_count, 3)
|
||||
self.assertEqual(third.growth_remaining, 414000)
|
||||
self.now = 16670
|
||||
self.assertGreater((await self.plot()).growth_remaining, 0)
|
||||
self.now = 16671
|
||||
mature = await self.plot()
|
||||
self.assertEqual(mature.mature_at, 16671)
|
||||
self.assertEqual(mature.maturity_pests, 3)
|
||||
receipt = await self.service.harvest(self.who, "harvest")
|
||||
self.assertEqual(receipt.data["quantity"], 85)
|
||||
self.assertEqual(receipt.data["experience"], 20)
|
||||
self.assertEqual(receipt.data["harvested"][0]["pests"], 3)
|
||||
again = await self.plot()
|
||||
self.assertEqual(again.pest_count, 0)
|
||||
self.assertEqual(again.maturity_pests, 0)
|
||||
|
||||
async def test_clear_before_maturity_restores_rate_but_not_lost_time(self):
|
||||
self.now = 6400
|
||||
receipt = await self.service.clear_pests(self.who, "clear")
|
||||
self.assertEqual(receipt.data["quantity"], 1)
|
||||
cleared = await self.plot()
|
||||
self.assertEqual(cleared.pest_count, 0)
|
||||
self.assertEqual(cleared.growth_remaining, 909000)
|
||||
self.assertEqual(cleared.pest_spawned, 1)
|
||||
self.now = 8200
|
||||
self.assertEqual((await self.plot()).pest_count, 1)
|
||||
repeated = await self.service.clear_pests(self.who, "clear")
|
||||
self.assertEqual(repeated, receipt)
|
||||
self.assertEqual((await self.plot()).pest_count, 1)
|
||||
|
||||
async def test_clear_after_maturity_cannot_erase_season_penalty(self):
|
||||
self.now = 20000
|
||||
await self.service.clear_pests(self.who, "late-clear")
|
||||
mature = await self.plot()
|
||||
self.assertEqual(mature.pest_count, 0)
|
||||
self.assertEqual(mature.maturity_pests, 3)
|
||||
receipt = await self.service.harvest(self.who, "harvest")
|
||||
self.assertEqual(receipt.data["quantity"], 85)
|
||||
|
||||
async def test_reopen_preserves_cleared_progress_and_concurrent_clear(self):
|
||||
self.now = 6400
|
||||
results = await asyncio.gather(
|
||||
self.service.clear_pests(self.who, "clear-1"),
|
||||
self.service.clear_pests(self.who, "clear-2"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 1)
|
||||
self.assertTrue(any(isinstance(r, FarmError) for r in results))
|
||||
before = await self.plot()
|
||||
await self.store.close()
|
||||
await self.store.initialize()
|
||||
self.assertEqual(await self.plot(), before)
|
||||
with closing(sqlite3.connect(self.path)) as db:
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT pest_count, pest_spawned FROM plots WHERE slot=1"
|
||||
).fetchone(), (0, 1))
|
||||
|
||||
async def test_failed_clear_rolls_back_all_plot_updates(self):
|
||||
self.now = 6400
|
||||
before = await self.store.snapshot(self.who)
|
||||
original = SQLiteRepository.save_plot
|
||||
|
||||
def fail(repository, plot):
|
||||
original(repository, plot)
|
||||
raise RuntimeError("fixture failure")
|
||||
|
||||
with patch.object(SQLiteRepository, "save_plot", fail):
|
||||
with self.assertRaises(RuntimeError):
|
||||
await self.service.clear_pests(self.who, "failed-clear")
|
||||
self.assertEqual(await self.store.snapshot(self.who), before)
|
||||
|
||||
async def test_empty_clear_and_ripe_clear_reward_no_assets(self):
|
||||
with self.assertRaisesRegex(FarmError, "nothing_to_clear"):
|
||||
await self.service.clear_pests(self.who, "empty-clear")
|
||||
self.now = 4600
|
||||
before = await self.service.snapshot(self.who)
|
||||
await self.service.clear_pests(self.who, "clear")
|
||||
after = await self.service.snapshot(self.who)
|
||||
self.assertEqual(before.account, after.account)
|
||||
|
||||
async def test_projection_is_independent_of_query_frequency(self):
|
||||
initial = (await self.store.snapshot(self.who)).plots[0]
|
||||
direct = advance_plot(initial, 20000)
|
||||
split = initial
|
||||
for instant in (1001, 4599, 4600, 7000, 11800, 16671, 20000):
|
||||
split = advance_plot(split, instant)
|
||||
self.assertEqual(split, direct)
|
||||
self.assertEqual(direct.maturity_pests, 3)
|
||||
|
||||
async def test_growth_stage_uses_work_not_predicted_finish_time(self):
|
||||
import sys
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(root / "scripts"))
|
||||
from ci_font import choose_font
|
||||
from farm_render import FarmRenderer
|
||||
|
||||
renderer = FarmRenderer(choose_font())
|
||||
self.plant = replace(self.plant, name="草莓")
|
||||
self.service.catalogue = Catalogue([self.plant])
|
||||
stored = (await self.store.snapshot(self.who)).plots[0]
|
||||
self.now = 8200
|
||||
projected = advance_plot(replace(stored, plant=self.plant), self.now)
|
||||
snapshot = replace(await self.service.snapshot(self.who),
|
||||
plots=(projected,))
|
||||
view = renderer.scene(self.who, snapshot, self.now).plots[0]
|
||||
self.assertEqual(view.stage, 2)
|
||||
self.assertEqual(view.pest_count, 2)
|
||||
@@ -90,7 +90,7 @@ class RenderTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(view(dead, 999999).sprite, "webgl/wither.png")
|
||||
|
||||
def test_result_cache_visible_state_scope_and_time(self):
|
||||
plant = self.plant
|
||||
@@ -112,6 +112,63 @@ class RenderTests(unittest.TestCase):
|
||||
self.assertFalse(self.renderer.render(
|
||||
self.who, data, 0, avatar=b"changed",
|
||||
).cache_hit)
|
||||
changed = replace(data, account=replace(data.account, experience=200))
|
||||
self.assertFalse(self.renderer.render(self.who, changed, 0).cache_hit)
|
||||
|
||||
def test_legacy_panel_assets_avatar_size_and_layer_order(self):
|
||||
avatar = BytesIO()
|
||||
Image.new("RGB", (80, 40), "red").save(avatar, "PNG")
|
||||
scene = self.renderer.scene(self.who, snapshot(), 0)
|
||||
for width in (1024, 1536, 3072):
|
||||
options = RenderOptions(width=width)
|
||||
s = lambda n: round(n * width / 3072)
|
||||
actual = Canvas.new((width, s(1872)))
|
||||
expected = Canvas.new((width, s(1872)))
|
||||
name = "background/userInfo.png"
|
||||
size = self.renderer.assets.metadata["images"][name]["size"]
|
||||
expected.paste(self.renderer.assets.image(
|
||||
name, tuple(s(n) for n in size),
|
||||
), (s(100), s(30)))
|
||||
expected.paste(Image.new("RGBA", (s(140), s(150)), "red"),
|
||||
(s(125), s(85)))
|
||||
name = "background/frame/frame.png"
|
||||
size = self.renderer.assets.metadata["images"][name]["size"]
|
||||
expected.paste(self.renderer.assets.image(
|
||||
name, tuple(s(n) for n in size),
|
||||
), (s(75), s(44)))
|
||||
with patch.object(actual, "text"), patch.object(actual, "rectangle"):
|
||||
self.renderer._panel(actual, scene, options, avatar.getvalue())
|
||||
self.assertEqual(actual.image.tobytes(), expected.image.tobytes())
|
||||
|
||||
def test_legacy_panel_fields_progress_and_level_boundaries(self):
|
||||
options = RenderOptions(width=3072)
|
||||
for exp, level, current, threshold in (
|
||||
(0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 600),
|
||||
(599, 1, 399, 600), (600, 2, 0, 1200), (3420, 5, 420, 4200),
|
||||
):
|
||||
data = snapshot(name="农场", point=12580, vip_point=36,
|
||||
experience=exp)
|
||||
scene = self.renderer.scene(self.who, data, 0)
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "text") as text, patch.object(
|
||||
canvas, "rectangle",
|
||||
) as rectangle:
|
||||
self.renderer._panel(canvas, scene, options, None)
|
||||
values = [(call.args[0], call.args[1])
|
||||
for call in text.call_args_list]
|
||||
self.assertEqual(values, [
|
||||
((300, 92), "农场"),
|
||||
((365, 164), f"{current} / {threshold}"),
|
||||
((570, 158), str(level)),
|
||||
((330, 211), "12580"), ((570, 211), "36"),
|
||||
])
|
||||
progress = current * 247 // threshold
|
||||
if progress:
|
||||
rectangle.assert_called_once_with(
|
||||
(292, 164, 292 + progress, 189), (171, 194, 41),
|
||||
)
|
||||
else:
|
||||
rectangle.assert_not_called()
|
||||
|
||||
def test_lru_budget_and_shared_image_safety(self):
|
||||
cache = ByteLRU(10)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -58,6 +60,51 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("成熟", await self.call("harvest", message="harvest"))
|
||||
self.assertTrue((self.root / "data/farm-preview.log").exists())
|
||||
|
||||
async def test_shop_filters_and_numbered_commands(self):
|
||||
catalogue = self.runtime.service.catalogue
|
||||
carrot = catalogue.get("胡萝卜")
|
||||
self.runtime.service.catalogue = type(catalogue)([
|
||||
carrot,
|
||||
replace(carrot, plant_id=9001, purchasable=False, name="禁售菜"),
|
||||
replace(carrot, plant_id=9002, enabled=False, name="停用菜"),
|
||||
replace(carrot, plant_id=9003, crop_type=11, name="特殊道具"),
|
||||
])
|
||||
shop = await self.call("shop")
|
||||
self.assertIn("胡萝卜(#1)", shop)
|
||||
for name in ("禁售菜", "停用菜", "特殊道具"):
|
||||
self.assertNotIn(name, shop)
|
||||
await self.call("register")
|
||||
self.assertIn("已完成", await self.call("buy", "#1 1", "number"))
|
||||
self.assertIn("不出售", await self.call("buy", "禁售菜 1", "blocked"))
|
||||
self.assertIn("(#1)", await self.call("inventory"))
|
||||
|
||||
async def test_pest_command_clears_pests_and_preserves_growth(self):
|
||||
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
|
||||
self.assertIn("每只虫", await self.call("help"))
|
||||
self.runtime.service.clock = lambda: 1000
|
||||
await self.call("register")
|
||||
await self.call("buy", "白萝卜 1", "buy")
|
||||
await self.call("sow", "白萝卜 1", "sow")
|
||||
self.assertIn("没有虫", await self.call("clear_pests", message="empty"))
|
||||
duration = self.runtime.service.catalogue.get("白萝卜").growth_seconds
|
||||
self.runtime.service.clock = lambda: 1000 + max(3600, (duration+3)//4)
|
||||
self.assertIn("虫子1只", await self.call("inventory"))
|
||||
first = await self.call("clear_pests", message="clear")
|
||||
self.assertIn("除虫已完成", first)
|
||||
self.assertEqual(first, await self.call("clear_pests", message="clear"))
|
||||
self.assertIn("没有虫", await self.call("clear_pests", message="new"))
|
||||
|
||||
async def test_duplicate_name_reports_numbered_choice(self):
|
||||
catalogue = self.runtime.service.catalogue
|
||||
carrot = catalogue.get("胡萝卜")
|
||||
self.runtime.service.catalogue = type(catalogue)([
|
||||
carrot, replace(carrot, plant_id=9001),
|
||||
])
|
||||
await self.call("register")
|
||||
response = await self.call("buy", "胡萝卜 1", "ambiguous")
|
||||
self.assertIn("同名", response)
|
||||
self.assertIn("#编号", response)
|
||||
|
||||
async def test_missing_message_id_and_other_identity(self):
|
||||
self.assertIn("未执行", await self.call("register", message=None))
|
||||
await self.call("register")
|
||||
@@ -75,7 +122,9 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
("sow", "白萝卜 -2"),
|
||||
("lock", ""), ("farm", "unknown")):
|
||||
self.assertIsInstance(await self.call(action, arguments), str)
|
||||
other = self.module.Runtime(self.root / "without-font")
|
||||
other = self.module.Runtime(
|
||||
self.root / "without-font", self.root / "missing.ttf",
|
||||
)
|
||||
await other.start()
|
||||
try:
|
||||
response = await other.execute("farm", "", **self.context)
|
||||
@@ -83,6 +132,37 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_default_font_uses_host_resources_and_skips_broken_font(self):
|
||||
fonts = self.root / "host-fonts"
|
||||
fonts.mkdir()
|
||||
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
|
||||
shutil.copyfile(choose_font(), fonts / "msyh.ttf")
|
||||
other = self.module.Runtime(self.root / "default-font", font_dir=fonts)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertIsNotNone(other.rendering)
|
||||
self.assertEqual(
|
||||
other.rendering.renderer.font_path,
|
||||
(fonts / "msyh.ttf").resolve(),
|
||||
)
|
||||
await other.execute("register", "", **self.context)
|
||||
result = await other.execute("farm", "", **self.context)
|
||||
self.assertIsInstance(result, bytes)
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_explicit_font_overrides_host_defaults(self):
|
||||
explicit = Path(choose_font()).resolve()
|
||||
other = self.module.Runtime(
|
||||
self.root / "explicit-font", explicit,
|
||||
font_dir=self.root / "not-present",
|
||||
)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertEqual(other.rendering.renderer.font_path, explicit)
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_insufficient_funds_has_chinese_message_and_no_debit(self):
|
||||
await self.call("register")
|
||||
before = await self.call("inventory")
|
||||
@@ -119,6 +199,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
uninfo.Uninfo = type("Uninfo", (), {})
|
||||
paths = ModuleType("zhenxun.configs.path_config")
|
||||
paths.DATA_PATH = self.root
|
||||
paths.FONT_PATH = self.root / "fonts"
|
||||
messages = ModuleType("zhenxun.utils.message")
|
||||
sent = []
|
||||
|
||||
|
||||