CLAUDE: Fix GroupCog interaction bug and GIF display issues
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 <noreply@anthropic.com>
This commit is contained in:
parent
2926664d2d
commit
0c6f7c8ffe
@ -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
|
||||
|
||||
494
commands/injuries/README.md
Normal file
494
commands/injuries/README.md
Normal file
@ -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 <player_name>
|
||||
```
|
||||
|
||||
**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 <player_name> <this_week> <this_game> <injury_games>
|
||||
```
|
||||
|
||||
**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 <player_name>
|
||||
```
|
||||
|
||||
**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 <rating> <games>` - Manual rating and games selection
|
||||
**New:** `/injury roll <player>` - Single parameter, automatic rating and games extraction from player's `injury_rating` field
|
||||
|
||||
**Old:** `/setinjury <player> <week> <game> <duration>`
|
||||
**New:** `/injury set-new <player> <week> <game> <duration>` - Same functionality, better naming
|
||||
|
||||
**Old:** `/clearinjury <player>`
|
||||
**New:** `/injury clear <player>` - 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
|
||||
34
commands/injuries/__init__.py
Normal file
34
commands/injuries/__init__.py
Normal file
@ -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
|
||||
534
commands/injuries/management.py
Normal file
534
commands/injuries/management.py
Normal file
@ -0,0 +1,534 @@
|
||||
"""
|
||||
Injury management slash commands for Discord Bot v2.0
|
||||
|
||||
Modern implementation for player injury tracking with three subcommands:
|
||||
- /injury roll <player> - 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())
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
50
models/injury.py
Normal file
50
models/injury.py
Normal file
@ -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})"
|
||||
@ -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'
|
||||
]
|
||||
284
services/giphy_service.py
Normal file
284
services/giphy_service.py
Normal file
@ -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)
|
||||
196
services/injury_service.py
Normal file
196
services/injury_service.py
Normal file
@ -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()
|
||||
507
tests/test_services_injury.py
Normal file
507
tests/test_services_injury.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user