Merge pull request #12 from calcorum/dev-daily

Dev daily
This commit is contained in:
Cal Corum 2025-10-29 01:23:39 -05:00 committed by GitHub
commit 4abbb8e6b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1136 additions and 122 deletions

View File

@ -4,9 +4,7 @@ Dice Rolling Commands
Implements slash commands for dice rolling functionality required for gameplay.
"""
import random
import re
from typing import Optional
from dataclasses import dataclass
import discord
from discord.ext import commands
@ -18,6 +16,7 @@ from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.team_utils import get_user_major_league_team
from utils.text_utils import split_text_for_fields
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
from views.embeds import EmbedColors, EmbedTemplate
from .chart_data import (
INFIELD_X_CHART,
@ -36,16 +35,6 @@ from .chart_data import (
PITCHER_ERRORS,
)
@dataclass
class DiceRoll:
"""Represents the result of a dice roll."""
dice_notation: str
num_dice: int
die_sides: int
rolls: list[int]
total: int
class DiceRollCommands(commands.Cog):
"""Dice rolling command handlers for gameplay."""
@ -70,7 +59,7 @@ class DiceRollCommands(commands.Cog):
await interaction.response.defer()
# Parse and validate dice notation (supports multiple rolls)
roll_results = self._parse_and_roll_multiple_dice(dice)
roll_results = parse_and_roll_multiple_dice(dice)
if not roll_results:
await interaction.followup.send(
"❌ Invalid dice notation. Use format like: 2d6, 1d20, or 1d6;2d6;1d20",
@ -93,7 +82,7 @@ class DiceRollCommands(commands.Cog):
return
# Parse and validate dice notation (supports multiple rolls)
roll_results = self._parse_and_roll_multiple_dice(dice)
roll_results = parse_and_roll_multiple_dice(dice)
if not roll_results:
self.logger.warning("Invalid dice notation provided", dice_input=dice)
await ctx.send("❌ Invalid dice notation. Use format like: 2d6, 1d20, or 1d6;2d6;1d20")
@ -105,6 +94,32 @@ class DiceRollCommands(commands.Cog):
embed = self._create_multi_roll_embed(dice, roll_results, ctx.author)
await ctx.send(embed=embed)
@discord.app_commands.command(
name="d20",
description="Roll a single d20"
)
@logged_command("/d20")
async def d20_dice(self, interaction: discord.Interaction):
"""Roll a single d20."""
await interaction.response.defer()
embed_color = await self._get_channel_embed_color(interaction)
# Roll 1d20
dice_notation = "1d20"
roll_results = parse_and_roll_multiple_dice(dice_notation)
# Create embed for the roll results
embed = self._create_multi_roll_embed(
dice_notation,
roll_results,
interaction.user,
set_author=False,
embed_color=embed_color
)
embed.title = f'd20 roll for {interaction.user.display_name}'
await interaction.followup.send(embed=embed)
@discord.app_commands.command(
name="ab",
description="Roll baseball at-bat dice (1d6;2d6;1d20)"
@ -117,7 +132,7 @@ class DiceRollCommands(commands.Cog):
# Use the standard baseball dice combination
dice_notation = "1d6;2d6;1d20"
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
roll_results = parse_and_roll_multiple_dice(dice_notation)
injury_risk = (roll_results[0].total == 6) and (roll_results[1].total in [7, 8, 9, 10, 11, 12])
d6_total = roll_results[1].total
@ -126,11 +141,11 @@ class DiceRollCommands(commands.Cog):
if roll_results[2].total == 1:
embed_title = 'Wild pitch roll'
dice_notation = '1d20'
roll_results = [self._parse_and_roll_single_dice(dice_notation)]
roll_results = [parse_and_roll_single_dice(dice_notation)]
elif roll_results[2].total == 2:
embed_title = 'PB roll'
dice_notation = '1d20'
roll_results = [self._parse_and_roll_single_dice(dice_notation)]
roll_results = [parse_and_roll_single_dice(dice_notation)]
# Create embed for the roll results
embed = self._create_multi_roll_embed(
@ -162,7 +177,7 @@ class DiceRollCommands(commands.Cog):
# Use the standard baseball dice combination
dice_notation = "1d6;2d6;1d20"
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
roll_results = parse_and_roll_multiple_dice(dice_notation)
self.logger.info("At Bat dice rolled successfully", roll_count=len(roll_results))
@ -235,7 +250,7 @@ class DiceRollCommands(commands.Cog):
# Roll the dice - 1d20 and 3d6
dice_notation = "1d20;3d6;1d100"
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
roll_results = parse_and_roll_multiple_dice(dice_notation)
# Create fielding embed
embed = self._create_fielding_embed(pos_value, roll_results, interaction.user, embed_color)
@ -256,9 +271,9 @@ class DiceRollCommands(commands.Cog):
await ctx.send("❌ Invalid position. Use: C, 1B, 2B, 3B, SS, LF, CF, RF")
return
# Roll the dice - 1d20 and 3d6
dice_notation = "1d20;3d6"
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
# Roll the dice - 1d20 and 3d6 and 1d100
dice_notation = "1d20;3d6;1d100"
roll_results = parse_and_roll_multiple_dice(dice_notation)
self.logger.info("SA Fielding dice rolled successfully", position=parsed_position, d20=roll_results[0].total, d6_total=roll_results[1].total)
@ -280,7 +295,7 @@ class DiceRollCommands(commands.Cog):
check_roll = random.randint(1, 20)
# Roll 2d6 for jump rating
jump_result = self._parse_and_roll_single_dice("2d6")
jump_result = parse_and_roll_single_dice("2d6")
# Roll another 1d20 for pickoff/balk resolution
resolution_roll = random.randint(1, 20)
@ -309,7 +324,7 @@ class DiceRollCommands(commands.Cog):
check_roll = random.randint(1, 20)
# Roll 2d6 for jump rating
jump_result = self._parse_and_roll_single_dice("2d6")
jump_result = parse_and_roll_single_dice("2d6")
# Roll another 1d20 for pickoff/balk resolution
resolution_roll = random.randint(1, 20)
@ -642,50 +657,6 @@ class DiceRollCommands(commands.Cog):
f'**TR3**: {OUTFIELD_X_CHART["tr3"]["rp"]}\n'
)
def _parse_and_roll_multiple_dice(self, dice_notation: str) -> list[DiceRoll]:
"""Parse dice notation (supports multiple rolls) and return roll results."""
# Split by semicolon for multiple rolls
dice_parts = [part.strip() for part in dice_notation.split(';')]
results = []
for dice_part in dice_parts:
result = self._parse_and_roll_single_dice(dice_part)
if result is None:
return [] # Return empty list if any part is invalid
results.append(result)
return results
def _parse_and_roll_single_dice(self, dice_notation: str) -> DiceRoll:
"""Parse single dice notation and return roll results."""
# Clean the input
dice_notation = dice_notation.strip().lower().replace(' ', '')
# Pattern: XdY
pattern = r'^(\d+)d(\d+)$'
match = re.match(pattern, dice_notation)
if not match:
raise ValueError(f'Cannot parse dice string **{dice_notation}**')
num_dice = int(match.group(1))
die_sides = int(match.group(2))
# Validate reasonable limits
if num_dice > 100 or die_sides > 1000 or num_dice < 1 or die_sides < 2:
raise ValueError('I don\'t know, bud, that just doesn\'t seem doable.')
# Roll the dice
rolls = [random.randint(1, die_sides) for _ in range(num_dice)]
total = sum(rolls)
return DiceRoll(
dice_notation=dice_notation,
num_dice=num_dice,
die_sides=die_sides,
rolls=rolls,
total=total
)
def _roll_weighted_scout_dice(self, card_type: str) -> list[DiceRoll]:
"""
@ -712,10 +683,10 @@ class DiceRollCommands(commands.Cog):
)
# Second roll (2d6) - normal
second_result = self._parse_and_roll_single_dice("2d6")
second_result = parse_and_roll_single_dice("2d6")
# Third roll (1d20) - normal
third_result = self._parse_and_roll_single_dice("1d20")
third_result = parse_and_roll_single_dice("1d20")
return [first_d6_result, second_result, third_result]

