From 39424f715724102c718071216c1348c6e192a91d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 8 Apr 2026 13:36:32 -0500 Subject: [PATCH] feat: show refractor progress in post-game summary embed (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #147 Adds a "Refractor Progress" field to the game summary embed showing: - Cards that tiered up during this game (⬆ Name → Tier Name) - Cards currently ≥80% toward their next tier on either team (◈ Name (pct%)) The field is omitted entirely when there is nothing to show. Implementation: - _run_post_game_refractor_hook() now returns evo_result (or None on failure) - New _build_refractor_progress_text() fetches close-to-tier cards from both teams via refractor/cards?progress=close and formats the combined output - complete_game() adds the field between Rewards and Highlights sections Co-Authored-By: Claude Sonnet 4.6 --- command_logic/logic_gameplay.py | 64 +++++- tests/test_refractor_progress_embed.py | 257 +++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 tests/test_refractor_progress_embed.py diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 2e0cb89..7c45c18 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -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}!", diff --git a/tests/test_refractor_progress_embed.py b/tests/test_refractor_progress_embed.py new file mode 100644 index 0000000..62ba69b --- /dev/null +++ b/tests/test_refractor_progress_embed.py @@ -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 -- 2.25.1