diff --git a/docs/21-farm-details.md b/docs/21-farm-details.md new file mode 100644 index 0000000..33bbe7f --- /dev/null +++ b/docs/21-farm-details.md @@ -0,0 +1,27 @@ +# 农场详述图片 + +`农场详述 [页码]` 返回上方全景与下方地块明细表,每页最多 10 块地。 +`我的农场 detail [页码]` 是兼容入口。其他资产、种子和作物查询继续返回 +原有文字仓库信息,不与新详述混用。 + +全景标注所有已解锁地块编号,黄色标出本页,表格沿用同一编号。 +表格列为地块/作物、生长阶段、收获季数、预计/可收数量、成熟时间。 +没有可靠的阶段名称元数据,因此生长阶段显示阶段序号,避免把发芽或 +开花等名称猜错;与第几季/总共几季分别展示。时间采用北京时间。 + +生长中产量按当前虫数估算;预计成熟时间沿用核心虫害投影,包含后续 +已确定的生虫事件。成熟后产量与收获一致:先计算土地加成向下取整, +再乘 `(100 - 成熟时虫数 × 5) // 100`。成熟后除虫不恢复被冻结的减产。 +当前虫数、生长减速或成熟减产以小字列在对应行下方。 + +不显示被偷明细。当前领域模型尚无偷菜扣减字段或交易,故未伪造或 +预扣偷菜损失。未来实现偷菜时,需同步接入实际收获结算与详述可收数量。 + +每次请求只读取一次账户快照,全景和明细使用同一快照与绘制时间, +不写入数据库。异步队列与农场/商店共享并发上限、去重和关闭排空。 +字体不可用时返回同一数据口径的分页文字。输出遵循配置的图片格式, +AUTO 使用 JPEG。无效页码返回中文提示,未开通时返回开通提示。 + +运行 `scripts/preview_details.py --output <目录>` 可生成离线示例, +包括生长、成熟、虫害、成熟后除虫、空地、枯萎及两页地块。 +示例余额与状态为构造数据,不访问真实玩家数据库。 diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py index 6cfc6a0..d1f0599 100644 --- a/integrations/zhenxun/commands.py +++ b/integrations/zhenxun/commands.py @@ -77,7 +77,7 @@ def handler_for(action): message_id = getattr(event, "id", None) try: avatar = (await avatar_for(user_id, platform) - if action == "farm" else None) + if action in ("farm", "details") else None) response = await runtime.execute( action, args.extract_plain_text(), bot_id=str(bot.self_id), platform=platform, user_id=user_id, message_id=message_id, diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 1b49103..4f994ae 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -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.seasons import season_for from ._vendor.farm_render.shop import shop_page +from ._vendor.farm_render.details import detail_rows, details_text logger = logging.getLogger(__name__) COMMANDS = { - "开通农场":"register", "我的农场":"farm", "农场详述":"inventory", + "开通农场":"register", "我的农场":"farm", "农场详述":"details", "我的农场币":"inventory", "我的点券":"inventory", "我的种子":"inventory", "我的作物":"inventory", "种子商店":"shop", "农场帮助":"help", "购买种子":"buy", @@ -25,7 +26,7 @@ COMMANDS = { "农场除虫":"clear_pests", "除虫":"clear_pests", } HELP = ( - "开通农场 [名称]|我的农场 [高清]|农场详述\n" + "开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n" "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" "收获|铲除|农场除虫|我的种子|我的作物\n" "作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n" @@ -164,7 +165,7 @@ class Runtime: args = shlex.split(arguments) if action == "farm" and args: aliases = { - "detail":"inventory", "my-point":"inventory", + "detail":"details", "my-point":"inventory", "my-vipPoint":"inventory", "my-seed":"inventory", "my-plant":"inventory", "seed-shop":"shop", "buy-seed":"buy", "sowing":"sow", "harvest":"harvest", @@ -198,6 +199,7 @@ class Runtime: raise return await self.rendering.seed_shop( chunk, account, page, pages, + image_format=self.image_format, ) return f'种子商店 第{page}/{pages}页\n' + '\n'.join( f'{p.name}(#{p.plant_id}):' @@ -207,6 +209,25 @@ class Runtime: '\n购买种子 #编号 数量 或 购买种子 种子名 数量' + (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 args: raise ValueError() diff --git a/scripts/preview_details.py b/scripts/preview_details.py new file mode 100644 index 0000000..a23c749 --- /dev/null +++ b/scripts/preview_details.py @@ -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() diff --git a/src/farm_render/details.py b/src/farm_render/details.py new file mode 100644 index 0000000..cb436fd --- /dev/null +++ b/src/farm_render/details.py @@ -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 diff --git a/src/farm_render/service.py b/src/farm_render/service.py index e10ff05..ee441fd 100644 --- a/src/farm_render/service.py +++ b/src/farm_render/service.py @@ -7,6 +7,7 @@ import time import hashlib from .shop import render_shop +from .details import render_details logger = logging.getLogger(__name__) @@ -65,11 +66,12 @@ class RenderService: async def _run(self, *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: raise RuntimeError('Render service closed') key = 'shop:' + hashlib.sha256( - repr((plants, account, page, pages)).encode(), + repr((plants, account, page, pages, image_format)).encode(), ).hexdigest() task = self._pending.get(key) if task is None: @@ -77,6 +79,32 @@ class RenderService: raise RuntimeError('Render queue full') task = asyncio.create_task(self._run_function( 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 diff --git a/src/farm_render/shop.py b/src/farm_render/shop.py index 348c4ff..a6f4717 100644 --- a/src/farm_render/shop.py +++ b/src/farm_render/shop.py @@ -6,6 +6,7 @@ import shlex from PIL import Image from .canvas import Canvas, font +from .models import RenderOptions logger = logging.getLogger(__name__) PAGE_SIZE = 6 @@ -29,7 +30,9 @@ def duration(seconds): ) 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))) 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: navigation += f' 上一页:种子商店 {page-1}' 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 diff --git a/tests/test_farm_details.py b/tests/test_farm_details.py new file mode 100644 index 0000000..c7eac59 --- /dev/null +++ b/tests/test_farm_details.py @@ -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) diff --git a/tests/test_shop_render.py b/tests/test_shop_render.py index 6b4f1c1..baf80d7 100644 --- a/tests/test_shop_render.py +++ b/tests/test_shop_render.py @@ -46,4 +46,15 @@ class ShopRenderTests(unittest.TestCase): self.assertIn('按名称:购买种子 胡萝卜 5', labels) with Image.open(BytesIO(result)) as image: 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) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index 7a26655..fcd2906 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -161,6 +161,42 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): 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): await self.call("register") for action, arguments in (("buy", "白萝卜 nope"),