View File

@ -32,6 +32,10 @@ class BotConfig(BaseSettings):
sba_color: str = "a6ce39"
weeks_per_season: int = 18
games_per_week: int = 4
playoff_weeks_per_season: int = 3
playoff_round_one_games: int = 5
playoff_round_two_games: int = 7
playoff_round_three_games: int = 7
modern_stats_start_season: int = 8
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw

View File

@ -8,7 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import discord
from discord.ext import commands
from commands.dice.rolls import DiceRollCommands, DiceRoll
from commands.dice.rolls import DiceRollCommands
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
class TestDiceRollCommands:
@ -32,6 +33,7 @@ class TestDiceRollCommands:
# Mock the user
user = MagicMock(spec=discord.User)
user.name = "TestUser"
user.display_name = "TestUser"
user.display_avatar.url = "https://example.com/avatar.png"
interaction.user = user
@ -62,7 +64,7 @@ class TestDiceRollCommands:
def test_parse_valid_dice_notation(self, dice_cog):
"""Test parsing valid dice notation."""
# Test basic notation
results = dice_cog._parse_and_roll_multiple_dice("2d6")
results = parse_and_roll_multiple_dice("2d6")
assert len(results) == 1
result = results[0]
assert result.num_dice == 2
@ -72,7 +74,7 @@ class TestDiceRollCommands:
assert result.total == sum(result.rolls)
# Test single die
results = dice_cog._parse_and_roll_multiple_dice("1d20")
results = parse_and_roll_multiple_dice("1d20")
assert len(results) == 1
result = results[0]
assert result.num_dice == 1
@ -83,22 +85,22 @@ class TestDiceRollCommands:
def test_parse_invalid_dice_notation(self, dice_cog):
"""Test parsing invalid dice notation."""
# Invalid formats
assert dice_cog._parse_and_roll_multiple_dice("invalid") == []
assert dice_cog._parse_and_roll_multiple_dice("2d") == []
assert dice_cog._parse_and_roll_multiple_dice("d6") == []
assert dice_cog._parse_and_roll_multiple_dice("2d6+5") == [] # No modifiers in simple version
assert dice_cog._parse_and_roll_multiple_dice("") == []
assert parse_and_roll_multiple_dice("invalid") == []
assert parse_and_roll_multiple_dice("2d") == []
assert parse_and_roll_multiple_dice("d6") == []
assert parse_and_roll_multiple_dice("2d6+5") == [] # No modifiers in simple version
assert parse_and_roll_multiple_dice("") == []
# Out of bounds values
assert dice_cog._parse_and_roll_multiple_dice("0d6") == [] # num_dice < 1
assert dice_cog._parse_and_roll_multiple_dice("2d1") == [] # die_sides < 2
assert dice_cog._parse_and_roll_multiple_dice("101d6") == [] # num_dice > 100
assert dice_cog._parse_and_roll_multiple_dice("1d1001") == [] # die_sides > 1000
assert parse_and_roll_multiple_dice("0d6") == [] # num_dice < 1
assert parse_and_roll_multiple_dice("2d1") == [] # die_sides < 2
assert parse_and_roll_multiple_dice("101d6") == [] # num_dice > 100
assert parse_and_roll_multiple_dice("1d1001") == [] # die_sides > 1000
def test_parse_multiple_dice(self, dice_cog):
"""Test parsing multiple dice rolls."""
# Test multiple rolls
results = dice_cog._parse_and_roll_multiple_dice("1d6;2d8;1d20")
results = parse_and_roll_multiple_dice("1d6;2d8;1d20")
assert len(results) == 3
assert results[0].dice_notation == '1d6'
@ -115,8 +117,8 @@ class TestDiceRollCommands:
def test_parse_case_insensitive(self, dice_cog):
"""Test that dice notation parsing is case insensitive."""
result_lower = dice_cog._parse_and_roll_multiple_dice("2d6")
result_upper = dice_cog._parse_and_roll_multiple_dice("2D6")
result_lower = parse_and_roll_multiple_dice("2d6")
result_upper = parse_and_roll_multiple_dice("2D6")
assert len(result_lower) == 1
assert len(result_upper) == 1
@ -125,12 +127,12 @@ class TestDiceRollCommands:
def test_parse_whitespace_handling(self, dice_cog):
"""Test that whitespace is handled properly."""
results = dice_cog._parse_and_roll_multiple_dice(" 2d6 ")
results = parse_and_roll_multiple_dice(" 2d6 ")
assert len(results) == 1
assert results[0].num_dice == 2
assert results[0].die_sides == 6
results = dice_cog._parse_and_roll_multiple_dice("2 d 6")
results = parse_and_roll_multiple_dice("2 d 6")
assert len(results) == 1
assert results[0].num_dice == 2
assert results[0].die_sides == 6
@ -232,7 +234,7 @@ class TestDiceRollCommands:
"""Test that dice rolls produce different results."""
results = []
for _ in range(20): # Roll 20 times
result = dice_cog._parse_and_roll_multiple_dice("1d20")
result = parse_and_roll_multiple_dice("1d20")
results.append(result[0].rolls[0])
# Should have some variation in results (very unlikely all 20 rolls are the same)
@ -242,20 +244,20 @@ class TestDiceRollCommands:
def test_dice_boundaries(self, dice_cog):
"""Test dice rolling at boundaries."""
# Test maximum allowed dice
results = dice_cog._parse_and_roll_multiple_dice("100d2")
results = parse_and_roll_multiple_dice("100d2")
assert len(results) == 1
result = results[0]
assert len(result.rolls) == 100
assert all(roll in [1, 2] for roll in result.rolls)
# Test maximum die size
results = dice_cog._parse_and_roll_multiple_dice("1d1000")
results = parse_and_roll_multiple_dice("1d1000")
assert len(results) == 1
result = results[0]
assert 1 <= result.rolls[0] <= 1000
# Test minimum valid values
results = dice_cog._parse_and_roll_multiple_dice("1d2")
results = parse_and_roll_multiple_dice("1d2")
assert len(results) == 1
result = results[0]
assert result.rolls[0] in [1, 2]
@ -328,6 +330,26 @@ class TestDiceRollCommands:
assert command.name == "roll"
assert command.aliases == ["r", "dice"]
@pytest.mark.asyncio
async def test_d20_command_slash(self, dice_cog, mock_interaction):
"""Test d20 slash command."""
await dice_cog.d20_dice.callback(dice_cog, mock_interaction)
# Verify response was deferred
mock_interaction.response.defer.assert_called_once()
# Verify followup was sent with embed
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert 'embed' in call_args.kwargs
# Verify embed has the correct format
embed = call_args.kwargs['embed']
assert isinstance(embed, discord.Embed)
assert embed.title == "d20 roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d20" in embed.fields[0].value
@pytest.mark.asyncio
async def test_ab_command_slash(self, dice_cog, mock_interaction):
"""Test ab slash command."""
@ -379,7 +401,7 @@ class TestDiceRollCommands:
def test_ab_command_dice_combination(self, dice_cog):
"""Test that ab command uses the correct dice combination."""
dice_notation = "1d6;2d6;1d20"
results = dice_cog._parse_and_roll_multiple_dice(dice_notation)
results = parse_and_roll_multiple_dice(dice_notation)
# Should have 3 dice groups
assert len(results) == 3
@ -538,7 +560,7 @@ class TestDiceRollCommands:
def test_fielding_dice_combination(self, dice_cog):
"""Test that fielding uses correct dice combination (1d20;3d6)."""
dice_notation = "1d20;3d6"
results = dice_cog._parse_and_roll_multiple_dice(dice_notation)
results = parse_and_roll_multiple_dice(dice_notation)
# Should have 2 dice groups
assert len(results) == 2
@ -620,7 +642,7 @@ class TestDiceRollCommands:
# Verify embed has the correct format
embed = call_args.kwargs['embed']
assert isinstance(embed, discord.Embed)
assert embed.title == "Scouting roll for TestUser (Batter)"
assert embed.title == "Scouting roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
@ -645,6 +667,6 @@ class TestDiceRollCommands:
# Verify embed has the correct format
embed = call_args.kwargs['embed']
assert isinstance(embed, discord.Embed)
assert embed.title == "Scouting roll for TestUser (Pitcher)"
assert embed.title == "Scouting roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value

