paper-dynasty-discord/cogs/dev_tools.py
Cal Corum f3a83f91fd
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
fix: remove dead parameters from PR review feedback
Remove unused `player_name` param from `_execute_refractor_test` and
unused `final` param from `update_embed` closure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:34:02 -05:00

390 lines
14 KiB
Python

"""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,
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,
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(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(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(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(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(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(view=CleanupView(interaction.user.id, game_id, embed))
async def setup(bot: commands.Bot):
await bot.add_cog(DevToolsCog(bot))