mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 23:09:58 +08:00
add # 新增农场详述指令
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# 农场详述图片
|
||||||
|
|
||||||
|
`农场详述 [页码]` 返回上方全景与下方地块明细表,每页最多 10 块地。
|
||||||
|
`我的农场 detail [页码]` 是兼容入口。其他资产、种子和作物查询继续返回
|
||||||
|
原有文字仓库信息,不与新详述混用。
|
||||||
|
|
||||||
|
全景标注所有已解锁地块编号,黄色标出本页,表格沿用同一编号。
|
||||||
|
表格列为地块/作物、生长阶段、收获季数、预计/可收数量、成熟时间。
|
||||||
|
没有可靠的阶段名称元数据,因此生长阶段显示阶段序号,避免把发芽或
|
||||||
|
开花等名称猜错;与第几季/总共几季分别展示。时间采用北京时间。
|
||||||
|
|
||||||
|
生长中产量按当前虫数估算;预计成熟时间沿用核心虫害投影,包含后续
|
||||||
|
已确定的生虫事件。成熟后产量与收获一致:先计算土地加成向下取整,
|
||||||
|
再乘 `(100 - 成熟时虫数 × 5) // 100`。成熟后除虫不恢复被冻结的减产。
|
||||||
|
当前虫数、生长减速或成熟减产以小字列在对应行下方。
|
||||||
|
|
||||||
|
不显示被偷明细。当前领域模型尚无偷菜扣减字段或交易,故未伪造或
|
||||||
|
预扣偷菜损失。未来实现偷菜时,需同步接入实际收获结算与详述可收数量。
|
||||||
|
|
||||||
|
每次请求只读取一次账户快照,全景和明细使用同一快照与绘制时间,
|
||||||
|
不写入数据库。异步队列与农场/商店共享并发上限、去重和关闭排空。
|
||||||
|
字体不可用时返回同一数据口径的分页文字。输出遵循配置的图片格式,
|
||||||
|
AUTO 使用 JPEG。无效页码返回中文提示,未开通时返回开通提示。
|
||||||
|
|
||||||
|
运行 `scripts/preview_details.py --output <目录>` 可生成离线示例,
|
||||||
|
包括生长、成熟、虫害、成熟后除虫、空地、枯萎及两页地块。
|
||||||
|
示例余额与状态为构造数据,不访问真实玩家数据库。
|
||||||
@@ -77,7 +77,7 @@ def handler_for(action):
|
|||||||
message_id = getattr(event, "id", None)
|
message_id = getattr(event, "id", None)
|
||||||
try:
|
try:
|
||||||
avatar = (await avatar_for(user_id, platform)
|
avatar = (await avatar_for(user_id, platform)
|
||||||
if action == "farm" else None)
|
if action in ("farm", "details") else None)
|
||||||
response = await runtime.execute(
|
response = await runtime.execute(
|
||||||
action, args.extract_plain_text(), bot_id=str(bot.self_id),
|
action, args.extract_plain_text(), bot_id=str(bot.self_id),
|
||||||
platform=platform, user_id=user_id, message_id=message_id,
|
platform=platform, user_id=user_id, message_id=message_id,
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ from ._vendor.farm_core.presentation import receipt_text, snapshot_text
|
|||||||
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
|
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
|
||||||
from ._vendor.farm_render.seasons import season_for
|
from ._vendor.farm_render.seasons import season_for
|
||||||
from ._vendor.farm_render.shop import shop_page
|
from ._vendor.farm_render.shop import shop_page
|
||||||
|
from ._vendor.farm_render.details import detail_rows, details_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"开通农场":"register", "我的农场":"farm", "农场详述":"inventory",
|
"开通农场":"register", "我的农场":"farm", "农场详述":"details",
|
||||||
"我的农场币":"inventory", "我的点券":"inventory",
|
"我的农场币":"inventory", "我的点券":"inventory",
|
||||||
"我的种子":"inventory", "我的作物":"inventory",
|
"我的种子":"inventory", "我的作物":"inventory",
|
||||||
"种子商店":"shop", "农场帮助":"help", "购买种子":"buy",
|
"种子商店":"shop", "农场帮助":"help", "购买种子":"buy",
|
||||||
@@ -25,7 +26,7 @@ COMMANDS = {
|
|||||||
"农场除虫":"clear_pests", "除虫":"clear_pests",
|
"农场除虫":"clear_pests", "除虫":"clear_pests",
|
||||||
}
|
}
|
||||||
HELP = (
|
HELP = (
|
||||||
"开通农场 [名称]|我的农场 [高清]|农场详述\n"
|
"开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n"
|
||||||
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
|
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
|
||||||
"收获|铲除|农场除虫|我的种子|我的作物\n"
|
"收获|铲除|农场除虫|我的种子|我的作物\n"
|
||||||
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
|
"作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n"
|
||||||
@@ -164,7 +165,7 @@ class Runtime:
|
|||||||
args = shlex.split(arguments)
|
args = shlex.split(arguments)
|
||||||
if action == "farm" and args:
|
if action == "farm" and args:
|
||||||
aliases = {
|
aliases = {
|
||||||
"detail":"inventory", "my-point":"inventory",
|
"detail":"details", "my-point":"inventory",
|
||||||
"my-vipPoint":"inventory", "my-seed":"inventory",
|
"my-vipPoint":"inventory", "my-seed":"inventory",
|
||||||
"my-plant":"inventory", "seed-shop":"shop",
|
"my-plant":"inventory", "seed-shop":"shop",
|
||||||
"buy-seed":"buy", "sowing":"sow", "harvest":"harvest",
|
"buy-seed":"buy", "sowing":"sow", "harvest":"harvest",
|
||||||
@@ -198,6 +199,7 @@ class Runtime:
|
|||||||
raise
|
raise
|
||||||
return await self.rendering.seed_shop(
|
return await self.rendering.seed_shop(
|
||||||
chunk, account, page, pages,
|
chunk, account, page, pages,
|
||||||
|
image_format=self.image_format,
|
||||||
)
|
)
|
||||||
return f'种子商店 第{page}/{pages}页\n' + '\n'.join(
|
return f'种子商店 第{page}/{pages}页\n' + '\n'.join(
|
||||||
f'{p.name}(#{p.plant_id}):'
|
f'{p.name}(#{p.plant_id}):'
|
||||||
@@ -207,6 +209,25 @@ class Runtime:
|
|||||||
'\n购买种子 #编号 数量 或 购买种子 种子名 数量'
|
'\n购买种子 #编号 数量 或 购买种子 种子名 数量'
|
||||||
+ (f'\n下一页:种子商店 {page+1}' if page < pages else '')
|
+ (f'\n下一页:种子商店 {page+1}' if page < pages else '')
|
||||||
)
|
)
|
||||||
|
if action == 'details':
|
||||||
|
if len(args) > 1:
|
||||||
|
raise ValueError()
|
||||||
|
page = int(args[0]) if args else 1
|
||||||
|
snapshot = await self.service.snapshot(who)
|
||||||
|
now = self.service.clock()
|
||||||
|
try:
|
||||||
|
rows, pages = detail_rows(
|
||||||
|
self.rendering.renderer if self.rendering else None,
|
||||||
|
who, snapshot, now, page,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return '页码无效,请发送:农场详述 1'
|
||||||
|
if not self.rendering:
|
||||||
|
return details_text(rows, page, pages)
|
||||||
|
return await self.rendering.farm_details(
|
||||||
|
who, snapshot, now, page, season_for(now),
|
||||||
|
image_format=self.image_format, avatar=avatar,
|
||||||
|
)
|
||||||
if action == "inventory":
|
if action == "inventory":
|
||||||
if args:
|
if args:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""离线生成带全景与地块表格的农场详述预览。"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import replace
|
||||||
|
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 Identity
|
||||||
|
from farm_core.pests import start_growth
|
||||||
|
from farm_render import FarmRenderer
|
||||||
|
from farm_render.details import render_details
|
||||||
|
from render_demo import demonstration
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
snapshot, now = demonstration()
|
||||||
|
plots = list(snapshot.plots)
|
||||||
|
plots[0] = replace(plots[0], pest_count=2, pest_spawned=2)
|
||||||
|
plots[2] = replace(plots[2], pest_count=0, pest_spawned=3,
|
||||||
|
maturity_pests=3)
|
||||||
|
plots[3] = start_growth(plots[3], now-14400)
|
||||||
|
snapshot = replace(snapshot, plots=tuple(plots))
|
||||||
|
renderer = FarmRenderer(choose_font())
|
||||||
|
who = Identity('demo', 'local', 'qq', 'details')
|
||||||
|
for page in (1, 2):
|
||||||
|
(args.output / f'farm-details-{page}.png').write_bytes(render_details(
|
||||||
|
renderer, who, snapshot, now, page, 'autumn', 'PNG',
|
||||||
|
))
|
||||||
|
print(f'Detail previews: {args.output}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""一次快照生成农场全景和分页明细;不读取或修改数据库。"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from io import BytesIO
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from farm_core.domain import boosted
|
||||||
|
from farm_core.pests import advance_plot
|
||||||
|
|
||||||
|
from .canvas import Canvas, font
|
||||||
|
from .assets import Assets
|
||||||
|
from .layout import scene_from_snapshot
|
||||||
|
from .models import RenderOptions
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
PAGE_SIZE = 10
|
||||||
|
BEIJING = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DetailRow:
|
||||||
|
slot: int # 与全景标号一致的本地地块编号。
|
||||||
|
name: str # 作物名称,空地显示空地。
|
||||||
|
state: str # 生长、成熟、枯萎或空地状态。
|
||||||
|
stage: str # 已验证资源中的阶段序号,不猜测开花等语义。
|
||||||
|
season: str # 当前待收获季数;枯萎时为已完成季数。
|
||||||
|
amount: int | None # 当前虫况预估或成熟后的确定产量。
|
||||||
|
maturity: str # 北京时间的预计成熟时间或可操作提示。
|
||||||
|
pest_note: str # 当前虫数及生长减速或成熟减产信息。
|
||||||
|
|
||||||
|
|
||||||
|
def detail_rows(renderer, identity, snapshot, now, page):
|
||||||
|
plots = sorted(snapshot.plots, key=lambda p: p.slot)
|
||||||
|
pages = max(1, (len(plots)+PAGE_SIZE-1)//PAGE_SIZE)
|
||||||
|
if type(page) is not int or not 1 <= page <= pages:
|
||||||
|
raise ValueError('Invalid detail page')
|
||||||
|
metadata = renderer.assets.metadata if renderer else Assets(0).metadata
|
||||||
|
views = {v.slot: v for v in scene_from_snapshot(
|
||||||
|
identity, snapshot, now, metadata,
|
||||||
|
).plots}
|
||||||
|
rows = []
|
||||||
|
for stored in plots[(page-1)*PAGE_SIZE:page*PAGE_SIZE]:
|
||||||
|
plot = advance_plot(stored, now)
|
||||||
|
plant = plot.plant
|
||||||
|
if plant is None:
|
||||||
|
rows.append(DetailRow(plot.slot, '空地', '空地', '—', '—',
|
||||||
|
None, '可播种', ''))
|
||||||
|
continue
|
||||||
|
if plot.withered:
|
||||||
|
rows.append(DetailRow(plot.slot, plant.name, '已枯萎', '枯萎',
|
||||||
|
f'{plant.crop}/{plant.crop}季', None,
|
||||||
|
'可铲除', ''))
|
||||||
|
continue
|
||||||
|
ripe = plot.mature_at is not None and plot.mature_at <= now
|
||||||
|
pests = plot.maturity_pests if ripe else plot.pest_count
|
||||||
|
amount = boosted(plant.harvest, plot.yield_bonus)*(100-5*pests)//100
|
||||||
|
info = metadata['plants'].get(plant.name)
|
||||||
|
total = len(set(info['phase'].split(','))) if info else None
|
||||||
|
stage = ('成熟' if ripe else
|
||||||
|
f'阶段 {views[plot.slot].stage}/{total}' if total else '生长中')
|
||||||
|
maturity = '可收获' if ripe else datetime.fromtimestamp(
|
||||||
|
plot.mature_at, BEIJING,
|
||||||
|
).strftime('%m-%d %H:%M')
|
||||||
|
note = ''
|
||||||
|
if ripe and (plot.pest_count or pests):
|
||||||
|
note = (f'当前虫 {plot.pest_count}只 · 成熟时 {pests}只'
|
||||||
|
f' · 本季减产 {5*pests}%')
|
||||||
|
elif plot.pest_count:
|
||||||
|
note = (f'虫 {plot.pest_count}只 · 生长减速 {5*pests}%'
|
||||||
|
f' · 预计减产 {5*pests}%')
|
||||||
|
rows.append(DetailRow(
|
||||||
|
plot.slot, plant.name, '已成熟' if ripe else '生长中', stage,
|
||||||
|
f'{plot.harvest_count+1}/{plant.crop}季', amount, maturity, note,
|
||||||
|
))
|
||||||
|
return tuple(rows), pages
|
||||||
|
|
||||||
|
|
||||||
|
def details_text(rows, page, pages):
|
||||||
|
lines = [f'农场详述 第{page}/{pages}页']
|
||||||
|
for row in rows:
|
||||||
|
amount = ('—' if row.amount is None else
|
||||||
|
f'{"当前" if row.state == "已成熟" else "预计"}'
|
||||||
|
f'{row.amount}个')
|
||||||
|
lines.append(f'{row.slot}号地 {row.name}|{row.stage}|{row.season}'
|
||||||
|
f'|{amount}|{row.maturity}')
|
||||||
|
if row.pest_note:
|
||||||
|
lines.append(' ' + row.pest_note)
|
||||||
|
lines.append('时间为北京时间;生长中产量按当前虫况估算,后续变化会更新。')
|
||||||
|
if page < pages:
|
||||||
|
lines.append(f'下一页:农场详述 {page+1}')
|
||||||
|
if page > 1:
|
||||||
|
lines.append(f'上一页:农场详述 {page-1}')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_details(renderer, identity, snapshot, now, page,
|
||||||
|
theme='default', image_format='AUTO', avatar=None):
|
||||||
|
rows, pages = detail_rows(renderer, identity, snapshot, now, page)
|
||||||
|
options = RenderOptions(format=image_format, quality=85, theme=theme)
|
||||||
|
farm = renderer.render(identity, snapshot, now, options, avatar)
|
||||||
|
height = 922 + max(1, len(rows))*92
|
||||||
|
canvas = Canvas.new((1024, height), '#ac713c')
|
||||||
|
draw = ImageDraw.Draw(canvas.image)
|
||||||
|
draw.rounded_rectangle((12, 12, 1012, height-12), 20,
|
||||||
|
fill='#fff1d0', outline='#663e20', width=4)
|
||||||
|
|
||||||
|
def text(value, x, y, size=24, color='#58341c', width=970):
|
||||||
|
value = canvas.fit_text(value, renderer.font_path, size, width)
|
||||||
|
box = font(renderer.font_path, size).getbbox(value)
|
||||||
|
canvas.text((x, y-box[1]), value, renderer.font_path, size, color)
|
||||||
|
|
||||||
|
draw.rounded_rectangle((24, 24, 1000, 82), 12,
|
||||||
|
fill='#d98a32', outline='#a46325', width=2)
|
||||||
|
text(snapshot.account.name+' · 农场详述', 44, 39, 30,
|
||||||
|
'#fff5dd', width=690)
|
||||||
|
text(f'第 {page}/{pages} 页', 802, 45, 23, '#fff5dd', width=180)
|
||||||
|
with Image.open(BytesIO(farm.content)) as image:
|
||||||
|
image = image.convert('RGBA').resize((976, 595),
|
||||||
|
Image.Resampling.LANCZOS)
|
||||||
|
canvas.paste(image, (24, 96))
|
||||||
|
soil_w, soil_h = renderer.assets.metadata['soil_size']
|
||||||
|
selected = {row.slot for row in rows}
|
||||||
|
for plot in snapshot.plots:
|
||||||
|
pos = renderer.assets.metadata['positions'][plot.slot-1]
|
||||||
|
x = round(24 + (pos['x']+soil_w/2)*976/3072)
|
||||||
|
y = round(96 + (pos['y']+soil_h*.85)*595/1872)
|
||||||
|
fill = '#f8d85b' if plot.slot in selected else '#e8e2cc'
|
||||||
|
draw.ellipse((x-14, y-14, x+14, y+14), fill=fill,
|
||||||
|
outline='#67431f', width=2)
|
||||||
|
label = str(plot.slot)
|
||||||
|
box = font(renderer.font_path, 17).getbbox(label)
|
||||||
|
text(label, x-(box[2]-box[0])//2, y-9, 17, width=30)
|
||||||
|
text('黄色编号为本页地块 · 生长阶段与收获季数分别展示',
|
||||||
|
40, 711, 22, '#7c704b')
|
||||||
|
text('生长中按当前虫况估算;已成熟显示当前可收数量。',
|
||||||
|
40, 747, 20, '#7c704b')
|
||||||
|
columns = ((40, 238), (292, 146), (452, 126), (592, 138), (750, 228))
|
||||||
|
draw.rounded_rectangle((24, 785, 1000, 833), 8, fill='#e5d09a')
|
||||||
|
for label, (x, width) in zip(
|
||||||
|
('地块 / 作物', '生长阶段', '收获季数', '预计 / 可收', '成熟时间'),
|
||||||
|
columns,
|
||||||
|
):
|
||||||
|
text(label, x, 797, 22, width=width)
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
y = 843 + index*92
|
||||||
|
draw.rectangle((24, y-4, 1000, y+82),
|
||||||
|
fill='#fff8e5' if index % 2 == 0 else '#f7eac9')
|
||||||
|
values = (f'{row.slot} {row.name}', row.stage, row.season,
|
||||||
|
'—' if row.amount is None else f'{row.amount}个', row.maturity)
|
||||||
|
for value, (x, width) in zip(values, columns):
|
||||||
|
text(value, x, y+9, 23, width=width)
|
||||||
|
if row.pest_note:
|
||||||
|
text(row.pest_note, 60, y+49, 19, '#9a682f', width=910)
|
||||||
|
if not rows:
|
||||||
|
text('暂无已解锁地块', 390, 870, 26)
|
||||||
|
footer = 858 + max(1, len(rows))*92
|
||||||
|
navigation = f'更新于 {datetime.fromtimestamp(now, BEIJING):%m-%d %H:%M:%S}'
|
||||||
|
if page > 1:
|
||||||
|
navigation += f' 上一页:农场详述 {page-1}'
|
||||||
|
if page < pages:
|
||||||
|
navigation += f' 下一页:农场详述 {page+1}'
|
||||||
|
text(navigation, 40, footer, 22, width=940)
|
||||||
|
codec = options.codec(('JPEG',))
|
||||||
|
content = canvas.encode(codec, options.quality, options.method)
|
||||||
|
logger.info('farm details page=%s pages=%s rows=%s bytes=%s codec=%s',
|
||||||
|
page, pages, len(rows), len(content), codec)
|
||||||
|
return content
|
||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from .shop import render_shop
|
from .shop import render_shop
|
||||||
|
from .details import render_details
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -65,11 +66,12 @@ class RenderService:
|
|||||||
async def _run(self, *args):
|
async def _run(self, *args):
|
||||||
return await self._run_function(self.renderer.render, *args)
|
return await self._run_function(self.renderer.render, *args)
|
||||||
|
|
||||||
async def seed_shop(self, plants, account, page, pages):
|
async def seed_shop(self, plants, account, page, pages,
|
||||||
|
image_format='AUTO'):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise RuntimeError('Render service closed')
|
raise RuntimeError('Render service closed')
|
||||||
key = 'shop:' + hashlib.sha256(
|
key = 'shop:' + hashlib.sha256(
|
||||||
repr((plants, account, page, pages)).encode(),
|
repr((plants, account, page, pages, image_format)).encode(),
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
task = self._pending.get(key)
|
task = self._pending.get(key)
|
||||||
if task is None:
|
if task is None:
|
||||||
@@ -77,6 +79,32 @@ class RenderService:
|
|||||||
raise RuntimeError('Render queue full')
|
raise RuntimeError('Render queue full')
|
||||||
task = asyncio.create_task(self._run_function(
|
task = asyncio.create_task(self._run_function(
|
||||||
render_shop, self.renderer, plants, account, page, pages,
|
render_shop, self.renderer, plants, account, 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 farm_details(self, identity, snapshot, now, page, theme,
|
||||||
|
image_format='AUTO', avatar=None):
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError('Render service closed')
|
||||||
|
key = 'details:' + hashlib.sha256(repr((
|
||||||
|
identity, snapshot, now, page, theme, image_format, avatar,
|
||||||
|
)).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_details, self.renderer, identity, snapshot, now,
|
||||||
|
page, theme, image_format, avatar,
|
||||||
))
|
))
|
||||||
self._pending[key] = task
|
self._pending[key] = task
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import shlex
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .canvas import Canvas, font
|
from .canvas import Canvas, font
|
||||||
|
from .models import RenderOptions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
PAGE_SIZE = 6
|
PAGE_SIZE = 6
|
||||||
@@ -29,7 +30,9 @@ def duration(seconds):
|
|||||||
) if value) or '0秒'
|
) if value) or '0秒'
|
||||||
|
|
||||||
|
|
||||||
def render_shop(renderer, plants, account, page, pages):
|
def render_shop(renderer, plants, account, 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)))
|
canvas = Canvas(renderer.assets.image('webgl/shop-board.png', (1024, 1536)))
|
||||||
|
|
||||||
def text(value, xy, size=28, color='#5b3015', width=850):
|
def text(value, xy, size=28, color='#5b3015', width=850):
|
||||||
@@ -90,4 +93,7 @@ def render_shop(renderer, plants, account, page, pages):
|
|||||||
elif page > 1:
|
elif page > 1:
|
||||||
navigation += f' 上一页:种子商店 {page-1}'
|
navigation += f' 上一页:种子商店 {page-1}'
|
||||||
text(navigation, (263, 1420), 23, width=510)
|
text(navigation, (263, 1420), 23, width=510)
|
||||||
return canvas.encode('PNG')
|
content = canvas.encode(codec, options.quality, options.method)
|
||||||
|
logger.info('farm shop render width=%s height=%s bytes=%s codec=%s',
|
||||||
|
*canvas.image.size, len(content), codec)
|
||||||
|
return content
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from dataclasses import replace
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
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, Identity
|
||||||
|
from farm_core.domain import Account, Plot, Snapshot, boosted
|
||||||
|
from farm_render import FarmRenderer
|
||||||
|
from farm_render.details import detail_rows, render_details
|
||||||
|
|
||||||
|
|
||||||
|
class DetailTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.renderer = FarmRenderer(choose_font())
|
||||||
|
self.plant = Catalogue.bundled().get('草莓')
|
||||||
|
self.who = Identity('test', 'local', 'qq', 'details')
|
||||||
|
|
||||||
|
def snapshot(self, plots):
|
||||||
|
return Snapshot(Account('详述演示', 500, 0, 0), (), tuple(plots))
|
||||||
|
|
||||||
|
def test_frozen_maturity_loss_survives_clearing_and_integer_rounding(self):
|
||||||
|
plot = Plot(1, self.plant, 100, yield_bonus=10,
|
||||||
|
pest_count=0, pest_spawned=3, maturity_pests=3)
|
||||||
|
rows, pages = detail_rows(self.renderer, self.who,
|
||||||
|
self.snapshot([plot]), 200, 1)
|
||||||
|
self.assertEqual(pages, 1)
|
||||||
|
self.assertEqual(rows[0].amount,
|
||||||
|
boosted(self.plant.harvest, 10)*85//100)
|
||||||
|
self.assertEqual(rows[0].state, '已成熟')
|
||||||
|
self.assertIn('15%', rows[0].pest_note)
|
||||||
|
self.assertIn('成熟时', rows[0].pest_note)
|
||||||
|
|
||||||
|
def test_current_pests_and_season_boundary_empty_withered_pagination(self):
|
||||||
|
plot = Plot(1, self.plant, 1000, pest_count=2, pest_spawned=2)
|
||||||
|
rows, _ = detail_rows(self.renderer, self.who,
|
||||||
|
self.snapshot([plot]), 200, 1)
|
||||||
|
self.assertEqual(rows[0].amount, self.plant.harvest*90//100)
|
||||||
|
self.assertIn('预计', rows[0].pest_note)
|
||||||
|
data = self.snapshot([Plot(i) for i in range(1, 12)] + [
|
||||||
|
replace(plot, slot=12, harvest_count=self.plant.crop),
|
||||||
|
])
|
||||||
|
rows, pages = detail_rows(self.renderer, self.who, data, 200, 2)
|
||||||
|
self.assertEqual(pages, 2)
|
||||||
|
self.assertEqual([r.slot for r in rows], [11, 12])
|
||||||
|
self.assertIsNone(rows[0].amount)
|
||||||
|
self.assertEqual(rows[1].state, '已枯萎')
|
||||||
|
self.assertIsNone(rows[1].amount)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
detail_rows(self.renderer, self.who, data, 200, 3)
|
||||||
|
|
||||||
|
def test_render_full_farm_and_table(self):
|
||||||
|
data = self.snapshot([Plot(1, self.plant, 100)])
|
||||||
|
result = render_details(self.renderer, self.who, data, 200, 1,
|
||||||
|
'autumn', 'PNG')
|
||||||
|
with Image.open(BytesIO(result)) as image:
|
||||||
|
self.assertEqual(image.width, 1024)
|
||||||
|
self.assertGreater(image.height, 624)
|
||||||
@@ -46,4 +46,15 @@ class ShopRenderTests(unittest.TestCase):
|
|||||||
self.assertIn('按名称:购买种子 胡萝卜 5', labels)
|
self.assertIn('按名称:购买种子 胡萝卜 5', labels)
|
||||||
with Image.open(BytesIO(result)) as image:
|
with Image.open(BytesIO(result)) as image:
|
||||||
self.assertEqual(image.size, (1024, 1536))
|
self.assertEqual(image.size, (1024, 1536))
|
||||||
self.assertEqual(image.format, 'PNG')
|
self.assertEqual(image.format, 'JPEG')
|
||||||
|
|
||||||
|
def test_explicit_png_and_default_jpeg_keep_dimensions(self):
|
||||||
|
renderer = FarmRenderer(choose_font())
|
||||||
|
rows, pages = shop_page(Catalogue.bundled(), 1)
|
||||||
|
jpeg = render_shop(renderer, rows, None, 1, pages)
|
||||||
|
png = render_shop(renderer, rows, None, 1, pages, 'PNG')
|
||||||
|
for content, codec in ((jpeg, 'JPEG'), (png, 'PNG')):
|
||||||
|
with Image.open(BytesIO(content)) as image:
|
||||||
|
self.assertEqual(image.size, (1024, 1536))
|
||||||
|
self.assertEqual(image.format, codec)
|
||||||
|
self.assertLess(len(jpeg), len(png) // 2)
|
||||||
|
|||||||
@@ -161,6 +161,42 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.module.operation_id("b", "p", "u", "2", "buy"),
|
self.module.operation_id("b", "p", "u", "2", "buy"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_farm_details_image_pagination_text_and_single_snapshot(self):
|
||||||
|
from io import BytesIO
|
||||||
|
from PIL import Image
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
self.assertEqual(self.module.COMMANDS['农场详述'], 'details')
|
||||||
|
self.assertEqual(self.module.COMMANDS['我的种子'], 'inventory')
|
||||||
|
await self.call('register')
|
||||||
|
await self.call('buy', '白萝卜 1', 'buy-details')
|
||||||
|
await self.call('sow', '白萝卜 1', 'sow-details')
|
||||||
|
before = await self.runtime.service.snapshot(self.runtime_identity())
|
||||||
|
with patch.object(self.runtime.service, 'snapshot',
|
||||||
|
new=AsyncMock(return_value=before)) as query:
|
||||||
|
image = await self.call('details')
|
||||||
|
query.assert_awaited_once()
|
||||||
|
with Image.open(BytesIO(image)) as decoded:
|
||||||
|
self.assertEqual(decoded.width, 1024)
|
||||||
|
self.assertIsInstance(await self.call('inventory'), str)
|
||||||
|
self.assertIn('页码无效', await self.call('details', '999'))
|
||||||
|
self.assertIsInstance(await self.call('farm', 'detail'), bytes)
|
||||||
|
rendering = self.runtime.rendering
|
||||||
|
self.runtime.rendering = None
|
||||||
|
try:
|
||||||
|
text = await self.call('details')
|
||||||
|
self.assertIn('白萝卜', text)
|
||||||
|
self.assertIn('1/1季', text)
|
||||||
|
self.assertNotIn('被偷', text)
|
||||||
|
finally:
|
||||||
|
self.runtime.rendering = rendering
|
||||||
|
|
||||||
|
def runtime_identity(self):
|
||||||
|
identity = importlib.import_module(
|
||||||
|
self.package + '._vendor.farm_core',
|
||||||
|
).Identity
|
||||||
|
return identity(self.runtime.deployment, self.context['bot_id'],
|
||||||
|
self.context['platform'], self.context['user_id'])
|
||||||
|
|
||||||
async def test_invalid_arguments_and_missing_font_are_explicit(self):
|
async def test_invalid_arguments_and_missing_font_are_explicit(self):
|
||||||
await self.call("register")
|
await self.call("register")
|
||||||
for action, arguments in (("buy", "白萝卜 nope"),
|
for action, arguments in (("buy", "白萝卜 nope"),
|
||||||
|
|||||||
Reference in New Issue
Block a user