feat: WP-14 tier completion notification embeds
All checks were successful
Build Docker Image / build (pull_request) Successful in 2m5s

Adds helpers/evolution_notifs.py with build_tier_up_embed() and
notify_tier_completion(). Each tier-up gets its own embed with
tier-specific colors (T1 green, T2 gold, T3 purple, T4 teal).
Tier 4 uses a special 'FULLY EVOLVED!' title with a future rating
boosts note. Notification failure is non-fatal (try/except). 23
unit tests cover all tiers, empty list, and failure path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-18 15:59:13 -05:00
parent 8da9157f3c
commit 6c725009db
2 changed files with 366 additions and 0 deletions

107
helpers/evolution_notifs.py Normal file
View File

@ -0,0 +1,107 @@
"""
Evolution Tier Completion Notifications
Builds and sends Discord embeds when a player completes an evolution tier
during post-game evaluation. Each tier-up event gets its own embed.
Notification failures are non-fatal: the send is wrapped in try/except so
a Discord API hiccup never disrupts game flow.
"""
import logging
from typing import Optional
import discord
logger = logging.getLogger("discord_app")
# Human-readable display names for each tier number.
TIER_NAMES = {
0: "Unranked",
1: "Initiate",
2: "Rising",
3: "Ascendant",
4: "Evolved",
}
# Tier-specific embed colors.
TIER_COLORS = {
1: 0x2ECC71, # green
2: 0xF1C40F, # gold
3: 0x9B59B6, # purple
4: 0x1ABC9C, # teal (fully evolved)
}
FOOTER_TEXT = "Paper Dynasty Evolution"
def build_tier_up_embed(tier_up: dict) -> 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.
Returns
-------
discord.Embed
A fully configured embed ready to send to a channel.
"""
player_name: str = tier_up["player_name"]
new_tier: int = tier_up["new_tier"]
track_name: str = tier_up["track_name"]
tier_name = TIER_NAMES.get(new_tier, f"Tier {new_tier}")
color = TIER_COLORS.get(new_tier, 0x2ECC71)
if new_tier >= 4:
# Fully evolved — special title and description.
embed = discord.Embed(
title="FULLY EVOLVED!",
description=(
f"**{player_name}** has reached maximum evolution on the **{track_name}** track"
),
color=color,
)
embed.add_field(
name="Rating Boosts",
value="Rating boosts coming in a future update!",
inline=False,
)
else:
embed = discord.Embed(
title="Evolution Tier Up!",
description=(
f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track"
),
color=color,
)
embed.set_footer(text=FOOTER_TEXT)
return embed
async def notify_tier_completion(channel, tier_up: dict) -> None:
"""Send a tier-up notification embed to the given channel.
Non-fatal: any exception during send is caught and logged so that a
Discord API failure never interrupts game evaluation.
Parameters
----------
channel:
A discord.TextChannel (or any object with an async ``send`` method).
tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
"""
try:
embed = build_tier_up_embed(tier_up)
await channel.send(embed=embed)
except Exception as exc:
logger.error(
"Failed to send tier-up notification for %s (tier %s): %s",
tier_up.get("player_name", "unknown"),
tier_up.get("new_tier"),
exc,
)

View File

@ -0,0 +1,259 @@
"""
Tests for Evolution Tier Completion Notification embeds.
These tests verify that:
1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color).
2. Tier 4 (Fully Evolved) embeds include the special title, description, and note field.
3. Multiple tier-up events each produce a separate embed.
4. An empty tier-up list results in no channel sends.
The channel interaction is mocked because we are testing the embed content, not Discord
network I/O. Notification failure must never affect game flow, so the non-fatal path
is also exercised.
"""
import pytest
from unittest.mock import AsyncMock
import discord
from helpers.evolution_notifs import build_tier_up_embed, notify_tier_completion
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter",
current_value=150,
):
"""Return a minimal tier_up dict matching the expected shape."""
return {
"player_name": player_name,
"old_tier": old_tier,
"new_tier": new_tier,
"track_name": track_name,
"current_value": current_value,
}
# ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tiers 1-3 (standard tier-up)
# ---------------------------------------------------------------------------
class TestBuildTierUpEmbed:
"""Verify that build_tier_up_embed produces correctly structured embeds."""
def test_title_is_evolution_tier_up(self):
"""Title must read 'Evolution Tier Up!' for any non-max tier."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.title == "Evolution Tier Up!"
def test_description_contains_player_name(self):
"""Description must contain the player's name."""
tier_up = make_tier_up(player_name="Mike Trout", new_tier=2)
embed = build_tier_up_embed(tier_up)
assert "Mike Trout" in embed.description
def test_description_contains_new_tier_name(self):
"""Description must include the human-readable tier name for the new tier."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
# Tier 2 display name is "Rising"
assert "Rising" in embed.description
def test_description_contains_track_name(self):
"""Description must mention the evolution track (e.g., 'Batter')."""
tier_up = make_tier_up(track_name="Batter", new_tier=2)
embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description
def test_tier1_color_is_green(self):
"""Tier 1 uses green (0x2ecc71)."""
tier_up = make_tier_up(old_tier=0, new_tier=1)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x2ECC71
def test_tier2_color_is_gold(self):
"""Tier 2 uses gold (0xf1c40f)."""
tier_up = make_tier_up(old_tier=1, new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0xF1C40F
def test_tier3_color_is_purple(self):
"""Tier 3 uses purple (0x9b59b6)."""
tier_up = make_tier_up(old_tier=2, new_tier=3)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x9B59B6
def test_footer_text_is_paper_dynasty_evolution(self):
"""Footer text must be 'Paper Dynasty Evolution' for brand consistency."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Evolution"
def test_returns_discord_embed_instance(self):
"""Return type must be discord.Embed so it can be sent directly."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert isinstance(embed, discord.Embed)
# ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tier 4 (fully evolved)
# ---------------------------------------------------------------------------
class TestBuildTierUpEmbedFullyEvolved:
"""Verify that tier 4 (Fully Evolved) embeds use special formatting."""
def test_title_is_fully_evolved(self):
"""Tier 4 title must be 'FULLY EVOLVED!' to emphasise max achievement."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.title == "FULLY EVOLVED!"
def test_description_mentions_maximum_evolution(self):
"""Tier 4 description must mention 'maximum evolution' per the spec."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "maximum evolution" in embed.description.lower()
def test_description_contains_player_name(self):
"""Player name must appear in the tier 4 description."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "Mike Trout" in embed.description
def test_description_contains_track_name(self):
"""Track name must appear in the tier 4 description."""
tier_up = make_tier_up(track_name="Batter", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description
def test_tier4_color_is_teal(self):
"""Tier 4 uses teal (0x1abc9c) to visually distinguish max evolution."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x1ABC9C
def test_note_field_present(self):
"""Tier 4 must include a note field about future rating boosts."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
field_names = [f.name for f in embed.fields]
assert any(
"rating" in name.lower()
or "boost" in name.lower()
or "note" in name.lower()
for name in field_names
), "Expected a field mentioning rating boosts for tier 4 embed"
def test_note_field_value_mentions_future_update(self):
"""The note field value must reference the future rating boost update."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
note_field = next(
(
f
for f in embed.fields
if "rating" in f.name.lower()
or "boost" in f.name.lower()
or "note" in f.name.lower()
),
None,
)
assert note_field is not None
assert (
"future" in note_field.value.lower() or "update" in note_field.value.lower()
)
def test_footer_text_is_paper_dynasty_evolution(self):
"""Footer must remain 'Paper Dynasty Evolution' for tier 4 as well."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Evolution"
# ---------------------------------------------------------------------------
# Unit: notify_tier_completion — multiple and empty cases
# ---------------------------------------------------------------------------
class TestNotifyTierCompletion:
"""Verify that notify_tier_completion sends the right number of messages."""
@pytest.mark.asyncio
async def test_single_tier_up_sends_one_message(self):
"""A single tier-up event sends exactly one embed to the channel."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
channel.send.assert_called_once()
@pytest.mark.asyncio
async def test_sends_embed_not_plain_text(self):
"""The channel.send call must use the embed= keyword, not content=."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args
assert (
"embed" in kwargs
), "notify_tier_completion must send an embed, not plain text"
@pytest.mark.asyncio
async def test_embed_type_is_discord_embed(self):
"""The embed passed to channel.send must be a discord.Embed instance."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args
assert isinstance(kwargs["embed"], discord.Embed)
@pytest.mark.asyncio
async def test_notification_failure_does_not_raise(self):
"""If channel.send raises, notify_tier_completion must swallow it so game flow is unaffected."""
channel = AsyncMock()
channel.send.side_effect = Exception("Discord API unavailable")
tier_up = make_tier_up(new_tier=2)
# Should not raise
await notify_tier_completion(channel, tier_up)
@pytest.mark.asyncio
async def test_multiple_tier_ups_caller_sends_multiple_embeds(self):
"""
Callers are responsible for iterating tier-up events; each call to
notify_tier_completion sends a separate embed. This test simulates
three consecutive calls (3 events) and asserts 3 sends occurred.
"""
channel = AsyncMock()
events = [
make_tier_up(player_name="Mike Trout", new_tier=2),
make_tier_up(player_name="Aaron Judge", new_tier=1),
make_tier_up(player_name="Shohei Ohtani", new_tier=3),
]
for event in events:
await notify_tier_completion(channel, event)
assert (
channel.send.call_count == 3
), "Each tier-up event must produce its own embed (no batching)"
@pytest.mark.asyncio
async def test_no_tier_ups_means_no_sends(self):
"""
When the caller has an empty list of tier-up events and simply
does not call notify_tier_completion, zero sends happen.
This explicitly guards against any accidental unconditional send.
"""
channel = AsyncMock()
tier_up_events = []
for event in tier_up_events:
await notify_tier_completion(channel, event)
channel.send.assert_not_called()