feat(gauntlet): post completion recap embed on 10-win finish (Roadmap 2.4a) #164
154
gauntlets.py
154
gauntlets.py
@ -2393,6 +2393,156 @@ 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 loss_max is not None and losses > loss_max
|
||||
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 +2694,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',
|
||||
|
||||
315
tests/test_gauntlet_recap.py
Normal file
315
tests/test_gauntlet_recap.py
Normal file
@ -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 with ❌ — definitively missed, not pending (⬜)
|
||||
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 "❌" 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()
|
||||
Loading…
Reference in New Issue
Block a user