Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8842d80f26 | |||
|
|
25b63b407f | ||
| bd6c387902 | |||
|
|
eb022c3d66 | ||
| 63a25ea0ba | |||
| e5ec88f794 | |||
| ff57b8fea3 | |||
|
|
2f22a11e17 | ||
|
|
8ddd58101c | ||
| 24420268cf | |||
|
|
21bad7af51 | ||
| 224250b03d | |||
|
|
1a3f8994a9 | ||
| f67c1c41a7 | |||
| 435bfd376f | |||
| c01167b097 | |||
|
|
59a41e0c39 | ||
|
|
ddc9a28023 | ||
|
|
f488cb66e0 |
@ -60,6 +60,7 @@ from helpers import (
|
||||
)
|
||||
from utilities.buttons import ask_with_buttons
|
||||
from utilities.autocomplete import cardset_autocomplete, player_autocomplete
|
||||
from helpers.refractor_constants import TIER_NAMES as REFRACTOR_TIER_NAMES
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
@ -471,20 +472,12 @@ def get_record_embed(team: dict, results: dict, league: str):
|
||||
return embed
|
||||
|
||||
|
||||
REFRACTOR_TIER_NAMES = {
|
||||
0: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
|
||||
async def _build_refractor_response(
|
||||
player_name: str,
|
||||
player_id: int,
|
||||
refractor_tier: int,
|
||||
refractor_data: dict,
|
||||
team: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Build response data for a /player refractor_tier request.
|
||||
|
||||
|
||||
@ -21,19 +21,12 @@ from discord.ext import commands
|
||||
from api_calls import db_get
|
||||
from helpers.discord_utils import get_team_embed
|
||||
from helpers.main import get_team_by_owner
|
||||
from helpers.refractor_constants import TIER_NAMES, STATUS_TIER_COLORS as TIER_COLORS
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
PAGE_SIZE = 10
|
||||
|
||||
TIER_NAMES = {
|
||||
0: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
# Tier-specific labels for the status display.
|
||||
TIER_SYMBOLS = {
|
||||
0: "Base", # Base Card — used in summary only, not in per-card display
|
||||
@ -45,15 +38,6 @@ TIER_SYMBOLS = {
|
||||
|
||||
_FULL_BAR = "▰" * 12
|
||||
|
||||
# Embed accent colors per tier (used for single-tier filtered views).
|
||||
TIER_COLORS = {
|
||||
0: 0x95A5A6, # slate grey
|
||||
1: 0xBDC3C7, # silver/chrome
|
||||
2: 0x3498DB, # refractor blue
|
||||
3: 0xF1C40F, # gold
|
||||
4: 0x1ABC9C, # teal superfractor
|
||||
}
|
||||
|
||||
|
||||
def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
|
||||
"""
|
||||
|
||||
@ -4244,14 +4244,45 @@ async def get_game_summary_embed(
|
||||
return game_embed
|
||||
|
||||
|
||||
async def _trigger_variant_renders(tier_ups: list) -> None:
|
||||
"""Fire-and-forget: hit card render URLs to trigger S3 upload for new variants.
|
||||
async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None:
|
||||
"""Post-game refractor processing — non-fatal.
|
||||
|
||||
Updates season stats then evaluates refractor milestones for all
|
||||
participating players. Triggers variant card renders first to obtain
|
||||
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.
|
||||
"""
|
||||
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"):
|
||||
tier_ups = evo_result["tier_ups"]
|
||||
image_url_map = await _trigger_variant_renders(tier_ups)
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
||||
|
||||
|
||||
async def _trigger_variant_renders(tier_ups: list) -> dict:
|
||||
"""Trigger S3 card renders for each tier-up variant and return image URLs.
|
||||
|
||||
Each tier-up with a variant_created value gets a GET request to the card
|
||||
render endpoint, which triggers Playwright render + S3 upload as a side effect.
|
||||
Failures are logged but never raised.
|
||||
render endpoint, which triggers Playwright render + S3 upload. The
|
||||
response image_url (if present) is captured and returned so callers can
|
||||
include the card art in tier-up notifications.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping of player_id -> image_url. Players whose render failed or
|
||||
returned no image_url are omitted; callers should treat a missing
|
||||
key as None.
|
||||
"""
|
||||
today = datetime.date.today().isoformat()
|
||||
image_urls = {}
|
||||
for tier_up in tier_ups:
|
||||
variant = tier_up.get("variant_created")
|
||||
if variant is None:
|
||||
@ -4260,16 +4291,19 @@ async def _trigger_variant_renders(tier_ups: list) -> None:
|
||||
track = tier_up.get("track_name", "Batter")
|
||||
card_type = "pitching" if track.lower() == "pitcher" else "batting"
|
||||
try:
|
||||
await db_get(
|
||||
result = await db_get(
|
||||
f"players/{player_id}/{card_type}card/{today}/{variant}",
|
||||
none_okay=True,
|
||||
)
|
||||
if result and isinstance(result, dict):
|
||||
image_urls[player_id] = result.get("image_url")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to trigger variant render for player %d variant %d (non-fatal)",
|
||||
player_id,
|
||||
variant,
|
||||
)
|
||||
return image_urls
|
||||
|
||||
|
||||
async def complete_game(
|
||||
@ -4372,19 +4406,8 @@ async def complete_game(
|
||||
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"]:
|
||||
await notify_tier_completion(interaction.channel, tier_up)
|
||||
await _trigger_variant_renders(evo_result["tier_ups"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
||||
# WP-13: season stats update + refractor milestone evaluation.
|
||||
await _run_post_game_refractor_hook(db_game["id"], interaction.channel)
|
||||
|
||||
session.delete(this_play)
|
||||
session.commit()
|
||||
|
||||
@ -120,8 +120,10 @@ async def get_card_embeds(card, include_stats=False) -> list:
|
||||
tier = evo_state["current_tier"]
|
||||
badge = TIER_BADGES.get(tier)
|
||||
tier_badge = f"[{badge}] " if badge else ""
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.debug(
|
||||
f"badge lookup failed for card {card.get('id')}: {e}", exc_info=True
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{tier_badge}{card['player']['p_name']}",
|
||||
@ -337,7 +339,7 @@ async def display_cards(
|
||||
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
|
||||
logger.debug("Cards sorted successfully")
|
||||
|
||||
card_embeds = [await get_card_embeds(x) for x in cards]
|
||||
card_embeds = list(await asyncio.gather(*[get_card_embeds(x) for x in cards]))
|
||||
logger.debug(f"Created {len(card_embeds)} card embeds")
|
||||
|
||||
page_num = 0 if pack_cover is None else -1
|
||||
@ -1784,14 +1786,18 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
||||
|
||||
pack_type_name = all_packs[0].get("pack_type", {}).get("name")
|
||||
if pack_type_name in SCOUTABLE_PACK_TYPES:
|
||||
for p_id in pack_ids:
|
||||
pack_cards = [c for c in all_cards if c.get("pack_id") == p_id]
|
||||
if pack_cards:
|
||||
await create_scout_opportunity(
|
||||
pack_cards, team, pack_channel, author, context
|
||||
await asyncio.gather(
|
||||
*[
|
||||
create_scout_opportunity(
|
||||
[c for c in all_cards if c.get("pack_id") == p_id],
|
||||
team,
|
||||
pack_channel,
|
||||
author,
|
||||
context,
|
||||
)
|
||||
if len(pack_ids) > 1:
|
||||
await asyncio.sleep(2)
|
||||
for p_id in pack_ids
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_choice_from_cards(
|
||||
|
||||
36
helpers/refractor_constants.py
Normal file
36
helpers/refractor_constants.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""
|
||||
Shared Refractor Constants
|
||||
|
||||
Single source of truth for tier names and colors used across the refractor
|
||||
system. All consumers (status view, notifications, player view) import from
|
||||
here to prevent silent divergence.
|
||||
"""
|
||||
|
||||
# Human-readable display names for each tier number.
|
||||
TIER_NAMES = {
|
||||
0: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
# Embed accent colors for the /refractor status view.
|
||||
# These use muted/metallic tones suited to a card-list display.
|
||||
STATUS_TIER_COLORS = {
|
||||
0: 0x95A5A6, # slate grey
|
||||
1: 0xBDC3C7, # silver/chrome
|
||||
2: 0x3498DB, # refractor blue
|
||||
3: 0xF1C40F, # gold
|
||||
4: 0x1ABC9C, # teal superfractor
|
||||
}
|
||||
|
||||
# Embed accent colors for tier-up notification embeds.
|
||||
# These use brighter/more celebratory tones to signal a milestone event.
|
||||
# T2 is gold (not blue) to feel like an achievement unlock, not a status indicator.
|
||||
NOTIF_TIER_COLORS = {
|
||||
1: 0x2ECC71, # green
|
||||
2: 0xF1C40F, # gold
|
||||
3: 0x9B59B6, # purple
|
||||
4: 0x1ABC9C, # teal (superfractor)
|
||||
}
|
||||
@ -12,35 +12,23 @@ import logging
|
||||
|
||||
import discord
|
||||
|
||||
from helpers.refractor_constants import TIER_NAMES, NOTIF_TIER_COLORS as TIER_COLORS
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
# Human-readable display names for each tier number.
|
||||
TIER_NAMES = {
|
||||
0: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
# Tier-specific embed colors.
|
||||
TIER_COLORS = {
|
||||
1: 0x2ECC71, # green
|
||||
2: 0xF1C40F, # gold
|
||||
3: 0x9B59B6, # purple
|
||||
4: 0x1ABC9C, # teal (superfractor)
|
||||
}
|
||||
|
||||
FOOTER_TEXT = "Paper Dynasty Refractor"
|
||||
|
||||
|
||||
def build_tier_up_embed(tier_up: dict) -> discord.Embed:
|
||||
def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> 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.
|
||||
image_url:
|
||||
Optional S3 URL for the newly rendered refractor card image. When
|
||||
provided, the card art is shown as the embed image.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -72,12 +60,14 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
|
||||
color=color,
|
||||
)
|
||||
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
embed.set_footer(text=FOOTER_TEXT)
|
||||
return embed
|
||||
|
||||
|
||||
async def notify_tier_completion(
|
||||
channel: discord.abc.Messageable, tier_up: dict
|
||||
channel: discord.abc.Messageable, tier_up: dict, image_url: str | None = None
|
||||
) -> None:
|
||||
"""Send a tier-up notification embed to the given channel.
|
||||
|
||||
@ -90,9 +80,12 @@ async def notify_tier_completion(
|
||||
A discord.abc.Messageable (e.g. discord.TextChannel).
|
||||
tier_up:
|
||||
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
||||
image_url:
|
||||
Optional S3 URL for the refractor card image. Passed through to
|
||||
build_tier_up_embed so the card art appears in the notification.
|
||||
"""
|
||||
try:
|
||||
embed = build_tier_up_embed(tier_up)
|
||||
embed = build_tier_up_embed(tier_up, image_url=image_url)
|
||||
await channel.send(embed=embed)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
||||
@ -382,11 +382,11 @@ API layer is functional. Execute via shell or Playwright network interception.
|
||||
These tests verify that tier badges appear in card embed titles across all
|
||||
commands that display card embeds via `get_card_embeds()`.
|
||||
|
||||
### REF-40: Tier badge on /card command (player lookup)
|
||||
### REF-40: Tier badge on /player command (player lookup)
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Look up a card that has a refractor tier > 0 |
|
||||
| **Discord command** | `/card {player_name}` (use a player known to have refractor state) |
|
||||
| **Discord command** | `/player {player_name}` (use a player known to have refractor state) |
|
||||
| **Expected result** | Embed title is `[BC] Player Name` (or appropriate badge for their tier) |
|
||||
| **Pass criteria** | 1. Embed title starts with the correct tier badge in brackets |
|
||||
| | 2. Player name follows the badge |
|
||||
@ -396,7 +396,7 @@ commands that display card embeds via `get_card_embeds()`.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Look up a card with current_tier=0 |
|
||||
| **Discord command** | `/card {player_name}` (use a player at T0) |
|
||||
| **Discord command** | `/player {player_name}` (use a player at T0) |
|
||||
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
|
||||
|
||||
@ -404,7 +404,7 @@ commands that display card embeds via `get_card_embeds()`.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Look up a card that has no RefractorCardState row |
|
||||
| **Discord command** | `/card {player_name}` (use a player with no refractor state) |
|
||||
| **Discord command** | `/player {player_name}` (use a player with no refractor state) |
|
||||
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||
| **Pass criteria** | 1. Title has no badge prefix |
|
||||
| | 2. No error in bot logs about the refractor API call |
|
||||
@ -414,7 +414,7 @@ commands that display card embeds via `get_card_embeds()`.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Start a card purchase for a player with refractor state |
|
||||
| **Discord command** | `/buy {player_name}` |
|
||||
| **Discord command** | `/buy card-by-name {player_name}` |
|
||||
| **Expected result** | The card embed shown during purchase confirmation includes the tier badge |
|
||||
| **Pass criteria** | Embed title includes tier badge if the player has refractor state |
|
||||
| **Notes** | The buy flow uses `get_card_embeds(get_blank_team_card(...))`. Since blank team cards have no team association, the refractor lookup by card_id may 404. Verify graceful fallback. |
|
||||
@ -423,16 +423,16 @@ commands that display card embeds via `get_card_embeds()`.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Open a pack and check if revealed cards show tier badges |
|
||||
| **Discord command** | `/openpack` (or equivalent pack opening command) |
|
||||
| **Discord command** | `/open-packs` |
|
||||
| **Expected result** | Cards displayed via `display_cards()` -> `get_card_embeds()` show tier badges if applicable |
|
||||
| **Pass criteria** | Cards with refractor state show badges; cards without state show no badge and no error |
|
||||
|
||||
### REF-45: Badge consistency between /card and /refractor status
|
||||
### REF-45: Badge consistency between /player and /refractor status
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Description** | Compare the badge shown for the same player in both views |
|
||||
| **Discord command** | Run both `/card {player}` and `/refractor status` for the same player |
|
||||
| **Expected result** | The badge in the `/card` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
|
||||
| **Discord command** | Run both `/player {player}` and `/refractor status` for the same player |
|
||||
| **Expected result** | The badge in the `/player` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
|
||||
| **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
|
||||
|
||||
---
|
||||
@ -568,11 +568,11 @@ REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
|
||||
These tests verify that tier badges appear (or correctly do not appear) in all
|
||||
commands that display card information.
|
||||
|
||||
### REF-70: /roster command -- cards show tier badges
|
||||
### REF-70: /team command -- cards show tier badges
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Discord command** | `/roster` or equivalent command that lists team cards |
|
||||
| **Expected result** | If roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
|
||||
| **Discord command** | `/team` |
|
||||
| **Expected result** | If team/roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
|
||||
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
|
||||
|
||||
### REF-71: /show-card defense (in-game) -- no badge expected
|
||||
@ -584,12 +584,13 @@ commands that display card information.
|
||||
| **Pass criteria** | This is EXPECTED behavior -- in-game card display does not fetch refractor state |
|
||||
| **Notes** | This is a known limitation, not a bug. Document for future consideration. |
|
||||
|
||||
### REF-72: /scouting view -- badge on scouted cards
|
||||
### REF-72: /scout-tokens -- no badge expected
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Discord command** | `/scout {player_name}` (if the scouting cog uses get_card_embeds) |
|
||||
| **Expected result** | If the scouting view calls get_card_embeds, badges should appear |
|
||||
| **Pass criteria** | Verify whether scouting uses get_card_embeds or its own embed builder |
|
||||
| **Discord command** | `/scout-tokens` |
|
||||
| **Expected result** | Scout tokens display does not show card embeds, so no badges are expected |
|
||||
| **Pass criteria** | Command responds with token count; no card embeds or badges displayed |
|
||||
| **Notes** | `/scout-tokens` shows remaining daily tokens, not card embeds. Badge propagation is not applicable here. |
|
||||
|
||||
---
|
||||
|
||||
@ -663,28 +664,38 @@ design but means tier-up notifications are best-effort.
|
||||
|
||||
Run order for Playwright automation:
|
||||
|
||||
1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||
1. [x] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||
- Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
|
||||
- Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
|
||||
2. [~] Execute REF-01 through REF-06 (basic /refractor status)
|
||||
- Tested 2026-04-07: REF-API-02 (tracks ✓), REF-API-04 (404 nonexistent ✓), REF-API-05 (evolution removed ✓), REF-API-08 (tier filter ✓), REF-API-09 (progress=close ✓)
|
||||
- REF-API-01 (bot health) not tested via API (port conflict with adminer on localhost:8080), but bot confirmed healthy via logs
|
||||
2. [x] Execute REF-01 through REF-06 (basic /refractor status)
|
||||
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
|
||||
- Bugs found and fixed: wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
|
||||
- Not yet tested: REF-03 (SP format), REF-04 (RP format), REF-06 (fully evolved)
|
||||
3. [~] Execute REF-10 through REF-19 (filters)
|
||||
- Tested 2026-04-07: REF-03 (SP format ✓), REF-04 (RP format ✓)
|
||||
- Bugs found and fixed (2026-03-25): wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
|
||||
- Note: formula labels (IP+K, PA+TB x 2) from test spec are not rendered; format is value/threshold (pct%) only
|
||||
- REF-06 (fully evolved) not testable — no T4 cards exist in test data
|
||||
3. [x] Execute REF-10 through REF-19 (filters)
|
||||
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
|
||||
- Tested 2026-04-07: REF-11 (sp ✓), REF-12 (rp ✓), REF-13 (tier=0 ✓), REF-14 (tier=1 ✓), REF-15 (tier=4 empty ✓), REF-16 (progress=close ✓), REF-17 (batter+T1 combined ✓), REF-18 (T4+close empty ✓)
|
||||
- Choice dropdown menus added for all filter params (PR #126)
|
||||
- Not yet tested: REF-11 through REF-19
|
||||
4. [~] Execute REF-20 through REF-23 (pagination)
|
||||
- REF-19 (season filter): N/A — season param not implemented in the slash command
|
||||
4. [x] Execute REF-20 through REF-23 (pagination)
|
||||
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
|
||||
- Not yet tested: REF-21 (page 2), REF-22 (beyond total), REF-23 (page 0)
|
||||
5. [ ] Execute REF-30 through REF-34 (edge cases)
|
||||
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
|
||||
- Tested 2026-04-07: REF-21 (page 2 ✓), REF-22 (page=999 clamps to last page ✓ — fixed in discord#141/#142), REF-23 (page 0 clamps to 1 ✓), Prev/Next buttons (✓)
|
||||
5. [x] Execute REF-30 through REF-34 (edge cases)
|
||||
- Tested 2026-04-07: REF-34 (page=-5 clamps to 1 ✓)
|
||||
- REF-30 (no team), REF-31 (no refractor data), REF-32 (invalid card_type), REF-33 (negative tier): not tested — require alt account or manual API state manipulation
|
||||
6. [N/A] Execute REF-40 through REF-45 (tier badges on card embeds)
|
||||
- **Design gap**: `get_card_embeds()` looks up refractor state via `card['id']`, but all user-facing commands (`/player`, `/buy`) use `get_blank_team_card()` which has no `id` field. The `except Exception: pass` silently swallows the KeyError. Badges never appear outside `/refractor status`. `/open-packs` uses real card objects but results are random. No command currently surfaces badges on card embeds in practice.
|
||||
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
|
||||
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
|
||||
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
|
||||
10. [~] Execute REF-80 through REF-82 (force-evaluate API)
|
||||
9. [N/A] Execute REF-70 through REF-72 (cross-command badge propagation)
|
||||
- REF-70: `/team` shows team overview, not card embeds — badges not applicable
|
||||
- REF-71: `/show-card defense` only works during active games — expected no badge (by design)
|
||||
- REF-72: `/scout-tokens` shows token count, not card embeds — badges not applicable
|
||||
10. [x] Execute REF-80 through REF-82 (force-evaluate API)
|
||||
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
|
||||
- Not yet tested: REF-81, REF-82
|
||||
- Tested 2026-04-07: REF-81 (no stats → 404 ✓), REF-82 (nonexistent card → 404 ✓)
|
||||
|
||||
### Approximate Time Estimates
|
||||
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
|
||||
|
||||
387
tests/test_post_game_refractor_hook.py
Normal file
387
tests/test_post_game_refractor_hook.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""
|
||||
Mock-based integration tests for the post-game refractor hook.
|
||||
|
||||
Tests _run_post_game_refractor_hook() which orchestrates:
|
||||
1. POST season-stats/update-game/{game_id} — update player season stats
|
||||
2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones
|
||||
3. _trigger_variant_renders() with the full tier_ups list (returns image_url map)
|
||||
4. notify_tier_completion() once per tier-up, with image_url from render
|
||||
|
||||
The hook is wrapped in try/except so failures are non-fatal — the game
|
||||
result is already persisted before this block runs. These tests cover the
|
||||
orchestration logic (REF-50+ scenarios) without requiring a live game.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from command_logic.logic_gameplay import _run_post_game_refractor_hook
|
||||
|
||||
|
||||
def _make_channel(channel_id: int = 999) -> MagicMock:
|
||||
ch = MagicMock()
|
||||
ch.id = channel_id
|
||||
return ch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndpointOrder:
|
||||
"""Season-stats must be POSTed before refractor evaluate."""
|
||||
|
||||
async def test_calls_both_endpoints(self):
|
||||
"""Both POST endpoints are called for every game completion."""
|
||||
with patch(
|
||||
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||
) as mock_post:
|
||||
mock_post.return_value = {}
|
||||
await _run_post_game_refractor_hook(42, _make_channel())
|
||||
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
async def test_season_stats_before_evaluate(self):
|
||||
"""Season stats must be updated before refractor evaluate runs.
|
||||
|
||||
player_season_stats must exist before the refractor engine reads them
|
||||
for milestone evaluation — wrong order yields stale data.
|
||||
"""
|
||||
with patch(
|
||||
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||
) as mock_post:
|
||||
mock_post.return_value = {}
|
||||
await _run_post_game_refractor_hook(42, _make_channel())
|
||||
|
||||
calls = mock_post.call_args_list
|
||||
assert calls[0] == call("season-stats/update-game/42")
|
||||
assert calls[1] == call("refractor/evaluate-game/42")
|
||||
|
||||
async def test_game_id_interpolated_correctly(self):
|
||||
"""The game ID is interpolated into both endpoint URLs."""
|
||||
with patch(
|
||||
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||
) as mock_post:
|
||||
mock_post.return_value = {}
|
||||
await _run_post_game_refractor_hook(99, _make_channel())
|
||||
|
||||
urls = [c.args[0] for c in mock_post.call_args_list]
|
||||
assert "season-stats/update-game/99" in urls
|
||||
assert "refractor/evaluate-game/99" in urls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier-up notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTierUpNotifications:
|
||||
"""notify_tier_completion is called once per tier-up in the API response."""
|
||||
|
||||
async def test_notifies_for_each_tier_up(self):
|
||||
"""Each tier_up dict is forwarded to notify_tier_completion."""
|
||||
tier_ups = [
|
||||
{
|
||||
"player_id": 101,
|
||||
"player_name": "Mike Trout",
|
||||
"old_tier": 0,
|
||||
"new_tier": 1,
|
||||
"current_value": 30.0,
|
||||
"track_name": "Batter Track",
|
||||
},
|
||||
{
|
||||
"player_id": 202,
|
||||
"player_name": "Shohei Ohtani",
|
||||
"old_tier": 1,
|
||||
"new_tier": 2,
|
||||
"current_value": 60.0,
|
||||
"track_name": "Pitcher Track",
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": tier_ups}
|
||||
return {}
|
||||
|
||||
channel = _make_channel()
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
await _run_post_game_refractor_hook(99, channel)
|
||||
|
||||
assert mock_notify.call_count == 2
|
||||
forwarded = [c.args[1] for c in mock_notify.call_args_list]
|
||||
assert tier_ups[0] in forwarded
|
||||
assert tier_ups[1] in forwarded
|
||||
|
||||
async def test_channel_passed_to_notify(self):
|
||||
"""notify_tier_completion receives the channel from complete_game."""
|
||||
tier_up = {
|
||||
"player_id": 1,
|
||||
"player_name": "Mike Trout",
|
||||
"old_tier": 0,
|
||||
"new_tier": 1,
|
||||
"current_value": 30.0,
|
||||
"track_name": "Batter Track",
|
||||
}
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": [tier_up]}
|
||||
return {}
|
||||
|
||||
channel = _make_channel()
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
await _run_post_game_refractor_hook(1, channel)
|
||||
|
||||
mock_notify.assert_called_once_with(channel, tier_up, image_url=None)
|
||||
|
||||
async def test_no_notify_when_empty_tier_ups(self):
|
||||
"""No notifications sent when evaluate returns an empty tier_ups list."""
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": []}
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await _run_post_game_refractor_hook(55, _make_channel())
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
async def test_no_notify_when_tier_ups_key_absent(self):
|
||||
"""No notifications when evaluate response has no tier_ups key."""
|
||||
|
||||
async def fake_post(endpoint):
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await _run_post_game_refractor_hook(55, _make_channel())
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variant render triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVariantRenderTriggers:
|
||||
"""_trigger_variant_renders receives the full tier_ups list."""
|
||||
|
||||
async def test_trigger_renders_called_with_all_tier_ups(self):
|
||||
"""_trigger_variant_renders is called once with the complete tier_ups list."""
|
||||
tier_ups = [
|
||||
{"player_id": 101, "variant_created": 7, "track_name": "Batter"},
|
||||
{"player_id": 202, "variant_created": 3, "track_name": "Pitcher"},
|
||||
]
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": tier_ups}
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as mock_render,
|
||||
):
|
||||
await _run_post_game_refractor_hook(42, _make_channel())
|
||||
|
||||
mock_render.assert_called_once_with(tier_ups)
|
||||
|
||||
async def test_no_trigger_when_no_tier_ups(self):
|
||||
"""_trigger_variant_renders is not called when tier_ups is empty."""
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": []}
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_render,
|
||||
):
|
||||
await _run_post_game_refractor_hook(42, _make_channel())
|
||||
|
||||
mock_render.assert_not_called()
|
||||
|
||||
async def test_render_before_notification(self):
|
||||
"""_trigger_variant_renders is called before notify_tier_completion.
|
||||
|
||||
Renders run first so that image URLs are available to include in the
|
||||
tier-up notification embed. The player sees the card art immediately
|
||||
rather than receiving a text-only notification.
|
||||
"""
|
||||
call_order = []
|
||||
tier_up = {"player_id": 1, "variant_created": 5, "track_name": "Batter"}
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return {"tier_ups": [tier_up]}
|
||||
return {}
|
||||
|
||||
async def fake_notify(ch, tu, image_url=None):
|
||||
call_order.append("notify")
|
||||
|
||||
async def fake_render(tier_ups):
|
||||
call_order.append("render")
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
side_effect=fake_notify,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||
side_effect=fake_render,
|
||||
),
|
||||
):
|
||||
await _run_post_game_refractor_hook(1, _make_channel())
|
||||
|
||||
assert call_order == ["render", "notify"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-fatal error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNonFatalErrors:
|
||||
"""Hook failures must never propagate to the caller."""
|
||||
|
||||
async def test_nonfatal_when_season_stats_raises(self):
|
||||
"""Exception from season-stats update does not propagate.
|
||||
|
||||
The game is already saved — refractor failure must not interrupt
|
||||
the completion flow or show an error to the user.
|
||||
"""
|
||||
with patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("stats API down"),
|
||||
):
|
||||
await _run_post_game_refractor_hook(7, _make_channel())
|
||||
|
||||
async def test_nonfatal_when_evaluate_game_raises(self):
|
||||
"""Exception from refractor evaluate does not propagate."""
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
raise Exception("refractor API unavailable")
|
||||
return {}
|
||||
|
||||
with patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
):
|
||||
await _run_post_game_refractor_hook(7, _make_channel())
|
||||
|
||||
async def test_nonfatal_when_evaluate_returns_none(self):
|
||||
"""None response from evaluate-game does not raise or notify."""
|
||||
|
||||
async def fake_post(endpoint):
|
||||
if "refractor" in endpoint:
|
||||
return None
|
||||
return {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"command_logic.logic_gameplay.db_post",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_post,
|
||||
),
|
||||
patch(
|
||||
"command_logic.logic_gameplay.notify_tier_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
):
|
||||
await _run_post_game_refractor_hook(7, _make_channel())
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
@ -415,47 +415,51 @@ def mock_interaction():
|
||||
|
||||
class TestTierNamesDivergenceCheck:
|
||||
"""
|
||||
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs
|
||||
are identical (same keys, same values).
|
||||
T1-6: Assert that TIER_NAMES in all three consumers (cogs.refractor,
|
||||
helpers.refractor_notifs, cogs.players) is identical.
|
||||
|
||||
Why: TIER_NAMES is duplicated in two modules. If one is updated and the
|
||||
other is not (e.g. a tier is renamed or a new tier is added), tier labels
|
||||
in the /refractor status embed and the tier-up notification embed will
|
||||
diverge silently. This test acts as a divergence tripwire — it will fail
|
||||
the moment the two copies fall out of sync, forcing an explicit fix.
|
||||
All three consumers now import from helpers.refractor_constants, so this
|
||||
test acts as a tripwire against accidental re-localization of the constant.
|
||||
If any consumer re-declares a local copy that diverges, these tests will
|
||||
catch it.
|
||||
"""
|
||||
|
||||
def test_tier_names_are_identical_across_modules(self):
|
||||
"""
|
||||
Import TIER_NAMES from both modules and assert deep equality.
|
||||
Import TIER_NAMES from all three consumers and assert deep equality.
|
||||
|
||||
The test imports the name at call-time rather than at module level to
|
||||
ensure it always reads the current definition and is not affected by
|
||||
module-level caching or monkeypatching in other tests.
|
||||
The test imports at call-time rather than module level to ensure it
|
||||
always reads the current definition and is not affected by caching or
|
||||
monkeypatching in other tests.
|
||||
"""
|
||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||
from helpers.refractor_constants import TIER_NAMES as constants_tier_names
|
||||
|
||||
assert cog_tier_names == notifs_tier_names, (
|
||||
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. "
|
||||
"Both copies must be kept in sync. "
|
||||
assert cog_tier_names == notifs_tier_names == constants_tier_names, (
|
||||
"TIER_NAMES differs across consumers. "
|
||||
f"cogs.refractor: {cog_tier_names!r} "
|
||||
f"helpers.refractor_notifs: {notifs_tier_names!r}"
|
||||
f"helpers.refractor_notifs: {notifs_tier_names!r} "
|
||||
f"helpers.refractor_constants: {constants_tier_names!r}"
|
||||
)
|
||||
|
||||
def test_tier_names_have_same_keys(self):
|
||||
"""Keys (tier numbers) must be identical in both modules."""
|
||||
"""Keys (tier numbers) must be identical in all consumers."""
|
||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||
from cogs.players import REFRACTOR_TIER_NAMES
|
||||
|
||||
assert set(cog_tier_names.keys()) == set(notifs_tier_names.keys()), (
|
||||
"TIER_NAMES key sets differ between modules."
|
||||
)
|
||||
assert (
|
||||
set(cog_tier_names.keys())
|
||||
== set(notifs_tier_names.keys())
|
||||
== set(REFRACTOR_TIER_NAMES.keys())
|
||||
), "TIER_NAMES key sets differ between consumers."
|
||||
|
||||
def test_tier_names_have_same_values(self):
|
||||
"""Display strings (values) must be identical for every shared key."""
|
||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||
from cogs.players import REFRACTOR_TIER_NAMES
|
||||
|
||||
for tier, name in cog_tier_names.items():
|
||||
assert notifs_tier_names.get(tier) == name, (
|
||||
@ -463,6 +467,11 @@ class TestTierNamesDivergenceCheck:
|
||||
f"cogs.refractor={name!r}, "
|
||||
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
|
||||
)
|
||||
assert REFRACTOR_TIER_NAMES.get(tier) == name, (
|
||||
f"Tier {tier} name mismatch: "
|
||||
f"cogs.refractor={name!r}, "
|
||||
f"cogs.players.REFRACTOR_TIER_NAMES={REFRACTOR_TIER_NAMES.get(tier)!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user