diff --git a/docs/19-seed-shop.md b/docs/19-seed-shop.md new file mode 100644 index 0000000..640be30 --- /dev/null +++ b/docs/19-seed-shop.md @@ -0,0 +1,30 @@ +# 文字指令种子商店 + +采用用户选定的方案二:木质价目板、单列六种。发送 `种子商店` 或 +`种子商店 2` 返回 1024×1536 PNG;标题、余额、商品信息、购买示例和 +页码都由程序绘制。原始作物 icon 复用包内资源;缺图显示“暂无图”。 + +显示农场等级、农场币和点券,商品显示稳定本地编号、名称、实际单价、 +币种、购买等级、首熟时长、可收获季数和每季基础产量。 +首熟时长和产量是目录基础值,虫害等实际种植结算仍由核心处理。 +等级不足时突出“需 Lv.…”。未开通用户可浏览,购买仍需先开通农场。 + +只展示 buyable 作物,按本地编号排序,每页六种。最后一页不补假商品, +越界页码返回中文提示,页数按实际目录计算。字体不可用时返回同样分页的 +文字列表。图片通过既有有限任务队列绘制,不在事件循环里执行 Pillow。 +相同待处理请求合并,关闭时等待任务排空。 + +购买入口保持核心事务和消息去重不变: + +- `购买种子 #1 5` +- `购买种子 胡萝卜 5` +- 名称有空格时使用引号;重名作物须用编号。 + +木牌空白底图为内置图像生成工具生成,非 QQ 农场原始资源。源文件 +`src/farm_render/source_assets/webgl/shop-board.png`,由素材导出脚本加入 +assets.zip,并在 metadata.json 保存 SHA-256。底图不含商品、价格和文字。 +生成提示要点:1024×1536,QQ 农场风格暖色手绘木牌、橙色标题、米色纸面、 +单列六个空白商品条、底部文字指令区;禁止生成文字、数字和商品图。 + +执行 `scripts/preview_shop.py --output <目录>` 可复现首页、第二页、末页与 +未开通预览。预览余额为演示值,商品使用实际目录。此改动不部署、不重启。 diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py index e0f8b21..554ae30 100644 --- a/integrations/zhenxun/commands.py +++ b/integrations/zhenxun/commands.py @@ -16,6 +16,7 @@ from zhenxun.utils.platform import PlatformUtils from .runtime import COMMANDS, Runtime from .data_paths import data_directories +from .config_migration import migrate_config logger = logging.getLogger(__name__) driver = get_driver() @@ -30,7 +31,14 @@ runtime = Runtime( font_dir=FONT_PATH, legacy_data_dir=legacy_data_dir, ) -driver.on_startup(runtime.start) +async def start_runtime(): + from zhenxun.configs.config import Config + + migrate_config(Config, __package__.split(".")[-1]) + await runtime.start() + + +driver.on_startup(start_runtime) driver.on_shutdown(runtime.close) _avatars = {} diff --git a/integrations/zhenxun/config_migration.py b/integrations/zhenxun/config_migration.py new file mode 100644 index 0000000..e06adee --- /dev/null +++ b/integrations/zhenxun/config_migration.py @@ -0,0 +1,32 @@ +"""仅清理农场已废弃配置,保存备份并同步网页配置数据。""" + +import logging +import shutil +from pathlib import Path + + +OBSOLETE_KEYS = ( + "绘制农场清晰度", "动态背景", "签到图片样式", "兑换倍数", + "点券兑换倍数", "手续费", "服务地址", +) + + +def migrate_config(manager, module): + group = manager.get(module) + simple = manager._simple_data.get(module, {}) + removed = [key for key in OBSOLETE_KEYS + if key in group.configs or key in simple] + if not removed: + return + for source in (manager.file, manager._simple_file): + source = Path(source) + backup = source.with_name(source.name + ".farm-v1.bak") + if source.exists() and not backup.exists(): + shutil.copy2(source, backup) + for key in removed: + group.configs.pop(key, None) + simple.pop(key, None) + manager.save(save_simple_data=True) + logging.getLogger(__name__).info( + "farm config migration module=%s removed_keys=%s", module, removed, + ) diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 76d0eec..6615a4b 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -12,6 +12,7 @@ from ._vendor.farm_core.storage import SCHEMA_VERSION from ._vendor.farm_core.presentation import receipt_text, snapshot_text from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService from ._vendor.farm_render.seasons import season_for +from ._vendor.farm_render.shop import shop_page from .data_paths import prepare_directory logger = logging.getLogger(__name__) @@ -167,17 +168,29 @@ class Runtime: return HELP if action == "shop": page = int(args[0]) if args else 1 - if not 1 <= page <= 100 or len(args) > 1: + if len(args) > 1: raise ValueError() - plants = sorted( - (p for p in self.service.catalogue if p.buyable), - key=lambda p: p.plant_id, - ) - chunk = plants[(page-1)*12:page*12] - return "种子商店 " + str(page) + "页\n" + "\n".join( - f"{p.name}(#{p.plant_id}):" - f"{p.cost}{'点券' if p.is_vip else '农场币'}" - f" / 等级{p.level}" for p in chunk + try: + chunk, pages = shop_page(self.service.catalogue, page) + except ValueError: + return '页码无效,请发送:种子商店 1' + if self.rendering: + account = None + try: + account = (await self.service.snapshot(who)).account + except FarmError as error: + if error.code != 'not_registered': + raise + return await self.rendering.seed_shop( + chunk, account, page, pages, + ) + return f'种子商店 第{page}/{pages}页\n' + '\n'.join( + f'{p.name}(#{p.plant_id}):' + f'{p.cost}{"点券" if p.is_vip else "农场币"}' + f' / 等级{p.level}' for p in chunk + ) + ( + '\n购买种子 #编号 数量 或 购买种子 种子名 数量' + + (f'\n下一页:种子商店 {page+1}' if page < pages else '') ) if action == "inventory": if args: diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index 8df40b5..d42b574 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -45,7 +45,7 @@ def build(root, output): integration = root / "integrations/zhenxun" for name in ( "__init__.py", "runtime.py", "commands.py", "data_paths.py", - "requirements.txt", + "requirements.txt", "config_migration.py", ): files[name] = (integration / name).read_bytes() files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n' diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index e7a837f..d5f4754 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -91,6 +91,7 @@ def export(root): metadata["panel"] = manifest["panel"] entries = {path.relative_to(source).as_posix(): path for path in paths} entries.update({name: webgl_source / name for name in manifest["images"]}) + entries['webgl/shop-board.png'] = webgl_source / 'webgl/shop-board.png' with ZipFile(output, "w", compression=ZIP_STORED) as bundle: for name, path in sorted(entries.items()): content = path.read_bytes() diff --git a/scripts/preview_shop.py b/scripts/preview_shop.py new file mode 100644 index 0000000..12b5e92 --- /dev/null +++ b/scripts/preview_shop.py @@ -0,0 +1,39 @@ +"""用实际目录和离线示例账户生成种子商店预览。""" + +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 import Catalogue +from farm_core.domain import Account +from farm_render import FarmRenderer +from farm_render.shop import render_shop, shop_page + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + renderer = FarmRenderer(choose_font()) + catalogue = Catalogue.bundled() + account = Account('演示农场', 12580, 36, 3420) + _, pages = shop_page(catalogue, 1) + for page in dict.fromkeys((1, min(2, pages), pages)): + plants, _ = shop_page(catalogue, page) + (args.output / f'shop-page-{page}.png').write_bytes(render_shop( + renderer, plants, account, page, pages, + )) + plants, _ = shop_page(catalogue, 1) + (args.output / 'shop-unregistered.png').write_bytes(render_shop( + renderer, plants, None, 1, pages, + )) + print(f'Shop previews: {args.output}') + + +if __name__ == '__main__': + main() diff --git a/src/farm_render/data/assets.zip b/src/farm_render/data/assets.zip index 55467b0..b027123 100644 Binary files a/src/farm_render/data/assets.zip and b/src/farm_render/data/assets.zip differ diff --git a/src/farm_render/data/metadata.json b/src/farm_render/data/metadata.json index 3a8458e..3bc7883 100644 --- a/src/farm_render/data/metadata.json +++ b/src/farm_render/data/metadata.json @@ -6244,6 +6244,13 @@ 30 ] }, + "webgl/shop-board.png": { + "sha256": "88730b82d88cb33fae45c7180a88954fcc0241b2b4f5de095fc6b22b30296645", + "size": [ + 1024, + 1536 + ] + }, "webgl/wither.png": { "sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472", "size": [ diff --git a/src/farm_render/service.py b/src/farm_render/service.py index c3756e7..e10ff05 100644 --- a/src/farm_render/service.py +++ b/src/farm_render/service.py @@ -4,6 +4,9 @@ import asyncio import logging from time import perf_counter import time +import hashlib + +from .shop import render_shop logger = logging.getLogger(__name__) @@ -60,13 +63,39 @@ class RenderService: return await asyncio.shield(task) async def _run(self, *args): + return await self._run_function(self.renderer.render, *args) + + async def seed_shop(self, plants, account, page, pages): + if self._closed: + raise RuntimeError('Render service closed') + key = 'shop:' + hashlib.sha256( + repr((plants, account, page, pages)).encode(), + ).hexdigest() + task = self._pending.get(key) + if task is None: + if len(self._pending) >= self._max_pending: + raise RuntimeError('Render queue full') + task = asyncio.create_task(self._run_function( + render_shop, self.renderer, plants, account, page, pages, + )) + self._pending[key] = task + + def finished(done): + self._pending.pop(key, None) + if not done.cancelled(): + done.exception() + + task.add_done_callback(finished) + return await asyncio.shield(task) + + async def _run_function(self, function, *args): started = perf_counter() async with self._semaphore: logger.info( "farm render queue_ms=%.2f", (perf_counter()-started)*1000, ) worker = asyncio.create_task(asyncio.to_thread( - self.renderer.render, *args, + function, *args, )) try: return await asyncio.shield(worker) diff --git a/src/farm_render/shop.py b/src/farm_render/shop.py new file mode 100644 index 0000000..348c4ff --- /dev/null +++ b/src/farm_render/shop.py @@ -0,0 +1,93 @@ +"""文字指令使用的木质种子价目板;全部价格来自离线目录。""" + +import logging +import shlex + +from PIL import Image + +from .canvas import Canvas, font + +logger = logging.getLogger(__name__) +PAGE_SIZE = 6 + + +def shop_page(catalogue, page): + plants = sorted((p for p in catalogue if p.buyable), + key=lambda p: p.plant_id) + pages = max(1, (len(plants) + PAGE_SIZE - 1) // PAGE_SIZE) + if type(page) is not int or not 1 <= page <= pages: + raise ValueError('Invalid shop page') + start = (page - 1) * PAGE_SIZE + return tuple(plants[start:start + PAGE_SIZE]), pages + + +def duration(seconds): + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return ''.join(f'{value}{unit}' for value, unit in ( + (hours, '小时'), (minutes, '分'), (seconds, '秒'), + ) if value) or '0秒' + + +def render_shop(renderer, plants, account, page, pages): + canvas = Canvas(renderer.assets.image('webgl/shop-board.png', (1024, 1536))) + + def text(value, xy, size=28, color='#5b3015', width=850): + value = canvas.fit_text(value, renderer.font_path, size, width) + box = font(renderer.font_path, size).getbbox(value) + canvas.text((xy[0], xy[1]-box[1]), value, + renderer.font_path, size, color) + + def centered(value, y, size, color='#5b3015'): + x = round((1024 - canvas.measure_text( + value, renderer.font_path, size, + )) / 2) + text(value, (x, y), size, color) + + centered('种 子 商 店', 58, 54, '#fff5d3') + centered('种子价目板 · 发送文字指令购买', 178, 25, '#7a6439') + balances = ('未开通农场 · 可浏览种子,购买前请发送:开通农场' + if account is None else + f'Lv.{account.level} 农场币 {account.point:,} ' + f'点券 {account.vip_point:,}') + text(balances, (142, 248), 29, width=745) + for index, plant in enumerate(plants): + y = 326 + index * 156 + icon = f'plant/{plant.name}/icon.png' + if icon in renderer.assets.metadata['images']: + image = renderer.assets.image(icon) + image.thumbnail((104, 110), Image.Resampling.LANCZOS) + canvas.paste(image, (175-image.width//2, y+67-image.height//2)) + else: + text('暂无图', (133, y+55), 23, '#8b8057', width=92) + logger.info('farm shop packaged icon unavailable id=%s', + plant.plant_id) + text(plant.name, (268, y+13), 34, width=490) + text(f'#{plant.plant_id}', (774, y+17), 26, '#69783e', width=132) + currency = '点券' if plant.is_vip else '农场币' + text(f'{plant.cost:,} {currency} / 粒', (268, y+62), 32, + '#b66522', width=480) + locked = account is not None and account.level < plant.level + text(f'{"需" if locked else "等级"} Lv.{plant.level}', + (774, y+69), 23, '#ac542d' if locked else '#66733d', width=132) + text(f'首熟 {duration(plant.growth_seconds)} · {plant.crop}季' + f' · 每季{plant.harvest}个', (268, y+108), 23, width=635) + if not plants: + text('暂无出售中的种子', (340, 690), 34) + example = plants[0] if plants else None + if example: + text(f'按编号:购买种子 #{example.plant_id} 5', (142, 1291), 27) + name = example.name + if any(c.isspace() for c in name) or any(c in name for c in '\"\''): + name = shlex.quote(name) + text(f'按名称:购买种子 {name} 5', (142, 1341), 27, width=750) + else: + text('购买种子 #编号 数量 或 购买种子 种子名 数量', + (142, 1316), 25, width=750) + navigation = f'第 {page} / {pages} 页' + if page < pages: + navigation += f' 下一页:种子商店 {page+1}' + elif page > 1: + navigation += f' 上一页:种子商店 {page-1}' + text(navigation, (263, 1420), 23, width=510) + return canvas.encode('PNG') diff --git a/src/farm_render/source_assets/webgl/shop-board.png b/src/farm_render/source_assets/webgl/shop-board.png new file mode 100644 index 0000000..54774a5 Binary files /dev/null and b/src/farm_render/source_assets/webgl/shop-board.png differ diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py new file mode 100644 index 0000000..0968456 --- /dev/null +++ b/tests/test_config_migration.py @@ -0,0 +1,47 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "farm_config_migration", + ROOT / "integrations/zhenxun/config_migration.py", +) +migration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(migration) + + +class ConfigMigrationTests(unittest.TestCase): + def test_cleanup_backups_and_repeat_are_scoped(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + group = SimpleNamespace(configs={ + "绘制农场清晰度": "low", "CUSTOM": "keep", + }) + saves = [] + manager = SimpleNamespace( + file=root / "plugins2config.yaml", + _simple_file=root / "config.yaml", + _simple_data={ + "farm": {"绘制农场清晰度": "low", "CUSTOM": "keep"}, + "other": {"服务地址": "keep"}, + }, + get=lambda module: group, + save=lambda **kwargs: saves.append(kwargs), + ) + for path in (manager.file, manager._simple_file): + path.write_text("original", encoding="utf-8") + migration.migrate_config(manager, "farm") + self.assertEqual(group.configs, {"CUSTOM": "keep"}) + self.assertEqual(manager._simple_data["farm"], {"CUSTOM": "keep"}) + self.assertEqual(manager._simple_data["other"], {"服务地址": "keep"}) + self.assertEqual(saves, [{"save_simple_data": True}]) + for path in (manager.file, manager._simple_file): + self.assertEqual(path.with_name( + path.name + ".farm-v1.bak", + ).read_text(encoding="utf-8"), "original") + migration.migrate_config(manager, "farm") + self.assertEqual(len(saves), 1) diff --git a/tests/test_shop_render.py b/tests/test_shop_render.py new file mode 100644 index 0000000..6b4f1c1 --- /dev/null +++ b/tests/test_shop_render.py @@ -0,0 +1,49 @@ +from dataclasses import replace +from io import BytesIO +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +from ci_font import choose_font +from farm_core import Catalogue +from farm_core.domain import Account +from farm_render import FarmRenderer +from farm_render.shop import shop_page, render_shop +from farm_render.canvas import Canvas + + +class ShopRenderTests(unittest.TestCase): + def test_pages_filter_unavailable_and_keep_stable_ids(self): + plant = Catalogue.bundled().get('胡萝卜') + plants = [replace(plant, plant_id=i) for i in range(1, 9)] + plants += [replace(plant, plant_id=99, purchasable=False)] + rows, total = shop_page(plants, 2) + self.assertEqual(total, 2) + self.assertEqual([p.plant_id for p in rows], [7, 8]) + for page in (0, 3, True): + with self.assertRaises(ValueError): + shop_page(plants, page) + self.assertEqual(shop_page([], 1), ((), 1)) + + def test_shop_uses_actual_fields_and_missing_icon_fallback(self): + renderer = FarmRenderer(choose_font()) + plant = Catalogue.bundled().get('胡萝卜') + rows = (plant, replace(plant, plant_id=99999, + name='不存在的素材', is_vip=True, vip_buy=35)) + with patch.object(Canvas, 'text', autospec=True) as text: + result = render_shop( + renderer, rows, Account('商店', 12580, 36, 0), 1, 1, + ) + labels = [call.args[2] for call in text.call_args_list] + self.assertIn(f'{plant.cost:,} 农场币 / 粒', labels) + self.assertIn('35 点券 / 粒', labels) + self.assertIn('暂无图', labels) + self.assertIn(f'按编号:购买种子 #{plant.plant_id} 5', labels) + self.assertIn('按名称:购买种子 胡萝卜 5', labels) + with Image.open(BytesIO(result)) as image: + self.assertEqual(image.size, (1024, 1536)) + self.assertEqual(image.format, 'PNG') diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index 2a7fb95..9472ce7 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -69,7 +69,12 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): replace(carrot, plant_id=9002, enabled=False, name="停用菜"), replace(carrot, plant_id=9003, crop_type=11, name="特殊道具"), ]) - shop = await self.call("shop") + rendering = self.runtime.rendering + self.runtime.rendering = None + try: + shop = await self.call("shop") + finally: + self.runtime.rendering = rendering self.assertIn("胡萝卜(#1)", shop) for name in ("禁售菜", "停用菜", "特殊道具"): self.assertNotIn(name, shop) @@ -78,6 +83,28 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("不出售", await self.call("buy", "禁售菜 1", "blocked")) self.assertIn("(#1)", await self.call("inventory")) + async def test_shop_image_page_and_buy_by_name_or_id(self): + from io import BytesIO + from PIL import Image + result = await self.call('shop') + with Image.open(BytesIO(result)) as image: + self.assertEqual(image.size, (1024, 1536)) + await self.call('register') + self.assertIn('已完成', await self.call('buy', '白萝卜 1', 'name')) + plant = self.runtime.service.catalogue.get('白萝卜') + self.assertIn('已完成', await self.call( + 'buy', f'#{plant.plant_id} 1', 'id', + )) + from unittest.mock import AsyncMock + self.runtime.rendering.seed_shop = AsyncMock(return_value=b'shop') + self.assertEqual(await self.call('shop', '2'), b'shop') + args = self.runtime.rendering.seed_shop.call_args.args + self.assertEqual(len(args[0]), 6) + self.assertEqual(args[1].name, '离线玩家') + self.assertEqual(args[2], 2) + for page in ('0', '999999'): + self.assertIn('页码无效', await self.call('shop', page)) + async def test_farm_uses_calendar_theme_and_bee_is_not_a_user_option(self): rendering = self.runtime.rendering from unittest.mock import AsyncMock @@ -251,8 +278,13 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): )) platform = ModuleType("zhenxun.utils.platform") platform.PlatformUtils = SimpleNamespace() + config = ModuleType("zhenxun.configs.config") + config.Config = SimpleNamespace( + get=lambda module: SimpleNamespace(configs={}), + _simple_data={}, + ) modules = {m.__name__:m for m in ( - nonebot, adapters, params, uninfo, paths, messages, platform, + nonebot, adapters, params, uninfo, paths, messages, platform, config, )} with patch.dict(sys.modules, modules): imported = importlib.import_module(self.package + ".commands")