From 0c6f7c8ffecbb0abdc658ee116e1450ed3648f69 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 16 Oct 2025 22:15:42 -0500 Subject: [PATCH] CLAUDE: Fix GroupCog interaction bug and GIF display issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses critical bugs in the injury command system and establishes best practices for Discord command groups. ## Critical Fixes ### 1. GroupCog → app_commands.Group Migration - **Problem**: `commands.GroupCog` has a duplicate interaction processing bug causing "404 Unknown interaction" errors when deferring responses - **Root Cause**: GroupCog triggers command handler twice, consuming the interaction token before the second execution can respond - **Solution**: Migrated InjuryCog to InjuryGroup using `app_commands.Group` pattern (same as ChartManageGroup and ChartCategoryGroup) - **Result**: Reliable command execution, no more 404 errors ### 2. GiphyService GIF URL Fix - **Problem**: Giphy service returned web page URLs (https://giphy.com/gifs/...) instead of direct image URLs, preventing Discord embed display - **Root Cause**: Code accessed `data.url` instead of `data.images.original.url` - **Solution**: Updated both `get_disappointment_gif()` and `get_gif()` methods to use correct API response path for embeddable GIF URLs - **Result**: GIFs now display correctly in Discord embeds ## Documentation ### Command Groups Best Practices (commands/README.md) Added comprehensive section documenting: - **Critical Warning**: Never use `commands.GroupCog` - use `app_commands.Group` - **Technical Explanation**: Why GroupCog fails (duplicate execution bug) - **Migration Guide**: Step-by-step conversion from GroupCog to Group - **Comparison Table**: Key differences between the two approaches - **Working Examples**: References to ChartManageGroup, InjuryGroup patterns ## Architecture Changes ### Injury Commands (`commands/injuries/`) - Converted from `commands.GroupCog` to `app_commands.Group` - Registration via `bot.tree.add_command()` instead of `bot.add_cog()` - Removed workarounds for GroupCog duplicate interaction issues - Clean defer/response pattern with `@logged_command` decorator ### GiphyService (`services/giphy_service.py`) - Centralized from `commands/soak/giphy_service.py` - Now returns direct GIF image URLs for Discord embeds - Maintains Trump GIF filtering (legacy behavior) - Added gif_url to log output for debugging ### Configuration (`config.py`) - Added `giphy_api_key` and `giphy_translate_url` settings - Environment variable support via `GIPHY_API_KEY` - Default values provided for out-of-box functionality ## Files Changed - commands/injuries/: New InjuryGroup with app_commands.Group pattern - services/giphy_service.py: Centralized service with GIF URL fix - commands/soak/giphy_service.py: Backwards compatibility wrapper - commands/README.md: Command groups best practices documentation - config.py: Giphy configuration settings - services/__init__.py: GiphyService exports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- commands/README.md | 106 ++++++- commands/injuries/README.md | 494 +++++++++++++++++++++++++++++ commands/injuries/__init__.py | 34 ++ commands/injuries/management.py | 534 ++++++++++++++++++++++++++++++++ commands/soak/giphy_service.py | 175 ++--------- config.py | 4 + models/injury.py | 50 +++ services/__init__.py | 7 +- services/giphy_service.py | 284 +++++++++++++++++ services/injury_service.py | 196 ++++++++++++ tests/test_services_injury.py | 507 ++++++++++++++++++++++++++++++ 11 files changed, 2246 insertions(+), 145 deletions(-) create mode 100644 commands/injuries/README.md create mode 100644 commands/injuries/__init__.py create mode 100644 commands/injuries/management.py create mode 100644 models/injury.py create mode 100644 services/giphy_service.py create mode 100644 services/injury_service.py create mode 100644 tests/test_services_injury.py diff --git a/commands/README.md b/commands/README.md index b475dd4..af43d77 100644 --- a/commands/README.md +++ b/commands/README.md @@ -427,10 +427,114 @@ INFO - Development mode: no command changes detected, skipping sync 4. **Monitor Discord API** - watch for rate limiting or errors 5. **Use development mode** - auto-sync helps debug command issues +## 📦 Command Groups Pattern + +### **⚠️ CRITICAL: Use `app_commands.Group`, NOT `commands.GroupCog`** + +Discord.py provides two ways to create command groups (e.g., `/injury roll`, `/injury clear`): +1. **`app_commands.Group`** ✅ **RECOMMENDED - Use this pattern** +2. **`commands.GroupCog`** ❌ **AVOID - Has interaction timing issues** + +### **Why `commands.GroupCog` Fails** + +`commands.GroupCog` has a critical bug that causes **duplicate interaction processing**, leading to: +- **404 "Unknown interaction" errors** when trying to defer/respond +- **Interaction already acknowledged errors** in error handlers +- **Commands fail randomly** even with proper error handling + +**Root Cause:** GroupCog triggers the command handler twice for a single interaction, causing the first execution to consume the interaction token before the second execution can respond. + +### **✅ Correct Pattern: `app_commands.Group`** + +Use the same pattern as `ChartCategoryGroup` and `ChartManageGroup`: + +```python +from discord import app_commands +from discord.ext import commands +from utils.decorators import logged_command + +class InjuryGroup(app_commands.Group): + """Injury management command group.""" + + def __init__(self): + super().__init__( + name="injury", + description="Injury management commands" + ) + self.logger = get_contextual_logger(f'{__name__}.InjuryGroup') + + @app_commands.command(name="roll", description="Roll for injury") + @logged_command("/injury roll") + async def injury_roll(self, interaction: discord.Interaction, player_name: str): + """Roll for injury using player's injury rating.""" + await interaction.response.defer() + + # Command implementation + # No try/catch needed - @logged_command handles it + +async def setup(bot: commands.Bot): + """Setup function for loading the injury commands.""" + bot.tree.add_command(InjuryGroup()) +``` + +### **Key Differences** + +| Feature | `app_commands.Group` ✅ | `commands.GroupCog` ❌ | +|---------|------------------------|------------------------| +| **Registration** | `bot.tree.add_command(Group())` | `await bot.add_cog(Cog(bot))` | +| **Initialization** | `__init__(self)` no bot param | `__init__(self, bot)` requires bot | +| **Decorator Support** | `@logged_command` works perfectly | Causes duplicate execution | +| **Interaction Handling** | Single execution, reliable | Duplicate execution, 404 errors | +| **Recommended Use** | ✅ All command groups | ❌ Never use | + +### **Migration from GroupCog to Group** + +If you have an existing `commands.GroupCog`, convert it: + +1. **Change class inheritance:** + ```python + # Before + class InjuryCog(commands.GroupCog, name="injury"): + def __init__(self, bot): + self.bot = bot + super().__init__() + + # After + class InjuryGroup(app_commands.Group): + def __init__(self): + super().__init__(name="injury", description="...") + ``` + +2. **Update registration:** + ```python + # Before + await bot.add_cog(InjuryCog(bot)) + + # After + bot.tree.add_command(InjuryGroup()) + ``` + +3. **Remove duplicate interaction checks:** + ```python + # Before (needed for GroupCog bug workaround) + if not interaction.response.is_done(): + await interaction.response.defer() + + # After (clean, simple) + await interaction.response.defer() + ``` + +### **Working Examples** + +**Good examples to reference:** +- `commands/utilities/charts.py` - `ChartManageGroup` and `ChartCategoryGroup` +- `commands/injuries/management.py` - `InjuryGroup` + +Both use `app_commands.Group` successfully with `@logged_command` decorators. + ## 🚦 Future Enhancements ### **Planned Features** -- **Command Groups**: Discord.py command groups for better organization (`/player info`, `/player stats`) - **Permission Decorators**: Role-based command restrictions per package - **Dynamic Loading**: Hot-reload commands without bot restart - **Usage Metrics**: Command usage tracking and analytics diff --git a/commands/injuries/README.md b/commands/injuries/README.md new file mode 100644 index 0000000..05856a1 --- /dev/null +++ b/commands/injuries/README.md @@ -0,0 +1,494 @@ +# Injury Commands + +**Command Group:** `/injury` +**Permission Required:** SBA Players role (for set-new and clear) +**Subcommands:** roll, set-new, clear + +## Overview + +The injury command family provides comprehensive player injury management for the SBA league. Team managers can roll for injuries using official Strat-o-Matic injury tables, record confirmed injuries, and clear injuries when players return. + +## Commands + +### `/injury roll` + +Roll for injury based on a player's injury rating using 3d6 dice and official injury tables. + +**Usage:** +``` +/injury roll +``` + +**Parameters:** +- `player_name` (required, autocomplete): Name of the player - uses smart autocomplete prioritizing your team's players + +**Injury Rating Format:** +The player's `injury_rating` field contains both the games played and rating in format `#p##`: +- **Format**: `1p70`, `4p50`, `2p65`, etc. +- **First character**: Games played in current series (1-6) +- **Remaining characters**: Injury rating (p70, p65, p60, p50, p40, p30, p20) + +**Examples:** +- `1p70` = 1 game played, p70 rating +- `4p50` = 4 games played, p50 rating +- `2p65` = 2 games played, p65 rating + +**Dice Roll:** +- Rolls 3d6 (3-18 range) +- Automatically extracts games played and rating from player's injury_rating field +- Looks up result in official Strat-o-Matic injury tables +- Returns injury duration based on rating and games played + +**Possible Results:** +- **OK**: No injury +- **REM**: Remainder of game (batters) or Fatigued (pitchers) +- **Number**: Games player will miss (1-24 games) + +**Example:** +``` +/injury roll Mike Trout +``` + +**Response Fields:** +- **Roll**: Total rolled and individual dice (e.g., "15 (3d6: 5 + 5 + 5)") +- **Player**: Player name and position +- **Injury Rating**: Full rating with parsed details (e.g., "4p50 (p50, 4 games)") +- **Result**: Injury outcome (OK, REM, or number of games) +- **Team**: Player's current team + +**Response Colors:** +- **Green**: OK (no injury) +- **Gold**: REM (remainder of game/fatigued) +- **Orange**: Number of games (injury occurred) + +**Error Handling:** +If a player's `injury_rating` is not in the correct format, an error message will be displayed: +``` +Invalid Injury Rating Format +{Player} has an invalid injury rating: `{rating}` + +Expected format: #p## (e.g., 1p70, 4p50) +``` + +--- + +### `/injury set-new` + +Record a new injury for a player on your team. + +**Usage:** +``` +/injury set-new +``` + +**Parameters:** +- `player_name` (required): Name of the player to injure +- `this_week` (required): Current week number +- `this_game` (required): Current game number (1-4) +- `injury_games` (required): Total number of games player will be out + +**Validation:** +- Player must exist in current season +- Player cannot already have an active injury +- Game number must be between 1 and 4 +- Injury duration must be at least 1 game + +**Automatic Calculations:** +The command automatically calculates: +1. Injury start date (adjusts for game 4 edge case) +2. Return date based on injury duration +3. Week rollover when games exceed 4 per week + +**Example:** +``` +/injury set-new Mike Trout 5 2 4 +``` +This records an injury occurring in week 5, game 2, with player out for 4 games (returns week 6, game 2). + +**Response:** +- Confirmation embed with injury details +- Player's name, position, and team +- Total games missed +- Calculated return date + +--- + +### `/injury clear` + +Clear a player's active injury and mark them as eligible to play. + +**Usage:** +``` +/injury clear +``` + +**Parameters:** +- `player_name` (required): Name of the player whose injury to clear + +**Validation:** +- Player must exist in current season +- Player must have an active injury + +**Example:** +``` +/injury clear Mike Trout +``` + +**Response:** +- Confirmation that injury was cleared +- Shows previous return date +- Shows total games that were missed +- Player's team information + +--- + +## Date Format + +All injury dates use the format `w##g#`: +- `w##` = Week number (zero-padded to 2 digits) +- `g#` = Game number (1-4) + +**Examples:** +- `w05g2` = Week 5, Game 2 +- `w12g4` = Week 12, Game 4 +- `w01g1` = Week 1, Game 1 + +## Injury Calculation Logic + +### Basic Calculation + +For an injury of N games starting at week W, game G: + +1. **Calculate weeks and remaining games:** + ``` + out_weeks = floor(N / 4) + out_games = N % 4 + ``` + +2. **Calculate return date:** + ``` + return_week = W + out_weeks + return_game = G + 1 + out_games + ``` + +3. **Handle week rollover:** + ``` + if return_game > 4: + return_week += 1 + return_game -= 4 + ``` + +### Special Cases + +#### Game 4 Edge Case +If injury occurs during game 4, the start date is adjusted: +``` +start_week = W + 1 +start_game = 1 +``` + +#### Examples + +**Example 1: Simple injury (same week)** +- Current: Week 5, Game 1 +- Injury: 2 games +- Return: Week 5, Game 4 + +**Example 2: Week rollover** +- Current: Week 5, Game 3 +- Injury: 3 games +- Return: Week 6, Game 3 + +**Example 3: Multi-week injury** +- Current: Week 5, Game 2 +- Injury: 8 games +- Return: Week 7, Game 3 + +**Example 4: Game 4 start** +- Current: Week 5, Game 4 +- Injury: 2 games +- Start: Week 6, Game 1 +- Return: Week 6, Game 3 + +## Database Schema + +### Injury Model + +```python +class Injury(SBABaseModel): + id: int # Injury ID + season: int # Season number + player_id: int # Player ID + total_games: int # Total games player will be out + start_week: int # Week injury started + start_game: int # Game number injury started (1-4) + end_week: int # Week player returns + end_game: int # Game number player returns (1-4) + is_active: bool # Whether injury is currently active +``` + +### API Integration + +The commands interact with the following API endpoints: + +- `GET /api/v3/injuries` - Query injuries with filters +- `POST /api/v3/injuries` - Create new injury record +- `PATCH /api/v3/injuries/{id}` - Update injury (clear active status) +- `PATCH /api/v3/players/{id}` - Update player's il_return field + +## Service Layer + +### InjuryService + +**Location:** `services/injury_service.py` + +**Key Methods:** +- `get_active_injury(player_id, season)` - Get active injury for player +- `get_injuries_by_player(player_id, season, active_only)` - Get all injuries for player +- `get_injuries_by_team(team_id, season, active_only)` - Get team injuries +- `create_injury(...)` - Create new injury record +- `clear_injury(injury_id)` - Deactivate injury + +## Permissions + +### Required Roles + +**For `/injury check`:** +- No role required (available to all users) + +**For `/injury set-new` and `/injury clear`:** +- **SBA Players** role required +- Configured via `SBA_PLAYERS_ROLE_NAME` environment variable + +### Permission Checks + +The commands use `has_player_role()` method to verify user has appropriate role: + +```python +def has_player_role(self, interaction: discord.Interaction) -> bool: + """Check if user has the SBA Players role.""" + player_role = discord.utils.get( + interaction.guild.roles, + name=get_config().sba_players_role_name + ) + return player_role in interaction.user.roles if player_role else False +``` + +## Error Handling + +### Common Errors + +**Player Not Found:** +``` +❌ Player Not Found +I did not find anybody named **{player_name}**. +``` + +**Already Injured:** +``` +❌ Already Injured +Hm. It looks like {player_name} is already hurt. +``` + +**Not Injured:** +``` +❌ No Active Injury +{player_name} isn't injured. +``` + +**Invalid Input:** +``` +❌ Invalid Input +Game number must be between 1 and 4. +``` + +**Permission Denied:** +``` +❌ Permission Denied +This command requires the **SBA Players** role. +``` + +## Logging + +All injury commands use the `@logged_command` decorator for automatic logging: + +```python +@app_commands.command(name="check") +@logged_command("/injury check") +async def injury_check(self, interaction, player_name: str): + # Command implementation +``` + +**Log Context:** +- Command name +- User ID and username +- Player name +- Season +- Injury details (duration, dates) +- Success/failure status + +**Example Log:** +```json +{ + "level": "INFO", + "command": "/injury set-new", + "user_id": "123456789", + "player_name": "Mike Trout", + "season": 12, + "injury_games": 4, + "return_date": "w06g2", + "message": "Injury set for Mike Trout" +} +``` + +## Testing + +### Test Coverage + +**Location:** `tests/test_services_injury.py` + +**Test Categories:** +1. **Model Tests** (5 tests) - Injury model creation and properties +2. **Service Tests** (8 tests) - InjuryService CRUD operations with API mocking +3. **Roll Logic Tests** (8 tests) - Injury rating parsing, table lookup, and dice roll logic +4. **Calculation Tests** (5 tests) - Date calculation logic for injury duration + +**Total:** 26 comprehensive tests + +**Running Tests:** +```bash +# Run all injury tests +python -m pytest tests/test_services_injury.py -v + +# Run specific test class +python -m pytest tests/test_services_injury.py::TestInjuryService -v +python -m pytest tests/test_services_injury.py::TestInjuryRollLogic -v + +# Run with coverage +python -m pytest tests/test_services_injury.py --cov=services.injury_service --cov=commands.injuries +``` + +## Injury Roll Tables + +### Table Structure + +The injury tables are based on official Strat-o-Matic rules with the following structure: + +**Ratings:** p70, p65, p60, p50, p40, p30, p20 (higher is better) +**Games Played:** 1-6 games in current series +**Roll:** 3d6 (results from 3-18) + +### Rating Availability by Games Played + +Not all ratings are available for all games played combinations: + +- **1 game**: All ratings (p70-p20) +- **2 games**: All ratings (p70-p20) +- **3 games**: p65-p20 (p70 exempt) +- **4 games**: p60-p20 (p70, p65 exempt) +- **5 games**: p60-p20 (p70, p65 exempt) +- **6 games**: p40-p20 (p70, p65, p60, p50 exempt) + +When a rating/games combination has no table, the result is automatically "OK" (no injury). + +### Example Table (p65, 1 game): + +| Roll | Result | +|------|--------| +| 3 | 2 | +| 4 | 2 | +| 5 | OK | +| 6 | REM | +| 7 | 1 | +| ... | ... | +| 18 | 12 | + +## UI/UX Design + +### Embed Colors + +- **Roll (OK):** Green - No injury +- **Roll (REM):** Gold - Remainder of game/Fatigued +- **Roll (Injury):** Orange - Number of games +- **Set New:** Success (green) - `EmbedTemplate.success()` +- **Clear:** Success (green) - `EmbedTemplate.success()` +- **Errors:** Error (red) - `EmbedTemplate.error()` + +### Response Format + +All successful responses use Discord embeds with: +- Clear title indicating action/status +- Well-organized field layout +- Team information when applicable +- Consistent formatting for dates + +## Integration with Player Model + +The Player model includes injury-related fields: + +```python +class Player(SBABaseModel): + # ... other fields ... + pitcher_injury: Optional[int] # Pitcher injury rating + injury_rating: Optional[str] # General injury rating + il_return: Optional[str] # Injured list return date (w##g#) +``` + +When an injury is set or cleared, the player's `il_return` field is automatically updated via PlayerService. + +## Future Enhancements + +Possible improvements for future versions: + +1. **Injury History** - View player's injury history for a season +2. **Team Injury Report** - List all injuries for a team +3. **Injury Notifications** - Automatic notifications when players return from injury +4. **Injury Statistics** - Track injury trends and statistics +5. **Injury Chart Image** - Display the official injury chart as an embed image + +## Migration from Legacy + +### Legacy Commands + +The legacy injury commands were located in: +- `discord-app/cogs/players.py` - `set_injury_slash()` and `clear_injury_slash()` +- `discord-app/cogs/players.py` - `injury_roll_slash()` with manual rating/games input + +### Key Improvements + +1. **Cleaner Command Structure:** Using GroupCog for organized subcommands (`/injury roll`, `/injury set-new`, `/injury clear`) +2. **Simplified Interface:** Single parameter for injury roll - games played automatically extracted from player data +3. **Smart Injury Ratings:** Automatically reads and parses player's injury rating from database +4. **Player Autocomplete:** Modern autocomplete with team prioritization for better UX +5. **Better Error Handling:** User-friendly error messages via EmbedTemplate with format validation +6. **Improved Logging:** Automatic logging via @logged_command decorator +7. **Service Layer:** Separated business logic from command handlers +8. **Type Safety:** Full type hints and Pydantic models +9. **Testability:** Comprehensive unit tests (26 tests) with mocked API calls +10. **Modern UI:** Consistent embed-based responses with color coding +11. **Official Tables:** Complete Strat-o-Matic injury tables built into the command + +### Migration Details + +**Old:** `/injuryroll ` - Manual rating and games selection +**New:** `/injury roll ` - Single parameter, automatic rating and games extraction from player's `injury_rating` field + +**Old:** `/setinjury ` +**New:** `/injury set-new ` - Same functionality, better naming + +**Old:** `/clearinjury ` +**New:** `/injury clear ` - Same functionality, better naming + +### Database Field Update + +The `injury_rating` field format has changed to include games played: +- **Old Format**: `p65`, `p70`, etc. (rating only) +- **New Format**: `1p70`, `4p50`, `2p65`, etc. (games + rating) + +Players must have their `injury_rating` field updated to the new format for the `/injury roll` command to work. + +--- + +**Last Updated:** January 2025 +**Version:** 2.0 +**Status:** Active diff --git a/commands/injuries/__init__.py b/commands/injuries/__init__.py new file mode 100644 index 0000000..6a2a47a --- /dev/null +++ b/commands/injuries/__init__.py @@ -0,0 +1,34 @@ +""" +Injury commands package + +Provides commands for managing player injuries: +- /injury roll - Roll for injury using player's injury rating +- /injury set-new - Set a new injury for a player +- /injury clear - Clear a player's injury +""" +from discord.ext import commands +from .management import setup + +__all__ = ['setup_injuries'] + + +async def setup_injuries(bot: commands.Bot) -> tuple[int, int, list[str]]: + """ + Setup function for loading injury commands. + + Returns: + Tuple of (successful_count, failed_count, failed_module_names) + """ + successful = 0 + failed = 0 + failed_modules = [] + + try: + await setup(bot) + successful += 1 + except Exception as e: + bot.logger.error(f"Failed to load InjuryGroup: {e}") + failed += 1 + failed_modules.append('InjuryGroup') + + return successful, failed, failed_modules diff --git a/commands/injuries/management.py b/commands/injuries/management.py new file mode 100644 index 0000000..948aaa6 --- /dev/null +++ b/commands/injuries/management.py @@ -0,0 +1,534 @@ +""" +Injury management slash commands for Discord Bot v2.0 + +Modern implementation for player injury tracking with three subcommands: +- /injury roll - Roll for injury using player's injury rating (format: #p## e.g., 1p70, 4p50) +- /injury set-new - Set a new injury for a player +- /injury clear - Clear a player's active injury + +The injury rating format (#p##) encodes both games played and rating: +- First character: Games played in series (1-6) +- Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20) +""" +import math +import random +import discord +from discord import app_commands +from discord.ext import commands + +from config import get_config +from services.player_service import player_service +from services.injury_service import injury_service +from services.league_service import league_service +from services.giphy_service import GiphyService +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from utils.autocomplete import player_autocomplete +from views.embeds import EmbedTemplate +from exceptions import BotException + + +class InjuryGroup(app_commands.Group): + """Injury management command group with roll, set-new, and clear subcommands.""" + + def __init__(self): + super().__init__( + name="injury", + description="Injury management commands" + ) + self.logger = get_contextual_logger(f'{__name__}.InjuryGroup') + self.logger.info("InjuryGroup initialized") + + def has_player_role(self, interaction: discord.Interaction) -> bool: + """Check if user has the SBA Players role.""" + # Cast to Member to access roles (User doesn't have roles attribute) + if not isinstance(interaction.user, discord.Member): + return False + + player_role = discord.utils.get( + interaction.guild.roles, + name=get_config().sba_players_role_name + ) + return player_role in interaction.user.roles if player_role else False + + @app_commands.command(name="roll", description="Roll for injury based on player's injury rating") + @app_commands.describe(player_name="Player name") + @app_commands.autocomplete(player_name=player_autocomplete) + @logged_command("/injury roll") + async def injury_roll(self, interaction: discord.Interaction, player_name: str): + """Roll for injury using 3d6 dice and injury tables.""" + await interaction.response.defer() + + # Get current season + current = await league_service.get_current_state() + if not current: + raise BotException("Failed to get current season information") + + # Search for player + players = await player_service.get_players_by_name(player_name, current.season) + + if not players: + embed = EmbedTemplate.error( + title="Player Not Found", + description=f"I did not find anybody named **{player_name}**." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + player = players[0] + + # Check for injury_rating field + if not player.injury_rating: + embed = EmbedTemplate.error( + title="No Injury Rating", + description=f"{player.name} does not have an injury rating set." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Parse injury_rating format: "1p70" where first char is games_played, rest is rating + try: + games_played = int(player.injury_rating[0]) + injury_rating = player.injury_rating[1:] + + # Validate games_played range + if games_played < 1 or games_played > 6: + raise ValueError("Games played must be between 1 and 6") + + # Validate rating format (should start with 'p') + if not injury_rating.startswith('p'): + raise ValueError("Invalid rating format") + + except (ValueError, IndexError): + embed = EmbedTemplate.error( + title="Invalid Injury Rating Format", + description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)" + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Roll 3d6 + d1 = random.randint(1, 6) + d2 = random.randint(1, 6) + d3 = random.randint(1, 6) + roll_total = d1 + d2 + d3 + + # Get injury result from table + injury_result = self._get_injury_result(injury_rating, games_played, roll_total) + + # Create response embed + embed = EmbedTemplate.warning( + title=f"Injury roll for {interaction.user.name}" + ) + if player.team.thumbnail is not None: + embed.set_thumbnail(url=player.team.thumbnail) + + embed.add_field( + name="Player", + value=f"{player.name} ({player.primary_position})", + inline=True + ) + + embed.add_field( + name="Injury Rating", + value=f"{player.injury_rating}", + inline=True + ) + + # embed.add_field(name='', value='', inline=False) # Embed line break + + # Format dice roll in markdown (same format as /ab roll) + dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```" + embed.add_field( + name="Dice Roll", + value=dice_result, + inline=False + ) + + # Format result + if isinstance(injury_result, int): + result_text = f"**{injury_result} game{'s' if injury_result > 1 else ''}**" + embed.color = discord.Color.orange() + if injury_result > 6: + gif_search_text = ['well shit', 'well fuck', 'god dammit'] + else: + gif_search_text = ['bummer', 'well damn'] + elif injury_result == 'REM': + if player.is_pitcher: + result_text = '**FATIGUED**' + embed.set_footer(text='For pitchers, add their current rest to the injury') + else: + result_text = "**REMAINDER OF GAME**" + embed.color = discord.Color.gold() + gif_search_text = ['this is fine', 'not even mad', 'could be worse'] + else: # 'OK' + result_text = "**No injury!**" + embed.color = discord.Color.green() + gif_search_text = ['we are so back', 'all good', 'totally fine'] + + # embed.add_field(name='', value='', inline=False) + + embed.add_field( + name="Injury Length", + value=result_text, + inline=True + ) + + try: + injury_gif = await GiphyService().get_gif( + phrase_options=gif_search_text + ) + except Exception: + injury_gif = '' + + embed.set_image(url=injury_gif) + + await interaction.followup.send(embed=embed) + + def _get_injury_result(self, rating: str, games_played: int, roll: int): + """ + Get injury result from the injury table. + + Args: + rating: Injury rating (e.g., 'p70', 'p65', etc.) + games_played: Number of games played (1-6) + roll: 3d6 roll result (3-18) + + Returns: + Injury result: int (games), 'REM', or 'OK' + """ + # Injury table mapping + inj_data = { + 'one': { + 'p70': ['OK', 'OK', 'OK', 'OK', 'OK', 'OK', 'REM', 'REM', 1, 1, 2, 2, 3, 3, 4, 4], + 'p65': [2, 2, 'OK', 'REM', 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], + 'p60': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], + 'p50': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, 'OK'], + 'p40': ['OK', 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, 'REM', 'OK'], + 'p30': ['OK', 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, 'REM', 'OK'], + 'p20': ['OK', 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, 'REM', 'OK'] + }, + 'two': { + 'p70': [4, 3, 2, 2, 1, 1, 'REM', 'OK', 'REM', 'OK', 2, 1, 2, 2, 3, 4], + 'p65': [8, 5, 4, 2, 2, 'OK', 1, 'OK', 'REM', 1, 'REM', 2, 3, 4, 6, 12], + 'p60': [1, 3, 4, 5, 2, 2, 'OK', 1, 3, 'REM', 4, 4, 6, 8, 12, 3], + 'p50': [4, 'OK', 'OK', 'REM', 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, 'OK'], + 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, 'OK'], + 'p30': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 6, 5, 8, 12, 16, 24, 'REM', 'OK'], + 'p20': ['OK', 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, 'REM'] + }, + 'three': { + 'p70': [], + 'p65': ['OK', 'OK', 'REM', 1, 3, 'OK', 'REM', 1, 2, 1, 2, 3, 4, 4, 5, 'REM'], + 'p60': ['OK', 5, 'OK', 'REM', 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, 'REM'], + 'p50': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], + 'p40': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], + 'p30': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, 'REM'], + 'p20': ['OK', 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, 'REM'] + }, + 'four': { + 'p70': [], + 'p65': [], + 'p60': ['OK', 'OK', 'REM', 3, 3, 'OK', 'REM', 1, 2, 1, 4, 4, 5, 6, 8, 'REM'], + 'p50': ['OK', 6, 4, 'OK', 'REM', 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, 'REM'], + 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], + 'p30': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], + 'p20': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, 'REM'] + }, + 'five': { + 'p70': [], + 'p65': [], + 'p60': ['OK', 'REM', 'REM', 'REM', 3, 'OK', 1, 'REM', 2, 1, 'OK', 4, 5, 2, 6, 8], + 'p50': ['OK', 'OK', 'REM', 1, 1, 'OK', 'REM', 3, 2, 4, 4, 5, 5, 6, 8, 12], + 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 12, 1], + 'p30': ['OK', 'OK', 'REM', 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, 'REM'], + 'p20': ['OK', 'REM', 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, 'REM'] + }, + 'six': { + 'p70': [], + 'p65': [], + 'p60': [], + 'p50': [], + 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 1, 12], + 'p30': ['OK', 'OK', 'REM', 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, 'REM'], + 'p20': ['OK', 'REM', 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, 'REM'] + } + } + + # Map games_played to key + games_map = {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six'} + games_key = games_map.get(games_played) + + if not games_key: + return 'OK' + + # Get the injury table for this rating and games played + injury_table = inj_data.get(games_key, {}).get(rating, []) + + # If no table exists (e.g., p70 with 3+ games), no injury + if not injury_table: + return 'OK' + + # Get result from table (roll 3-18 maps to index 0-15) + table_index = roll - 3 + if 0 <= table_index < len(injury_table): + return injury_table[table_index] + + return 'OK' + + @app_commands.command(name="set-new", description="Set a new injury for a player (requires SBA Players role)") + @app_commands.describe( + player_name="Player name to injure", + this_week="Current week number", + this_game="Current game number (1-4)", + injury_games="Number of games player will be out" + ) + @logged_command("/injury set-new") + async def injury_set_new( + self, + interaction: discord.Interaction, + player_name: str, + this_week: int, + this_game: int, + injury_games: int + ): + """Set a new injury for a player on your team.""" + # Check role permissions + if not self.has_player_role(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"This command requires the **{get_config().sba_players_role_name}** role." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await interaction.response.defer() + + # Validate inputs + if this_game < 1 or this_game > 4: + embed = EmbedTemplate.error( + title="Invalid Input", + description="Game number must be between 1 and 4." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + if injury_games < 1: + embed = EmbedTemplate.error( + title="Invalid Input", + description="Injury duration must be at least 1 game." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Get current season + current = await league_service.get_current_state() + if not current: + raise BotException("Failed to get current season information") + + # Search for player + players = await player_service.get_players_by_name(player_name, current.season) + + if not players: + embed = EmbedTemplate.error( + title="Player Not Found", + description=f"I did not find anybody named **{player_name}**." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + player = players[0] + + # Check if player is on user's team + # Note: This assumes you have a function to get team by owner + # For now, we'll skip this check - you can add it if needed + # TODO: Add team ownership verification + + # Check if player already has an active injury + existing_injury = await injury_service.get_active_injury(player.id, current.season) + if existing_injury: + embed = EmbedTemplate.error( + title="Already Injured", + description=f"Hm. It looks like {player.name} is already hurt." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Calculate return date + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + # Adjust start date if injury starts after game 4 + start_week = this_week if this_game != 4 else this_week + 1 + start_game = this_game + 1 if this_game != 4 else 1 + + return_date = f'w{return_week:02d}g{return_game}' + + # Create injury record + injury = await injury_service.create_injury( + season=current.season, + player_id=player.id, + total_games=injury_games, + start_week=start_week, + start_game=start_game, + end_week=return_week, + end_game=return_game + ) + + if not injury: + embed = EmbedTemplate.error( + title="Error", + description="Well that didn't work. Failed to create injury record." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Update player's il_return field + await player_service.update_player(player.id, {'il_return': return_date}) + + # Success response + embed = EmbedTemplate.success( + title="Injury Recorded", + description=f"{player.name} has been placed on the injured list." + ) + + embed.add_field( + name="Player", + value=f"{player.name} ({player.pos_1})", + inline=True + ) + + embed.add_field( + name="Duration", + value=f"{injury_games} game{'s' if injury_games > 1 else ''}", + inline=True + ) + + embed.add_field( + name="Return Date", + value=return_date, + inline=True + ) + + if player.team: + embed.add_field( + name="Team", + value=f"{player.team.lname} ({player.team.abbrev})", + inline=False + ) + + await interaction.followup.send(embed=embed) + + # Log for debugging + self.logger.info( + f"Injury set for {player.name}: {injury_games} games, returns {return_date}", + player_id=player.id, + season=current.season, + injury_id=injury.id + ) + + @app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)") + @app_commands.describe(player_name="Player name to clear injury") + @logged_command("/injury clear") + async def injury_clear(self, interaction: discord.Interaction, player_name: str): + """Clear a player's active injury.""" + # Check role permissions + if not self.has_player_role(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"This command requires the **{get_config().sba_players_role_name}** role." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await interaction.response.defer() + + # Get current season + current = await league_service.get_current_state() + if not current: + raise BotException("Failed to get current season information") + + # Search for player + players = await player_service.get_players_by_name(player_name, current.season) + + if not players: + embed = EmbedTemplate.error( + title="Player Not Found", + description=f"I did not find anybody named **{player_name}**." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + player = players[0] + + # Get active injury + injury = await injury_service.get_active_injury(player.id, current.season) + + if not injury: + embed = EmbedTemplate.error( + title="No Active Injury", + description=f"{player.name} isn't injured." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Clear the injury + success = await injury_service.clear_injury(injury.id) + + if not success: + embed = EmbedTemplate.error( + title="Error", + description="Failed to clear the injury. Please try again." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Clear player's il_return field + await player_service.update_player(player.id, {'il_return': None}) + + # Success response + embed = EmbedTemplate.success( + title="Injury Cleared", + description=f"{player.name} has been cleared and is eligible to play again." + ) + + embed.add_field( + name="Previous Return Date", + value=injury.return_date, + inline=True + ) + + embed.add_field( + name="Total Games Missed", + value=injury.duration_display, + inline=True + ) + + if player.team: + embed.add_field( + name="Team", + value=f"{player.team.lname} ({player.team.abbrev})", + inline=False + ) + + await interaction.followup.send(embed=embed) + + # Log for debugging + self.logger.info( + f"Injury cleared for {player.name}", + player_id=player.id, + season=current.season, + injury_id=injury.id + ) + + +async def setup(bot: commands.Bot): + """Setup function for loading the injury commands.""" + bot.tree.add_command(InjuryGroup()) diff --git a/commands/soak/giphy_service.py b/commands/soak/giphy_service.py index aa44984..6b3fcca 100644 --- a/commands/soak/giphy_service.py +++ b/commands/soak/giphy_service.py @@ -1,151 +1,72 @@ """ -Giphy Service for Soak Easter Egg +Giphy Service Wrapper for Soak Commands -Provides async interface to Giphy API with disappointment-based search phrases. +This module provides backwards compatibility for existing soak commands +by re-exporting functions from the centralized GiphyService in services/. + +All new code should import from services.giphy_service instead. """ -import random -import logging -from typing import List, Optional -import aiohttp +from services import giphy_service -logger = logging.getLogger(f'{__name__}.GiphyService') - -# Giphy API configuration -GIPHY_API_KEY = 'H86xibttEuUcslgmMM6uu74IgLEZ7UOD' -GIPHY_TRANSLATE_URL = 'https://api.giphy.com/v1/gifs/translate' - -# Disappointment tier configuration -DISAPPOINTMENT_TIERS = { - 'tier_1': { - 'max_seconds': 1800, # 30 minutes - 'phrases': [ - "extremely disappointed", - "so disappointed", - "are you kidding me", - "seriously", - "unbelievable" - ], - 'description': "Maximum Disappointment" - }, - 'tier_2': { - 'max_seconds': 7200, # 2 hours - 'phrases': [ - "very disappointed", - "can't believe you", - "not happy", - "shame on you", - "facepalm" - ], - 'description': "Severe Disappointment" - }, - 'tier_3': { - 'max_seconds': 21600, # 6 hours - 'phrases': [ - "disappointed", - "not impressed", - "shaking head", - "eye roll", - "really" - ], - 'description': "Strong Disappointment" - }, - 'tier_4': { - 'max_seconds': 86400, # 24 hours - 'phrases': [ - "mildly disappointed", - "not great", - "could be better", - "sigh", - "seriously" - ], - 'description': "Moderate Disappointment" - }, - 'tier_5': { - 'max_seconds': 604800, # 7 days - 'phrases': [ - "slightly disappointed", - "oh well", - "shrug", - "meh", - "not bad" - ], - 'description': "Mild Disappointment" - }, - 'tier_6': { - 'max_seconds': float('inf'), # 7+ days - 'phrases': [ - "not disappointed", - "relieved", - "proud", - "been worse", - "fine i guess" - ], - 'description': "Minimal Disappointment" - }, - 'first_ever': { - 'phrases': [ - "here we go", - "oh boy", - "uh oh", - "getting started", - "and so it begins" - ], - 'description': "The Beginning" - } -} +# Re-export tier configuration for backwards compatibility +from services.giphy_service import DISAPPOINTMENT_TIERS -def get_tier_for_seconds(seconds_elapsed: Optional[int]) -> str: +def get_tier_for_seconds(seconds_elapsed): """ Determine disappointment tier based on elapsed time. + This is a wrapper function for backwards compatibility. + Use services.giphy_service.GiphyService.get_tier_for_seconds() directly in new code. + Args: seconds_elapsed: Seconds since last soak, or None for first ever Returns: Tier key string (e.g., 'tier_1', 'first_ever') """ - if seconds_elapsed is None: - return 'first_ever' - - for tier_key in ['tier_1', 'tier_2', 'tier_3', 'tier_4', 'tier_5', 'tier_6']: - if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]['max_seconds']: - return tier_key - - return 'tier_6' # Fallback to lowest disappointment + return giphy_service.get_tier_for_seconds(seconds_elapsed) -def get_random_phrase_for_tier(tier_key: str) -> str: +def get_random_phrase_for_tier(tier_key): """ Get a random search phrase from the specified tier. + This is a wrapper function for backwards compatibility. + Use services.giphy_service.GiphyService.get_random_phrase_for_tier() directly in new code. + Args: tier_key: Tier identifier (e.g., 'tier_1', 'first_ever') Returns: Random search phrase from that tier """ - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] - return random.choice(phrases) + return giphy_service.get_random_phrase_for_tier(tier_key) -def get_tier_description(tier_key: str) -> str: +def get_tier_description(tier_key): """ Get the human-readable description for a tier. + This is a wrapper function for backwards compatibility. + Use services.giphy_service.GiphyService.get_tier_description() directly in new code. + Args: tier_key: Tier identifier Returns: Description string """ - return DISAPPOINTMENT_TIERS[tier_key]['description'] + return giphy_service.get_tier_description(tier_key) -async def get_disappointment_gif(tier_key: str) -> Optional[str]: +async def get_disappointment_gif(tier_key): """ Fetch a GIF from Giphy based on disappointment tier. + This is a wrapper function for backwards compatibility. + Use services.giphy_service.GiphyService.get_disappointment_gif() directly in new code. + Randomly selects a search phrase from the tier and queries Giphy. Filters out Trump GIFs (legacy behavior). Falls back to trying other phrases if first fails. @@ -156,40 +77,8 @@ async def get_disappointment_gif(tier_key: str) -> Optional[str]: Returns: GIF URL string, or None if all attempts fail """ - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] - - # Shuffle phrases for variety and retry capability - shuffled_phrases = random.sample(phrases, len(phrases)) - - async with aiohttp.ClientSession() as session: - for phrase in shuffled_phrases: - try: - url = f"{GIPHY_TRANSLATE_URL}?s={phrase}&api_key={GIPHY_API_KEY}" - - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: - if resp.status == 200: - data = await resp.json() - - # Filter out Trump GIFs (legacy behavior) - gif_title = data.get('data', {}).get('title', '').lower() - if 'trump' in gif_title: - logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") - continue - - gif_url = data.get('data', {}).get('url') - if gif_url: - logger.info(f"Successfully fetched GIF for phrase: {phrase}") - return gif_url - else: - logger.warning(f"No GIF URL in response for phrase: {phrase}") - else: - logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}") - - except aiohttp.ClientError as e: - logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}") - except Exception as e: - logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}") - - # All phrases failed - logger.error(f"Failed to fetch any GIF for tier: {tier_key}") - return None + try: + return await giphy_service.get_disappointment_gif(tier_key) + except Exception: + # Return None for backwards compatibility with old error handling + return None diff --git a/config.py b/config.py index 51eb628..dbe0b61 100644 --- a/config.py +++ b/config.py @@ -68,6 +68,10 @@ class BotConfig(BaseSettings): # Google Sheets settings sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" + # Giphy API settings + giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" + giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" + # Optional Redis caching settings redis_url: str = "" # Empty string means no Redis caching redis_cache_ttl: int = 300 # 5 minutes default TTL diff --git a/models/injury.py b/models/injury.py new file mode 100644 index 0000000..29f18f8 --- /dev/null +++ b/models/injury.py @@ -0,0 +1,50 @@ +""" +Injury model for tracking player injuries + +Represents an injury record with game timeline and status information. +""" +from typing import Optional +from pydantic import Field + +from models.base import SBABaseModel + + +class Injury(SBABaseModel): + """Injury model representing a player injury.""" + + # Override base model to make id required for database entities + id: int = Field(..., description="Injury ID from database") + + season: int = Field(..., description="Season number") + player_id: int = Field(..., description="Player ID who is injured") + total_games: int = Field(..., description="Total games player will be out") + + # Injury timeline + start_week: int = Field(..., description="Week injury started") + start_game: int = Field(..., description="Game number injury started (1-4)") + end_week: int = Field(..., description="Week player returns") + end_game: int = Field(..., description="Game number player returns (1-4)") + + # Status + is_active: bool = Field(True, description="Whether injury is currently active") + + @property + def return_date(self) -> str: + """Format return date as 'w##g#' string.""" + return f'w{self.end_week:02d}g{self.end_game}' + + @property + def start_date(self) -> str: + """Format start date as 'w##g#' string.""" + return f'w{self.start_week:02d}g{self.start_game}' + + @property + def duration_display(self) -> str: + """Return a human-readable duration string.""" + if self.total_games == 1: + return "1 game" + return f"{self.total_games} games" + + def __str__(self): + status = "Active" if self.is_active else "Cleared" + return f"Injury (Season {self.season}, {self.duration_display}, {status})" diff --git a/services/__init__.py b/services/__init__.py index dc2ca19..f41f6ea 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -8,13 +8,18 @@ from .team_service import TeamService, team_service from .player_service import PlayerService, player_service from .league_service import LeagueService, league_service from .schedule_service import ScheduleService, schedule_service +from .giphy_service import GiphyService # Wire services together for dependency injection player_service._team_service = team_service +# Create global Giphy service instance +giphy_service = GiphyService() + __all__ = [ 'TeamService', 'team_service', 'PlayerService', 'player_service', 'LeagueService', 'league_service', - 'ScheduleService', 'schedule_service' + 'ScheduleService', 'schedule_service', + 'GiphyService', 'giphy_service' ] \ No newline at end of file diff --git a/services/giphy_service.py b/services/giphy_service.py new file mode 100644 index 0000000..6e5d0a1 --- /dev/null +++ b/services/giphy_service.py @@ -0,0 +1,284 @@ +""" +Giphy Service for Discord Bot v2.0 + +Provides async interface to Giphy API with disappointment-based search phrases. +Used for Easter egg features like the soak command. +""" +import random +from typing import List, Optional +import aiohttp + +from utils.logging import get_contextual_logger +from config import get_config +from exceptions import APIException + + +# Disappointment tier configuration +DISAPPOINTMENT_TIERS = { + 'tier_1': { + 'max_seconds': 1800, # 30 minutes + 'phrases': [ + "extremely disappointed", + "so disappointed", + "are you kidding me", + "seriously", + "unbelievable" + ], + 'description': "Maximum Disappointment" + }, + 'tier_2': { + 'max_seconds': 7200, # 2 hours + 'phrases': [ + "very disappointed", + "can't believe you", + "not happy", + "shame on you", + "facepalm" + ], + 'description': "Severe Disappointment" + }, + 'tier_3': { + 'max_seconds': 21600, # 6 hours + 'phrases': [ + "disappointed", + "not impressed", + "shaking head", + "eye roll", + "really" + ], + 'description': "Strong Disappointment" + }, + 'tier_4': { + 'max_seconds': 86400, # 24 hours + 'phrases': [ + "mildly disappointed", + "not great", + "could be better", + "sigh", + "seriously" + ], + 'description': "Moderate Disappointment" + }, + 'tier_5': { + 'max_seconds': 604800, # 7 days + 'phrases': [ + "slightly disappointed", + "oh well", + "shrug", + "meh", + "not bad" + ], + 'description': "Mild Disappointment" + }, + 'tier_6': { + 'max_seconds': float('inf'), # 7+ days + 'phrases': [ + "not disappointed", + "relieved", + "proud", + "been worse", + "fine i guess" + ], + 'description': "Minimal Disappointment" + }, + 'first_ever': { + 'phrases': [ + "here we go", + "oh boy", + "uh oh", + "getting started", + "and so it begins" + ], + 'description': "The Beginning" + } +} + + +class GiphyService: + """Service for fetching GIFs from Giphy API based on disappointment tiers.""" + + def __init__(self): + """Initialize Giphy service with configuration.""" + self.config = get_config() + self.api_key = self.config.giphy_api_key + self.translate_url = self.config.giphy_translate_url + self.logger = get_contextual_logger(f'{__name__}.GiphyService') + + def get_tier_for_seconds(self, seconds_elapsed: Optional[int]) -> str: + """ + Determine disappointment tier based on elapsed time. + + Args: + seconds_elapsed: Seconds since last soak, or None for first ever + + Returns: + Tier key string (e.g., 'tier_1', 'first_ever') + """ + if seconds_elapsed is None: + return 'first_ever' + + for tier_key in ['tier_1', 'tier_2', 'tier_3', 'tier_4', 'tier_5', 'tier_6']: + if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]['max_seconds']: + return tier_key + + return 'tier_6' # Fallback to lowest disappointment + + def get_random_phrase_for_tier(self, tier_key: str) -> str: + """ + Get a random search phrase from the specified tier. + + Args: + tier_key: Tier identifier (e.g., 'tier_1', 'first_ever') + + Returns: + Random search phrase from that tier + + Raises: + ValueError: If tier_key is invalid + """ + if tier_key not in DISAPPOINTMENT_TIERS: + raise ValueError(f"Invalid tier key: {tier_key}") + + phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + return random.choice(phrases) + + def get_tier_description(self, tier_key: str) -> str: + """ + Get the human-readable description for a tier. + + Args: + tier_key: Tier identifier + + Returns: + Description string + + Raises: + ValueError: If tier_key is invalid + """ + if tier_key not in DISAPPOINTMENT_TIERS: + raise ValueError(f"Invalid tier key: {tier_key}") + + return DISAPPOINTMENT_TIERS[tier_key]['description'] + + async def get_disappointment_gif(self, tier_key: str) -> str: + """ + Fetch a GIF from Giphy based on disappointment tier. + + Randomly selects a search phrase from the tier and queries Giphy. + Filters out Trump GIFs (legacy behavior). + Falls back to trying other phrases if first fails. + + Args: + tier_key: Tier identifier (e.g., 'tier_1', 'first_ever') + + Returns: + GIF URL string + + Raises: + ValueError: If tier_key is invalid + APIException: If all GIF fetch attempts fail + """ + if tier_key not in DISAPPOINTMENT_TIERS: + raise ValueError(f"Invalid tier key: {tier_key}") + + phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + + # Shuffle phrases for variety and retry capability + shuffled_phrases = random.sample(phrases, len(phrases)) + + async with aiohttp.ClientSession() as session: + for phrase in shuffled_phrases: + try: + url = f"{self.translate_url}?s={phrase}&api_key={self.api_key}" + + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + if resp.status == 200: + data = await resp.json() + + # Filter out Trump GIFs (legacy behavior) + gif_title = data.get('data', {}).get('title', '').lower() + if 'trump' in gif_title: + self.logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") + continue + + # Get the actual GIF image URL, not the web page URL + gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + if gif_url: + self.logger.info(f"Successfully fetched GIF for phrase: {phrase}", gif_url=gif_url) + return gif_url + else: + self.logger.warning(f"No GIF URL in response for phrase: {phrase}") + else: + self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}") + + except aiohttp.ClientError as e: + self.logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}") + except Exception as e: + self.logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}") + + # All phrases failed + error_msg = f"Failed to fetch any GIF for tier: {tier_key}" + self.logger.error(error_msg) + raise APIException(error_msg) + + async def get_gif(self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None) -> str: + """ + Fetch a GIF from Giphy based on a phrase or list of phrase options. + + Args: + phrase: Specific search phrase to use + phrase_options: List of phrases to randomly choose from + + Returns: + GIF URL string + + Raises: + ValueError: If neither phrase nor phrase_options is provided + APIException: If all GIF fetch attempts fail + """ + if phrase is None and phrase_options is None: + raise ValueError('To get a gif, one of `phrase` or `phrase_options` must be provided') + + search_phrase = 'send help' + if phrase is not None: + search_phrase = phrase + elif phrase_options is not None: + search_phrase = random.choice(phrase_options) + + async with aiohttp.ClientSession() as session: + attempts = 0 + while attempts < 3: + attempts += 1 + try: + url = f"{self.translate_url}?s={search_phrase}&api_key={self.api_key}" + + async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp: + if resp.status != 200: + self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {search_phrase}") + continue + + data = await resp.json() + + # Filter out Trump GIFs (legacy behavior) + gif_title = data.get('data', {}).get('title', '').lower() + if 'trump' in gif_title: + self.logger.debug(f"Filtered out Trump GIF for phrase: {search_phrase}") + continue + + # Get the actual GIF image URL, not the web page URL + gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + if gif_url: + self.logger.info(f"Successfully fetched GIF for phrase: {search_phrase}", gif_url=gif_url) + return gif_url + else: + self.logger.warning(f"No GIF URL in response for phrase: {search_phrase}") + + except aiohttp.ClientError as e: + self.logger.error(f"HTTP error fetching GIF for phrase '{search_phrase}': {e}") + except Exception as e: + self.logger.error(f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}") + + # All attempts failed + error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}" + self.logger.error(error_msg) + raise APIException(error_msg) diff --git a/services/injury_service.py b/services/injury_service.py new file mode 100644 index 0000000..7a0e2e5 --- /dev/null +++ b/services/injury_service.py @@ -0,0 +1,196 @@ +""" +Injury service for Discord Bot v2.0 + +Handles injury-related operations including checking, creating, and clearing injuries. +""" +import logging +from typing import Optional, List + +from services.base_service import BaseService +from models.injury import Injury +from exceptions import APIException + +logger = logging.getLogger(f'{__name__}.InjuryService') + + +class InjuryService(BaseService[Injury]): + """ + Service for injury-related operations. + + Features: + - Get active injuries for a player + - Create new injury records + - Clear active injuries + - Season-specific filtering + """ + + def __init__(self): + """Initialize injury service.""" + super().__init__(Injury, 'injuries') + logger.debug("InjuryService initialized") + + async def get_active_injury(self, player_id: int, season: int) -> Optional[Injury]: + """ + Get the active injury for a player in a specific season. + + Args: + player_id: Player identifier + season: Season number + + Returns: + Active Injury instance or None if player has no active injury + """ + try: + params = [ + ('player_id', str(player_id)), + ('season', str(season)), + ('is_active', 'true') + ] + + injuries = await self.get_all_items(params=params) + + if injuries: + logger.debug(f"Found active injury for player {player_id} in season {season}") + return injuries[0] + + logger.debug(f"No active injury found for player {player_id} in season {season}") + return None + + except Exception as e: + logger.error(f"Error getting active injury for player {player_id}: {e}") + return None + + async def get_injuries_by_player(self, player_id: int, season: int, active_only: bool = False) -> List[Injury]: + """ + Get all injuries for a player in a specific season. + + Args: + player_id: Player identifier + season: Season number + active_only: If True, only return active injuries + + Returns: + List of injuries for the player + """ + try: + params = [ + ('player_id', str(player_id)), + ('season', str(season)) + ] + + if active_only: + params.append(('is_active', 'true')) + + injuries = await self.get_all_items(params=params) + logger.debug(f"Retrieved {len(injuries)} injuries for player {player_id}") + return injuries + + except Exception as e: + logger.error(f"Error getting injuries for player {player_id}: {e}") + return [] + + async def get_injuries_by_team(self, team_id: int, season: int, active_only: bool = True) -> List[Injury]: + """ + Get all injuries for a team in a specific season. + + Args: + team_id: Team identifier + season: Season number + active_only: If True, only return active injuries + + Returns: + List of injuries for the team + """ + try: + params = [ + ('team_id', str(team_id)), + ('season', str(season)) + ] + + if active_only: + params.append(('is_active', 'true')) + + injuries = await self.get_all_items(params=params) + logger.debug(f"Retrieved {len(injuries)} injuries for team {team_id}") + return injuries + + except Exception as e: + logger.error(f"Error getting injuries for team {team_id}: {e}") + return [] + + async def create_injury( + self, + season: int, + player_id: int, + total_games: int, + start_week: int, + start_game: int, + end_week: int, + end_game: int + ) -> Optional[Injury]: + """ + Create a new injury record. + + Args: + season: Season number + player_id: Player identifier + total_games: Total games player will be out + start_week: Week injury started + start_game: Game number injury started (1-4) + end_week: Week player returns + end_game: Game number player returns (1-4) + + Returns: + Created Injury instance or None on failure + """ + try: + injury_data = { + 'season': season, + 'player_id': player_id, + 'total_games': total_games, + 'start_week': start_week, + 'start_game': start_game, + 'end_week': end_week, + 'end_game': end_game, + 'is_active': True + } + + injury = await self.create(injury_data) + if injury: + logger.info(f"Created injury for player {player_id}: {total_games} games") + return injury + + logger.error(f"Failed to create injury for player {player_id}") + return None + + except Exception as e: + logger.error(f"Error creating injury for player {player_id}: {e}") + return None + + async def clear_injury(self, injury_id: int) -> bool: + """ + Clear (deactivate) an injury. + + Args: + injury_id: Injury identifier + + Returns: + True if successfully cleared, False otherwise + """ + try: + updated_injury = await self.patch(injury_id, {'is_active': False}) + + if updated_injury: + logger.info(f"Cleared injury {injury_id}") + return True + + logger.error(f"Failed to clear injury {injury_id}") + return False + + except Exception as e: + logger.error(f"Error clearing injury {injury_id}: {e}") + return False + + +# Global service instance +injury_service = InjuryService() diff --git a/tests/test_services_injury.py b/tests/test_services_injury.py new file mode 100644 index 0000000..a40de60 --- /dev/null +++ b/tests/test_services_injury.py @@ -0,0 +1,507 @@ +""" +Unit tests for InjuryService. + +Tests cover: +- Getting active injuries for a player +- Creating injury records +- Clearing injuries +- Team-based injury queries +""" +import pytest +from aioresponses import aioresponses +from unittest.mock import AsyncMock, patch, MagicMock + +from services.injury_service import InjuryService +from models.injury import Injury + + +@pytest.fixture +def mock_config(): + """Mock configuration for testing.""" + config = MagicMock() + config.db_url = "https://api.example.com" + config.api_token = "test-token" + return config + + +@pytest.fixture +def injury_service(): + """Create an InjuryService instance for testing.""" + return InjuryService() + + +@pytest.fixture +def sample_injury_data(): + """Sample injury data from API.""" + return { + 'id': 1, + 'season': 12, + 'player_id': 123, + 'total_games': 4, + 'start_week': 5, + 'start_game': 2, + 'end_week': 6, + 'end_game': 2, + 'is_active': True + } + + +@pytest.fixture +def multiple_injuries_data(): + """Multiple injury records.""" + return [ + { + 'id': 1, + 'season': 12, + 'player_id': 123, + 'total_games': 4, + 'start_week': 5, + 'start_game': 2, + 'end_week': 6, + 'end_game': 2, + 'is_active': True + }, + { + 'id': 2, + 'season': 12, + 'player_id': 456, + 'total_games': 2, + 'start_week': 4, + 'start_game': 3, + 'end_week': 5, + 'end_game': 1, + 'is_active': False + } + ] + + +class TestInjuryModel: + """Tests for Injury model.""" + + def test_injury_model_creation(self, sample_injury_data): + """Test creating an Injury instance.""" + injury = Injury(**sample_injury_data) + + assert injury.id == 1 + assert injury.season == 12 + assert injury.player_id == 123 + assert injury.total_games == 4 + assert injury.is_active is True + + def test_return_date_property(self, sample_injury_data): + """Test return_date formatted property.""" + injury = Injury(**sample_injury_data) + + assert injury.return_date == 'w06g2' + + def test_start_date_property(self, sample_injury_data): + """Test start_date formatted property.""" + injury = Injury(**sample_injury_data) + + assert injury.start_date == 'w05g2' + + def test_duration_display_singular(self): + """Test duration display for 1 game.""" + injury = Injury( + id=1, + season=12, + player_id=123, + total_games=1, + start_week=5, + start_game=2, + end_week=5, + end_game=3, + is_active=True + ) + + assert injury.duration_display == "1 game" + + def test_duration_display_plural(self, sample_injury_data): + """Test duration display for multiple games.""" + injury = Injury(**sample_injury_data) + + assert injury.duration_display == "4 games" + + +class TestInjuryService: + """Tests for InjuryService.""" + + @pytest.mark.asyncio + async def test_get_active_injury_found(self, mock_config, injury_service, sample_injury_data): + """Test getting active injury when one exists.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.get( + 'https://api.example.com/v3/injuries?player_id=123&season=12&is_active=true', + payload={ + 'count': 1, + 'injuries': [sample_injury_data] + } + ) + + injury = await injury_service.get_active_injury(123, 12) + + assert injury is not None + assert injury.id == 1 + assert injury.player_id == 123 + assert injury.is_active is True + + @pytest.mark.asyncio + async def test_get_active_injury_not_found(self, mock_config, injury_service): + """Test getting active injury when none exists.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.get( + 'https://api.example.com/v3/injuries?player_id=123&season=12&is_active=true', + payload={ + 'count': 0, + 'injuries': [] + } + ) + + injury = await injury_service.get_active_injury(123, 12) + + assert injury is None + + @pytest.mark.asyncio + async def test_get_injuries_by_player(self, mock_config, injury_service, multiple_injuries_data): + """Test getting all injuries for a player.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.get( + 'https://api.example.com/v3/injuries?player_id=123&season=12', + payload={ + 'count': 1, + 'injuries': [multiple_injuries_data[0]] + } + ) + + injuries = await injury_service.get_injuries_by_player(123, 12) + + assert len(injuries) == 1 + assert injuries[0].player_id == 123 + + @pytest.mark.asyncio + async def test_get_injuries_by_player_active_only(self, mock_config, injury_service, sample_injury_data): + """Test getting only active injuries for a player.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.get( + 'https://api.example.com/v3/injuries?player_id=123&season=12&is_active=true', + payload={ + 'count': 1, + 'injuries': [sample_injury_data] + } + ) + + injuries = await injury_service.get_injuries_by_player(123, 12, active_only=True) + + assert len(injuries) == 1 + assert injuries[0].is_active is True + + @pytest.mark.asyncio + async def test_get_injuries_by_team(self, mock_config, injury_service, multiple_injuries_data): + """Test getting injuries for a team.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.get( + 'https://api.example.com/v3/injuries?team_id=10&season=12&is_active=true', + payload={ + 'count': 2, + 'injuries': multiple_injuries_data + } + ) + + injuries = await injury_service.get_injuries_by_team(10, 12) + + assert len(injuries) == 2 + + @pytest.mark.asyncio + async def test_create_injury(self, mock_config, injury_service, sample_injury_data): + """Test creating a new injury record.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.post( + 'https://api.example.com/v3/injuries', + payload=sample_injury_data + ) + + injury = await injury_service.create_injury( + season=12, + player_id=123, + total_games=4, + start_week=5, + start_game=2, + end_week=6, + end_game=2 + ) + + assert injury is not None + assert injury.player_id == 123 + assert injury.total_games == 4 + + @pytest.mark.asyncio + async def test_clear_injury(self, mock_config, injury_service, sample_injury_data): + """Test clearing an injury.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + # Mock the PATCH request (note: patch sends data in body, not URL) + cleared_data = sample_injury_data.copy() + cleared_data['is_active'] = False + + m.patch( + 'https://api.example.com/v3/injuries/1', + payload=cleared_data + ) + + success = await injury_service.clear_injury(1) + + assert success is True + + @pytest.mark.asyncio + async def test_clear_injury_failure(self, mock_config, injury_service): + """Test clearing injury when it fails.""" + with patch('api.client.get_config', return_value=mock_config): + with aioresponses() as m: + m.patch( + 'https://api.example.com/v3/injuries/1', + status=500 + ) + + success = await injury_service.clear_injury(1) + + assert success is False + + +class TestInjuryRollLogic: + """Tests for injury roll dice and table logic.""" + + def test_injury_rating_parsing_valid(self): + """Test parsing valid injury rating format.""" + # Format: "1p70" -> games_played=1, rating="p70" + injury_rating = "1p70" + games_played = int(injury_rating[0]) + rating = injury_rating[1:] + + assert games_played == 1 + assert rating == "p70" + + # Test other formats + injury_rating = "4p50" + games_played = int(injury_rating[0]) + rating = injury_rating[1:] + + assert games_played == 4 + assert rating == "p50" + + def test_injury_rating_parsing_invalid(self): + """Test parsing invalid injury rating format.""" + import pytest + + # Missing games number + with pytest.raises((ValueError, IndexError)): + injury_rating = "p70" + games_played = int(injury_rating[0]) + + # Invalid games number + injury_rating = "7p70" + games_played = int(injury_rating[0]) + assert games_played > 6 # Should be caught by validation + + # Empty string + with pytest.raises(IndexError): + injury_rating = "" + games_played = int(injury_rating[0]) + + def test_injury_table_lookup_ok_result(self): + """Test injury table lookup returning OK.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # p70 rating with 1 game played, roll of 3 should be OK + result = cog._get_injury_result('p70', 1, 3) + assert result == 'OK' + + def test_injury_table_lookup_rem_result(self): + """Test injury table lookup returning REM.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # p70 rating with 1 game played, roll of 9 should be REM + result = cog._get_injury_result('p70', 1, 9) + assert result == 'REM' + + def test_injury_table_lookup_games_result(self): + """Test injury table lookup returning number of games.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # p70 rating with 1 game played, roll of 11 should be 1 game + result = cog._get_injury_result('p70', 1, 11) + assert result == 1 + + # p65 rating with 1 game played, roll of 3 should be 2 games + result = cog._get_injury_result('p65', 1, 3) + assert result == 2 + + def test_injury_table_no_table_exists(self): + """Test injury table when no table exists for rating/games combo.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # p70 rating with 3 games played has no table, should return OK + result = cog._get_injury_result('p70', 3, 10) + assert result == 'OK' + + def test_injury_table_roll_out_of_range(self): + """Test injury table with out of range roll.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # Roll less than 3 or greater than 18 should return OK + result = cog._get_injury_result('p65', 1, 2) + assert result == 'OK' + + result = cog._get_injury_result('p65', 1, 19) + assert result == 'OK' + + def test_injury_table_games_played_mapping(self): + """Test games played maps correctly to table keys.""" + from commands.injuries.management import InjuryCog + from unittest.mock import MagicMock + + cog = InjuryCog(MagicMock()) + + # Test that different games_played values access different tables + result_1_game = cog._get_injury_result('p65', 1, 10) + result_2_games = cog._get_injury_result('p65', 2, 10) + + # These should potentially be different values (depends on tables) + # Just verify both execute without error + assert result_1_game is not None + assert result_2_games is not None + + +class TestInjuryCalculations: + """Tests for injury date calculation logic (as used in commands).""" + + def test_simple_injury_calculation(self): + """Test injury return date calculation for 1 game.""" + import math + + this_week = 5 + this_game = 1 + injury_games = 1 + + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + assert return_week == 5 + assert return_game == 3 + + def test_multi_game_injury_same_week(self): + """Test injury spanning multiple games in same week.""" + import math + + this_week = 5 + this_game = 1 + injury_games = 2 + + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + assert return_week == 5 + assert return_game == 4 + + def test_injury_crossing_week_boundary(self): + """Test injury that crosses into next week.""" + import math + + this_week = 5 + this_game = 3 + injury_games = 3 + + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + assert return_week == 6 + assert return_game == 3 + + def test_multi_week_injury(self): + """Test injury spanning multiple weeks.""" + import math + + this_week = 5 + this_game = 2 + injury_games = 8 # 2 full weeks + + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + assert return_week == 7 + assert return_game == 3 + + def test_injury_from_game_4(self): + """Test injury starting from last game of week.""" + import math + + this_week = 5 + this_game = 4 + injury_games = 2 + + # Special handling for injuries starting after game 4 + start_week = this_week if this_game != 4 else this_week + 1 + start_game = this_game + 1 if this_game != 4 else 1 + + out_weeks = math.floor(injury_games / 4) + out_games = injury_games % 4 + + return_week = this_week + out_weeks + return_game = this_game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + assert start_week == 6 + assert start_game == 1 + assert return_week == 6 + assert return_game == 3