fixed # 修复作物资源位置不对的历史遗留BUG

add # 新增作物会生虫的逻辑
This commit is contained in:
2026-10-19 17:25:25 +08:00
parent 24ae6102c0
commit 15646525cb
80 changed files with 793505 additions and 226 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -7
View File
@@ -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
+17 -2
View File
@@ -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。
+43
View File
@@ -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=自定义目录)`,真寻接入已传入目录。
账户、流水、历史操作回执和地块成熟时间不改写。
迁移提交后记录版本和行数日志,不记录玩家身份或库存明细。
仅在临时测试库验证迁移,不代表已升级部署中的玩家库。
本次不包含生长阶段、浇水虫害或网页数据导入器。
+66
View File
@@ -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。
+64
View File
@@ -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 在相同地块和相同缩放比例下比较。
预览同时给出旧居中算法与新落地锚点算法,观察茎根而非叶片或果实边缘。
自动测试校验跨阶段、跨分辨率的坐标变换;美术位置还需结合预览检查。
+70
View File
@@ -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 三种输出档位的作物阶段及虫子预览。
+71
View File
@@ -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匹配源码。未修改实现/正式规格、操作远端、真实玩家库或启动宿主。
+27
View File
@@ -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。不是通过真实聊天消息的发图验证。
- 菜单/网页插件管理不显示问题尚未定位,不能报告为已解决。
+101
View File
@@ -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
}
]
}
+2 -1
View File
@@ -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)
+46 -9
View File
@@ -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)
+25 -2
View File
@@ -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"
+83
View File
@@ -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()
+93
View File
@@ -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()
+84 -23
View File
@@ -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,
+18 -8
View File
@@ -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())
+43 -2
View File
@@ -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:
+67
View File
@@ -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,
)
+4 -4
View File
@@ -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: ...
+15 -1
View File
@@ -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)
+33 -19
View File
@@ -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:
+95 -7
View File
@@ -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:
Binary file not shown.
+9
View File
@@ -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"
}
+369
View File
@@ -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,
+29 -11
View File
@@ -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),
)
+2 -2
View File
@@ -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)
+79 -33
View File
@@ -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,
)
+354
View File
@@ -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
]
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+238
View File
@@ -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(), [])
+160
View File
@@ -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)
+156
View File
@@ -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)
+58 -1
View File
@@ -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)
+82 -1
View File
@@ -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 = []