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