Merge pull request 'feat: show refractor progress in post-game summary embed (#147)' (#160) from issue/147-feat-show-refractor-progress-in-post-game-summary into main

Reviewed-on: #160
This commit is contained in:
cal 2026-04-08 23:21:57 +00:00
commit 5d86641fda
2 changed files with 319 additions and 2 deletions

View File

@ -24,6 +24,7 @@ from helpers import (
position_name_to_abbrev,
team_role,
)
from helpers.refractor_constants import TIER_NAMES
from helpers.refractor_notifs import notify_tier_completion
from in_game.ai_manager import get_starting_lineup
from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check
@ -4244,7 +4245,7 @@ async def get_game_summary_embed(
return game_embed
async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None:
async def _run_post_game_refractor_hook(db_game_id: int, channel) -> dict | None:
"""Post-game refractor processing — non-fatal.
Updates season stats then evaluates refractor milestones for all
@ -4252,6 +4253,8 @@ async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None:
image URLs, then fires tier-up notifications with card art included.
Wrapped in try/except so any failure here is non-fatal the game is
already saved and refractor will self-heal on the next evaluate call.
Returns the evaluate-game API response dict, or None on failure.
"""
try:
await db_post(f"season-stats/update-game/{db_game_id}")
@ -4262,8 +4265,10 @@ async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None:
for tier_up in tier_ups:
img = image_url_map.get(tier_up.get("player_id"))
await notify_tier_completion(channel, tier_up, image_url=img)
return evo_result
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
return None
async def _trigger_variant_renders(tier_ups: list) -> dict:
@ -4306,6 +4311,52 @@ async def _trigger_variant_renders(tier_ups: list) -> dict:
return image_urls
async def _build_refractor_progress_text(
evo_result: dict | None,
winning_team_id: int,
losing_team_id: int,
) -> str | None:
"""Build the Refractor Progress embed field value for the post-game summary.
Shows tier-ups that occurred this game (from the evaluate-game response)
and any cards currently close (80%) to their next tier on either team.
Returns None when there is nothing to show so the caller can skip the field.
"""
lines = []
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
name = tier_up.get("player_name", "Unknown")
new_tier = tier_up.get("new_tier", 0)
tier_name = TIER_NAMES.get(new_tier, f"T{new_tier}")
lines.append(f"⬆ **{name}** → {tier_name}")
try:
close_lines = []
for team_id in (winning_team_id, losing_team_id):
data = await db_get(
"refractor/cards",
params=[("team_id", team_id), ("progress", "close"), ("limit", 5)],
)
if not data:
continue
items = data if isinstance(data, list) else data.get("items", [])
for card in items:
name = card.get("player_name", "Unknown")
current_value = int(card.get("current_value", 0))
next_threshold = int(card.get("next_threshold") or 0)
if next_threshold:
pct = f"{min(current_value / next_threshold, 1.0):.0%}"
else:
pct = "100%"
close_lines.append(f"{name} ({pct})")
lines.extend(close_lines[:5])
except Exception:
pass
return "\n".join(lines) if lines else None
async def complete_game(
session: Session,
interaction: discord.Interaction,
@ -4407,7 +4458,7 @@ async def complete_game(
# Post-game refractor processing (non-blocking)
# WP-13: season stats update + refractor milestone evaluation.
await _run_post_game_refractor_hook(db_game["id"], interaction.channel)
evo_result = await _run_post_game_refractor_hook(db_game["id"], interaction.channel)
session.delete(this_play)
session.commit()
@ -4426,6 +4477,15 @@ async def complete_game(
summary_embed.add_field(name=f"{winning_team.abbrev} Rewards", value=win_reward)
summary_embed.add_field(name=f"{losing_team.abbrev} Rewards", value=loss_reward)
refractor_text = await _build_refractor_progress_text(
evo_result, winning_team.id, losing_team.id
)
if refractor_text:
summary_embed.add_field(
name="Refractor Progress", value=refractor_text, inline=False
)
summary_embed.add_field(
name="Highlights",
value=f"Please share the highlights in {get_channel(interaction, 'pd-news-ticker').mention}!",

View File

@ -0,0 +1,257 @@
"""
Tests for the post-game Refractor Progress embed field (#147).
Covers _build_refractor_progress_text() which formats tier-ups and
near-threshold cards into the summary embed field value, and the updated
_run_post_game_refractor_hook() return value.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from command_logic.logic_gameplay import (
_build_refractor_progress_text,
_run_post_game_refractor_hook,
)
# ---------------------------------------------------------------------------
# _build_refractor_progress_text
# ---------------------------------------------------------------------------
class TestBuildRefractorProgressText:
"""_build_refractor_progress_text formats tier-ups and close cards."""
async def test_returns_none_when_no_tier_ups_and_no_close_cards(self):
"""Returns None when evaluate-game had no tier-ups and refractor/cards returns empty.
Caller uses None to skip adding the field to the embed entirely.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": [], "count": 0}
result = await _build_refractor_progress_text(
evo_result={"tier_ups": []},
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_returns_none_when_evo_result_is_none(self):
"""Returns None gracefully when the hook returned None (e.g. on API failure).
Near-threshold fetch still runs; returns None when that also yields nothing.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_tier_up_shows_player_name_and_tier_name(self):
"""Tier-ups are formatted as '⬆ **Name** → Tier Name'.
The tier name comes from TIER_NAMES (e.g. new_tier=1 'Base Chrome').
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{
"player_id": 10,
"player_name": "Mike Trout",
"new_tier": 1,
}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Mike Trout" in result
assert "Base Chrome" in result
async def test_multiple_tier_ups_each_on_own_line(self):
"""Each tier-up gets its own line in the output."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1},
{
"player_id": 11,
"player_name": "Shohei Ohtani",
"new_tier": 2,
},
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
assert "Shohei Ohtani" in result
assert "Base Chrome" in result
assert "Refractor" in result
async def test_near_threshold_card_shows_percentage(self):
"""Near-threshold cards appear as '◈ Name (pct%)'.
The percentage is current_value / next_threshold rounded to nearest integer.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {
"items": [
{
"player_name": "Sandy Koufax",
"current_value": 120,
"next_threshold": 149,
}
],
"count": 1,
}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Sandy Koufax" in result
assert "81%" in result # 120/149 = ~80.5% → 81%
async def test_near_threshold_fetch_queried_for_both_teams(self):
"""refractor/cards is called once per team with progress=close."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
await _build_refractor_progress_text(
evo_result=None,
winning_team_id=3,
losing_team_id=7,
)
team_ids_queried = []
for call in mock_get.call_args_list:
params = dict(call.kwargs.get("params", []))
if "team_id" in params:
team_ids_queried.append(params["team_id"])
assert 3 in team_ids_queried
assert 7 in team_ids_queried
async def test_near_threshold_api_failure_is_non_fatal(self):
"""An exception during the near-threshold fetch does not propagate.
Tier-ups are still shown; close cards silently dropped.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.side_effect = RuntimeError("API down")
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
async def test_close_cards_capped_at_five(self):
"""At most 5 near-threshold entries are included across both teams."""
many_cards = [
{"player_name": f"Player {i}", "current_value": 90, "next_threshold": 100}
for i in range(10)
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": many_cards, "count": len(many_cards)}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert result.count("") <= 5
# ---------------------------------------------------------------------------
# _run_post_game_refractor_hook return value
# ---------------------------------------------------------------------------
class TestRefractorHookReturnValue:
"""_run_post_game_refractor_hook returns evo_result on success, None on failure."""
async def test_returns_evo_result_when_successful(self):
"""The evaluate-game response dict is returned so complete_game can use it."""
evo_response = {
"tier_ups": [{"player_id": 1, "player_name": "Babe Ruth", "new_tier": 2}]
}
def _side_effect(url, *args, **kwargs):
if url.startswith("season-stats"):
return None
return evo_response
with (
patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
),
):
mock_post.side_effect = _side_effect
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response
async def test_returns_none_on_exception(self):
"""Hook returns None when an exception occurs (game result is unaffected)."""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.side_effect = RuntimeError("db unreachable")
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result is None
async def test_returns_evo_result_when_no_tier_ups(self):
"""Returns the full evo_result even when tier_ups is empty or absent."""
evo_response = {"tier_ups": []}
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = evo_response
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response