From 3debfd6e82fd6df7621314cd2cea4939bdfa7b3d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 22 Jul 2025 09:22:19 -0500 Subject: [PATCH] Catchup commit Includes discord_ui refactor, testing overhaul, addition of --- .dockerignore | 6 +- .gitignore | 6 +- .vscode/settings.json | 5 +- api_calls.py | 13 +- cogs/admins.py | 23 +- constants.py | 346 ++++ discord_ui/__init__.py | 23 + discord_ui/confirmations.py | 188 ++ discord_ui/dropdowns.py | 52 + discord_ui/pagination.py | 49 + discord_ui/selectors.py | 488 +++++ discord_utils.py | 231 +++ exceptions.py | 2 +- gauntlets.py | 22 +- helpers.py | 1785 +---------------- in_game/gameplay_queries.py | 20 +- pytest.ini | 2 + random_content.py | 219 ++ search_utils.py | 104 + tests/command_logic/test_logic_gameplay.py | 227 ++- tests/command_logic/test_logic_groundballs.py | 2 +- tests/conftest.py | 19 + tests/factory.py | 775 +++++-- .../test_batterscouting_model.py | 202 +- tests/gameplay_models/test_card_model.py | 4 +- tests/gameplay_models/test_game_model.py | 10 +- tests/gameplay_models/test_managerai_model.py | 6 +- tests/gameplay_models/test_play_model.py | 13 +- tests/gameplay_models/test_player_model.py | 2 +- .../gameplay_models/test_rosterlinks_model.py | 4 +- tests/gameplay_models/test_team_model.py | 13 +- tests/in_game/test_gameplay_queries.py | 175 ++ tests/in_game/test_managerai_responses.py | 261 +++ tests/in_game/test_simulations.py | 425 ++++ tests/test_api_calls.py | 164 ++ utilities/dropdown.py | 18 +- utils.py | 104 + 37 files changed, 3809 insertions(+), 2199 deletions(-) create mode 100644 constants.py create mode 100644 discord_ui/__init__.py create mode 100644 discord_ui/confirmations.py create mode 100644 discord_ui/dropdowns.py create mode 100644 discord_ui/pagination.py create mode 100644 discord_ui/selectors.py create mode 100644 discord_utils.py create mode 100644 random_content.py create mode 100644 search_utils.py create mode 100644 tests/conftest.py create mode 100644 tests/in_game/test_gameplay_queries.py create mode 100644 tests/in_game/test_managerai_responses.py create mode 100644 tests/in_game/test_simulations.py create mode 100644 tests/test_api_calls.py create mode 100644 utils.py diff --git a/.dockerignore b/.dockerignore index 2d7d9c4..e974370 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,5 +32,9 @@ README.md **/venv **/tests **/storage +**/htmlcov *_legacy.py -pytest.ini \ No newline at end of file +pytest.ini +CLAUDE.md +**.db +**/.claude \ No newline at end of file diff --git a/.gitignore b/.gitignore index a66645d..c78cbe5 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,8 @@ dmypy.json .idea/ storage* *compose.yml - +CLAUDE** +**.db +**/htmlcov +.vscode/** +.claude/** \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e99ede..c40c239 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } } \ No newline at end of file diff --git a/api_calls.py b/api_calls.py index 5930de8..f12e044 100644 --- a/api_calls.py +++ b/api_calls.py @@ -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) diff --git a/cogs/admins.py b/cogs/admins.py index b90bcb4..1d1b438 100644 --- a/cogs/admins.py +++ b/cogs/admins.py @@ -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: diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..7d51817 --- /dev/null +++ b/constants.py @@ -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!' +] \ No newline at end of file diff --git a/discord_ui/__init__.py b/discord_ui/__init__.py new file mode 100644 index 0000000..6e40231 --- /dev/null +++ b/discord_ui/__init__.py @@ -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' +] \ No newline at end of file diff --git a/discord_ui/confirmations.py b/discord_ui/confirmations.py new file mode 100644 index 0000000..551e487 --- /dev/null +++ b/discord_ui/confirmations.py @@ -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() \ No newline at end of file diff --git a/discord_ui/dropdowns.py b/discord_ui/dropdowns.py new file mode 100644 index 0000000..fd202d7 --- /dev/null +++ b/discord_ui/dropdowns.py @@ -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) \ No newline at end of file diff --git a/discord_ui/pagination.py b/discord_ui/pagination.py new file mode 100644 index 0000000..c53a127 --- /dev/null +++ b/discord_ui/pagination.py @@ -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() \ No newline at end of file diff --git a/discord_ui/selectors.py b/discord_ui/selectors.py new file mode 100644 index 0000000..fffdf2b --- /dev/null +++ b/discord_ui/selectors.py @@ -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) \ No newline at end of file diff --git a/discord_utils.py b/discord_utils.py new file mode 100644 index 0000000..f5cd508 --- /dev/null +++ b/discord_utils.py @@ -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 \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index 3e83391..8838981 100644 --- a/exceptions.py +++ b/exceptions.py @@ -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 diff --git a/gauntlets.py b/gauntlets.py index 08e4161..4a13ebf 100644 --- a/gauntlets.py +++ b/gauntlets.py @@ -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)] diff --git a/helpers.py b/helpers.py index 0cf9000..c27bab3 100644 --- a/helpers.py +++ b/helpers.py @@ -19,1525 +19,14 @@ from typing import Optional, Literal from exceptions import log_exception from in_game.gameplay_models import Team +from constants import * +from discord_ui import * +from random_content import * +from utils import * +from search_utils import * +from discord_utils import * -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 -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)] - } -} -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' -PD_IMAGE_BUCKET = 'https://paper-dynasty.s3.us-east-1.amazonaws.com/static-images' -PKMN_REF_URL = 'https://pkmncards.com/card/' -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' -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'], -} -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' -} -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' - } -} -RARITY = { - 'HoF': 8, - 'MVP': 5, - 'All-Star': 3, - 'Starter': 2, - 'Reserve': 1, - 'Replacement': 0 -} -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') -] -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'] -COLORS = { - 'sba': int('a6ce39', 16), - 'yellow': int('FFEA00', 16), - 'red': int('C70039', 16), - 'white': int('FFFFFF', 16) -} -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!' -] - - -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() - - -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() - - -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='Baltimore Orioles'), - discord.SelectOption(label='Boston Red Sox'), - discord.SelectOption(label='Chicago White Sox'), - discord.SelectOption(label='Cleveland Guardians'), - discord.SelectOption(label='Detroit Tigers'), - discord.SelectOption(label='Houston Astros'), - discord.SelectOption(label='Kansas City Royals'), - discord.SelectOption(label='Los Angeles Angels'), - discord.SelectOption(label='Minnesota Twins'), - discord.SelectOption(label='New York Yankees'), - discord.SelectOption(label='Oakland Athletics'), - discord.SelectOption(label='Seattle Mariners'), - discord.SelectOption(label='Tampa Bay Rays'), - discord.SelectOption(label='Texas Rangers'), - discord.SelectOption(label='Toronto Blue Jays') - ] - else: - options = [ - discord.SelectOption(label='Arizona Diamondbacks'), - discord.SelectOption(label='Atlanta Braves'), - discord.SelectOption(label='Chicago Cubs'), - discord.SelectOption(label='Cincinnati Reds'), - discord.SelectOption(label='Colorado Rockies'), - discord.SelectOption(label='Los Angeles Dodgers'), - discord.SelectOption(label='Miami Marlins'), - discord.SelectOption(label='Milwaukee Brewers'), - discord.SelectOption(label='New York Mets'), - discord.SelectOption(label='Philadelphia Phillies'), - discord.SelectOption(label='Pittsburgh Pirates'), - discord.SelectOption(label='San Diego Padres'), - discord.SelectOption(label='San Francisco Giants'), - discord.SelectOption(label='St. Louis Cardinals'), - discord.SelectOption(label='Washington Nationals') - ] - super().__init__(placeholder=f'Select an {which} team', options=options) - - async def callback(self, interaction: discord.Interaction): - team_id = None - if self.which == 'AL': - if self.values[0] == 'Baltimore Orioles': - team_id = 3 - elif self.values[0] == 'Boston Red Sox': - team_id = 4 - elif self.values[0] == 'Chicago White Sox': - team_id = 6 - elif self.values[0] == 'Cleveland Guardians': - team_id = 8 - elif self.values[0] == 'Detroit Tigers': - team_id = 10 - elif self.values[0] == 'Houston Astros': - team_id = 11 - elif self.values[0] == 'Kansas City Royals': - team_id = 12 - elif self.values[0] == 'Los Angeles Angels': - team_id = 13 - elif self.values[0] == 'Minnesota Twins': - team_id = 17 - elif self.values[0] == 'New York Yankees': - team_id = 19 - elif self.values[0] == 'Oakland Athletics': - team_id = 20 - elif self.values[0] == 'Seattle Mariners': - team_id = 24 - elif self.values[0] == 'Tampa Bay Rays': - team_id = 27 - elif self.values[0] == 'Texas Rangers': - team_id = 28 - elif self.values[0] == 'Toronto Blue Jays': - team_id = 29 - else: - if self.values[0] == 'Arizona Diamondbacks': - team_id = 1 - elif self.values[0] == 'Atlanta Braves': - team_id = 2 - elif self.values[0] == 'Chicago Cubs': - team_id = 5 - elif self.values[0] == 'Cincinnati Reds': - team_id = 7 - elif self.values[0] == 'Colorado Rockies': - team_id = 9 - elif self.values[0] == 'Los Angeles Dodgers': - team_id = 14 - elif self.values[0] == 'Miami Marlins': - team_id = 15 - elif self.values[0] == 'Milwaukee Brewers': - team_id = 16 - elif self.values[0] == 'New York Mets': - team_id = 18 - elif self.values[0] == 'Philadelphia Phillies': - team_id = 21 - elif self.values[0] == 'Pittsburgh Pirates': - team_id = 22 - elif self.values[0] == 'San Diego Padres': - team_id = 23 - elif self.values[0] == 'San Francisco Giants': - team_id = 25 - elif self.values[0] == 'St. Louis Cardinals': - team_id = 26 - elif self.values[0] == 'Washington Nationals': - team_id = 30 - - 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): - 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): - logger.info(f'SelectPaperdexCardset - selection: {self.values[0]}') - cardset_id = None - if self.values[0] == '2022 Season': - cardset_id = 3 - elif self.values[0] == '2022 Promos': - cardset_id = 4 - elif self.values[0] == '2021 Season': - cardset_id = 1 - elif self.values[0] == '2019 Season': - cardset_id = 5 - elif self.values[0] == '2013 Season': - cardset_id = 6 - elif self.values[0] == '2012 Season': - cardset_id = 7 - elif self.values[0] == 'Mario Super Sluggers': - cardset_id = 8 - elif self.values[0] == '2023 Season': - cardset_id = 9 - elif self.values[0] == '2016 Season': - cardset_id = 11 - elif self.values[0] == '2008 Season': - cardset_id = 12 - elif self.values[0] == '2018 Season': - cardset_id = 13 - elif self.values[0] == '2024 Season': - cardset_id = 17 - elif self.values[0] == '2024 Promos': - cardset_id = 18 - elif self.values[0] == '1998 Season': - cardset_id = 20 - elif self.values[0] == '2025 Live': - cardset_id = 24 - - 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='Baltimore Orioles'), - discord.SelectOption(label='Boston Red Sox'), - discord.SelectOption(label='Chicago White Sox'), - discord.SelectOption(label='Cleveland Guardians'), - discord.SelectOption(label='Detroit Tigers'), - discord.SelectOption(label='Houston Astros'), - discord.SelectOption(label='Kansas City Royals'), - discord.SelectOption(label='Los Angeles Angels'), - discord.SelectOption(label='Minnesota Twins'), - discord.SelectOption(label='New York Yankees'), - discord.SelectOption(label='Oakland Athletics'), - discord.SelectOption(label='Seattle Mariners'), - discord.SelectOption(label='Tampa Bay Rays'), - discord.SelectOption(label='Texas Rangers'), - discord.SelectOption(label='Toronto Blue Jays') - ] - else: - options = [ - discord.SelectOption(label='Arizona Diamondbacks'), - discord.SelectOption(label='Atlanta Braves'), - discord.SelectOption(label='Chicago Cubs'), - discord.SelectOption(label='Cincinnati Reds'), - discord.SelectOption(label='Colorado Rockies'), - discord.SelectOption(label='Los Angeles Dodgers'), - discord.SelectOption(label='Miami Marlins'), - discord.SelectOption(label='Milwaukee Brewers'), - discord.SelectOption(label='New York Mets'), - discord.SelectOption(label='Philadelphia Phillies'), - discord.SelectOption(label='Pittsburgh Pirates'), - discord.SelectOption(label='San Diego Padres'), - discord.SelectOption(label='San Francisco Giants'), - discord.SelectOption(label='St. Louis Cardinals'), - discord.SelectOption(label='Washington Nationals') - ] - super().__init__(placeholder=f'Select an {which} team', options=options) - - async def callback(self, interaction: discord.Interaction): - team_id = None - if self.which == 'AL': - if self.values[0] == 'Baltimore Orioles': - team_id = 3 - elif self.values[0] == 'Boston Red Sox': - team_id = 4 - elif self.values[0] == 'Chicago White Sox': - team_id = 6 - elif self.values[0] == 'Cleveland Guardians': - team_id = 8 - elif self.values[0] == 'Detroit Tigers': - team_id = 10 - elif self.values[0] == 'Houston Astros': - team_id = 11 - elif self.values[0] == 'Kansas City Royals': - team_id = 12 - elif self.values[0] == 'Los Angeles Angels': - team_id = 13 - elif self.values[0] == 'Minnesota Twins': - team_id = 17 - elif self.values[0] == 'New York Yankees': - team_id = 19 - elif self.values[0] == 'Oakland Athletics': - team_id = 20 - elif self.values[0] == 'Seattle Mariners': - team_id = 24 - elif self.values[0] == 'Tampa Bay Rays': - team_id = 27 - elif self.values[0] == 'Texas Rangers': - team_id = 28 - elif self.values[0] == 'Toronto Blue Jays': - team_id = 29 - else: - if self.values[0] == 'Arizona Diamondbacks': - team_id = 1 - elif self.values[0] == 'Atlanta Braves': - team_id = 2 - elif self.values[0] == 'Chicago Cubs': - team_id = 5 - elif self.values[0] == 'Cincinnati Reds': - team_id = 7 - elif self.values[0] == 'Colorado Rockies': - team_id = 9 - elif self.values[0] == 'Los Angeles Dodgers': - team_id = 14 - elif self.values[0] == 'Miami Marlins': - team_id = 15 - elif self.values[0] == 'Milwaukee Brewers': - team_id = 16 - elif self.values[0] == 'New York Mets': - team_id = 18 - elif self.values[0] == 'Philadelphia Phillies': - team_id = 21 - elif self.values[0] == 'Pittsburgh Pirates': - team_id = 22 - elif self.values[0] == 'San Diego Padres': - team_id = 23 - elif self.values[0] == 'San Francisco Giants': - team_id = 25 - elif self.values[0] == 'St. Louis Cardinals': - team_id = 26 - elif self.values[0] == 'Washington Nationals': - team_id = 30 - - 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): - logger.info(f'SelectBuyPacksCardset - selection: {self.values[0]}') - cardset_id = None - if self.values[0] == '2022 Season': - cardset_id = 3 - elif self.values[0] == '2021 Season': - cardset_id = 1 - elif self.values[0] == '2019 Season': - cardset_id = 5 - elif self.values[0] == '2013 Season': - cardset_id = 6 - elif self.values[0] == '2012 Season': - cardset_id = 7 - elif self.values[0] == '2023 Season': - cardset_id = 9 - elif self.values[0] == '2016 Season': - cardset_id = 11 - elif self.values[0] == '2008 Season': - cardset_id = 12 - elif self.values[0] == '2018 Season': - cardset_id = 13 - elif self.values[0] == '2024 Season': - cardset_id = 17 - elif self.values[0] == '1998 Season': - cardset_id = 20 - elif self.values[0] == '2025 Live': - cardset_id = 24 - elif self.values[0] == 'Pokemon - Brilliant Stars': - cardset_id = 23 - 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='Baltimore Orioles'), - discord.SelectOption(label='Boston Red Sox'), - discord.SelectOption(label='Chicago White Sox'), - discord.SelectOption(label='Cleveland Guardians'), - discord.SelectOption(label='Detroit Tigers'), - discord.SelectOption(label='Houston Astros'), - discord.SelectOption(label='Kansas City Royals'), - discord.SelectOption(label='Los Angeles Angels'), - discord.SelectOption(label='Minnesota Twins'), - discord.SelectOption(label='New York Yankees'), - discord.SelectOption(label='Oakland Athletics'), - discord.SelectOption(label='Seattle Mariners'), - discord.SelectOption(label='Tampa Bay Rays'), - discord.SelectOption(label='Texas Rangers'), - discord.SelectOption(label='Toronto Blue Jays') - ] - else: - options = [ - discord.SelectOption(label='Arizona Diamondbacks'), - discord.SelectOption(label='Atlanta Braves'), - discord.SelectOption(label='Chicago Cubs'), - discord.SelectOption(label='Cincinnati Reds'), - discord.SelectOption(label='Colorado Rockies'), - discord.SelectOption(label='Los Angeles Dodgers'), - discord.SelectOption(label='Miami Marlins'), - discord.SelectOption(label='Milwaukee Brewers'), - discord.SelectOption(label='New York Mets'), - discord.SelectOption(label='Philadelphia Phillies'), - discord.SelectOption(label='Pittsburgh Pirates'), - discord.SelectOption(label='San Diego Padres'), - discord.SelectOption(label='San Francisco Giants'), - discord.SelectOption(label='St. Louis Cardinals'), - discord.SelectOption(label='Washington Nationals') - ] - super().__init__(placeholder=f'Select an {which} team', options=options) - - async def callback(self, interaction: discord.Interaction): - team_id = None - if self.which == 'AL': - if self.values[0] == 'Baltimore Orioles': - team_id = 3 - elif self.values[0] == 'Boston Red Sox': - team_id = 4 - elif self.values[0] == 'Chicago White Sox': - team_id = 6 - elif self.values[0] == 'Cleveland Guardians': - team_id = 8 - elif self.values[0] == 'Detroit Tigers': - team_id = 10 - elif self.values[0] == 'Houston Astros': - team_id = 11 - elif self.values[0] == 'Kansas City Royals': - team_id = 12 - elif self.values[0] == 'Los Angeles Angels': - team_id = 13 - elif self.values[0] == 'Minnesota Twins': - team_id = 17 - elif self.values[0] == 'New York Yankees': - team_id = 19 - elif self.values[0] == 'Oakland Athletics': - team_id = 20 - elif self.values[0] == 'Seattle Mariners': - team_id = 24 - elif self.values[0] == 'Tampa Bay Rays': - team_id = 27 - elif self.values[0] == 'Texas Rangers': - team_id = 28 - elif self.values[0] == 'Toronto Blue Jays': - team_id = 29 - else: - if self.values[0] == 'Arizona Diamondbacks': - team_id = 1 - elif self.values[0] == 'Atlanta Braves': - team_id = 2 - elif self.values[0] == 'Chicago Cubs': - team_id = 5 - elif self.values[0] == 'Cincinnati Reds': - team_id = 7 - elif self.values[0] == 'Colorado Rockies': - team_id = 9 - elif self.values[0] == 'Los Angeles Dodgers': - team_id = 14 - elif self.values[0] == 'Miami Marlins': - team_id = 15 - elif self.values[0] == 'Milwaukee Brewers': - team_id = 16 - elif self.values[0] == 'New York Mets': - team_id = 18 - elif self.values[0] == 'Philadelphia Phillies': - team_id = 21 - elif self.values[0] == 'Pittsburgh Pirates': - team_id = 22 - elif self.values[0] == 'San Diego Padres': - team_id = 23 - elif self.values[0] == 'San Francisco Giants': - team_id = 25 - elif self.values[0] == 'St. Louis Cardinals': - team_id = 26 - elif self.values[0] == 'Washington Nationals': - team_id = 30 - - 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='Baltimore Orioles'), - discord.SelectOption(label='Boston Red Sox'), - discord.SelectOption(label='Chicago White Sox'), - discord.SelectOption(label='Cleveland Guardians'), - discord.SelectOption(label='Detroit Tigers'), - discord.SelectOption(label='Houston Astros'), - discord.SelectOption(label='Kansas City Royals'), - discord.SelectOption(label='Los Angeles Angels'), - discord.SelectOption(label='Minnesota Twins'), - discord.SelectOption(label='New York Yankees'), - discord.SelectOption(label='Oakland Athletics'), - discord.SelectOption(label='Seattle Mariners'), - discord.SelectOption(label='Tampa Bay Rays'), - discord.SelectOption(label='Texas Rangers'), - discord.SelectOption(label='Toronto Blue Jays') - ] - else: - options = [ - discord.SelectOption(label='Arizona Diamondbacks'), - discord.SelectOption(label='Atlanta Braves'), - discord.SelectOption(label='Chicago Cubs'), - discord.SelectOption(label='Cincinnati Reds'), - discord.SelectOption(label='Colorado Rockies'), - discord.SelectOption(label='Los Angeles Dodgers'), - discord.SelectOption(label='Miami Marlins'), - discord.SelectOption(label='Milwaukee Brewers'), - discord.SelectOption(label='New York Mets'), - discord.SelectOption(label='Philadelphia Phillies'), - discord.SelectOption(label='Pittsburgh Pirates'), - discord.SelectOption(label='San Diego Padres'), - discord.SelectOption(label='San Francisco Giants'), - discord.SelectOption(label='St. Louis Cardinals'), - discord.SelectOption(label='Washington Nationals') - ] - super().__init__(placeholder=f'Select an {which} team', options=options) - - async def callback(self, interaction: discord.Interaction): - 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) - - -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) - - -def random_conf_gif(): - 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(): - 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(): - 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(): - conf_words = [ - 'dope', - 'cool', - 'got it', - 'noice', - 'ok', - 'lit', - ] - return conf_words[random.randint(0, len(conf_words) - 1)] - - -def random_codename(): - 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(): - phrases = [ - 'uhh...no', - 'lol no', - 'nope', - ] - - -async def send_to_bothole(ctx, content, embed): - 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): - await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \ - .send(content=content, embed=embed) - - -async def typing_pause(ctx, seconds=1): - async with ctx.typing(): - await asyncio.sleep(seconds) - - -async def pause_then_type(ctx, message): - async with ctx.typing(): - await asyncio.sleep(len(message) / 100) - await ctx.send(message) - - -async def check_if_pdhole(ctx): - 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 - - -def get_roster_sheet_legacy(team): - return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit' - - -def get_special_embed(special): - 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) - # embed.add_field(name='To Redeem', value=f'Run .redeem {special.get_id()}', 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): - embed = discord.Embed(title=title, color=discord.Color.random()) - if thumb: - embed.set_thumbnail(url=thumb) - - return embed - async def get_player_photo(player): search_term = player['bbref_id'] if player['bbref_id'] else player['p_name'] @@ -1571,121 +60,16 @@ async def get_player_headshot(player): return await get_player_photo(player) -def fuzzy_search(name, master_list): - 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): - """ - Takes a name to search and returns the name of the best match - - :param ctx: discord context - :param channel: discord channel - :param bot: discord.py bot object - :param name: string - :param master_list: list of names - :return: - """ - 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 create_channel_old( - ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None, - allowed_roles=None): - 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 react_and_reply(ctx, reaction, message): - await ctx.message.add_reaction(reaction) - await ctx.send(message) -async def send_to_channel(bot, channel_name, content=None, embed=None): - 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): - this_role = discord.utils.get(ctx.guild.roles, name=role_name) - # logger.info(f'this_role: {this_role} / role_name: {role_name} (POST SEARCH)') - - if not this_role: - this_role = await ctx.guild.create_role(name=role_name, mentionable=mentionable) - # logger.info(f'this_role: {this_role} / role_name: {role_name} (PRE RETURN)') - - return this_role """ @@ -1693,25 +77,6 @@ NEW FOR SEASON 4 """ -def get_team_embed(title, team=None, thumbnail: bool = True): - 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 get_team_by_owner(owner_id: int): @@ -1727,19 +92,8 @@ async def team_role(ctx, team: Team): return await get_or_create_role(ctx, f'{team.abbrev} - {team.lname}') -def get_cal_user(ctx): - return ctx.guild.get_member(258104532423147520) -async def get_emoji(ctx, name, return_empty=True): - try: - emoji = await commands.converter.EmojiConverter().convert(ctx, name) - except: - if return_empty: - emoji = '' - else: - return name - return emoji def get_all_pos(player): @@ -1752,36 +106,6 @@ def get_all_pos(player): return all_pos -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): - 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 async def share_channel(channel, user, read_only=False): @@ -1814,7 +138,7 @@ async def get_card_embeds(card, include_stats=False) -> list: all_dex = await db_get('paperdex', params=[("player_id", card["player"]["player_id"]), ('flat', True)]) count = all_dex['count'] if card['team']['lname'] != 'Paper Dynasty': - bool_list = [True for elem in all_dex['paperdex'] if elem['team'] == card['team']['id']] + bool_list = [True for elem in all_dex['paperdex'] if elem['team'] == card['team'].get('id', None)] if any(bool_list): if count == 1: coll_string = f'Only you' @@ -1836,7 +160,7 @@ async def get_card_embeds(card, include_stats=False) -> list: # embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}') if card['player']['franchise'] != 'Pokemon': - player_pages = f'[BBRef]({get_player_url(card["player"], "bbref")})' + player_pages = f'[BBRef](https://www.baseball-reference.com/players/{card["player"]["bbref_id"][0]}/{card["player"]["bbref_id"]}.shtml)' else: player_pages = f'[Pkmn]({PKMN_REF_URL}{card["player"]["bbref_id"]})' embed.add_field(name='Player Page', value=f'{player_pages}') @@ -2091,48 +415,12 @@ async def embed_pagination( await msg.edit(content=None, embed=all_embeds[page_num], view=view) -def get_roster_sheet(team: Team, allow_embed: bool = False): - try: - sheet_url = team.gsheet - except AttributeError: - sheet_url = team['gsheet'] - - return f'{"" if allow_embed else "<"}' \ - f'https://docs.google.com/spreadsheets/d/{sheet_url}/edit' \ - f'{"" if allow_embed else ">"}' -def get_player_url(player, which="sba"): - if which == 'bbref': - return f'https://www.baseball-reference.com/search/search.fcgi?search={player["bbref_id"]}' - else: - stub_name = player["p_name"].replace(" ", "%20") - return f'https://sombaseball.ddns.net/players?name={stub_name}' -async def bad_channel(ctx): - 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]: - channel = discord.utils.get( - ctx.guild.text_channels, - name=name - ) - if channel: - return channel - - return None async def get_test_pack(ctx, team): @@ -2552,7 +840,7 @@ async def cardset_search(cardset: str, cardset_list: list) -> Optional[dict]: def get_blank_team_card(player): - return {'player': player, 'team': {'lname': 'Paper Dynasty', 'logo': IMAGES['logo'], 'season': PD_SEASON}} + return {'player': player, 'team': {'lname': 'Paper Dynasty', 'logo': IMAGES['logo'], 'season': PD_SEASON, 'id': None}} def get_rosters(team, bot, roster_num: Optional[int] = None) -> list: @@ -2663,11 +951,6 @@ async def legal_channel(ctx): return True -def owner_only(interaction: discord.Interaction): - if interaction.user.id == 258104532423147520: - return True - else: - return False def get_role(ctx, role_name): @@ -3410,55 +1693,5 @@ def player_bcard(this_player): return this_player['image'] -def random_gif(search_term: str): - 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): - item = data_list[random.randint(0, len(data_list) - 1)] - logger.info(f'random_from_list: {item}') - return item - - -def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool: - for x in user.roles: - if x.name == role_name: - return True - - return False - - -def random_insult() -> str: - return random_from_list(INSULTS) - - -def position_name_to_abbrev(position_name): - 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' - else: - log_exception(NameError, f'{position_name} not recognized') diff --git a/in_game/gameplay_queries.py b/in_game/gameplay_queries.py index 9b243a1..5227ef2 100644 --- a/in_game/gameplay_queries.py +++ b/in_game/gameplay_queries.py @@ -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) diff --git a/pytest.ini b/pytest.ini index 14953f0..e86cb2b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,5 @@ asyncio_mode = auto filterwarnings = ignore::DeprecationWarning +env = + DOCKER_HOST = unix:///home/cal/.docker/desktop/docker.sock diff --git a/random_content.py b/random_content.py new file mode 100644 index 0000000..a0c4425 --- /dev/null +++ b/random_content.py @@ -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) \ No newline at end of file diff --git a/search_utils.py b/search_utils.py new file mode 100644 index 0000000..c23bdad --- /dev/null +++ b/search_utils.py @@ -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] \ No newline at end of file diff --git a/tests/command_logic/test_logic_gameplay.py b/tests/command_logic/test_logic_gameplay.py index a19d954..6a6a97f 100644 --- a/tests/command_logic/test_logic_gameplay.py +++ b/tests/command_logic/test_logic_gameplay.py @@ -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 diff --git a/tests/command_logic/test_logic_groundballs.py b/tests/command_logic/test_logic_groundballs.py index 93b1cec..ae89dc5 100644 --- a/tests/command_logic/test_logic_groundballs.py +++ b/tests/command_logic/test_logic_groundballs.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5076d50 --- /dev/null +++ b/tests/conftest.py @@ -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) \ No newline at end of file diff --git a/tests/factory.py b/tests/factory.py index 8a4f9b8..e802fb8 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -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 diff --git a/tests/gameplay_models/test_batterscouting_model.py b/tests/gameplay_models/test_batterscouting_model.py index 21ff44b..170d7aa 100644 --- a/tests/gameplay_models/test_batterscouting_model.py +++ b/tests/gameplay_models/test_batterscouting_model.py @@ -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 diff --git a/tests/gameplay_models/test_card_model.py b/tests/gameplay_models/test_card_model.py index b9a4a62..82f279f 100644 --- a/tests/gameplay_models/test_card_model.py +++ b/tests/gameplay_models/test_card_model.py @@ -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): diff --git a/tests/gameplay_models/test_game_model.py b/tests/gameplay_models/test_game_model.py index c757651..4fe43b0 100644 --- a/tests/gameplay_models/test_game_model.py +++ b/tests/gameplay_models/test_game_model.py @@ -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() diff --git a/tests/gameplay_models/test_managerai_model.py b/tests/gameplay_models/test_managerai_model.py index 5bf84e2..1749ab1 100644 --- a/tests/gameplay_models/test_managerai_model.py +++ b/tests/gameplay_models/test_managerai_model.py @@ -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) diff --git a/tests/gameplay_models/test_play_model.py b/tests/gameplay_models/test_play_model.py index 2f2c478..56bfd72 100644 --- a/tests/gameplay_models/test_play_model.py +++ b/tests/gameplay_models/test_play_model.py @@ -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() diff --git a/tests/gameplay_models/test_player_model.py b/tests/gameplay_models/test_player_model.py index 8fff987..9061155 100644 --- a/tests/gameplay_models/test_player_model.py +++ b/tests/gameplay_models/test_player_model.py @@ -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) diff --git a/tests/gameplay_models/test_rosterlinks_model.py b/tests/gameplay_models/test_rosterlinks_model.py index dc6236f..b9d4196 100644 --- a/tests/gameplay_models/test_rosterlinks_model.py +++ b/tests/gameplay_models/test_rosterlinks_model.py @@ -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 diff --git a/tests/gameplay_models/test_team_model.py b/tests/gameplay_models/test_team_model.py index 72e8067..1b75b7c 100644 --- a/tests/gameplay_models/test_team_model.py +++ b/tests/gameplay_models/test_team_model.py @@ -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) == "" + # 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): diff --git a/tests/in_game/test_gameplay_queries.py b/tests/in_game/test_gameplay_queries.py new file mode 100644 index 0000000..55ba07a --- /dev/null +++ b/tests/in_game/test_gameplay_queries.py @@ -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 \ No newline at end of file diff --git a/tests/in_game/test_managerai_responses.py b/tests/in_game/test_managerai_responses.py new file mode 100644 index 0000000..5fa5f2b --- /dev/null +++ b/tests/in_game/test_managerai_responses.py @@ -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 \ No newline at end of file diff --git a/tests/in_game/test_simulations.py b/tests/in_game/test_simulations.py new file mode 100644 index 0000000..9bdddf4 --- /dev/null +++ b/tests/in_game/test_simulations.py @@ -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 \ No newline at end of file diff --git a/tests/test_api_calls.py b/tests/test_api_calls.py new file mode 100644 index 0000000..9a44a68 --- /dev/null +++ b/tests/test_api_calls.py @@ -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 \ No newline at end of file diff --git a/utilities/dropdown.py b/utilities/dropdown.py index 02b20da..49e9f2c 100644 --- a/utilities/dropdown.py +++ b/utilities/dropdown.py @@ -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) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..82af5a6 --- /dev/null +++ b/utils.py @@ -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 \ No newline at end of file