mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 13:10:02 +08:00
add # 新增使用开源字体
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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) *阿咪为什么是神*
|
||||
|
||||
@@ -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
@@ -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无损格式不受此值影响。
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、
|
||||
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。
|
||||
农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。
|
||||
[](https://qm.qq.com/q/7hsOD4rOw2)
|
||||
|
||||

|
||||
</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. 成熟后 `/收获`,用 `/我的作物` 查看库存,或 `/出售作物` 出售。
|
||||
---
|
||||
|
||||
发送 `/农场帮助` 查看当前版本自动生成的图片菜单;
|
||||
发送 `/农场帮助 文字` 查看文字版。仅 `/我的农场 高清` 支持高清。
|
||||
## 农场界面
|
||||
|
||||
## 指令
|
||||

|
||||
|
||||
`[参数]` 表示可选。作物支持名称或 `#编号`;同名作物请使用编号。
|
||||
仓库默认图片,例如 `/我的种子 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`),其他平台尚未完成真实环境验证。
|
||||
## 农场帮助
|
||||

|
||||
|
||||
插件不携带大型`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
@@ -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">
|
||||
|
||||

|
||||
<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 启动脚本中加入:
|
||||
[](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 文字”。
|
||||
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
|
||||
## 农场界面
|
||||
|
||||
## 当前命令
|
||||

|
||||
|
||||
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
|
||||
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
|
||||
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
|
||||
发送「农场帮助」查看参数。图片与提及由 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 和实际机器人消息行为
|
||||
需要在部署环境进一步验证。
|
||||
## 农场帮助
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 待办事宜 `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
@@ -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] | 将土地升级,带来收益提升 | 如果土地升级时,土地有播种作物,那么将直接成熟 |
|
||||
## 农场帮助
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 更新日志[(详细)](./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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,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__":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 固定。
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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
|
||||
@@ -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,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ class PlotView:
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
pest_count: int = 0 # 当前可见虫数;参与成图缓存键。
|
||||
pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。
|
||||
land_level: int = 0 # 土地等级;影响底图和成图缓存。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()])
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user