Added /jump command for baserunner stealing mechanics with pickoff/balk detection. Enhanced dice rolling commands with team color support in embeds. Improved /admin-sync with local/global options and prefix command fallback. Fixed type safety issues in admin commands and injury management. Updated config for expanded draft rounds and testing mode. Key changes: - commands/dice/rolls.py: New /jump and !j commands with special cases for pickoff (d20=1) and balk (d20=2) - commands/dice/rolls.py: Added team/channel color support to /ab and dice embeds - commands/dice/rolls.py: Added pitcher position to /fielding command with proper range/error charts - commands/admin/management.py: Enhanced /admin-sync with local/clear options and !admin-sync prefix fallback - commands/admin/management.py: Fixed Member type checking and channel type validation - commands/injuries/management.py: Fixed responder team detection for injury clearing - models/custom_command.py: Made creator_id optional for execute endpoint compatibility - config.py: Updated draft_rounds to 32 and enabled testing mode - services/transaction_builder.py: Adjusted ML roster limit to 26 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
889 lines
37 KiB
Python
889 lines
37 KiB
Python
"""
|
|
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
|
|
|
|
from models.team import Team
|
|
from services.team_service import team_service
|
|
from utils import team_utils
|
|
from utils.logging import get_contextual_logger
|
|
from utils.decorators import logged_command
|
|
from utils.team_utils import get_user_major_league_team
|
|
from views.embeds import EmbedColors, EmbedTemplate
|
|
|
|
|
|
@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."""
|
|
|
|
def __init__(self, bot: commands.Bot):
|
|
self.bot = bot
|
|
self.logger = get_contextual_logger(f'{__name__}.DiceRollCommands')
|
|
|
|
@discord.app_commands.command(
|
|
name="roll",
|
|
description="Roll polyhedral dice using XdY notation (e.g., 2d6, 1d20, 3d8)"
|
|
)
|
|
@discord.app_commands.describe(
|
|
dice="Dice notation - single or multiple separated by semicolon (e.g., 2d6, 1d20;2d6;1d6)"
|
|
)
|
|
@logged_command("/roll")
|
|
async def roll_dice(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
dice: str
|
|
):
|
|
"""Roll dice using standard XdY dice notation. Supports multiple rolls separated by semicolon."""
|
|
await interaction.response.defer()
|
|
|
|
# Parse and validate dice notation (supports multiple rolls)
|
|
roll_results = self._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",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Create embed for the roll results
|
|
embed = self._create_multi_roll_embed(dice, roll_results, interaction.user)
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
@commands.command(name="roll", aliases=["r", "dice"])
|
|
async def roll_dice_prefix(self, ctx: commands.Context, *, dice: str | None = None):
|
|
"""Roll dice using prefix commands (!roll, !r, !dice)."""
|
|
self.logger.info(f"Prefix roll command started by {ctx.author.display_name}", dice_input=dice)
|
|
|
|
if dice is None:
|
|
self.logger.debug("No dice input provided")
|
|
await ctx.send("❌ Please provide dice notation. Usage: `!roll 2d6` or `!roll 1d6;2d6;1d20`")
|
|
return
|
|
|
|
# Parse and validate dice notation (supports multiple rolls)
|
|
roll_results = self._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")
|
|
return
|
|
|
|
self.logger.info(f"Dice rolled successfully", roll_count=len(roll_results))
|
|
|
|
# Create embed for the roll results
|
|
embed = self._create_multi_roll_embed(dice, roll_results, ctx.author)
|
|
await ctx.send(embed=embed)
|
|
|
|
@discord.app_commands.command(
|
|
name="ab",
|
|
description="Roll baseball at-bat dice (1d6;2d6;1d20)"
|
|
)
|
|
@logged_command("/ab")
|
|
async def ab_dice(self, interaction: discord.Interaction):
|
|
"""Roll the standard baseball at-bat dice combination."""
|
|
await interaction.response.defer()
|
|
embed_color = await self._get_channel_embed_color(interaction)
|
|
|
|
# Use the standard baseball dice combination
|
|
dice_notation = "1d6;2d6;1d20"
|
|
roll_results = self._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'At bat roll for {interaction.user.display_name}'
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
@commands.command(name="ab", aliases=["atbat"])
|
|
async def ab_dice_prefix(self, ctx: commands.Context):
|
|
"""Roll baseball at-bat dice using prefix commands (!ab, !atbat)."""
|
|
self.logger.info(f"At Bat dice command started by {ctx.author.display_name}")
|
|
team = await get_user_major_league_team(user_id=ctx.author.id)
|
|
embed_color = EmbedColors.PRIMARY
|
|
if team is not None and team.color is not None:
|
|
embed_color = int(team.color,16)
|
|
|
|
# Use the standard baseball dice combination
|
|
dice_notation = "1d6;2d6;1d20"
|
|
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
|
|
|
self.logger.info("At Bat dice rolled successfully", roll_count=len(roll_results))
|
|
|
|
# Create embed for the roll results
|
|
embed = self._create_multi_roll_embed(dice_notation, roll_results, ctx.author, set_author=False, embed_color=embed_color)
|
|
embed.title = f'At bat roll for {ctx.author.display_name}'
|
|
await ctx.send(embed=embed)
|
|
|
|
@discord.app_commands.command(
|
|
name="scout",
|
|
description="Roll weighted scouting dice (1d6;2d6;1d20) based on card type"
|
|
)
|
|
@discord.app_commands.describe(
|
|
card_type="Type of card being scouted"
|
|
)
|
|
@discord.app_commands.choices(card_type=[
|
|
discord.app_commands.Choice(name="Batter", value="batter"),
|
|
discord.app_commands.Choice(name="Pitcher", value="pitcher")
|
|
])
|
|
@logged_command("/scout")
|
|
async def scout_dice(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
card_type: discord.app_commands.Choice[str]
|
|
):
|
|
"""Roll weighted scouting dice based on card type (batter or pitcher)."""
|
|
await interaction.response.defer()
|
|
|
|
# Get the card type value
|
|
card_type_value = card_type.value
|
|
|
|
# Roll weighted scouting dice
|
|
roll_results = self._roll_weighted_scout_dice(card_type_value)
|
|
|
|
# Create embed for the roll results
|
|
embed = self._create_multi_roll_embed("1d6;2d6;1d20", roll_results, interaction.user, set_author=False)
|
|
embed.title = f'Scouting roll for {interaction.user.display_name}'
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
@discord.app_commands.command(
|
|
name="fielding",
|
|
description="Roll Super Advanced fielding dice for a defensive position"
|
|
)
|
|
@discord.app_commands.describe(
|
|
position="Defensive position"
|
|
)
|
|
@discord.app_commands.choices(position=[
|
|
discord.app_commands.Choice(name="Pitcher (P)", value="P"),
|
|
discord.app_commands.Choice(name="Catcher (C)", value="C"),
|
|
discord.app_commands.Choice(name="First Base (1B)", value="1B"),
|
|
discord.app_commands.Choice(name="Second Base (2B)", value="2B"),
|
|
discord.app_commands.Choice(name="Third Base (3B)", value="3B"),
|
|
discord.app_commands.Choice(name="Shortstop (SS)", value="SS"),
|
|
discord.app_commands.Choice(name="Left Field (LF)", value="LF"),
|
|
discord.app_commands.Choice(name="Center Field (CF)", value="CF"),
|
|
discord.app_commands.Choice(name="Right Field (RF)", value="RF")
|
|
])
|
|
@logged_command("/fielding")
|
|
async def fielding_roll(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
position: discord.app_commands.Choice[str]
|
|
):
|
|
"""Roll Super Advanced fielding dice for a defensive position."""
|
|
await interaction.response.defer()
|
|
|
|
# Get the position value from the choice
|
|
pos_value = position.value
|
|
|
|
# Roll the dice - 1d20 and 3d6
|
|
dice_notation = "1d20;3d6"
|
|
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
|
|
|
# Create fielding embed
|
|
embed = self._create_fielding_embed(pos_value, roll_results, interaction.user)
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
@commands.command(name="f", aliases=["fielding", "saf"])
|
|
async def fielding_roll_prefix(self, ctx: commands.Context, position: str | None = None):
|
|
"""Roll Super Advanced fielding dice using prefix commands (!f, !fielding, !saf)."""
|
|
self.logger.info(f"SA Fielding command started by {ctx.author.display_name}", position=position)
|
|
|
|
if position is None:
|
|
await ctx.send("❌ Please specify a position. Usage: `!f 3B` or `!fielding SS`")
|
|
return
|
|
|
|
# Parse and validate position
|
|
parsed_position = self._parse_position(position)
|
|
if not parsed_position:
|
|
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)
|
|
|
|
self.logger.info("SA Fielding dice rolled successfully", position=parsed_position, d20=roll_results[0].total, d6_total=roll_results[1].total)
|
|
|
|
# Create fielding embed
|
|
embed = self._create_fielding_embed(parsed_position, roll_results, ctx.author)
|
|
await ctx.send(embed=embed)
|
|
|
|
@discord.app_commands.command(
|
|
name="jump",
|
|
description="Roll for baserunner's jump before stealing"
|
|
)
|
|
@logged_command("/jump")
|
|
async def jump_dice(self, interaction: discord.Interaction):
|
|
"""Roll to check for a baserunner's jump before attempting to steal a base."""
|
|
await interaction.response.defer()
|
|
embed_color = await self._get_channel_embed_color(interaction)
|
|
|
|
# Roll 1d20 for pickoff/balk check
|
|
check_roll = random.randint(1, 20)
|
|
|
|
# Roll 2d6 for jump rating
|
|
jump_result = self._parse_and_roll_single_dice("2d6")
|
|
|
|
# Roll another 1d20 for pickoff/balk resolution
|
|
resolution_roll = random.randint(1, 20)
|
|
|
|
# Create embed based on check roll
|
|
embed = self._create_jump_embed(
|
|
check_roll,
|
|
jump_result,
|
|
resolution_roll,
|
|
interaction.user,
|
|
embed_color,
|
|
show_author=False
|
|
)
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
@commands.command(name="j", aliases=["jump"])
|
|
async def jump_dice_prefix(self, ctx: commands.Context):
|
|
"""Roll for baserunner's jump using prefix commands (!j, !jump)."""
|
|
self.logger.info(f"Jump command started by {ctx.author.display_name}")
|
|
team = await get_user_major_league_team(user_id=ctx.author.id)
|
|
embed_color = EmbedColors.PRIMARY
|
|
if team is not None and team.color is not None:
|
|
embed_color = int(team.color, 16)
|
|
|
|
# Roll 1d20 for pickoff/balk check
|
|
check_roll = random.randint(1, 20)
|
|
|
|
# Roll 2d6 for jump rating
|
|
jump_result = self._parse_and_roll_single_dice("2d6")
|
|
|
|
# Roll another 1d20 for pickoff/balk resolution
|
|
resolution_roll = random.randint(1, 20)
|
|
|
|
self.logger.info("Jump dice rolled successfully", check=check_roll, jump=jump_result.total if jump_result else None, resolution=resolution_roll)
|
|
|
|
# Create embed based on check roll
|
|
embed = self._create_jump_embed(
|
|
check_roll,
|
|
jump_result,
|
|
resolution_roll,
|
|
ctx.author,
|
|
embed_color
|
|
)
|
|
await ctx.send(embed=embed)
|
|
|
|
async def _get_channel_embed_color(self, interaction: discord.Interaction) -> int:
|
|
# Check if channel is a type that has a name attribute (DMChannel doesn't have one)
|
|
if isinstance(interaction.channel, (discord.TextChannel, discord.VoiceChannel, discord.Thread)):
|
|
channel_starter = interaction.channel.name[:6]
|
|
if '-' in channel_starter:
|
|
abbrev = channel_starter.split('-')[0]
|
|
channel_team = await team_service.get_team_by_abbrev(abbrev)
|
|
if channel_team is not None and channel_team.color is not None:
|
|
return int(channel_team.color,16)
|
|
|
|
team = await get_user_major_league_team(user_id=interaction.user.id)
|
|
if team is not None and team.color is not None:
|
|
return int(team.color,16)
|
|
|
|
return EmbedColors.PRIMARY
|
|
|
|
def _parse_position(self, position: str) -> str | None:
|
|
"""Parse and validate fielding position input for prefix commands."""
|
|
if not position:
|
|
return None
|
|
|
|
pos = position.upper().strip()
|
|
|
|
# Map common inputs to standard position names
|
|
position_map = {
|
|
'P': 'P', 'PITCHER': 'P',
|
|
'C': 'C', 'CATCHER': 'C',
|
|
'1': '1B', '1B': '1B', 'FIRST': '1B', 'FIRSTBASE': '1B',
|
|
'2': '2B', '2B': '2B', 'SECOND': '2B', 'SECONDBASE': '2B',
|
|
'3': '3B', '3B': '3B', 'THIRD': '3B', 'THIRDBASE': '3B',
|
|
'SS': 'SS', 'SHORT': 'SS', 'SHORTSTOP': 'SS',
|
|
'LF': 'LF', 'LEFT': 'LF', 'LEFTFIELD': 'LF',
|
|
'CF': 'CF', 'CENTER': 'CF', 'CENTERFIELD': 'CF',
|
|
'RF': 'RF', 'RIGHT': 'RF', 'RIGHTFIELD': 'RF'
|
|
}
|
|
|
|
return position_map.get(pos)
|
|
|
|
def _create_fielding_embed(self, position: str, roll_results: list[DiceRoll], user) -> discord.Embed:
|
|
"""Create an embed for fielding roll results."""
|
|
d20_result = roll_results[0].total
|
|
d6_total = roll_results[1].total
|
|
d6_rolls = roll_results[1].rolls
|
|
|
|
# Create base embed
|
|
embed = EmbedTemplate.create_base_embed(
|
|
title=f"SA Fielding roll for {user.display_name}",
|
|
color=EmbedColors.PRIMARY
|
|
)
|
|
|
|
# Set user info
|
|
embed.set_author(
|
|
name=user.display_name,
|
|
icon_url=user.display_avatar.url
|
|
)
|
|
|
|
# Add dice results in standard format
|
|
dice_notation = "1d20;3d6"
|
|
embed_dice = self._create_multi_roll_embed(dice_notation, roll_results, user)
|
|
|
|
# Extract just the dice result part from the field
|
|
dice_field_value = embed_dice.fields[0].value
|
|
embed.add_field(
|
|
name="Dice Results",
|
|
value=dice_field_value,
|
|
inline=False
|
|
)
|
|
|
|
# Add fielding check summary
|
|
range_result = self._get_range_result(position, d20_result)
|
|
embed.add_field(
|
|
name=f"{position} Range Result",
|
|
value=f"```md\n 1 | 2 | 3 | 4 | 5\n{range_result}```",
|
|
inline=False
|
|
)
|
|
|
|
# Add error result
|
|
error_result = self._get_error_result(position, d6_total)
|
|
if error_result:
|
|
embed.add_field(
|
|
name="Error Result",
|
|
value=error_result,
|
|
inline=False
|
|
)
|
|
|
|
# # Add help commands
|
|
# embed.add_field(
|
|
# name="Help Commands",
|
|
# value="Run !<result> for full chart readout (e.g. !g1 or !do3)",
|
|
# inline=False
|
|
# )
|
|
|
|
# # Add references
|
|
# embed.add_field(
|
|
# name="References",
|
|
# value="Range Chart / Error Chart / Result Reference",
|
|
# inline=False
|
|
# )
|
|
|
|
return embed
|
|
|
|
def _create_jump_embed(
|
|
self,
|
|
check_roll: int,
|
|
jump_result: DiceRoll | None,
|
|
resolution_roll: int,
|
|
user: discord.User | discord.Member,
|
|
embed_color: int = EmbedColors.PRIMARY,
|
|
show_author: bool = True
|
|
) -> discord.Embed:
|
|
"""Create an embed for jump roll results."""
|
|
# Create base embed
|
|
embed = EmbedTemplate.create_base_embed(
|
|
title=f"Jump roll for {user.name}",
|
|
color=embed_color
|
|
)
|
|
|
|
if show_author:
|
|
# Set user info
|
|
embed.set_author(
|
|
name=user.name,
|
|
icon_url=user.display_avatar.url
|
|
)
|
|
|
|
# Check for pickoff or balk
|
|
if check_roll == 1:
|
|
# Pickoff attempt
|
|
embed.add_field(
|
|
name="Special",
|
|
value="```md\nCheck pickoff```",
|
|
inline=False
|
|
)
|
|
embed.add_field(
|
|
name="Pickoff roll",
|
|
value=f"```md\n# {resolution_roll}\nDetails:[1d20 ({resolution_roll})]```",
|
|
inline=False
|
|
)
|
|
elif check_roll == 2:
|
|
# Balk
|
|
embed.add_field(
|
|
name="Special",
|
|
value="```md\nCheck balk```",
|
|
inline=False
|
|
)
|
|
embed.add_field(
|
|
name="Balk roll",
|
|
value=f"```md\n# {resolution_roll}\nDetails:[1d20 ({resolution_roll})]```",
|
|
inline=False
|
|
)
|
|
else:
|
|
# Normal jump - show 2d6 result
|
|
if jump_result:
|
|
rolls_str = ' '.join(str(r) for r in jump_result.rolls)
|
|
embed.add_field(
|
|
name="Result",
|
|
value=f"```md\n# {jump_result.total}\nDetails:[2d6 ({rolls_str})]```",
|
|
inline=False
|
|
)
|
|
|
|
return embed
|
|
|
|
def _get_range_result(self, position: str, d20_roll: int) -> str:
|
|
"""Get the range result display for a position and d20 roll."""
|
|
if position == 'P':
|
|
return self._get_pitcher_range(d20_roll)
|
|
elif position in ['1B', '2B', '3B', 'SS']:
|
|
return self._get_infield_range(d20_roll)
|
|
elif position in ['LF', 'CF', 'RF']:
|
|
return self._get_outfield_range(d20_roll)
|
|
elif position == 'C':
|
|
return self._get_catcher_range(d20_roll)
|
|
return "Unknown position"
|
|
|
|
def _get_infield_range(self, d20_roll: int) -> str:
|
|
"""Get infield range result based on d20 roll."""
|
|
infield_ranges = {
|
|
1: 'G3# SI1 ----SI2----',
|
|
2: 'G2# SI1 ----SI2----',
|
|
3: 'G2# G3# SI1 --SI2--',
|
|
4: 'G2# G3# SI1 --SI2--',
|
|
5: 'G1 --G3#-- SI1 SI2',
|
|
6: 'G1 G2# G3# SI1 SI2',
|
|
7: 'G1 G2 --G3#-- SI1',
|
|
8: 'G1 G2 --G3#-- SI1',
|
|
9: 'G1 G2 G3 --G3#--',
|
|
10: '--G1--- G2 --G3#--',
|
|
11: '--G1--- G2 G3 G3#',
|
|
12: '--G1--- G2 G3 G3#',
|
|
13: '--G1--- G2 --G3---',
|
|
14: '--G1--- --G2--- G3',
|
|
15: '----G1----- G2 G3',
|
|
16: '----G1----- G2 G3',
|
|
17: '------G1------- G3',
|
|
18: '------G1------- G2',
|
|
19: '------G1------- G2',
|
|
20: '--------G1---------'
|
|
}
|
|
return infield_ranges.get(d20_roll, 'Unknown')
|
|
|
|
def _get_outfield_range(self, d20_roll: int) -> str:
|
|
"""Get outfield range result based on d20 roll."""
|
|
outfield_ranges = {
|
|
1: 'F1 DO2 DO3 --TR3--',
|
|
2: 'F2 SI2 DO2 DO3 TR3',
|
|
3: 'F2 SI2 --DO2-- DO3',
|
|
4: 'F2 F1 SI2 DO2 DO3',
|
|
5: '--F2--- --SI2-- DO2',
|
|
6: '--F2--- --SI2-- DO2',
|
|
7: '--F2--- F1 SI2 DO2',
|
|
8: '--F2--- F1 --SI2--',
|
|
9: '----F2----- --SI2--',
|
|
10: '----F2----- --SI2--',
|
|
11: '----F2----- --SI2--',
|
|
12: '----F2----- F1 SI2',
|
|
13: '----F2----- F1 SI2',
|
|
14: 'F3 ----F2----- SI2',
|
|
15: 'F3 ----F2----- SI2',
|
|
16: '--F3--- --F2--- F1',
|
|
17: '----F3----- F2 F1',
|
|
18: '----F3----- F2 F1',
|
|
19: '------F3------- F2',
|
|
20: '--------F3---------'
|
|
}
|
|
return outfield_ranges.get(d20_roll, 'Unknown')
|
|
|
|
def _get_catcher_range(self, d20_roll: int) -> str:
|
|
"""Get catcher range result based on d20 roll."""
|
|
catcher_ranges = {
|
|
1: 'G3 ------SI1------',
|
|
2: 'G3 SPD ----SI1----',
|
|
3: '--G3--- SPD --SI1--',
|
|
4: 'G2 G3 --SPD-- SI1',
|
|
5: 'G2 --G3--- --SPD--',
|
|
6: '--G2--- G3 --SPD--',
|
|
7: 'PO G2 G3 --SPD--',
|
|
8: 'PO --G2--- G3 SPD',
|
|
9: '--PO--- G2 G3 SPD',
|
|
10: 'FO PO G2 G3 SPD',
|
|
11: 'FO --PO--- G2 G3',
|
|
12: '--FO--- PO G2 G3',
|
|
13: 'G1 FO PO G2 G3',
|
|
14: 'G1 --FO--- PO G2',
|
|
15: '--G1--- FO PO G2',
|
|
16: '--G1--- FO PO G2',
|
|
17: '----G1----- FO PO',
|
|
18: '----G1----- FO PO',
|
|
19: '----G1----- --FO---',
|
|
20: '------G1------- FO'
|
|
}
|
|
return catcher_ranges.get(d20_roll, 'Unknown')
|
|
|
|
def _get_pitcher_range(self, d20_roll: int) -> str:
|
|
"""Get pitcher range result based on d20 roll."""
|
|
pitcher_ranges = {
|
|
1: 'G3 ------SI1------',
|
|
2: 'G3 ------SI1------',
|
|
3: '--G3--- ----SI1----',
|
|
4: '----G3----- --SI1--',
|
|
5: '------G3------- SI1',
|
|
6: '------G3------- SI1',
|
|
7: '--------G3---------',
|
|
8: 'G2 ------G3-------',
|
|
9: 'G2 ------G3-------',
|
|
10: 'G1 G2 ----G3-----',
|
|
11: 'G1 G2 ----G3-----',
|
|
12: 'G1 G2 ----G3-----',
|
|
13: '--G1--- G2 --G3---',
|
|
14: '--G1--- --G2--- G3',
|
|
15: '--G1--- ----G2-----',
|
|
16: '--G1--- ----G2-----',
|
|
17: '----G1----- --G2---',
|
|
18: '----G1----- --G2---',
|
|
19: '------G1------- G2',
|
|
20: '--------G1---------'
|
|
}
|
|
return pitcher_ranges.get(d20_roll, 'Unknown')
|
|
|
|
def _get_error_result(self, position: str, d6_total: int) -> str:
|
|
"""Get the error result for a position and 3d6 total."""
|
|
# Get the appropriate error chart
|
|
if position == 'P':
|
|
return self._get_pitcher_error(d6_total)
|
|
elif position == '1B':
|
|
return self._get_1b_error(d6_total)
|
|
elif position == '2B':
|
|
return self._get_2b_error(d6_total)
|
|
elif position == '3B':
|
|
return self._get_3b_error(d6_total)
|
|
elif position == 'SS':
|
|
return self._get_ss_error(d6_total)
|
|
elif position in ['LF', 'RF']:
|
|
return self._get_corner_of_error(d6_total)
|
|
elif position == 'CF':
|
|
return self._get_cf_error(d6_total)
|
|
elif position == 'C':
|
|
return self._get_catcher_error(d6_total)
|
|
|
|
# Should never reach here due to position validation, but follow "Raise or Return" pattern
|
|
raise ValueError(f"Unknown position: {position}")
|
|
|
|
def _get_3b_error(self, d6_total: int) -> str:
|
|
"""Get 3B error result based on 3d6 total."""
|
|
errors = {
|
|
18: '2-base error for e11 -> e18, e32, e33, e37, e53, e62, e65\n1-base error for e4, e8, e19, e21, e22, e27, e41',
|
|
17: '2-base error for e3 -> e10, e17, e18, e25 -> e27, e34 -> e37, e44, e47\n1-base error for e11, e19, e32, e56',
|
|
16: '2-base error for e11 -> e18, e32, e33, e37, e53, e62, e65\n1-base error for e4, e8, e19, e21, e22, e27, e41',
|
|
15: '2-base error for e19 -> 27, e32, e33, e37, e39, e44, e50, e59\n1-base error for e5 -> e8, e11, e14, e15, e17, e18, e28 -> e31, e34',
|
|
14: '2-base error for e28 -> e31, e34, e35, e50\n1-base error for e14, e16, e19, e20, e22, e32, e39, e44, e56, e62',
|
|
13: '2-base error for e41, e47, e53, e59\n1-base error for e10, e15, e23, e25, e28, e30, e32, e33, e35, e44, e65',
|
|
12: '2-base error for e62\n1-base error for e12, e17, e22, e24, e27, e29, e34 -> e50, e56 -> e59, e65',
|
|
11: '2-base error for e56, e65\n1-base error for e13, e18, e20, e21, e23, e26, e28, e31 -> e33, e35, e37, e41 -> e53, e59',
|
|
10: '1-base error for e26, e31, e41, e53 -> 65',
|
|
9: '1-base error for e24, e27, e29, e34, e37, e39, e47 -> e65',
|
|
8: '1-base error for e25, e30, e33, e47, e53, e56, e62, e65',
|
|
7: '1-base error for e16, e19, e39, e59 -> e65',
|
|
6: '1-base error for e21, e25, e30, e34, e53',
|
|
5: 'No error',
|
|
4: '1-base error for e2, e3, e6, e14, e16, e44',
|
|
3: '2-base error for e10, e15, e16, e23, e24, e56\n1-base error for e1 -> e4, e8, e14'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_1b_error(self, d6_total: int) -> str:
|
|
"""Get 1B error result based on 3d6 total."""
|
|
errors = {
|
|
18: '2-base error for e3 -> e12, e19 -> e28\n1-base error for e1, e2, e30',
|
|
17: '2-base error for e13 -> e28\n1-base error for e1, e5, e8, e9, e29',
|
|
16: '2-base error for e29, e30\n1-base error for e2, e8, e16, e19, e23',
|
|
15: '1-base error for e3, e8, e10 -> e12, e20, e26, e30',
|
|
14: '1-base error for e4, e5, e9, e15, e18, e22, e24 -> e28',
|
|
13: '1-base error for e6, e13, e24, e26 -> e28, e30',
|
|
12: '1-base error for e14 -> e18, e21 -> e26, e28 -> e30',
|
|
11: '1-base error for e10, e13, e16 -> e20, e23 -> e25, e27 -> e30',
|
|
10: '1-base error for e19 -> e21, e23, e29',
|
|
9: '1-base error for e7, e12, e14, e21, e25, e26, e29',
|
|
8: '1-base error for e11, e27',
|
|
7: '1-base error for e9, e15, e22, e27, e28',
|
|
6: '1-base error for e8, e11, e12, e17, e20',
|
|
5: 'No error',
|
|
4: 'No error',
|
|
3: '2-base error for e8 -> e12, e24 -> e28\n1-base error for e2, e3, e6, e7, e14, e16, e17, e21'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_2b_error(self, d6_total: int) -> str:
|
|
"""Get 2B error result based on 3d6 total."""
|
|
errors = {
|
|
18: '2-base error for e4 -> e19, e28 -> e41, e53 -> e65\n1-base error for e22, e24, e25, e27, e44, e50',
|
|
17: '2-base error for e20 -> e41, e68, e71\n1-base error for e3, e4, e8 -> e12, e15, e16, e19',
|
|
16: '2-base error for e53 -> 71\n1-base error for e5 -> 10, e14, e16, e29, e37',
|
|
15: '1-base error for e11, e12, e14, e16, e17, e19, e26 -> e28, e30, e32, e37, e50 -> e62, e71',
|
|
14: '1-base error for e13, e15, e34, e47, e65',
|
|
13: '1-base error for e18, e20, e21, e26 -> e28, e39, e41, e50, e56, e59, e65, e71',
|
|
12: '1-base error for e22, e30, e34, e39, e44, e47, e53, e56, e62, e68, e71',
|
|
11: '1-base error for e23 -> e25, e29, e32, e37, e41, e50, e53, e59, e62, e68',
|
|
10: '1-base error for e68',
|
|
9: '1-base error for e44',
|
|
8: 'No error',
|
|
7: '1-base error for e47, e65',
|
|
6: '1-base error for e17, e19, e56 -> 62',
|
|
5: 'No error',
|
|
4: '1-base error for e10, e21',
|
|
3: '2-base error for e12 -> e19, e37 -> e41, e59 -> e65\n1-base error for e2 -> e4, e6, e20, e25, e28, e29'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_ss_error(self, d6_total: int) -> str:
|
|
"""Get SS error result based on 3d6 total."""
|
|
errors = {
|
|
18: '2-base error for e4 -> e12, e22 -> e32, e40 -> e48, e64, e68\n1-base error for e1, e18, e34, e52, e56',
|
|
17: '2-base error for e14 -> 32, e52, e56, e72 -> e84\n1-base error for e3 -> e5, e8 ,e10, e36',
|
|
16: '2-base error for e33 -> 56, e72\n1-base error for e6 -> e10, e17, e18, e20, e28, e31, e88',
|
|
15: '2-base error for e60 -> e68, e76 -> 84\n1-base error for e12, e14, e17, e18, e20 -> e22, e24, e28, e31 -> 36, e40, e48, e72',
|
|
14: '1-base error for e16, e19, e38, e42, e60, e68',
|
|
13: '1-base error for e23, e25, e32 -> 38, e44, e52, e72 -> 84',
|
|
12: '1-base error for e26, e27, e30, e42, e48, e56, e64, e68, e76 -> e88',
|
|
11: '1-base error for e29, e40, e52 -> e60, e72, e80 -> e88',
|
|
10: '1-base error for e84',
|
|
9: '1-base error for e64, e68, e76, e88',
|
|
8: '1-base error for e44',
|
|
7: '1-base error for e60',
|
|
6: '1-base error for e21, e22, e24, e28, e31, e48, e64, e72',
|
|
5: 'No error',
|
|
4: '2-base error for e72\n1-base error for e14, e19, e20, e24, e25, e30, e31, e80',
|
|
3: '2-base error for e10, e12, e28 -> e32, e48, e84\n1-base error for e2, e5, e7, e23, e27'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_corner_of_error(self, d6_total: int) -> str:
|
|
"""Get LF/RF error result based on 3d6 total."""
|
|
errors = {
|
|
18: '3-base error for e4 -> e12, e19 -> e25\n2-base error for e18\n1-base error for e2, e3, e15',
|
|
17: '3-base error for e13 -> e25\n2-base error for e1, e6, e8, e10',
|
|
16: '2-base error for e2\n1-base error for e7 -> 12, e22, e24, e25',
|
|
15: '2-base error for e3, e4, e7, e8, e10, e11, e13, e20, e21',
|
|
14: '2-base error for e5, e6, e10, e12, e14, e15, e22, e23',
|
|
13: '2-base error for e11, e12, e16, e20, e24, e25',
|
|
12: '2-base error for e13 -> e18, e21 -> e23, e25',
|
|
11: '2-base error for e9, e18 -> e21, e23 -> e25',
|
|
10: '2-base error for e19',
|
|
9: '2-base error for e22',
|
|
8: '2-base error for e24',
|
|
7: '1-base error for e19 -> e21, e23',
|
|
6: '2-base error for e7, e8\n1-base error for e13 -> e18, e22, e24, e25',
|
|
5: 'No error',
|
|
4: '2-base error for e1, e5, e6, e9\n1-base error for e14 -> e16, e20 -> e23',
|
|
3: '3-base error for e16 -> e25\n2-base error for e1, e3, e4, e7, e9, e11\n1-base error for e17'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_cf_error(self, d6_total: int) -> str:
|
|
"""Get CF error result based on 3d6 total."""
|
|
errors = {
|
|
18: '3-base error for e8 -> e16, e24 -> e32\n2-base error for e1, e2, e40\n1-base error for e17, e19, e21, e36',
|
|
17: '3-base error for e17 -> e32, e34, e36, e38\n2-base error for e3 -> e7, e10, e12, e14, e22',
|
|
16: '2-base error for e1, e2, e4, e8 -> e12, e17, e19, e24, e26, e28, e32, e34',
|
|
15: '2-base error for e5 -> e8, e13, e15 -> e19, e21, e24, e28, e30, e36, e38, e40',
|
|
14: '2-base error for e9 -> e11, e14, e20, e22, e26, e30, e34, e38',
|
|
13: '2-base error for e12 -> e21, e23, e25, e26, e32, e36, e40',
|
|
12: '2-base error for e22 -> e25, e27 -> e32, e34 -> e40',
|
|
11: '2-base error for e26, e27, e29 -> e34, e36 -> e40',
|
|
10: '2-base error for e28',
|
|
9: '2-base error for e29',
|
|
8: '2-base error for e30',
|
|
7: '1-base error for e27, e28, e31, e32, e35',
|
|
6: '2-base error for e15, e16\n1-base error for e23 -> e32, e34 -> e40',
|
|
5: 'No error',
|
|
4: '2-base error for e9 -> e13, e17, e19 -> e21\n1-base error for e24, e25, e29 -> e38',
|
|
3: '3-base error for e24 -> e32, e36 -> e40\n2-base error for e1 -> e8, e10, e14\n1-base error for e15'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_catcher_error(self, d6_total: int) -> str:
|
|
"""Get Catcher error result based on 3d6 total."""
|
|
errors = {
|
|
18: 'Passed ball for sb2 -> sb12, sb16 -> sb26\nNo error for sb14',
|
|
17: 'Passed ball for sb3 -> sb12, sb17 -> sb26\nNo error for sb1, sb13 -> sb15',
|
|
16: 'Passed ball for sb4 -> sb12, sb18 -> sb26',
|
|
15: 'Passed ball for sb5 -> sb12, sb19 -> sb26',
|
|
14: 'Passed ball for sb6 -> sb12, sb20 -> sb26',
|
|
13: 'Passed ball for sb7 -> sb12, sb21 -> sb26',
|
|
12: 'Passed ball for sb8 -> sb12, sb22 -> sb26',
|
|
11: 'Passed ball for sb9 -> sb12, sb23 -> sb26',
|
|
10: 'Passed ball for sb10 -> sb12, sb24 -> sb26',
|
|
9: 'Passed ball for sb11, sb12, sb25, sb26',
|
|
8: 'No error',
|
|
7: 'No error',
|
|
6: 'No error',
|
|
5: 'No error',
|
|
4: 'Passed ball for sb1 -> sb12, sb15 -> sb26\nNo error for sb13, sb14',
|
|
3: 'Passed ball for sb1 -> sb26'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
def _get_pitcher_error(self, d6_total: int) -> str:
|
|
"""Get Pitcher error result based on 3d6 total."""
|
|
errors = {
|
|
18: '2-base error for e4 -> e12, e19 -> e28, e34 -> e43, e46 -> e48',
|
|
17: '2-base error for e13 -> e28, e44 -> e50',
|
|
16: '2-base error for e30 -> e48, e50, e51\n1-base error for e8, e11, e16, e23',
|
|
15: '2-base error for e50, e51\n1-base error for e10 -> e12, e19, e20, e24, e26, e30, e35, e38, e40, e46, e47',
|
|
14: '1-base error for e4, e14, e18, e21, e22, e26, e31, e35, e42, e43, e48 -> e51',
|
|
13: '1-base error for e6, e13, e14, e21, e22, e26, e27, e30 -> 34, e38 -> e51',
|
|
12: '1-base error for e7, e11, e12, e15 -> e19, e22 -> e51',
|
|
11: '1-base error for e10, e13, e15, e17, e18, e20, e21, e23, e24, e27 -> 38, e40, e42, e44 -> e51',
|
|
10: '1-base error for e20, e23, e24, e27 -> e51',
|
|
9: '1-base error for e16, e19, e26, e28, e34 -> e36, e39 -> e51',
|
|
8: '1-base error for e22, e33, e38, e39, e43 -> e51',
|
|
7: '1-base error for e14, e21, e36, e39, e42 -> e44, e47 -> e51',
|
|
6: '1-base error for e8, e22, e38, e39, e43 -> e51',
|
|
5: 'No error',
|
|
4: '1-base error for e15, e16, e40',
|
|
3: '2-base error for e8 -> e12, e26 -> e28, e39 -> e43\n1-base error for e2, e3, e7, e14, e15'
|
|
}
|
|
return errors.get(d6_total, 'No error')
|
|
|
|
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) -> Optional[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:
|
|
return None
|
|
|
|
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:
|
|
return None
|
|
|
|
# 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]:
|
|
"""
|
|
Roll scouting dice with weighted first d6 based on card type.
|
|
|
|
Args:
|
|
card_type: Either "batter" (1-3) or "pitcher" (4-6) for first d6
|
|
|
|
Returns:
|
|
List of 3 roll result dataclasses: weighted 1d6, normal 2d6, normal 1d20
|
|
"""
|
|
# First die (1d6) - weighted based on card type
|
|
if card_type == "batter":
|
|
first_roll = random.randint(1, 3)
|
|
else: # pitcher
|
|
first_roll = random.randint(4, 6)
|
|
|
|
first_d6_result = DiceRoll(
|
|
dice_notation='1d6',
|
|
num_dice=1,
|
|
die_sides=6,
|
|
rolls=[first_roll],
|
|
total=first_roll
|
|
)
|
|
|
|
# Second roll (2d6) - normal
|
|
second_result = self._parse_and_roll_single_dice("2d6")
|
|
|
|
# Third roll (1d20) - normal
|
|
third_result = self._parse_and_roll_single_dice("1d20")
|
|
|
|
return [first_d6_result, second_result, third_result]
|
|
|
|
def _create_multi_roll_embed(self, dice_notation: str, roll_results: list[DiceRoll], user: discord.User | discord.Member, set_author: bool = True, embed_color: int = EmbedColors.PRIMARY) -> discord.Embed:
|
|
"""Create an embed for multiple dice roll results."""
|
|
embed = EmbedTemplate.create_base_embed(
|
|
title="🎲 Dice Roll",
|
|
color=embed_color
|
|
)
|
|
|
|
if set_author:
|
|
# Set user info
|
|
embed.set_author(
|
|
name=user.name,
|
|
icon_url=user.display_avatar.url
|
|
)
|
|
|
|
# Create summary line with totals
|
|
totals = [str(result.total) for result in roll_results]
|
|
summary = f"# {','.join(totals)}"
|
|
|
|
# Create details line in the specified format: Details:[1d6;2d6;1d20 (5 - 5 6 - 13)]
|
|
dice_notations = [result.dice_notation for result in roll_results]
|
|
|
|
# Create the rolls breakdown part - group dice within each roll, separate roll groups with dashes
|
|
roll_groups = []
|
|
for result in roll_results:
|
|
rolls = result.rolls
|
|
if len(rolls) == 1:
|
|
# Single die: just the number
|
|
roll_groups.append(str(rolls[0]))
|
|
else:
|
|
# Multiple dice: space-separated within the group
|
|
roll_groups.append(' '.join(str(r) for r in rolls))
|
|
|
|
details = f"Details:[{';'.join(dice_notations)} ({' - '.join(roll_groups)})]"
|
|
|
|
# Set as description
|
|
embed.add_field(
|
|
name='Result',
|
|
value=f"```md\n{summary}\n{details}```"
|
|
)
|
|
|
|
return embed
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
"""Load the dice roll commands cog."""
|
|
await bot.add_cog(DiceRollCommands(bot)) |