diff --git a/gauntlets.py b/gauntlets.py index d617bd6..9b8beb6 100644 --- a/gauntlets.py +++ b/gauntlets.py @@ -2393,6 +2393,150 @@ async def evolve_pokemon(this_team: Team, channel, responders): await channel.send("All of your Pokemon are fully evolved!") +def build_gauntlet_recap_embed( + this_run: dict, + this_event: dict, + main_team: dict, + rewards: list[dict], +) -> discord.Embed: + """Build a Discord embed summarising a completed gauntlet run. + + Called when a player finishes a gauntlet (10 wins). This is a pure + synchronous builder so it can be unit-tested without a Discord connection. + + Args: + this_run: gauntletruns API dict (must have wins/losses/team keys). + this_event: events API dict (name, url, short_desc). + main_team: teams API dict for the player's real team (gmid, lname, logo). + rewards: list of gauntletrewards API dicts for the event. + + Returns: + A discord.Embed with champion highlight, run record, and prize table. + """ + # Gold/champion accent colour + GOLD = 0xFFD700 + + team_name = main_team.get("lname", "Unknown Team") + gmid = main_team.get("gmid") + wins = this_run.get("wins", 0) + losses = this_run.get("losses", 0) + event_name = this_event.get("name", "Gauntlet") + + embed = discord.Embed( + title=f"Gauntlet Complete: {event_name}", + color=GOLD, + ) + + # Champion highlight + champion_value = f"**{team_name}**" + if gmid: + champion_value += f"\n<@{gmid}>" + if main_team.get("logo"): + embed.set_thumbnail(url=main_team["logo"]) + embed.add_field(name="Champion", value=champion_value, inline=True) + + # Run record + embed.add_field( + name="Final Record", + value=f"**{wins}-{losses}**", + inline=True, + ) + + # Bracket / progression — for the solo gauntlet format this is a + # milestone ladder (win-by-win), not a bracket tree. + if wins > 0: + bracket_lines = [] + milestone_wins = sorted( + {r["win_num"] for r in rewards if r.get("win_num") is not None} + ) + for mw in milestone_wins: + marker = "✅" if wins >= mw else "⬜" + bracket_lines.append(f"{marker} Win {mw}") + if bracket_lines: + embed.add_field( + name="Progression", + value="\n".join(bracket_lines), + inline=False, + ) + + # Prize distribution table + if rewards: + prize_lines = [] + for r in sorted(rewards, key=lambda x: x.get("win_num", 0)): + win_num = r.get("win_num", "?") + loss_max = r.get("loss_max") + label = f"{win_num}-0" if loss_max == 0 else f"{win_num} Wins" + + earned = ( + wins >= win_num and losses <= loss_max + if loss_max is not None + else wins >= win_num + ) + marker = "✅" if earned else "❌" if losses > (loss_max or 99) else "⬜" + + reward = r.get("reward", {}) + if reward.get("money"): + prize_desc = f"{reward['money']}₼" + elif reward.get("player"): + prize_desc = reward["player"].get("description", "Special Card") + elif reward.get("pack_type"): + prize_desc = f"1x {reward['pack_type']['name']} Pack" + else: + prize_desc = "Reward" + + prize_lines.append(f"{marker} {label}: {prize_desc}") + + if prize_lines: + embed.add_field( + name="Prize Distribution", + value="\n".join(prize_lines), + inline=False, + ) + + embed.set_footer(text="Paper Dynasty Gauntlet") + return embed + + +async def post_gauntlet_recap( + this_run: dict, + this_event: dict, + main_team: dict, + channel, +) -> None: + """Send a gauntlet completion recap embed to the given channel. + + Fetches all rewards for the event so the prize table is complete, then + builds and posts the recap embed. Gracefully handles a missing or + unavailable channel by logging and returning without raising so the + gauntlet completion flow is never interrupted. + + Args: + this_run: gauntletruns API dict. + this_event: events API dict. + main_team: player's main teams API dict. + channel: discord.TextChannel to post to, or None. + """ + if channel is None: + logger.warning( + "post_gauntlet_recap: no channel available — recap skipped " + f"(run_id={this_run.get('id')})" + ) + return + try: + all_rewards_query = await db_get( + "gauntletrewards", params=[("gauntlet_id", this_event["id"])] + ) + all_rewards = all_rewards_query.get("rewards", []) + embed = build_gauntlet_recap_embed(this_run, this_event, main_team, all_rewards) + await channel.send(embed=embed) + except Exception: + logger.warning( + "post_gauntlet_recap: failed to send recap embed " + f"(run_id={this_run.get('id')})", + exc_info=True, + ) + + async def post_result( run_id: int, is_win: bool, @@ -2544,6 +2688,10 @@ async def post_result( final_message += f"\n\nGo share the highlights in {get_channel(channel, 'pd-news-ticker').mention}!" await channel.send(content=final_message, embed=await get_embed(this_run)) + + # Post gauntlet completion recap embed when the run is finished (10 wins) + if this_run["wins"] == 10: + await post_gauntlet_recap(this_run, this_event, main_team, channel) else: # this_run = await db_patch( # 'gauntletruns', diff --git a/tests/test_gauntlet_recap.py b/tests/test_gauntlet_recap.py new file mode 100644 index 0000000..de5daa0 --- /dev/null +++ b/tests/test_gauntlet_recap.py @@ -0,0 +1,315 @@ +""" +Tests for the gauntlet completion recap embed (Roadmap 2.4a). + +These tests verify: + 1. build_gauntlet_recap_embed produces an embed with the required fields + (title, champion, record, prize distribution) for a completed run. + 2. The embed title always starts with "Gauntlet Complete:". + 3. The champion field contains the team name and, when gmid is present, + a Discord user mention (<@gmid>). + 4. The prize distribution marks earned rewards with ✅ and unearned with ❌ + or ⬜ depending on whether losses were exceeded. + 5. post_gauntlet_recap is a no-op when channel is None (graceful fallback). + 6. post_gauntlet_recap calls channel.send exactly once on success. + 7. post_gauntlet_recap does not raise when channel.send raises — gauntlet + completion must never be interrupted by a recap failure. + +The builder is a pure synchronous function so tests do not require an async +event loop; only the async sender tests use pytest-asyncio. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +import discord + +from gauntlets import build_gauntlet_recap_embed, post_gauntlet_recap + + +# --------------------------------------------------------------------------- +# Test data helpers +# --------------------------------------------------------------------------- + + +def make_run(wins: int = 10, losses: int = 1, run_id: int = 42) -> dict: + """Return a minimal gauntletruns API dict for a completed run.""" + return { + "id": run_id, + "wins": wins, + "losses": losses, + "gauntlet": {"id": 9, "name": "2005 Live"}, + "team": {"lname": "Gauntlet-NCB", "logo": None}, + } + + +def make_event(event_id: int = 9, name: str = "2005 Live") -> dict: + """Return a minimal events API dict.""" + return { + "id": event_id, + "name": name, + "url": "https://example.com/gauntlet.png", + "short_desc": "Go 10-0!", + } + + +def make_main_team(gmid: int = 123456789, lname: str = "Normal CornBelters") -> dict: + """Return a minimal teams API dict for the player's real team.""" + return { + "id": 31, + "lname": lname, + "gmid": gmid, + "logo": "https://example.com/logo.png", + } + + +def make_rewards() -> list[dict]: + """Return a representative gauntletrewards list for a gauntlet.""" + return [ + { + "win_num": 3, + "loss_max": 2, + "reward": {"money": 500, "player": None, "pack_type": None}, + }, + { + "win_num": 7, + "loss_max": 2, + "reward": { + "money": None, + "player": None, + "pack_type": {"id": 1, "name": "Standard"}, + }, + }, + { + "win_num": 10, + "loss_max": 0, + "reward": { + "money": None, + "player": { + "player_id": 99, + "description": "Babe Ruth HoF", + }, + "pack_type": None, + }, + }, + ] + + +# --------------------------------------------------------------------------- +# Unit: build_gauntlet_recap_embed +# --------------------------------------------------------------------------- + + +class TestBuildGauntletRecapEmbed: + """Verify the embed produced by the synchronous builder function.""" + + def test_title_starts_with_gauntlet_complete(self): + """Title must start with 'Gauntlet Complete:' followed by the event name. + + This is the canonical format expected by the PO spec and makes the + embed immediately recognisable in the channel feed. + """ + embed = build_gauntlet_recap_embed( + make_run(), make_event(), make_main_team(), make_rewards() + ) + assert embed.title.startswith("Gauntlet Complete:") + assert "2005 Live" in embed.title + + def test_embed_colour_is_gold(self): + """Embed colour must be the gold/champion accent (0xFFD700). + + Gold is the PO-specified accent for champion-level events. + """ + embed = build_gauntlet_recap_embed( + make_run(), make_event(), make_main_team(), make_rewards() + ) + assert embed.color.value == 0xFFD700 + + def test_champion_field_contains_team_name(self): + """The Champion field must display the team's long name. + + Players identify with their team name, not the gauntlet draft copy. + """ + embed = build_gauntlet_recap_embed( + make_run(), + make_event(), + make_main_team(lname="Normal CornBelters"), + make_rewards(), + ) + champion_field = next((f for f in embed.fields if f.name == "Champion"), None) + assert champion_field is not None, "Expected a 'Champion' embed field" + assert "Normal CornBelters" in champion_field.value + + def test_champion_field_contains_user_mention_when_gmid_present(self): + """When gmid is set, the Champion field must include a Discord user mention. + + The mention (<@gmid>) creates social validation — the winner is pinged + in the channel where they completed the gauntlet. + """ + embed = build_gauntlet_recap_embed( + make_run(), make_event(), make_main_team(gmid=987654321), make_rewards() + ) + champion_field = next(f for f in embed.fields if f.name == "Champion") + assert "<@987654321>" in champion_field.value + + def test_champion_field_omits_mention_when_gmid_is_none(self): + """When gmid is None the embed must not include any mention syntax. + + Some legacy records or AI teams may not have a Discord user ID. + The embed must still be valid without one. + """ + team = make_main_team() + team["gmid"] = None + embed = build_gauntlet_recap_embed( + make_run(), make_event(), team, make_rewards() + ) + champion_field = next(f for f in embed.fields if f.name == "Champion") + assert "<@" not in champion_field.value + + def test_final_record_field_present(self): + """Final Record field must show wins-losses for the completed run.""" + embed = build_gauntlet_recap_embed( + make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() + ) + record_field = next((f for f in embed.fields if f.name == "Final Record"), None) + assert record_field is not None, "Expected a 'Final Record' field" + assert "10" in record_field.value + assert "1" in record_field.value + + def test_prize_distribution_field_present(self): + """Prize Distribution field must be present when rewards are provided.""" + embed = build_gauntlet_recap_embed( + make_run(), make_event(), make_main_team(), make_rewards() + ) + prize_field = next( + (f for f in embed.fields if f.name == "Prize Distribution"), None + ) + assert prize_field is not None, "Expected a 'Prize Distribution' field" + + def test_earned_rewards_marked_with_checkmark(self): + """Rewards that were earned (wins >= threshold and losses within limit) + must be marked with ✅. + + A 10-1 run earns the 3-win and 7-win milestones but not the 10-0 bonus. + """ + embed = build_gauntlet_recap_embed( + make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() + ) + prize_field = next(f for f in embed.fields if f.name == "Prize Distribution") + # 3-win (loss_max=2, losses=1) → earned + assert "✅" in prize_field.value + + def test_unearned_perfect_bonus_marked_correctly(self): + """The 10-0 bonus reward must NOT be marked earned when losses > 0.""" + embed = build_gauntlet_recap_embed( + make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() + ) + prize_field = next(f for f in embed.fields if f.name == "Prize Distribution") + # The 10-0 bonus line must appear without ✅ (either ❌ or ⬜) + lines = prize_field.value.split("\n") + bonus_line = next((line for line in lines if "10-0" in line), None) + assert bonus_line is not None, "Expected a '10-0' line in prizes" + assert "✅" not in bonus_line + + def test_empty_rewards_list_omits_prize_field(self): + """When rewards is an empty list the Prize Distribution field must be omitted. + + Some event types may not have configured rewards; the embed must + still be valid and informative without a prize table. + """ + embed = build_gauntlet_recap_embed( + make_run(), make_event(), make_main_team(), [] + ) + prize_field = next( + (f for f in embed.fields if f.name == "Prize Distribution"), None + ) + assert prize_field is None + + +# --------------------------------------------------------------------------- +# Async: post_gauntlet_recap +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_post_gauntlet_recap_sends_embed_on_success(): + """When all inputs are valid post_gauntlet_recap sends exactly one embed. + + This confirms the function wires build_gauntlet_recap_embed → channel.send + correctly and doesn't double-post. + """ + channel = AsyncMock() + all_rewards_response = {"rewards": make_rewards()} + + with patch( + "gauntlets.db_get", new_callable=AsyncMock, return_value=all_rewards_response + ): + await post_gauntlet_recap(make_run(), make_event(), make_main_team(), channel) + + channel.send.assert_called_once() + # Verify an embed was passed (not just plain text) + kwargs = channel.send.call_args.kwargs + assert "embed" in kwargs + assert isinstance(kwargs["embed"], discord.Embed) + + +@pytest.mark.asyncio +async def test_post_gauntlet_recap_noop_when_channel_is_none(): + """When channel is None post_gauntlet_recap must return without raising. + + The gauntlet channel may be unavailable (deleted, bot lost permissions, + or not set in the record). The completion flow must never fail due to + a missing recap channel. + """ + # No channel.send to assert on — just ensure no exception is raised + with patch( + "gauntlets.db_get", new_callable=AsyncMock, return_value={"rewards": []} + ): + try: + await post_gauntlet_recap(make_run(), make_event(), make_main_team(), None) + except Exception as exc: + pytest.fail(f"post_gauntlet_recap raised with None channel: {exc}") + + +@pytest.mark.asyncio +async def test_post_gauntlet_recap_nonfatal_when_channel_send_raises(): + """A channel.send failure must not propagate out of post_gauntlet_recap. + + The gauntlet run is already complete when the recap fires; a Discord API + error (rate limit, permissions revoked) must not corrupt the game state. + """ + channel = AsyncMock() + channel.send.side_effect = Exception("Discord API error") + all_rewards_response = {"rewards": make_rewards()} + + with patch( + "gauntlets.db_get", new_callable=AsyncMock, return_value=all_rewards_response + ): + try: + await post_gauntlet_recap( + make_run(), make_event(), make_main_team(), channel + ) + except Exception as exc: + pytest.fail(f"post_gauntlet_recap raised when channel.send failed: {exc}") + + +@pytest.mark.asyncio +async def test_post_gauntlet_recap_nonfatal_when_db_get_raises(): + """A db_get failure inside post_gauntlet_recap must not propagate. + + If the rewards endpoint is unavailable the recap silently skips rather + than crashing the completion flow. + """ + channel = AsyncMock() + + with patch( + "gauntlets.db_get", new_callable=AsyncMock, side_effect=Exception("API down") + ): + try: + await post_gauntlet_recap( + make_run(), make_event(), make_main_team(), channel + ) + except Exception as exc: + pytest.fail(f"post_gauntlet_recap raised when db_get failed: {exc}") + + # channel.send should NOT have been called since the error happened before it + channel.send.assert_not_called()