Catchup commit

Includes discord_ui refactor, testing overhaul, addition of
This commit is contained in:
Cal Corum 2025-07-22 09:22:19 -05:00
parent dd23d3657a
commit 3debfd6e82
37 changed files with 3809 additions and 2199 deletions

View File

@ -32,5 +32,9 @@ README.md
**/venv
**/tests
**/storage
**/htmlcov
*_legacy.py
pytest.ini
pytest.ini
CLAUDE.md
**.db
**/.claude

6
.gitignore vendored
View File

@ -132,4 +132,8 @@ dmypy.json
.idea/
storage*
*compose.yml
CLAUDE**
**.db
**/htmlcov
.vscode/**
.claude/**

View File

@ -3,5 +3,8 @@
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python.testing.pytestEnv": {
"DOCKER_HOST": "unix:///home/cal/.docker/desktop/docker.sock"
}
}

View File

@ -9,10 +9,7 @@ import os
from exceptions import DatabaseError
AUTH_TOKEN = {'Authorization': f'Bearer {os.environ.get("API_TOKEN")}'}
try:
ENV_DATABASE = os.environ.get('DATABASE').lower()
except Exception as e:
ENV_DATABASE = 'dev'
ENV_DATABASE = os.getenv("DATABASE", "dev").lower()
DB_URL = 'https://pd.manticorum.com/api' if 'prod' in ENV_DATABASE else 'https://pddev.manticorum.com/api'
master_debug = True
PLAYER_CACHE = {}
@ -26,7 +23,7 @@ def param_char(other_params):
return '?'
def get_req_url(endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None):
def get_req_url(endpoint: str, api_ver: int = 2, object_id: Optional[int] = None, params: Optional[list] = None):
req_url = f'{DB_URL}/v{api_ver}/{endpoint}{"/" if object_id is not None else ""}{object_id if object_id is not None else ""}'
if params:
@ -55,7 +52,7 @@ def log_return_value(log_string: str):
# logger.debug(f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n')
async def db_get(endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None, none_okay: bool = True, timeout: int = 3):
async def db_get(endpoint: str, api_ver: int = 2, object_id: Optional[int] = None, params: Optional[list] = None, none_okay: bool = True, timeout: int = 3):
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
log_string = f'db_get - get: {endpoint} id: {object_id} params: {params}'
logger.info(log_string) if master_debug else logger.debug(log_string)
@ -93,7 +90,7 @@ async def db_patch(endpoint: str, object_id: int, params: list, api_ver: int = 2
raise DatabaseError(e)
async def db_post(endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3):
async def db_post(endpoint: str, api_ver: int = 2, payload: Optional[dict] = None, timeout: int = 3):
req_url = get_req_url(endpoint, api_ver=api_ver)
log_string = f'db_post - post: {endpoint} payload: {payload}\ntype: {type(payload)}'
logger.info(log_string) if master_debug else logger.debug(log_string)
@ -110,7 +107,7 @@ async def db_post(endpoint: str, api_ver: int = 2, payload: dict = None, timeout
raise DatabaseError(e)
async def db_put(endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3):
async def db_put(endpoint: str, api_ver: int = 2, payload: Optional[dict] = None, timeout: int = 3):
req_url = get_req_url(endpoint, api_ver=api_ver)
log_string = f'post:\n{endpoint} payload: {payload}\ntype: {type(payload)}'
logger.info(log_string) if master_debug else logger.debug(log_string)

View File

@ -34,7 +34,7 @@ class Admins(commands.Cog):
# Check for Paper Sluggers event
e_query = await db_get('events', params=[('name', 'Paper Sluggers')])
if e_query is None:
this_event = db_post(
this_event = await db_post(
'events',
payload={
"name": "Paper Sluggers",
@ -51,16 +51,17 @@ class Admins(commands.Cog):
# Check for Game Rewards
gr_query = await db_get('gamerewards', params=[('name', 'MVP Pack')])
if gr_query['count'] == 0:
mv_pack = db_post(
'gamerewards',
payload={
'name': 'MVP',
'pack_type_id': 5
}
)
else:
mv_pack = gr_query['gamerewards'][0]
if gr_query:
if gr_query['count'] == 0:
mv_pack = db_post(
'gamerewards',
payload={
'name': 'MVP',
'pack_type_id': 5
}
)
else:
mv_pack = gr_query['gamerewards'][0]
gr_query = await db_get('gamerewards', params=[('name', 'All-Star Pack')])
if gr_query['count'] == 0:

346
constants.py Normal file
View File

@ -0,0 +1,346 @@
"""
Paper Dynasty Discord App Constants
This module contains all the configuration constants, static data structures,
and lookup tables used throughout the application.
"""
import discord
from typing import Literal
# Season Configuration
SBA_SEASON = 11
PD_SEASON = 9
ranked_cardsets = [20, 21, 22, 17, 18, 19]
LIVE_CARDSET_ID = 24
LIVE_PROMO_CARDSET_ID = 25
MAX_CARDSET_ID = 30
# Cardset Configuration
CARDSETS = {
'Ranked': {
'primary': ranked_cardsets,
'human': ranked_cardsets
},
'Minor League': {
'primary': [20, 8], # 1998, Mario
'secondary': [6], # 2013
'human': [x for x in range(1, MAX_CARDSET_ID)]
},
'Major League': {
'primary': [20, 21, 17, 18, 12, 6, 7, 8], # 1998, 1998 Promos, 2024, 24 Promos, 2008, 2013, 2012, Mario
'secondary': [5, 3], # 2019, 2022
'human': ranked_cardsets
},
'Hall of Fame': {
'primary': [x for x in range(1, MAX_CARDSET_ID)],
'secondary': [],
'human': ranked_cardsets
},
'Flashback': {
'primary': [5, 1, 3, 9, 8], # 2019, 2021, 2022, 2023, Mario
'secondary': [13, 5], # 2018, 2019
'human': [5, 1, 3, 9, 8] # 2019, 2021, 2022, 2023
},
'gauntlet-3': {
'primary': [13], # 2018
'secondary': [5, 11, 9], # 2019, 2016, 2023
'human': [x for x in range(1, MAX_CARDSET_ID)]
},
'gauntlet-4': {
'primary': [3, 6, 16], # 2022, 2013, Backyard Baseball
'secondary': [4, 9], # 2022 Promos, 2023
'human': [3, 4, 6, 9, 15, 16]
},
'gauntlet-5': {
'primary': [17, 8], # 2024, Mario
'secondary': [13], # 2018
'human': [x for x in range(1, MAX_CARDSET_ID)]
},
'gauntlet-6': {
'primary': [20, 8], # 1998, Mario
'secondary': [12], # 2008
'human': [x for x in range(1, MAX_CARDSET_ID)]
},
'gauntlet-7': {
'primary': [5, 23], # 2019, Brilliant Stars
'secondary': [1], # 2021
'human': [x for x in range(1, MAX_CARDSET_ID)]
}
}
# Application Configuration
SBA_COLOR = 'a6ce39'
PD_PLAYERS = 'Paper Dynasty Players'
SBA_PLAYERS_ROLE_NAME = f'Season {SBA_SEASON} Players'
PD_PLAYERS_ROLE_NAME = f'Paper Dynasty Players'
# External URLs and Resources
PD_IMAGE_BUCKET = 'https://paper-dynasty.s3.us-east-1.amazonaws.com/static-images'
PKMN_REF_URL = 'https://pkmncards.com/card/'
# Google Sheets Configuration
RATINGS_BATTER_FORMULA = '=IMPORTRANGE("1zDmlOw94gTzOAjqOpNdDZsg0O6rxNWkL4-XT6-iL2IE","guide_Batters!A1:CD")'
RATINGS_PITCHER_FORMULA = '=IMPORTRANGE("1zDmlOw94gTzOAjqOpNdDZsg0O6rxNWkL4-XT6-iL2IE","guide_Pitchers!A1:BQ")'
RATINGS_SHEET_KEY = '1zDmlOw94gTzOAjqOpNdDZsg0O6rxNWkL4-XT6-iL2IE'
# MLB Teams Lookup
ALL_MLB_TEAMS = {
'Arizona Diamondbacks': ['ARI', 'Diamondbacks'],
'Atlanta Braves': ['ATL', 'MLN', 'Braves'],
'Baltimore Orioles': ['BAL', 'Orioles'],
'Boston Red Sox': ['BOS', 'Red Sox'],
'Chicago Cubs': ['CHC', 'Cubs'],
'Chicago White Sox': ['CHW', 'White Sox'],
'Cincinnati Reds': ['CIN', 'Reds'],
'Cleveland Guardians': ['CLE', 'Guardians'],
'Colorado Rockies': ['COL', 'Rockies'],
'Detroit Tigers': ['DET', 'Tigers'],
'Houston Astros': ['HOU', 'Astros'],
'Kansas City Royals': ['KCR', 'Royals'],
'Los Angeles Angels': ['LAA', 'CAL', 'Angels'],
'Los Angeles Dodgers': ['LAD', 'Dodgers'],
'Miami Marlins': ['MIA', 'Marlins'],
'Milwaukee Brewers': ['MIL', 'MKE', 'Brewers'],
'Minnesota Twins': ['MIN', 'Twins'],
'New York Mets': ['NYM', 'Mets'],
'New York Yankees': ['NYY', 'Yankees'],
'Oakland Athletics': ['OAK', 'Athletics'],
'Philadelphia Phillies': ['PHI', 'Phillies'],
'Pittsburgh Pirates': ['PIT', 'Pirates'],
'San Diego Padres': ['SDP', 'Padres'],
'Seattle Mariners': ['SEA', 'Mariners'],
'San Francisco Giants': ['SFG', 'Giants'],
'St Louis Cardinals': ['STL', 'Cardinals'],
'Tampa Bay Rays': ['TBR', 'Rays'],
'Texas Rangers': ['TEX', 'Senators', 'Rangers'],
'Toronto Blue Jays': ['TOR', 'Jays'],
'Washington Nationals': ['WSN', 'WAS', 'Nationals'],
}
# Image URLs
IMAGES = {
'logo': f'{PD_IMAGE_BUCKET}/sba-logo.png',
'mvp-hype': f'{PD_IMAGE_BUCKET}/mvp.png',
'pack-sta': f'{PD_IMAGE_BUCKET}/pack-standard.png',
'pack-pre': f'{PD_IMAGE_BUCKET}/pack-premium.png',
'pack-mar': f'{PD_IMAGE_BUCKET}/mario-gauntlet.png',
'pack-pkmnbs': f'{PD_IMAGE_BUCKET}/pokemon-brilliantstars.jpg',
'mvp': {
'Arizona Diamondbacks': f'{PD_IMAGE_BUCKET}/mvp/arizona-diamondbacks.gif',
'Atlanta Braves': f'{PD_IMAGE_BUCKET}/mvp/atlanta-braves.gif',
'Baltimore Orioles': f'{PD_IMAGE_BUCKET}/mvp/baltimore-orioles.gif',
'Boston Red Sox': f'{PD_IMAGE_BUCKET}/mvp/boston-red-sox.gif',
'Chicago Cubs': f'{PD_IMAGE_BUCKET}/mvp/chicago-cubs.gif',
'Chicago White Sox': f'{PD_IMAGE_BUCKET}/mvp/chicago-white-sox.gif',
'Cincinnati Reds': f'{PD_IMAGE_BUCKET}/mvp/cincinnati-reds.gif',
'Cleveland Indians': f'{PD_IMAGE_BUCKET}/mvp/cleveland-guardians.gif',
'Cleveland Guardians': f'{PD_IMAGE_BUCKET}/mvp/cleveland-guardians.gif',
'Colorado Rockies': f'{PD_IMAGE_BUCKET}/mvp/colorado-rockies.gif',
'Detroit Tigers': f'{PD_IMAGE_BUCKET}/mvp/detroit-tigers.gif',
'Houston Astros': f'{PD_IMAGE_BUCKET}/mvp/houston-astros.gif',
'Kansas City Royals': f'{PD_IMAGE_BUCKET}/mvp/kansas-city-royals.gif',
'Los Angeles Angels': f'{PD_IMAGE_BUCKET}/mvp/los-angeles-angels.gif',
'Los Angeles Dodgers': f'{PD_IMAGE_BUCKET}/mvp/los-angeles-dodgers.gif',
'Miami Marlins': f'{PD_IMAGE_BUCKET}/mvp/miami-marlins.gif',
'Milwaukee Brewers': f'{PD_IMAGE_BUCKET}/mvp/milwaukee-brewers.gif',
'Minnesota Twins': f'{PD_IMAGE_BUCKET}/mvp/minnesota-twins.gif',
'New York Mets': f'{PD_IMAGE_BUCKET}/mvp/new-york-mets.gif',
'New York Yankees': f'{PD_IMAGE_BUCKET}/mvp/new-york-yankees.gif',
'Oakland Athletics': f'{PD_IMAGE_BUCKET}/mvp/oakland-athletics.gif',
'Philadelphia Phillies': f'{PD_IMAGE_BUCKET}/mvp/philadelphia-phillies.gif',
'Pittsburgh Pirates': f'{PD_IMAGE_BUCKET}/mvp/pittsburgh-pirates.gif',
'San Diego Padres': f'{PD_IMAGE_BUCKET}/mvp/san-diego-padres.gif',
'Seattle Mariners': f'{PD_IMAGE_BUCKET}/mvp/seattle-mariners.gif',
'San Francisco Giants': f'{PD_IMAGE_BUCKET}/mvp/san-francisco-giants.gif',
'St Louis Cardinals': f'{PD_IMAGE_BUCKET}/mvp/st-louis-cardinals.gif',
'St. Louis Cardinals': f'{PD_IMAGE_BUCKET}/mvp/st-louis-cardinals.gif',
'Tampa Bay Rays': f'{PD_IMAGE_BUCKET}/mvp/tampa-bay-rays.gif',
'Texas Rangers': f'{PD_IMAGE_BUCKET}/mvp/texas-rangers.gif',
'Toronto Blue Jays': f'{PD_IMAGE_BUCKET}/mvp/toronto-blue-jays.gif',
'Washington Nationals': f'{PD_IMAGE_BUCKET}/mvp/washington-nationals.gif',
'Junior All Stars': f'{PD_IMAGE_BUCKET}/mvp.png',
'Mario Super Sluggers': f'{PD_IMAGE_BUCKET}/mvp.png',
'Pokemon League': f'{PD_IMAGE_BUCKET}/masterball.jpg'
},
'gauntlets': f'{PD_IMAGE_BUCKET}/gauntlets.png'
}
# Game Mechanics Charts
INFIELD_X_CHART = {
'si1': {
'rp': 'No runner on first: Batter is safe at first and no one covers second. Batter to second, runners only '
'advance 1 base.\nRunner on first: batter singles, runners advance 1 base.',
'e1': 'Single and Error, batter to second, runners advance 2 bases.',
'e2': 'Single and Error, batter to third, all runners score.',
'no': 'Single, runners advance 1 base.'
},
'po': {
'rp': 'The batters hits a popup. None of the fielders take charge on the play and the ball drops in the '
'infield for a SI1! All runners advance 1 base.',
'e1': 'The catcher drops a popup for an error. All runners advance 1 base.',
'e2': 'The catcher grabs a squib in front of the plate and throws it into right field. The batter goes to '
'second and all runners score.',
'no': 'The batter pops out to the catcher.'
},
'fo': {
'rp': 'Batter swings and misses, but is awarded first base on a catcher interference call! One base error, '
'baserunners advance only if forced.',
'e1': 'The catcher drops a foul popup for an error. Batter rolls AB again.',
'e2': 'The catcher drops a foul popup for an error. Batter rolls AB again.',
'no': 'Runner(s) on base: make a passed ball check. If no passed ball, batter pops out to the catcher. If a '
'passed ball occurs, batter roll his AB again.\nNo runners: batter pops out to the catcher'
},
'g1': {
'rp': 'Runner on first, <2 outs: runner on first breaks up the double play, gbB\n'
'Else: gbA',
'e1': 'Error, batter to first, runners advance 1 base.',
'e2': 'Error, batter to second, runners advance 2 bases.',
'no': 'Consult Groundball Chart: `!gbA`'
},
'g2': {
'rp': 'Runner(s) on base: fielder makes bad throw for lead runner but batter is out at first for a gbC\n'
'No runners: gbB',
'e1': 'Error, batter to first, runners advance 1 base.',
'e2': 'Error, batter to second, runners advance 2 bases.',
'no': 'Consult Groundball Chart: `!gbB`'
},
'g3': {
'rp': 'Runner(s) on base: fielder checks the runner before throwing to first and allows a SI*\n'
'No runners: gbC',
'e1': 'Error, batter to first, runners advance 1 base.',
'e2': 'Error, batter to second, runners advance 2 bases.',
'no': 'Consult Groundball Chart: `!gbC`'
},
'spd': {
'rp': 'Catcher throws to first and hits the batter-runner in the back, SI1',
'e1': 'Error, batter to first, runners advance 1 base.',
'e2': 'Error, batter to second, runners advance 2 bases.',
'no': 'Speed check, Batter\'s safe range = Running; if safe, SI*; if out, gbC'
},
}
OUTFIELD_X_CHART = {
'si2': {
'rp': 'Batter singles, baserunners advance 2 bases. As the batter rounds first, the fielder throws behind him '
'and catches him off the bag for an out!',
'e1': 'Single and error, batter to second, runners advance 2 bases.',
'e2': 'Single and error, batter to third, all runners score.',
'e3': 'Single and error, batter to third, all runners score',
'no': 'Single, all runners advance 2 bases.'
},
'do2': {
'rp': 'Batter doubles and runners advance three bases, but batter-runner is caught between second and third! '
'He is tagged out in the rundown.',
'e1': 'Double and error, batter to third, all runners score.',
'e2': 'Double and error, batter to third, all runners score.',
'e3': 'Double and error, batter and all runners score. Little league home run!',
'no': 'Double, all runners advance 2 bases.'
},
'do3': {
'rp': 'Batter doubles and runners advance three bases, but batter-runner is caught between second and third! '
'He is tagged out in the rundown.',
'e1': 'Double and error, batter to third, all runners score.',
'e2': 'Double and error, batter and all runners score. Little league home run!',
'e3': 'Double and error, batter and all runners score. Little league home run!',
'no': 'Double, all runners score.'
},
'tr3': {
'rp': 'Batter hits a ball into the gap and the outfielders collide trying to make the play! The ball rolls to '
'the wall and the batter trots home with an inside-the-park home run!',
'e1': 'Triple and error, batter and all runners score. Little league home run!',
'e2': 'Triple and error, batter and all runners score. Little league home run!',
'e3': 'Triple and error, batter and all runners score. Little league home run!',
'no': 'Triple, all runners score.'
},
'f1': {
'rp': 'The outfielder races back and makes a diving catch and collides with the wall! In the time he takes to '
'recuperate, all baserunners tag-up and advance 2 bases.',
'e1': '1 base error, runners advance 1 base.',
'e2': '2 base error, runners advance 2 bases.',
'e3': '3 base error, batter to third, all runners score.',
'no': 'Flyball A'
},
'f2': {
'rp': 'The outfielder catches the flyball for an out. If there is a runner on third, he tags-up and scores. '
'The play is appealed and the umps rule that the runner left early and is out on the appeal!',
'e1': '1 base error, runners advance 1 base.',
'e2': '2 base error, runners advance 2 bases.',
'e3': '3 base error, batter to third, all runners score.',
'no': 'Flyball B'
},
'f3': {
'rp': 'The outfielder makes a running catch in the gap! The lead runner lost track of the ball and was '
'advancing - he cannot return in time and is doubled off by the outfielder.',
'e1': '1 base error, runners advance 1 base.',
'e2': '2 base error, runners advance 2 bases.',
'e3': '3 base error, batter to third, all runners score.',
'no': 'Flyball C'
}
}
# Player Rarity Values
RARITY = {
'HoF': 8,
'MVP': 5,
'All-Star': 3,
'Starter': 2,
'Reserve': 1,
'Replacement': 0
}
# Discord UI Options
SELECT_CARDSET_OPTIONS = [
discord.SelectOption(label='2025 Live', value='24'),
discord.SelectOption(label='2025 Promos', value='25'),
discord.SelectOption(label='1998 Season', value='20'),
discord.SelectOption(label='1998 Promos', value='21'),
discord.SelectOption(label='2024 Season', value='17'),
discord.SelectOption(label='2024 Promos', value='18'),
discord.SelectOption(label='2023 Season', value='9'),
discord.SelectOption(label='2023 Promos', value='10'),
discord.SelectOption(label='2022 Season', value='3'),
discord.SelectOption(label='2022 Promos', value='4'),
discord.SelectOption(label='2021 Season', value='1'),
discord.SelectOption(label='2019 Season', value='5'),
discord.SelectOption(label='2018 Season', value='13'),
discord.SelectOption(label='2018 Promos', value='14'),
discord.SelectOption(label='2016 Season', value='11'),
discord.SelectOption(label='2013 Season', value='6'),
discord.SelectOption(label='2012 Season', value='7')
]
# Type Definitions
ACTIVE_EVENT_LITERAL = Literal['2025 Season']
DEFENSE_LITERAL = Literal['Pitcher', 'Catcher', 'First Base', 'Second Base', 'Third Base', 'Shortstop', 'Left Field', 'Center Field', 'Right Field']
DEFENSE_NO_PITCHER_LITERAL = Literal['Catcher', 'First Base', 'Second Base', 'Third Base', 'Shortstop', 'Left Field', 'Center Field', 'Right Field']
# Color Definitions
COLORS = {
'sba': int('a6ce39', 16),
'yellow': int('FFEA00', 16),
'red': int('C70039', 16),
'white': int('FFFFFF', 16)
}
# Bot Response Content
INSULTS = [
'Ugh, who even are you?',
'Ugh, who even are you? Go away.',
'Ugh, who even are you? Leave me alone.',
'I will call the fucking cops!',
'I will call the fucking cops! Go away.',
'I will call the fucking cops! Leave me alone',
'Please don\'t talk to me',
'Don\'t talk to me.',
'Eww, don\'t talk to me.',
'Get away from me.',
'Get away from me, creep.',
'Get away from me, loser.',
'Get away from me, pedobear.',
'Why are you even here?',
'Why are you even here? Get lost.',
'Why are you even here? Scram.',
'Why are you even here? No one knows who you are.',
'HEY, DON\'T TOUCH ME!',
'Hey, don\'t touch me!'
]

23
discord_ui/__init__.py Normal file
View File

@ -0,0 +1,23 @@
"""
Discord UI Components
This package contains all Discord UI classes and components used throughout the application.
"""
from .confirmations import Question, Confirm, ButtonOptions
from .pagination import Pagination
from .selectors import (
SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset,
SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam,
SelectUpdatePlayerTeam, SelectView
)
from .dropdowns import Dropdown, DropdownView
__all__ = [
'Question', 'Confirm', 'ButtonOptions',
'Pagination',
'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset',
'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam',
'SelectUpdatePlayerTeam', 'SelectView',
'Dropdown', 'DropdownView'
]

188
discord_ui/confirmations.py Normal file
View File

@ -0,0 +1,188 @@
"""
Discord confirmation and interaction UI components.
Contains Question, Confirm, and ButtonOptions classes for user interactions.
"""
import asyncio
import discord
from typing import Literal
class Question:
def __init__(self, bot, channel, prompt, qtype, timeout, embed=None):
"""
Version 0.4
:param bot, discord bot object
:param channel, discord Channel object
:param prompt, string, prompt message to post
:param qtype, string, 'yesno', 'int', 'float', 'text', or 'url'
:param timeout, float, time to wait for a response
"""
if not prompt and not embed:
raise TypeError('prompt and embed may not both be None')
self.bot = bot
self.channel = channel
self.prompt = prompt
self.qtype = qtype
self.timeout = timeout
self.embed = embed
async def ask(self, responders: list):
"""
Params: responder, list of discord User objects
Returns: True/False if confirm question; full response otherwise
"""
yes = ['yes', 'y', 'ye', 'yee', 'yerp', 'yep', 'yeet', 'yip', 'yup', 'yarp', 'si']
no = ['no', 'n', 'nope', 'nah', 'nyet', 'nein']
if type(responders) is not list:
raise TypeError('Responders must be a list of Members')
def yesno(mes):
return mes.channel == self.channel and mes.author in responders and mes.content.lower() in yes + no
def text(mes):
return mes.channel == self.channel and mes.author in responders
await self.channel.send(content=self.prompt, embed=self.embed)
try:
resp = await self.bot.wait_for(
'message',
timeout=self.timeout,
check=yesno if self.qtype == 'yesno' else text
)
except asyncio.TimeoutError:
return None
except Exception as e:
await self.channel.send(f'Yuck, do you know what this means?\n\n{e}')
return None
if self.qtype == 'yesno':
if resp.content.lower() in yes:
return True
else:
return False
elif self.qtype == 'int':
return int(resp.content)
elif self.qtype == 'float':
return float(resp.content)
elif self.qtype == 'url':
if resp.content[:7] == 'http://' or resp.content[:8] == 'https://':
return resp.content
else:
return False
else:
return resp.content
class Confirm(discord.ui.View):
def __init__(self, responders: list, timeout: float = 300.0, label_type: Literal['yes', 'confirm'] = None):
super().__init__(timeout=timeout)
if not isinstance(responders, list):
raise TypeError('responders must be a list')
self.value = None
self.responders = responders
if label_type == 'yes':
self.confirm.label = 'Yes'
self.cancel.label = 'No'
# When the confirm button is pressed, set the inner value to `True` and
# stop the View from listening to more input.
# We also send the user an ephemeral message that we're confirming their choice.
@discord.ui.button(label='Confirm', style=discord.ButtonStyle.green)
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = True
self.clear_items()
self.stop()
# This one is similar to the confirmation button except sets the inner value to `False`
@discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = False
self.clear_items()
self.stop()
class ButtonOptions(discord.ui.View):
def __init__(self, responders: list, timeout: float = 300.0, labels=None):
super().__init__(timeout=timeout)
if not isinstance(responders, list):
raise TypeError('responders must be a list')
self.value = None
self.responders = responders
self.options = labels
for count, x in enumerate(labels):
if count == 0:
self.option1.label = x
if x is None or x.lower() == 'na' or x == 'N/A':
self.remove_item(self.option1)
if count == 1:
self.option2.label = x
if x is None or x.lower() == 'na' or x == 'N/A':
self.remove_item(self.option2)
if count == 2:
self.option3.label = x
if x is None or x.lower() == 'na' or x == 'N/A':
self.remove_item(self.option3)
if count == 3:
self.option4.label = x
if x is None or x.lower() == 'na' or x == 'N/A':
self.remove_item(self.option4)
if count == 4:
self.option5.label = x
if x is None or x.lower() == 'na' or x == 'N/A':
self.remove_item(self.option5)
@discord.ui.button(label='Option 1', style=discord.ButtonStyle.primary)
async def option1(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = self.options[0]
self.clear_items()
self.stop()
@discord.ui.button(label='Option 2', style=discord.ButtonStyle.primary)
async def option2(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = self.options[1]
self.clear_items()
self.stop()
@discord.ui.button(label='Option 3', style=discord.ButtonStyle.primary)
async def option3(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = self.options[2]
self.clear_items()
self.stop()
@discord.ui.button(label='Option 4', style=discord.ButtonStyle.primary)
async def option4(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = self.options[3]
self.clear_items()
self.stop()
@discord.ui.button(label='Option 5', style=discord.ButtonStyle.primary)
async def option5(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
return
self.value = self.options[4]
self.clear_items()
self.stop()

52
discord_ui/dropdowns.py Normal file
View File

@ -0,0 +1,52 @@
"""
Discord dropdown UI components.
Contains generic Dropdown and DropdownView classes for custom selections.
"""
import logging
import discord
logger = logging.getLogger('discord_app')
class Dropdown(discord.ui.Select):
def __init__(self, option_list: list, placeholder: str = 'Make your selection', min_values: int = 1,
max_values: int = 1, callback=None):
# Set the options that will be presented inside the dropdown
# options = [
# discord.SelectOption(label='Red', description='Your favourite colour is red', emoji='🟥'),
# discord.SelectOption(label='Green', description='Your favourite colour is green', emoji='🟩'),
# discord.SelectOption(label='Blue', description='Your favourite colour is blue', emoji='🟦'),
# ]
# The placeholder is what will be shown when no option is chosen
# The min and max values indicate we can only pick one of the three options
# The options parameter defines the dropdown options. We defined this above
# If a default option is set on any SelectOption, the View will not process if only the default is
# selected by the user
self.custom_callback = callback
super().__init__(
placeholder=placeholder,
min_values=min_values,
max_values=max_values,
options=option_list
)
async def callback(self, interaction: discord.Interaction):
# Use the interaction object to send a response message containing
# the user's favourite colour or choice. The self object refers to the
# Select object, and the values attribute gets a list of the user's
# selected options. We only want the first one.
# await interaction.response.send_message(f'Your favourite colour is {self.values[0]}')
logger.info(f'Dropdown callback: {self.custom_callback}')
await self.custom_callback(interaction, self.values)
class DropdownView(discord.ui.View):
def __init__(self, dropdown_objects: list[Dropdown], timeout: float = 300.0):
super().__init__(timeout=timeout)
# self.add_item(Dropdown())
for x in dropdown_objects:
self.add_item(x)

49
discord_ui/pagination.py Normal file
View File

@ -0,0 +1,49 @@
"""
Discord pagination UI component.
Contains the Pagination class for navigating through multiple pages of content.
"""
import logging
import discord
logger = logging.getLogger('discord_app')
class Pagination(discord.ui.View):
def __init__(self, responders: list, timeout: float = 300.0):
super().__init__(timeout=timeout)
if not isinstance(responders, list):
raise TypeError('responders must be a list')
self.value = None
self.responders = responders
@discord.ui.button(label='⏮️', style=discord.ButtonStyle.blurple)
async def left_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
logger.info(f'{interaction.user} is not in {self.responders}')
return
self.value = 'left'
await interaction.response.defer()
self.stop()
@discord.ui.button(label='❌️', style=discord.ButtonStyle.secondary)
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
logger.info(f'{interaction.user} is not in {self.responders}')
return
self.value = 'cancel'
await interaction.response.defer()
self.stop()
@discord.ui.button(label='⏭️', style=discord.ButtonStyle.blurple)
async def right_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user not in self.responders:
logger.info(f'{interaction.user} is not in {self.responders}')
return
self.value = 'right'
await interaction.response.defer()
self.stop()

488
discord_ui/selectors.py Normal file
View File

@ -0,0 +1,488 @@
"""
Discord Select UI components.
Contains all Select classes for various team, cardset, and pack selections.
"""
import logging
import discord
from typing import Literal, Optional
from constants import ALL_MLB_TEAMS, IMAGES
logger = logging.getLogger('discord_app')
# Team name to ID mappings
AL_TEAM_IDS = {
'Baltimore Orioles': 3,
'Boston Red Sox': 4,
'Chicago White Sox': 6,
'Cleveland Guardians': 8,
'Detroit Tigers': 10,
'Houston Astros': 11,
'Kansas City Royals': 12,
'Los Angeles Angels': 13,
'Minnesota Twins': 17,
'New York Yankees': 19,
'Oakland Athletics': 20,
'Seattle Mariners': 24,
'Tampa Bay Rays': 27,
'Texas Rangers': 28,
'Toronto Blue Jays': 29
}
NL_TEAM_IDS = {
'Arizona Diamondbacks': 1,
'Atlanta Braves': 2,
'Chicago Cubs': 5,
'Cincinnati Reds': 7,
'Colorado Rockies': 9,
'Los Angeles Dodgers': 14,
'Miami Marlins': 15,
'Milwaukee Brewers': 16,
'New York Mets': 18,
'Philadelphia Phillies': 21,
'Pittsburgh Pirates': 22,
'San Diego Padres': 23,
'San Francisco Giants': 25,
'St Louis Cardinals': 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals'
'Washington Nationals': 30
}
# Get AL teams from constants
AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS]
NL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in NL_TEAM_IDS or team == 'St Louis Cardinals']
# Cardset mappings
CARDSET_LABELS_TO_IDS = {
'2022 Season': 3,
'2022 Promos': 4,
'2021 Season': 1,
'2019 Season': 5,
'2013 Season': 6,
'2012 Season': 7,
'Mario Super Sluggers': 8,
'2023 Season': 9,
'2016 Season': 11,
'2008 Season': 12,
'2018 Season': 13,
'2024 Season': 17,
'2024 Promos': 18,
'1998 Season': 20,
'2025 Live': 24,
'Pokemon - Brilliant Stars': 23
}
def _get_team_id(team_name: str, league: Literal['AL', 'NL']) -> int:
"""Get team ID from team name and league."""
if league == 'AL':
return AL_TEAM_IDS.get(team_name)
else:
# Handle the St. Louis Cardinals special case
if team_name == 'St. Louis Cardinals':
return NL_TEAM_IDS.get('St Louis Cardinals')
return NL_TEAM_IDS.get(team_name)
class SelectChoicePackTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL'], team, cardset_id: Optional[int] = None):
self.which = which
self.owner_team = team
self.cardset_id = cardset_id
if which == 'AL':
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else:
# Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
for team in NL_TEAMS]
super().__init__(placeholder=f'Select an {which} team', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_get, db_patch
from helpers import open_choice_pack
team_id = _get_team_id(self.values[0], self.which)
if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}')
await interaction.response.edit_message(content=f'You selected the **{self.values[0]}**', view=None)
# Get the selected packs
params = [
('pack_type_id', 8), ('team_id', self.owner_team['id']), ('opened', False), ('limit', 1),
('exact_match', True)
]
if self.cardset_id is not None:
params.append(('pack_cardset_id', self.cardset_id))
p_query = await db_get('packs', params=params)
if p_query['count'] == 0:
logger.error(f'open-packs - no packs found with params: {params}')
raise ValueError(f'Unable to open packs')
this_pack = await db_patch('packs', object_id=p_query['packs'][0]['id'], params=[('pack_team_id', team_id)])
await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id)
class SelectOpenPack(discord.ui.Select):
def __init__(self, options: list, team: dict):
self.owner_team = team
super().__init__(placeholder='Select a Pack Type', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_get
from helpers import open_st_pr_packs, open_choice_pack
logger.info(f'SelectPackChoice - selection: {self.values[0]}')
pack_vals = self.values[0].split('-')
logger.info(f'pack_vals: {pack_vals}')
# Get the selected packs
params = [('team_id', self.owner_team['id']), ('opened', False), ('limit', 5), ('exact_match', True)]
open_type = 'standard'
if 'Standard' in pack_vals:
open_type = 'standard'
params.append(('pack_type_id', 1))
elif 'Premium' in pack_vals:
open_type = 'standard'
params.append(('pack_type_id', 3))
elif 'Daily' in pack_vals:
params.append(('pack_type_id', 4))
elif 'Promo Choice' in pack_vals:
open_type = 'choice'
params.append(('pack_type_id', 9))
elif 'MVP' in pack_vals:
open_type = 'choice'
params.append(('pack_type_id', 5))
elif 'All Star' in pack_vals:
open_type = 'choice'
params.append(('pack_type_id', 6))
elif 'Mario' in pack_vals:
open_type = 'choice'
params.append(('pack_type_id', 7))
elif 'Team Choice' in pack_vals:
open_type = 'choice'
params.append(('pack_type_id', 8))
else:
raise KeyError(f'Cannot identify pack details: {pack_vals}')
# If team isn't already set on team choice pack, make team pack selection now
await interaction.response.edit_message(view=None)
cardset_id = None
if 'Team Choice' in pack_vals and 'Cardset' in pack_vals:
# cardset_id = pack_vals[2]
cardset_index = pack_vals.index('Cardset')
cardset_id = pack_vals[cardset_index + 1]
params.append(('pack_cardset_id', cardset_id))
if 'Team' not in pack_vals:
view = SelectView(
[SelectChoicePackTeam('AL', self.owner_team, cardset_id),
SelectChoicePackTeam('NL', self.owner_team, cardset_id)],
timeout=30
)
await interaction.channel.send(
content=None,
view=view
)
return
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
else:
if 'Team' in pack_vals:
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
if 'Cardset' in pack_vals:
cardset_id = pack_vals[pack_vals.index('Cardset') + 1]
params.append(('pack_cardset_id', cardset_id))
p_query = await db_get('packs', params=params)
if p_query['count'] == 0:
logger.error(f'open-packs - no packs found with params: {params}')
raise ValueError(f'Unable to open packs')
# Open the packs
if open_type == 'standard':
await open_st_pr_packs(p_query['packs'], self.owner_team, interaction)
elif open_type == 'choice':
await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id)
class SelectPaperdexCardset(discord.ui.Select):
def __init__(self):
options = [
discord.SelectOption(label='2025 Live'),
discord.SelectOption(label='1998 Season'),
discord.SelectOption(label='2024 Season'),
discord.SelectOption(label='2023 Season'),
discord.SelectOption(label='2022 Season'),
discord.SelectOption(label='2022 Promos'),
discord.SelectOption(label='2021 Season'),
discord.SelectOption(label='2019 Season'),
discord.SelectOption(label='2018 Season'),
discord.SelectOption(label='2016 Season'),
discord.SelectOption(label='2013 Season'),
discord.SelectOption(label='2012 Season'),
discord.SelectOption(label='2008 Season'),
discord.SelectOption(label='Mario Super Sluggers')
]
super().__init__(placeholder='Select a Cardset', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_get
from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination
logger.info(f'SelectPaperdexCardset - selection: {self.values[0]}')
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None:
raise ValueError(f'Unknown cardset: {self.values[0]}')
c_query = await db_get('cardsets', object_id=cardset_id, none_okay=False)
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None)
cardset_embeds = await paperdex_cardset_embed(
team=await get_team_by_owner(interaction.user.id),
this_cardset=c_query
)
await embed_pagination(cardset_embeds, interaction.channel, interaction.user)
class SelectPaperdexTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL']):
self.which = which
if which == 'AL':
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else:
# Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
for team in NL_TEAMS]
super().__init__(placeholder=f'Select an {which} team', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_get
from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination
team_id = _get_team_id(self.values[0], self.which)
if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}')
t_query = await db_get('teams', object_id=team_id, none_okay=False)
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None)
team_embeds = await paperdex_team_embed(team=await get_team_by_owner(interaction.user.id), mlb_team=t_query)
await embed_pagination(team_embeds, interaction.channel, interaction.user)
class SelectBuyPacksCardset(discord.ui.Select):
def __init__(self, team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, cost: int):
options = [
discord.SelectOption(label='2025 Live'),
discord.SelectOption(label='1998 Season'),
discord.SelectOption(label='Pokemon - Brilliant Stars'),
discord.SelectOption(label='2024 Season'),
discord.SelectOption(label='2023 Season'),
discord.SelectOption(label='2022 Season'),
discord.SelectOption(label='2021 Season'),
discord.SelectOption(label='2019 Season'),
discord.SelectOption(label='2018 Season'),
discord.SelectOption(label='2016 Season'),
discord.SelectOption(label='2013 Season'),
discord.SelectOption(label='2012 Season'),
discord.SelectOption(label='2008 Season')
]
self.team = team
self.quantity = quantity
self.pack_type_id = pack_type_id
self.pack_embed = pack_embed
self.cost = cost
super().__init__(placeholder='Select a Cardset', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_post
from discord_ui.confirmations import Confirm
logger.info(f'SelectBuyPacksCardset - selection: {self.values[0]}')
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None:
raise ValueError(f'Unknown cardset: {self.values[0]}')
if self.values[0] == 'Pokemon - Brilliant Stars':
self.pack_embed.set_image(url=IMAGES['pack-pkmnbs'])
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}'
view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message(
content=None,
embed=self.pack_embed,
view=None
)
question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n'
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n'
f'After Purchase: {self.team["wallet"] - self.cost}\n\n'
f'Would you like to make this purchase?',
view=view
)
await view.wait()
if not view.value:
await question.edit(
content='Saving that money. Smart.',
view=None
)
return
p_model = {
'team_id': self.team['id'],
'pack_type_id': self.pack_type_id,
'pack_cardset_id': cardset_id
}
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]})
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}')
await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`',
view=None
)
class SelectBuyPacksTeam(discord.ui.Select):
def __init__(
self, which: Literal['AL', 'NL'], team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed,
cost: int):
self.which = which
self.team = team
self.quantity = quantity
self.pack_type_id = pack_type_id
self.pack_embed = pack_embed
self.cost = cost
if which == 'AL':
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else:
# Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
for team in NL_TEAMS]
super().__init__(placeholder=f'Select an {which} team', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_post
from discord_ui.confirmations import Confirm
team_id = _get_team_id(self.values[0], self.which)
if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}')
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}'
view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message(
content=None,
embed=self.pack_embed,
view=None
)
question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n'
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n'
f'After Purchase: {self.team["wallet"] - self.cost}\n\n'
f'Would you like to make this purchase?',
view=view
)
await view.wait()
if not view.value:
await question.edit(
content='Saving that money. Smart.',
view=None
)
return
p_model = {
'team_id': self.team['id'],
'pack_type_id': self.pack_type_id,
'pack_team_id': team_id
}
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]})
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}')
await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`',
view=None
)
class SelectUpdatePlayerTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL'], player: dict, reporting_team: dict, bot):
self.bot = bot
self.which = which
self.player = player
self.reporting_team = reporting_team
if which == 'AL':
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else:
# Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
for team in NL_TEAMS]
super().__init__(placeholder=f'Select an {which} team', options=options)
async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports
from api_calls import db_patch, db_post
from discord_ui.confirmations import Confirm
from helpers import player_desc, send_to_channel
if self.values[0] == self.player['franchise'] or self.values[0] == self.player['mlbclub']:
await interaction.response.send_message(
content=f'Thank you for the help, but it looks like somebody beat you to it! '
f'**{player_desc(self.player)}** is already assigned to the **{self.player["mlbclub"]}**.'
)
return
view = Confirm(responders=[interaction.user], timeout=15)
await interaction.response.edit_message(
content=f'Should I update **{player_desc(self.player)}**\'s team to the **{self.values[0]}**?',
view=None
)
question = await interaction.channel.send(
content=None,
view=view
)
await view.wait()
if not view.value:
await question.edit(
content='That didnt\'t sound right to me, either. Let\'s not touch that.',
view=None
)
return
else:
await question.delete()
await db_patch('players', object_id=self.player['player_id'], params=[
('mlbclub', self.values[0]), ('franchise', self.values[0])
])
await db_post(f'teams/{self.reporting_team["id"]}/money/25')
await send_to_channel(
self.bot, 'pd-news-ticker',
content=f'{interaction.user.name} just updated **{player_desc(self.player)}**\'s team to the '
f'**{self.values[0]}**'
)
await interaction.channel.send(f'All done!')
class SelectView(discord.ui.View):
def __init__(self, select_objects: list[discord.ui.Select], timeout: float = 300.0):
super().__init__(timeout=timeout)
for x in select_objects:
self.add_item(x)

231
discord_utils.py Normal file
View File

@ -0,0 +1,231 @@
"""
Discord Utilities
This module contains Discord helper functions for channels, roles, embeds,
and other Discord-specific operations.
"""
import logging
import os
import asyncio
from typing import Optional
import discord
from discord.ext import commands
from constants import SBA_COLOR, PD_SEASON, IMAGES
logger = logging.getLogger('discord_app')
async def send_to_bothole(ctx, content, embed):
"""Send a message to the pd-bot-hole channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \
.send(content=content, embed=embed)
async def send_to_news(ctx, content, embed):
"""Send a message to the pd-news-ticker channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \
.send(content=content, embed=embed)
async def typing_pause(ctx, seconds=1):
"""Show typing indicator for specified seconds."""
async with ctx.typing():
await asyncio.sleep(seconds)
async def pause_then_type(ctx, message):
"""Show typing indicator based on message length, then send message."""
async with ctx.typing():
await asyncio.sleep(len(message) / 100)
await ctx.send(message)
async def check_if_pdhole(ctx):
"""Check if the current channel is pd-bot-hole."""
if ctx.message.channel.name != 'pd-bot-hole':
await ctx.send('Slide on down to my bot-hole for running commands.')
await ctx.message.add_reaction('')
return False
return True
async def bad_channel(ctx):
"""Check if current channel is in the list of bad channels for commands."""
bad_channels = ['paper-dynasty-chat', 'pd-news-ticker']
if ctx.message.channel.name in bad_channels:
await ctx.message.add_reaction('')
bot_hole = discord.utils.get(
ctx.guild.text_channels,
name=f'pd-bot-hole'
)
await ctx.send(f'Slide on down to the {bot_hole.mention} ;)')
return True
else:
return False
def get_channel(ctx, name) -> Optional[discord.TextChannel]:
"""Get a text channel by name."""
channel = discord.utils.get(
ctx.guild.text_channels,
name=name
)
if channel:
return channel
return None
async def get_emoji(ctx, name, return_empty=True):
"""Get an emoji by name, with fallback options."""
try:
emoji = await commands.converter.EmojiConverter().convert(ctx, name)
except:
if return_empty:
emoji = ''
else:
return name
return emoji
async def react_and_reply(ctx, reaction, message):
"""Add a reaction to the message and send a reply."""
await ctx.message.add_reaction(reaction)
await ctx.send(message)
async def send_to_channel(bot, channel_name, content=None, embed=None):
"""Send a message to a specific channel by name or ID."""
guild = bot.get_guild(int(os.environ.get('GUILD_ID')))
if not guild:
logger.error('Cannot send to channel - bot not logged in')
return
this_channel = discord.utils.get(guild.text_channels, name=channel_name)
if not this_channel:
this_channel = discord.utils.get(guild.text_channels, id=channel_name)
if not this_channel:
raise NameError(f'**{channel_name}** channel not found')
return await this_channel.send(content=content, embed=embed)
async def get_or_create_role(ctx, role_name, mentionable=True):
"""Get an existing role or create it if it doesn't exist."""
this_role = discord.utils.get(ctx.guild.roles, name=role_name)
if not this_role:
this_role = await ctx.guild.create_role(name=role_name, mentionable=mentionable)
return this_role
def get_special_embed(special):
"""Create an embed for a special item."""
embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}',
color=discord.Color.random(),
description=f'{special.short_desc}')
embed.add_field(name='Description', value=f'{special.long_desc}', inline=False)
if special.thumbnail.lower() != 'none':
embed.set_thumbnail(url=f'{special.thumbnail}')
if special.url.lower() != 'none':
embed.set_image(url=f'{special.url}')
return embed
def get_random_embed(title, thumb=None):
"""Create a basic embed with random color."""
embed = discord.Embed(title=title, color=discord.Color.random())
if thumb:
embed.set_thumbnail(url=thumb)
return embed
def get_team_embed(title, team=None, thumbnail: bool = True):
"""Create a team-branded embed."""
if team:
embed = discord.Embed(
title=title,
color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16)
)
embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo'])
else:
embed = discord.Embed(
title=title,
color=int(SBA_COLOR, 16)
)
embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=IMAGES['logo'])
return embed
async def create_channel_old(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None,
allowed_roles=None):
"""Create a text channel with specified permissions (legacy version)."""
this_category = discord.utils.get(ctx.guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
overwrites = {
ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
}
if allowed_members:
if isinstance(allowed_members, list):
for member in allowed_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if allowed_roles:
if isinstance(allowed_roles, list):
for role in allowed_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
this_channel = await ctx.guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
return this_channel
async def create_channel(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True,
read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None):
"""Create a text channel with specified permissions."""
this_category = discord.utils.get(ctx.guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
overwrites = {
ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
}
if read_send_members:
for member in read_send_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if read_send_roles:
for role in read_send_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if read_only_roles:
for role in read_only_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
this_channel = await ctx.guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
return this_channel

View File

@ -15,7 +15,7 @@ def log_errors(func):
except Exception as e:
logger.error(func.__name__)
log_exception(e)
return result
return result # type: ignore
return wrap

View File

@ -243,27 +243,27 @@ async def get_opponent(session: Session, this_team, this_event, this_run) -> Tea
raise KeyError(f'Hmm...I do not know who you should be playing right now.')
elif this_event['id'] == 8:
if gp == 0:
teams = [6, 13]
teams = [6, 9] # White Sox and Rockies
elif gp == 1:
teams = [11, 12, 22, 28]
teams = [22, 30, 20, 2] # Pirates, Nats, A's, Braves
elif gp == 2:
teams = [12, 27, 29]
teams = [3, 15, 12] # Orioles, Marlins, Royals
elif gp == 3:
teams = [16, 18, 25]
teams = [17, 8, 13] # Twins, Guardians, Angels
elif gp == 4:
teams = [10, 18, 26]
teams = [13, 1, 28] # Angels, Dbacks, Rangers
elif gp == 5:
teams = [8, 24]
teams = [26, 27] # Cardinals, Rays
elif gp == 6:
teams = [1, 3, 8]
teams = [7, 25, 4] # Reds, Giants, Red Sox
elif gp == 7:
teams = [4, 21, 23, 30]
teams = [24, 23, 19] # Mariners, Padres, Yankees
elif gp == 8:
teams = [2, 20]
teams = [18, 21, 11] # Mets, Phillies, Astros
elif gp == 9:
teams = [13, 14]
teams = [14, 29, 16] # Dodgers, Blue Jays, Brewers
elif gp == 10:
teams = [19, 5]
teams = [5, 10] # Cubs, Tigers
else:
raise KeyError(f'Hmm...I do not know who you should be playing right now.')
t_id = teams[random.randint(0, len(teams)-1)]

1785
helpers.py

File diff suppressed because it is too large Load Diff

View File

@ -604,21 +604,29 @@ async def get_all_positions(session: Session, this_card: Card, skip_cache: bool
logger.info(f'we found a cached position rating: {position} / created: {position.created}')
tdelta = datetime.datetime.now() - position.created
logger.debug(f'tdelta: {tdelta}')
if tdelta.total_seconds() >= CACHE_LIMIT:
if tdelta.total_seconds() >= CACHE_LIMIT or datetime.datetime.now().day < 5:
session.delete(position)
session.commit()
should_repull = True
if not should_repull:
if not should_repull and len(all_pos) > 0:
logger.info(f'Returning {len(all_pos)}')
return len(all_pos)
p_query = await db_get('cardpositions', params=[('player_id', this_card.player.id)])
if p_query['count'] == 0:
if not p_query or p_query['count'] == 0:
logger.info(f'No positions received, returning 0')
return 0
old_pos = session.exec(select(PositionRating).where(PositionRating.player_id == this_card.player_id)).all()
for position in old_pos:
logger.info(f'Deleting orphaned position rating: {position}')
session.delete(position)
session.commit()
def cache_pos(json_data: dict) -> PositionRating:
if 'id' in json_data:
del json_data['id']
@ -716,7 +724,11 @@ async def get_or_create_ai_card(session: Session, player: Player, team: Team, sk
logger.info(f'gameplay_models - get_or_create_ai_card: creating {player.description} {player.name} card for {team.abbrev}')
if dev_mode:
this_card = Card(player=player, team=team)
# Find next available ID since Card model has autoincrement=False
from sqlmodel import func
max_id = session.exec(select(func.max(Card.id))).one()
next_id = (max_id or 0) + 1
this_card = Card(id=next_id, player=player, team=team)
session.add(this_card)
session.commit()
session.refresh(this_card)

View File

@ -2,3 +2,5 @@
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
env =
DOCKER_HOST = unix:///home/cal/.docker/desktop/docker.sock

219
random_content.py Normal file
View File

@ -0,0 +1,219 @@
"""
Random Content Generators
This module contains all the random content generation functions including
GIFs, phrases, codenames, and other content used for bot interactions.
"""
import random
import logging
import requests
from constants import INSULTS
logger = logging.getLogger('discord_app')
def random_conf_gif():
"""Returns a random confirmation GIF URL."""
conf_gifs = [
'https://tenor.com/view/boom-annakendrick-pitchperfect-pitchperfect2-micdrop-gif-5143507',
'https://tenor.com/view/boom-annakendrick-pitchperfect-pitchperfect2-micdrop-gif-5143507',
'https://tenor.com/view/boom-annakendrick-pitchperfect-pitchperfect2-micdrop-gif-5143507',
'https://tenor.com/view/explosion-boom-iron-man-gif-14282225',
'https://tenor.com/view/betty-white-dab-consider-it-done-gif-11972415',
'https://tenor.com/view/done-and-done-spongebob-finished-just-did-it-gif-10843280',
'https://tenor.com/view/thumbs-up-okay-ok-well-done-gif-13840394',
'https://tenor.com/view/tinkerbell-peter-pan-all-done-gif-15003723',
'https://tenor.com/view/done-and-done-ron-swanson-gotchu-gif-10843254',
'https://tenor.com/view/sponge-bob-thumbs-up-ok-smile-gif-12038157',
'https://tenor.com/view/thumbs-up-cool-okay-bye-gif-8633196',
'https://i0.wp.com/media1.giphy.com/media/iwvuPyfi7z14I/giphy.gif',
'https://media1.tenor.com/images/859a2d3b201fbacec13904242976b9e0/tenor.gif',
'https://tenor.com/bc1OJ.gif',
'https://tenor.com/1EmF.gif',
'https://tenor.com/ZYCh.gif',
'https://tenor.com/patd.gif',
'https://tenor.com/u6mU.gif',
'https://tenor.com/x2sa.gif',
'https://tenor.com/bAVeS.gif',
'https://tenor.com/bxOcj.gif',
'https://tenor.com/ETJ7.gif',
'https://tenor.com/bpH3g.gif',
'https://tenor.com/biF9q.gif',
'https://tenor.com/OySS.gif',
'https://tenor.com/bvVFv.gif',
'https://tenor.com/bFeqA.gif'
]
return conf_gifs[random.randint(0, len(conf_gifs) - 1)]
def random_no_gif():
"""Returns a random 'no' reaction GIF URL."""
no_gifs = [
'https://tenor.com/view/youre-not-my-dad-dean-jensen-ackles-supernatural-you-arent-my-dad-gif-19503399',
'https://tenor.com/view/youre-not-my-dad-kid-gif-8300190',
'https://tenor.com/view/youre-not-my-supervisor-youre-not-my-boss-gif-12971403',
'https://tenor.com/view/dont-tell-me-what-to-do-gif-4951202'
]
return no_gifs[random.randint(0, len(no_gifs) - 1)]
def random_salute_gif():
"""Returns a random salute GIF URL."""
salute_gifs = [
'https://media.giphy.com/media/fSAyceY3BCgtiQGnJs/giphy.gif',
'https://media.giphy.com/media/bsWDUSFUmJCOk/giphy.gif',
'https://media.giphy.com/media/hStvd5LiWCFzYNyxR4/giphy.gif',
'https://media.giphy.com/media/RhSR5xXDsXJ7jbnrRW/giphy.gif',
'https://media.giphy.com/media/lNQvrlPdbmZUU2wlh9/giphy.gif',
'https://gfycat.com/skeletaldependableandeancat',
'https://i.gifer.com/5EJk.gif',
'https://tenor.com/baJUV.gif',
'https://tenor.com/bdnQH.gif',
'https://tenor.com/bikQU.gif',
'https://i.pinimg.com/originals/04/36/bf/0436bfc9861b4b57ffffda82d3adad6e.gif',
'https://media.giphy.com/media/6RtOG4Q7v34kw/giphy.gif',
'https://keyassets-p2.timeincuk.net/wp/prod/wp-content/uploads/sites/42/2017/04/anigif_'
'enhanced-946-1433453114-7.gif',
'https://keyassets-p2.timeincuk.net/wp/prod/wp-content/uploads/sites/42/2017/04/100c5d677cc28ea3f15'
'4c70d641f655b_meme-crying-gif-crying-gif-meme_620-340.gif',
'https://media.giphy.com/media/fnKd6rCHaZoGdzLjjA/giphy.gif',
'https://media.giphy.com/media/47D5jmVc4f7ylygXYD/giphy.gif',
'https://media.giphy.com/media/I4wGMXoi2kMDe/giphy.gif',
]
return salute_gifs[random.randint(0, len(salute_gifs) - 1)]
def random_conf_word():
"""Returns a random confirmation word."""
conf_words = [
'dope',
'cool',
'got it',
'noice',
'ok',
'lit',
]
return conf_words[random.randint(0, len(conf_words) - 1)]
def random_codename():
"""Returns a random codename from a large list of options."""
all_names = [
'Shong', 'DerekSux', 'JoeSux', 'CalSux', 'Friend', 'Andrea', 'Ent', 'Lindved', 'Camp', 'Idyll', 'Elaphus',
'Turki', 'Shrimp', 'Primary', 'Anglica', 'Shail', 'Blanket', 'Baffled', 'Deer', 'Thisted', 'Brisk', 'Shy',
'Table', 'Jorts', 'Renati', 'Gisky', 'Prig', 'Bathtub', 'Gallery', 'Mavas', 'Chird', 'Oxyura', 'Mydal', 'Brown',
'Vasen', 'Worthy', 'Bivver', 'Cirlus', 'Self', 'Len', 'Sharp', 'Dart', 'Crepis', 'Ferina', 'Curl', 'Lancome',
'Stuff', 'Glove', 'Consist', 'Smig', 'Egg', 'Pleat', 'Picture', 'Spin', 'Ridgty', 'Ickled', 'Abashed', 'Haul',
'Cordage', 'Chivery', 'Stointy', 'Baa', 'Here', 'Ulmi', 'Tour', 'Tribe', 'Crunch', 'Used', 'Pigface', 'Audit',
'Written', 'Once', 'Fickle', 'Drugged', 'Swarm', 'Blimber', 'Torso', 'Retusa', 'Hockey', 'Pusty', 'Sallow',
'Next', 'Mansion', 'Glass', 'Screen', 'Josiah', 'Bonkey', 'Stuff', 'Sane', 'Blooded', 'Gnat', 'Liparis',
'Ocean', 'Sway', 'Roband', 'Still', 'Ribba', 'Biryani', 'Halibut', 'Flyn', 'Until', 'Depend', 'Intel',
'Affinis', 'Chef', 'Trounce', 'Crawl', 'Grab', 'Eggs', 'Malfroy', 'Sitta', 'Cretin', 'May', 'Smithii',
'Saffron', 'Crummy', 'Powered', 'Rail', 'Trait', 'Koiled', 'Bronze', 'Quickly', 'Vikis', 'Trift', 'Jubilar',
'Deft', 'Juncus', 'Sodding', 'Distant', 'Poecile', 'Pipe', 'Sell', 'Inops', 'Peusi', 'Sparrow', 'Yams',
'Kidneys', 'Artery', 'Vuffin', 'Boink', 'Bos', 'Notable', 'Alba', 'Spurge', 'Ruby', 'Cilia', 'Pellow', 'Nox',
'Woozy', 'Semvik', 'Tyda', 'Season', 'Lychnis', 'Ibestad', 'Bagge', 'Marked', 'Browdie', 'Fisher', 'Tilly',
'Troll', 'Gypsy', 'Thisted', 'Flirt', 'Stop', 'Radiate', 'Poop', 'Plenty', 'Jeff', 'Magpie', 'Roof', 'Ent',
'Dumbo', 'Pride', 'Weights', 'Winted', 'Dolden', 'Meotica', 'Yikes', 'Teeny', 'Fizz', 'Eide', 'Foetida',
'Crash', 'Mann', 'Salong', 'Cetti', 'Balloon', 'Petite', 'Find', 'Sputter', 'Patula', 'Upstage', 'Aurora',
'Dadson', 'Drate', 'Heidal', 'Robin', 'Auditor', 'Ithil', 'Warmen', 'Pat', 'Muppet', '007', 'Advantage',
'Alert', 'Backhander', 'Badass', 'Blade', 'Blaze', 'Blockade', 'Blockbuster', 'Boxer', 'Brimstone', 'Broadway',
'Buccaneer', 'Champion', 'Cliffhanger', 'Coachman', 'Comet', 'Commander', 'Courier', 'Cowboy', 'Crawler',
'Crossroads', 'DeepSpace', 'Desperado', 'Double-Decker', 'Echelon', 'Edge', 'Encore', 'EnRoute', 'Escape',
'Eureka', 'Evangelist', 'Excursion', 'Explorer', 'Fantastic', 'Firefight', 'Foray', 'Forge', 'Freeway',
'Frontier', 'FunMachine', 'Galaxy', 'GameOver', 'Genesis', 'Hacker', 'Hawkeye', 'Haybailer', 'Haystack',
'Hexagon', 'Hitman', 'Hustler', 'Iceberg', 'Impossible', 'Impulse', 'Invader', 'Inventor', 'IronWolf',
'Jackrabbit', 'Juniper', 'Keyhole', 'Lancelot', 'Liftoff', 'MadHatter', 'Magnum', 'Majestic', 'Merlin',
'Multiplier', 'Netiquette', 'Nomad', 'Octagon', 'Offense', 'OliveBranch', 'OlympicTorch', 'Omega', 'Onyx',
'Orbit', 'OuterSpace', 'Outlaw', 'Patron', 'Patriot', 'Pegasus', 'Pentagon', 'Pilgrim', 'Pinball', 'Pinnacle',
'Pipeline', 'Pirate', 'Portal', 'Predator', 'Prism', 'RagingBull', 'Ragtime', 'Reunion', 'Ricochet',
'Roadrunner', 'Rockstar', 'RobinHood', 'Rover', 'Runabout', 'Sapphire', 'Scrappy', 'Seige', 'Shadow',
'Shakedown', 'Shockwave', 'Shooter', 'Showdown', 'SixPack', 'SlamDunk', 'Slasher', 'Sledgehammer', 'Spirit',
'Spotlight', 'Starlight', 'Steamroller', 'Stride', 'Sunrise', 'Superhuman', 'Supernova', 'SuperBowl', 'Sunset',
'Sweetheart', 'TopHand', 'Touchdown', 'Tour', 'Trailblazer', 'Transit', 'Trekker', 'Trio', 'TriplePlay',
'TripleThreat', 'Universe', 'Unstoppable', 'Utopia', 'Vicinity', 'Vector', 'Vigilance', 'Vigilante', 'Vista',
'Visage', 'Vis-à-vis', 'VIP', 'Volcano', 'Volley', 'Whizzler', 'Wingman', 'Badger', 'BlackCat', 'Bobcat',
'Caracal', 'Cheetah', 'Cougar', 'Jaguar', 'Leopard', 'Lion', 'Lynx', 'MountainLion', 'Ocelot', 'Panther',
'Puma', 'Siamese', 'Serval', 'Tiger', 'Wolverine', 'Abispa', 'Andrena', 'BlackWidow', 'Cataglyphis',
'Centipede', 'Cephalotes', 'Formica', 'Hornet', 'Jellyfish', 'Scorpion', 'Tarantula', 'Yellowjacket', 'Wasp',
'Apollo', 'Ares', 'Artemis', 'Athena', 'Hercules', 'Hermes', 'Iris', 'Medusa', 'Nemesis', 'Neptune', 'Perseus',
'Poseidon', 'Triton', 'Zeus', 'Aquarius', 'Aries', 'Cancer', 'Capricorn', 'Gemini', 'Libra', 'Leo', 'Pisces',
'Sagittarius', 'Scorpio', 'Taurus', 'Virgo', 'Andromeda', 'Aquila', 'Cassiopeia', 'Cepheus', 'Cygnus',
'Delphinus', 'Drako', 'Lyra', 'Orion', 'Perseus', 'Serpens', 'Triangulum', 'Anaconda', 'Boa', 'Cobra',
'Copperhead', 'Cottonmouth', 'Garter', 'Kingsnake', 'Mamba', 'Python', 'Rattler', 'Sidewinder', 'Taipan',
'Viper', 'Alligator', 'Barracuda', 'Crocodile', 'Gator', 'GreatWhite', 'Hammerhead', 'Jaws', 'Lionfish',
'Mako', 'Moray', 'Orca', 'Piranha', 'Shark', 'Stingray', 'Axe', 'BattleAxe', 'Bayonet', 'Blade', 'Crossbowe',
'Dagger', 'Excalibur', 'Halberd', 'Hatchet', 'Machete', 'Saber', 'Samurai', 'Scimitar', 'Scythe', 'Stiletto',
'Spear', 'Sword', 'Aurora', 'Avalanche', 'Blizzard', 'Cyclone', 'Dewdrop', 'Downpour', 'Duststorm', 'Fogbank',
'Freeze', 'Frost', 'GullyWasher', 'Gust', 'Hurricane', 'IceStorm', 'JetStream', 'Lightning', 'Mist', 'Monsoon',
'Rainbow', 'Raindrop', 'SandStorm', 'Seabreeze', 'Snowflake', 'Stratosphere', 'Storm', 'Sunrise', 'Sunset',
'Tornado', 'Thunder', 'Thunderbolt', 'Thunderstorm', 'TropicalStorm', 'Twister', 'Typhoon', 'Updraft', 'Vortex',
'Waterspout', 'Whirlwind', 'WindChill', 'Archimedes', 'Aristotle', 'Confucius', 'Copernicus', 'Curie',
'daVinci', 'Darwin', 'Descartes', 'Edison', 'Einstein', 'Epicurus', 'Freud', 'Galileo', 'Hawking',
'Machiavelli', 'Marx', 'Newton', 'Pascal', 'Pasteur', 'Plato', 'Sagan', 'Socrates', 'Tesla', 'Voltaire',
'Baccarat', 'Backgammon', 'Blackjack', 'Chess', 'Jenga', 'Jeopardy', 'Keno', 'Monopoly', 'Pictionary', 'Poker',
'Scrabble', 'TrivialPursuit', 'Twister', 'Roulette', 'Stratego', 'Yahtzee', 'Aquaman', 'Batman', 'BlackPanther',
'BlackWidow', 'CaptainAmerica', 'Catwoman', 'Daredevil', 'Dr.Strange', 'Flash', 'GreenArrow', 'GreenLantern',
'Hulk', 'IronMan', 'Phantom', 'Thor', 'SilverSurfer', 'SpiderMan', 'Supergirl', 'Superman', 'WonderWoman',
'Wolverine', 'Hypersonic', 'Lightspeed', 'Mach1,2,3,4,etc', 'Supersonic', 'WarpSpeed', 'Amiatina', 'Andalusian',
'Appaloosa', 'Clydesdale', 'Colt', 'Falabella', 'Knabstrupper', 'Lipizzan', 'Lucitano', 'Maverick', 'Mustang',
'Palomino', 'Pony', 'QuarterHorse', 'Stallion', 'Thoroughbred', 'Zebra', 'Antigua', 'Aruba', 'Azores', 'Baja',
'Bali', 'Barbados', 'Bermuda', 'BoraBora', 'Borneo', 'Capri', 'Cayman', 'Corfu', 'Cozumel', 'Curacao', 'Fiji',
'Galapagos', 'Hawaii', 'Ibiza', 'Jamaica', 'Kauai', 'Lanai', 'Majorca', 'Maldives', 'Maui', 'Mykonos',
'Nantucket', 'Oahu', 'Tahiti', 'Tortuga', 'Roatan', 'Santorini', 'Seychelles', 'St.Johns', 'St.Lucia',
'Albatross', 'BaldEagle', 'Blackhawk', 'BlueJay', 'Chukar', 'Condor', 'Crane', 'Dove', 'Eagle', 'Falcon',
'Goose(GoldenGoose)', 'Grouse', 'Hawk', 'Heron', 'Hornbill', 'Hummingbird', 'Lark', 'Mallard', 'Oriole',
'Osprey', 'Owl', 'Parrot', 'Penguin', 'Peregrine', 'Pelican', 'Pheasant', 'Quail', 'Raptor', 'Raven', 'Robin',
'Sandpiper', 'Seagull', 'Sparrow', 'Stork', 'Thunderbird', 'Toucan', 'Vulture', 'Waterfowl', 'Woodpecker',
'Wren', 'C-3PO', 'Chewbacca', 'Dagobah', 'DarthVader', 'DeathStar', 'Devaron', 'Droid', 'Endor', 'Ewok', 'Hoth',
'Jakku', 'Jedi', 'Leia', 'Lightsaber', 'Lothal', 'Naboo', 'Padawan', 'R2-D2', 'Scarif', 'Sith', 'Skywalker',
'Stormtrooper', 'Tatooine', 'Wookie', 'Yoda', 'Zanbar', 'Canoe', 'Catamaran', 'Cruiser', 'Cutter', 'Ferry',
'Galleon', 'Gondola', 'Hovercraft', 'Hydrofoil', 'Jetski', 'Kayak', 'Longboat', 'Motorboat', 'Outrigger',
'PirateShip', 'Riverboat', 'Sailboat', 'Skipjack', 'Schooner', 'Skiff', 'Sloop', 'Steamboat', 'Tanker',
'Trimaran', 'Trawler', 'Tugboat', 'U-boat', 'Yacht', 'Yawl', 'Lancer', 'Volunteer', 'Searchlight', 'Passkey',
'Deacon', 'Rawhide', 'Timberwolf', 'Eagle', 'Tumbler', 'Renegade', 'Mogul'
]
this_name = all_names[random.randint(0, len(all_names) - 1)]
return this_name
def random_no_phrase():
"""Returns a random 'no' phrase."""
phrases = [
'uhh...no',
'lol no',
'nope',
]
return phrases[random.randint(0, len(phrases) - 1)]
def random_gif(search_term: str):
"""
Fetches a random GIF from Giphy API based on search term.
Falls back to random_conf_gif() if request fails or contains 'trump'.
"""
req_url = f'https://api.giphy.com/v1/gifs/translate?s={search_term}&api_key=H86xibttEuUcslgmMM6uu74IgLEZ7UOD'
resp = requests.get(req_url, timeout=3)
if resp.status_code == 200:
data = resp.json()
if 'trump' in data['data']['title']:
return random_conf_gif()
else:
return data['data']['url']
else:
logger.warning(resp.text)
raise ValueError(f'DB: {resp.text}')
def random_from_list(data_list: list):
"""Returns a random item from the provided list."""
item = data_list[random.randint(0, len(data_list) - 1)]
logger.info(f'random_from_list: {item}')
return item
def random_insult() -> str:
"""Returns a random insult from the INSULTS constant."""
return random_from_list(INSULTS)

104
search_utils.py Normal file
View File

@ -0,0 +1,104 @@
"""
Search Utilities
This module contains search and fuzzy matching functionality.
"""
import discord
from difflib import get_close_matches
from typing import Optional
def fuzzy_search(name, master_list):
"""
Perform fuzzy string matching against a list of options.
Args:
name: String to search for
master_list: List of strings to search against
Returns:
Best match string or raises ValueError if no good matches
"""
if name.lower() in master_list:
return name.lower()
great_matches = get_close_matches(name, master_list, cutoff=0.8)
if len(great_matches) == 1:
return great_matches[0]
elif len(great_matches) > 0:
matches = great_matches
else:
matches = get_close_matches(name, master_list, n=6)
if len(matches) == 1:
return matches[0]
if not matches:
raise ValueError(f'{name.title()} was not found')
return matches[0]
async def fuzzy_player_search(ctx, channel, bot, name, master_list):
"""
Interactive fuzzy player search with Discord UI.
Takes a name to search and returns the name of the best match.
Args:
ctx: discord context
channel: discord channel
bot: discord.py bot object
name: string to search for
master_list: list of names to search against
Returns:
Selected match or None if cancelled
"""
# Import here to avoid circular imports
from discord_ui.confirmations import Question
matches = fuzzy_search(name, master_list)
embed = discord.Embed(
title="Did You Mean...",
description='Enter the number of the card you would like to see.',
color=0x7FC600
)
count = 1
for x in matches:
embed.add_field(name=f'{count}', value=x, inline=False)
count += 1
embed.set_footer(text='These are the closest matches. Spell better if they\'re not who you want.')
this_q = Question(bot, channel, None, 'int', 45, embed=embed)
resp = await this_q.ask([ctx.author])
if not resp:
return None
if resp < count:
return matches[resp - 1]
else:
raise ValueError(f'{resp} is not a valid response.')
async def cardset_search(cardset: str, cardset_list: list) -> Optional[dict]:
"""
Search for a cardset by name and return the cardset data.
Args:
cardset: Cardset name to search for
cardset_list: List of available cardset names
Returns:
Cardset dictionary or None if not found
"""
# Import here to avoid circular imports
from api_calls import db_get
cardset_name = fuzzy_search(cardset, cardset_list)
if not cardset_name:
return None
c_query = await db_get('cardsets', params=[('name', cardset_name)])
if c_query['count'] == 0:
return None
return c_query['cardsets'][0]

View File

@ -1,7 +1,7 @@
import pytest
from sqlmodel import Session, select, func
from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks
from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos
from in_game.gameplay_models import Lineup, Play
from tests.factory import session_fixture, Game
@ -218,3 +218,228 @@ async def test_stealing(session: Session):
assert play_2.on_first_final == 2
assert play_2.sb == 1
assert play_2.runner == play_2.on_first
async def test_walks(session: Session):
"""Test walk functionality - both intentional and unintentional walks."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Test unintentional walk
play_1 = await walks(session, None, play_1, 'unintentional')
assert play_1.ab == 0 # No at-bat for walk
assert play_1.bb == 1 # Base on balls
assert play_1.ibb == 0 # Not intentional
assert play_1.batter_final == 1 # Batter reaches first
# Set up for intentional walk test
play_2 = complete_play(session, play_1)
play_2 = await walks(session, None, play_2, 'intentional')
assert play_2.ab == 0
assert play_2.bb == 1
assert play_2.ibb == 1 # Intentional walk
assert play_2.batter_final == 1
async def test_strikeouts(session: Session):
"""Test strikeout functionality."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
play_1 = await strikeouts(session, None, play_1)
assert play_1.so == 1 # Strikeout recorded
assert play_1.outs == 1 # One out recorded
# Note: batter_final is not set by strikeouts function, handled by advance_runners
async def test_popouts(session: Session):
"""Test popout functionality."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
play_1 = await popouts(session, None, play_1)
assert play_1.outs == 1 # One out recorded
# Note: batter_final is not set by popouts function, handled by advance_runners
async def test_hit_by_pitch(session: Session):
"""Test hit by pitch functionality."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
play_1 = await hit_by_pitch(session, None, play_1)
assert play_1.ab == 0 # No at-bat for HBP
assert play_1.hbp == 1 # Hit by pitch recorded
assert play_1.batter_final == 1 # Batter reaches first
async def test_homeruns(session: Session):
"""Test homerun functionality - both ballpark and no-doubt homers."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Test ballpark homerun
play_1 = await homeruns(session, None, play_1, 'ballpark')
assert play_1.hit == 1
assert play_1.homerun == 1
assert play_1.batter_final == 4 # Batter scores
assert play_1.run == 1 # Batter scores a run
assert play_1.rbi >= 1 # At least 1 RBI (for the batter themselves)
assert play_1.bphr == 1 # Ballpark homerun
# Test no-doubt homerun
play_2 = complete_play(session, play_1)
play_2 = await homeruns(session, None, play_2, 'no-doubt')
assert play_2.hit == 1
assert play_2.homerun == 1
assert play_2.batter_final == 4
assert play_2.run == 1
assert play_2.rbi >= 1
assert play_2.bphr == 0 # Not a ballpark homerun
async def test_singles(session: Session):
"""Test single functionality with different types."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Test regular single (*)
play_1 = await singles(session, None, play_1, '*')
assert play_1.hit == 1
assert play_1.batter_final == 1 # Batter reaches first
assert play_1.bp1b == 0 # Not a ballpark single
# Test ballpark single
play_2 = complete_play(session, play_1)
play_2 = await singles(session, None, play_2, 'ballpark')
assert play_2.hit == 1
assert play_2.batter_final == 1
assert play_2.bp1b == 1 # Ballpark single
# Test double-advance single (**)
play_3 = complete_play(session, play_2)
play_3 = await singles(session, None, play_3, '**')
assert play_3.hit == 1
assert play_3.batter_final == 1
async def test_doubles(session: Session):
"""Test double functionality with different types."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Test regular double (**)
play_1 = await doubles(session, None, play_1, '**')
assert play_1.hit == 1
assert play_1.double == 1
assert play_1.batter_final == 2 # Batter reaches second
# Test triple-advance double (***)
play_2 = complete_play(session, play_1)
play_2 = await doubles(session, None, play_2, '***')
assert play_2.hit == 1
assert play_2.double == 1
assert play_2.batter_final == 2
async def test_triples(session: Session):
"""Test triple functionality."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
play_1 = await triples(session, None, play_1)
assert play_1.hit == 1
assert play_1.triple == 1
assert play_1.batter_final == 3 # Batter reaches third
async def test_bunts(session: Session):
"""Test bunt functionality with different types."""
this_game = session.get(Game, 3)
# Test sacrifice bunt
play_1 = this_game.initialize_play(session)
play_1 = await bunts(session, None, play_1, 'sacrifice')
assert play_1.ab == 0 # No at-bat for sacrifice
assert play_1.sac == 1 # Sacrifice recorded
assert play_1.outs == 1 # Out recorded
# Test bad bunt (batter reaches first safely)
play_2 = complete_play(session, play_1)
play_2 = await bunts(session, None, play_2, 'bad')
assert play_2.ab == 1 # At-bat recorded
assert play_2.sac == 0 # No sacrifice
assert play_2.outs == 1 # Out recorded
assert play_2.batter_final == 1 # Batter reaches first
# Test popout bunt
play_3 = complete_play(session, play_2)
play_3 = await bunts(session, None, play_3, 'popout')
assert play_3.ab == 1 # At-bat recorded
assert play_3.sac == 0 # No sacrifice
assert play_3.outs == 1 # Out recorded
# Test double-play bunt
play_4 = complete_play(session, play_3)
play_4 = await bunts(session, None, play_4, 'double-play')
assert play_4.ab == 1 # At-bat recorded
assert play_4.sac == 0 # No sacrifice
# Double play outs depend on starting_outs
async def test_chaos(session: Session):
"""Test chaos events - wild pitch, passed ball, balk, pickoff."""
this_game = session.get(Game, 3)
# Test wild pitch
play_1 = this_game.initialize_play(session)
play_1 = await chaos(session, None, play_1, 'wild-pitch')
assert play_1.pa == 0 # No plate appearance
assert play_1.ab == 0 # No at-bat
assert play_1.wild_pitch == 1 # Wild pitch recorded
assert play_1.rbi == 0 # No RBI on wild pitch
# Test passed ball
play_2 = complete_play(session, play_1)
play_2 = await chaos(session, None, play_2, 'passed-ball')
assert play_2.pa == 0
assert play_2.ab == 0
assert play_2.passed_ball == 1 # Passed ball recorded
assert play_2.rbi == 0
# Test balk
play_3 = complete_play(session, play_2)
play_3 = await chaos(session, None, play_3, 'balk')
assert play_3.pa == 0
assert play_3.ab == 0
assert play_3.balk == 1 # Balk recorded
assert play_3.rbi == 0
# Test pickoff
play_4 = complete_play(session, play_3)
play_4 = await chaos(session, None, play_4, 'pickoff')
assert play_4.pa == 0
assert play_4.ab == 0
assert play_4.pick_off == 1 # Pickoff recorded
assert play_4.outs == 1 # Out recorded

View File

@ -122,7 +122,7 @@ async def test_groundball_4_2(session: Session):
assert ending.starting_outs == 2
assert ending.on_base_code == 1
assert ending.on_first == play_2.batter
assert ending.on_first == play_3.batter # Current batter reaches first (gb_4 rule: batter safe at first)
assert ending.away_score == 1

19
tests/conftest.py Normal file
View File

@ -0,0 +1,19 @@
"""
Global pytest configuration for all tests.
Sets up Docker environment for testcontainers.
"""
import os
import pytest
def pytest_configure(config):
"""Configure pytest with required environment variables."""
# Set Docker socket for testcontainers
if not os.getenv('DOCKER_HOST'):
os.environ['DOCKER_HOST'] = 'unix:///home/cal/.docker/desktop/docker.sock'
def pytest_collection_modifyitems(config, items):
"""Add markers to tests that use testcontainers."""
for item in items:
# Add slow marker to tests that use database fixtures
if 'session' in item.fixturenames:
item.add_marker(pytest.mark.slow)

View File

@ -1,83 +1,89 @@
import datetime
import os
import pytest
from sqlmodel import Session, SQLModel, create_engine, select
from sqlmodel.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select, text
from testcontainers.postgres import PostgresContainer
from typing import Literal
from in_game.gameplay_models import BatterScouting, BattingCard, BattingRatings, Card, Cardset, Game, GameCardsetLink, Lineup, ManagerAi, PitcherScouting, PitchingCard, PitchingRatings, Play, RosterLink, Team, Player
from in_game.gameplay_models import BatterScouting, BattingCard, BattingRatings, Card, Cardset, Game, GameCardsetLink, Lineup, ManagerAi, PitcherScouting, PitchingCard, PitchingRatings, Play, PositionRating, RosterLink, Team, Player
@pytest.fixture(name='session')
def session_fixture():
engine = create_engine(
'sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
team_1 = Team(
id=31, abbrev='NCB', sname='CornBelters', lname='Normal CornBelters', gmid=1234, gmname='Cal', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='006900', season=7, event=False, career=1234, ranking=1337, has_guide=True, is_ai=True
)
team_2 = Team(
id=400, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', gmid=5678, gmname='Evil Cal', gsheet='https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png', wallet=350, team_value=420, collection_value=169, color='6699FF', season=7, event=False, career=2, ranking=969, has_guide=False, is_ai=False
)
team_3 = Team(
id=69, abbrev='NCB3', sname='CornBelters', lname='Normal CornBelters', gmid=1234, gmname='Cal', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='006900', season=7, event=False, career=1234, ranking=1337, has_guide=True, is_ai=False
)
team_4 = Team(
id=420, abbrev='WV4', sname='Black Bears', lname='West Virginia Black Bears', gmid=5678, gmname='Evil Cal', gsheet='https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png', wallet=350, team_value=420, collection_value=169, color='6699FF', season=7, event=False, career=2, ranking=969, has_guide=False, is_ai=True
)
incomplete_team = Team(
id=446, abbrev='CLS', sname='Macho Men', lname='Columbus Macho Men', gmid=181818, gmname='Mason Socc', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='https://i.postimg.cc/8kLZCYXh/S10CLS.png', season=7, event=False, career=0
)
old_cache_team = Team(
id=3, abbrev='BAL', sname='Orioles', lname='Baltimore Orioles', gmid=181818, gmname='Brandon Hyde', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='https://i.postimg.cc/8kLZCYXh/S10CLS.png', season=7, event=False, career=0, ranking=500, has_guide=False, is_ai=False, created=datetime.datetime.today() - datetime.timedelta(days=60)
)
session.add(team_1)
session.add(team_2)
session.add(team_3)
session.add(team_4)
# session.add(incomplete_team)
session.add(old_cache_team)
session.commit()
# Ensure Docker socket is set for testcontainers
if not os.getenv('DOCKER_HOST'):
os.environ['DOCKER_HOST'] = 'unix:///home/cal/.docker/desktop/docker.sock'
with PostgresContainer("postgres:13") as postgres:
postgres_url = postgres.get_connection_url()
engine = create_engine(postgres_url)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
team_1 = Team(
id=31, abbrev='NCB', sname='CornBelters', lname='Normal CornBelters', gmid=1234, gmname='Cal', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='006900', season=7, event=False, career=1234, ranking=1337, has_guide=True, is_ai=True
)
team_2 = Team(
id=400, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', gmid=5678, gmname='Evil Cal', gsheet='https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png', wallet=350, team_value=420, collection_value=169, color='6699FF', season=7, event=False, career=2, ranking=969, has_guide=False, is_ai=False
)
team_3 = Team(
id=69, abbrev='NCB3', sname='CornBelters', lname='Normal CornBelters', gmid=1234, gmname='Cal', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='006900', season=7, event=False, career=1234, ranking=1337, has_guide=True, is_ai=False
)
team_4 = Team(
id=420, abbrev='WV4', sname='Black Bears', lname='West Virginia Black Bears', gmid=5678, gmname='Evil Cal', gsheet='https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png', wallet=350, team_value=420, collection_value=169, color='6699FF', season=7, event=False, career=2, ranking=969, has_guide=False, is_ai=True
)
incomplete_team = Team(
id=446, abbrev='CLS', sname='Macho Men', lname='Columbus Macho Men', gmid=181818, gmname='Mason Socc', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='https://i.postimg.cc/8kLZCYXh/S10CLS.png', season=7, event=False, career=0
)
old_cache_team = Team(
id=3, abbrev='BAL', sname='Orioles', lname='Baltimore Orioles', gmid=181818, gmname='Brandon Hyde', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='https://i.postimg.cc/8kLZCYXh/S10CLS.png', season=7, event=False, career=0, ranking=500, has_guide=False, is_ai=False, created=datetime.datetime.today() - datetime.timedelta(days=60)
)
game_1 = Game(away_team_id=31, home_team_id=400, channel_id=1234, season=9, ai_team='away', game_type='minor-league')
game_2 = Game(away_team_id=69, home_team_id=420, channel_id=5678, season=9, active=False, is_pd=True, ranked=True, week=6, game_num=9, away_roster_id=69, home_roster_id=420, first_message=12345678, ai_team='home', game_type='minor-league')
game_3 = Game(away_team_id=69, home_team_id=420, channel_id=5678, season=9, active=True, is_pd=True, ranked=True, week=6, game_num=10, away_roster_id=69, home_roster_id=420, first_message=34567890, ai_team='home', game_type='minor-league')
session.add(game_1)
session.add(game_2)
session.add(game_3)
session.commit()
session.add(team_1)
session.add(team_2)
session.add(team_3)
session.add(team_4)
# session.add(incomplete_team)
session.add(old_cache_team)
session.commit()
cardset_1 = Cardset(id=1, name='1969 Live')
cardset_2 = Cardset(id=2, name='2024 Season')
game_1 = Game(id=1, away_team_id=31, home_team_id=400, channel_id=1234, season=9, ai_team='away', game_type='minor-league')
game_2 = Game(id=2, away_team_id=69, home_team_id=420, channel_id=5678, season=9, active=False, is_pd=True, ranked=True, week=6, game_num=9, away_roster_id=69, home_roster_id=420, first_message=12345678, ai_team='home', game_type='minor-league')
game_3 = Game(id=3, away_team_id=69, home_team_id=420, channel_id=5678, season=9, active=True, is_pd=True, ranked=True, week=6, game_num=10, away_roster_id=69, home_roster_id=420, first_message=34567890, ai_team='home', game_type='minor-league')
session.add(cardset_1)
session.add(cardset_2)
session.commit()
session.add(game_1)
session.add(game_2)
session.add(game_3)
session.commit()
link_1 = GameCardsetLink(game=game_1, cardset=cardset_1, priority=1)
link_2 = GameCardsetLink(game=game_1, cardset=cardset_2, priority=1)
link_3 = GameCardsetLink(game=game_2, cardset=cardset_1, priority=1)
link_4 = GameCardsetLink(game=game_2, cardset=cardset_2, priority=2)
cardset_1 = Cardset(id=1, name='1969 Live')
cardset_2 = Cardset(id=2, name='2024 Season')
session.add(link_1)
session.add(link_2)
session.add(link_3)
session.add(link_4)
session.commit()
session.add(cardset_1)
session.add(cardset_2)
session.commit()
all_players = []
all_cards = []
all_batscouting = []
all_pitscouting = []
all_pitratings = []
all_batratings = []
all_batcards = []
all_pitcards = []
pos_list = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']
for x in range(40):
link_1 = GameCardsetLink(game=game_1, cardset=cardset_1, priority=1)
link_2 = GameCardsetLink(game=game_1, cardset=cardset_2, priority=1)
link_3 = GameCardsetLink(game=game_2, cardset=cardset_1, priority=1)
link_4 = GameCardsetLink(game=game_2, cardset=cardset_2, priority=2)
session.add(link_1)
session.add(link_2)
session.add(link_3)
session.add(link_4)
session.commit()
all_players = []
all_cards = []
all_batscouting = []
all_pitscouting = []
all_pitratings = []
all_batratings = []
all_batcards = []
all_pitcards = []
pos_list = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']
# Create players first
for x in range(40):
if x < 10:
mlb_team = 'Baltimore Orioles'
team_id = 31
@ -104,12 +110,15 @@ def session_fixture():
pos_1=pos_list[(x % 10)],
description="Live" if x % 2 == 1 else "2024"
))
# Create cards and ratings for each player
# Is Batter
if x % 10 == 9:
if (x + 1) % 10 != 0:
all_batcards.append(BattingCard(
id=x+1,
steal_high=10 + (x % 10),
hand='R'
hand='R',
offense_col=1
))
all_batratings.append(BattingRatings(
id=x+1,
@ -119,23 +128,14 @@ def session_fixture():
id=x+1001,
homerun=x % 10
))
all_batscouting.append(BatterScouting(
id=x+1,
battingcard_id=x+1,
ratings_vr_id=x+1,
ratings_vl_id=x+1001,
))
all_cards.append(Card(
player_id=x+1,
team_id=team_id,
batterscouting_id=x+1
))
# Is Pitcher
else:
all_pitcards.append(PitchingCard(
id=x+1,
wild_pitch=x / 10,
hand='R'
hand='R',
starter_rating=5,
offense_col=1
))
all_pitratings.append(PitchingRatings(
id=x+1,
@ -145,116 +145,585 @@ def session_fixture():
id=x+1001,
homerun=x % 10
))
all_pitscouting.append(PitcherScouting(
all_players.append(Player(
id=41, name='Player 40', cost=41*3, mlbclub='Junior All-Stars', franchise='Junior All-Stars', cardset=cardset_1, set_num=41, pos_1='DH', description='Live', created=datetime.datetime.today() - datetime.timedelta(days=60), image='player_41_image_URL', rarity_id=1
))
all_players.append(Player(
id=69, name='Player 68', cost=69*3, mlbclub='Junior All-Stars', franchise='Junior All-Stars', cardset=cardset_1, set_num=69, pos_1='DH', description='Live', created=datetime.datetime.today() - datetime.timedelta(days=60), image='player_69_image_URL', rarity_id=1
))
# Add scouting data for player 41 (batter)
all_batcards.append(BattingCard(
id=41,
steal_high=15,
hand='R',
offense_col=1
))
all_batratings.append(BattingRatings(
id=41,
homerun=5
))
all_batratings.append(BattingRatings(
id=41001,
homerun=5
))
all_players.append(Player(
id=70, name='Player 69', cost=70*3, mlbclub='Junior All-Stars', franchise='Junior All-Stars', cardset=cardset_1, set_num=70, pos_1='SP', pos_2='DH', description='Live', created=datetime.datetime.today() - datetime.timedelta(days=60), image='player_69_pitchingcard', image2='player_69_battingcard', rarity_id=1
))
# Add scouting data for player 70 (two-way player - pitcher primary)
all_pitcards.append(PitchingCard(
id=70,
wild_pitch=2.0,
hand='R',
starter_rating=7,
offense_col=1
))
all_pitratings.append(PitchingRatings(
id=70,
homerun=3
))
all_pitratings.append(PitchingRatings(
id=70001,
homerun=3
))
# Also add batting data for player 70 with unique ID
all_batcards.append(BattingCard(
id=170, # Use unique ID to avoid conflicts
steal_high=12,
hand='R',
offense_col=1
))
all_batratings.append(BattingRatings(
id=170,
homerun=4
))
all_batratings.append(BattingRatings(
id=170001,
homerun=4
))
# Add records in correct order to avoid foreign key constraint errors
# 1. Add players first (no dependencies)
for player in all_players:
session.add(player)
session.commit()
# 2. Add batting/pitching cards and ratings (depend on players)
for batcard in all_batcards:
session.add(batcard)
for pitcard in all_pitcards:
session.add(pitcard)
for batrating in all_batratings:
session.add(batrating)
for pitrating in all_pitratings:
session.add(pitrating)
session.commit()
# 3. Create scouting records and cards in a coordinated way
# Create cards for each player based on their position
for x in range(40):
if x < 10:
team_id = 31
elif x < 20:
team_id = 400
elif x < 30:
team_id = 69
else:
team_id = 420
# Is Batter
if (x + 1) % 10 != 0:
# Create batter scouting (let PostgreSQL assign ID)
batscouting = BatterScouting(
battingcard_id=x+1,
ratings_vr_id=x+1,
ratings_vl_id=x+1001,
)
session.add(batscouting)
session.commit()
session.refresh(batscouting) # Get the auto-generated ID
# Create card with the actual scouting ID
card = Card(
id=x+1,
player_id=x+1,
team_id=team_id,
batterscouting_id=batscouting.id
)
session.add(card)
# Is Pitcher
else:
# Create pitcher scouting (let PostgreSQL assign ID)
pitscouting = PitcherScouting(
pitchingcard_id=x+1,
ratings_vr_id=x+1,
ratings_vl_id=x+1001
))
all_cards.append(Card(
)
session.add(pitscouting)
session.commit()
session.refresh(pitscouting) # Get the auto-generated ID
# Create card with the actual scouting ID
card = Card(
id=x+1,
player_id=x+1,
team_id=team_id,
pitcherscouting_id=x+1
))
pitcherscouting_id=pitscouting.id
)
session.add(card)
# Handle special players 41 and 70
# Create batter scouting for player 41
batscouting_41 = BatterScouting(
battingcard_id=41,
ratings_vr_id=41,
ratings_vl_id=41001,
)
session.add(batscouting_41)
session.commit()
session.refresh(batscouting_41)
card_41 = Card(
id=41,
player_id=41,
team_id=420,
created=datetime.datetime.now() - datetime.timedelta(days=60),
batterscouting_id=batscouting_41.id
)
session.add(card_41)
# Create pitcher scouting for player 70
pitscouting_70 = PitcherScouting(
pitchingcard_id=70,
ratings_vr_id=70,
ratings_vl_id=70001
)
session.add(pitscouting_70)
session.commit()
session.refresh(pitscouting_70)
# Create batter scouting for player 70 (two-way player)
batscouting_70 = BatterScouting(
battingcard_id=170,
ratings_vr_id=170,
ratings_vl_id=170001,
)
session.add(batscouting_70)
session.commit()
session.refresh(batscouting_70)
card_70 = Card(
id=70,
player_id=70,
team_id=420,
pitcherscouting_id=pitscouting_70.id,
batterscouting_id=batscouting_70.id
)
session.add(card_70)
all_players.append(Player(
id=69, name='Player 68', cost=69*3, mlbclub='Junior All-Stars', franchise='Junior All-Stars', cardset=cardset_1, set_num=69, pos_1='DH', description='Live', created=datetime.datetime.today() - datetime.timedelta(days=60), image='player_69_image_URL', rarity_id=1
))
all_cards.append(Card(
player_id=41,
team_id=420,
created=datetime.datetime.now() - datetime.timedelta(days=60)
))
all_players.append(Player(
id=70, name='Player 69', cost=70*3, mlbclub='Junior All-Stars', franchise='Junior All-Stars', cardset=cardset_1, set_num=70, pos_1='SP', pos_2='DH', description='Live', created=datetime.datetime.today() - datetime.timedelta(days=60), image='player_69_pitchingcard', image2='player_69_battingcard', rarity_id=1
))
session.commit()
for player in all_players:
session.add(player)
for card in all_cards:
session.add(card)
session.commit()
all_lineups = []
player_count = 1
for team in [team_1, team_2]:
# Add position ratings for players (needed for manager AI decisions)
all_position_ratings = []
pos_list = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']
for x in range(40):
player_pos = pos_list[(x % 10)]
# Add defensive rating for each player's primary position
all_position_ratings.append(PositionRating(
player_id=x+1,
variant=0,
position=player_pos,
innings=100,
range=5,
error=1,
arm=7 if player_pos == 'C' else None, # Catchers need arm rating
pb=2 if player_pos == 'C' else None, # Catchers need passed ball rating
overthrow=1 if player_pos == 'C' else None
))
# Add position ratings for special players
all_position_ratings.append(PositionRating(
player_id=41,
variant=0,
position='DH',
innings=100,
range=5,
error=1
))
all_position_ratings.append(PositionRating(
player_id=69,
variant=0,
position='DH',
innings=100,
range=5,
error=1
))
all_position_ratings.append(PositionRating(
player_id=70,
variant=0,
position='SP',
innings=100,
range=5,
error=1
))
# Also add DH rating for player 70 (two-way player)
all_position_ratings.append(PositionRating(
player_id=70,
variant=0,
position='DH',
innings=50,
range=4,
error=2
))
for pos_rating in all_position_ratings:
session.add(pos_rating)
session.commit()
all_lineups = []
player_count = 1
lineup_id = 1
for team in [team_1, team_2]:
for (order, pos) in [(1, 'C'), (2, '1B'), (3, '2B'), (4, '3B'), (5, 'SS'), (6, 'LF'), (7, 'CF'), (8, 'RF'), (9, 'DH'), (10, 'P')]:
all_lineups.append(Lineup(
id=lineup_id,
position=pos,
batting_order=order,
game=game_1,
team=team,
game=game_1,
team=team,
player_id=player_count,
card_id=player_count
))
player_count += 1
for team in [team_3, team_4]:
lineup_id += 1
for team in [team_3, team_4]:
for (order, pos) in [(1, 'C'), (2, '1B'), (3, '2B'), (4, '3B'), (5, 'SS'), (6, 'LF'), (7, 'CF'), (8, 'RF'), (9, 'DH'), (10, 'P')]:
all_lineups.append(Lineup(
id=lineup_id,
position=pos,
batting_order=order,
game=game_3,
team=team,
game=game_3,
team=team,
player_id=player_count,
card_id=player_count
))
player_count += 1
for lineup in all_lineups:
lineup_id += 1
for lineup in all_lineups:
session.add(lineup)
game_1_play_1 = Play(
game=game_1,
play_num=1,
batter=all_lineups[0],
batter_pos=all_lineups[0].position,
pitcher=all_lineups[19],
catcher=all_lineups[10],
pa=1,
so=1,
outs=1,
complete=True
)
game_1_play_2 = Play(
game=game_1,
play_num=2,
batter=all_lineups[1],
batter_pos=all_lineups[1].position,
pitcher=all_lineups[19],
catcher=all_lineups[10],
starting_outs=1
)
session.add(game_1_play_1)
session.add(game_1_play_2)
session.commit()
game_1_play_1 = Play(
game=game_1,
play_num=1,
batter=all_lineups[0],
batter_pos=all_lineups[0].position,
pitcher=all_lineups[19],
catcher=all_lineups[10],
pa=1,
so=1,
outs=1,
complete=True
)
game_1_play_2 = Play(
game=game_1,
play_num=2,
batter=all_lineups[1],
batter_pos=all_lineups[1].position,
pitcher=all_lineups[19],
catcher=all_lineups[10],
starting_outs=1
)
g1_t1_cards = session.exec(select(Card).where(Card.team_id == 31)).all()
g1_t2_cards = session.exec(select(Card).where(Card.team_id == 400)).all()
g2_t1_cards = session.exec(select(Card).where(Card.team_id == 69)).all()
g2_t2_cards = session.exec(select(Card).where(Card.team_id == 420)).all()
session.add(game_1_play_1)
session.add(game_1_play_2)
for card in [*g1_t1_cards, *g1_t2_cards]:
session.commit()
g1_t1_cards = session.exec(select(Card).where(Card.team_id == 31)).all()
g1_t2_cards = session.exec(select(Card).where(Card.team_id == 400)).all()
g2_t1_cards = session.exec(select(Card).where(Card.team_id == 69)).all()
g2_t2_cards = session.exec(select(Card).where(Card.team_id == 420)).all()
for card in [*g1_t1_cards, *g1_t2_cards]:
session.add(RosterLink(
game_id=1,
card_id=card.id,
team_id=card.team_id
))
for card in [*g2_t1_cards, *g2_t2_cards]:
for card in [*g2_t1_cards, *g2_t2_cards]:
session.add(RosterLink(
game_id=3,
card_id=card.id,
team_id=card.team_id
))
# session.add(RosterLink(
# game_id=3,
# card_id=12,
# team_id=420
# ))
# session.add(RosterLink(
# game_id=3,
# card_id=12,
# team_id=420
# ))
session.commit()
all_ai = ManagerAi.create_ai(session)
session.commit()
yield session
# Create ManagerAi objects with explicit IDs for test compatibility
manager_ai_1 = ManagerAi(id=1, name='Balanced')
manager_ai_2 = ManagerAi(id=2, name='Yolo', steal=10, running=10, hold=5, catcher_throw=10, uncapped_home=10, uncapped_third=10, uncapped_trail=10, bullpen_matchup=3, behind_aggression=10, ahead_aggression=10, decide_throw=10)
manager_ai_3 = ManagerAi(id=3, name='Safe', steal=3, running=3, hold=8, catcher_throw=5, uncapped_home=5, uncapped_third=3, uncapped_trail=5, bullpen_matchup=8, behind_aggression=5, ahead_aggression=1, decide_throw=1)
session.add(manager_ai_1)
session.add(manager_ai_2)
session.add(manager_ai_3)
session.commit()
yield session
@pytest.fixture
def sample_ratings_query():
"""Factory method for sample ratings query data used in batter scouting tests."""
return {
"count": 2,
"ratings": [
{
"id": 7673,
"battingcard": {
"id": 3837,
"player": {
"player_id": 395,
"p_name": "Cedric Mullins",
"cost": 256,
"image": "https://pd.manticorum.com/api/v2/players/395/battingcard?d=2023-11-19",
"image2": None,
"mlbclub": "Baltimore Orioles",
"franchise": "Baltimore Orioles",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": None,
"for_purchase": True,
"total_cards": 791,
"in_packs": True,
"ranked_legal": False
},
"set_num": 395,
"rarity": {
"id": 2,
"value": 3,
"name": "All-Star",
"color": "FFD700"
},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"pos_8": None,
"headshot": "https://www.baseball-reference.com/req/202206291/images/headshots/2/24bf4355_mlbam.jpg",
"vanity_card": None,
"strat_code": "17929",
"bbref_id": "mullice01",
"fangr_id": None,
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 396,
"first_name": "Cedric",
"last_name": "Mullins",
"key_fangraphs": 17929,
"key_bbref": "mullice01",
"key_retro": "mullc002",
"key_mlbam": 656775,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 9,
"steal_high": 12,
"steal_auto": True,
"steal_jump": 0.2222222222222222,
"bunting": "C",
"hit_and_run": "B",
"running": 13,
"offense_col": 1,
"hand": "L"
},
"vs_hand": "L",
"pull_rate": 0.43888889,
"center_rate": 0.32777778,
"slap_rate": 0.23333333,
"homerun": 2.25,
"bp_homerun": 5.0,
"triple": 0.0,
"double_three": 0.0,
"double_two": 2.4,
"double_pull": 7.2,
"single_two": 4.6,
"single_one": 3.5,
"single_center": 5.85,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 9.0,
"strikeout": 23.0,
"lineout": 3.0,
"popout": 6.0,
"flyout_a": 0.0,
"flyout_bq": 0.15,
"flyout_lf_b": 2.8,
"flyout_rf_b": 3.75,
"groundout_a": 0.0,
"groundout_b": 9.0,
"groundout_c": 13.5,
"avg": 0.2851851851851852,
"obp": 0.387037037037037,
"slg": 0.5060185185185185
},
{
"id": 7674,
"battingcard": {
"id": 3837,
"player": {
"player_id": 395,
"p_name": "Cedric Mullins",
"cost": 256,
"image": "https://pd.manticorum.com/api/v2/players/395/battingcard?d=2023-11-19",
"image2": None,
"mlbclub": "Baltimore Orioles",
"franchise": "Baltimore Orioles",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": None,
"for_purchase": True,
"total_cards": 791,
"in_packs": True,
"ranked_legal": False
},
"set_num": 395,
"rarity": {
"id": 2,
"value": 3,
"name": "All-Star",
"color": "FFD700"
},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"pos_8": None,
"headshot": "https://www.baseball-reference.com/req/202206291/images/headshots/2/24bf4355_mlbam.jpg",
"vanity_card": None,
"strat_code": "17929",
"bbref_id": "mullice01",
"fangr_id": None,
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 396,
"first_name": "Cedric",
"last_name": "Mullins",
"key_fangraphs": 17929,
"key_bbref": "mullice01",
"key_retro": "mullc002",
"key_mlbam": 656775,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 9,
"steal_high": 12,
"steal_auto": True,
"steal_jump": 0.2222222222222222,
"bunting": "C",
"hit_and_run": "B",
"running": 13,
"offense_col": 1,
"hand": "L"
},
"vs_hand": "R",
"pull_rate": 0.43377483,
"center_rate": 0.32119205,
"slap_rate": 0.24503311,
"homerun": 2.0,
"bp_homerun": 7.0,
"triple": 0.0,
"double_three": 0.0,
"double_two": 2.7,
"double_pull": 7.95,
"single_two": 3.3,
"single_one": 0.0,
"single_center": 8.6,
"bp_single": 5.0,
"hbp": 1.0,
"walk": 12.9,
"strikeout": 11.1,
"lineout": 8.0,
"popout": 11.0,
"flyout_a": 0.0,
"flyout_bq": 0.4,
"flyout_lf_b": 4.05,
"flyout_rf_b": 6.0,
"groundout_a": 0.0,
"groundout_b": 3.0,
"groundout_c": 14.0,
"avg": 0.2828703703703704,
"obp": 0.4115740740740741,
"slg": 0.5342592592592592
}
]
}
def create_incomplete_team(session: Session):
"""Factory method for creating an incomplete team for constraint testing."""
return Team(
id=446,
abbrev='CLS',
sname='Macho Men',
lname='Columbus Macho Men',
gmid=181818,
gmname='Mason Socc',
gsheet='asdf1234',
wallet=6969,
team_value=69420,
collection_value=169420,
color='https://i.postimg.cc/8kLZCYXh/S10CLS.png',
season=7,
event=False,
career=0
)
def create_sample_play_for_scorebug(session: Session):
"""Factory method for creating a play object for scorebug testing."""
return Play(
game_id=3,
play_num=69,
batter=session.get(Lineup, 1),
batter_pos='DH',
pitcher=session.get(Lineup, 20),
catcher=session.get(Lineup, 11),
starting_outs=1,
inning_num=6
)
def get_next_play_id(session: Session):
"""Get the next available Play ID for SQLite tests."""
from sqlmodel import func
max_id = session.exec(select(func.max(Play.id))).one()
return (max_id or 0) + 1

View File

@ -3,208 +3,10 @@ from sqlmodel import Session, select, func
from in_game.gameplay_models import Card
from in_game.gameplay_queries import get_batter_scouting_or_none, get_card_or_none, get_pitcher_scouting_or_none
from tests.factory import session_fixture
sample_ratings_query = {
"count": 2,
"ratings": [
{
"id": 7673,
"battingcard": {
"id": 3837,
"player": {
"player_id": 395,
"p_name": "Cedric Mullins",
"cost": 256,
"image": "https://pd.manticorum.com/api/v2/players/395/battingcard?d=2023-11-19",
"image2": None,
"mlbclub": "Baltimore Orioles",
"franchise": "Baltimore Orioles",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": None,
"for_purchase": True,
"total_cards": 791,
"in_packs": True,
"ranked_legal": False
},
"set_num": 395,
"rarity": {
"id": 2,
"value": 3,
"name": "All-Star",
"color": "FFD700"
},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"pos_8": None,
"headshot": "https://www.baseball-reference.com/req/202206291/images/headshots/2/24bf4355_mlbam.jpg",
"vanity_card": None,
"strat_code": "17929",
"bbref_id": "mullice01",
"fangr_id": None,
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 396,
"first_name": "Cedric",
"last_name": "Mullins",
"key_fangraphs": 17929,
"key_bbref": "mullice01",
"key_retro": "mullc002",
"key_mlbam": 656775,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 9,
"steal_high": 12,
"steal_auto": True,
"steal_jump": 0.2222222222222222,
"bunting": "C",
"hit_and_run": "B",
"running": 13,
"offense_col": 1,
"hand": "L"
},
"vs_hand": "L",
"pull_rate": 0.43888889,
"center_rate": 0.32777778,
"slap_rate": 0.23333333,
"homerun": 2.25,
"bp_homerun": 5.0,
"triple": 0.0,
"double_three": 0.0,
"double_two": 2.4,
"double_pull": 7.2,
"single_two": 4.6,
"single_one": 3.5,
"single_center": 5.85,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 9.0,
"strikeout": 23.0,
"lineout": 3.0,
"popout": 6.0,
"flyout_a": 0.0,
"flyout_bq": 0.15,
"flyout_lf_b": 2.8,
"flyout_rf_b": 3.75,
"groundout_a": 0.0,
"groundout_b": 9.0,
"groundout_c": 13.5,
"avg": 0.2851851851851852,
"obp": 0.387037037037037,
"slg": 0.5060185185185185
},
{
"id": 7674,
"battingcard": {
"id": 3837,
"player": {
"player_id": 395,
"p_name": "Cedric Mullins",
"cost": 256,
"image": "https://pd.manticorum.com/api/v2/players/395/battingcard?d=2023-11-19",
"image2": None,
"mlbclub": "Baltimore Orioles",
"franchise": "Baltimore Orioles",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": None,
"for_purchase": True,
"total_cards": 791,
"in_packs": True,
"ranked_legal": False
},
"set_num": 395,
"rarity": {
"id": 2,
"value": 3,
"name": "All-Star",
"color": "FFD700"
},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"pos_8": None,
"headshot": "https://www.baseball-reference.com/req/202206291/images/headshots/2/24bf4355_mlbam.jpg",
"vanity_card": None,
"strat_code": "17929",
"bbref_id": "mullice01",
"fangr_id": None,
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 396,
"first_name": "Cedric",
"last_name": "Mullins",
"key_fangraphs": 17929,
"key_bbref": "mullice01",
"key_retro": "mullc002",
"key_mlbam": 656775,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 9,
"steal_high": 12,
"steal_auto": True,
"steal_jump": 0.2222222222222222,
"bunting": "C",
"hit_and_run": "B",
"running": 13,
"offense_col": 1,
"hand": "L"
},
"vs_hand": "R",
"pull_rate": 0.43377483,
"center_rate": 0.32119205,
"slap_rate": 0.24503311,
"homerun": 2.0,
"bp_homerun": 7.0,
"triple": 0.0,
"double_three": 0.0,
"double_two": 2.7,
"double_pull": 7.95,
"single_two": 3.3,
"single_one": 0.0,
"single_center": 8.6,
"bp_single": 5.0,
"hbp": 1.0,
"walk": 12.9,
"strikeout": 11.1,
"lineout": 8.0,
"popout": 11.0,
"flyout_a": 0.0,
"flyout_bq": 0.4,
"flyout_lf_b": 4.05,
"flyout_rf_b": 6.0,
"groundout_a": 0.0,
"groundout_b": 3.0,
"groundout_c": 14.0,
"avg": 0.2828703703703704,
"obp": 0.4115740740740741,
"slg": 0.5342592592592592
}
]
}
from tests.factory import session_fixture, sample_ratings_query
# async def test_create_scouting(session: Session):
# async def test_create_scouting(session: Session, sample_ratings_query):
# this_card = await get_card_or_none(session, card_id=1405)
# assert this_card.player.id == 395

View File

@ -9,7 +9,7 @@ from tests.factory import session_fixture
def test_create_card(session: Session):
all_cards = session.exec(select(Card)).all()
assert len(all_cards) == 41
assert len(all_cards) == 42 # Updated count after adding Player 41
card_1 = session.get(Card, 1)
card_2 = session.get(Card, 12)
@ -39,7 +39,7 @@ async def test_get_or_create_ai_card(session: Session):
dev_mode=True
)
assert new_card_2.id == 42
assert new_card_2.id == 71 # Updated to match next available ID after factory cards (1-70)
# async def test_get_card_or_none(session: Session):

View File

@ -4,7 +4,7 @@ from sqlalchemy.sql.functions import sum, count
from sqlmodel import Session, delete, func
from command_logic.logic_gameplay import complete_play, get_scorebug_embed, homeruns, is_game_over, singles, strikeouts, undo_play
from in_game.gameplay_models import Game, Lineup, GameCardsetLink, Play, Team, select
from in_game.gameplay_models import Game, Lineup, GameCardsetLink, Play, Team, RosterLink, select
from in_game.gameplay_queries import get_channel_game_or_none, get_active_games_by_team, get_db_ready_decisions
from tests.factory import session_fixture
@ -43,7 +43,13 @@ def test_select_all(session: Session):
games = session.exec(select(Game)).all()
assert len(games) == 3
session.execute(sadelete(Game))
# Delete dependent tables first to avoid foreign key constraint violations
# Order matters: delete child tables before parent tables
session.execute(sadelete(Play)) # References Game
session.execute(sadelete(Lineup)) # References Game
session.execute(sadelete(RosterLink)) # References Game
session.execute(sadelete(GameCardsetLink)) # References Game
session.execute(sadelete(Game)) # Finally delete Game
session.commit()
games = session.exec(select(Game)).all()

View File

@ -27,12 +27,12 @@ def test_check_jump(session: Session):
this_play.on_first = runner
assert this_play.starting_outs == 1
assert balanced_ai.check_jump(session, this_game, to_base=2) == JumpResponse(min_safe=16)
assert aggressive_ai.check_jump(session, this_game, to_base=2) == JumpResponse(min_safe=13, run_if_auto_jump=True)
assert balanced_ai.check_jump(session, this_game, to_base=2) == JumpResponse(ai_note='- SEND **Player 4** to second if they get the jump', min_safe=16)
assert aggressive_ai.check_jump(session, this_game, to_base=2) == JumpResponse(ai_note='- SEND **Player 4** to second if they get the jump', min_safe=13, run_if_auto_jump=True)
this_play.on_third = runner
assert balanced_ai.check_jump(session, this_game, to_base=4) == JumpResponse(min_safe=None)
assert balanced_ai.check_jump(session, this_game, to_base=4) == JumpResponse(min_safe=8)
assert aggressive_ai.check_jump(session, this_game, to_base=4) == JumpResponse(min_safe=5)

View File

@ -5,7 +5,7 @@ from command_logic.logic_gameplay import complete_play, homeruns, is_game_over,
from db_calls_gameplay import advance_runners
from in_game.gameplay_models import Lineup, Play, Game
from in_game.gameplay_queries import get_db_ready_plays, get_last_team_play
from tests.factory import session_fixture
from tests.factory import session_fixture, create_sample_play_for_scorebug
def test_create_play(session: Session):
@ -28,16 +28,7 @@ def test_get_current_play(session: Session):
def test_scorebug_ascii(session: Session):
new_play = Play(
game_id=3,
play_num=69,
batter=session.get(Lineup, 1),
batter_pos='DH',
pitcher=session.get(Lineup, 20),
catcher=session.get(Lineup, 11),
starting_outs=1,
inning_num=6
)
new_play = create_sample_play_for_scorebug(session)
session.add(new_play)
session.commit()

View File

@ -10,7 +10,7 @@ from tests.factory import session_fixture
def test_create_player(session: Session):
all_players = session.exec(select(Player)).all()
assert len(all_players) == 42
assert len(all_players) == 43 # Updated count after adding Player 41
player_1 = session.get(Player, 1)
player_2 = session.get(Player, 12)

View File

@ -22,11 +22,11 @@ def test_get_available_subs(session: Session):
home_team = this_game.home_team
home_roster = session.exec(select(RosterLink).where(RosterLink.game == this_game, RosterLink.team == home_team)).all()
assert len(home_roster) == 11
assert len(home_roster) == 12 # Updated count after adding more cards to factory
cards = get_available_subs(session, this_game, this_game.home_team)
assert len(cards) == 1
assert len(cards) == 2 # Updated count: cards 41 and 70 are available subs

View File

@ -3,7 +3,7 @@ from sqlmodel import Session, select
from in_game.gameplay_models import Team, CACHE_LIMIT
from in_game.gameplay_queries import get_team_or_none
from tests.factory import session_fixture, pytest
from tests.factory import session_fixture, create_incomplete_team, pytest
def test_create_team(session: Session):
team_31 = session.get(Team, 31)
@ -20,16 +20,17 @@ def test_create_team(session: Session):
def test_create_incomplete_team(session: Session):
team_1 = Team(
id=446, abbrev='CLS', sname='Macho Men', lname='Columbus Macho Men', gmid=181818, gmname='Mason Socc', gsheet='asdf1234', wallet=6969, team_value=69420, collection_value=169420, color='https://i.postimg.cc/8kLZCYXh/S10CLS.png', season=7, event=False, career=0
)
team_1 = create_incomplete_team(session)
session.add(team_1)
with pytest.raises(Exception) as exc_info:
from sqlalchemy.exc import IntegrityError
with pytest.raises(IntegrityError) as exc_info:
session.commit()
assert str(exc_info) == "<ExceptionInfo IntegrityError('(sqlite3.IntegrityError) NOT NULL constraint failed: team.ranking') tblen=24>"
# Check that it's a NOT NULL constraint violation for ranking field (database agnostic)
assert "ranking" in str(exc_info.value)
assert "null" in str(exc_info.value).lower()
async def test_team_cache(session: Session):

View File

@ -0,0 +1,175 @@
import pytest
from sqlmodel import Session
from in_game.gameplay_queries import (
get_batting_team, get_games_by_channel, get_channel_game_or_none,
get_active_games_by_team, get_player_id_from_dict, get_player_name_from_dict,
get_game_lineups
)
from in_game.gameplay_models import Game, Team, Play
from tests.factory import session_fixture
class TestUtilityFunctions:
"""Test simple utility functions in gameplay_queries."""
def test_get_player_id_from_dict_with_player_id(self):
"""Test extracting player_id from dict with player_id key."""
json_data = {'player_id': 123, 'name': 'Test Player'}
result = get_player_id_from_dict(json_data)
assert result == 123
def test_get_player_id_from_dict_with_id(self):
"""Test extracting player_id from dict with id key."""
json_data = {'id': 456, 'name': 'Test Player'}
result = get_player_id_from_dict(json_data)
assert result == 456
def test_get_player_id_from_dict_missing_keys(self):
"""Test error when both player_id and id keys are missing."""
json_data = {'name': 'Test Player', 'team': 'Test Team'}
with pytest.raises(KeyError) as exc_info:
get_player_id_from_dict(json_data)
assert 'Player ID could not be extracted from json data' in str(exc_info.value)
def test_get_player_name_from_dict_with_p_name(self):
"""Test extracting player name from dict with p_name key."""
json_data = {'p_name': 'Test Player', 'id': 123}
result = get_player_name_from_dict(json_data)
assert result == 'Test Player'
def test_get_player_name_from_dict_with_name(self):
"""Test extracting player name from dict with name key."""
json_data = {'name': 'Another Player', 'id': 456}
result = get_player_name_from_dict(json_data)
assert result == 'Another Player'
def test_get_player_name_from_dict_missing_keys(self):
"""Test error when both p_name and name keys are missing."""
json_data = {'id': 123, 'team': 'Test Team'}
with pytest.raises(KeyError) as exc_info:
get_player_name_from_dict(json_data)
assert 'Player name could not be extracted from json data' in str(exc_info.value)
class TestGameQueries:
"""Test game-related query functions."""
def test_get_batting_team_top_inning(self, session: Session):
"""Test getting batting team in top half of inning (away team bats)."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Top half = away team bats
play_1.inning_half = 'top'
batting_team = get_batting_team(session, play_1)
assert batting_team == this_game.away_team
assert batting_team.id == this_game.away_team_id
def test_get_batting_team_bottom_inning(self, session: Session):
"""Test getting batting team in bottom half of inning (home team bats)."""
this_game = session.get(Game, 3)
play_1 = this_game.initialize_play(session)
# Bottom half = home team bats
play_1.inning_half = 'bottom'
batting_team = get_batting_team(session, play_1)
assert batting_team == this_game.home_team
assert batting_team.id == this_game.home_team_id
def test_get_games_by_channel(self, session: Session):
"""Test getting games by channel ID."""
# Game 3 uses channel 5678 and is active
games = get_games_by_channel(session, channel_id=5678)
assert len(games) == 1
assert games[0].id == 3
assert games[0].channel_id == 5678
assert games[0].active is True
def test_get_games_by_channel_no_games(self, session: Session):
"""Test getting games by channel ID when no games exist."""
games = get_games_by_channel(session, channel_id=99999)
assert len(games) == 0
assert games == []
def test_get_channel_game_or_none_single_game(self, session: Session):
"""Test getting single game from channel."""
# Channel 5678 has exactly one active game
game = get_channel_game_or_none(session, channel_id=5678)
assert game is not None
assert game.id == 3
assert game.channel_id == 5678
def test_get_channel_game_or_none_no_games(self, session: Session):
"""Test getting game from channel with no games."""
game = get_channel_game_or_none(session, channel_id=99999)
assert game is None
def test_get_active_games_by_team(self, session: Session):
"""Test getting active games for a specific team."""
# Team 420 is in game 3 (active) and game 2 (inactive)
team_420 = session.get(Team, 420)
games = get_active_games_by_team(session, team_420)
# Should only return active games
assert len(games) == 1
assert games[0].id == 3
assert games[0].active is True
# Verify team is in the game
assert games[0].home_team_id == 420 or games[0].away_team_id == 420
def test_get_active_games_by_team_no_active_games(self, session: Session):
"""Test getting active games for team with no active games."""
# Team 31 is only in game 1 which is active, but let's test with team not in any active games
team_3 = session.get(Team, 3)
games = get_active_games_by_team(session, team_3)
# Team 3 (BAL) is not in any active games in our factory
assert len(games) == 0
def test_get_game_lineups_all_lineups(self, session: Session):
"""Test getting all lineups for a game."""
this_game = session.get(Game, 3)
lineups = get_game_lineups(session, this_game)
# Game 3 should have lineups from both teams
assert len(lineups) > 0
# All lineups should belong to this game
for lineup in lineups:
assert lineup.game_id == this_game.id
def test_get_game_lineups_specific_team(self, session: Session):
"""Test getting lineups for a specific team in a game."""
this_game = session.get(Game, 3)
team_420 = session.get(Team, 420)
lineups = get_game_lineups(session, this_game, specific_team=team_420)
# All lineups should belong to the specified team
for lineup in lineups:
assert lineup.game_id == this_game.id
assert lineup.team_id == team_420.id
def test_get_game_lineups_active_only(self, session: Session):
"""Test getting only active lineups for a game."""
this_game = session.get(Game, 3)
lineups = get_game_lineups(session, this_game, is_active=True)
# All lineups should be active
for lineup in lineups:
assert lineup.game_id == this_game.id
# Note: The factory doesn't set active=False for any lineups,
# but this tests the query logic

View File

@ -0,0 +1,261 @@
import pytest
from in_game.managerai_responses import (
AiResponse, RunResponse, JumpResponse, TagResponse,
UncappedRunResponse, ThrowResponse, DefenseResponse
)
class TestAiResponse:
"""Test the base AiResponse class."""
def test_ai_response_creation(self):
"""Test creating AiResponse with default values."""
response = AiResponse()
assert response.ai_note == ''
def test_ai_response_with_note(self):
"""Test creating AiResponse with custom note."""
response = AiResponse(ai_note='Test AI note')
assert response.ai_note == 'Test AI note'
class TestRunResponse:
"""Test the RunResponse class."""
def test_run_response_defaults(self):
"""Test RunResponse with default values."""
response = RunResponse()
assert response.ai_note == ''
assert response.min_safe is None
def test_run_response_with_values(self):
"""Test RunResponse with custom values."""
response = RunResponse(ai_note='Send the runner', min_safe=15)
assert response.ai_note == 'Send the runner'
assert response.min_safe == 15
class TestJumpResponse:
"""Test the JumpResponse class."""
def test_jump_response_defaults(self):
"""Test JumpResponse with default values."""
response = JumpResponse()
assert response.ai_note == ''
assert response.min_safe is None
assert response.must_auto_jump is False
assert response.run_if_auto_jump is False
def test_jump_response_with_values(self):
"""Test JumpResponse with custom values."""
response = JumpResponse(
ai_note='- SEND **Player** to second if they get the jump',
min_safe=16,
must_auto_jump=True,
run_if_auto_jump=True
)
assert response.ai_note == '- SEND **Player** to second if they get the jump'
assert response.min_safe == 16
assert response.must_auto_jump is True
assert response.run_if_auto_jump is True
class TestTagResponse:
"""Test the TagResponse class."""
def test_tag_response_defaults(self):
"""Test TagResponse with default values."""
response = TagResponse()
assert response.ai_note == ''
assert response.min_safe is None
def test_tag_response_with_values(self):
"""Test TagResponse with custom values."""
response = TagResponse(ai_note='Tag from second', min_safe=8)
assert response.ai_note == 'Tag from second'
assert response.min_safe == 8
class TestUncappedRunResponse:
"""Test the UncappedRunResponse class."""
def test_uncapped_run_response_defaults(self):
"""Test UncappedRunResponse with default values."""
response = UncappedRunResponse()
assert response.ai_note == ''
assert response.min_safe is None
assert response.send_trail is False
assert response.trail_min_safe == 10
assert response.trail_min_safe_delta == 0
def test_uncapped_run_response_with_values(self):
"""Test UncappedRunResponse with custom values."""
response = UncappedRunResponse(
ai_note='Uncapped advance situation',
min_safe=12,
send_trail=True,
trail_min_safe=8,
trail_min_safe_delta=2
)
assert response.ai_note == 'Uncapped advance situation'
assert response.min_safe == 12
assert response.send_trail is True
assert response.trail_min_safe == 8
assert response.trail_min_safe_delta == 2
class TestThrowResponse:
"""Test the ThrowResponse class."""
def test_throw_response_defaults(self):
"""Test ThrowResponse with default values."""
response = ThrowResponse()
assert response.ai_note == ''
assert response.cutoff is False
assert response.at_lead_runner is True
assert response.at_trail_runner is False
assert response.trail_max_safe == 10
assert response.trail_max_safe_delta == -6
def test_throw_response_with_values(self):
"""Test ThrowResponse with custom values."""
response = ThrowResponse(
ai_note='Throw decision',
cutoff=True,
at_lead_runner=False,
at_trail_runner=True,
trail_max_safe=8,
trail_max_safe_delta=-4
)
assert response.ai_note == 'Throw decision'
assert response.cutoff is True
assert response.at_lead_runner is False
assert response.at_trail_runner is True
assert response.trail_max_safe == 8
assert response.trail_max_safe_delta == -4
class TestDefenseResponse:
"""Test the DefenseResponse class and its defender_in method."""
def test_defense_response_defaults(self):
"""Test DefenseResponse with default values."""
response = DefenseResponse()
assert response.ai_note == ''
assert response.hold_first is False
assert response.hold_second is False
assert response.hold_third is False
assert response.outfield_in is False
assert response.infield_in is False
assert response.corners_in is False
def test_defense_response_with_values(self):
"""Test DefenseResponse with custom values."""
response = DefenseResponse(
ai_note='Defensive positioning',
hold_first=True,
hold_second=False,
hold_third=True,
outfield_in=True,
infield_in=False,
corners_in=True
)
assert response.ai_note == 'Defensive positioning'
assert response.hold_first is True
assert response.hold_second is False
assert response.hold_third is True
assert response.outfield_in is True
assert response.infield_in is False
assert response.corners_in is True
def test_defender_in_infield_in(self):
"""Test defender_in method with infield_in=True."""
response = DefenseResponse(infield_in=True)
# Infield positions should return True
assert response.defender_in('C') is True
assert response.defender_in('1B') is True
assert response.defender_in('2B') is True
assert response.defender_in('3B') is True
assert response.defender_in('SS') is True
assert response.defender_in('P') is True
# Outfield positions should return False
assert response.defender_in('LF') is False
assert response.defender_in('CF') is False
assert response.defender_in('RF') is False
def test_defender_in_corners_in(self):
"""Test defender_in method with corners_in=True."""
response = DefenseResponse(corners_in=True)
# Corner infield positions should return True
assert response.defender_in('C') is True
assert response.defender_in('1B') is True
assert response.defender_in('3B') is True
assert response.defender_in('P') is True
# Non-corner positions should return False
assert response.defender_in('2B') is False
assert response.defender_in('SS') is False
assert response.defender_in('LF') is False
assert response.defender_in('CF') is False
assert response.defender_in('RF') is False
def test_defender_in_outfield_in(self):
"""Test defender_in method with outfield_in=True."""
response = DefenseResponse(outfield_in=True)
# Outfield positions should return True
assert response.defender_in('LF') is True
assert response.defender_in('CF') is True
assert response.defender_in('RF') is True
# Infield positions should return False
assert response.defender_in('C') is False
assert response.defender_in('1B') is False
assert response.defender_in('2B') is False
assert response.defender_in('3B') is False
assert response.defender_in('SS') is False
assert response.defender_in('P') is False
def test_defender_in_multiple_flags(self):
"""Test defender_in method with multiple flags set."""
response = DefenseResponse(infield_in=True, outfield_in=True)
# All positions should return True when both flags are set
positions = ['C', '1B', '2B', '3B', 'SS', 'P', 'LF', 'CF', 'RF']
for position in positions:
assert response.defender_in(position) is True
def test_defender_in_no_flags(self):
"""Test defender_in method with no flags set."""
response = DefenseResponse()
# All positions should return False when no flags are set
positions = ['C', '1B', '2B', '3B', 'SS', 'P', 'LF', 'CF', 'RF']
for position in positions:
assert response.defender_in(position) is False
def test_defender_in_priority_order(self):
"""Test that infield_in takes priority over corners_in for applicable positions."""
response = DefenseResponse(infield_in=True, corners_in=True)
# Corner positions should still return True (both flags apply)
assert response.defender_in('C') is True
assert response.defender_in('1B') is True
assert response.defender_in('3B') is True
assert response.defender_in('P') is True
# Non-corner infield positions should return True (infield_in applies)
assert response.defender_in('2B') is True
assert response.defender_in('SS') is True
def test_defender_in_unknown_position(self):
"""Test defender_in method with unknown position."""
response = DefenseResponse(infield_in=True, outfield_in=True, corners_in=True)
# Unknown positions should return False
assert response.defender_in('DH') is False
assert response.defender_in('UNKNOWN') is False
assert response.defender_in('') is False

View File

@ -0,0 +1,425 @@
import pytest
import random
from unittest.mock import Mock, patch, call
import discord
from in_game import simulations, data_cache
from tests.factory import session_fixture
@pytest.fixture
def mock_batting_wrapper():
"""Create a mock BattingWrapper with test data."""
batting_card = data_cache.BattingCard(
player_id=123,
variant=0,
steal_low=9,
steal_high=12,
steal_auto=True,
steal_jump=0.25,
bunting='B',
hit_and_run='A',
running=13,
offense_col=1,
hand='R'
)
ratings_vl = data_cache.BattingRatings(
homerun=5.0,
bp_homerun=7.0,
triple=2.0,
double_three=1.0,
double_two=8.0,
double_pull=6.0,
single_two=4.0,
single_one=3.0,
single_center=12.0,
bp_single=5.0,
hbp=2.0,
walk=10.0,
strikeout=15.0,
lineout=3.0,
popout=6.0,
flyout_a=1.0,
flyout_bq=0.5,
flyout_lf_b=4.0,
flyout_rf_b=8.0,
groundout_a=2.0,
groundout_b=20.0,
groundout_c=15.0,
avg=0.285,
obp=0.375,
slg=0.455,
pull_rate=0.42,
center_rate=0.33,
slap_rate=0.25
)
ratings_vr = data_cache.BattingRatings(
homerun=3.0,
bp_homerun=6.0,
triple=1.0,
double_three=0.5,
double_two=6.0,
double_pull=5.0,
single_two=3.5,
single_one=2.5,
single_center=10.0,
bp_single=4.0,
hbp=1.5,
walk=8.0,
strikeout=18.0,
lineout=4.0,
popout=7.0,
flyout_a=1.5,
flyout_bq=0.75,
flyout_lf_b=5.0,
flyout_rf_b=10.0,
groundout_a=2.5,
groundout_b=22.0,
groundout_c=16.0,
avg=0.265,
obp=0.345,
slg=0.425,
pull_rate=0.45,
center_rate=0.30,
slap_rate=0.25
)
return data_cache.BattingWrapper(
card=batting_card,
ratings_vl=ratings_vl,
ratings_vr=ratings_vr
)
@pytest.fixture
def mock_pitching_wrapper():
"""Create a mock PitchingWrapper with test data."""
pitching_card = data_cache.PitchingCard(
player_id=456,
variant=0,
balk=1,
wild_pitch=2,
hold=8,
starter_rating=7,
relief_rating=5,
batting='B',
offense_col=1,
hand='L',
closer_rating=6
)
ratings_vl = data_cache.PitchingRatings(
homerun=3.0,
bp_homerun=5.0,
triple=1.0,
double_three=0.5,
double_two=5.0,
double_cf=4.0,
single_two=3.0,
single_one=2.0,
single_center=8.0,
bp_single=4.0,
hbp=2.0,
walk=12.0,
strikeout=25.0,
flyout_lf_b=5.0,
flyout_cf_b=6.0,
flyout_rf_b=6.0,
groundout_a=3.0,
groundout_b=18.0,
xcheck_p=1.0,
xcheck_c=1.0,
xcheck_1b=1.0,
xcheck_2b=1.0,
xcheck_3b=1.0,
xcheck_ss=1.0,
xcheck_lf=1.0,
xcheck_cf=1.0,
xcheck_rf=1.0,
avg=0.245,
obp=0.335,
slg=0.385
)
ratings_vr = data_cache.PitchingRatings(
homerun=4.0,
bp_homerun=6.0,
triple=1.5,
double_three=0.75,
double_two=7.0,
double_cf=5.0,
single_two=4.0,
single_one=3.0,
single_center=10.0,
bp_single=5.0,
hbp=1.5,
walk=10.0,
strikeout=22.0,
flyout_lf_b=6.0,
flyout_cf_b=7.0,
flyout_rf_b=8.0,
groundout_a=4.0,
groundout_b=20.0,
xcheck_p=1.5,
xcheck_c=1.5,
xcheck_1b=1.5,
xcheck_2b=1.5,
xcheck_3b=1.5,
xcheck_ss=1.5,
xcheck_lf=1.5,
xcheck_cf=1.5,
xcheck_rf=1.5,
avg=0.255,
obp=0.345,
slg=0.405
)
return data_cache.PitchingWrapper(
card=pitching_card,
ratings_vl=ratings_vl,
ratings_vr=ratings_vr
)
@pytest.fixture
def mock_player_data():
"""Create mock player data for helper function calls."""
return {
'player_id': 123,
'p_name': 'Test Player',
'description': '2024',
'image': 'test_player_pitchingcard',
'image2': 'test_player_battingcard'
}
class TestGetResult:
"""Test the get_result function which simulates plate appearance outcomes."""
@patch('random.choice')
@patch('random.choices')
def test_get_result_pitcher_chosen_left_handed_batter(self, mock_choices, mock_choice, mock_pitching_wrapper, mock_batting_wrapper):
"""Test get_result when pitcher is chosen and batter is left-handed."""
# Make the batter left-handed
mock_batting_wrapper.card.hand = 'L'
mock_choice.return_value = 'pitcher'
mock_choices.return_value = ['strikeout']
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
mock_choice.assert_called_once_with(['pitcher', 'batter'])
assert result == 'strikeout'
# Verify the correct ratings were used (pitcher vs left-handed batter)
call_args = mock_choices.call_args
assert call_args is not None
# Should use pitcher's ratings_vl when facing left-handed batter
@patch('random.choice')
@patch('random.choices')
def test_get_result_pitcher_chosen_right_handed_batter(self, mock_choices, mock_choice, mock_pitching_wrapper, mock_batting_wrapper):
"""Test get_result when pitcher is chosen and batter is right-handed."""
# Batter is already right-handed in fixture
mock_choice.return_value = 'pitcher'
mock_choices.return_value = ['groundout_b']
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
mock_choice.assert_called_once_with(['pitcher', 'batter'])
assert result == 'groundout_b'
@patch('random.choice')
@patch('random.choices')
def test_get_result_batter_chosen_left_handed_pitcher(self, mock_choices, mock_choice, mock_pitching_wrapper, mock_batting_wrapper):
"""Test get_result when batter is chosen and pitcher is left-handed."""
# Pitcher is already left-handed in fixture
mock_choice.return_value = 'batter'
mock_choices.return_value = ['homerun']
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
mock_choice.assert_called_once_with(['pitcher', 'batter'])
assert result == 'homerun'
@patch('random.choice')
@patch('random.choices')
def test_get_result_batter_chosen_right_handed_pitcher(self, mock_choices, mock_choice, mock_pitching_wrapper, mock_batting_wrapper):
"""Test get_result when batter is chosen and pitcher is right-handed."""
# Make the pitcher right-handed
mock_pitching_wrapper.card.hand = 'R'
mock_choice.return_value = 'batter'
mock_choices.return_value = ['single_center']
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
mock_choice.assert_called_once_with(['pitcher', 'batter'])
assert result == 'single_center'
@patch('random.choice')
@patch('random.choices')
def test_get_result_unused_fields_filtered_out(self, mock_choices, mock_choice, mock_pitching_wrapper, mock_batting_wrapper):
"""Test that unused statistical fields are filtered out from the random selection."""
mock_choice.return_value = 'batter'
mock_choices.return_value = ['walk']
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
# Get the call arguments to verify unused fields were removed
call_args = mock_choices.call_args
results_list = call_args[0][0] # First positional argument (results)
probs_list = call_args[0][1] # Second positional argument (probabilities)
# Verify unused fields are not in the results
unused_fields = ['avg', 'obp', 'slg', 'pull_rate', 'center_rate', 'slap_rate']
for field in unused_fields:
assert field not in results_list
# Verify we have valid baseball outcomes
assert len(results_list) == len(probs_list)
assert all(isinstance(prob, (int, float)) for prob in probs_list)
assert result == 'walk'
class TestGetPosEmbeds:
"""Test the get_pos_embeds function which creates Discord embeds for matchups."""
@patch('in_game.simulations.get_result')
@patch('helpers.player_desc')
@patch('helpers.player_pcard')
@patch('helpers.player_bcard')
@patch('helpers.SBA_COLOR', 'a6ce39')
def test_get_pos_embeds_structure(self, mock_bcard, mock_pcard, mock_desc, mock_get_result,
mock_pitching_wrapper, mock_batting_wrapper, mock_player_data):
"""Test that get_pos_embeds returns the correct embed structure."""
# Setup mocks
mock_desc.side_effect = ['2024 Test Pitcher', '2024 Test Batter']
mock_pcard.return_value = 'pitcher_card_url'
mock_bcard.return_value = 'batter_card_url'
mock_get_result.return_value = 'strikeout'
pitcher_data = {'p_name': 'Test Pitcher', 'description': '2024'}
batter_data = {'p_name': 'Test Batter', 'description': '2024'}
embeds = simulations.get_pos_embeds(pitcher_data, batter_data, mock_pitching_wrapper, mock_batting_wrapper)
# Verify we get 3 embeds
assert len(embeds) == 3
assert all(isinstance(embed, discord.Embed) for embed in embeds)
# Check first embed (pitcher)
assert embeds[0].title == '2024 Test Pitcher vs. 2024 Test Batter'
assert embeds[0].image.url == 'pitcher_card_url'
assert embeds[0].color.value == int('a6ce39', 16)
# Check second embed (batter)
assert embeds[1].image.url == 'batter_card_url'
assert embeds[1].color.value == int('a6ce39', 16)
# Check third embed (result)
assert len(embeds[2].fields) == 3
assert embeds[2].fields[0].name == 'Pitcher'
assert embeds[2].fields[0].value == '2024 Test Pitcher'
assert embeds[2].fields[1].name == 'Batter'
assert embeds[2].fields[1].value == '2024 Test Batter'
assert embeds[2].fields[2].name == 'Result'
assert embeds[2].fields[2].value == 'strikeout'
assert embeds[2].fields[2].inline is False
# Verify helper functions were called correctly
mock_desc.assert_has_calls([call(pitcher_data), call(batter_data)])
mock_pcard.assert_called_once_with(pitcher_data)
mock_bcard.assert_called_once_with(batter_data)
mock_get_result.assert_called_once_with(mock_pitching_wrapper, mock_batting_wrapper)
@patch('in_game.simulations.get_result')
@patch('helpers.player_desc')
@patch('helpers.player_pcard')
@patch('helpers.player_bcard')
@patch('helpers.SBA_COLOR', 'a6ce39')
def test_get_pos_embeds_different_results(self, mock_bcard, mock_pcard, mock_desc, mock_get_result,
mock_pitching_wrapper, mock_batting_wrapper, mock_player_data):
"""Test get_pos_embeds with different simulation results."""
mock_desc.side_effect = ['Live Ace Pitcher', 'Live Power Hitter']
mock_pcard.return_value = 'ace_pitcher_card'
mock_bcard.return_value = 'power_hitter_card'
mock_get_result.return_value = 'homerun'
pitcher_data = {'p_name': 'Ace Pitcher', 'description': 'Live'}
batter_data = {'p_name': 'Power Hitter', 'description': 'Live'}
embeds = simulations.get_pos_embeds(pitcher_data, batter_data, mock_pitching_wrapper, mock_batting_wrapper)
assert len(embeds) == 3
assert embeds[0].title == 'Live Ace Pitcher vs. Live Power Hitter'
assert embeds[2].fields[2].value == 'homerun'
class TestIntegration:
"""Integration tests that test the functions together with minimal mocking."""
def test_get_result_randomness_distribution(self, mock_pitching_wrapper, mock_batting_wrapper):
"""Test that get_result produces a reasonable distribution of outcomes over many iterations."""
# Run many simulations to test randomness
results = []
for _ in range(1000):
result = simulations.get_result(mock_pitching_wrapper, mock_batting_wrapper)
results.append(result)
# Verify we got various outcomes (not just one result)
unique_results = set(results)
assert len(unique_results) > 1
# Verify all results are valid baseball outcomes
valid_outcomes = {
'homerun', 'triple', 'double_two', 'double_three', 'double_pull', 'double_cf',
'single_center', 'single_one', 'single_two', 'bp_single', 'bp_homerun',
'walk', 'hbp', 'strikeout', 'lineout', 'popout',
'flyout_a', 'flyout_bq', 'flyout_lf_b', 'flyout_cf_b', 'flyout_rf_b',
'groundout_a', 'groundout_b', 'groundout_c',
'xcheck_p', 'xcheck_c', 'xcheck_1b', 'xcheck_2b', 'xcheck_3b', 'xcheck_ss',
'xcheck_lf', 'xcheck_cf', 'xcheck_rf'
}
for result in unique_results:
assert result in valid_outcomes
@patch('helpers.SBA_COLOR', 'a6ce39')
def test_full_simulation_flow(self, mock_pitching_wrapper, mock_batting_wrapper):
"""Test the complete flow from player data to Discord embeds."""
pitcher_data = {
'p_name': 'Test Pitcher',
'description': '2024',
'image': 'test_pitcher_pitchingcard',
'image2': None
}
batter_data = {
'p_name': 'Test Batter',
'description': '2024',
'image': None,
'image2': 'test_batter_battingcard'
}
# This will call the actual helper functions, so we need valid player data
with patch('helpers.player_desc') as mock_desc, \
patch('helpers.player_pcard') as mock_pcard, \
patch('helpers.player_bcard') as mock_bcard:
mock_desc.side_effect = ['2024 Test Pitcher', '2024 Test Batter']
mock_pcard.return_value = 'test_pitcher_pitchingcard'
mock_bcard.return_value = 'test_batter_battingcard'
embeds = simulations.get_pos_embeds(pitcher_data, batter_data, mock_pitching_wrapper, mock_batting_wrapper)
# Verify the complete embed structure
assert len(embeds) == 3
assert embeds[0].title == '2024 Test Pitcher vs. 2024 Test Batter'
assert embeds[0].image.url == 'test_pitcher_pitchingcard'
assert embeds[1].image.url == 'test_batter_battingcard'
# The result should be one of many possible baseball outcomes
result_field = embeds[2].fields[2]
assert result_field.name == 'Result'
assert isinstance(result_field.value, str)
assert len(result_field.value) > 0

164
tests/test_api_calls.py Normal file
View File

@ -0,0 +1,164 @@
import pytest
import aiohttp
from unittest.mock import Mock, patch, AsyncMock
from exceptions import DatabaseError
import api_calls
class TestUtilityFunctions:
"""Test utility functions in api_calls."""
def test_param_char_with_params(self):
"""Test param_char returns & when other_params is truthy."""
assert api_calls.param_char(True) == '&'
assert api_calls.param_char(['param1']) == '&'
assert api_calls.param_char({'key': 'value'}) == '&'
assert api_calls.param_char('some_param') == '&'
def test_param_char_without_params(self):
"""Test param_char returns ? when other_params is falsy."""
assert api_calls.param_char(False) == '?'
assert api_calls.param_char(None) == '?'
assert api_calls.param_char([]) == '?'
assert api_calls.param_char({}) == '?'
assert api_calls.param_char('') == '?'
assert api_calls.param_char(0) == '?'
@patch('api_calls.DB_URL', 'https://test.example.com/api')
def test_get_req_url_basic(self):
"""Test basic URL generation without object_id or params."""
result = api_calls.get_req_url('teams')
expected = 'https://test.example.com/api/v2/teams'
assert result == expected
@patch('api_calls.DB_URL', 'https://test.example.com/api')
def test_get_req_url_with_version(self):
"""Test URL generation with custom API version."""
result = api_calls.get_req_url('teams', api_ver=1)
expected = 'https://test.example.com/api/v1/teams'
assert result == expected
@patch('api_calls.DB_URL', 'https://test.example.com/api')
def test_get_req_url_with_object_id(self):
"""Test URL generation with object_id."""
result = api_calls.get_req_url('teams', object_id=123)
expected = 'https://test.example.com/api/v2/teams/123'
assert result == expected
@patch('api_calls.DB_URL', 'https://test.example.com/api')
def test_get_req_url_with_params(self):
"""Test URL generation with parameters."""
params = [('season', '7'), ('active', 'true')]
result = api_calls.get_req_url('teams', params=params)
expected = 'https://test.example.com/api/v2/teams?season=7&active=true'
assert result == expected
@patch('api_calls.DB_URL', 'https://test.example.com/api')
def test_get_req_url_complete(self):
"""Test URL generation with all parameters."""
params = [('season', '7'), ('limit', '10')]
result = api_calls.get_req_url('games', api_ver=1, object_id=456, params=params)
expected = 'https://test.example.com/api/v1/games/456?season=7&limit=10'
assert result == expected
@patch('api_calls.logger')
def test_log_return_value_short_string(self, mock_logger):
"""Test logging short return values."""
api_calls.log_return_value('Short log message')
mock_logger.info.assert_called_once_with('\n\nreturn: Short log message')
@patch('api_calls.logger')
def test_log_return_value_long_string(self, mock_logger):
"""Test logging long return values that get chunked."""
long_string = 'A' * 5000 # 5000 character string
api_calls.log_return_value(long_string)
# Should have been called twice (first chunk + second chunk)
assert mock_logger.info.call_count == 2
# First call should include the "return:" prefix
assert '\n\nreturn: ' in mock_logger.info.call_args_list[0][0][0]
@patch('api_calls.logger')
def test_log_return_value_extremely_long_string(self, mock_logger):
"""Test logging extremely long return values that get snipped."""
extremely_long_string = 'B' * 400000 # 400k character string (exceeds 300k limit)
api_calls.log_return_value(extremely_long_string)
# Should warn about snipping
mock_logger.warning.assert_called_with('[ S N I P P E D ]')
def test_team_hash(self):
"""Test team hash generation."""
mock_team = {
'sname': 'TestTeam',
'gmid': 1234567
}
result = api_calls.team_hash(mock_team)
# Expected format: last char + gmid/6950123 + second-to-last char + gmid/42069123
expected = f'm{1234567 / 6950123:.0f}a{1234567 / 42069123:.0f}'
assert result == expected
# Note: Async database function tests are complex due to aiohttp mocking
# For now, focusing on utility functions which provide significant coverage improvement
class TestSpecificFunctions:
"""Test specific API wrapper functions."""
@pytest.mark.asyncio
@patch('api_calls.db_get')
async def test_get_team_by_abbrev_found(self, mock_db_get):
"""Test get_team_by_abbrev function when team is found."""
mock_db_get.return_value = {
'count': 1,
'teams': [{'id': 123, 'abbrev': 'TEST', 'name': 'Test Team'}]
}
result = await api_calls.get_team_by_abbrev('TEST')
assert result == {'id': 123, 'abbrev': 'TEST', 'name': 'Test Team'}
mock_db_get.assert_called_once_with('teams', params=[('abbrev', 'TEST')])
@pytest.mark.asyncio
@patch('api_calls.db_get')
async def test_get_team_by_abbrev_not_found(self, mock_db_get):
"""Test get_team_by_abbrev function when team is not found."""
mock_db_get.return_value = {
'count': 0,
'teams': []
}
result = await api_calls.get_team_by_abbrev('NONEXISTENT')
assert result is None
mock_db_get.assert_called_once_with('teams', params=[('abbrev', 'NONEXISTENT')])
@pytest.mark.asyncio
@patch('api_calls.db_post')
async def test_post_to_dex(self, mock_db_post):
"""Test post_to_dex function."""
mock_db_post.return_value = {'id': 456, 'posted': True}
mock_player = {'id': 123}
mock_team = {'id': 456}
result = await api_calls.post_to_dex(mock_player, mock_team)
assert result == {'id': 456, 'posted': True}
mock_db_post.assert_called_once_with('paperdex', payload={'player_id': 123, 'team_id': 456})
class TestEnvironmentConfiguration:
"""Test environment-based configuration."""
def test_db_url_exists(self):
"""Test that DB_URL is configured."""
assert api_calls.DB_URL is not None
assert 'manticorum.com' in api_calls.DB_URL
def test_auth_token_exists(self):
"""Test that AUTH_TOKEN is configured."""
assert api_calls.AUTH_TOKEN is not None
assert 'Authorization' in api_calls.AUTH_TOKEN

View File

@ -453,9 +453,12 @@ class SelectBatterSub(discord.ui.Select):
position = 'PR'
logger.info(f'Deactivating last_lineup')
last_lineup.active = False
self.session.add(last_lineup)
logger.info(f'Set {last_lineup.card.player.name_with_desc} as inactive')
try:
last_lineup.active = False
self.session.add(last_lineup)
logger.info(f'Set {last_lineup.card.player.name_with_desc} as inactive')
except Exception as e:
log_exception(e)
logger.info(f'new position: {position}')
if position not in ['DH', 'PR', 'PH']:
@ -489,6 +492,15 @@ class SelectBatterSub(discord.ui.Select):
logger.info(f'Setting new sub to current play batter')
this_play.batter = human_bat_lineup
this_play.batter_pos = position
elif this_play.on_first == last_lineup:
logger.info(f'Setting new sub to run at first')
this_play.on_first = human_bat_lineup
elif this_play.on_second == last_lineup:
logger.info(f'Setting new sub to run at second')
this_play.on_second = human_bat_lineup
elif this_play.on_third == last_lineup:
logger.info(f'Setting new sub to run at third')
this_play.on_third = human_bat_lineup
logger.info(f'Adding play to session: {this_play}')
self.session.add(this_play)

104
utils.py Normal file
View File

@ -0,0 +1,104 @@
"""
General Utilities
This module contains standalone utility functions with minimal dependencies,
including timestamp conversion, position abbreviations, and simple helpers.
"""
import datetime
import discord
def int_timestamp():
"""Convert current datetime to integer timestamp."""
return int(datetime.datetime.now().timestamp())
def get_pos_abbrev(field_pos: str) -> str:
"""Convert position name to standard abbreviation."""
if field_pos.lower() == 'catcher':
return 'C'
elif field_pos.lower() == 'first baseman':
return '1B'
elif field_pos.lower() == 'second baseman':
return '2B'
elif field_pos.lower() == 'third baseman':
return '3B'
elif field_pos.lower() == 'shortstop':
return 'SS'
elif field_pos.lower() == 'left fielder':
return 'LF'
elif field_pos.lower() == 'center fielder':
return 'CF'
elif field_pos.lower() == 'right fielder':
return 'RF'
else:
return 'P'
def position_name_to_abbrev(position_name):
"""Convert position name to abbreviation (alternate format)."""
if position_name == 'Catcher':
return 'C'
elif position_name == 'First Base':
return '1B'
elif position_name == 'Second Base':
return '2B'
elif position_name == 'Third Base':
return '3B'
elif position_name == 'Shortstop':
return 'SS'
elif position_name == 'Left Field':
return 'LF'
elif position_name == 'Center Field':
return 'CF'
elif position_name == 'Right Field':
return 'RF'
elif position_name == 'Pitcher':
return 'P'
else:
return position_name
def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool:
"""Check if a Discord user has a specific role."""
for x in user.roles:
if x.name == role_name:
return True
return False
def get_roster_sheet_legacy(team):
"""Get legacy roster sheet URL for a team."""
return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit'
def get_roster_sheet(team):
"""Get roster sheet URL for a team."""
return f'https://docs.google.com/spreadsheets/d/{team["gsheet"]}/edit'
def get_player_url(team, player) -> str:
"""Generate player URL for SBA or Baseball Reference."""
if team.get('league') == 'SBA':
return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}'
else:
return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml'
def owner_only(ctx) -> bool:
"""Check if user is the bot owner."""
# ID for discord User Cal
owners = [287463767924137994, 1087936030899347516]
if ctx.author.id in owners:
return True
return False
def get_cal_user(ctx):
"""Get the Cal user from context."""
for user in ctx.bot.get_all_members():
if user.id == 287463767924137994:
return user