diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index f8c70cd..2e0cb89 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -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( diff --git a/helpers/refractor_notifs.py b/helpers/refractor_notifs.py index 91a1aaa..a1ca1d5 100644 --- a/helpers/refractor_notifs.py +++ b/helpers/refractor_notifs.py @@ -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( diff --git a/tests/test_post_game_refractor_hook.py b/tests/test_post_game_refractor_hook.py index 719765f..c76fea9 100644 --- a/tests/test_post_game_refractor_hook.py +++ b/tests/test_post_game_refractor_hook.py @@ -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"] # ---------------------------------------------------------------------------