From dd47ab70c47be1848df6b51632fb8c98b26fd00f Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Wed, 21 Oct 2026 17:31:45 +0800 Subject: [PATCH] =?UTF-8?q?add=20#=20=E6=96=B0=E5=A2=9E=E4=BD=9C=E7=89=A9?= =?UTF-8?q?=E3=80=81=E7=A7=8D=E5=AD=90=E4=BB=93=E5=BA=93=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/warehouse.md | 21 ++++ integrations/zhenxun/runtime.py | 30 ++++- src/farm_render/service.py | 24 ++++ src/farm_render/warehouse.py | 191 ++++++++++++++++++++++++++++++++ tests/test_warehouse.py | 76 +++++++++++++ tests/test_zhenxun_runtime.py | 34 +++++- 6 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 docs/warehouse.md create mode 100644 src/farm_render/warehouse.py create mode 100644 tests/test_warehouse.py diff --git a/docs/warehouse.md b/docs/warehouse.md new file mode 100644 index 0000000..f40b263 --- /dev/null +++ b/docs/warehouse.md @@ -0,0 +1,21 @@ +# 仓库界面(方案C) + +真寻命令「我的作物 [页码]」「我的种子 [页码]」分别查询当前用户库存。 +省略页码为第1页,每页最多12种;只显示数量大于0的条目。 +「我的农场 my-plant」「我的农场 my-seed」保留为对应入口。 +「我的农场币」「我的点券」的原文字查询行为不变。 + +- 作物分为「可出售」「已锁定」,每组按稳定本地作物编号排序。 + 编号跨页不变;可出售汇总排除已锁定项,单项价值为数量乘当前目录售价。 +- 种子分为「单季种子」「多季种子」,显示粒数、首熟时间、最多收获季数。 + 空地统计只计空地,枯萎地块需先铲除。 +- 页首汇总整份库存,页内只展示当前12种;分组标题中的种数也是整组总数。 +- 空仓库提供收获或种子商店提示;无字体时按相同分类与分页返回文字。 +- 原库存编号在目录中缺失时保留项目,明确标注信息缺失,不按名称猜测。 + 无法确定的售价不计入已知价值,汇总标明不含目录缺失项。 +- 显示名称过长时省略,完整作物编号始终可用于文字操作。 +- 使用现有木质面板和图标,默认JPEG压缩,复用有界异步渲染队列。 + 查询只读取一次一致快照,不修改库存、土地、锁定状态或资产。 + +操作仍使用原有指令:出售作物、作物加锁、作物解锁、播种;支持名称和 +`#编号`。不增加出售种子或种子锁定机制,不修改已有经济结算规则。 diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 3040861..5792942 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -17,12 +17,15 @@ 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 ._vendor.farm_render.details import detail_rows, details_text +from ._vendor.farm_render.warehouse import ( + warehouse_page, warehouse_rows, warehouse_text, +) logger = logging.getLogger(__name__) COMMANDS = { "开通农场":"register", "我的农场":"farm", "农场详述":"details", "我的农场币":"inventory", "我的点券":"inventory", - "我的种子":"inventory", "我的作物":"inventory", + "我的种子":"seeds", "我的作物":"crops", "种子商店":"shop", "农场帮助":"help", "购买种子":"buy", "播种":"sow", "收获":"harvest", "铲除":"eradicate", "作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell", @@ -32,7 +35,7 @@ HELP = ( "开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n" "地块编号 [高清]|查看当前土地编号及开垦位置\n" "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" - "收获|铲除|农场除虫|我的种子|我的作物\n" + "收获|铲除|农场除虫|我的种子 [页码]|我的作物 [页码]\n" "作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n" "作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n" "每成功播种1块地获得2经验;收获经验按作物结算。\n" @@ -170,8 +173,8 @@ class Runtime: if action == "farm" and args: aliases = { "detail":"details", "my-point":"inventory", - "my-vipPoint":"inventory", "my-seed":"inventory", - "my-plant":"inventory", "seed-shop":"shop", + "my-vipPoint":"inventory", "my-seed":"seeds", + "my-plant":"crops", "seed-shop":"shop", "buy-seed":"buy", "sowing":"sow", "harvest":"harvest", "eradicate":"eradicate", "lock-plant":"lock", "unlock-plant":"unlock", "sell-plant":"sell", @@ -232,6 +235,25 @@ class Runtime: who, snapshot, now, page, season_for(now), image_format=self.image_format, avatar=avatar, ) + if action in ('crops', 'seeds'): + title = '我的作物' if action == 'crops' else '我的种子' + if len(args) > 1: + raise ValueError() + try: + page = int(args[0]) if args else 1 + except ValueError: + return f'页码无效,请发送:{title} 1' + snapshot = await self.service.snapshot(who) + rows = warehouse_rows(snapshot, self.service.catalogue, action) + try: + _, pages = warehouse_page(rows, page) + except ValueError: + return f'页码无效,请发送:{title} 1' + 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, + ) if action == "inventory": if args: raise ValueError() diff --git a/src/farm_render/service.py b/src/farm_render/service.py index ee441fd..4f2d8cf 100644 --- a/src/farm_render/service.py +++ b/src/farm_render/service.py @@ -8,6 +8,7 @@ import hashlib from .shop import render_shop from .details import render_details +from .warehouse import render_warehouse logger = logging.getLogger(__name__) @@ -116,6 +117,29 @@ class RenderService: task.add_done_callback(finished) return await asyncio.shield(task) + async def warehouse(self, rows, snapshot, kind, page, pages, + image_format='AUTO'): + if self._closed: + raise RuntimeError('Render service closed') + key = ('warehouse', rows, snapshot, kind, page, pages, image_format) + 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_warehouse, self.renderer, rows, snapshot, kind, + page, pages, image_format, + )) + 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: diff --git a/src/farm_render/warehouse.py b/src/farm_render/warehouse.py new file mode 100644 index 0000000..7adaa33 --- /dev/null +++ b/src/farm_render/warehouse.py @@ -0,0 +1,191 @@ +"""一次快照的分类仓库展示;不修改库存或资产。""" + +from dataclasses import dataclass +import logging +import shlex + +from PIL import Image + +from farm_core.domain import FarmError, Plant + +from .canvas import Canvas, font +from .models import RenderOptions +from .shop import duration + +logger = logging.getLogger(__name__) +PAGE_SIZE = 12 + + +@dataclass(frozen=True) +class StockRow: + plant_id: int # 稳定本地作物编号。 + name: str # 仓库存量名称。 + quantity: int # 正库存数量,种子以粒计。 + group: str # 可出售、已锁定、单季、多季或信息缺失。 + plant: Plant | None # 当前目录规则;目录缺失时为None。 + + +def warehouse_rows(snapshot, catalogue, kind): + if kind not in ('crops', 'seeds'): + raise ValueError('Invalid inventory kind') + rows = [] + for item in snapshot.inventory: + amount = getattr(item, kind) + if amount <= 0: + continue + try: + plant = catalogue.get_by_id(item.plant_id) + except FarmError: + plant = None + if kind == 'crops': + group = '已锁定' if item.locked else '可出售' + else: + group = ('信息缺失' if plant is None else + '多季种子' if plant.crop > 1 else '单季种子') + rows.append(StockRow(item.plant_id, item.name, amount, group, plant)) + groups = ('可出售', '已锁定', '单季种子', '多季种子', '信息缺失') + return tuple(sorted(rows, key=lambda r: ( + groups.index(r.group), r.plant_id, + ))) + + +def warehouse_page(rows, page): + pages = max(1, (len(rows)+PAGE_SIZE-1)//PAGE_SIZE) + if type(page) is not int or not 1 <= page <= pages: + raise ValueError('Invalid inventory page') + return rows[(page-1)*PAGE_SIZE:page*PAGE_SIZE], pages + + +def warehouse_text(rows, snapshot, kind, page, pages): + title = '我的作物' if kind == 'crops' else '我的种子' + unit = '个' if kind == 'crops' else '粒' + lines = [f'{title} 第{page}/{pages}页', + f'{len(rows)}种 · 共{sum(r.quantity for r in rows):,}{unit}'] + if kind == 'crops': + value = sum(r.quantity*r.plant.price for r in rows + if r.group == '可出售' and r.plant is not None) + note = '(不含目录缺失项)' if any( + r.plant is None for r in rows if r.group == '可出售' + ) else '' + lines.append(f'可出售价值 {value:,} 农场币{note}') + else: + empty = sum(p.plant is None for p in snapshot.plots) + lines.append(f'当前空地 {empty}/{len(snapshot.plots)}块') + chunk, _ = warehouse_page(rows, page) + group = None + for row in chunk: + if row.group != group: + group = row.group + count = sum(r.group == group for r in rows) + suffix = ' · 出售时自动跳过' if group == '已锁定' else '' + lines.append(f'{group}({count}种){suffix}') + if row.plant is None: + detail = '目录信息缺失' + elif kind == 'crops': + detail = (f'单价{row.plant.price} · ' + f'价值{row.plant.price*row.quantity:,}农场币') + else: + detail = (f'首熟{duration(row.plant.growth_seconds)} / ' + f'{row.plant.crop}季') + lines.append(f'{row.name}(#{row.plant_id}) ' + f'{row.quantity:,}{unit}|{detail}') + if not rows: + lines.append('暂无作物,请播种并收获。' if kind == 'crops' + else '暂无种子,请发送「种子商店」选购。') + example = next((r for r in chunk if r.plant is not None and ( + kind == 'seeds' or r.group == '可出售' + )), None) + if example: + action = '出售作物' if kind == 'crops' else '播种' + lines.append(f'按编号:{action} #{example.plant_id} 1') + lines.append(f'按名称:{action} {shlex.quote(example.name)} 1') + if kind == 'crops': + lines.append('全部出售:出售作物(自动跳过已锁定作物)') + lines.append('锁定/解锁:作物加锁 #编号 / 作物解锁 #编号') + if page < pages: + lines.append(f'下一页:{title} {page+1}') + if page > 1: + lines.append(f'上一页:{title} {page-1}') + return '\n'.join(lines) + + +def render_warehouse(renderer, rows, snapshot, kind, page, pages, + image_format='AUTO'): + options = RenderOptions(format=image_format, quality=80) + codec = options.codec() + canvas = Canvas(renderer.assets.image('webgl/shop-board.png', + (1024, 1536))) + patch = canvas.image.crop((300, 340, 880, 445)) + canvas.image.paste(patch.resize((824, 930)), (100, 320)) + canvas.rectangle((105, 322, 919, 1249), '#fff1cf', radius=15) + def text(value, xy, size=26, color='#603817', width=760): + 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) + + title = '我的作物' if kind == 'crops' else '我的种子' + text(title, (385, 60), 62, '#fff4cb') + text('分类仓库 · 发送文字指令操作', (310, 178), 27, '#7e653a') + summary = warehouse_text(rows, snapshot, kind, page, pages).splitlines() + text(' · '.join(summary[1:3]), (138, 248), 26, '#607d30', 750) + chunk, _ = warehouse_page(rows, page) + group = None + y = 336 + unit = '个' if kind == 'crops' else '粒' + for index, row in enumerate(chunk): + if row.group != group: + group = row.group + canvas.rectangle((123, y, 899, y+43), '#d8e5b6', radius=7) + count = sum(r.group == group for r in rows) + label = f'{group}({count}种)' + if group == '已锁定': + label += ' · 出售时自动跳过' + text(label, (140, y+8), 24, '#607d30') + y += 49 + if index % 2 == 0: + canvas.rectangle((123, y, 899, y+55), '#fff8e4', radius=5) + icon = f'plant/{row.name}/icon.png' + if icon in renderer.assets.metadata['images']: + image = renderer.assets.image(icon) + image.thumbnail((44, 46), Image.Resampling.LANCZOS) + canvas.paste(image, (158-image.width//2, y+27-image.height//2)) + else: + text('?', (149, y+15), 26) + text(f'#{row.plant_id}', (193, y+19), 21, '#607d30', 82) + text(row.name, (283, y+15), 26, width=250) + text(f'{row.quantity:,}{unit}', (550, y+16), 25, '#b86d22', 152) + if row.plant is None: + detail = '目录缺失' + elif kind == 'crops': + detail = f'{row.quantity*row.plant.price:,} 币' + else: + detail = (f'{duration(row.plant.growth_seconds)} / ' + f'{row.plant.crop}季') + text(detail, (714, y+19), 20, width=174) + y += 60 + if not rows: + text('暂无作物,请播种并收获。' if kind == 'crops' else + '暂无种子,请发送「种子商店」选购。', (235, 675), 30, width=620) + if kind == 'crops': + footer = ['全部出售:出售作物(自动跳过已锁定作物)', + '指定出售:出售作物 #编号 数量(也支持作物名)', + '锁定/解锁:作物加锁 #编号 / 作物解锁 #编号'] + else: + footer = ['按编号:播种 #编号 数量 | 按名称:播种 作物名 数量', + '每块地消耗1粒;不填数量时自动播种到可用空地。', + '表内时间为首熟时间,季数为最多可收获季数。'] + for y, line in zip((1278, 1318, 1358), footer): + text(line, (142, y), 24, width=745) + nav = f'第 {page}/{pages} 页' + if page < pages: + nav += f' 下一页:{title} {page+1}' + if page > 1: + nav += f' 上一页:{title} {page-1}' + nav = canvas.fit_text(nav, renderer.font_path, 23, 510) + x = round((1024-canvas.measure_text(nav, renderer.font_path, 23))/2) + text(nav, (x, 1424), 23, width=510) + content = canvas.encode(codec, options.quality, options.method) + logger.info('farm warehouse kind=%s page=%s bytes=%s codec=%s', + kind, page, len(content), codec) + return content diff --git a/tests/test_warehouse.py b/tests/test_warehouse.py new file mode 100644 index 0000000..7ea488d --- /dev/null +++ b/tests/test_warehouse.py @@ -0,0 +1,76 @@ +from dataclasses import replace +from io import BytesIO +import unittest + +from PIL import Image + +from farm_core import Catalogue +from farm_core.domain import Account, InventoryItem, Plot, Snapshot +from farm_render import FarmRenderer +from farm_render.warehouse import ( + render_warehouse, warehouse_page, warehouse_rows, warehouse_text, +) + + +class WarehouseTests(unittest.TestCase): + def setUp(self): + self.catalogue = Catalogue.bundled() + self.carrot = self.catalogue.get('胡萝卜') + self.berry = self.catalogue.get('草莓') + self.snapshot = Snapshot(Account('仓库', 500, 0, 0), ( + InventoryItem('草莓', 3, 8, True, self.berry.plant_id), + InventoryItem('胡萝卜', 2, 10, False, self.carrot.plant_id), + InventoryItem('零库存', 0, 0, False, 999999), + ), (Plot(1), Plot(2))) + + def test_separate_positive_stocks_groups_and_exclude_locked_value(self): + crops = warehouse_rows(self.snapshot, self.catalogue, 'crops') + self.assertEqual([r.group for r in crops], ['可出售', '已锁定']) + text = warehouse_text(crops, self.snapshot, 'crops', 1, 1) + self.assertIn(f'可出售价值 {10*self.carrot.price:,}', text) + self.assertIn('胡萝卜(#1) 10个', text) + self.assertNotIn('零库存', text) + seeds = warehouse_rows(self.snapshot, self.catalogue, 'seeds') + self.assertEqual([r.group for r in seeds], ['单季种子', '多季种子']) + self.assertEqual([r.quantity for r in seeds], [2, 3]) + self.assertIn('当前空地 2/2块', warehouse_text( + seeds, self.snapshot, 'seeds', 1, 1, + )) + + def test_pagination_preserves_ids_and_unknown_directory_is_not_zero(self): + items = tuple(InventoryItem('未知', 1, 2, False, i) + for i in range(9000, 9025)) + snapshot = replace(self.snapshot, inventory=items) + rows = warehouse_rows(snapshot, self.catalogue, 'seeds') + self.assertEqual([r.plant_id for r in warehouse_page(rows, 2)[0]], + list(range(9012, 9024))) + self.assertEqual(warehouse_page(rows, 3)[1], 3) + self.assertEqual(rows[0].group, '信息缺失') + for page in (0, 4, True): + with self.assertRaises(ValueError): + warehouse_page(rows, page) + crops = warehouse_rows(snapshot, self.catalogue, 'crops') + self.assertIn('不含目录缺失项', warehouse_text( + crops, snapshot, 'crops', 1, 3, + )) + + def test_empty_stock_and_no_snapshot_mutation(self): + snapshot = replace(self.snapshot, inventory=()) + rows = warehouse_rows(snapshot, self.catalogue, 'seeds') + self.assertEqual(warehouse_page(rows, 1), ((), 1)) + self.assertIn('种子商店', warehouse_text( + rows, snapshot, 'seeds', 1, 1, + )) + self.assertEqual(snapshot.inventory, ()) + + def test_render_crops_and_seeds_as_compressed_jpeg(self): + from scripts.ci_font import choose_font + renderer = FarmRenderer(choose_font()) + for kind in ('crops', 'seeds'): + rows = warehouse_rows(self.snapshot, self.catalogue, kind) + content = render_warehouse(renderer, rows, self.snapshot, + kind, 1, 1) + with Image.open(BytesIO(content)) as image: + self.assertEqual(image.format, 'JPEG') + self.assertEqual(image.size, (1024, 1536)) + self.assertLess(len(content), 500000) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index 5537558..2c11199 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -83,6 +83,38 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): self.assertIn('待开垦(24块):7、8、9', text) self.assertIn('农场详述', text) + async def test_classified_warehouse_images_text_and_read_only_paging(self): + from io import BytesIO + from PIL import Image + from unittest.mock import AsyncMock + self.assertEqual(self.module.COMMANDS['我的作物'], 'crops') + self.assertEqual(self.module.COMMANDS['我的种子'], 'seeds') + await self.call('register') + await self.call('buy', '胡萝卜 2', 'warehouse-buy') + before = await self.runtime.service.snapshot(self.runtime_identity()) + with patch.object(self.runtime.service, 'snapshot', + new=AsyncMock(return_value=before)) as query: + content = await self.call('seeds', message=None) + query.assert_awaited_once() + with Image.open(BytesIO(content)) as image: + self.assertEqual(image.format, 'JPEG') + self.assertEqual(before, await self.runtime.service.snapshot( + self.runtime_identity(), + )) + for invalid in ('0', '999', 'abc'): + self.assertIn('页码无效', await self.call('seeds', invalid)) + self.assertIn('开通农场', await self.call('crops', user_id='other')) + rendering = self.runtime.rendering + self.runtime.rendering = None + try: + text = await self.call('seeds', message=None) + self.assertIn('胡萝卜(#1) 2粒', text) + self.assertNotIn('累计经验', text) + self.assertIn('暂无作物', await self.call('crops', message=None)) + self.assertEqual(text, await self.call('farm', 'my-seed')) + finally: + self.runtime.rendering = rendering + async def test_original_commands_transactions_and_png(self): self.assertNotIn("测试农场", self.module.COMMANDS) self.assertIn("开通农场", self.module.COMMANDS) @@ -204,7 +236,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): from PIL import Image from unittest.mock import AsyncMock self.assertEqual(self.module.COMMANDS['农场详述'], 'details') - self.assertEqual(self.module.COMMANDS['我的种子'], 'inventory') + self.assertEqual(self.module.COMMANDS['我的种子'], 'seeds') await self.call('register') await self.call('buy', '白萝卜 1', 'buy-details') await self.call('sow', '白萝卜 1', 'sow-details')