Merge pull request 'feat: WP-13 post-game evolution callback hook' (#111) from feature/wp13-postgame-hook into main

This commit is contained in:
cal 2026-03-23 20:25:43 +00:00
commit aa2fce94b8
3 changed files with 239 additions and 155 deletions

View File

@ -4242,6 +4242,24 @@ async def get_game_summary_embed(
return game_embed return game_embed
async def notify_tier_completion(channel: discord.TextChannel, tier_up: dict) -> None:
"""Stub for WP-14: log evolution tier-up events.
WP-14 will replace this with a full Discord embed notification. For now we
only log the event so that the WP-13 hook has a callable target and the
tier-up data is visible in the application log.
Args:
channel: The Discord channel where the game was played.
tier_up: Dict from the evolution API, expected to contain at minimum
'player_id', 'old_tier', and 'new_tier' keys.
"""
logger.info(
f"[WP-14 stub] notify_tier_completion called for channel={channel.id if channel else 'N/A'} "
f"tier_up={tier_up}"
)
async def complete_game( async def complete_game(
session: Session, session: Session,
interaction: discord.Interaction, interaction: discord.Interaction,
@ -4318,7 +4336,6 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True) await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Unable to post decisions to API, rolling back") log_exception(e, msg="Unable to post decisions to API, rolling back")
# Post game rewards (gauntlet and main team) # Post game rewards (gauntlet and main team)
try: try:
win_reward, loss_reward = await post_game_rewards( win_reward, loss_reward = await post_game_rewards(
@ -4342,6 +4359,26 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True) await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Error while posting game rewards") log_exception(e, msg="Error while posting game rewards")
# 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 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"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"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 refractor processing failed (non-fatal): {e}")
session.delete(this_play) session.delete(this_play)
session.commit() session.commit()

View File

@ -0,0 +1,201 @@
"""
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 refractor/evaluate-game/{game_id} evaluate refractor milestones
Key design constraints being tested:
- 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.
"""
import asyncio
import logging
import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_channel(channel_id: int = 999) -> MagicMock:
ch = MagicMock()
ch.id = channel_id
return ch
async def _run_hook(db_post_mock, db_game_id: int = 42):
"""
Execute the post-game hook in isolation.
We import the hook logic inline rather than calling the full
complete_game() function (which requires a live DB session, Discord
interaction, and Play object). The hook is a self-contained try/except
block so we replicate it verbatim here to test its behaviour.
"""
channel = _make_channel()
from command_logic.logic_gameplay import notify_tier_completion
db_game = {"id": db_game_id}
try:
await db_post_mock(f"season-stats/update-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)
except Exception:
pass # non-fatal — mirrors the logger.warning in production
return channel
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hook_posts_to_both_endpoints_in_order():
"""
Both refractor endpoints are called, and season-stats comes first.
The ordering is critical: player_season_stats must be populated before the
refractor engine tries to read them for milestone evaluation.
"""
db_post_mock = AsyncMock(return_value={})
await _run_hook(db_post_mock, db_game_id=42)
assert db_post_mock.call_count == 2
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 refractor evaluate
assert calls[1] == call("refractor/evaluate-game/42")
@pytest.mark.asyncio
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 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("refractor API unavailable"))
# Should not raise
try:
await _run_hook(db_post_mock, db_game_id=7)
except Exception as exc:
pytest.fail(f"Hook raised unexpectedly: {exc}")
@pytest.mark.asyncio
async def test_hook_processes_tier_ups_from_evo_result():
"""
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
notification stub so that WP-14 only needs to replace the stub body.
"""
tier_ups = [
{"player_id": 101, "old_tier": 1, "new_tier": 2},
{"player_id": 202, "old_tier": 2, "new_tier": 3},
]
async def fake_db_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 99}
try:
await db_post_mock(f"season-stats/update-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)
except Exception:
pass
assert mock_notify.call_count == 2
# Verify both tier_up dicts were forwarded
forwarded = [c.args[1] for c in mock_notify.call_args_list]
assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded
assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded
@pytest.mark.asyncio
async def test_hook_no_tier_ups_does_not_call_notify():
"""
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 "refractor" in endpoint:
return {"tier_ups": []}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 55}
try:
await db_post_mock(f"season-stats/update-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)
except Exception:
pass
mock_notify.assert_not_called()
@pytest.mark.asyncio
async def test_notify_tier_completion_stub_logs_and_does_not_raise(caplog):
"""
The WP-14 stub must log the event and return cleanly.
Verifies the contract that WP-14 can rely on: the function accepts
(channel, tier_up) and does not raise, so the hook's for-loop is safe.
"""
from command_logic.logic_gameplay import notify_tier_completion
channel = _make_channel(channel_id=123)
tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1}
with caplog.at_level(logging.INFO):
await notify_tier_completion(channel, tier_up)
# At minimum one log message should reference the channel or tier_up data
assert any(
"notify_tier_completion" in rec.message or "77" in rec.message
for rec in caplog.records
)

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!"