View File

@ -0,0 +1,189 @@
"""
Tests for scorebug_helpers utility functions.
Tests the create_team_progress_bar function to ensure correct
win probability visualization for home and away teams.
"""
import pytest
from utils.scorebug_helpers import create_team_progress_bar
class TestCreateTeamProgressBar:
"""Tests for the create_team_progress_bar function."""
def test_home_team_winning_75_percent(self):
"""Test progress bar when home team has 75% win probability."""
result = create_team_progress_bar(
win_percentage=75.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Home team winning: should show dark blocks (▓) on right side
# Arrow should extend from right side (►)
assert "" in result
assert "" not in result
assert "75.0%" in result
assert "POR" in result
assert "WV" in result
# Should have more dark blocks (▓) than light blocks (░)
dark_blocks = result.count("")
light_blocks = result.count("")
assert dark_blocks > light_blocks, "Home team winning should have more dark blocks"
def test_away_team_winning_25_percent_home(self):
"""Test progress bar when home team has only 25% win probability (away team winning)."""
result = create_team_progress_bar(
win_percentage=25.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Away team winning: should show dark blocks (▓) on left side
# Arrow should extend from left side (◄)
assert "" in result
assert "" not in result
# Percentage should show away team's win % (75.0%) on left
assert result.startswith("75.0%"), "Percentage should be on left when away team winning"
assert "POR" in result
assert "WV" in result
# Should have more dark blocks (▓) than light blocks (░)
dark_blocks = result.count("")
light_blocks = result.count("")
assert dark_blocks > light_blocks, "Away team winning should have more dark blocks"
def test_even_game_50_percent(self):
"""Test progress bar when game is even at 50%."""
result = create_team_progress_bar(
win_percentage=50.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Even game: should have equals signs on both sides
assert "=" in result
assert "" not in result
assert "" not in result
# Percentage should appear on both sides for even game
assert result.startswith("50.0%"), "Percentage should be on left for even game"
assert result.endswith("50.0%"), "Percentage should be on right for even game"
assert "POR" in result
assert "WV" in result
# All blocks should be dark (▓) for even game
assert "" not in result, "Even game should have no light blocks"
def test_home_team_slight_advantage_55_percent(self):
"""Test progress bar when home team has slight advantage (55%)."""
result = create_team_progress_bar(
win_percentage=55.0,
away_abbrev="NYK",
home_abbrev="BOS"
)
# Home team winning: arrow extends from right
assert "" in result
assert "" not in result
assert "55.0%" in result
def test_away_team_strong_advantage_30_percent_home(self):
"""Test progress bar when away team has strong advantage (home only 30%)."""
result = create_team_progress_bar(
win_percentage=30.0,
away_abbrev="LAD",
home_abbrev="SF"
)
# Away team winning: arrow extends from left
assert "" in result
assert "" not in result
# Percentage should show away team's win % (70.0%) on left
assert result.startswith("70.0%"), "Percentage should be on left when away team winning"
def test_home_team_dominant_95_percent(self):
"""Test progress bar when home team is dominant (95%)."""
result = create_team_progress_bar(
win_percentage=95.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Home team dominant: almost all dark blocks
assert "" in result
assert "95.0%" in result
dark_blocks = result.count("")
light_blocks = result.count("")
# With 95% home win probability, should be 9.5/10 blocks dark (rounds to 9 or 10)
assert dark_blocks >= 9, "95% should result in 9+ dark blocks"
assert light_blocks <= 1, "95% should result in 0-1 light blocks"
def test_away_team_dominant_5_percent_home(self):
"""Test progress bar when away team is dominant (home only 5%)."""
result = create_team_progress_bar(
win_percentage=5.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Away team dominant: almost all dark blocks
assert "" in result
# Percentage should show away team's win % (95.0%) on left
assert result.startswith("95.0%"), "Percentage should be on left when away team winning"
dark_blocks = result.count("")
light_blocks = result.count("")
# With 5% home win probability, should be 9.5/10 blocks dark (rounds to 9 or 10)
assert dark_blocks >= 9, "5% should result in 9+ dark blocks"
assert light_blocks <= 1, "5% should result in 0-1 light blocks"
def test_custom_bar_length(self):
"""Test progress bar with custom length."""
result = create_team_progress_bar(
win_percentage=75.0,
away_abbrev="POR",
home_abbrev="WV",
length=20
)
# Should have more blocks total
total_blocks = result.count("") + result.count("")
assert total_blocks == 20, "Should have exactly 20 blocks with custom length"
def test_edge_case_0_percent(self):
"""Test progress bar at edge case of 0% home win probability."""
result = create_team_progress_bar(
win_percentage=0.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Away team certain to win: arrow from left
assert "" in result
# Percentage should show away team's win % (100.0%) on left
assert result.startswith("100.0%"), "Percentage should be on left when away team winning"
# Should be all dark blocks (away team dominant)
assert result.count("") == 10, "0% home should be all dark blocks"
assert "" not in result, "0% home should have no light blocks"
def test_edge_case_100_percent(self):
"""Test progress bar at edge case of 100% home win probability."""
result = create_team_progress_bar(
win_percentage=100.0,
away_abbrev="POR",
home_abbrev="WV"
)
# Home team certain to win: arrow from right
assert "" in result
# Percentage should be on right when home team winning
assert result.endswith("100.0%"), "Percentage should be on right when home team winning"
# Should be all dark blocks (home team dominant)
assert result.count("") == 10, "100% home should be all dark blocks"
assert "" not in result, "100% home should have no light blocks"

View File

@ -0,0 +1,501 @@
"""
Tests for Injury Modal Validation in Discord Bot v2.0
Tests week and game validation for BatterInjuryModal and PitcherRestModal,
including regular season and playoff round validation.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch, PropertyMock
from datetime import datetime, timezone
import discord
from views.modals import BatterInjuryModal, PitcherRestModal
from views.embeds import EmbedTemplate
from models.player import Player
@pytest.fixture
def mock_config():
"""Mock configuration with standard season structure."""
config = MagicMock()
config.weeks_per_season = 18
config.playoff_weeks_per_season = 3
config.games_per_week = 4
config.playoff_round_one_games = 5
config.playoff_round_two_games = 7
config.playoff_round_three_games = 7
return config
@pytest.fixture
def sample_player():
"""Create a sample player for testing."""
return Player(
id=1,
name="Test Player",
wara=2.5,
season=12,
team_id=1,
image="https://example.com/player.jpg",
pos_1="1B"
)
@pytest.fixture
def mock_interaction():
"""Create a mock Discord interaction."""
interaction = MagicMock(spec=discord.Interaction)
interaction.response = MagicMock()
interaction.response.send_message = AsyncMock()
return interaction
def create_mock_text_input(value: str):
"""Create a mock TextInput with a specific value."""
mock_input = MagicMock()
type(mock_input).value = PropertyMock(return_value=value)
return mock_input
class TestBatterInjuryModalWeekValidation:
"""Test week validation in BatterInjuryModal."""
@pytest.mark.asyncio
async def test_regular_season_week_valid(self, sample_player, mock_interaction, mock_config):
"""Test that regular season weeks (1-18) are accepted."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
# Mock the TextInput values
modal.current_week = create_mock_text_input("10")
modal.current_game = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
# Mock successful injury creation
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error message
assert not any(
call[1].get('embed') and
'Invalid Week' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config):
"""Test that playoff week 19 (round 1) is accepted."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("19")
modal.current_game = create_mock_text_input("3")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error message
assert not any(
call[1].get('embed') and
'Invalid Week' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_playoff_week_21_valid(self, sample_player, mock_interaction, mock_config):
"""Test that playoff week 21 (round 3) is accepted."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("21")
modal.current_game = create_mock_text_input("5")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error message
assert not any(
call[1].get('embed') and
'Invalid Week' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_week_too_high_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that week > 21 is rejected."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("22")
modal.current_game = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Week' in call_kwargs['embed'].title
assert '21 (including playoffs)' in call_kwargs['embed'].description
@pytest.mark.asyncio
async def test_week_zero_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that week 0 is rejected."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("0")
modal.current_game = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Week' in call_kwargs['embed'].title
class TestBatterInjuryModalGameValidation:
"""Test game validation in BatterInjuryModal."""
@pytest.mark.asyncio
async def test_regular_season_game_4_valid(self, sample_player, mock_interaction, mock_config):
"""Test that game 4 is accepted in regular season."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("10")
modal.current_game = create_mock_text_input("4")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid game
assert not any(
call[1].get('embed') and
'Invalid Game' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_regular_season_game_5_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that game 5 is rejected in regular season (only 4 games)."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("10")
modal.current_game = create_mock_text_input("5")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Game' in call_kwargs['embed'].title
assert 'between 1 and 4' in call_kwargs['embed'].description
@pytest.mark.asyncio
async def test_playoff_round_1_game_5_valid(self, sample_player, mock_interaction, mock_config):
"""Test that game 5 is accepted in playoff round 1 (week 19)."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("19")
modal.current_game = create_mock_text_input("5")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid game
assert not any(
call[1].get('embed') and
'Invalid Game' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that game 6 is rejected in playoff round 1 (only 5 games)."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("19")
modal.current_game = create_mock_text_input("6")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Game' in call_kwargs['embed'].title
assert 'between 1 and 5' in call_kwargs['embed'].description
@pytest.mark.asyncio
async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config):
"""Test that game 7 is accepted in playoff round 2 (week 20)."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("20")
modal.current_game = create_mock_text_input("7")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid game
assert not any(
call[1].get('embed') and
'Invalid Game' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_playoff_round_3_game_7_valid(self, sample_player, mock_interaction, mock_config):
"""Test that game 7 is accepted in playoff round 3 (week 21)."""
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("21")
modal.current_game = create_mock_text_input("7")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid game
assert not any(
call[1].get('embed') and
'Invalid Game' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
class TestPitcherRestModalValidation:
"""Test week and game validation in PitcherRestModal (should match BatterInjuryModal)."""
@pytest.mark.asyncio
async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config):
"""Test that playoff week 19 is accepted for pitchers."""
modal = PitcherRestModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("19")
modal.current_game = create_mock_text_input("3")
modal.rest_games = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid week
assert not any(
call[1].get('embed') and
'Invalid Week' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_week_22_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that week 22 is rejected for pitchers."""
modal = PitcherRestModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("22")
modal.current_game = create_mock_text_input("2")
modal.rest_games = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Week' in call_kwargs['embed'].title
assert '21 (including playoffs)' in call_kwargs['embed'].description
@pytest.mark.asyncio
async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config):
"""Test that game 7 is accepted in playoff round 2 for pitchers."""
modal = PitcherRestModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("20")
modal.current_game = create_mock_text_input("7")
modal.rest_games = create_mock_text_input("3")
with patch('config.get_config', return_value=mock_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid game
assert not any(
call[1].get('embed') and
'Invalid Game' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)
@pytest.mark.asyncio
async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config):
"""Test that game 6 is rejected in playoff round 1 for pitchers (only 5 games)."""
modal = PitcherRestModal(
player=sample_player,
injury_games=4,
season=12
)
modal.current_week = create_mock_text_input("19")
modal.current_game = create_mock_text_input("6")
modal.rest_games = create_mock_text_input("2")
with patch('config.get_config', return_value=mock_config):
await modal.on_submit(mock_interaction)
# Should send error message
mock_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1]
assert 'embed' in call_kwargs
assert 'Invalid Game' in call_kwargs['embed'].title
assert 'between 1 and 5' in call_kwargs['embed'].description
class TestConfigDrivenValidation:
"""Test that validation correctly uses config values."""
@pytest.mark.asyncio
async def test_custom_config_values_respected(self, sample_player, mock_interaction):
"""Test that custom config values change validation behavior."""
# Create config with different values
custom_config = MagicMock()
custom_config.weeks_per_season = 20 # Different from default
custom_config.playoff_weeks_per_season = 2 # Different from default
custom_config.games_per_week = 4
custom_config.playoff_round_one_games = 5
custom_config.playoff_round_two_games = 7
custom_config.playoff_round_three_games = 7
modal = BatterInjuryModal(
player=sample_player,
injury_games=4,
season=12
)
# Week 22 should be valid with this config (20 + 2 = 22)
modal.current_week = create_mock_text_input("22")
modal.current_game = create_mock_text_input("3")
with patch('config.get_config', return_value=custom_config), \
patch('services.player_service.player_service') as mock_player_service, \
patch('services.injury_service.injury_service') as mock_injury_service:
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
mock_player_service.update_player = AsyncMock()
await modal.on_submit(mock_interaction)
# Should not send error about invalid week
assert not any(
call[1].get('embed') and
'Invalid Week' in str(call[1]['embed'].title)
for call in mock_interaction.response.send_message.call_args_list
)

View File

@ -8,7 +8,8 @@ This package contains utility functions, helpers, and shared components used thr
1. [**Structured Logging**](#-structured-logging) - Contextual logging with Discord integration
2. [**Redis Caching**](#-redis-caching) - Optional performance caching system
3. [**Command Decorators**](#-command-decorators) - Boilerplate reduction decorators
4. [**Future Utilities**](#-future-utilities) - Planned utility modules
4. [**Dice Utilities**](#-dice-utilities) - Reusable dice rolling functions
5. [**Future Utilities**](#-future-utilities) - Planned utility modules
---
@ -935,7 +936,215 @@ class RosterCommands(commands.Cog):
---
**Last Updated:** January 2025 - Added Autocomplete Functions and Fixed Team Filtering
## 🎲 Dice Utilities
**Location:** `utils/dice_utils.py`
**Purpose:** Provides reusable dice rolling functionality for commands that need dice mechanics.
### **Overview**
The dice utilities module provides a clean, reusable implementation of dice rolling functionality that can be imported by any command file. This was refactored from `commands/dice/rolls.py` to promote code reuse and maintainability.
### **Quick Start**
```python
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
# Roll multiple dice
results = parse_and_roll_multiple_dice("1d6;2d6;1d20")
for result in results:
print(f"{result.dice_notation}: {result.total}")
# Roll a single die
result = parse_and_roll_single_dice("2d6")
print(f"Rolled {result.total} on {result.dice_notation}")
print(f"Individual rolls: {result.rolls}")
```
### **Data Structures**
#### **`DiceRoll` Dataclass**
Represents the result of a dice roll with complete information:
```python
@dataclass
class DiceRoll:
dice_notation: str # Original notation (e.g., "2d6")
num_dice: int # Number of dice rolled
die_sides: int # Number of sides per die
rolls: list[int] # Individual roll results
total: int # Sum of all rolls
```
**Example:**
```python
result = parse_and_roll_single_dice("2d6")
# DiceRoll(
# dice_notation='2d6',
# num_dice=2,
# die_sides=6,
# rolls=[4, 5],
# total=9
# )
```
### **Functions**
#### **`parse_and_roll_multiple_dice(dice_notation: str) -> list[DiceRoll]`**
Parse and roll multiple dice notations separated by semicolons.
**Parameters:**
- `dice_notation` (str): Dice notation string, supports multiple rolls separated by semicolon (e.g., "2d6", "1d20;2d6;1d6")
**Returns:**
- `list[DiceRoll]`: List of DiceRoll results, or empty list if any part is invalid
**Examples:**
```python
# Single roll
results = parse_and_roll_multiple_dice("2d6")
# Returns: [DiceRoll(dice_notation='2d6', ...)]
# Multiple rolls
results = parse_and_roll_multiple_dice("1d6;2d6;1d20")
# Returns: [
# DiceRoll(dice_notation='1d6', ...),
# DiceRoll(dice_notation='2d6', ...),
# DiceRoll(dice_notation='1d20', ...)
# ]
# Invalid input
results = parse_and_roll_multiple_dice("invalid")
# Returns: []
```
**Error Handling:**
- Returns empty list `[]` for invalid dice notation
- Catches `ValueError` exceptions from individual rolls
- Safe to use in user-facing commands
#### **`parse_and_roll_single_dice(dice_notation: str) -> DiceRoll`**
Parse and roll a single dice notation string.
**Parameters:**
- `dice_notation` (str): Single dice notation string (e.g., "2d6", "1d20")
**Returns:**
- `DiceRoll`: Roll result with complete information
**Raises:**
- `ValueError`: If dice notation is invalid or values are out of reasonable limits
**Examples:**
```python
# Valid rolls
result = parse_and_roll_single_dice("2d6")
result = parse_and_roll_single_dice("1d20")
result = parse_and_roll_single_dice("3d8")
# Invalid notation (raises ValueError)
result = parse_and_roll_single_dice("invalid") # ValueError: Cannot parse dice string
# Out of bounds (raises ValueError)
result = parse_and_roll_single_dice("101d6") # Too many dice
result = parse_and_roll_single_dice("1d1001") # Die too large
```
**Validation Rules:**
- **Format**: Must match pattern `XdY` (e.g., "2d6", "1d20")
- **Number of dice**: 1-100 (inclusive)
- **Die sides**: 2-1000 (inclusive)
- **Case insensitive**: "2d6" and "2D6" both work
- **Whitespace tolerant**: " 2d6 " and "2 d 6" both work
### **Usage in Commands**
The dice utilities are used throughout the dice commands package:
```python
from utils.dice_utils import parse_and_roll_multiple_dice
class DiceRollCommands(commands.Cog):
@discord.app_commands.command(name="d20", description="Roll a single d20")
@logged_command("/d20")
async def d20_dice(self, interaction: discord.Interaction):
await interaction.response.defer()
# Use the utility function
dice_notation = "1d20"
roll_results = parse_and_roll_multiple_dice(dice_notation)
# Create embed and send
embed = self._create_multi_roll_embed(dice_notation, roll_results, interaction.user)
await interaction.followup.send(embed=embed)
```
### **Design Benefits**
1. **Reusability**: Can be imported by any command file that needs dice functionality
2. **Maintainability**: Centralized dice logic in one place
3. **Testability**: Functions can be tested independently of command cogs
4. **Consistency**: All dice commands use the same underlying logic
5. **Error Handling**: Graceful error handling with empty list returns
### **Implementation Details**
**Random Number Generation:**
- Uses Python's `random.randint(1, die_sides)` for each die
- Each roll is independent and equally likely
**Parsing:**
- Regular expression pattern: `^(\d+)d(\d+)$`
- Case-insensitive matching
- Whitespace stripped before parsing
**Error Recovery:**
- `parse_and_roll_multiple_dice` catches exceptions and returns empty list
- Safe for user input validation in commands
- Detailed error messages in exceptions for debugging
### **Testing**
Comprehensive test coverage in `tests/test_commands_dice.py`:
```python
# Test valid notation
results = parse_and_roll_multiple_dice("2d6")
assert len(results) == 1
assert results[0].num_dice == 2
assert results[0].die_sides == 6
# Test invalid notation
results = parse_and_roll_multiple_dice("invalid")
assert results == []
# Test multiple rolls
results = parse_and_roll_multiple_dice("1d6;2d8;1d20")
assert len(results) == 3
```
### **Commands Using Dice Utilities**
Current commands that use the dice utilities:
- `/roll` - General purpose dice rolling
- `/d20` - Quick d20 roll
- `/ab` - Baseball at-bat dice (1d6;2d6;1d20)
- `/fielding` - Super Advanced fielding rolls
- `/scout` - Weighted scouting rolls
- `/jump` - Baserunner jump rolls
### **Migration Notes**
**October 2025**: Dice rolling functions were refactored from `commands/dice/rolls.py` into `utils/dice_utils.py` to promote code reuse and allow other command files to easily import dice functionality.
**Breaking Changes:** None - all existing commands updated to use the new module.
---
**Last Updated:** October 2025 - Added Dice Utilities Documentation
**Next Update:** When additional utility modules are added
For questions or improvements to the logging system, check the implementation in `utils/logging.py` or refer to the JSON log outputs in `logs/discord_bot_v2.json`.

84
utils/dice_utils.py Normal file
View File

@ -0,0 +1,84 @@
"""
Dice Rolling Utilities
Provides reusable dice rolling functionality for commands that need dice mechanics.
"""
import random
import re
from dataclasses import dataclass
@dataclass
class DiceRoll:
"""Represents the result of a dice roll."""
dice_notation: str
num_dice: int
die_sides: int
rolls: list[int]
total: int
def parse_and_roll_multiple_dice(dice_notation: str) -> list[DiceRoll]:
"""Parse dice notation (supports multiple rolls) and return roll results.
Args:
dice_notation: Dice notation string, supports multiple rolls separated by semicolon
(e.g., "2d6", "1d20;2d6;1d6")
Returns:
List of DiceRoll results, or empty list if any part is invalid
"""
# Split by semicolon for multiple rolls
dice_parts = [part.strip() for part in dice_notation.split(';')]
results = []
for dice_part in dice_parts:
try:
result = parse_and_roll_single_dice(dice_part)
results.append(result)
except ValueError:
return [] # Return empty list if any part is invalid
return results
def parse_and_roll_single_dice(dice_notation: str) -> DiceRoll:
"""Parse single dice notation and return roll results.
Args:
dice_notation: Single dice notation string (e.g., "2d6", "1d20")
Returns:
DiceRoll result
Raises:
ValueError: If dice notation is invalid or values are out of reasonable limits
"""
# Clean the input
dice_notation = dice_notation.strip().lower().replace(' ', '')
# Pattern: XdY
pattern = r'^(\d+)d(\d+)$'
match = re.match(pattern, dice_notation)
if not match:
raise ValueError(f'Cannot parse dice string **{dice_notation}**')
num_dice = int(match.group(1))
die_sides = int(match.group(2))
# Validate reasonable limits
if num_dice > 100 or die_sides > 1000 or num_dice < 1 or die_sides < 2:
raise ValueError('I don\'t know, bud, that just doesn\'t seem doable.')
# Roll the dice
rolls = [random.randint(1, die_sides) for _ in range(num_dice)]
total = sum(rolls)
return DiceRoll(
dice_notation=dice_notation,
num_dice=num_dice,
die_sides=die_sides,
rolls=rolls,
total=total
)

View File

@ -175,10 +175,11 @@ def create_team_progress_bar(
Returns:
Formatted bar with dark blocks () weighted toward winning team.
Arrow extends from the side with the advantage.
Percentage displayed on winning team's side (or both sides if even).
Examples:
Home winning: "POR ░▓▓▓▓▓▓▓▓▓► WV 95.0%"
Away winning: "POR ◄▓▓▓▓▓▓▓░░░ WV 30.0%"
Even game: "POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%"
Away winning: "70.0% POR ◄▓▓▓▓▓▓▓░░░ WV"
Even game: "50.0% POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%"
"""
# Calculate blocks for each team (home team's percentage)
home_blocks = int((win_percentage / 100) * length)
@ -189,19 +190,20 @@ def create_team_progress_bar(
away_char = '' # Light blocks for losing team
home_char = '' # Dark blocks for winning team
bar = away_char * away_blocks + home_char * home_blocks
# Arrow extends from right side
# Arrow extends from right side, percentage on right
return f'{away_abbrev} {bar}{home_abbrev} {win_percentage:.1f}%'
elif win_percentage < 50:
# Away team (left side) is winning
away_char = '' # Dark blocks for winning team
home_char = '' # Light blocks for losing team
bar = away_char * away_blocks + home_char * home_blocks
# Arrow extends from left side
return f'{away_abbrev}{bar} {home_abbrev} {win_percentage:.1f}%'
# Arrow extends from left side, percentage on left (showing away team's win %)
away_win_pct = 100 - win_percentage
return f'{away_win_pct:.1f}% {away_abbrev}{bar} {home_abbrev}'
else:
# Even game (50/50)
away_char = ''
home_char = ''
bar = away_char * away_blocks + home_char * home_blocks
# Arrows on both sides for balanced display
return f'{away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%'
# Arrows on both sides for balanced display, percentage on both sides
return f'{win_percentage:.1f}% {away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%'

View File

@ -539,48 +539,64 @@ class BatterInjuryModal(BaseModal):
"""Handle batter injury input and log injury."""
from services.player_service import player_service
from services.injury_service import injury_service
from config import get_config
import math
config = get_config()
max_week = config.weeks_per_season + config.playoff_weeks_per_season
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > 18:
raise ValueError("Week must be between 1 and 18")
if week < 1 or week > max_week:
raise ValueError(f"Week must be between 1 and {max_week}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description="Current week must be a number between 1 and 18."
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Determine max games based on week (regular season vs playoff rounds)
if week <= config.weeks_per_season:
max_game = config.games_per_week
elif week == config.weeks_per_season + 1:
max_game = config.playoff_round_one_games
elif week == config.weeks_per_season + 2:
max_game = config.playoff_round_two_games
elif week == config.weeks_per_season + 3:
max_game = config.playoff_round_three_games
else:
max_game = config.games_per_week # Fallback
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > 4:
raise ValueError("Game must be between 1 and 4")
if game < 1 or game > max_game:
raise ValueError(f"Game must be between 1 and {max_game}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description="Current game must be a number between 1 and 4."
description=f"Current game must be a number between 1 and {max_game}."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Calculate injury dates
out_weeks = math.floor(self.injury_games / 4)
out_games = self.injury_games % 4
out_weeks = math.floor(self.injury_games / config.games_per_week)
out_games = self.injury_games % config.games_per_week
return_week = week + out_weeks
return_game = game + 1 + out_games
if return_game > 4:
if return_game > config.games_per_week:
return_week += 1
return_game -= 4
return_game -= config.games_per_week
# Adjust start date if injury starts after game 4
start_week = week if game != 4 else week + 1
start_game = game + 1 if game != 4 else 1
start_week = week if game != config.games_per_week else week + 1
start_game = game + 1 if game != config.games_per_week else 1
return_date = f'w{return_week:02d}g{return_game}'
@ -707,30 +723,46 @@ class PitcherRestModal(BaseModal):
from services.player_service import player_service
from services.injury_service import injury_service
from models.injury import Injury
from config import get_config
import math
config = get_config()
max_week = config.weeks_per_season + config.playoff_weeks_per_season
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > 18:
raise ValueError("Week must be between 1 and 18")
if week < 1 or week > max_week:
raise ValueError(f"Week must be between 1 and {max_week}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description="Current week must be a number between 1 and 18."
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Determine max games based on week (regular season vs playoff rounds)
if week <= config.weeks_per_season:
max_game = config.games_per_week
elif week == config.weeks_per_season + 1:
max_game = config.playoff_round_one_games
elif week == config.weeks_per_season + 2:
max_game = config.playoff_round_two_games
elif week == config.weeks_per_season + 3:
max_game = config.playoff_round_three_games
else:
max_game = config.games_per_week # Fallback
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > 4:
raise ValueError("Game must be between 1 and 4")
if game < 1 or game > max_game:
raise ValueError(f"Game must be between 1 and {max_game}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description="Current game must be a number between 1 and 4."
description=f"Current game must be a number between 1 and {max_game}."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return