diff --git a/.github/workflows/astrbot-preview.yml b/.github/workflows/astrbot-preview.yml index d02ec5e..8103cec 100644 --- a/.github/workflows/astrbot-preview.yml +++ b/.github/workflows/astrbot-preview.yml @@ -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: | diff --git a/.github/workflows/nonebot-preview.yml b/.github/workflows/nonebot-preview.yml index dc7f1fd..49bf727 100644 --- a/.github/workflows/nonebot-preview.yml +++ b/.github/workflows/nonebot-preview.yml @@ -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 diff --git a/.github/workflows/zhenxun-preview.yml b/.github/workflows/zhenxun-preview.yml index 4d232f2..7eba477 100644 --- a/.github/workflows/zhenxun-preview.yml +++ b/.github/workflows/zhenxun-preview.yml @@ -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/ diff --git a/README.md b/README.md index e3f8324..0c1eb31 100644 --- a/README.md +++ b/README.md @@ -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) *阿咪为什么是神* diff --git a/docs/10-rendering.md b/docs/10-rendering.md index 91e252e..8baebfe 100644 --- a/docs/10-rendering.md +++ b/docs/10-rendering.md @@ -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 字形。 ## 调用 diff --git a/docs/12-nonebot.md b/docs/12-nonebot.md index 8484be9..3b1bdca 100644 --- a/docs/12-nonebot.md +++ b/docs/12-nonebot.md @@ -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无损格式不受此值影响。 diff --git a/docs/18-web-panel-and-data-directory.md b/docs/18-web-panel-and-data-directory.md index c06c22d..5ad93c8 100644 --- a/docs/18-web-panel-and-data-directory.md +++ b/docs/18-web-panel-and-data-directory.md @@ -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 或可能含凭据的异常正文。 + 本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。 diff --git a/docs/land-rules.md b/docs/land-rules.md index 19446a7..b5e2c1d 100644 --- a/docs/land-rules.md +++ b/docs/land-rules.md @@ -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。重复注册不额外发地。 diff --git a/images/help-astrbot.png b/images/help-astrbot.png new file mode 100644 index 0000000..00d1eca Binary files /dev/null and b/images/help-astrbot.png differ diff --git a/images/help-nonebot.png b/images/help-nonebot.png new file mode 100644 index 0000000..fae7e84 Binary files /dev/null and b/images/help-nonebot.png differ diff --git a/images/help-zhenxun.png b/images/help-zhenxun.png new file mode 100644 index 0000000..10b4b13 Binary files /dev/null and b/images/help-zhenxun.png differ diff --git a/integrations/astrbot/README.md b/integrations/astrbot/README.md index f321a3a..bfe8428 100644 --- a/integrations/astrbot/README.md +++ b/integrations/astrbot/README.md @@ -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配置的唤醒前缀发送,例如`/开通农场`、`/农场帮助`、 `/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。 diff --git a/integrations/astrbot/_conf_schema.json b/integrations/astrbot/_conf_schema.json index d7713f0..d6aaa37 100644 --- a/integrations/astrbot/_conf_schema.json +++ b/integrations/astrbot/_conf_schema.json @@ -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", diff --git a/integrations/astrbot/main.py b/integrations/astrbot/main.py index d0dfdf5..6fa2cbe 100644 --- a/integrations/astrbot/main.py +++ b/integrations/astrbot/main.py @@ -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', diff --git a/integrations/nonebot/commands.py b/integrations/nonebot/commands.py index c41add2..f310823 100644 --- a/integrations/nonebot/commands.py +++ b/integrations/nonebot/commands.py @@ -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, diff --git a/integrations/nonebot/config.py b/integrations/nonebot/config.py index 66e81dd..4cd1f1f 100644 --- a/integrations/nonebot/config.py +++ b/integrations/nonebot/config.py @@ -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 diff --git a/integrations/nonebot/data_paths.py b/integrations/nonebot/data_paths.py index 68bc2a8..cadff26 100644 --- a/integrations/nonebot/data_paths.py +++ b/integrations/nonebot/data_paths.py @@ -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" diff --git a/integrations/shared/runtime.py b/integrations/shared/runtime.py index e284d06..a019487 100644 --- a/integrations/shared/runtime.py +++ b/integrations/shared/runtime.py @@ -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: diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py index 0a8b2d8..1b93851 100644 --- a/integrations/zhenxun/commands.py +++ b/integrations/zhenxun/commands.py @@ -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, ) diff --git a/integrations/zhenxun/data_paths.py b/integrations/zhenxun/data_paths.py index d21cb70..94904b1 100644 --- a/integrations/zhenxun/data_paths.py +++ b/integrations/zhenxun/data_paths.py @@ -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' diff --git a/pyproject.toml b/pyproject.toml index 8db871f..e325b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/readme/README.md b/readme/README.md index 0d4bf44..c1dc494 100644 --- a/readme/README.md +++ b/readme/README.md @@ -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,随包附带完整字体许可* diff --git a/readme/astrbot.md b/readme/astrbot.md index 244c964..90022f3 100644 --- a/readme/astrbot.md +++ b/readme/astrbot.md @@ -1,102 +1,106 @@ +
+
-# 农场 · AstrBot
+-开通自己的农场,购买种子、播种、收获、管理仓库,与群友一起偷菜、开垦土地。 -农场图片、商店、仓库和帮助菜单均可在聊天中查看,存档保存在本地。 +[](https://qm.qq.com/q/7hsOD4rOw2) - +
-## 安装与快速开始 +你是说可以种地对吧🤔? -在 AstrBot 插件市场搜索 `astrbot_plugin_farm`,或在插件管理中选择 -「从文件安装」,上传作者提供的完整插件 ZIP。不要只上传 `main.py`。 +
-
+
-## 安装与配置
+-需要 Python 3.10 及以上。生成仓库中执行 `pip install .`,或安装经过 -标签发布的 `nonebot_plugin_farm` 包,然后在 NoneBot 启动脚本中加入: +[](https://qm.qq.com/q/7hsOD4rOw2) -```python -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_RESOURCE_SOURCE=Github -FARM_SERVICE_URL= -``` +
-> 资源配置:「资源获取地址」默认Github,可选Github、Gitea、farm_service;
-> 「farm_service 地址」始终独立存在,供资源及后续活动服务共用。
-> 选择farm_service下载时填写HTTPS基础地址,两项修改后重载。
-> 插件不携带assets.zip,启动校验缓存SHA256,缺失或不匹配时从所选源获取;
-> Github/Gitea均使用farm_service仓库根目录assets/。失败保留文字功能,重载重试。
-> 活动服务尚未实现,请先发布服务仓库素材再发布插件。
@@ -20,12 +14,6 @@
-
-
-
-
-
-
@@ -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) diff --git a/release/nonebot/MANIFEST.in b/release/nonebot/MANIFEST.in index b1316a2..d01586f 100644 --- a/release/nonebot/MANIFEST.in +++ b/release/nonebot/MANIFEST.in @@ -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 diff --git a/release/nonebot/pyproject.toml b/release/nonebot/pyproject.toml index c00fa75..82182b8 100644 --- a/release/nonebot/pyproject.toml +++ b/release/nonebot/pyproject.toml @@ -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", ] diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py index 60aae35..ae778c4 100644 --- a/scripts/build_astrbot.py +++ b/scripts/build_astrbot.py @@ -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 = '' + 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__': diff --git a/scripts/build_nonebot.py b/scripts/build_nonebot.py index fbe1b48..052d66d 100644 --- a/scripts/build_nonebot.py +++ b/scripts/build_nonebot.py @@ -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__": diff --git a/scripts/build_plugin.ps1 b/scripts/build_plugin.ps1 index 4301d5d..0b2417b 100644 --- a/scripts/build_plugin.ps1 +++ b/scripts/build_plugin.ps1 @@ -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 } diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index 6093546..2504408 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -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__": diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index b2ef6c4..62f7dc2 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -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"] diff --git a/scripts/ci_font.py b/scripts/ci_font.py index e9b24a8..c978473 100644 --- a/scripts/ci_font.py +++ b/scripts/ci_font.py @@ -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__": diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index c5a19b6..9a3264c 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -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 diff --git a/scripts/help_image.py b/scripts/help_image.py new file mode 100644 index 0000000..9077421 --- /dev/null +++ b/scripts/help_image.py @@ -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 diff --git a/scripts/preview_help.py b/scripts/preview_help.py new file mode 100644 index 0000000..7cb652f --- /dev/null +++ b/scripts/preview_help.py @@ -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() diff --git a/scripts/verify_astrbot.py b/scripts/verify_astrbot.py index f40b99e..cab9c1d 100644 --- a/scripts/verify_astrbot.py +++ b/scripts/verify_astrbot.py @@ -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): diff --git a/scripts/verify_nonebot.py b/scripts/verify_nonebot.py index d9281e2..ba716e0 100644 --- a/scripts/verify_nonebot.py +++ b/scripts/verify_nonebot.py @@ -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): diff --git a/scripts/verify_zhenxun.py b/scripts/verify_zhenxun.py index 979c283..7e00884 100644 --- a/scripts/verify_zhenxun.py +++ b/scripts/verify_zhenxun.py @@ -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): diff --git a/src/farm_core/application.py b/src/farm_core/application.py index 731884e..7bfc3e4 100644 --- a/src/farm_core/application.py +++ b/src/farm_core/application.py @@ -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, ): diff --git a/src/farm_core/commands.py b/src/farm_core/commands.py index fb62d90..536d2ef 100644 --- a/src/farm_core/commands.py +++ b/src/farm_core/commands.py @@ -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)) diff --git a/src/farm_core/domain.py b/src/farm_core/domain.py index 7893c06..40393bb 100644 --- a/src/farm_core/domain.py +++ b/src/farm_core/domain.py @@ -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) diff --git a/src/farm_core/land.py b/src/farm_core/land.py index fdb9fa8..402d5bc 100644 --- a/src/farm_core/land.py +++ b/src/farm_core/land.py @@ -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 diff --git a/src/farm_core/legacy.py b/src/farm_core/legacy.py index 3423eb5..c31d7d8 100644 --- a/src/farm_core/legacy.py +++ b/src/farm_core/legacy.py @@ -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') diff --git a/src/farm_core/pests.py b/src/farm_core/pests.py index 6dc2178..d467b3c 100644 --- a/src/farm_core/pests.py +++ b/src/farm_core/pests.py @@ -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: diff --git a/src/farm_core/presentation.py b/src/farm_core/presentation.py index 48fbbd0..095e7d6 100644 --- a/src/farm_core/presentation.py +++ b/src/farm_core/presentation.py @@ -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: diff --git a/src/farm_core/repository.py b/src/farm_core/repository.py index ec67560..4e1bcbc 100644 --- a/src/farm_core/repository.py +++ b/src/farm_core/repository.py @@ -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: diff --git a/src/farm_core/storage.py b/src/farm_core/storage.py index f0b12bb..f13dcee 100644 --- a/src/farm_core/storage.py +++ b/src/farm_core/storage.py @@ -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: diff --git a/src/farm_render/assets.py b/src/farm_render/assets.py index 71c6b78..895af2c 100644 --- a/src/farm_render/assets.py +++ b/src/farm_render/assets.py @@ -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) diff --git a/src/farm_render/data/NOTICE.md b/src/farm_render/data/NOTICE.md index 9973a03..c2660f0 100644 --- a/src/farm_render/data/NOTICE.md +++ b/src/farm_render/data/NOTICE.md @@ -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 固定。 diff --git a/src/farm_render/data/OFL-Xiaolai.txt b/src/farm_render/data/OFL-Xiaolai.txt new file mode 100644 index 0000000..2fea47d --- /dev/null +++ b/src/farm_render/data/OFL-Xiaolai.txt @@ -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. diff --git a/src/farm_render/data/font-manifest.json b/src/farm_render/data/font-manifest.json new file mode 100644 index 0000000..659e988 --- /dev/null +++ b/src/farm_render/data/font-manifest.json @@ -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" + } +} diff --git a/src/farm_render/data/land-soils.zip b/src/farm_render/data/land-soils.zip new file mode 100644 index 0000000..9077ef8 Binary files /dev/null and b/src/farm_render/data/land-soils.zip differ diff --git a/src/farm_render/font_bundle.py b/src/farm_render/font_bundle.py new file mode 100644 index 0000000..932c117 --- /dev/null +++ b/src/farm_render/font_bundle.py @@ -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 diff --git a/src/farm_render/layout.py b/src/farm_render/layout.py index 8dd2cbc..15369a3 100644 --- a/src/farm_render/layout.py +++ b/src/farm_render/layout.py @@ -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, + )) + ), ) diff --git a/src/farm_render/models.py b/src/farm_render/models.py index 744912d..c41ca5f 100644 --- a/src/farm_render/models.py +++ b/src/farm_render/models.py @@ -57,6 +57,7 @@ class PlotView: stage: int = 0 # 本季展示阶段。 pest_count: int = 0 # 当前可见虫数;参与成图缓存键。 pest_frames: tuple[str, ...] = () # 按虫序稳定选择的包内帧,参与缓存键。 + land_level: int = 0 # 土地等级;影响底图和成图缓存。 @dataclass(frozen=True) diff --git a/src/farm_render/renderer.py b/src/farm_render/renderer.py index 3d890f7..d053bcc 100644 --- a/src/farm_render/renderer.py +++ b/src/farm_render/renderer.py @@ -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, diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py index 0ec8e66..7d6e826 100644 --- a/tests/test_astrbot_package.py +++ b/tests/test_astrbot_package.py @@ -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('', 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() diff --git a/tests/test_build_plugin.py b/tests/test_build_plugin.py index 6a910b2..3451776 100644 --- a/tests/test_build_plugin.py +++ b/tests/test_build_plugin.py @@ -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'} diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py index ec42c63..c51fb09 100644 --- a/tests/test_config_migration.py +++ b/tests/test_config_migration.py @@ -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, diff --git a/tests/test_data_directory.py b/tests/test_data_directory.py index 53066f3..ef9b9cf 100644 --- a/tests/test_data_directory.py +++ b/tests/test_data_directory.py @@ -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') diff --git a/tests/test_font_bundle.py b/tests/test_font_bundle.py new file mode 100644 index 0000000..ccfcc3a --- /dev/null +++ b/tests/test_font_bundle.py @@ -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()]) diff --git a/tests/test_help_packages.py b/tests/test_help_packages.py new file mode 100644 index 0000000..503e23a --- /dev/null +++ b/tests/test_help_packages.py @@ -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() diff --git a/tests/test_land_upgrade.py b/tests/test_land_upgrade.py new file mode 100644 index 0000000..01be87c --- /dev/null +++ b/tests/test_land_upgrade.py @@ -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) diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py index 524d4bf..b8f5eea 100644 --- a/tests/test_nonebot_package.py +++ b/tests/test_nonebot_package.py @@ -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)) diff --git a/tests/test_render.py b/tests/test_render.py index 8e0c5e6..e92c8dd 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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) diff --git a/tests/test_stealing.py b/tests/test_stealing.py index 1a416eb..ec557f8 100644 --- a/tests/test_stealing.py +++ b/tests/test_stealing.py @@ -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) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index b2277b4..69768ad 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -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")