add # 新增作物、种子仓库界面

This commit is contained in:
2026-10-21 17:31:45 +08:00
parent 4a484bfde6
commit dd47ab70c4
6 changed files with 371 additions and 5 deletions
+21
View File
@@ -0,0 +1,21 @@
# 仓库界面(方案C)
真寻命令「我的作物 [页码]」「我的种子 [页码]」分别查询当前用户库存。
省略页码为第1页,每页最多12种;只显示数量大于0的条目。
「我的农场 my-plant」「我的农场 my-seed」保留为对应入口。
「我的农场币」「我的点券」的原文字查询行为不变。
- 作物分为「可出售」「已锁定」,每组按稳定本地作物编号排序。
编号跨页不变;可出售汇总排除已锁定项,单项价值为数量乘当前目录售价。
- 种子分为「单季种子」「多季种子」,显示粒数、首熟时间、最多收获季数。
空地统计只计空地,枯萎地块需先铲除。
- 页首汇总整份库存,页内只展示当前12种;分组标题中的种数也是整组总数。
- 空仓库提供收获或种子商店提示;无字体时按相同分类与分页返回文字。
- 原库存编号在目录中缺失时保留项目,明确标注信息缺失,不按名称猜测。
无法确定的售价不计入已知价值,汇总标明不含目录缺失项。
- 显示名称过长时省略,完整作物编号始终可用于文字操作。
- 使用现有木质面板和图标,默认JPEG压缩,复用有界异步渲染队列。
查询只读取一次一致快照,不修改库存、土地、锁定状态或资产。
操作仍使用原有指令:出售作物、作物加锁、作物解锁、播种;支持名称和
`#编号`。不增加出售种子或种子锁定机制,不修改已有经济结算规则。
+26 -4
View File
@@ -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()
+24
View File
@@ -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:
+191
View File
@@ -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
+76
View File
@@ -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)
+33 -1
View File
@@ -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')