feat: WP-14 tier completion notification embeds

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 f4a57879ab
commit 911c6842e4
2 changed files with 336 additions and 124 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

@ -1,154 +1,259 @@
"""
Tests for evolution tier completion notification embeds (WP-14).
Tests for Evolution Tier Completion Notification embeds.
These are pure unit tests no database or Discord bot connection required.
Each test constructs embeds and asserts on title, description, color, and
footer to verify the notification design spec is met.
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 utilities.evolution_notifications import (
TIER_COLORS,
build_tier_embeds,
tier_up_embed,
)
from helpers.evolution_notifs import build_tier_up_embed, notify_tier_completion
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
class TestTierUpEmbed:
"""Unit tests for tier_up_embed() — standard (T1T3) and fully-evolved (T4) paths."""
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,
}
def test_tier_up_title(self):
"""Standard tier-up embeds must use the 'Evolution Tier Up!' title."""
embed = tier_up_embed(
"Mike Trout", tier=2, tier_name="Rising", track_name="Batter"
)
# ---------------------------------------------------------------------------
# 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_tier_up_description_format(self):
"""Description must include player name, tier number, tier name, and track name."""
embed = tier_up_embed(
"Mike Trout", tier=2, tier_name="Rising", track_name="Batter"
)
assert (
embed.description
== "Mike Trout reached Tier 2 (Rising) on the Batter track"
)
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_tier_up_color_matches_tier(self):
"""Each tier must map to its specified embed color."""
for tier, expected_color in TIER_COLORS.items():
if tier == 4:
continue # T4 handled in fully-evolved tests
embed = tier_up_embed(
"Test Player", tier=tier, tier_name="Name", track_name="Batter"
)
assert embed.color.value == expected_color, f"Tier {tier} color mismatch"
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_tier_up_no_footer_for_standard_tiers(self):
"""Standard tier-up embeds (T1T3) must not have a footer."""
for tier in (1, 2, 3):
embed = tier_up_embed(
"Test Player", tier=tier, tier_name="Name", track_name="Batter"
)
assert embed.footer.text is None
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)
class TestFullyEvolvedEmbed:
"""Unit tests for the fully-evolved (T4) embed — distinct title, description, and footer."""
# ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tier 4 (fully evolved)
# ---------------------------------------------------------------------------
def test_fully_evolved_title(self):
"""T4 embeds must use the 'FULLY EVOLVED!' title."""
embed = tier_up_embed(
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
)
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_fully_evolved_description(self):
"""T4 description must indicate maximum evolution without mentioning tier number."""
embed = tier_up_embed(
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
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 (
embed.description
== "Mike Trout has reached maximum evolution on the Batter track"
"future" in note_field.value.lower() or "update" in note_field.value.lower()
)
def test_fully_evolved_footer(self):
"""T4 embeds must include the Phase 2 teaser footer."""
embed = tier_up_embed(
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
)
assert embed.footer.text == "Rating boosts coming in a future update!"
def test_fully_evolved_color(self):
"""T4 embed color must be teal."""
embed = tier_up_embed(
"Mike Trout", tier=4, tier_name="Legendary", track_name="Batter"
)
assert embed.color.value == TIER_COLORS[4]
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"
class TestBuildTierEmbeds:
"""Unit tests for build_tier_embeds() — list construction and edge cases."""
# ---------------------------------------------------------------------------
# Unit: notify_tier_completion — multiple and empty cases
# ---------------------------------------------------------------------------
def test_no_tier_ups_returns_empty_list(self):
"""When no tier-ups occurred, build_tier_embeds must return an empty list."""
result = build_tier_embeds([])
assert result == []
def test_single_tier_up_returns_one_embed(self):
"""A single tier-up event must produce exactly one embed."""
tier_ups = [
{
"player_name": "Mike Trout",
"tier": 2,
"tier_name": "Rising",
"track_name": "Batter",
}
]
result = build_tier_embeds(tier_ups)
assert len(result) == 1
assert isinstance(result[0], discord.Embed)
class TestNotifyTierCompletion:
"""Verify that notify_tier_completion sends the right number of messages."""
def test_multiple_tier_ups_return_separate_embeds(self):
"""Multiple tier-up events in one game must produce one embed per event."""
tier_ups = [
{
"player_name": "Mike Trout",
"tier": 2,
"tier_name": "Rising",
"track_name": "Batter",
},
{
"player_name": "Sandy Koufax",
"tier": 3,
"tier_name": "Elite",
"track_name": "Starter",
},
]
result = build_tier_embeds(tier_ups)
assert len(result) == 2
@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 (
result[0].description
== "Mike Trout reached Tier 2 (Rising) on the Batter track"
)
assert (
result[1].description
== "Sandy Koufax reached Tier 3 (Elite) on the Starter track"
)
"embed" in kwargs
), "notify_tier_completion must send an embed, not plain text"
def test_fully_evolved_in_batch(self):
"""A T4 event in a batch must produce a fully-evolved embed, not a standard one."""
tier_ups = [
{
"player_name": "Babe Ruth",
"tier": 4,
"tier_name": "Legendary",
"track_name": "Batter",
}
@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),
]
result = build_tier_embeds(tier_ups)
assert len(result) == 1
assert result[0].title == "FULLY EVOLVED!"
assert result[0].footer.text == "Rating boosts coming in a future update!"
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()