add # 新增背景图的优化

This commit is contained in:
2026-10-20 10:39:49 +08:00
parent 54164aeb9c
commit 2761be6c3f
20 changed files with 1644 additions and 102 deletions
+5
View File
@@ -16,6 +16,9 @@ ENV/
/build/
/dist/
/wheelhouse/
*.whl
pip-delete-this-directory.txt
installed-files.txt
*.egg-info/
.eggs/
*.egg
@@ -72,6 +75,8 @@ Desktop.ini
/uploads/
/artifacts/
/.local/
/.cache/
/incoming/
**/farm_db/
**/farm_res/
*.log
+4
View File
@@ -74,6 +74,10 @@ now, options),统一 now 使用UTC秒整数。正常未注册/存储故障仍
## 阶段与缓存
四季建筑与背景已接入:用户发送“我的农场”时按北京时间月份选图,
蜂巢主题仅生成离线预览。建筑定位、缺图回退和生成方式见
`17-season-backgrounds.md`。
布局使用旧30块地坐标。新播种及新一季使用虫害工作量计算阶段,
未启用虫害的迁移前当前季仍从成熟时间和基础时长推导。
枯萎优先于成熟。作物采用完整画布底边中点统一公式,
+112 -96
View File
@@ -1,124 +1,140 @@
# 真寻农场预览版
<div align="center">
本文件随生成包分发;业务源码只在主仓库main_plugin_farm维护。
生成目标为Shu-Ying/zhenxun_plugin_farm的farm-preview分支,保留main。
# 真寻农场(zhenxun_plugin_farm)
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
</a>
<a href="https://www.python.org">
<img src="https://img.shields.io/badge/Python-3.10%2B-blue" alt="Python">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json" alt="ruff">
</a>
<a href="https://nonebot.dev/">
<img src="https://img.shields.io/badge/Nonebot-2.0.0%2B-black" alt="Nonebot">
</a>
<a href="https://github.com/zhenxun-org/zhenxun_bot">
<img src="https://img.shields.io/badge/zhenxun-0.2.4%2B-%23ECD9D3" alt="Python">
</a>
</p>
preview.3恢复旧版信息底板和头像框、昵称、经验进度、等级及两种货币。
按用户确认校正原素材上的字段错位,保留旧版经验计算口径;点券显示
真实余额。仍使用目标尺寸绘制、一次快照查询及有界缓存。
<p align="center">
## 安装前
[![tencent-qq](https://img.shields.io/badge/%E7%BE%A4-%E7%9C%9F%E5%AF%BB%E5%86%9C%E5%9C%BA%E6%B5%8B%E8%AF%95-%23FF99CC
)](https://qm.qq.com/q/7hsOD4rOw2)
此版沿用“开通农场”“我的农场”等原指令,没有测试前缀。请先停止
机器人,并停用旧农场插件,避免同名指令被旧版抢先处理。仅在菜单
禁用旧版不一定阻止其导入;建议把旧插件目录备份到插件扫描目录之外。
安装脚本不会自动禁用、删除或覆盖旧版。
</p>
预览数据默认位于真寻DATA_PATH/farm_preview/farm-preview.sqlite3。
不读取旧版数据库,不迁移真实玩家资产;同平台、同机器人内共享农场,
不同机器人/平台分开。不要将预览数据路径指向旧版数据库。
你是说可以种地对吧🤔?
## 主仓库本地生成
</div>
```text
python scripts/build_zhenxun.py --output dist/zhenxun-preview
python scripts/verify_zhenxun.py dist/zhenxun-preview/zhenxun_plugin_farm_preview-0.1.0-preview.1.zip
python scripts/install_zhenxun.py dist/zhenxun-preview/zhenxun_plugin_farm_preview-0.1.0-preview.1.zip --plugins-dir /path/to/zhenxun_bot/zhenxun/plugins
```
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [使用指令](#使用指令)
- [更新日志(详细):](#更新日志详细)
- [用户方面](#用户方面)
- [代码方面](#代码方面)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
必须使用真寻的Python环境安装包内requirements.txt;该依赖约束为
Pillow>=10.4,<11,不静默升级宿主到12。NoneBot、真寻和Uninfo由
现有宿主提供,不把通用NoneBot适配器或AstrBot代码一起分发。
---
如果安装目录已存在,脚本会拒绝覆盖。更新前停机器人、备份插件目录,
手动移走旧预览目录后再安装;数据目录位于插件外,不随插件删除。
## 农场界面
## 配置与指令
![农场界面](./images/farm-preview.png)
默认优先使用真寻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指定路径:
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
```dotenv
FARM_PREVIEW_FONT=C:/Windows/Fonts/msyh.ttc
FARM_PREVIEW_DEPLOYMENT=zhenxun-preview
FARM_PREVIEW_IMAGE_FORMAT=AUTO
```
---
Linux请提供合法可用的中文字体。显式配置优先,配置错误时不会静默
替换成其他字体。所有默认候选都不可用时,文本交易可用,图片命令
会提示配置;不捆绑Windows字体。可选FARM_PREVIEW_DATA_DIR显式设置
独立预览数据目录。AUTO默认JPEG以保守兼容真实平台,确认WebP支持后
可设WEBP,也支持JPEG/PNG。
## 使用指令
测试顺序(是否需要/前缀遵循宿主COMMAND_START配置):
| 指令 | 描述 | Tip |
| --- | --- | --- |
| @小真寻 开通农场 | 首次开通农场 | |
| 我的农场 | 你的农场 | |
| 农场详述 | 农场详细信息 | |
| 我的农场币 | 查询农场币 | |
| 种子商店 [筛选关键字] [页数] or [页数] | 查看种子商店 | 当第一个参数为非整数时,会默认进入筛选状态。页数不填默认为1 |
| 购买种子 [种子名称] [数量] | 购买种子 | 数量不填默认为1 |
| 我的种子 | 查询仓库种子 | |
| 播种 [种子名称] [数量] | 播种种子 | 数量不填默认将最大可能播种 |
| 收获 | 收获成熟作物 | |
| 铲除 | 铲除荒废作物 | |
| 我的作物 | 你的作物 | |
| 出售作物 [作物名称] [数量] | 从仓库里向系统售卖作物 | 不填写作物名将售卖仓库种全部作物 填作物名不填数量将指定作物全部出售 |
| 偷菜 @美波理 | 偷别人的菜 | 每人每天只能偷5次 |
| 购买农场币 | 将真寻金币兑换成农场币 | 兑换比例默认为1:2 手续费默认20% |
| 更改农场名 [新的农场名] | 改名 | 农场名称无法存储特殊字符 |
| 农场签到 | 签到 | 需要注意,该项会从服务器拉取签到数据 |
| 土地升级 [地块ID] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
```text
农场帮助
开通农场
我的农场
种子商店
购买种子 白萝卜 1
我的种子
播种 白萝卜 1
我的农场 高清
农场详述
收获
```
---
作物未成熟时收获会明确失败,不提供管理员快进命令或加币后门。
其他入口:铲除、我的作物、我的农场币、我的点券、作物加锁 作物、
作物解锁 作物、出售作物 作物 数量;不带参数出售全部未锁定产物。
也可发送“我的农场 buy-seed 白萝卜 1”等旧子命令(支持范围见帮助)。
不实现签到、偷菜、充值、兑换、升级和旧数据迁移,不能替代旧版全部功能。
## 更新日志[(详细)](./log/log.md):
用户方面
---
- 新增土地升级指令,快来将土地升级至红土地、黑土地、金土地吧
- 新增查漏补缺作物资源功能,自动更新最新作物和作物资源文件
- 定时更新签到文件、作物资源从00:30调整至04:30
- 修正了部分土地资源错误的情况
- 修正了部分文本信息错误的情况
重复事件通过稳定消息标识幂等;无法获取消息标识时写操作拒绝。
头像最多等待2秒,失败使用默认图;图片缓存与后台任务均有限额。
关键日志写入数据目录farm-preview.log,同时沿用宿主日志传播配置;
不记录真实玩家数据。
代码方面
---
- 修正部分事件连接机制
- 修正网络请求端口
## GitHub工作流
## 待办事宜 `Todo` 列表
先由你提交主仓库代码后,主仓库的Zhenxun preview工作流会检查并上传
Actions artifact。下载并解压附件,取其中插件ZIP安装;不需要令牌。
Windows/Linux、Pillow10.4/当前支持版属于CI矩阵,不代表本机已跑Linux。
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
若还需将生成内容写入发布仓库,主仓库手动Run workflow时勾选publish,
并配置受保护environment zhenxun-preview。该环境Secret名为
FARM_RELEASE_TOKEN,仅授权目标仓库Contents写入;生成内容含工作流,
相应令牌也须允许Workflows写入(GitHub App使用对应权限)。配置
环境审批及仅允许main分支部署。不把Token填进脚本、URL或输入参数。
脚本默认dry-run,只有--publish才访问GitHub;固定写farm-preview,
不更新main、不force、不创建Release。第一次创建预览分支,之后普通
提交更新。专用预览分支只放生成内容,不在那里手改业务或存储治理资料。
已有预览分支必须带本项目来源清单;若它已成为默认分支则拒绝发布,
并发期间分支发生变化也拒绝更新,避免覆盖其他提交。
用ZIP安装最直接,不能假设插件管理器支持任意分支地址。
---
产物内FARM-MANIFEST.json记录版本、源提交、dirty和每文件SHA256;
源有未提交修改会标dirty,禁止发布这种产物。本地测试允许dirty。
哈希用于内容一致性,不是签名或来源身份保证,请使用你信任的附件。
## 关于
## 验证边界与回退
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
本轮不会在真实机器人中安装/启动,也不会执行真实GitHub同步。真实
登录、权限、适配器消息格式和发图需要你部署后验证,不能把离线模拟
报告为线上成功。旧素材455个阶段引用缺图时显示问号,不会整图失败。
素材来均源于互联网,侵权请联系我删除
源码中ZIP时间、系统类型和文件权限固定;文本统一LF,以便跨平台
复现。构建后repository目录是发布仓库根,外层插件ZIP用于本地安装;
二者不要再叠套一层主仓库目录。
---
回退:停机器人,移走预览插件,恢复旧插件目录,再由你启动;旧版库
没有被本版更改。保留预览数据备份,不尝试让旧版打开新SQLite schema。
## 致谢
官方依据:[Actions附件](https://docs.github.com/en/actions/tutorials/store-and-share-data)、
[手动触发](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow)。
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) * 绝大多数的素材来源 *
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
- [ATTomato](https://github.com/ATTomatoo) *流泪偷码头.jpg*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
## 上游仓库
- [Client](https://github.com/Shu-Ying/main_plugin_farm)
+61
View File
@@ -0,0 +1,61 @@
# 四季背景与蜂巢预览
## 用户指令
`我的农场` 和 `我的农场 高清` 根据请求时刻的北京时间自动选择:
| 月份 | 主题 |
| --- | --- |
| 3~5 月 | 春季 spring |
| 6~8 月 | 夏季 summer |
| 9~11 月 | 秋季 autumn |
| 12~2 月 | 冬季 winter |
使用固定 UTC+8,不依赖宿主操作系统时区或额外时区数据包。
接入层只解析一次主题和当前时间,并将同一时间传给渲染服务。
主题参与底图缓存与成图缓存,跨季节不会命中上一季的图片。
服务无需重启即可在下一次请求时切换季节;没有后台定时广播。
按用户要求,蜂巢 bee 只生成离线预览,用户指令不支持蜂巢参数。
底层显式 RenderOptions(theme="bee") 供离线绘图使用;四季规则不会返回 bee。
## 建筑与定位
统一位置保存在 `src/farm_render/data/background_layout.json`。
以已完成对照的默认主题完整背景为参考,保留九个独立建筑及装饰。
配置保留每项来源依据:房屋原始变换链;矿洞和入口包含实例化行为推断;
其余为用户同意采用的截图反算位置,不能称为所有主题的官方原始配置。
各主题替换对应 PNG,使用相同底边中点与宽度,按本主题图片原始比例
计算高度。秋、冬房屋为 890×821,其余房屋为 890×981,底边保持一致,
不把较矮的房屋强行拉高。完整透明画布不裁剪,狗窝不额外镜像。
码头属于前景,在作物和虫子之后叠加,其余装饰绘入缓存底图。
## 缺失素材的明确回退
- 春、夏没有对应邮箱:使用默认邮箱。
- 秋季没有对应邮箱、加工坊标牌:使用默认图片。
- 五个主题没有单独的稻草人标牌:使用默认标牌。
导出的 metadata.decorations 为每项记录 source_theme、fallback 和 basis。
不丢失建筑,不用其他任意主题图片替代,也不修改旧参考素材。
主题图片均来自本地已有资源;没有为这些图片增加生成式美术内容。
许愿树、直升机等额外动态模块不包含在这九张装饰中。
## 打包与生成预览
`scripts/export_render_assets.py` 原样打包六套主题底图及现存独立 PNG,
并生成各主题九项定位数据。`--check` 验证可复现输出。
渲染器使用包内资源,不依赖旧目录或本机预览路径。
```text
python scripts/preview_seasons.py --output <输出目录>
```
输出五张 `background-主题.jpg` 完整底图、五张 `farm-主题-1536.png`
完整农场样图,以及 `all-themes.jpg` 总览。蜂巢仅存在于这类预览中。
底图预览为 3072×1872,农场样图为 1536×936。
本轮验证包含北京时间月份边界、缺图回退、房屋比例和落地点、
前景码头图层、主题缓存隔离、普通/高清 QQ 指令选图。
未部署正在运行的机器人,也未验证真实 QQ 上传。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+1 -1
View File
@@ -7,7 +7,7 @@ from ._version import __version__
from .runtime import COMMANDS, HELP
__plugin_meta__ = PluginMetadata(
name="农场预览", description="共享农场核心的真寻预览版",
name="真寻农场", description="快乐的农场时光",
usage=HELP, type="application",
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
supported_adapters={"~onebot.v11", "~qq"},
+11 -2
View File
@@ -11,6 +11,7 @@ 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
from ._vendor.farm_render.seasons import season_for
logger = logging.getLogger(__name__)
COMMANDS = {
@@ -30,6 +31,7 @@ HELP = (
"作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n"
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
"我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n"
"本版使用独立预览数据库,不会读取旧版资产。"
)
ERRORS = {
@@ -182,11 +184,18 @@ class Runtime:
raise ValueError()
if not self.rendering:
return "请配置 FARM_PREVIEW_FONT 为可用中文字体路径。"
options = (RenderOptions.high(format=self.image_format)
if args else RenderOptions(format=self.image_format))
now = self.service.clock()
theme = season_for(now)
logger.info("farm preview seasonal theme=%s", theme)
options = (
RenderOptions.high(format=self.image_format, theme=theme)
if args else RenderOptions(format=self.image_format,
theme=theme)
)
picture = await self.rendering.my_farm(
self.service, who, options=options, avatar=avatar,
supported=("JPEG",),
now=now,
)
return picture.content
if op is None:
+1
View File
@@ -63,6 +63,7 @@ def build(root, output, version):
for name, source in {
"LICENSE": root / "LICENSE",
"README.md": root / "docs/11-zhenxun-preview.md",
"images/farm-preview.png": root / "images/farm-preview.png",
"verify_package.py": root / "scripts/verify_zhenxun.py",
".github/workflows/verify.yml": root / "release/zhenxun/verify.yml",
}.items():
+38 -1
View File
@@ -21,6 +21,41 @@ def apply_positions(root, metadata):
metadata["placement"] = document
def apply_decorations(root, metadata):
document = json.loads((
root / "src/farm_render/data/background_layout.json"
).read_bytes())
if (document["schema"] != 1 or document["canvas"] != metadata["canvas"]
or document["anchor"] != "full_canvas_bottom_center"):
raise ValueError("Unsupported background layout")
prefix = "background/decorateIcon/"
layouts = {}
for theme in ("default", "spring", "summer", "autumn", "winter", "bee"):
entries = []
for definition in document["buildings"]:
default = prefix + definition["sprite"]
sprite = default.replace("decorate_default_", f"decorate_{theme}_")
source_theme = theme
if sprite not in metadata["images"]:
sprite, source_theme = default, "default"
image = metadata["images"][sprite]
x, y, w, h = definition["rect"]
if w <= 0 or h <= 0:
raise ValueError("Invalid background rectangle")
height = w * image["size"][1] / image["size"][0]
if definition["layer"] not in ("background", "foreground"):
raise ValueError("Invalid decoration layer")
entries.append({
"name": definition["name"], "sprite": sprite,
"position": [x, y + h - height], "size": [w, height],
"layer": definition["layer"], "source_theme": source_theme,
"fallback": source_theme != theme,
"basis": definition["basis"],
})
layouts[theme] = entries
metadata["decorations"] = layouts
def export(root):
legacy = root / "legacy/zhenxun_plugin_farm"
source = legacy / "resource"
@@ -34,7 +69,8 @@ def export(root):
paths += [source / "background/frame/frame.png"]
paths += [source / (
f"background/decorateIcon/decorate_{theme}_1.jpg"
) for theme in ("default", "spring", "summer", "autumn", "winter")]
) for theme in ("default", "spring", "summer", "autumn", "winter", "bee")]
paths += list((source / "background/decorateIcon").glob("decorate_*.png"))
layout = json.loads((legacy / "config/soil.json").read_bytes())
catalogue = json.loads((
root / "src/farm_core/data/plants.json"
@@ -72,6 +108,7 @@ def export(root):
item.external_attr = 0o100644 << 16
bundle.writestr(item, content)
apply_positions(root, metadata)
apply_decorations(root, metadata)
return output.getvalue(), (
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n"
+59
View File
@@ -0,0 +1,59 @@
"""离线生成四季与蜂巢底图,以及同一农场状态的主题对照。"""
import argparse
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 Identity
from farm_render import FarmRenderer, RenderOptions
from farm_render.canvas import Canvas
from render_demo import demonstration
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--font", type=Path)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
renderer = FarmRenderer(args.font or choose_font())
who = Identity("preview", "local", "qq", "seasons")
snapshot, now = demonstration()
themes = (("spring", "春季"), ("summer", "夏季"), ("autumn", "秋季"),
("winter", "冬季"), ("bee", "蜂巢 · 仅预览"))
sheet = Image.new("RGB", (1536, 3 * 510), "#edf0eb")
draw = ImageDraw.Draw(sheet)
font = ImageFont.truetype(str(renderer.font_path), 24)
for index, (theme, label) in enumerate(themes):
options = RenderOptions(width=3072, theme=theme)
image = renderer.assets.image(
f"background/decorateIcon/decorate_{theme}_1.jpg", (3072, 1872),
)
canvas = Canvas(image)
renderer._decorations(canvas, options, "background")
renderer._decorations(canvas, options, "foreground")
canvas.image.convert("RGB").save(
args.output / f"background-{theme}.jpg", quality=92,
)
thumb = canvas.image.convert("RGB").resize((768, 468),
Image.Resampling.LANCZOS)
x, y = (index % 2) * 768, (index // 2) * 510
draw.text((x+15, y+8), label, font=font, fill="#203e2c")
sheet.paste(thumb, (x, y+40))
result = renderer.render(
who, snapshot, now,
RenderOptions.high(theme=theme, format="PNG"),
)
(args.output / f"farm-{theme}-1536.png").write_bytes(result.content)
sheet.save(args.output / "all-themes.jpg", quality=92)
print(f"Generated 5 backgrounds, 5 farm previews: {args.output}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -10,7 +10,7 @@ from urllib.request import Request, urlopen
from verify_zhenxun import read_package, verify_files
REPOSITORY = "Shu-Ying/zhenxun_plugin_farm"
BRANCH = "farm-preview"
BRANCH = "main"
def github(method, path, payload, token, allow_missing=False):
Binary file not shown.
@@ -0,0 +1,16 @@
{
"schema": 1,
"canvas": [3072, 1872],
"anchor": "full_canvas_bottom_center",
"buildings": [
{"name": "房屋", "sprite": "decorate_default_2.png", "rect": [1675.75, 158.36, 572.14, 630.64], "layer": "background", "basis": "prefab parent and image transforms"},
{"name": "矿洞", "sprite": "decorate_default_3.png", "rect": [1385.61, 256.06, 282.88, 282.88], "layer": "background", "basis": "prefab geometry and inferred runtime origin"},
{"name": "主题入口", "sprite": "decorate_default_4.png", "rect": [860.44, 423.68, 294.97, 249.07], "layer": "background", "basis": "prefab geometry and inferred runtime origin"},
{"name": "摊位", "sprite": "decorate_default_5.png", "rect": [514.20, 553.27, 298.97, 288.52], "layer": "background", "basis": "approved screenshot inverse projection"},
{"name": "狗窝", "sprite": "decorate_default_6.png", "rect": [277.95, 762.35, 305.25, 206.98], "layer": "background", "basis": "approved screenshot inverse projection"},
{"name": "码头", "sprite": "decorate_default_7.png", "rect": [1074.51, 1019.51, 355.42, 397.24], "layer": "foreground", "basis": "approved screenshot inverse projection"},
{"name": "邮箱", "sprite": "decorate_default_8.png", "rect": [790.18, 576.27, 104.54, 140.08], "layer": "background", "basis": "approved screenshot inverse projection"},
{"name": "加工坊标牌", "sprite": "decorate_default_9.png", "rect": [2347.77, 756.08, 269.70, 269.70], "layer": "background", "basis": "approved screenshot inverse projection"},
{"name": "稻草人标牌", "sprite": "decorate_default_10.png", "rect": [1120.51, 300.30, 238.34, 238.34], "layer": "background", "basis": "approved screenshot inverse projection"}
]
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ class RenderOptions:
if self.format not in ("AUTO", "WEBP", "JPEG", "PNG"):
raise ValueError("Unsupported output format")
if self.theme not in ("default", "spring", "summer", "autumn",
"winter"):
"winter", "bee"):
raise ValueError("Unsupported theme")
@classmethod
+12
View File
@@ -65,6 +65,7 @@ class FarmRenderer:
canvas = self._base(scene, options)
self._plants(canvas, scene, options)
self._pests(canvas, scene, options)
self._decorations(canvas, options, "foreground")
self._panel(canvas, scene, options, avatar)
composed = perf_counter()
content = canvas.encode(codec, options.quality, options.method)
@@ -101,6 +102,7 @@ class FarmRenderer:
f"decorate_{options.theme}_1.jpg", size,
)
canvas = Canvas(background)
self._decorations(canvas, options, "background")
soil_size = tuple(max(1, s(n)) for n in (
self.assets.metadata["soil_size"]
))
@@ -121,6 +123,16 @@ class FarmRenderer:
self.assets.cache.put(key, canvas.image, size[0] * size[1] * 4)
return canvas.copy()
def _decorations(self, canvas, options, layer):
s = lambda n: self._scale(n, options)
for item in self.assets.metadata["decorations"][options.theme]:
if item["layer"] != layer:
continue
image = self.assets.image(
item["sprite"], tuple(max(1, s(n)) for n in item["size"]),
)
canvas.paste(image, tuple(s(n) for n in item["position"]))
def _plants(self, canvas, scene, options):
s = lambda n: self._scale(n, options)
soil_w, soil_h = self.assets.metadata["soil_size"]
+18
View File
@@ -0,0 +1,18 @@
"""按北京时间选择四季主题;蜂巢主题只供显式离线预览。"""
from datetime import datetime, timedelta, timezone
from farm_core.domain import integer
BEIJING = timezone(timedelta(hours=8))
def season_for(now: int) -> str:
month = datetime.fromtimestamp(integer(now), BEIJING).month
if 3 <= month <= 5:
return "spring"
if 6 <= month <= 8:
return "summer"
if 9 <= month <= 11:
return "autumn"
return "winter"
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime, timezone
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from farm_core import Identity
from farm_core.domain import Account, Plot, Snapshot
from farm_render import FarmRenderer, RenderOptions
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from ci_font import choose_font
class SeasonBackgroundTests(unittest.TestCase):
def test_calendar_uses_beijing_time_and_month_boundaries(self):
from farm_render.seasons import season_for
cases = (
("2026-02-28T15:59:59", "winter"),
("2026-02-28T16:00:00", "spring"),
("2026-05-31T15:59:59", "spring"),
("2026-05-31T16:00:00", "summer"),
("2026-08-31T16:00:00", "autumn"),
("2026-11-30T16:00:00", "winter"),
("2026-12-31T16:00:00", "winter"),
)
for value, expected in cases:
utc = datetime.fromisoformat(value).replace(tzinfo=timezone.utc)
self.assertEqual(season_for(int(utc.timestamp())), expected)
def test_every_theme_has_nine_decorations_and_known_fallbacks(self):
renderer = FarmRenderer(choose_font())
layouts = renderer.assets.metadata["decorations"]
for theme in ("default", "spring", "summer", "autumn", "winter", "bee"):
self.assertEqual(len(layouts[theme]), 9)
self.assertEqual(RenderOptions(theme=theme).theme, theme)
for item in layouts[theme]:
self.assertIn(item["sprite"], renderer.assets.metadata["images"])
spring_mail = layouts["spring"][6]
autumn_workshop = layouts["autumn"][7]
self.assertEqual(spring_mail["source_theme"], "default")
self.assertEqual(autumn_workshop["source_theme"], "default")
def test_shorter_house_keeps_ground_baseline_and_aspect_ratio(self):
renderer = FarmRenderer(choose_font())
layouts = renderer.assets.metadata["decorations"]
spring, winter = layouts["spring"][0], layouts["winter"][0]
self.assertAlmostEqual(
spring["position"][1] + spring["size"][1],
winter["position"][1] + winter["size"][1], places=5,
)
self.assertAlmostEqual(winter["size"][0] / winter["size"][1],
890 / 821, places=5)
def test_dock_is_drawn_after_crops_and_theme_cache_is_separate(self):
renderer = FarmRenderer(choose_font())
who = Identity("test", "local", "qq", "seasons")
data = Snapshot(Account("四季测试", 500, 0, 0), (), (Plot(1),))
for theme in ("spring", "summer", "autumn", "winter", "bee"):
first = renderer.render(who, data, 0, RenderOptions(theme=theme))
warm = renderer.render(who, data, 1, RenderOptions(theme=theme))
self.assertFalse(first.cache_hit)
self.assertTrue(warm.cache_hit)
scene = renderer.scene(who, data, 0)
order = []
with patch.object(renderer, "_plants", side_effect=lambda *a:
order.append("plants")), patch.object(
renderer, "_decorations", side_effect=lambda c, o, layer:
order.append(layer)
):
renderer.render(who, data, 0, RenderOptions(width=1536))
self.assertLess(order.index("plants"), order.index("foreground"))
+13
View File
@@ -93,6 +93,19 @@ class ZhenxunPackageTests(unittest.TestCase):
self.assertEqual(sentinel.read_text(), "preserved")
self.assertTrue((target / "__init__.py").is_file())
def test_readme_image_is_in_repository_archive_and_manifest(self):
expected = (ROOT / "images/farm-preview.png").read_bytes()
relative = "images/farm-preview.png"
self.assertEqual(
(self.output / "repository" / relative).read_bytes(), expected,
)
manifest = verify_package(self.bundle)
self.assertIn(relative, manifest["files"])
with ZipFile(self.bundle) as archive:
self.assertEqual(archive.read(
f"zhenxun_plugin_farm_preview/{relative}",
), expected)
def test_tampering_extra_path_and_traversal_rejected(self):
for name in ("../outside.txt", "C:/escape.txt", "extra.txt",
"..\\outside.txt"):
+17
View File
@@ -78,6 +78,23 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("不出售", await self.call("buy", "禁售菜 1", "blocked"))
self.assertIn("(#1)", await self.call("inventory"))
async def test_farm_uses_calendar_theme_and_bee_is_not_a_user_option(self):
rendering = self.runtime.rendering
from unittest.mock import AsyncMock
rendering.my_farm = AsyncMock(return_value=SimpleNamespace(content=b"ok"))
for now, expected in (
(1767225600, "winter"), (1775001600, "spring"),
(1782864000, "summer"), (1790812800, "autumn"),
):
self.runtime.service.clock = lambda: now
self.assertEqual(await self.call("farm"), b"ok")
call = rendering.my_farm.call_args
self.assertEqual(call.kwargs["options"].theme, expected)
self.assertEqual(call.kwargs["now"], now)
self.assertEqual(await self.call("farm", "高清"), b"ok")
self.assertEqual(rendering.my_farm.call_args.kwargs["options"].width, 1536)
self.assertIn("参数有误", await self.call("farm", "蜂巢"))
async def test_pest_command_clears_pests_and_preserves_growth(self):
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
self.assertIn("每只虫", await self.call("help"))