add # 新增种子商店界面

fixed # 修复数据库目录错误的BUG
This commit is contained in:
2026-10-20 13:15:01 +08:00
parent 92a3944e43
commit 38f4b84cf0
15 changed files with 395 additions and 15 deletions
+30
View File
@@ -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 <目录>` 可复现首页、第二页、末页与
未开通预览。预览余额为演示值,商品使用实际目录。此改动不部署、不重启。
+9 -1
View File
@@ -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 = {}
+32
View File
@@ -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,
)
+23 -10
View File
@@ -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:
+1 -1
View File
@@ -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'
+1
View File
@@ -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()
+39
View File
@@ -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()
Binary file not shown.
+7
View File
@@ -6244,6 +6244,13 @@
30
]
},
"webgl/shop-board.png": {
"sha256": "88730b82d88cb33fae45c7180a88954fcc0241b2b4f5de095fc6b22b30296645",
"size": [
1024,
1536
]
},
"webgl/wither.png": {
"sha256": "1c87f05e0b7bd8287926351e07746bc9e36371a860afaef0e4b00c51536dc472",
"size": [
+30 -1
View File
@@ -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)
+93
View File
@@ -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')
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+47
View File
@@ -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)
+49
View File
@@ -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')
+34 -2
View File
@@ -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")