add # 新增使用开源字体

This commit is contained in:
2026-10-26 11:34:00 +08:00
parent dc17f7f65c
commit 22cfddcf06
68 changed files with 1772 additions and 461 deletions
+13 -1
View File
@@ -49,7 +49,19 @@ jobs:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_astrbot.py --output dist/astrbot-preview
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate AstrBot help menu
run: >-
python scripts/preview_help.py --platform astrbot
--output runtime/help-images/images
- run: >-
python scripts/build_astrbot.py --output dist/astrbot-preview
--help-image runtime/help-images/images/help-astrbot.png
- name: Verify AstrBot package
shell: bash
run: |
+13 -1
View File
@@ -49,7 +49,19 @@ jobs:
python-version: '3.10'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- run: python scripts/build_nonebot.py --output dist/nonebot-preview
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate NoneBot help menu
run: >-
python scripts/preview_help.py --platform nonebot
--output runtime/help-images/images
- run: >-
python scripts/build_nonebot.py --output dist/nonebot-preview
--help-image runtime/help-images/images/help-nonebot.png
- run: >-
python scripts/verify_nonebot.py
--directory dist/nonebot-preview/repository
+11
View File
@@ -59,10 +59,21 @@ jobs:
python-version: '3.12'
- name: Verify external asset manifest
run: python scripts/check_asset_manifest.py
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
- name: Generate Zhenxun help menu
run: >-
python scripts/preview_help.py --platform zhenxun
--output runtime/help-images/images
- name: Build selected target only
run: >-
python scripts/build_zhenxun.py
--output dist/zhenxun-preview
--help-image runtime/help-images/images/help-zhenxun.png
- name: Copy standalone helper scripts
run: |
cp scripts/install_zhenxun.py dist/zhenxun-preview/
+6 -2
View File
@@ -35,16 +35,20 @@
- [真寻](https://github.com/Shu-Ying/zhenxun_plugin_farm)
- [Nonebot](https://github.com/Shu-Ying/nonebot_plugin_farm)
- [Astrbot](https://github.com/Shu-Ying/astrbot_plugin_farm)
`如果没有你需要的框架,那么请在本仓库提交issue吧,我会尽量迁移的(其实是用AI)`
`如果没有你需要的框架,那么请在本仓库提交issue吧,Codex会尽量迁移的`
## 开源协议
本项目采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./src/farm_render/data/OFL-Xiaolai.txt)。
## 致谢
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [Nonebot2](https://github.com/nonebot/nonebot2) *✨ 跨平台 Python 异步机器人框架 ✨*
- [zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) *最爱真寻的一集*
- [HibiKier](https://github.com/HibiKier) *阿咪为什么是神*
+4 -3
View File
@@ -22,9 +22,10 @@ NoneBot、AstrBot、真寻均可调用,不导入任何宿主对象。
| src/farm_render/renderer.py | 底图、作物、账户面板、成图缓存 |
| src/farm_render/service.py | 一次查询、后台执行、并发和取消管理 |
字体由部署方提供可合法使用的中文 TrueType/OpenType 文件路径。
示例使用本机已有微软雅黑;不会复制或发布 Windows 字体。Linux
应配置包含中文的字体;测试可通过 FARM_TEST_FONT 指定路径。当前
默认使用由 farm_service 分发并缓存的小赖字体(lxgw/kose-font v3.126),原版字体与
完整 SIL OFL 1.1 许可一同分发,版权声明见
`src/farm_render/data/OFL-Xiaolai.txt`。插件固定使用小赖字体,不提供
字体路径配置;开发测试可通过 FARM_TEST_FONT 指定路径。当前
实现校验字体可加载,不保证任意字体具备所有中文、Emoji 字形。
## 调用
+6 -3
View File
@@ -17,16 +17,19 @@ nonebot.load_plugin("nonebot_plugin_farm")
可在 `.env` 中设置:
```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO
FARM_IMAGE_QUALITY=75
FARM_DEPLOYMENT=nonebot
```
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
默认数据目录沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
日志写入该目录的 `farm-preview.log`。默认使用由 farm_service 分发并缓存的小赖字体;
字体不提供用户配置,旧的 `FARM_FONT` 不再读取;为避免旧存档被忽略,
继续兼容 `FARM_DATA_DIR`,未配置或为空时使用默认目录,不自动搬动数据库。
字体或素材下载失败时停止本次资源加载,保留文字功能,不自动重试;
重新加载插件后才会再次尝试。日志区分资源类别和安全错误原因。
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
+8 -1
View File
@@ -36,8 +36,15 @@
的 deployment、机器人 ID 和平台,不跨身份重复导入;转换规则、失败处理
和不支持字段的保留方式见 [旧数据迁移](05-migration.md)。
原有显式 `farm_preview_data_dir` 配置继续生效。仅从选定目录读取旧版
默认数据目录为真寻 `DATA_PATH/farm_db`;继续兼容已有的
`farm_preview_data_dir` 显式配置,避免升级后忽略旧存档。不自动搬动数据库。
仅从当前选用的数据目录读取旧版
`farm.db`;不会搜索其他目录。未发布预览版的 `farm-preview.sqlite3`
不自动改名或导入,预览数据由开发者手动处理。
素材或字体下载失败时停止本次资源加载,保留文字功能,不自动重试。
重新加载插件后才会再次尝试;关闭插件时清理已有资源。
日志区分配置、素材、字体与渲染器,记录 HTTP 状态、超时或校验失败等
安全分类,不记录 URL 或可能含凭据的异常正文。
本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。
+29 -1
View File
@@ -8,13 +8,41 @@
- 前 6 块由 `initial_plot_count: 6` 表示,不重复保存空条件记录。
新用户默认6块;可靠识别为初始3块的旧用户一次性补3块普通空地。
- 开垦时,`expansion.rows.ordinal` 对应补偿完成后的下一实际地块编号。
土地升级表仍只是规则数据,本次未接入土地升级。
土地升级已接入,具体行为见下文。
- 表外序号,以及材料、星钻替代支付、冷却和额外解锁条件没有在图中
给出。不能默认免费、默认通过,或套用最后一行。24 不是土地总上限。
- 已提供“开垦”(每次1块)和“开垦 条件”(查询等级及费用)。
等级检查、扣币及新增空地在同一事务内完成,重放同消息不重复扣款。
目前配置覆盖7~24号,25号起明确提示规则未配置,不扣币。
## 土地升级
- `土地升级 1 条件` 只查询1号地下一等级的条件;`土地升级 1` 扣费升级。
地块编号来自“地块编号”,支持各平台共用命令。
- 普通→红→黑→金,逐级升级。每种目标等级的费用序号为:
已达到该等级或更高等级的地块数加1,不等于地块编号。
升为更高等级不会使低等级费用重新降低;表外序号拒绝,不猜测费用。
- 红土地增产10%,黑土地增产20%,金土地增产28%、经验加成28%。
黑、金土地开始新一季时,生长时间取基础时长的80%,向上取整到秒。
沿用旧实现加成,不叠加到原加成上;普通地的新账号规则不变。
- 升级使正在生长的作物当季立即成熟,不发放收获物、不增加收获次数。
多季作物收获后才进入下一季。先结算升级时刻已发生的虫害,并冻结
当前虫数对应的减产;不清除已经偷取的数量或偷取记录。
- 已成熟作物保持成熟,不恢复虫害损失;枯萎的是作物而非土地,
升级不会使枯萎作物复活,仍需铲除。空地只改变土地等级。
- 扣币、土地加成、当前季成熟和操作回执在同一SQLite事务提交。
同一消息重复处理返回原收据;等级不足、余额不足或中途失败全部回滚。
不同消息各执行一次升级,因此不要重复发送不同的升级消息。
- schema v5 为地块增加land_level;v1~v4按顺序事务迁移。
旧版导入账号从已保存的legacy_rows.userSoil恢复明确等级;
没有旧版记录的地块默认普通,不按加成猜等级,不改资产与历史加成。
原始记录非法时中止并回滚迁移,保留旧库。
- 三种升级土地图从legacy原样导出为包内land-soils.zip,约57KiB。
保持既有外部assets.zip的内容、摘要和下载来源不变;绘图和底图缓存
包含土地等级,升级后不会复用普通地旧图。
实现和验证使用临时构造玩家;未迁移生产数据、部署或重启机器人。
## 新用户注册
注册事务创建 1~6 号空地,初始农场币仍为 500。重复注册不额外发地。
Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

+7 -4
View File
@@ -17,11 +17,14 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印
## 配置与使用
管理面板支持`font_path`(中文字体)、`data_dir`、`image_format`及
管理面板支持`resource_source`、`farm_service_url`、`image_format`及
`image_quality`(整数1~95,默认75)。质量修改后下一次请求生效;
其他配置修改后重载插件。默认数据目录为
`data/plugin_data/astrbot_plugin_farm`,数据库与日志位于该目录,卸载
插件前自行备份;更改目录不自动搬迁数据,旧数据不会被覆盖。
其他配置修改后重载插件。字体使用小赖字体,默认数据目录为
`data/plugin_data/astrbot_plugin_farm`。为兼容旧部署,继续读取已有的
`data_dir` 配置;留空使用默认目录。不会自动搬动数据库,卸载前自行备份。
素材或字体下载失败时停止本次资源加载,保留文字功能,不自动重试;
重新加载插件后才会再次尝试。日志按配置、素材、字体或渲染器区分失败阶段,
仅记录安全错误分类,不输出远程地址及异常正文。
按AstrBot配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
+6 -12
View File
@@ -1,4 +1,10 @@
{
"data_dir": {
"description": "兼容旧部署的数据目录",
"type": "string",
"default": "",
"hint": "保留原有自定义目录以继续读取旧存档;留空使用 AstrBot 默认目录。修改此项不会自动搬移数据库。"
},
"resource_source": {
"description": "资源获取地址",
"type": "string",
@@ -12,18 +18,6 @@
"default": "",
"hint": "始终独立配置,用于资源及后续活动服务;选择farm_service获取素材时必填HTTPS基础地址。"
},
"font_path": {
"description": "中文字体路径",
"type": "string",
"default": "",
"hint": "部署环境可访问的中文字体文件;留空时尝试系统字体,找不到则退回文字。"
},
"data_dir": {
"description": "农场数据目录",
"type": "string",
"default": "",
"hint": "留空使用data/plugin_data/astrbot_plugin_farm。修改后重载插件;不会自动搬迁数据。"
},
"image_format": {
"description": "图片格式",
"type": "string",
+1 -2
View File
@@ -33,8 +33,7 @@ class FarmPlugin(Star):
if image_format not in ('AUTO', 'JPEG', 'PNG'):
raise ValueError('image_format must be AUTO, JPEG or PNG')
self.runtime = Runtime(
data_dir, config.get('font_path') or None, 'astrbot',
image_format, font_setting='font_path',
data_dir, deployment='astrbot', image_format=image_format,
image_quality=config.get('image_quality', 75),
image_quality_provider=lambda: self.config.get('image_quality', 75),
framework='astrbot',
+2 -2
View File
@@ -21,8 +21,8 @@ config = get_plugin_config(Config)
driver = get_driver()
runtime = Runtime(
data_directory(store.get_plugin_data_dir(), config.farm_data_dir),
config.farm_font, config.farm_deployment, config.farm_image_format,
font_setting="FARM_FONT",
deployment=config.farm_deployment,
image_format=config.farm_image_format,
image_quality=config.farm_image_quality,
framework='nonebot',
resource_source=config.farm_resource_source,
+9 -8
View File
@@ -1,23 +1,20 @@
"""普通 NoneBot 配置;不依赖真寻配置系统。"""
from typing import Literal
from pathlib import Path
from typing import Literal, Optional
from pydantic import BaseModel, Field, conint
from pydantic import BaseModel, Field, conint, validator
class Config(BaseModel):
"""农场启动配置。"""
farm_data_dir: Optional[Path] = Field(
None, description="数据目录;默认兼容旧版 localstore 农场目录",
)
farm_font: Optional[Path] = Field(
None, description="中文字体路径;不可用时退回文字响应",
)
farm_deployment: str = Field(
"nonebot", min_length=1, description="农场存档部署标识",
)
farm_data_dir: Path | None = Field(
None, description='兼容旧部署的数据目录;未设置时使用平台默认目录',
)
farm_image_format: Literal["AUTO", "JPEG", "PNG"] = Field(
"AUTO", description="图片格式,AUTO 优先使用压缩 JPEG",
)
@@ -31,3 +28,7 @@ class Config(BaseModel):
farm_service_url: str = Field(
'', description='farm_service 地址,独立供资源及活动服务使用',
)
@validator('farm_data_dir', pre=True)
def empty_data_directory(cls, value):
return None if value == '' else value
+2 -2
View File
@@ -4,6 +4,6 @@ from pathlib import Path
def data_directory(base, configured=None):
if configured is not None:
return Path(configured)
if configured:
return Path(configured).expanduser()
return Path(base) / "nonebot_plugin_farm" / "farm_db"
+122 -54
View File
@@ -7,6 +7,8 @@ import json
import logging
from pathlib import Path
import shlex
from urllib.error import HTTPError, URLError
from zipfile import BadZipFile
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
from ._vendor.farm_core.domain import FarmError
@@ -20,6 +22,7 @@ from ._vendor.farm_core.presentation import (
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
from ._vendor.farm_render.seasons import season_for
from ._vendor.farm_render.bundle import ensure_assets, validate_options
from ._vendor.farm_render.font_bundle import ensure_font
from ._vendor.farm_render.shop import shop_page
from ._vendor.farm_render.details import detail_rows, details_text
from ._vendor.farm_render.warehouse import (
@@ -29,7 +32,36 @@ from ._vendor.farm_render.warehouse import (
logger = logging.getLogger(__name__)
COMMANDS = command_map('zhenxun')
HELP = help_text(command_specs('zhenxun'))
def resource_failure_reason(error):
"""只返回固定分类,不输出可能含 URL 或凭据的异常正文。"""
if isinstance(error, HTTPError):
return f'http_{error.code}'
if isinstance(error, TimeoutError):
return 'timeout'
if isinstance(error, URLError):
return ('timeout' if isinstance(error.reason, TimeoutError)
else 'network_error')
if isinstance(error, BadZipFile):
return 'invalid_archive'
if isinstance(error, ValueError):
known = {
'Resource SHA256 mismatch': 'integrity_mismatch',
'Resource size mismatch': 'integrity_mismatch',
'Font archive size or SHA256 mismatch': 'integrity_mismatch',
'Font member SHA256 mismatch': 'integrity_mismatch',
'Resource download limit or timeout': 'download_limit_or_timeout',
}
return known.get(str(error), 'validation_error')
if isinstance(error, OSError):
return 'io_error'
return 'unexpected_error'
ERRORS = {
'land_max_level': '该地块已是金土地,当前不支持继续升级。',
'unknown_plot': '地块不存在,请发送「地块编号」查询已开垦土地。',
'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,'
'请联系管理员核查注册或迁移记录。',
'land_compensation_capacity': '旧用户补3块后将超出当前30个绘图位置,'
@@ -76,7 +108,7 @@ 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", *, font_dir=None,
font_setting="FARM_PREVIEW_FONT", image_quality=75,
image_quality=75,
image_quality_provider=None, framework='zhenxun',
resource_source='Github', service_url='',
resource_options_provider=None):
@@ -92,7 +124,6 @@ class Runtime:
self.image_quality_provider = image_quality_provider
self.data_dir = Path(data_dir).resolve()
self.font_path = font_path
self.font_setting = font_setting
self.font_dir = Path(font_dir or "resources/font")
self.deployment = deployment
self.image_format = image_format
@@ -102,8 +133,13 @@ class Runtime:
self.started = False
self.log_handler = None
self.legacy_source = self.data_dir / 'farm.db'
self._resource_lock = asyncio.Lock()
self._closing = False
self._close_task = None
async def start(self):
if self._closing:
return
if self.started:
return
self.data_dir.mkdir(parents=True, exist_ok=True)
@@ -121,55 +157,70 @@ class Runtime:
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",
))
try:
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
)
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
)
except Exception:
# 不将下载URL、服务地址或可能带凭据的网络异常写入日志。
logger.error('farm assets unavailable; using text; '
'check resource source and service address')
bundle_path = None
for candidate in candidates if bundle_path is not None else ():
if not candidate.is_file():
continue
try:
self.rendering = RenderService(FarmRenderer(
candidate, bundle_path=bundle_path,
))
logger.info("farm preview font selected file=%s", candidate.name)
break
except (ValueError, OSError):
logger.warning("farm preview font candidate is not loadable")
if self.rendering is None:
logger.warning("farm preview font unavailable; text mode only")
await self._prepare_resources()
self.started = True
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
async def _prepare_resources(self):
async with self._resource_lock:
if self._closing or self.rendering is not None:
return
resource = 'configuration'
try:
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
)
validate_options(self.resource_source, self.service_url)
resource = 'assets'
bundle_path = await asyncio.to_thread(
ensure_assets, self.data_dir, self.resource_source,
self.service_url,
)
resource = 'font'
candidate = (Path(self.font_path) if self.font_path else
await asyncio.to_thread(
ensure_font, self.data_dir,
self.resource_source, self.service_url,
))
resource = 'renderer'
self.rendering = RenderService(FarmRenderer(
candidate, bundle_path=bundle_path,
))
logger.info('farm resources ready font=%s', candidate.name)
except Exception as error:
source = (self.resource_source if self.resource_source in (
'Github', 'Gitea', 'farm_service',
) else 'invalid')
logger.warning(
'farm resource unavailable resource=%s source=%s '
'reason=%s; loading stopped until plugin reload; '
'using text',
resource, source, resource_failure_reason(error),
)
async def close(self):
if self._close_task is None or self._close_task.done():
self._closing = True
self._close_task = asyncio.create_task(self._finish_close())
task = self._close_task
try:
if self.rendering:
await self.rendering.close()
# 调用方取消关闭等待时,清理仍独立运行。
await asyncio.shield(task)
finally:
if task.done():
self._close_task = None
async def _finish_close(self):
try:
async with self._resource_lock:
if self.rendering:
await self.rendering.close()
self.rendering = None
finally:
await self.store.close()
self.started = False
self._closing = False
if self.log_handler:
logging.getLogger(__package__).removeHandler(self.log_handler)
self.log_handler.close()
@@ -202,8 +253,7 @@ class Runtime:
args = shlex.split(arguments)
if action == "farm" and args:
aliases = {
"detail":"details", "my-point":"inventory",
"my-vipPoint":"inventory", "my-seed":"seeds",
"detail":"details", "my-seed":"seeds",
"my-plant":"crops", "seed-shop":"shop",
"buy-seed":"buy", "sowing":"sow", "harvest":"harvest",
"eradicate":"eradicate", "lock-plant":"lock",
@@ -229,14 +279,33 @@ class Runtime:
f"(当前{conditions['point']})\n"
'满足条件后发送:开垦'
)
if action == 'upgrade':
if len(args) not in (1, 2) or (len(args) == 2
and args[1] != '条件'):
return '请发送:土地升级 地块编号 或 土地升级 地块编号 条件'
slot = int(args[0])
if len(args) == 2:
condition = await self.service.upgrade_conditions(who, slot)
return (
f"地块{slot}:{condition['current_land']}→"
f"{condition['land_name']}(第{condition['ordinal']}块)\n"
f"所需等级:{condition['required_level']}"
f"(当前{condition['level']})\n"
f"所需农场币:{condition['coin_cost']}"
f"(当前{condition['point']})\n"
f'升级并使当前季成熟:土地升级 {slot}'
)
if op is None:
return '无法取得稳定消息标识,本次写操作未执行。'
return receipt_text(await self.service.upgrade(who, slot, op))
image_actions = ('farm', 'plot_ids', 'details', 'shop',
'seeds', 'crops')
if action in image_actions and action != 'farm' and '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
quality = self.image_quality
text_mode = (action in ('seeds', 'crops') and args
and args[-1] == '文字')
if action in image_actions and self.rendering and not text_mode:
if action in ('seeds', 'crops') and '文字' in args:
raise ValueError()
if action in image_actions and self.rendering:
try:
if self.image_quality_provider is not None:
quality = self.image_quality_provider()
@@ -294,7 +363,6 @@ class Runtime:
)
if action in ('crops', 'seeds'):
title = '我的作物' if action == 'crops' else '我的种子'
mode = args.pop() if args and args[-1] == '文字' else ''
if len(args) > 1:
raise ValueError()
try:
@@ -307,7 +375,7 @@ class Runtime:
_, pages = warehouse_page(rows, page)
except ValueError:
return f'页码无效,请发送:{title} 1'
if not self.rendering or mode == '文字':
if not self.rendering:
return warehouse_text(rows, snapshot, action, page, pages)
return await self.rendering.warehouse(
rows, snapshot, action, page, pages, self.image_format,
@@ -323,7 +391,7 @@ class Runtime:
if not self.rendering:
if action == "plot_ids":
return plot_ids_text(await self.service.snapshot(who))
return f"请配置 {self.font_setting} 为可用中文字体路径。"
return "农场图片资源暂不可用,请检查素材和小赖字体的下载日志。"
now = self.service.clock()
theme = season_for(now)
logger.info("farm preview seasonal theme=%s", theme)
@@ -396,10 +464,10 @@ class Runtime:
async def help_menu(self, args):
if '高清' in args:
return '仅我的农场支持高清,请移除高清参数后重试。'
if args not in ([], ['文字']):
return '参数有误,请发送:农场帮助 或 农场帮助 文字'
if args:
return '参数有误,请发送:农场帮助'
fallback = help_text(self.command_specs)
if args or self.rendering is None:
if self.rendering is None:
return fallback
quality = self.image_quality
if self.image_quality_provider is not None:
+7 -5
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, FONT_PATH
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
@@ -42,10 +42,12 @@ data_dir = data_directory(
)
runtime = Runtime(
data_dir,
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,
deployment=getattr(
driver.config, "farm_preview_deployment", "zhenxun-preview",
),
image_format=str(getattr(
driver.config, "farm_preview_image_format", "AUTO",
)).upper(),
image_quality_provider=image_quality,
resource_options_provider=resource_options,
)
+2 -2
View File
@@ -4,6 +4,6 @@ from pathlib import Path
def data_directory(base, configured=None):
if configured is not None:
return Path(configured)
if configured:
return Path(configured).expanduser()
return Path(base) / 'farm_db'
+1 -1
View File
@@ -22,7 +22,7 @@ version = {file = "version.txt"}
[tool.setuptools.package-data]
farm_core = ["data/*.json"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md", "data/*.txt"]
[tool.ruff]
line-length = 80
+16
View File
@@ -10,4 +10,20 @@
在生成的发布仓库和安装包中写为根目录 `README.md`。
文中的相对资源路径以生成的发布仓库根目录为准。
帮助菜单由 `scripts/preview_help.py` 根据当前命令表与绘图代码生成。
三平台工作流先生成各自菜单,再打包到发布仓库根目录的 `images/`:
`help-zhenxun.png`、`help-nonebot.png`、`help-astrbot.png`。
各自 README 使用 `./images/help-对应平台.png` 引用,不依赖外部图片地址。
本地直接运行构建脚本也会自动调用生成脚本,不复用旧菜单。
手动运行 `python scripts/preview_help.py` 默认刷新主仓库 `images/` 的三张图。
生成需要 Pillow,默认使用由 farm_service 分发并缓存的小赖字体;
可用 `--font` 或 `FARM_TEST_FONT` 指定字体。
本地存在同级 `farm_service/fonts/Xiaolai-Regular.ttf` 且摘要匹配时直接
使用;否则从 service 仓库下载并缓存。应先发布 service 字体资源,再发布插件。
CI 在临时工作目录生成,避免生成图片使开发源被标记为未提交变更。
后续发布文案请修改本目录;原位置的文档保留供历史参考。
## 致谢
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
+77 -73
View File
@@ -1,102 +1,106 @@
<div align="center">
# 真寻农场(zhenxun_plugin_farm)
<img src="./logo.png" width="400" alt="真寻农场 Logo">
<p align="center">
<img src="https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/logo-readme.png" width="400" alt="真寻农场 Logo">
<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>
</p>
# 农场 · AstrBot
<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)
![农场预览](https://raw.githubusercontent.com/Shu-Ying/astrbot_plugin_farm/main/images/farm-preview.png)
</p>
## 安装与快速开始
你是说可以种地对吧🤔?
在 AstrBot 插件市场搜索 `astrbot_plugin_farm`,或在插件管理中选择
「从文件安装」,上传作者提供的完整插件 ZIP。不要只上传 `main.py`。
</div>
安装依赖后重载插件。按你配置的唤醒前缀发送指令,下面以 `/` 为例:
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
1. `/开通农场`:创建农场,获得初始6块土地。
2. `/种子商店`:查看种子名称、编号与价格。
3. `/购买种子 白萝卜 1`,然后 `/播种 白萝卜 1`。
4. `/我的农场` 查看状态,生长期间用 `/农场除虫` 清除虫害。
5. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
---
发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
## 农场界面
## 指令
![农场界面](./images/farm-preview.png)
`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
仓库默认图片,例如 `/我的种子 2 文字`。偷菜目标必须通过消息中的 @ 指定。
---
<!-- FARM_COMMANDS -->
## 如何安装
## 配置
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `font_path` | 空 | 中文字体路径;留空尝试系统字体,无可用字体时退回文字 |
| `data_dir` | 空 | 留空使用 `data/plugin_data/astrbot_plugin_farm`;更改不会自动搬迁存档 |
| `image_format` | `AUTO` | `AUTO` 为 JPEG;也可选 `JPEG` 或 `PNG` |
| `image_quality` | `75` | 整数1~95,下一次图片请求生效;PNG不受影响 |
| `resource_source`(资源获取地址) | `Github` | `Github`、`Gitea`、`farm_service`,重载生效 |
| `farm_service_url`(farm_service 地址) | 空 | 独立HTTPS基础地址,供资源及后续活动服务共用;选farm_service下载时必填 |
---
除图片质量外,配置变更后请重载插件。高清农场的图片质量不低于85。
依赖 Pillow 和 aiohttp,由插件依赖声明安装;插件不捆绑中文字体。
当前重点适配 QQ/OneBot(`aiocqhttp`),其他平台尚未完成真实环境验证。
## 农场帮助
![农场帮助](./images/help-astrbot.png)
插件不携带大型`assets.zip`。启动时校验数据目录下缓存素材的SHA256,
缺失或不匹配时从所选源下载并校验后原子替换;GitHub/Gitea均从
`farm_service`仓库根目录`assets/`获取。默认Github,不自动跨源切换;
下载失败保留文字功能,调整来源后重载重试。两项配置始终同时存在,
活动功能尚未实现。已有正确缓存时不联网。
---
离线部署可将服务仓库的`assets/assets.zip`放到农场数据目录
`assets/<SHA256>/assets.zip`;SHA见插件的`_vendor/farm_render/data/asset-manifest.json`。
请先提交/发布服务仓库的素材,再发布依赖它的插件;不要把素材ZIP放回插件仓库。
## 待办事宜 `Todo` 列表
## 玩法与旧账号兼容
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
- 新账号初始6块土地。`/开垦 条件` 查询下一块地的等级和费用,
`/开垦` 每次购买1块普通空地;未配置的地块不会扣款或放行。
- 可靠识别为初始3块地的旧账号,一次性追加3块普通空地,保留已有开垦成果、
作物、加成与资产。来源不明、编号异常或补后超过当前30个显示位置时,
保留原状并提示管理员处理,不截断、不折现。
- 新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。
已排定的旧生虫事件仍按原计划执行一次。
- 每只虫降低生长速度5%;成熟时每只虫使本季减产5%。成熟后除虫无法恢复
本季减产,请在生长期间及时除虫。
- 每日最多5次成功偷菜,北京时间零点重置;无可偷作物不消耗成功次数。
- 出售时自动跳过已锁定作物。仓库可使用作物编号操作。
---
## 升级与数据备份
## 关于
升级前备份 `data/plugin_data/astrbot_plugin_farm`;若自定义 `data_dir`,
请备份实际目录。数据库为 `farm.sqlite3`,日志为 `farm-preview.log`。
旧库迁移前建议先在副本中验证。更换机器人、平台实例或部署不会自动合并账号。
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
新版当前没有签到、改名、土地升级、点券商店、外部金币兑换及旧远程服务功能。
请以当前帮助菜单为准,不要套用旧1.x版本指令。缺少稳定消息ID时,
插件会拒绝写操作以避免重复扣款。离线包校验及模拟宿主测试不代表线上验证。
素材来均源于互联网,侵权请联系我删除
QQ/OneBot 图片查询会请求发送者的腾讯头像用于农场及详情图片;
请求失败使用默认头像,成功缓存5分钟、失败缓存30秒,最多缓存16项。
其他平台使用默认头像。存档按部署、平台实例、机器人及用户隔离。
---
## 更新日志与反馈
## 致谢
- [更新日志](https://github.com/Shu-Ying/astrbot_plugin_farm/blob/main/CHANGELOG.md)
- [反馈问题](https://github.com/Shu-Ying/astrbot_plugin_farm/issues)
- [插件商店](https://cloud.astrbot.app/plugin/Shu-Ying/astrbot_plugin_farm)
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
代码许可见插件所附 LICENSE。角色图标为非官方二次创作,不表示获得官方授权;
角色与原作相关权利不因代码许可证而转移。
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [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*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 开发者本地构建
## 许可证
开发源为 Farm 主仓库。Windows 双击 `build_astrbot.cmd`,或运行
`python scripts/build_plugin.py astrbot`,构建后生成完整 ZIP 和校验文件。
每次使用独立输出目录,不安装或启动机器人、不自动推送远端。
版本来自 `version.txt`;命令表、元数据与展示文件由构建生成。
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+89 -59
View File
@@ -1,76 +1,106 @@
# NoneBot 农场
<div align="center">
<p align="center"><img src="./logo.png" width="400" alt="真寻农场 Logo"></p>
# 真寻农场(zhenxun_plugin_farm)
从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的
业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。
<img src="./logo.png" width="400" alt="真寻农场 Logo">
![农场预览](images/farm-preview.png)
<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>
</p>
## 安装与配置
<p align="center">
需要 Python 3.10 及以上。生成仓库中执行 `pip install .`,或安装经过
标签发布的 `nonebot_plugin_farm` 包,然后在 NoneBot 启动脚本中加入:
[![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)
```python
nonebot.load_plugin("nonebot_plugin_farm")
```
</p>
可在 `.env` 中设置:
你是说可以种地对吧🤔?
```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO
FARM_IMAGE_QUALITY=75
FARM_DEPLOYMENT=nonebot
FARM_RESOURCE_SOURCE=Github
FARM_SERVICE_URL=
```
</div>
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
---
## 目录
- [真寻农场(zhenxun\_plugin\_farm)](#真寻农场zhenxun_plugin_farm)
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
- [许可证](#许可证)
- [上游仓库](#上游仓库)
`FARM_RESOURCE_SOURCE`(资源获取地址)支持`Github`、`Gitea`、`farm_service`,
默认`Github`。`FARM_SERVICE_URL`(farm_service 地址)始终独立存在,
供资源及后续活动功能共用;选择farm_service下载时须填写HTTPS基础地址。
两项修改后重载。插件不包含素材ZIP:每次启动校验本地SHA256,缺失或不匹配
才从`farm_service`仓库根目录`assets/assets.zip`下载,校验成功后原子替换。
失败时保留文字功能,不自动跨源切换,重载后重试。活动功能尚未实施。
---
`FARM_IMAGE_QUALITY` 为整数1~95,默认75,统一控制全部农场图片
的编码质量;越高通常越清晰、文件越大,PNG无损格式不受此值影响。
启动时校验配置。只有“我的农场 高清”允许高清,使用1536宽,质量
取配置值与85的较大值;其他命令不接受高清。种子/作物仓库保留
分页和“文字”选项,例如“我的种子 2 文字”。
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
## 农场界面
## 当前命令
![农场界面](./images/farm-preview.png)
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
尚未在共享核心中实现,此版本不提供这些命令。
---
## 生成与发布
## 如何安装
主仓库 `version.txt` 是版本的唯一来源,生成 ZIP、包版本和清单。
`nonebot-preview.yml` 在 PR 中测试和构建,在主仓库 `main` 推送后
使用 `FARM_RELEASE_TOKEN` 同步到 `Shu-Ying/nonebot_plugin_farm/main`。
同步任务沿用真寻的 `zhenxun-preview` environment,兼容其中已有密钥。
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
目标仓库保留 `v*` 标签发布 PyPI 的方式和 `PYPI_TOKEN` 密钥。
普通同步只校验、构建,不发布 PyPI。手动推送标签前应确认版本未在
PyPI 使用,且标签去掉 `v` 后与 `version.txt` 完全一致。
接入时远端旧版为 1.5.7,主仓库为 1.5.4;用户计划后续统一改为
2.0.0,本次未提前修改。统一后发布标签应为 `v2.0.0`。
---
生成仓库的 `verify_package.py --directory .` 校验文件完整性;
`python -m build` 生成 wheel/sdist。远端 CI 和实际机器人消息行为
需要在部署环境进一步验证。
## 农场帮助
![农场帮助](./images/help-nonebot.png)
---
## 待办事宜 `Todo` 列表
- [x] 完善我的农场图片,例如左上角显示用户数据
- [ ] 完善升级数据、作物数据、作物图片
- [x] 签到功能
- [x] 在线更新作物信息
- [ ] 添加渔场功能
- [ ] 增加活动、交易行功能
- [ ] 增加交易行总行功能
- [ ] 添加其他游戏种子素材
- [ ] 想不到了,想到再说
---
## 关于
本人毫无任何Python经验,也从未正式的、系统的、完整的去学习Python。如有看到写的不对的地方,欢迎指出,也欢迎任何人一起来开发、完善农场。
素材来均源于互联网,侵权请联系我删除
---
## 致谢
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [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*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./nonebot_plugin_farm/_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+10 -53
View File
@@ -4,12 +4,6 @@
<img src="./logo.png" width="400" alt="真寻农场 Logo">
> 资源配置:「资源获取地址」默认Github,可选Github、Gitea、farm_service;
> 「farm_service 地址」始终独立存在,供资源及后续活动服务共用。
> 选择farm_service下载时填写HTTPS基础地址,两项修改后重载。
> 插件不携带assets.zip,启动校验缓存SHA256,缺失或不匹配时从所选源获取;
> Github/Gitea均使用farm_service仓库根目录assets/。失败保留文字功能,重载重试。
> 活动服务尚未实现,请先发布服务仓库素材再发布插件。
<p align="center">
<a href="./LICENSE">
<img src="https://img.shields.io/badge/license-GPL3.0-FE7D37" alt="license">
@@ -20,12 +14,6 @@
<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>
<p align="center">
@@ -45,10 +33,7 @@
- [目录](#目录)
- [农场界面](#农场界面)
- [如何安装](#如何安装)
- [使用指令](#使用指令)
- [更新日志(详细):](#更新日志详细)
- [用户方面](#用户方面)
- [代码方面](#代码方面)
- [农场帮助](#农场帮助)
- [待办事宜 `Todo` 列表](#待办事宜-todo-列表)
- [关于](#关于)
- [致谢](#致谢)
@@ -70,44 +55,11 @@
---
## 使用指令
| 指令 | 描述 | Tip |
| --- | --- | --- |
| @小真寻 开通农场 | 首次开通农场 | |
| 我的农场 | 你的农场 | |
| 农场详述 | 农场详细信息 | |
| 我的农场币 | 查询农场币 | |
| 种子商店 [筛选关键字] [页数] or [页数] | 查看种子商店 | 当第一个参数为非整数时,会默认进入筛选状态。页数不填默认为1 |
| 购买种子 [种子名称] [数量] | 购买种子 | 数量不填默认为1 |
| 我的种子 | 查询仓库种子 | |
| 播种 [种子名称] [数量] | 播种种子 | 数量不填默认将最大可能播种 |
| 收获 | 收获成熟作物 | |
| 铲除 | 铲除荒废作物 | |
| 我的作物 | 你的作物 | |
| 出售作物 [作物名称] [数量] | 从仓库里向系统售卖作物 | 不填写作物名将售卖仓库种全部作物 填作物名不填数量将指定作物全部出售 |
| 偷菜 @美波理 | 偷别人的菜 | 每人每天只能偷5次 |
| 购买农场币 | 将真寻金币兑换成农场币 | 兑换比例默认为1:2 手续费默认20% |
| 更改农场名 [新的农场名] | 改名 | 农场名称无法存储特殊字符 |
| 农场签到 | 签到 | 需要注意,该项会从服务器拉取签到数据 |
| 土地升级 [地块ID] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
## 农场帮助
![农场帮助](./images/help-zhenxun.png)
---
## 更新日志[(详细)](./log/log.md):
用户方面
---
- 新增土地升级指令,快来将土地升级至红土地、黑土地、金土地吧
- 新增查漏补缺作物资源功能,自动更新最新作物和作物资源文件
- 定时更新签到文件、作物资源从00:30调整至04:30
- 修正了部分土地资源错误的情况
- 修正了部分文本信息错误的情况
代码方面
---
- 修正部分事件连接机制
- 修正网络请求端口
## 待办事宜 `Todo` 列表
- [x] 完善我的农场图片,例如左上角显示用户数据
@@ -134,16 +86,21 @@
最后感谢以下框架/作者的提供的技术支持(排名不分先后):
- [QQ农场](https://qqnc.qq.com/) * 绝大多数的素材来源 *
- [QQ农场](https://qqnc.qq.com/) *绝大多数的素材来源*
- [小赖字体](https://github.com/lxgw/kose-font) *lxgw 提供的开源中文手写字体,采用 SIL OFL 1.1,随包附带完整字体许可*
- [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*
- [ChatGPT](https://chatgpt.com/) *AI神力*
## 许可证
`真寻农场(zhenxun_plugin_farm)`将采用的是`GPLv3`许可证进行开源
默认使用的小赖字体由 farm_service 分发,单独遵循 `SIL OFL 1.1`,完整版权声明与许可见
[OFL-Xiaolai.txt](./_vendor/farm_render/data/OFL-Xiaolai.txt)。
## 上游仓库
- [Client](https://github.com/Shu-Ying/main_plugin_farm)
- [main_plugin_farm](https://github.com/Shu-Ying/main_plugin_farm)
+3 -1
View File
@@ -1,4 +1,6 @@
include version.txt LICENSE README.md FARM-MANIFEST.json verify_package.py
include images/farm-preview.png
recursive-include nonebot_plugin_farm *.py *.json *.md
include images/help-nonebot.png
recursive-include nonebot_plugin_farm land-soils.zip
recursive-include nonebot_plugin_farm *.py *.json *.md *.txt
global-exclude assets.zip
+2 -1
View File
@@ -31,5 +31,6 @@ include = ["nonebot_plugin_farm*"]
[tool.setuptools.package-data]
"nonebot_plugin_farm._vendor.farm_core" = ["data/*.json"]
"nonebot_plugin_farm._vendor.farm_render" = [
"data/*.json", "data/*.md",
"data/*.json", "data/*.md", "data/land-soils.zip",
"data/*.txt",
]
+14 -2
View File
@@ -12,11 +12,16 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import build as build_shared, read_version, source_identity
from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
from verify_zhenxun import read_package as read_shared
from help_image import build_help_image
def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->'
if marker not in template:
if './images/help-astrbot.png' not in template:
raise ValueError('AstrBot README requires its help menu image')
return template.encode('utf-8')
if template.count(marker) != 1:
raise ValueError('AstrBot README requires one command marker')
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
@@ -32,7 +37,7 @@ def render_readme(root):
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
@@ -53,6 +58,9 @@ def build(root, output):
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
files['images/farm-preview.png'] = shared['images/farm-preview.png']
files['images/help-astrbot.png'] = build_help_image(
root, 'astrbot', help_image,
)
files['README.md'] = render_readme(root)
files['runtime.py'] = (
root / 'integrations/shared/runtime.py'
@@ -99,6 +107,8 @@ def build(root, output):
item.external_attr = 0o100644 << 16
archive.writestr(item, content)
verify_package(archive_path)
if archive_path.stat().st_size > 16_000_000:
raise ValueError('AstrBot plugin ZIP exceeds 16 MB')
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix('.zip.sha256').write_text(
f'{digest} {archive_path.name}\n', encoding='ascii',
@@ -109,8 +119,10 @@ def build(root, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--help-image', type=Path)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
print(build(Path(__file__).resolve().parents[1], args.output,
help_image=args.help_image))
if __name__ == '__main__':
+10 -3
View File
@@ -10,9 +10,10 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import read_version, source_identity
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
@@ -20,6 +21,9 @@ def build(root, output):
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
files = {}
files['images/help-nonebot.png'] = build_help_image(
root, 'nonebot', help_image,
)
prefix = ROOT_NAME + "/"
integration = root / "integrations/nonebot"
for name in ("__init__.py", "config.py", "commands.py", "data_paths.py"):
@@ -33,7 +37,8 @@ def build(root, output):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
path for path in (source / "data").iterdir()
if path.suffix in (".json", ".md")
if path.suffix in (".json", ".md", ".zip", ".txt")
and path.name != 'assets.zip'
]
for path in selected:
if path.is_symlink():
@@ -96,8 +101,10 @@ def build(root, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument('--help-image', type=Path)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
print(build(Path(__file__).resolve().parents[1], args.output,
help_image=args.help_image))
if __name__ == "__main__":
+8 -2
View File
@@ -41,7 +41,12 @@ foreach ($farmCandidate in $farmCandidates) {
try {
$farmExe = $farmCandidate.Exe
$farmPrefix = $farmCandidate.Prefix
& $farmExe @farmPrefix -c 'import sys; sys.exit(sys.version_info < (3, 10))'
$farmProbe = 'import sys, importlib.util; sys.exit(' +
'sys.version_info < (3, 10) or ' +
'importlib.util.find_spec("PIL") is None)'
# Windows PowerShell 5.1 rewrites quotes in native arguments.
# Use stdin instead of -c; keep this script ASCII for PS 5.1.
$farmProbe | & $farmExe @farmPrefix -
if ($LASTEXITCODE -eq 0) {
$farmPython = $farmCandidate
break
@@ -51,7 +56,8 @@ foreach ($farmCandidate in $farmCandidates) {
}
}
if (-not $farmPython) {
Write-Error 'Python 3.10+ not found. Install Python or set FARM_PYTHON.'
Write-Error ('Python 3.10+ with Pillow not found. ' +
'Install Python/Pillow or set FARM_PYTHON.')
exit 1
}
+8 -3
View File
@@ -10,6 +10,7 @@ import subprocess
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
def source_identity(root):
@@ -33,7 +34,7 @@ def read_version(root):
return version
def build(root, output):
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
@@ -44,6 +45,9 @@ def build(root, output):
)):
raise ValueError("Output must not be a source directory")
files = {}
files['images/help-zhenxun.png'] = build_help_image(
root, 'zhenxun', help_image,
)
integration = root / "integrations/zhenxun"
for name in (
"__init__.py", "commands.py", "data_paths.py",
@@ -58,7 +62,7 @@ def build(root, output):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
p for p in (source / "data").iterdir() if p.suffix in (
".json", ".zip", ".md",
".json", ".zip", ".md", ".txt",
) and p.name != 'assets.zip'
]
for path in selected:
@@ -121,9 +125,10 @@ def build(root, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument('--help-image', type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
print(build(root, args.output))
print(build(root, args.output, help_image=args.help_image))
if __name__ == "__main__":
+15
View File
@@ -64,6 +64,21 @@ def main():
for tokens in install_commands
):
raise ValueError("Full test suite requires AstrBot dependencies")
for platform, workflow in (
('zhenxun', source), ('nonebot', nonebot), ('astrbot', astrbot),
):
steps = workflow['jobs']['build']['steps']
generation = next((i for i, step in enumerate(steps)
if 'scripts/preview_help.py' in step.get('run', '')),
None)
build = next((i for i, step in enumerate(steps)
if f'scripts/build_{platform}.py'
in step.get('run', '')), None)
if generation is None or build is None or generation >= build:
raise ValueError('Help menu must be generated before packaging')
if (f'--platform {platform}' not in steps[generation]['run']
or f'help-{platform}.png' not in steps[build]['run']):
raise ValueError('Workflow help image platform mismatch')
if nonebot["permissions"] != {"contents": "read"}:
raise ValueError("NoneBot source permissions differ")
trigger = nonebot["on"]
+15 -11
View File
@@ -1,21 +1,25 @@
"""为CI选择现有字体;部署仍须显式配置中文字体。"""
"""预览与CI复用 service 字体缓存,不将大字体打入插件。"""
import os
from pathlib import Path
import sys
import tempfile
def choose_font():
configured = os.environ.get("FARM_TEST_FONT")
candidates = ([configured] if configured else []) + [
"C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
]
for name in candidates:
if Path(name).is_file():
return name
raise RuntimeError("Set FARM_TEST_FONT to an existing font")
if configured:
if not Path(configured).is_file():
raise RuntimeError("FARM_TEST_FONT does not exist")
return configured
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root / "src"))
from farm_render.font_bundle import FILES, _valid, ensure_font
local = root.parent / "farm_service/fonts/Xiaolai-Regular.ttf"
if _valid(local, *FILES[local.name]):
return str(local)
cache = Path(tempfile.gettempdir()) / "farm-preview-resources"
return str(ensure_font(cache))
if __name__ == "__main__":
+16 -1
View File
@@ -170,12 +170,27 @@ def export(root):
).encode("utf-8")
def export_soils(root):
"""小型升级土地图随插件内置,不改变既有外部素材包摘要。"""
output = BytesIO()
source = root / 'legacy/zhenxun_plugin_farm/resource'
with ZipFile(output, 'w', compression=ZIP_STORED) as bundle:
for name in ('红土地', '黑土地', '金土地'):
path = f'soil/{name}.png'
item = ZipInfo(path, (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.external_attr = 0o100644 << 16
bundle.writestr(item, (source / path).read_bytes())
return output.getvalue()
def main():
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
files = zip(("assets.zip", "metadata.json"), export(root))
files = (*zip(("assets.zip", "metadata.json"), export(root)),
('land-soils.zip', export_soils(root)))
destination = root / "src/farm_render/data"
for name, content in files:
path = destination / name
+30
View File
@@ -0,0 +1,30 @@
"""构建期间调用独立帮助菜单脚本;不改变开发源或源码身份。"""
from io import BytesIO
from pathlib import Path
import subprocess
import sys
import tempfile
def build_help_image(root, platform, prepared=None):
if platform not in ('zhenxun', 'nonebot', 'astrbot'):
raise ValueError('Unknown help platform')
if prepared is None:
with tempfile.TemporaryDirectory() as temporary:
subprocess.run([
sys.executable, str(Path(root) / 'scripts/preview_help.py'),
'--platform', platform, '--output', temporary,
], check=True, stdout=subprocess.PIPE, timeout=60)
content = (Path(temporary) / f'help-{platform}.png').read_bytes()
else:
path = Path(prepared)
if path.name != f'help-{platform}.png':
raise ValueError('Help image belongs to a different platform')
content = path.read_bytes()
from PIL import Image
with Image.open(BytesIO(content)) as image:
if image.format != 'PNG' or image.width != 1200:
raise ValueError('Expected generated PNG help menu at width 1200')
image.verify()
return content
+58
View File
@@ -0,0 +1,58 @@
"""从当前命令注册表离线生成帮助菜单图片,不访问玩家数据库。"""
import argparse
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from ci_font import choose_font
from farm_core.commands import command_specs
from farm_render import FarmRenderer
from farm_render.help import render_help
PLATFORMS = ('zhenxun', 'nonebot', 'astrbot')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'--platform', choices=(*PLATFORMS, 'all'), default='all',
help='目标平台;默认一次生成三个平台的菜单。',
)
parser.add_argument(
'--output', type=Path, default=ROOT / 'images',
help='图片输出目录;默认主仓库 images。',
)
parser.add_argument('--font', type=Path, help='中文字体文件路径。')
parser.add_argument(
'--format', choices=('PNG', 'JPEG', 'WEBP'), default='PNG',
type=str.upper, help='默认PNG无损图片。',
)
parser.add_argument('--quality', type=int, default=75,
help='JPEG/WebP质量,1~95;PNG不受影响。')
args = parser.parse_args(argv)
if not 1 <= args.quality <= 95:
parser.error('--quality 必须为1~95的整数')
try:
renderer = FarmRenderer(str(args.font) if args.font else choose_font())
except (OSError, ValueError, RuntimeError) as error:
parser.error(f'无法加载字体或素材,请用 --font 指定中文字体:{error}')
platforms = PLATFORMS if args.platform == 'all' else (args.platform,)
extension = {'PNG': 'png', 'JPEG': 'jpg', 'WEBP': 'webp'}[args.format]
args.output.mkdir(parents=True, exist_ok=True)
for platform in platforms:
picture = render_help(
renderer, command_specs(platform), platform,
image_format=args.format, quality=args.quality,
)
target = args.output / f'help-{platform}.{extension}'
target.write_bytes(picture)
print(f'{target.resolve()} ({len(picture)} bytes)')
if __name__ == '__main__':
main()
+3 -1
View File
@@ -43,7 +43,9 @@ def verify_files(files):
'images/farm-preview.png',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
'_vendor/farm_core/__init__.py',
'_vendor/farm_render/data/asset-manifest.json'}
'_vendor/farm_render/data/asset-manifest.json',
'_vendor/farm_render/data/font-manifest.json',
'_vendor/farm_render/data/OFL-Xiaolai.txt'}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
+2
View File
@@ -42,6 +42,8 @@ def verify_files(files):
"nonebot_plugin_farm/commands.py", "nonebot_plugin_farm/runtime.py",
"nonebot_plugin_farm/_vendor/farm_core/__init__.py",
"nonebot_plugin_farm/_vendor/farm_render/data/asset-manifest.json",
"nonebot_plugin_farm/_vendor/farm_render/data/font-manifest.json",
"nonebot_plugin_farm/_vendor/farm_render/data/OFL-Xiaolai.txt",
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
}
if not required <= set(files):
+3 -1
View File
@@ -38,7 +38,9 @@ def verify_files(files):
raise ValueError("Package checksum mismatch")
required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt",
"LICENSE", "_vendor/farm_core/__init__.py",
"_vendor/farm_render/data/asset-manifest.json"}
"_vendor/farm_render/data/asset-manifest.json",
"_vendor/farm_render/data/font-manifest.json",
"_vendor/farm_render/data/OFL-Xiaolai.txt"}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
+43
View File
@@ -13,6 +13,7 @@ from .ports import Store
from .pests import advance_plot, start_growth
from .yield_rules import season_yield, remaining_yield
from .land import COMPENSATION_ID, SUPPORTED_PLOTS, next_rule, owned_count
from .land import LAND_NAMES, upgrade_rule
LOGGER = logging.getLogger(__name__)
@@ -115,6 +116,48 @@ class FarmService:
return await self._execute(identity, operation_id, 'reclaim', {},
operation)
async def upgrade_conditions(self, identity: Identity, slot: int):
snapshot = await self.snapshot(identity)
plot, level, rule = upgrade_rule(snapshot.plots, slot)
return {
'slot': slot, 'land_name': LAND_NAMES[level],
'current_land': LAND_NAMES[plot.land_level],
'ordinal': rule.ordinal, 'required_level': rule.required_level,
'coin_cost': rule.coin_cost, 'level': snapshot.account.level,
'point': snapshot.account.point,
}
async def upgrade(self, identity: Identity, slot: int, operation_id: str):
integer(slot, 1, SUPPORTED_PLOTS)
def operation(repository, now):
account = repository.account()
plot, level, rule = upgrade_rule(repository.plots(), slot)
if account.level < rule.required_level:
raise FarmError('level_required')
repository.asset(Asset.POINT, -rule.coin_cost)
plot = advance_plot(plot, now)
matured = (plot.plant is not None and not plot.withered
and plot.mature_at is not None and plot.mature_at > now)
if matured:
plot = replace(
plot, mature_at=now, growth_remaining=0,
growth_updated_at=now, next_pest_at=None,
maturity_pests=plot.pest_count,
)
repository.save_plot(replace(
plot, land_level=level, yield_bonus=(0, 10, 20, 28)[level],
experience_bonus=28 if level == 3 else 0,
))
return self._result(
repository, slot=slot, land_level=level,
land_name=LAND_NAMES[level], cost=rule.coin_cost,
ordinal=rule.ordinal, matured=matured,
)
return await self._execute(identity, operation_id, 'upgrade',
{'slot': slot}, operation)
async def register(
self, identity: Identity, name: str, operation_id: str,
):
+159 -67
View File
@@ -16,71 +16,160 @@ class CommandSpec:
COMMAND_SPECS = (
CommandSpec('开通农场', 'register', '开通与查看', '开通农场 [名称]',
'首次开通获得6块土地,可指定农场名称。'),
CommandSpec('我的农场', 'farm', '开通与查看', '我的农场 [高清]',
'查看农场全景;仅我的农场支持高清。'),
CommandSpec('农场详述', 'details', '开通与查看', '农场详述 [页码]',
'查看各地块的作物、生长和虫害信息。'),
CommandSpec('地块编号', 'plot_ids', '开通与查看', '地块编号',
'查看已开垦土地编号及待开垦位置。'),
CommandSpec('我的农场币', 'inventory', '开通与查看', '我的农场币',
'查看农场币、点券等资产。'),
CommandSpec('我的点券', 'inventory', '开通与查看', '我的点券',
'查看点券等资产。'),
CommandSpec('种子商店', 'shop', '种子与种植', '种子商店 [页码]',
'查看种子价格、等级要求与作物编号。'),
CommandSpec('购买种子', 'buy', '种子与种植', '购买种子 作物 数量',
'购买指定数量的种子。'),
CommandSpec('播种', 'sow', '种子与种植', '播种 作物 [数量]',
'使用库存种子播种到空地。'),
CommandSpec('收获', 'harvest', '田间管理与扩建', '收获',
'收获成熟作物,获得作物与经验。'),
CommandSpec('农场除虫', 'clear_pests', '田间管理与扩建', '农场除虫',
'清除虫害;生长期间及时除虫。', aliases=('除虫',)),
CommandSpec('铲除', 'eradicate', '田间管理与扩建', '铲除',
'清理枯萎作物,腾出土地。'),
CommandSpec('开垦', 'reclaim', '田间管理与扩建', '开垦',
'满足等级和费用后,新增1块普通空地。',
extra=(('开垦 条件', '查询下一块土地的等级及费用。'),)),
CommandSpec('我的种子', 'seeds', '仓库与出售',
'我的种子 [页码] [文字]',
'查看种子库存;默认图片,追加「文字」切换。'),
CommandSpec('我的作物', 'crops', '仓库与出售',
'我的作物 [页码] [文字]',
'查看作物库存;默认图片,追加「文字」切换。'),
CommandSpec('出售作物', 'sell', '仓库与出售',
'出售作物 [作物] [数量]',
'出售作物获得农场币;省略作物时出售全部未锁定作物。'),
CommandSpec('作物加锁', 'lock', '仓库与出售', '作物加锁 作物',
'锁定作物,出售时自动跳过。'),
CommandSpec('作物解锁', 'unlock', '仓库与出售', '作物解锁 作物',
'解除指定作物的锁定。'),
CommandSpec('偷菜', 'steal', '好友互动', '偷菜 @目标',
'每日最多5次成功偷取,北京时间零点重置。'),
CommandSpec('农场帮助', 'help', '使用帮助', '农场帮助 [文字]',
'查看当前平台菜单;追加「文字」查看文字版。'),
CommandSpec(
"开通农场",
"register",
"开通与查看",
"开通农场 [名称]",
"欢迎开通农场。",
),
CommandSpec(
"我的农场",
"farm",
"开通与查看",
"我的农场 [高清]",
"查看农场全景。",
),
CommandSpec(
"农场详述",
"details",
"开通与查看",
"农场详述 [页码]",
"查看各地块的作物、生长和虫害信息。",
),
CommandSpec(
"地块编号",
"plot_ids",
"开通与查看",
"地块编号",
"查看已开垦土地编号及待开垦位置。",
),
CommandSpec(
"种子商店",
"shop",
"种子与种植",
"种子商店 [页码]",
"查看种子价格、等级要求与作物编号。",
),
CommandSpec(
"购买种子",
"buy",
"种子与种植",
"购买种子 作物 数量",
"购买指定数量的种子。",
),
CommandSpec(
"播种",
"sow",
"种子与种植",
"播种 作物 [数量]",
"使用库存种子播种到空地。",
),
CommandSpec(
"收获",
"harvest",
"田间管理与扩建",
"收获",
"收获成熟作物,获得作物与经验。",
),
CommandSpec(
"农场除虫",
"clear_pests",
"田间管理与扩建",
"农场除虫",
"清除虫害;生长期间及时除虫。",
aliases=("除虫",),
),
CommandSpec(
"铲除",
"eradicate",
"田间管理与扩建",
"铲除",
"清理枯萎作物,腾出土地。",
),
CommandSpec(
"开垦",
"reclaim",
"田间管理与扩建",
"开垦",
"满足等级和费用后,新增1块普通空地。",
extra=(("开垦 条件", "查询下一块土地的等级及费用。"),),
),
CommandSpec(
"土地升级",
"upgrade",
"田间管理与扩建",
"土地升级 地块编号",
"逐级升为红、黑、金土地,并使当前季作物立即成熟。",
extra=(("土地升级 地块编号 条件", "查询该地块的升级等级与费用。"),),
),
CommandSpec(
"我的种子",
"seeds",
"仓库与出售",
"我的种子 [页码]",
"查看种子库存。",
),
CommandSpec(
"我的作物",
"crops",
"仓库与出售",
"我的作物 [页码]",
"查看作物库存。",
),
CommandSpec(
"出售作物",
"sell",
"仓库与出售",
"出售作物 [作物] [数量]",
"出售作物获得农场币;省略作物时出售全部未锁定作物。",
),
CommandSpec(
"作物加锁",
"lock",
"仓库与出售",
"作物加锁 作物",
"锁定作物,出售时自动跳过。",
),
CommandSpec(
"作物解锁",
"unlock",
"仓库与出售",
"作物解锁 作物",
"解除指定作物的锁定。",
),
CommandSpec(
"偷菜",
"steal",
"好友互动",
"偷菜 @目标",
"每日最多5次成功偷取。",
),
CommandSpec(
"农场帮助",
"help",
"使用帮助",
"农场帮助",
"查看当前平台菜单。",
),
)
HELP_NOTES = (
'[参数] 表示可选;作物支持名称或 #编号,同名作物请使用编号。',
'示例:我的种子 2 文字;购买种子 #编号 5;播种 #编号 3。',
'仅我的农场支持高清;其他图片使用统一图片质量配置。',
'新播种及下一季每半个基础生长周期生虫一次,间隔至少2小时。',
'每只虫降低生长速度5%;成熟时每只虫使本季减产5%。',
'成熟后除虫不能恢复本季减产,请在生长期间及时除虫。',
'每播种1块地或铲除1块枯萎地获得2经验;每清除1只虫获得1经验。',
'收获经验按作物结算;农场背景按北京时间自动切换季节。',
'旧版数据首次使用时迁移,绑定当前机器人和平台。',
"[参数] 表示可选;作物支持名称或 #编号,同名作物请使用编号。",
"示例:我的种子 2 文字;购买种子 #编号 5;播种 #编号 3。",
"每只虫降低生长速度5%;成熟时每只虫使本季减产5%。",
"成熟后除虫不能恢复本季减产,请在生长期间及时除虫。",
)
def command_specs(platform, specs=None):
if platform not in ('zhenxun', 'nonebot', 'astrbot'):
raise ValueError('Unknown farm framework')
return tuple(spec for spec in (
COMMAND_SPECS if specs is None else specs
) if not spec.platforms or platform in spec.platforms)
if platform not in ("zhenxun", "nonebot", "astrbot"):
raise ValueError("Unknown farm framework")
return tuple(
spec
for spec in (COMMAND_SPECS if specs is None else specs)
if not spec.platforms or platform in spec.platforms
)
def command_map(platform, specs=None):
@@ -88,7 +177,7 @@ def command_map(platform, specs=None):
for spec in command_specs(platform, specs):
for name in (spec.name, *spec.aliases):
if name in result:
raise ValueError('Duplicate farm command')
raise ValueError("Duplicate farm command")
result[name] = spec.action
return result
@@ -98,17 +187,20 @@ def help_groups(specs):
for spec in specs:
description = spec.description
if spec.aliases:
description += ' 别名:' + '、'.join(spec.aliases) + '。'
groups.setdefault(spec.group, []).extend((
(spec.usage, description), *spec.extra,
))
description += " 别名:" + "、".join(spec.aliases) + "。"
groups.setdefault(spec.group, []).extend(
(
(spec.usage, description),
*spec.extra,
)
)
return tuple((title, tuple(rows)) for title, rows in groups.items())
def help_text(specs):
lines = ['农场帮助']
lines = ["真寻农场帮助"]
for title, rows in help_groups(specs):
lines.append('\n【' + title + '】')
lines.append("\n【" + title + "】")
for usage, description in rows:
lines.extend((usage, ' ' + description))
return '\n'.join((*lines, '\n【参数与玩法】', *HELP_NOTES))
lines.extend((usage, " " + description))
return "\n".join((*lines, "\n【参数与玩法】", *HELP_NOTES))
+2
View File
@@ -214,8 +214,10 @@ class Plot:
next_pest_at: int | None = None # 下一次生虫时间,最多每季三次。
maturity_pests: int = 0 # 成熟瞬间冻结的虫数,决定本季减产。
stolen_count: int = 0 # 本季已被偷走数量;收获或铲除后归零。
land_level: int = 0 # 0普通、1红、2黑、3金;独立于作物生命周期。
def __post_init__(self):
integer(self.land_level, 0, 3)
integer(self.stolen_count)
integer(self.pest_count, 0, 3)
integer(self.pest_spawned, self.pest_count, 3)
+43
View File
@@ -11,6 +11,8 @@ from .domain import FarmError, integer
SUPPORTED_PLOTS = 30 # 当前布局支持的位置数,不代表永久土地总上限。
COMPENSATION_ID = 'initial-plots-3-to-6:v1' # 每个账号唯一的补偿操作。
LAND_NAMES = ('普通土地', '红土地', '黑土地', '金土地')
LAND_TYPES = ('normal', 'red', 'black', 'gold')
@dataclass(frozen=True)
@@ -58,3 +60,44 @@ def next_rule(plots):
if rule is None:
raise FarmError('land_rule_unavailable')
return rule
@lru_cache(maxsize=1)
def upgrade_rules():
data = json.loads(resources.files(__package__).joinpath(
'data', 'land_rules.json',
).read_bytes())
rules = {}
try:
for entry in data['upgrades']:
level = LAND_TYPES.index(entry['land_type'])
if level == 0 or entry['from_land_type'] != LAND_TYPES[level-1]:
raise ValueError('Invalid upgrade chain')
for row in entry['rows']:
rule = ExpansionRule(
integer(row['ordinal'], 1, SUPPORTED_PLOTS),
integer(row['required_level']),
integer(row['coin_cost'], 1),
)
key = (level, rule.ordinal)
if key in rules:
raise ValueError('Duplicate upgrade ordinal')
rules[key] = rule
return MappingProxyType(rules)
except (KeyError, TypeError, ValueError) as error:
raise FarmError('land_rules_invalid') from error
def upgrade_rule(plots, slot):
integer(slot, 1, SUPPORTED_PLOTS)
plot = next((p for p in plots if p.slot == slot), None)
if plot is None:
raise FarmError('unknown_plot')
level = plot.land_level + 1
if level >= len(LAND_TYPES):
raise FarmError('land_max_level')
ordinal = 1 + sum(p.land_level >= level for p in plots)
rule = upgrade_rules().get((level, ordinal))
if rule is None:
raise FarmError('land_rule_unavailable')
return plot, level, rule
+1 -1
View File
@@ -52,7 +52,7 @@ def legacy_plot(row, catalogue):
slot = integer(row['soilIndex'], 1, 10_000)
level = integer(row['soilLevel'], 0, 3)
bonuses = dict(yield_bonus=(10, 10, 20, 28)[level],
experience_bonus=28 if level == 3 else 0)
experience_bonus=28 if level == 3 else 0, land_level=level)
planted = row['isSoilPlanted']
if planted not in (None, 0, 1):
raise FarmError('legacy_data_invalid')
+2 -1
View File
@@ -6,8 +6,9 @@ from .domain import Plot, integer
def season_duration(plot: Plot) -> int:
return (plot.plant.growth_seconds if plot.harvest_count == 0
base = (plot.plant.growth_seconds if plot.harvest_count == 0
else plot.plant.regrowth_seconds)
return max(1, (base * (80 if plot.land_level >= 2 else 100) + 99) // 100)
def spawn_interval(plot: Plot) -> int:
+57 -34
View File
@@ -4,33 +4,49 @@ from .domain import Receipt, Snapshot
def receipt_text(receipt: Receipt) -> str:
if receipt.action == 'steal':
if receipt.action == "steal":
data = receipt.data
items = data['items']
lines = [f"偷菜成功:{len(items)}块地,共{data['quantity']}个。"
if items else '本次未偷到作物,不消耗次数。']
items = data["items"]
lines = [
f"偷菜成功:{len(items)}块地,共{data['quantity']}个。"
if items
else "本次未偷到作物,不消耗次数。"
]
grouped = {}
for item in items:
key = (item['plant_id'], item['name'])
grouped[key] = grouped.get(key, 0)+item['quantity']
lines.extend(f'{name}(#{pid})×{amount}'
for (pid, name), amount in grouped.items())
reasons = {'already_stolen': '本季已偷过',
'protected': '防偷保护', 'not_ripe': '无成熟作物'}
notes = [f'{reasons[key]} {count}块'
for key, count in data['skipped'].items() if count]
key = (item["plant_id"], item["name"])
grouped[key] = grouped.get(key, 0) + item["quantity"]
lines.extend(
f"{name}(#{pid})×{amount}"
for (pid, name), amount in grouped.items()
)
reasons = {
"already_stolen": "本季已偷过",
"protected": "防偷保护",
"not_ripe": "无成熟作物",
}
notes = [
f"{reasons[key]} {count}块"
for key, count in data["skipped"].items()
if count
]
if notes:
lines.append('跳过:'+';'.join(notes))
lines.append("跳过:" + ";".join(notes))
lines.append(f"今日剩余:{data['remaining_attempts']}/5次(北京时间)")
return '\n'.join(lines)
return "\n".join(lines)
labels = {
"register": "注册", "buy": "购买", "sow": "播种",
"harvest": "收获", "eradicate": "铲除",
"lock": "锁定设置", "sell": "出售",
"clear_pests": "除虫", "reclaim": "开垦",
"register": "注册",
"buy": "购买",
"sow": "播种",
"harvest": "收获",
"eradicate": "铲除",
"lock": "锁定设置",
"sell": "出售",
"clear_pests": "除虫",
"reclaim": "开垦",
"upgrade": "土地升级",
}
data = receipt.data
account = data["account"]
lines = [f"{labels[receipt.action]}已完成"]
if "created" in data:
lines.append("首次开通" if data["created"] else "农场已存在")
@@ -38,21 +54,23 @@ def receipt_text(receipt: Receipt) -> str:
lines.append(f"实际数量:{data['quantity']}")
if "proceeds" in data:
lines.append(f"本次收入:{data['proceeds']}")
if "experience" in data:
if data.get("experience", 0):
lines.append(f"本次经验:+{data['experience']}")
if receipt.action == 'reclaim':
if receipt.action == "reclaim":
lines.append(f"新增地块:{data['slot']}号普通空地")
lines.append(f"本次消耗:{data['cost']}农场币")
lines.append(f"当前土地:{data['plot_count']}块")
if receipt.action == "upgrade":
lines.append(f"地块{data['slot']}已升为{data['land_name']}")
lines.append(f"本次消耗:{data['cost']}农场币")
if data["matured"]:
lines.append("当前季作物已成熟,请发送「收获」。")
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']}")
return "\n".join(lines)
@@ -60,21 +78,26 @@ def plot_ids_text(snapshot: Snapshot) -> str:
"""无图片能力时按实际快照返回地块编号。"""
owned = sorted(plot.slot for plot in snapshot.plots)
remaining = sorted(set(range(1, 31)) - set(owned))
return '\n'.join((
'地块编号',
f'已开垦({len(owned)}块):' + ('、'.join(map(str, owned)) or '无'),
f'待开垦({len(remaining)}块):'
+ ('、'.join(map(str, remaining)) or '无'),
'前两排交错编号,之后逐排推进。',
'发送「农场详述」查看各地块的作物信息。',
))
return "\n".join(
(
"地块编号",
f"已开垦({len(owned)}块):"
+ ("、".join(map(str, owned)) or "无"),
f"待开垦({len(remaining)}块):"
+ ("、".join(map(str, remaining)) or "无"),
"前两排交错编号,之后逐排推进。",
"发送「农场详述」查看各地块的作物信息。",
)
)
def snapshot_text(snapshot: Snapshot) -> str:
account = snapshot.account
lines = [
account.name, f"等级:{account.level}",
f"农场币:{account.point}", f"点券:{account.vip_point}",
account.name,
f"等级:{account.level}",
f"农场币:{account.point}",
f"点券:{account.vip_point}",
f"累计经验:{account.experience}",
]
for item in snapshot.inventory:
+4 -3
View File
@@ -170,7 +170,7 @@ class SQLiteRepository:
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"], r['stolen_count'],
r["maturity_pests"], r['stolen_count'], r['land_level'],
)
for r in rows
)
@@ -186,13 +186,14 @@ class SQLiteRepository:
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
"yield_bonus=?, experience_bonus=?, pest_count=?, pest_spawned=?, "
"growth_remaining=?, growth_updated_at=?, next_pest_at=?, "
"maturity_pests=?, stolen_count=? WHERE identity=? AND slot=?",
"maturity_pests=?, stolen_count=?, land_level=? "
"WHERE identity=? AND slot=?",
(
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
plot.experience_bonus, plot.pest_count, plot.pest_spawned,
plot.growth_remaining, plot.growth_updated_at,
plot.next_pest_at, plot.maturity_pests, plot.stolen_count,
self._identity, plot.slot,
plot.land_level, self._identity, plot.slot,
),
)
if cursor.rowcount != 1:
+34 -1
View File
@@ -16,7 +16,7 @@ from .catalogue import Catalogue
LOGGER = logging.getLogger(__name__)
APPLICATION_ID = 0x4641524D
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
PEST_COLUMNS = {
"pest_count": (
@@ -73,6 +73,7 @@ SCHEMA = (
yield_bonus INTEGER NOT NULL DEFAULT 0,
experience_bonus INTEGER NOT NULL DEFAULT 0,
stolen_count INTEGER NOT NULL DEFAULT 0 CHECK(stolen_count>=0),
land_level INTEGER NOT NULL DEFAULT 0 CHECK(land_level BETWEEN 0 AND 3),
""" + ",\n ".join(
f"{name} {definition}" for name, definition in PEST_COLUMNS.items()
) + """,
@@ -250,6 +251,38 @@ class SQLiteStore:
))
connection.execute('PRAGMA user_version=4')
LOGGER.info('farm schema migrated version=4 theft=enabled')
version = 4
if app == APPLICATION_ID and version == 4:
self._validate(connection, expected_version=4)
connection.execute(
'ALTER TABLE plots ADD COLUMN land_level '
'INTEGER NOT NULL DEFAULT 0 '
'CHECK(land_level BETWEEN 0 AND 3)'
)
# 从已保留的迁移原始记录恢复等级,不按收益加成猜测。
for row in connection.execute(
"SELECT identity, result FROM operations "
"WHERE action='legacy_import'"
).fetchall():
try:
archive = json.loads(row['result'])
soils = archive['legacy_rows'].get('userSoil', [])
for soil in soils:
level = soil['soilLevel']
slot = soil['soilIndex']
if (type(level) is not int
or not 0 <= level <= 3
or type(slot) is not int or slot < 1):
raise ValueError('Invalid legacy soil')
connection.execute(
'UPDATE plots SET land_level=? '
'WHERE identity=? AND slot=?',
(level, row['identity'], slot),
)
except (KeyError, TypeError, ValueError) as error:
raise SchemaError('legacy_land_invalid') from error
connection.execute('PRAGMA user_version=5')
LOGGER.info('farm schema migrated version=5 land=enabled')
self._validate(connection)
connection.commit()
if migrated_rows is not None:
+8 -3
View File
@@ -50,13 +50,18 @@ class Assets:
else data.joinpath('assets.zip'))
def image(self, name, size=None):
if name not in self.metadata["images"]:
upgrade = name in ('soil/红土地.png', 'soil/黑土地.png', 'soil/金土地.png')
if not upgrade and name not in self.metadata["images"]:
raise KeyError("Unknown packaged image")
size = tuple(size or self.metadata["images"][name]["size"])
size = tuple(size or (self.metadata['soil_size'] if upgrade
else self.metadata["images"][name]["size"]))
key = (name, size)
cached = self.cache.get(key)
if cached is None:
with self._bundle.open("rb") as stream, ZipFile(stream) as bundle:
source = (resources.files(__package__).joinpath(
'data', 'land-soils.zip',
) if upgrade else self._bundle)
with source.open("rb") as stream, ZipFile(stream) as bundle:
content = bundle.read(name)
with Image.open(BytesIO(content)) as original:
original.thumbnail(size, Image.Resampling.LANCZOS)
+12 -1
View File
@@ -5,4 +5,15 @@ assets.zip 原样复制自 Shu-Ying/zhenxun_plugin_farm 的本地保留基线,
仅选取农场季节背景、草地/普通地、作物及两个状态图标;metadata.json
来自旧布局和作物配置。沿用仓库 GPLv3 许可与原始素材归属,迁移不
增加第三方素材权利。后续对外发布前仍须核实历史素材来源及分发权限。
不包含字体;部署方配置可合法使用的中文字体路径。
## 小赖字体
默认字体由 farm_service 的 fonts/assets.zip 提供,插件不内置 TTF。
Xiaolai-Regular.ttf 原样取自 lxgw/kose-font 的 v3.126 发布,
项目地址:https://github.com/lxgw/kose-font 。
下载地址:https://github.com/lxgw/kose-font/releases/download/v3.126/Xiaolai-Regular.ttf
上游 SHA-256:e2f68daf0e72777a8cf58bc83de1b98634b251e537ddbfca24b0ae50d1802da2
字体采用 SIL Open Font License 1.1,完整版权声明与许可见同目录
OFL-Xiaolai.txt;字体不适用本项目代码的 GPLv3 许可。
随软件分发时须保留字体的版权声明和完整许可,不得单独销售字体。
插件缓存中同时保存原版字体和许可;下载摘要由 font-manifest.json 固定。
+94
View File
@@ -0,0 +1,94 @@
Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
{
"schema": 1,
"version": "xiaolai-v3.126",
"sha256": "c2d1c1a5247e05b766d12a8d8410594d78b1a8b86031624a05b76ee0515c1335",
"size_bytes": 14741741,
"sources": {
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/fonts/assets.zip",
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/fonts/assets.zip",
"farm_service": "https://farm.example/v1/fonts/c2d1c1a5247e05b766d12a8d8410594d78b1a8b86031624a05b76ee0515c1335/assets.zip"
}
}
Binary file not shown.
+106
View File
@@ -0,0 +1,106 @@
"""从 service 仓库下载原版小赖字体,校验后原子写入本地缓存。"""
import hashlib
from importlib import resources
import json
from pathlib import Path
import tempfile
from zipfile import ZipFile
from .bundle import _LOCK, _copy_remote, validate_options
FONT_NAME = "Xiaolai-Regular.ttf"
FILES = {
FONT_NAME: (
22220806,
"e2f68daf0e72777a8cf58bc83de1b98634b251e537ddbfca24b0ae50d1802da2",
),
"OFL-Xiaolai.txt": (
4432,
"0df7e09be4c2c850a48bd8beb9cd64b343aad49cd5d3f6cfb2ad2e3d28a56ca4",
),
}
def expected_manifest():
return json.loads(resources.files(__package__).joinpath(
"data", "font-manifest.json",
).read_bytes())
def _valid(path, size, digest):
if not path.is_file() or path.stat().st_size != size:
return False
checksum = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(65536), b""):
checksum.update(chunk)
return checksum.hexdigest() == digest
def _atomic_write(path, content):
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=path.parent, suffix=".download", delete=False,
) as stream:
temporary = Path(stream.name)
stream.write(content)
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def ensure_font(data_dir, source="Github", service_url=""):
"""校验全部缓存文件;无效时下载,始终同时保留完整 OFL 许可。"""
validate_options(source, service_url)
manifest = expected_manifest()
root = Path(data_dir) / "fonts" / manifest["sha256"]
with _LOCK:
if all(_valid(root / name, *checks)
for name, checks in FILES.items()):
return root / FONT_NAME
root.mkdir(parents=True, exist_ok=True)
archive_path = root / "assets.zip"
if not _valid(archive_path, manifest["size_bytes"],
manifest["sha256"]):
url = (
service_url.rstrip("/") + "/v1/fonts/"
+ manifest["sha256"] + "/assets.zip"
if source == "farm_service"
else manifest["sources"][source.lower()]
)
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=root, suffix=".download", delete=False,
) as stream:
temporary = Path(stream.name)
_copy_remote(url, stream, manifest["size_bytes"])
if not _valid(temporary, manifest["size_bytes"],
manifest["sha256"]):
raise ValueError("Font archive size or SHA256 mismatch")
temporary.replace(archive_path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
with ZipFile(archive_path) as archive:
entries = archive.infolist()
if (len(entries) != len(FILES)
or {item.filename for item in entries} != set(FILES)):
raise ValueError("Unexpected font archive contents")
content = {}
for item in entries:
size, digest = FILES[item.filename]
if item.file_size != size or item.flag_bits & 1:
raise ValueError("Invalid font archive member")
value = archive.read(item)
if hashlib.sha256(value).hexdigest() != digest:
raise ValueError("Font member SHA256 mismatch")
content[item.filename] = value
# 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。
for name in ("OFL-Xiaolai.txt", FONT_NAME):
_atomic_write(root / name, content[name])
return root / FONT_NAME
+9 -4
View File
@@ -1,9 +1,10 @@
"""从一次快照与统一时间构造可缓存场景;不访问存储。"""
import hashlib
from dataclasses import replace
from farm_core.domain import Identity, Snapshot, integer
from farm_core.pests import advance_plot
from farm_core.pests import advance_plot, season_duration
from .models import PlotView, Scene
@@ -53,8 +54,7 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
))
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)
duration = season_duration(plot)
elapsed = max(0, now - (plot.mature_at - duration))
work = (elapsed * 100 if plot.growth_remaining is None else
duration * 100 - plot.growth_remaining)
@@ -72,5 +72,10 @@ def scene_from_snapshot(identity, snapshot, now, metadata):
pest_frames=frames,
))
return Scene(
scope, snapshot.account, tuple(views),
scope, snapshot.account, tuple(
replace(view, land_level=plot.land_level)
for view, plot in zip(views, sorted(
snapshot.plots, key=lambda p: p.slot,
))
),
)
+1
View File
@@ -57,6 +57,7 @@ class PlotView:
stage: int = 0 # 本季展示阶段。
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
land_level: int = 0 # 土地等级;影响底图和成图缓存。
@dataclass(frozen=True)
+8 -7
View File
@@ -133,8 +133,9 @@ class FarmRenderer:
)
def _base(self, scene, options):
slots = frozenset(p.slot for p in scene.plots)
key = ("base", options.theme, options.width, slots)
slots = {p.slot: p.land_level for p in scene.plots}
key = ("base", options.theme, options.width,
tuple(sorted(slots.items())))
cached = self.assets.cache.get(key)
if cached is not None:
return Canvas(cached.copy())
@@ -149,14 +150,14 @@ class FarmRenderer:
soil_size = tuple(max(1, s(n)) for n in (
self.assets.metadata["soil_size"]
))
tiles = {
True: self.assets.image("soil/普通土地.png", soil_size),
False: self.assets.image("soil/草土地.png", soil_size),
}
names = ('普通土地', '红土地', '黑土地', '金土地')
tiles = {level: self.assets.image(f'soil/{names[level]}.png', soil_size)
for level in set(slots.values())}
grass = self.assets.image('soil/草土地.png', soil_size)
expansion = False
for slot, pos in enumerate(self.assets.metadata["positions"], 1):
xy = (s(pos["x"]), s(pos["y"]))
canvas.paste(tiles[slot in slots], xy)
canvas.paste(tiles[slots[slot]] if slot in slots else grass, xy)
if slot not in slots and not expansion:
icon = self.assets.image(
"background/expansion.png", (max(1, s(90)),) * 2,
+31 -7
View File
@@ -52,12 +52,10 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
ROOT / 'integrations/astrbot/CHANGELOG.md'
).read_bytes())
readme = files['README.md'].decode()
self.assertIn('/main/logo-readme.png', readme)
self.assertIn('logo.png', readme)
self.assertIn('width="400"', readme)
self.assertNotIn('<!-- FARM_COMMANDS -->', readme)
self.assertIn('/农场帮助 [文字]', readme)
self.assertIn('/开垦 条件', readme)
self.assertIn('raw.githubusercontent.com', readme)
self.assertIn('images/help-astrbot.png', readme)
self.assertIn('images/farm-preview.png', files)
self.assertFalse(any(name.endswith('/assets.zip') for name in files))
from verify_astrbot import verify_package
@@ -124,8 +122,13 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
try:
main = importlib.import_module(package.__name__ + '.main')
plugin = main.FarmPlugin(object(), {
'font_path': str(choose_font()), 'image_quality': 55,
'font_path': 'ignored-custom.ttf',
'data_dir': str(self.root / 'existing-custom-data'),
'image_quality': 55,
})
self.assertEqual(plugin.runtime.data_dir,
(self.root / 'existing-custom-data').resolve())
self.assertIsNone(plugin.runtime.font_path)
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
manifest = json.loads((ROOT /
'src/farm_render/data/asset-manifest.json').read_bytes())
@@ -134,6 +137,15 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
cached.parent.mkdir(parents=True, exist_ok=True)
cached.write_bytes((ROOT /
'src/farm_render/data/assets.zip').read_bytes())
font_manifest = json.loads((ROOT /
'src/farm_render/data/font-manifest.json').read_bytes())
fonts = (plugin.runtime.data_dir / 'fonts'
/ font_manifest['sha256'])
fonts.mkdir(parents=True, exist_ok=True)
(fonts / 'Xiaolai-Regular.ttf').write_bytes(
Path(choose_font()).read_bytes())
(fonts / 'OFL-Xiaolai.txt').write_bytes((ROOT /
'src/farm_render/data/OFL-Xiaolai.txt').read_bytes())
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
@@ -207,12 +219,24 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
self.assertLess(green, 90)
self.assertLess(blue, 90)
self.assertIn('仅我的农场', await call('我的种子 高清'))
self.assertIn('暂无种子', await call('我的种子 文字'))
self.assertIn('参数有误', await call('我的种子 文字'))
self.assertTrue(
(self.root / 'plugin-data/farm.sqlite3').is_file(),
(self.root / 'existing-custom-data/farm.sqlite3').is_file(),
)
self.assertFalse(
(self.root / 'plugin-data/farm.sqlite3').exists(),
)
await plugin.terminate()
self.assertFalse(plugin.runtime.started)
plugin = main.FarmPlugin(object(), {
'data_dir': str(self.root / 'existing-custom-data'),
})
await plugin.initialize()
reopened = Event('开通农场')
reopened.message_obj = SimpleNamespace(message_id='after-reload')
response = [r async for r in plugin.farm(reopened)][0]
self.assertIn('农场已存在', response)
await plugin.terminate()
finally:
if 'plugin' in locals() and plugin.runtime.started:
await plugin.terminate()
+1 -1
View File
@@ -25,7 +25,7 @@ class BuildPluginTests(unittest.TestCase):
@unittest.skipUnless(os.name == 'nt', 'Windows entry points')
def test_cmd_entries_and_nonzero_failure(self):
with tempfile.TemporaryDirectory() as temporary:
with tempfile.TemporaryDirectory(prefix='farm build ') as temporary:
root = Path(temporary)
env = {**os.environ, 'FARM_PYTHON': sys.executable,
'FARM_BUILD_NO_PAUSE': '1'}
+4
View File
@@ -78,6 +78,10 @@ class ConfigMigrationTests(unittest.TestCase):
self.assertEqual(config().farm_image_quality, 75)
self.assertEqual(config().farm_resource_source, 'Github')
self.assertEqual(config().farm_service_url, '')
self.assertEqual(str(config(
farm_data_dir='existing-custom-data',
).farm_data_dir), 'existing-custom-data')
self.assertIsNone(config(farm_data_dir='').farm_data_dir)
for source in ('Github', 'Gitea', 'farm_service'):
selected = config(
farm_resource_source=source,
+16 -1
View File
@@ -18,9 +18,24 @@ class DataDirectoryTests(unittest.TestCase):
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
def test_default_and_explicit_directory(self):
def test_fixed_directory(self):
self.assertEqual(
self.module.data_directory(self.base), self.base / 'farm_db',
)
custom = self.base / 'custom'
self.assertEqual(self.module.data_directory(self.base, custom), custom)
self.assertEqual(self.module.data_directory(self.base, ''),
self.base / 'farm_db')
def test_nonebot_directory_is_fixed(self):
spec = importlib.util.spec_from_file_location(
'nonebot_farm_paths', ROOT / 'integrations/nonebot/data_paths.py',
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertEqual(module.data_directory(self.base),
self.base / 'nonebot_plugin_farm/farm_db')
custom = self.base / 'custom'
self.assertEqual(module.data_directory(self.base, custom), custom)
self.assertEqual(module.data_directory(self.base, ''),
self.base / 'nonebot_plugin_farm/farm_db')
+81
View File
@@ -0,0 +1,81 @@
import hashlib
from io import BytesIO
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
from zipfile import ZIP_DEFLATED, ZipFile
from farm_render import font_bundle
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from ci_font import choose_font
class FontBundleTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.font = Path(choose_font()).read_bytes()
cls.license = (ROOT / "src/farm_render/data/OFL-Xiaolai.txt").read_bytes()
output = BytesIO()
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
archive.writestr("Xiaolai-Regular.ttf", cls.font)
archive.writestr("OFL-Xiaolai.txt", cls.license)
cls.archive = output.getvalue()
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
manifest = font_bundle.expected_manifest()
manifest.update(
sha256=hashlib.sha256(self.archive).hexdigest(),
size_bytes=len(self.archive),
)
self.manifest = manifest
patcher = patch.object(font_bundle, "expected_manifest",
return_value=manifest)
patcher.start()
self.addCleanup(patcher.stop)
def download(self, url, destination, limit):
self.assertEqual(limit, len(self.archive))
destination.write(self.archive)
def test_service_download_cache_and_license_repair(self):
with patch.object(font_bundle, "_copy_remote",
side_effect=self.download) as fetch:
path = font_bundle.ensure_font(
self.root, "farm_service", "https://farm.example/prefix/",
)
self.assertEqual(fetch.call_args.args[0],
"https://farm.example/prefix/v1/fonts/"
+ self.manifest["sha256"] + "/assets.zip")
self.assertEqual(path.read_bytes(), self.font)
license_path = path.with_name("OFL-Xiaolai.txt")
self.assertEqual(license_path.read_bytes(), self.license)
path.write_bytes(b"corrupt")
license_path.unlink()
font_bundle.ensure_font(self.root)
self.assertEqual(fetch.call_count, 1)
self.assertEqual(path.read_bytes(), self.font)
self.assertEqual(license_path.read_bytes(), self.license)
def test_corrupt_download_leaves_no_font_or_partial(self):
def corrupt(url, destination, limit):
destination.write(b"bad")
with patch.object(font_bundle, "_copy_remote", side_effect=corrupt):
with self.assertRaisesRegex(ValueError, "SHA256"):
font_bundle.ensure_font(self.root)
self.assertFalse(list(self.root.rglob("*.ttf")))
self.assertFalse(list(self.root.rglob("*.download")))
def test_mirrors_follow_selected_resource_source(self):
for source in ("Github", "Gitea"):
with patch.object(font_bundle, "_copy_remote",
side_effect=self.download) as fetch:
font_bundle.ensure_font(self.root / source, source)
self.assertEqual(fetch.call_args.args[0],
self.manifest["sources"][source.lower()])
+56
View File
@@ -0,0 +1,56 @@
from io import BytesIO
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
class HelpPackageTests(unittest.TestCase):
def test_prepared_workflow_image_is_used_and_wrong_platform_rejected(self):
from build_zhenxun import build
from help_image import build_help_image
from verify_zhenxun import read_package
with tempfile.TemporaryDirectory() as temporary:
folder = Path(temporary)
prepared = folder / 'help-zhenxun.png'
content = build_help_image(ROOT, 'zhenxun')
prepared.write_bytes(content)
with patch('help_image.subprocess',
SimpleNamespace(run=Mock())) as regenerate:
archive = build(ROOT, folder / 'built', help_image=prepared)
regenerate.run.assert_not_called()
self.assertEqual(
read_package(archive)['images/help-zhenxun.png'], content,
)
with self.assertRaisesRegex(ValueError, 'different platform'):
build_help_image(ROOT, 'astrbot', prepared)
def test_all_packages_embed_matching_generated_help_and_readme(self):
import importlib
with tempfile.TemporaryDirectory() as temporary:
for platform in ('zhenxun', 'nonebot', 'astrbot'):
with self.subTest(platform=platform):
builder = importlib.import_module('build_' + platform)
verifier = importlib.import_module('verify_' + platform)
archive = builder.build(ROOT, Path(temporary) / platform)
files = verifier.read_package(archive)
image_name = f'images/help-{platform}.png'
self.assertIn(image_name, files.keys())
self.assertIn('./' + image_name,
files['README.md'].decode('utf-8'))
for other in ('zhenxun', 'nonebot', 'astrbot'):
if other != platform:
self.assertNotIn(f'images/help-{other}.png',
files.keys())
with Image.open(BytesIO(files[image_name])) as image:
self.assertEqual(image.format, 'PNG')
self.assertEqual(image.width, 1200)
self.assertGreater(image.height, 500)
image.verify()
+189
View File
@@ -0,0 +1,189 @@
import asyncio
import json
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 Asset, Catalogue, FarmService, Identity, SQLiteStore
from farm_core.domain import FarmError, Plot
from farm_core.repository import SQLiteRepository
class UpgradeTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / 'farm.sqlite3'
self.store = SQLiteStore(self.path)
await self.store.initialize()
self.now = 1000
self.service = FarmService(self.store, Catalogue.bundled(),
lambda: self.now)
self.who = Identity('test', 'bot', 'qq', 'upgrade')
await self.service.register(self.who, '农场', 'register')
async def asyncTearDown(self):
await self.store.close()
self.temp.cleanup()
async def fund(self, coins=100000000, exp=10000000):
def operation(repo):
account = repo.account()
repo.asset(Asset.POINT, coins - account.point)
repo.asset(Asset.EXPERIENCE, exp - account.experience)
return {}
await self.store.transact(self.who, f'fund:{coins}:{exp}',
'fixture', {}, self.now, operation)
async def test_tiers_cost_ordinal_replay_and_restart(self):
await self.fund()
self.assertEqual((await self.service.upgrade_conditions(
self.who, 1,
))['coin_cost'], 200000)
first = await self.service.upgrade(self.who, 1, 'red')
self.assertEqual(first.data['land_name'], '红土地')
self.assertEqual(await self.service.upgrade(self.who, 1, 'red'), first)
for operation in ('black', 'gold'):
await self.service.upgrade(self.who, 1, operation)
plot = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual((plot.land_level, plot.yield_bonus,
plot.experience_bonus), (3, 28, 28))
condition = await self.service.upgrade_conditions(self.who, 2)
self.assertEqual((condition['ordinal'], condition['coin_cost']),
(2, 220000))
before = await self.store.snapshot(self.who)
with self.assertRaisesRegex(FarmError, 'land_max_level'):
await self.service.upgrade(self.who, 1, 'fourth')
with self.assertRaisesRegex(FarmError, 'operation_conflict'):
await self.service.upgrade(self.who, 2, 'red')
self.assertEqual(await self.store.snapshot(self.who), before)
await self.store.close()
await self.store.initialize()
self.assertEqual((await self.service.snapshot(self.who)).plots[0], plot)
async def test_current_season_matures_only_once_and_next_season_grows(self):
await self.fund()
plant = next(p for p in self.service.catalogue if p.crop > 1)
await self.service.buy(self.who, f'#{plant.plant_id}', 1, 'buy')
await self.service.sow(self.who, f'#{plant.plant_id}', 1, 'sow')
await self.service.upgrade(self.who, 1, 'red')
plot = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual((plot.mature_at, plot.harvest_count), (self.now, 0))
await self.service.harvest(self.who, 'harvest')
plot = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual(plot.harvest_count, 1)
self.assertGreater(plot.mature_at, self.now)
await self.service.upgrade(self.who, 1, 'black')
plot = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual((plot.harvest_count, plot.mature_at), (1, self.now))
if plant.crop > 2:
await self.service.harvest(self.who, 'harvest2')
next_plot = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual(next_plot.growth_remaining,
(plant.regrowth_seconds * 80 + 99) // 100 * 100)
async def test_level_balance_invalid_slot_and_rollback(self):
for slot in (0, -1, True, 1.5, 31):
with self.assertRaises(FarmError):
await self.service.upgrade(self.who, slot, 'invalid')
with self.assertRaisesRegex(FarmError, 'unknown_plot'):
await self.service.upgrade(self.who, 7, 'missing')
with self.assertRaisesRegex(FarmError, 'level_required'):
await self.service.upgrade(self.who, 1, 'level')
await self.fund(coins=199999)
before = await self.store.snapshot(self.who)
with self.assertRaisesRegex(FarmError, 'insufficient_funds'):
await self.service.upgrade(self.who, 1, 'poor')
self.assertEqual(await self.store.snapshot(self.who), before)
await self.fund()
before = await self.store.snapshot(self.who)
with patch.object(SQLiteRepository, 'save_plot',
side_effect=RuntimeError('fault')):
with self.assertRaisesRegex(RuntimeError, 'fault'):
await self.service.upgrade(self.who, 1, 'rollback')
self.assertEqual(await self.store.snapshot(self.who), before)
async def test_concurrent_same_message_single_charge(self):
await self.fund()
before = await self.store.snapshot(self.who)
results = await asyncio.gather(*(
self.service.upgrade(self.who, 1, 'same') for _ in range(12)
))
self.assertTrue(all(r == results[0] for r in results))
after = await self.store.snapshot(self.who)
self.assertEqual(after.plots[0].land_level, 1)
self.assertEqual(before.account.point - after.account.point, 200000)
async def test_upgrade_preserves_pests_theft_and_withered_state(self):
await self.fund()
plant = next(p for p in self.service.catalogue if p.crop > 1)
original = Plot(1, plant, self.now - 1, pest_count=1, pest_spawned=2,
maturity_pests=2, stolen_count=1)
def operation(repo):
repo.save_plot(original)
repo.save_plot(Plot(2, plant, None, plant.crop))
return {}
await self.store.transact(self.who, 'setup', 'fixture', {}, self.now,
operation)
await self.service.upgrade(self.who, 1, 'ripe')
await self.service.upgrade(self.who, 2, 'withered')
plots = (await self.service.snapshot(self.who)).plots
self.assertEqual((plots[0].maturity_pests, plots[0].stolen_count),
(2, 1))
self.assertTrue(plots[1].withered)
async def test_v4_migration_and_original_legacy_land_levels(self):
await self.store.close()
with closing(sqlite3.connect(self.path)) as db, db:
db.execute('ALTER TABLE plots DROP COLUMN land_level')
db.execute('PRAGMA user_version=4')
db.execute('INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)', (
self.who.key, 'legacy-fixture', 'legacy_import', 'fixture',
json.dumps({'legacy_rows': {'userSoil': [
{'soilIndex': 2, 'soilLevel': 3},
{'soilIndex': 3, 'soilLevel': 1},
]}}), self.now,
))
await self.store.initialize()
plots = (await self.store.snapshot(self.who)).plots
self.assertEqual([p.land_level for p in plots], [0, 3, 1, 0, 0, 0])
# 只恢复明确等级,不重写旧版已经保存的资产和加成。
self.assertEqual(plots[1].yield_bonus, 0)
async def test_growing_pests_freeze_at_forced_maturity(self):
await self.fund()
plant = self.service.catalogue.get('白萝卜')
from farm_core.pests import start_growth
plot = start_growth(Plot(1, plant), self.now)
await self.store.transact(
self.who, 'plant', 'fixture', {}, self.now,
lambda repo: repo.save_plot(plot) or {},
)
self.now += max(7200, (plant.growth_seconds + 1) // 2) + 1
await self.service.upgrade(self.who, 1, 'upgrade-pests')
mature = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual((mature.mature_at, mature.growth_remaining,
mature.maturity_pests), (self.now, 0, 1))
self.assertIsNone(mature.next_pest_at)
self.now += 999999
later = (await self.service.snapshot(self.who)).plots[0]
self.assertEqual(later.pest_count, 1)
async def test_ordinal_above_configured_table_is_rejected(self):
await self.fund()
def operation(repo):
for slot in range(1, 25):
if slot > 6:
repo.add_plot(slot)
repo.save_plot(Plot(slot, land_level=1))
repo.add_plot(25)
return {}
await self.store.transact(self.who, '24-red', 'fixture', {}, self.now,
operation)
before = await self.store.snapshot(self.who)
with self.assertRaisesRegex(FarmError, 'land_rule_unavailable'):
await self.service.upgrade(self.who, 25, '25th-red')
self.assertEqual(await self.store.snapshot(self.who), before)
+11 -1
View File
@@ -29,9 +29,19 @@ class NoneBotPackageTests(unittest.TestCase):
ROOT / "integrations/shared/runtime.py"
).read_bytes())
for suffix in ("farm_core/data/plants.json",
"farm_render/data/asset-manifest.json"):
"farm_render/data/asset-manifest.json",
"farm_render/data/font-manifest.json",
"farm_render/data/OFL-Xiaolai.txt"):
self.assertTrue(any(name.endswith(suffix) for name in self.files))
self.assertEqual(verify_files(self.files)["target"], "nonebot")
self.assertFalse(any(name.endswith('.ttf') for name in self.files))
for name in ("font-manifest.json", "OFL-Xiaolai.txt"):
self.assertEqual(
self.files[
"nonebot_plugin_farm/_vendor/farm_render/data/" + name
],
(ROOT / "src/farm_render/data" / name).read_bytes(),
)
self.assertIn("pyproject.toml", self.files)
self.assertIn(".github/workflows/publish.yaml", self.files)
self.assertFalse(any("legacy/" in name for name in self.files))
+23
View File
@@ -60,6 +60,29 @@ class RenderTests(unittest.TestCase):
self.assertEqual(canvas.image.getpixel((x-15, y))[:3],
ImageColor.getrgb(color))
def test_land_upgrade_sprites_and_both_caches_change(self):
baseline = snapshot()
options = RenderOptions(format='PNG')
original = self.renderer.render(self.who, baseline, 0, options)
for level, name in ((1, '红土地'), (2, '黑土地'), (3, '金土地')):
current = replace(baseline, plots=(
replace(baseline.plots[0], land_level=level),
*baseline.plots[1:],
))
with patch.object(self.renderer.assets, 'image',
wraps=self.renderer.assets.image) as read:
result = self.renderer.render(self.who, current, 0, options)
self.assertFalse(result.cache_hit)
self.assertNotEqual(result.content, original.content)
self.assertIn(f'soil/{name}.png',
[call.args[0] for call in read.call_args_list])
self.assertTrue(self.renderer.render(
self.who, current, 0, options,
).cache_hit)
self.assertEqual(self.renderer.render(
self.who, baseline, 0, options,
).content, original.content)
def test_plot_id_image_has_separate_cache_from_normal_farm(self):
plain = self.renderer.render(self.who, snapshot(), 0)
options = RenderOptions(show_plot_ids=True)
+1
View File
@@ -142,6 +142,7 @@ class StealingTests(unittest.IsolatedAsyncioTestCase):
with closing(sqlite3.connect(self.store.path)) as db, db:
db.execute('DROP TABLE thefts')
db.execute('ALTER TABLE plots DROP COLUMN stolen_count')
db.execute('ALTER TABLE plots DROP COLUMN land_level')
db.execute('PRAGMA user_version=3')
await self.store.initialize()
self.assertEqual(await self.service.snapshot(self.owner), before)
+137 -14
View File
@@ -48,6 +48,15 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
/ bundle.expected_manifest()['sha256'] / 'assets.zip')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / 'src/farm_render/data/assets.zip', target)
fonts = importlib.import_module(
self.package + '._vendor.farm_render.font_bundle',
)
font_dir = (runtime.data_dir / 'fonts'
/ fonts.expected_manifest()['sha256'])
font_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(choose_font(), font_dir / 'Xiaolai-Regular.ttf')
shutil.copyfile(ROOT / 'src/farm_render/data/OFL-Xiaolai.txt',
font_dir / 'OFL-Xiaolai.txt')
async def call(self, action, arguments="", message="event", **changes):
return await self.runtime.execute(
@@ -55,12 +64,60 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
**{**self.context, "message_id":message, **changes},
)
async def test_removed_commands_and_text_options(self):
for platform in ('zhenxun', 'nonebot', 'astrbot'):
self.assertNotIn('我的农场币', self.module.command_map(platform))
self.assertNotIn('我的点券', self.module.command_map(platform))
await self.call('register')
for action in ('seeds', 'crops', 'help'):
for args in ('文字', '1 文字'):
with self.subTest(action=action, args=args):
self.assertIn('参数有误', await self.call(action, args))
for alias in ('my-point', 'my-vipPoint'):
self.assertIn('参数有误', await self.call('farm', alias))
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1'), bytes)
async def test_land_upgrade_command_conditions_charge_and_replay(self):
self.assertEqual(self.module.COMMANDS['土地升级'], 'upgrade')
await self.call('register')
from unittest.mock import AsyncMock
asset = importlib.import_module(
self.package + '._vendor.farm_core.domain',
).Asset
def fund(repo):
repo.asset(asset.POINT, 999500)
repo.asset(asset.EXPERIENCE, 1000000)
return {}
await self.runtime.store.transact(self.runtime_identity(), 'fund',
'fixture', {}, 1000, fund)
with patch.object(self.runtime.service, 'upgrade',
new=AsyncMock()) as upgrade:
condition = await self.call('upgrade', '1 条件', message=None)
self.assertIn('红土地', condition)
self.assertIn('200000', condition)
upgrade.assert_not_awaited()
self.assertIn('稳定消息标识', await self.call(
'upgrade', '1', message=None,
))
upgrade.assert_not_awaited()
result = await self.call('upgrade', '1', message='upgrade-red')
self.assertIn('地块1已升为红土地', result)
self.assertEqual(result, await self.call(
'upgrade', '1', message='upgrade-red',
))
snapshot = await self.runtime.service.snapshot(self.runtime_identity())
self.assertEqual(snapshot.account.point, 800000)
self.assertEqual(snapshot.plots[0].land_level, 1)
self.assertIn('地块不存在', await self.call('upgrade', '7'))
self.assertIn('请发送', await self.call('upgrade', '1 2'))
async def test_plot_ids_command_registration_hint_and_read_only(self):
from io import BytesIO
from PIL import Image
from unittest.mock import AsyncMock
self.assertEqual(self.module.COMMANDS['地块编号'], 'plot_ids')
self.assertIn('地块编号', await self.call('help', '文字'))
self.assertIn('地块编号', self.module.help_text(self.runtime.command_specs))
first = await self.call('register')
self.assertIn('6块土地', first)
self.assertIn('地块编号', first)
@@ -91,7 +148,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
migrate.assert_not_awaited()
with Image.open(BytesIO(image)) as decoded:
self.assertEqual(decoded.width, 1200)
text = await self.call('help', '文字', message=None)
text = self.module.help_text(self.runtime.command_specs)
for command in self.module.COMMANDS:
self.assertIn(command, text)
self.assertNotIn('购买农场币', text)
@@ -117,11 +174,11 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(render.await_args.args[-1], 91)
self.runtime.image_quality_provider = lambda: False
self.assertIn('图片质量配置无效', await self.call('help'))
self.assertIsInstance(await self.call('help', '文字'), str)
self.assertIsInstance(self.module.help_text(self.runtime.command_specs), str)
async def test_reclaim_conditions_charge_replay_and_arguments(self):
self.assertEqual(self.module.COMMANDS['开垦'], 'reclaim')
self.assertIn('开垦 条件', await self.call('help', '文字'))
self.assertIn('开垦 条件', self.module.help_text(self.runtime.command_specs))
await self.call('register')
who = self.runtime_identity()
asset = importlib.import_module(
@@ -180,7 +237,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('仅我的农场支持高清', await self.call(
'seeds', '1 高清', message=None,
))
self.assertIn('胡萝卜(#1) 2粒', await self.call(
self.assertIn('参数有误', await self.call(
'seeds', '文字', message=None,
))
self.assertEqual(before, await self.runtime.service.snapshot(
@@ -264,7 +321,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
with Image.open(BytesIO(content)) as image:
self.assertEqual(image.width, 1536)
async def test_only_farm_accepts_high_and_warehouse_keeps_text(self):
async def test_only_farm_accepts_high_and_text_option_is_removed(self):
await self.call('register')
for action in ('plot_ids', 'details', 'shop', 'seeds', 'crops'):
for arguments in ('高清', '1 高清'):
@@ -273,9 +330,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
action, arguments, message=None,
))
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1 文字'), str)
help_text = await self.call('help', '文字')
self.assertIn('仅我的农场支持高清', help_text)
self.assertIn('参数有误', await self.call(action, '1 文字'))
help_text = self.module.help_text(self.runtime.command_specs)
self.assertNotIn('地块编号 [高清]', help_text)
self.assertNotIn('[高清/文字]', help_text)
@@ -376,9 +432,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_pest_command_clears_pests_and_preserves_growth(self):
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
help_text = await self.call("help", "文字")
help_text = self.module.help_text(self.runtime.command_specs)
self.assertIn("每只虫", help_text)
self.assertIn("间隔至少2小时", help_text)
self.runtime.service.clock = lambda: 1000
await self.call("register")
await self.call("buy", "白萝卜 1", "buy")
@@ -466,11 +521,11 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
await other.start()
try:
response = await other.execute("farm", "", **self.context)
self.assertIn("FARM_PREVIEW_FONT", response)
self.assertIn("小赖字体", response)
finally:
await other.close()
async def test_default_font_uses_host_resources_and_skips_broken_font(self):
async def test_default_font_uses_service_cache_xiaolai(self):
fonts = self.root / "host-fonts"
fonts.mkdir()
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
@@ -482,7 +537,9 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNotNone(other.rendering)
self.assertEqual(
other.rendering.renderer.font_path,
(fonts / "msyh.ttf").resolve(),
next((other.data_dir / "fonts").glob(
"*/Xiaolai-Regular.ttf",
)).resolve(),
)
await other.execute("register", "", **self.context)
result = await other.execute("farm", "", **self.context)
@@ -490,6 +547,19 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
finally:
await other.close()
async def test_font_download_failure_keeps_text_commands_available(self):
other = self.module.Runtime(self.root / "offline-font")
self.seed_assets(other)
with patch.object(self.module, 'ensure_font', side_effect=OSError()):
await other.start()
try:
self.assertIsNone(other.rendering)
text = await other.execute("register", "", **self.context)
self.assertIsInstance(text, str)
self.assertIn("注册", text)
finally:
await other.close()
async def test_explicit_font_overrides_host_defaults(self):
explicit = Path(choose_font()).resolve()
other = self.module.Runtime(
@@ -503,6 +573,59 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
finally:
await other.close()
async def test_download_failure_stops_until_explicit_reload(self):
for resource in ('assets', 'font'):
other = self.module.Runtime(self.root / ('stop-' + resource))
self.seed_assets(other)
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=TimeoutError()) as fetch:
with patch.object(self.module, 'monotonic',
return_value=1000, create=True):
await other.start()
self.assertTrue(other.started)
self.assertIsNone(other.rendering)
for now in (1001, 1061, 100000):
with patch.object(self.module, 'monotonic',
return_value=now, create=True):
await asyncio.gather(*(
other.start() for _ in range(10)
))
response = await other.execute(
'help', '', **self.context,
)
self.assertIsInstance(response, str)
await asyncio.sleep(0)
self.assertEqual(fetch.call_count, 1)
await other.close()
await other.start()
self.assertIsNotNone(other.rendering)
finally:
await other.close()
async def test_resource_logs_identify_stage_without_private_errors(self):
from urllib.error import HTTPError
for resource, failure, reason in (
('assets', HTTPError('https://private/token', 404,
'private-token', {}, None), 'http_404'),
('font', ValueError('Font archive size or SHA256 mismatch'),
'integrity_mismatch'),
):
other = self.module.Runtime(self.root / ('failed-' + resource))
self.seed_assets(other)
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=failure), \
self.assertLogs(self.module.logger, 'WARNING') as logs:
await other.start()
output = '\n'.join(logs.output)
self.assertIn('resource=' + resource, output)
self.assertIn('reason=' + reason, output)
self.assertNotIn('private', output)
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")