"""Dev-only tools for testing Paper Dynasty systems. This cog is only loaded when DATABASE != prod. It provides commands for integration testing that create and clean up synthetic test data. """ import logging from datetime import date import discord from discord import app_commands from discord.ext import commands from api_calls import db_delete, db_get, db_post from helpers.constants import PD_SEASON from helpers.main import get_team_by_owner from helpers.refractor_constants import TIER_NAMES from helpers.refractor_test_data import ( build_batter_plays, build_decision_data, build_game_data, build_pitcher_plays, calculate_plays_needed, ) CURRENT_SEASON = PD_SEASON logger = logging.getLogger(__name__) class CleanupView(discord.ui.View): """Post-test buttons to clean up or keep synthetic game data.""" def __init__( self, owner_id: int, game_id: int, embed: discord.Embed, timeout: float = 300.0 ): super().__init__(timeout=timeout) self.owner_id = owner_id self.game_id = game_id self.embed = embed @discord.ui.button( label="Clean Up Test Data", style=discord.ButtonStyle.danger, emoji="๐Ÿงน" ) async def cleanup_btn( self, interaction: discord.Interaction, button: discord.ui.Button ): if interaction.user.id != self.owner_id: return try: await db_delete("decisions/game", self.game_id) await db_delete("plays/game", self.game_id) await db_delete("games", self.game_id) self.embed.add_field( name="", value=f"๐Ÿงน Test data cleaned up (game #{self.game_id} removed)", inline=False, ) except Exception as e: self.embed.add_field( name="", value=f"โŒ Cleanup failed: {e}", inline=False, ) self.clear_items() await interaction.response.edit_message(embed=self.embed, view=self) self.stop() @discord.ui.button( label="Keep Test Data", style=discord.ButtonStyle.secondary, emoji="๐Ÿ“Œ" ) async def keep_btn( self, interaction: discord.Interaction, button: discord.ui.Button ): if interaction.user.id != self.owner_id: return self.embed.add_field( name="", value=f"๐Ÿ“Œ Test data kept (game #{self.game_id})", inline=False, ) self.clear_items() await interaction.response.edit_message(embed=self.embed, view=self) self.stop() async def on_timeout(self): # Note: clear_items() updates the local view but cannot push to Discord # without a message reference. Buttons will become unresponsive after timeout. self.clear_items() class DevToolsCog(commands.Cog): """Dev-only commands for integration testing. Only loaded when DATABASE env var is not 'prod'. """ def __init__(self, bot: commands.Bot): self.bot = bot group_dev = app_commands.Group(name="dev", description="Dev-only testing tools") @group_dev.command( name="refractor-test", description="Run refractor integration test on a card" ) @app_commands.describe(card_id="The batting or pitching card ID to test") async def refractor_test(self, interaction: discord.Interaction, card_id: int): await interaction.response.defer() # --- Phase 1: Setup --- # Look up card (try batting first, then pitching) card = await db_get("battingcards", object_id=card_id) card_type_key = "batting" if card is None: card = await db_get("pitchingcards", object_id=card_id) card_type_key = "pitching" if card is None: await interaction.edit_original_response( content=f"โŒ Card #{card_id} not found (checked batting and pitching)." ) return player_id = card["player"]["id"] player_name = card["player"]["p_name"] team_id = card.get("team_id") or card["player"].get("team_id") if team_id is None: team = await get_team_by_owner(interaction.user.id) if team is None: await interaction.edit_original_response( content="โŒ Could not determine team ID. You must own a team." ) return team_id = team["id"] # Fetch refractor state refractor_data = await db_get( "refractor/cards", params=[("team_id", team_id), ("limit", 100)], ) # Find this player's entry card_state = None if refractor_data and refractor_data.get("items"): for item in refractor_data["items"]: if item["player_id"] == player_id: card_state = item break # Determine current state and thresholds if card_state: current_tier = card_state["current_tier"] current_value = card_state["current_value"] card_type = card_state["track"]["card_type"] next_threshold = card_state["next_threshold"] else: current_tier = 0 current_value = 0 card_type = "batter" if card_type_key == "batting" else "sp" next_threshold = ( 37 if card_type == "batter" else (10 if card_type == "sp" else 3) ) if current_tier >= 4: await interaction.edit_original_response( content=f"โš ๏ธ {player_name} is already at T4 Superfractor โ€” fully evolved." ) return # Calculate plan gap = max(0, next_threshold - current_value) plan = calculate_plays_needed(gap, card_type) # Find an opposing player if card_type == "batter": opposing_cards = await db_get( "pitchingcards", params=[("team_id", team_id), ("variant", 0)], ) else: opposing_cards = await db_get( "battingcards", params=[("team_id", team_id), ("variant", 0)], ) if not opposing_cards or not opposing_cards.get("cards"): await interaction.edit_original_response( content=f"โŒ No opposing {'pitcher' if card_type == 'batter' else 'batter'} cards found on team {team_id}." ) return opposing_player_id = opposing_cards["cards"][0]["player"]["id"] # Build and send initial embed tier_name = TIER_NAMES.get(current_tier, f"T{current_tier}") next_tier_name = TIER_NAMES.get(current_tier + 1, f"T{current_tier + 1}") play_desc = ( f"{plan['num_plays']} HR plays" if card_type == "batter" else f"{plan['num_plays']} K plays" ) embed = discord.Embed( title="Refractor Integration Test", color=0x3498DB, ) embed.add_field( name="Setup", value=( f"**Player:** {player_name} (card #{card_id})\n" f"**Type:** {card_type_key.title()}\n" f"**Current:** T{current_tier} {tier_name} โ†’ **Target:** T{current_tier + 1} {next_tier_name}\n" f"**Value:** {current_value} / {next_threshold} (need {gap} more)\n" f"**Plan:** {play_desc} (+{plan['total_value']:.0f} value)" ), inline=False, ) embed.add_field(name="", value="โณ Executing...", inline=False) await interaction.edit_original_response(embed=embed) # --- Phase 2: Execute --- await self._execute_refractor_test( interaction=interaction, embed=embed, player_id=player_id, player_name=player_name, team_id=team_id, card_type=card_type, card_type_key=card_type_key, opposing_player_id=opposing_player_id, num_plays=plan["num_plays"], ) async def _execute_refractor_test( self, interaction: discord.Interaction, embed: discord.Embed, player_id: int, player_name: str, team_id: int, card_type: str, card_type_key: str, opposing_player_id: int, num_plays: int, ): """Execute the refractor integration test chain. Creates synthetic game data, runs the real refractor pipeline, and reports pass/fail at each step. Stops on first failure. """ results = [] game_id = None # Remove the "Executing..." field if len(embed.fields) > 1: embed.remove_field(len(embed.fields) - 1) # Helper to update the embed with current results async def update_embed( final: bool = False, view: discord.ui.View | None = None ): results_text = "\n".join(results) # Remove old results field if present, add new one while len(embed.fields) > 1: embed.remove_field(len(embed.fields) - 1) embed.add_field(name="Results", value=results_text, inline=False) if view is not None: await interaction.edit_original_response(embed=embed, view=view) else: await interaction.edit_original_response(embed=embed) try: # Step 1: Create game game_data = build_game_data(team_id=team_id, season=CURRENT_SEASON) game_resp = await db_post("games", payload=game_data) game_id = game_resp["id"] results.append(f"โœ… Game created (#{game_id})") await update_embed() except Exception as e: results.append(f"โŒ Game creation failed: {e}") await update_embed() return try: # Step 2: Create plays if card_type == "batter": plays = build_batter_plays( game_id, player_id, team_id, opposing_player_id, num_plays ) else: plays = build_pitcher_plays( game_id, player_id, team_id, opposing_player_id, num_plays ) await db_post("plays", payload={"plays": plays}) results.append(f"โœ… {num_plays} plays inserted") await update_embed() except Exception as e: results.append(f"โŒ Play insertion failed: {e}") await update_embed( final=True, view=CleanupView(interaction.user.id, game_id, embed) ) return try: # Step 3: Create pitcher decision pitcher_id = opposing_player_id if card_type == "batter" else player_id decision_data = build_decision_data( game_id, pitcher_id, team_id, CURRENT_SEASON ) await db_post("decisions", payload=decision_data) results.append("โœ… Pitcher decision inserted") await update_embed() except Exception as e: results.append(f"โŒ Decision insertion failed: {e}") await update_embed( final=True, view=CleanupView(interaction.user.id, game_id, embed) ) return try: # Step 4: Update season stats stats_resp = await db_post(f"season-stats/update-game/{game_id}") if stats_resp and stats_resp.get("skipped"): results.append("โš ๏ธ Season stats skipped (already processed)") else: updated = stats_resp.get("updated", "?") if stats_resp else "?" results.append(f"โœ… Season stats updated ({updated} players)") await update_embed() except Exception as e: results.append(f"โŒ Season stats update failed: {e}") results.append("โญ๏ธ Skipped: evaluate-game (depends on season stats)") await update_embed( final=True, view=CleanupView(interaction.user.id, game_id, embed) ) return try: # Step 5: Evaluate refractor eval_resp = await db_post(f"refractor/evaluate-game/{game_id}") tier_ups = eval_resp.get("tier_ups", []) if eval_resp else [] if tier_ups: for tu in tier_ups: old_t = tu.get("old_tier", "?") new_t = tu.get("new_tier", "?") variant = tu.get("variant_created", "") results.append(f"โœ… Tier-up detected! T{old_t} โ†’ T{new_t}") if variant: results.append(f"โœ… Variant card created (variant: {variant})") else: evaluated = eval_resp.get("evaluated", "?") if eval_resp else "?" results.append(f"โš ๏ธ No tier-up detected (evaluated {evaluated} cards)") await update_embed() except Exception as e: results.append(f"โŒ Evaluate-game failed: {e}") await update_embed( final=True, view=CleanupView(interaction.user.id, game_id, embed) ) return # Step 6: Trigger card render (if tier-up) if tier_ups: for tu in tier_ups: variant = tu.get("variant_created") if not variant: continue try: today = date.today().isoformat() render_resp = await db_get( f"players/{tu['player_id']}/{card_type_key}card/{today}/{variant}", none_okay=True, ) if render_resp: results.append("โœ… Card rendered + S3 upload triggered") img_url = ( render_resp if isinstance(render_resp, str) else render_resp.get("image_url") ) if ( img_url and isinstance(img_url, str) and img_url.startswith("http") ): embed.set_image(url=img_url) else: results.append( "โš ๏ธ Card render returned no data (may still be processing)" ) except Exception as e: results.append(f"โš ๏ธ Card render failed (non-fatal): {e}") # Final update with cleanup buttons await update_embed( final=True, view=CleanupView(interaction.user.id, game_id, embed) ) async def setup(bot: commands.Bot): await bot.add_cog(DevToolsCog(bot))