feat: include refractor card image in tier-up notification embed (#144) #159

Merged
cal merged 1 commits from issue/144-feat-tier-up-notification-should-include-refractor into main 2026-04-08 15:25:52 +00:00
3 changed files with 51 additions and 24 deletions

View File

@ -4248,30 +4248,41 @@ async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None:
"""Post-game refractor processing — non-fatal.
Updates season stats then evaluates refractor milestones for all
participating players. Fires tier-up notifications and triggers variant
card renders. Wrapped in try/except so any failure here is non-fatal
the game is already saved and refractor will self-heal on the next
evaluate call.
participating players. Triggers variant card renders first to obtain
image URLs, then fires tier-up notifications with card art included.
Wrapped in try/except so any failure here is non-fatal the game is
already saved and refractor will self-heal on the next evaluate call.
"""
try:
await db_post(f"season-stats/update-game/{db_game_id}")
evo_result = await db_post(f"refractor/evaluate-game/{db_game_id}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(channel, tier_up)
await _trigger_variant_renders(evo_result["tier_ups"])
tier_ups = evo_result["tier_ups"]
image_url_map = await _trigger_variant_renders(tier_ups)
for tier_up in tier_ups:
img = image_url_map.get(tier_up.get("player_id"))
await notify_tier_completion(channel, tier_up, image_url=img)
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
async def _trigger_variant_renders(tier_ups: list) -> None:
"""Fire-and-forget: hit card render URLs to trigger S3 upload for new variants.
async def _trigger_variant_renders(tier_ups: list) -> dict:
"""Trigger S3 card renders for each tier-up variant and return image URLs.
Each tier-up with a variant_created value gets a GET request to the card
render endpoint, which triggers Playwright render + S3 upload as a side effect.
Failures are logged but never raised.
render endpoint, which triggers Playwright render + S3 upload. The
response image_url (if present) is captured and returned so callers can
include the card art in tier-up notifications.
Returns
-------
dict
Mapping of player_id -> image_url. Players whose render failed or
returned no image_url are omitted; callers should treat a missing
key as None.
"""
today = datetime.date.today().isoformat()
image_urls = {}
for tier_up in tier_ups:
variant = tier_up.get("variant_created")
if variant is None:
@ -4280,16 +4291,19 @@ async def _trigger_variant_renders(tier_ups: list) -> None:
track = tier_up.get("track_name", "Batter")
card_type = "pitching" if track.lower() == "pitcher" else "batting"
try:
await db_get(
result = await db_get(
f"players/{player_id}/{card_type}card/{today}/{variant}",
none_okay=True,
)
if result and isinstance(result, dict):
image_urls[player_id] = result.get("image_url")
except Exception:
logger.warning(
"Failed to trigger variant render for player %d variant %d (non-fatal)",
player_id,
variant,
)
return image_urls
async def complete_game(

View File

@ -19,13 +19,16 @@ logger = logging.getLogger("discord_app")
FOOTER_TEXT = "Paper Dynasty Refractor"
def build_tier_up_embed(tier_up: dict) -> discord.Embed:
def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> discord.Embed:
"""Build a Discord embed for a tier-up event.
Parameters
----------
tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
image_url:
Optional S3 URL for the newly rendered refractor card image. When
provided, the card art is shown as the embed image.
Returns
-------
@ -57,12 +60,14 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
color=color,
)
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text=FOOTER_TEXT)
return embed
async def notify_tier_completion(
channel: discord.abc.Messageable, tier_up: dict
channel: discord.abc.Messageable, tier_up: dict, image_url: str | None = None
) -> None:
"""Send a tier-up notification embed to the given channel.
@ -75,9 +80,12 @@ async def notify_tier_completion(
A discord.abc.Messageable (e.g. discord.TextChannel).
tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
image_url:
Optional S3 URL for the refractor card image. Passed through to
build_tier_up_embed so the card art appears in the notification.
"""
try:
embed = build_tier_up_embed(tier_up)
embed = build_tier_up_embed(tier_up, image_url=image_url)
await channel.send(embed=embed)
except Exception as exc:
logger.error(

View File

@ -4,8 +4,8 @@ Mock-based integration tests for the post-game refractor hook.
Tests _run_post_game_refractor_hook() which orchestrates:
1. POST season-stats/update-game/{game_id} update player season stats
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones
3. notify_tier_completion() once per tier-up returned
4. _trigger_variant_renders() with the full tier_ups list
3. _trigger_variant_renders() with the full tier_ups list (returns image_url map)
4. notify_tier_completion() once per tier-up, with image_url from render
The hook is wrapped in try/except so failures are non-fatal the game
result is already persisted before this block runs. These tests cover the
@ -118,6 +118,7 @@ class TestTierUpNotifications:
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
):
await _run_post_game_refractor_hook(99, channel)
@ -157,11 +158,12 @@ class TestTierUpNotifications:
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
):
await _run_post_game_refractor_hook(1, channel)
mock_notify.assert_called_once_with(channel, tier_up)
mock_notify.assert_called_once_with(channel, tier_up, image_url=None)
async def test_no_notify_when_empty_tier_ups(self):
"""No notifications sent when evaluate returns an empty tier_ups list."""
@ -249,6 +251,7 @@ class TestVariantRenderTriggers:
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
) as mock_render,
):
await _run_post_game_refractor_hook(42, _make_channel())
@ -282,11 +285,12 @@ class TestVariantRenderTriggers:
mock_render.assert_not_called()
async def test_notifications_before_render_trigger(self):
"""All notify_tier_completion calls happen before _trigger_variant_renders.
async def test_render_before_notification(self):
"""_trigger_variant_renders is called before notify_tier_completion.
Notifications should be dispatched first so the player sees the message
before any background card renders begin.
Renders run first so that image URLs are available to include in the
tier-up notification embed. The player sees the card art immediately
rather than receiving a text-only notification.
"""
call_order = []
tier_up = {"player_id": 1, "variant_created": 5, "track_name": "Batter"}
@ -296,11 +300,12 @@ class TestVariantRenderTriggers:
return {"tier_ups": [tier_up]}
return {}
async def fake_notify(ch, tu):
async def fake_notify(ch, tu, image_url=None):
call_order.append("notify")
async def fake_render(tier_ups):
call_order.append("render")
return {}
with (
patch(
@ -319,7 +324,7 @@ class TestVariantRenderTriggers:
):
await _run_post_game_refractor_hook(1, _make_channel())
assert call_order == ["notify", "render"]
assert call_order == ["render", "notify"]
# ---------------------------------------------------------------------------