fix: rename evolution/ to refractor/ endpoint and remove misplaced notifs module
All checks were successful
Ruff Lint / lint (pull_request) Successful in 14s

- Change `evolution/evaluate-game/` API call to `refractor/evaluate-game/` in
  complete_game() hook (was calling the wrong endpoint path)
- Update all test assertions in test_complete_game_hook.py to match the
  corrected endpoint path and update docstrings to "refractor" naming
- Remove helpers/evolution_notifs.py and tests/test_evolution_notifications.py
  from this PR — they belong to PR #112 (WP-14 tier notifications). The
  notify_tier_completion stub in logic_gameplay.py remains as the WP-14
  integration target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-23 15:22:25 -05:00
parent 2c57fbcdf5
commit 29f2a8683f
4 changed files with 24 additions and 286 deletions

View File

@ -4336,7 +4336,6 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Unable to post decisions to API, rolling back")
# Post game rewards (gauntlet and main team)
try:
win_reward, loss_reward = await post_game_rewards(
@ -4360,25 +4359,25 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Error while posting game rewards")
# Post-game evolution processing (non-blocking)
# WP-13: update season stats then evaluate evolution milestones for all
# Post-game refractor processing (non-blocking)
# WP-13: update season stats then evaluate refractor milestones for all
# participating players. Wrapped in try/except so any failure here is
# non-fatal — the game is already saved and evolution will catch up on the
# non-fatal — the game is already saved and refractor will catch up on the
# next evaluate call.
try:
await db_post(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post(f"evolution/evaluate-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"]:
# WP-14 will implement full Discord notification; stub for now
logger.info(
f"Evolution tier-up for player {tier_up.get('player_id')}: "
f"Refractor tier-up for player {tier_up.get('player_id')}: "
f"{tier_up.get('old_tier')} -> {tier_up.get('new_tier')} "
f"(game {db_game['id']})"
)
await notify_tier_completion(interaction.channel, tier_up)
except Exception as e:
logger.warning(f"Post-game evolution processing failed (non-fatal): {e}")
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
session.delete(this_play)
session.commit()

View File

@ -1,107 +0,0 @@
"""
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

@ -4,13 +4,13 @@ Tests for the WP-13 post-game callback integration hook.
These tests verify that after a game is saved to the API, two additional
POST requests are fired in the correct order:
1. POST season-stats/update-game/{game_id} update player_season_stats
2. POST evolution/evaluate-game/{game_id} evaluate evolution milestones
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones
Key design constraints being tested:
- Season stats MUST be updated before evolution is evaluated (ordering).
- Failure of either evolution call must NOT propagate the game result has
already been committed; evolution will self-heal on the next evaluate pass.
- Tier-up dicts returned by the evolution endpoint are passed to
- Season stats MUST be updated before refractor is evaluated (ordering).
- Failure of either refractor call must NOT propagate the game result has
already been committed; refractor will self-heal on the next evaluate pass.
- Tier-up dicts returned by the refractor endpoint are passed to
notify_tier_completion so WP-14 can present them to the player.
"""
@ -46,7 +46,7 @@ async def _run_hook(db_post_mock, db_game_id: int = 42):
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
evo_result = await db_post_mock(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)
@ -64,10 +64,10 @@ async def _run_hook(db_post_mock, db_game_id: int = 42):
@pytest.mark.asyncio
async def test_hook_posts_to_both_endpoints_in_order():
"""
Both evolution endpoints are called, and season-stats comes first.
Both refractor endpoints are called, and season-stats comes first.
The ordering is critical: player_season_stats must be populated before the
evolution engine tries to read them for milestone evaluation.
refractor engine tries to read them for milestone evaluation.
"""
db_post_mock = AsyncMock(return_value={})
@ -77,8 +77,8 @@ async def test_hook_posts_to_both_endpoints_in_order():
calls = db_post_mock.call_args_list
# First call must be season-stats
assert calls[0] == call("season-stats/update-game/42")
# Second call must be evolution evaluate
assert calls[1] == call("evolution/evaluate-game/42")
# Second call must be refractor evaluate
assert calls[1] == call("refractor/evaluate-game/42")
@pytest.mark.asyncio
@ -86,11 +86,11 @@ async def test_hook_is_nonfatal_when_db_post_raises():
"""
A failure inside the hook must not raise to the caller.
The game result is already persisted when the hook runs. If the evolution
The game result is already persisted when the hook runs. If the refractor
API is down or returns an error, we log a warning and continue the game
completion flow must not be interrupted.
"""
db_post_mock = AsyncMock(side_effect=Exception("evolution API unavailable"))
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable"))
# Should not raise
try:
@ -102,7 +102,7 @@ async def test_hook_is_nonfatal_when_db_post_raises():
@pytest.mark.asyncio
async def test_hook_processes_tier_ups_from_evo_result():
"""
When the evolution endpoint returns tier_ups, each entry is forwarded to
When the refractor endpoint returns tier_ups, each entry is forwarded to
notify_tier_completion.
This confirms the data path between the API response and the WP-14
@ -114,7 +114,7 @@ async def test_hook_processes_tier_ups_from_evo_result():
]
async def fake_db_post(endpoint):
if "evolution" in endpoint:
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
@ -129,7 +129,7 @@ async def test_hook_processes_tier_ups_from_evo_result():
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
evo_result = await db_post_mock(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 mock_notify(channel, tier_up)
@ -146,14 +146,14 @@ async def test_hook_processes_tier_ups_from_evo_result():
@pytest.mark.asyncio
async def test_hook_no_tier_ups_does_not_call_notify():
"""
When the evolution response has no tier_ups (empty list or missing key),
When the refractor response has no tier_ups (empty list or missing key),
notify_tier_completion is never called.
Avoids spurious Discord messages for routine game completions.
"""
async def fake_db_post(endpoint):
if "evolution" in endpoint:
if "refractor" in endpoint:
return {"tier_ups": []}
return {}
@ -168,7 +168,7 @@ async def test_hook_no_tier_ups_does_not_call_notify():
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
evo_result = await db_post_mock(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 mock_notify(channel, tier_up)

View File

@ -1,154 +0,0 @@
"""
Tests for evolution tier completion notification embeds (WP-14).
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.
"""
import discord
from utilities.evolution_notifications import (
TIER_COLORS,
build_tier_embeds,
tier_up_embed,
)
class TestTierUpEmbed:
"""Unit tests for tier_up_embed() — standard (T1T3) and fully-evolved (T4) paths."""
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"
)
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_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_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
class TestFullyEvolvedEmbed:
"""Unit tests for the fully-evolved (T4) embed — distinct title, description, and footer."""
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"
)
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"
)
assert (
embed.description
== "Mike Trout has reached maximum evolution on the Batter track"
)
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]
class TestBuildTierEmbeds:
"""Unit tests for build_tier_embeds() — list construction and edge 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)
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
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"
)
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",
}
]
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!"