From c3418c4dfdac714f016b2a5426ee1f0b6b16847a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 9 Nov 2024 00:48:13 -0600 Subject: [PATCH] New show-card dropdown view Added PlayInitException Added complete_and_post_play for log commands Added many more log plays Add undo-play Added query logging --- cogs/gameplay.py | 185 +++++++++++++++----- command_logic/logic_gameplay.py | 191 +++++++++++++++++++-- exceptions.py | 4 + helpers.py | 1 + in_game/gameplay_models.py | 10 +- in_game/gameplay_queries.py | 28 ++- tests/command_logic/test_logic_gameplay.py | 33 +++- tests/factory.py | 3 +- tests/gameplay_models/test_game_model.py | 44 +++++ tests/gameplay_models/test_lineup_model.py | 21 ++- tests/gameplay_models/test_play_model.py | 32 +++- utilities/dropdown.py | 124 ++++++++++++- 12 files changed, 596 insertions(+), 80 deletions(-) diff --git a/cogs/gameplay.py b/cogs/gameplay.py index 2003a58..58ef9f3 100644 --- a/cogs/gameplay.py +++ b/cogs/gameplay.py @@ -8,14 +8,14 @@ from discord.ext import commands, tasks import pygsheets from api_calls import db_get -from command_logic.logic_gameplay import doubles, flyballs, get_lineups_from_sheets, checks_log_interaction, complete_play, singles +from command_logic.logic_gameplay import advance_runners, bunts, doubles, flyballs, get_lineups_from_sheets, checks_log_interaction, complete_play, hit_by_pitch, homeruns, popouts, show_defense_cards, singles, strikeouts, triples, undo_play, walks from exceptions import GameNotFoundException, TeamNotFoundException, PlayNotFoundException, GameException -from helpers import PD_PLAYERS_ROLE_NAME, team_role, user_has_role, random_gif, random_from_list +from helpers import DEFENSE_LITERAL, PD_PLAYERS_ROLE_NAME, team_role, user_has_role, random_gif, random_from_list # from in_game import ai_manager from in_game.ai_manager import get_starting_pitcher, get_starting_lineup from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check -from in_game.gameplay_models import Lineup, Session, engine, player_description, select, Game +from in_game.gameplay_models import Lineup, Play, Session, engine, player_description, select, Game from in_game.gameplay_queries import get_channel_game_or_none, get_active_games_by_team, get_game_lineups, get_team_or_none, get_card_or_none from utilities.buttons import Confirm @@ -49,6 +49,25 @@ class Gameplay(commands.Cog): logging.error(msg=error, stack_info=True) await ctx.send(f'{error[:1600]}') + async def post_play(self, session: Session, interaction: discord.Interaction, this_play: Play, buffer_message: str = None): + if buffer_message is not None: + await interaction.edit_original_response( + content=buffer_message + ) + await interaction.channel.send( + content=None, + embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) + ) + else: + await interaction.edit_original_response( + content=None, + embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) + ) + + async def complete_and_post_play(self, session: Session, interaction: discord.Interaction, this_play: Play, buffer_message: str = None): + complete_play(session, this_play) + await self.post_play(session, interaction, this_play, buffer_message) + group_new_game = app_commands.Group(name='new-game', description='Start a new baseball game') @group_new_game.command(name='mlb-campaign', description='Start a new MLB campaign game against an AI') @@ -106,7 +125,7 @@ class Gameplay(commands.Cog): ai_team = away_team if away_team.is_ai else home_team human_team = away_team if home_team.is_ai else away_team - conflict_games = get_active_games_by_team(session, team_id=human_team.id) + conflict_games = get_active_games_by_team(session, team=human_team) if len(conflict_games) > 0: await interaction.edit_original_response( content=f'Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}' @@ -352,69 +371,141 @@ class Gameplay(commands.Cog): @group_log.command(name='flyball', description='Flyballs: a, b, ballpark, bq, c') async def log_flyball(self, interaction: discord.Interaction, flyball_type: Literal['a', 'b', 'ballpark', 'b?', 'c']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='flyball') + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log flyball') - this_play = await flyballs(session, interaction, this_game, this_play, flyball_type) + this_play = await flyballs(session, interaction, this_play, flyball_type) logging.info(f'log flyball {flyball_type} - this_play: {this_play}') - complete_play(session, this_play) - - if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type == 'b') or (this_play.on_third and flyball_type == '?b')): - await interaction.edit_original_response(content='Flyball logged') - await interaction.channel.send( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) - else: - await interaction.edit_original_response( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) + await self.complete_and_post_play( + session, + interaction, + this_play, + buffer_message='Double logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type == 'b') or (this_play.on_third and flyball_type == '?b')) else None + ) @group_log.command(name='single', description='Singles: *, **, ballpark, uncapped') async def log_single( self, interaction: discord.Interaction, single_type: Literal['*', '**', 'ballpark', 'uncapped']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='single') + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log single') - this_play = await singles(session, interaction, this_game, this_play, single_type) + this_play = await singles(session, interaction, this_play, single_type) logging.info(f'log single {single_type} - this_play: {this_play}') - complete_play(session, this_play) + await self.complete_and_post_play(session, interaction, this_play, buffer_message='Double logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None) - if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped'): - await interaction.edit_original_response(content='Single logged') - await interaction.channel.send( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) - else: - await interaction.edit_original_response( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) + # complete_play(session, this_play) + + # if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped'): + # await interaction.edit_original_response(content='Single logged') + # await interaction.channel.send( + # content=None, + # embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) + # ) + # else: + # await interaction.edit_original_response( + # content=None, + # embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) + # ) @group_log.command(name='double', description='Doubles: **, ***, uncapped') async def log_double(self, interaction: discord.Interaction, double_type: Literal['**', '***', 'uncapped']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='double') + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log double') - this_play = await doubles(session, interaction, this_game, this_play, double_type) + this_play = await doubles(session, interaction, this_play, double_type) logging.info(f'log double {double_type} - this_play: {this_play}') - complete_play(session, this_play) + await self.complete_and_post_play(session, interaction, this_play, buffer_message='Double logged' if (this_play.on_first and double_type == 'uncapped') else None) + + @group_log.command(name='triple', description='Triples: no sub-types') + async def log_triple(self, interaction: discord.Interaction): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log triple') - if (this_play.on_first and double_type == 'uncapped'): - await interaction.edit_original_response(content='Double logged') - await interaction.channel.send( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) - else: - await interaction.edit_original_response( - content=None, - embed=this_play.game.get_scorebug_embed(session, full_length=False, classic=CLASSIC_EMBED) - ) + this_play = await triples(session, interaction, this_play) + logging.info(f'log triple - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='homerun', description='Home Runs: ballpark, no-doubt') + async def log_homerun(self, interaction: discord.Interaction, homerun_type: Literal['ballpark', 'no-doubt']): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log homerun') + + this_play = await homeruns(session, interaction, this_play, homerun_type) + logging.info(f'log homerun {homerun_type} - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='walk', description='Walks: unintentional (default), intentional') + async def log_walk(self, interaction: discord.Interaction, walk_type: Literal['unintentional', 'intentional'] = 'unintentional'): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log walk') + + this_play = await walks(session, interaction, this_play, walk_type) + logging.info(f'log walk {walk_type} - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='strikeout', description='Strikeout') + async def log_strikeout(self, interaction: discord.Interaction): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log strikeout') + + this_play = await strikeouts(session, interaction, this_play) + logging.info(f'log strikeout - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='popout', description='Popout') + async def log_popout(self, interaction: discord.Interaction): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log popout') + + this_play = await popouts(session, interaction, this_play) + logging.info(f'log popout - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='hit-by-pitch', description='Hit by pitch: batter to first; runners advance if forced') + async def log_hit_by_pitch(self, interaction: discord.Interaction): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log hit-by-pitch') + + this_play = await hit_by_pitch(session, interaction, this_play) + logging.info(f'log hit-by-pitch - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='bunt', description='Hit by pitch: batter to first; runners advance if forced') + async def log_sac_bunt(self, interaction: discord.Interaction, bunt_type: Literal['sacrifice', 'bad', 'popout', 'double-play', 'defense']): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log bunt') + + this_play = await bunts(session, interaction, this_play, bunt_type) + logging.info(f'log bunt - this_play: {this_play}') + + await self.complete_and_post_play(session, interaction, this_play) + + @group_log.command(name='undo-play', description='Roll back most recent play from the log') + async def log_undo_play_command(self, interaction: discord.Interaction): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log undo-play') + + this_play = undo_play(session, this_play) + logging.info(f'log undo-play - this_play: {this_play}') + + await self.post_play(session, interaction, this_play) + + group_show = app_commands.Group(name='show-card', description='Display the player card for an active player') + @group_show.command(name='defense', description='Display a defender\'s player card') + async def show_defense_command(self, interaction: discord.Interaction, position: DEFENSE_LITERAL): + with Session(engine) as session: + this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='show-card defense') + + await show_defense_cards(session, interaction, this_play, position) + logging.info(f'show-card defense - position: {position}') async def setup(bot): diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 6f37062..3d07f36 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -4,13 +4,16 @@ import logging import discord import pandas as pd from sqlmodel import Session, select, func +from sqlalchemy import delete from typing import Literal from exceptions import * +from helpers import DEFENSE_LITERAL from in_game.game_helpers import legal_check from in_game.gameplay_models import Game, Lineup, Team, Play -from in_game.gameplay_queries import get_card_or_none, get_channel_game_or_none, get_last_team_play, get_one_lineup, get_team_or_none, get_players_last_pa +from in_game.gameplay_queries import get_card_or_none, get_channel_game_or_none, get_last_team_play, get_one_lineup, get_sorted_lineups, get_team_or_none, get_players_last_pa from utilities.buttons import ButtonOptions, Confirm, ask_confirm +from utilities.dropdown import DropdownOptions, DropdownView, SelectViewDefense from utilities.embeds import image_embed from utilities.pages import Pagination @@ -307,7 +310,7 @@ async def checks_log_interaction(session: Session, interaction: discord.Interact this_play = this_game.current_play_or_none(session) if this_play is None: logging.error(f'{command_name} command: No play found for Game ID {this_game.id} - attempting to initialize play') - this_play = this_game.initialize_play(session) + this_play = activate_last_play(session, this_game) this_play.locked = True session.add(this_play) @@ -421,11 +424,6 @@ def advance_runners(session: Session, this_play: Play, num_bases: int, is_error: this_play.on_first_final = 2 else: this_play.on_first_final = 1 - - if num_bases == 4: - this_play.batter_final = 4 - this_play.rbi += 1 - this_play.run = 1 return this_play @@ -511,10 +509,11 @@ async def show_outfield_cards(session: Session, interaction: discord.Interaction return [lf, cf, rf][page_num] -async def flyballs(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, flyball_type: Literal['a', 'ballpark', 'b', 'b?', 'c']) -> Play: +async def flyballs(session: Session, interaction: discord.Interaction, this_play: Play, flyball_type: Literal['a', 'ballpark', 'b', 'b?', 'c']) -> Play: """ Commits this_play """ + this_game = this_play.game num_outs = 1 if flyball_type == 'a': @@ -639,7 +638,8 @@ async def flyballs(session: Session, interaction: discord.Interaction, this_game return this_play -async def check_uncapped_advance(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, lead_runner: Lineup, lead_base: int, trail_runner: Lineup, trail_base: int): +async def check_uncapped_advance(session: Session, interaction: discord.Interaction, this_play: Play, lead_runner: Lineup, lead_base: int, trail_runner: Lineup, trail_base: int): + this_game = this_play.game outfielder = await show_outfield_cards(session, interaction, this_play) logging.info(f'throw from {outfielder.player.name_with_desc}') def_team = this_play.pitcher.team @@ -919,11 +919,11 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact return this_play -async def singles(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, single_type: Literal['*', '**', 'ballpark', 'uncapped']) -> Play: +async def singles(session: Session, interaction: discord.Interaction, this_play: Play, single_type: Literal['*', '**', 'ballpark', 'uncapped']) -> Play: """ Commits this_play """ - this_play.pa, this_play.ab, this_play.hit, this_play.batter_final = 1, 1, 1, 1 + this_play.hit, this_play.batter_final = 1, 1 if single_type == '**': advance_runners(session, this_play, num_bases=2) @@ -954,7 +954,7 @@ async def singles(session: Session, interaction: discord.Interaction, this_game: trail_runner = this_play.batter trail_base = 2 - this_play = await check_uncapped_advance(session, interaction, this_game, this_play, lead_runner, lead_base, trail_runner, trail_base) + this_play = await check_uncapped_advance(session, interaction, this_play, lead_runner, lead_base, trail_runner, trail_base) session.add(this_play) session.commit() @@ -963,7 +963,7 @@ async def singles(session: Session, interaction: discord.Interaction, this_game: return this_play -async def doubles(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, double_type: Literal['**', '***', 'uncapped']) -> Play: +async def doubles(session: Session, interaction: discord.Interaction, this_play: Play, double_type: Literal['**', '***', 'uncapped']) -> Play: """ Commits this_play """ @@ -979,11 +979,174 @@ async def doubles(session: Session, interaction: discord.Interaction, this_game: this_play = advance_runners(session, this_play, num_bases=2) if this_play.on_first: - this_play = await check_uncapped_advance(session, interaction, this_game, this_play, lead_runner=this_play.on_first, lead_base=4, trail_runner=this_play.batter, trail_base=3) + this_play = await check_uncapped_advance(session, interaction, this_play, lead_runner=this_play.on_first, lead_base=4, trail_runner=this_play.batter, trail_base=3) session.add(this_play) session.commit() session.refresh(this_play) return this_play + + +async def triples(session: Session, interaction: discord.Interaction, this_play: Play): + """ + Commits this play + """ + this_play.hit, this_play.triple, this_play.batter_final = 1, 1, 3 + this_play = advance_runners(session, this_play, num_bases=3) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def homeruns(session: Session, interaction: discord.Interaction, this_play: Play, homerun_type: Literal['ballpark', 'no-doubt']): + this_play.hit, this_play.homerun, this_play.batter_final, this_play.rbi, this_play.run = 1, 1, 4, 1, 1 + this_play.bphr = 1 if homerun_type == 'ballpark' else 0 + this_play = advance_runners(session, this_play, num_bases=4) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def walks(session: Session, interaction: discord.Interaction, this_play: Play, walk_type: Literal['unintentional', 'intentional'] = 'unintentional'): + this_play.ab, this_play.bb, this_play.batter_final = 0, 1, 1 + this_play.ibb = 1 if walk_type == 'intentional' else 0 + this_play = advance_runners(session, this_play, num_bases=1, only_forced=True) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def strikeouts(session: Session, interaction: discord.Interaction, this_play: Play): + this_play.so, this_play.outs = 1, 1 + this_play = advance_runners(session, this_play, num_bases=0) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def popouts(session: Session, interaction: discord.Interaction, this_play: Play): + this_play.outs = 1 + this_play = advance_runners(session, this_play, num_bases=0) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def hit_by_pitch(session: Session, interaction: discord.Interaction, this_play: Play): + this_play.ab, this_play.hbp = 0, 1 + this_play = advance_runners(session, this_play, num_bases=1, only_forced=True) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +async def bunts(session: Session, interaction: discord.Interaction, this_play: Play, bunt_type: Literal['sacrifice', 'bad', 'popout', 'double-play', 'defense']): + this_play.ab = 1 if bunt_type != 'sacrifice' else 0 + this_play.sac = 1 if bunt_type != 'sacrifice' else 0 + + if bunt_type == 'sacrifice': + this_play = advance_runners(session, this_play, num_bases=1) + elif bunt_type == 'popout': + this_play = advance_runners(session, this_play, num_bases=0) + else: + log_exception(KeyError, f'Bunt type {bunt_type} is not yet implemented') + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play + + +def activate_last_play(session: Session, this_game: Game) -> Play: + p_query = session.exec(select(Play).where(Play.game == this_game).order_by(Play.id.desc()).limit(1)).all() + + this_play = complete_play(session, p_query[0]) + + return this_play + + +def undo_play(session: Session, this_play: Play): + this_game = this_play.game + + last_two_plays = session.exec(select(Play).where(Play.game == this_game).order_by(Play.id.desc()).limit(2)).all() + + for play in last_two_plays: + for runner, to_base in [(play.on_first, play.on_first_final), (play.on_second, play.on_second_final), (play.on_third, play.on_third_final)]: + if to_base == 4: + last_pa = get_players_last_pa(session, runner) + last_pa.run, last_pa.e_run = 0, 0 + session.add(last_pa) + + last_two_ids = [last_two_plays[0].id, last_two_plays[1].id] + logging.warning(f'Deleting plays: {last_two_ids}') + session.exec(delete(Play).where(Play.id.in_(last_two_ids))) + session.commit() + + try: + this_play = this_game.initialize_play(session) + logging.info(f'Initialized play: {this_play.id}') + except PlayInitException: + this_play = activate_last_play(session, this_game) + logging.info(f'Re-activated play: {this_play.id}') + + return this_play + + +async def show_defense_cards(session: Session, interaction: discord.Interaction, this_play: Play, first_position: DEFENSE_LITERAL): + position_map = { + 'Pitcher': 'P', + 'Catcher': 'C', + 'First Base': '1B', + 'Second Base': '2B', + 'Third Base': '3B', + 'Shortstop': 'SS', + 'Left Field': 'LF', + 'Center Field': 'CF', + 'Right Field': 'RF' + } + this_position = position_map[first_position] + + sorted_lineups = get_sorted_lineups(session, this_play.game, this_play.pitcher.team) + select_player_options = [ + discord.SelectOption(label=f'{x.position} - {x.player.name}', value=f'{x.id}', default=this_position == x.position) for x in sorted_lineups + ] + + this_lineup = get_one_lineup(session, this_play.game, this_play.pitcher.team, position=this_position) + player_embed = image_embed( + image_url=this_lineup.player.image, + color=this_play.pitcher.team.color, + author_name=this_play.pitcher.team.lname, + author_icon=this_play.pitcher.team.logo + ) + player_dropdown = SelectViewDefense( + options=select_player_options, + this_play=this_play, + base_embed=player_embed, + session=session, + sorted_lineups=sorted_lineups + ) + dropdown_view = DropdownView(dropdown_objects=[player_dropdown], timeout=60) + + await interaction.edit_original_response(content=None, embed=player_embed, view=dropdown_view) + \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index e6ff1bd..99bd20c 100644 --- a/exceptions.py +++ b/exceptions.py @@ -34,3 +34,7 @@ class TeamNotFoundException(GameException): class PlayNotFoundException(GameException): pass + + +class PlayInitException(GameException): + pass diff --git a/helpers.py b/helpers.py index 89f7e39..d8e2afc 100644 --- a/helpers.py +++ b/helpers.py @@ -244,6 +244,7 @@ SELECT_CARDSET_OPTIONS = [ discord.SelectOption(label='2012 Season', value='7') ] ACTIVE_EVENT_LITERAL = Literal['1998 Season'] +DEFENSE_LITERAL = Literal['Pitcher', 'Catcher', 'First Base', 'Second Base', 'Third Base', 'Shortstop', 'Left Field', 'Center Field', 'Right Field'] class Question: diff --git a/in_game/gameplay_models.py b/in_game/gameplay_models.py index 40cf38d..bfc0a08 100644 --- a/in_game/gameplay_models.py +++ b/in_game/gameplay_models.py @@ -128,7 +128,7 @@ class Game(SQLModel, table=True): return f'{pri_cardsets}{back_cardsets}' def current_play_or_none(self, session: Session): - this_play = session.exec(select(Play).where(Play.game == self).order_by(Play.id.desc()).limit(1)).all() + this_play = session.exec(select(Play).where(Play.game == self, Play.complete == False).order_by(Play.id.desc()).limit(1)).all() if len(this_play) == 1: return this_play[0] else: @@ -231,6 +231,10 @@ class Game(SQLModel, table=True): if existing_play is not None: return existing_play + all_plays = session.exec(select(func.count(Play.id)).where(Play.game == self)).one() + if all_plays > 0: + raise PlayInitException(f'{all_plays} plays for game {self.id} already exist, but all are complete.') + leadoff_batter, home_pitcher, home_catcher = None, None, None home_positions, away_positions = [], [] for line in [x for x in self.lineups if x.active]: @@ -796,9 +800,7 @@ class Play(PlayBase, table=True): # Defensive Alignment if self.on_third and self.starting_outs < 2: - if self.on_first: - ai_note += f'- play the corners in\n' - elif abs(self.away_score - self.home_score) <= 3: + if abs(self.away_score - self.home_score) <= 3: ai_note += f'- play the whole infield in\n' else: ai_note += f'- play the corners in\n' diff --git a/in_game/gameplay_queries.py b/in_game/gameplay_queries.py index ad33e2d..3365345 100644 --- a/in_game/gameplay_queries.py +++ b/in_game/gameplay_queries.py @@ -8,10 +8,12 @@ from exceptions import log_exception, PlayNotFoundException def get_games_by_channel(session: Session, channel_id: int) -> list[Game]: + logging.info(f'Getting games in channel {channel_id}') return session.exec(select(Game).where(Game.channel_id == channel_id, Game.active)).all() def get_channel_game_or_none(session: Session, channel_id: int) -> Game | None: + logging.info(f'Getting one game from channel {channel_id}') all_games = get_games_by_channel(session, channel_id) if len(all_games) > 1: err = 'Too many games found in get_channel_game_or_none' @@ -22,12 +24,14 @@ def get_channel_game_or_none(session: Session, channel_id: int) -> Game | None: return all_games[0] -def get_active_games_by_team(session: Session, team_id: int) -> list[Game]: - return session.exec(select(Game).where(Game.active, or_(Game.away_team_id == team_id, Game.home_team_id == team_id))).all() +def get_active_games_by_team(session: Session, team: Team) -> list[Game]: + logging.info(f'Getting game for team {team.lname}') + return session.exec(select(Game).where(Game.active, or_(Game.away_team_id == team.id, Game.home_team_id == team.id))).all() async def get_team_or_none( session: Session, team_id: int | None = None, gm_id: int | None = None, team_abbrev: str | None = None, skip_cache: bool = False) -> Team | None: + logging.info(f'Getting team or none / team_id: {team_id} / gm_id: {gm_id} / team_abbrev: {team_abbrev} / skip_cache: {skip_cache}') if team_id is None and gm_id is None and team_abbrev is None: err = 'One of "team_id", "gm_id", or "team_abbrev" must be included in search' logging.error(f'gameplay_models - get_team - {err}') @@ -120,6 +124,7 @@ async def get_player_or_none(session: Session, player_id: int, skip_cache: bool def get_player_id_from_dict(json_data: dict) -> int: + logging.info(f'Getting player from dict {json_data}') if 'player_id' in json_data: return json_data['player_id'] elif 'id' in json_data: @@ -130,6 +135,7 @@ def get_player_id_from_dict(json_data: dict) -> int: async def get_or_create_ai_card(session: Session, player: Player, team: Team, skip_cache: bool = False, dev_mode: bool = False) -> Card: + logging.info(f'Getting or creating card for {player.name_with_desc} on the {team.sname}') if not team.is_ai: err = f'Cannot create AI cards for human teams' logging.error(f'gameplay_models - get_or_create_ai_card: {err}') @@ -196,6 +202,7 @@ async def get_or_create_ai_card(session: Session, player: Player, team: Team, sk async def get_card_or_none(session: Session, card_id: int, skip_cache: bool = False) -> Card | None: + logging.info(f'Getting card {card_id}') if not skip_cache: this_card = session.get(Card, card_id) @@ -236,6 +243,7 @@ async def get_card_or_none(session: Session, card_id: int, skip_cache: bool = Fa def get_game_lineups(session: Session, this_game: Game, specific_team: Team = None, is_active: bool = None) -> list[Lineup]: + logging.info(f'Getting lineups for game {this_game.id} / specific_team: {specific_team} / is_active: {is_active}') st = select(Lineup).where(Lineup.game == this_game) if specific_team is not None: @@ -247,6 +255,7 @@ def get_game_lineups(session: Session, this_game: Game, specific_team: Team = No def get_players_last_pa(session: Session, lineup_member: Lineup, none_okay: bool = False): + logging.info(f'Getting last AB for {lineup_member.player.name_with_desc} on the {lineup_member.team.lname}') last_pa = session.exec(select(Play).where(Play.game == lineup_member.game, Play.batter == lineup_member).order_by(Play.id.desc()).limit(1)).all() if len(last_pa) == 1: return last_pa[0] @@ -258,6 +267,7 @@ def get_players_last_pa(session: Session, lineup_member: Lineup, none_okay: bool def get_one_lineup(session: Session, this_game: Game, this_team: Team, active: bool = True, position: str = None, batting_order: int = None) -> Lineup: + logging.info(f'Getting one lineup / this_game: {this_game.id} / this_team: {this_team.lname} / active: {active}, position: {position}, batting_order: {batting_order}') if position is None and batting_order is None: raise KeyError('Position or batting order must be provided for get_one_lineup') @@ -271,6 +281,7 @@ def get_one_lineup(session: Session, this_game: Game, this_team: Team, active: b def get_last_team_play(session: Session, this_game: Game, this_team: Team, none_okay: bool = False): + logging.info(f'Getting last play for the {this_team.lname} in game {this_game.id}') last_play = session.exec(select(Play).join(Lineup, onclause=Lineup.id == Play.batter_id).where(Play.game == this_game, Lineup.team == this_team).order_by(Play.id.desc()).limit(1)).all() if len(last_play) == 1: @@ -279,4 +290,15 @@ def get_last_team_play(session: Session, this_game: Game, this_team: Team, none_ if none_okay: return None else: - log_exception(PlayNotFoundException, f'No last play found for the {this_team.sname}') \ No newline at end of file + log_exception(PlayNotFoundException, f'No last play found for the {this_team.sname}') + + +def get_sorted_lineups(session: Session, this_game: Game, this_team: Team) -> list[Lineup]: + logging.info(f'Getting sorted lineups for the {this_team.lname} in game {this_game.id}') + custom_order = {'P': 1, 'C': 2, '1B': 3, '2B': 4, '3B': 5, 'SS': 6, 'LF': 7, 'CF': 8, 'RF': 9} + + all_lineups = session.exec(select(Lineup).where(Lineup.game == this_game, Lineup.active == True, Lineup.team == this_team)).all() + + sorted_lineups = sorted(all_lineups, key=lambda x: custom_order.get(x.position, float('inf'))) + return sorted_lineups + diff --git a/tests/command_logic/test_logic_gameplay.py b/tests/command_logic/test_logic_gameplay.py index e9788ed..54f8ffa 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, get_obc, get_re24, get_wpa, complete_play, log_run_scored +from command_logic.logic_gameplay import advance_runners, doubles, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts from in_game.gameplay_models import Lineup, Play from tests.factory import session_fixture, Game @@ -35,6 +35,7 @@ def test_advance_runners(session: Session): assert play_3.on_second is None assert play_3.on_first is not None + def test_get_obc(): assert get_obc() == 0 assert get_obc(on_first=True) == 1 @@ -161,5 +162,35 @@ def test_log_run_scored(session: Session): assert e_runs == 1 +async def test_strikeouts(session: Session): + game_1 = session.get(Game, 1) + play_1 = session.get(Play, 1) + + play_1 = await strikeouts(session, None, game_1, play_1) + + assert play_1.so == 1 + assert play_1.outs == 1 + assert play_1.batter_final is None + + +async def test_doubles(session: Session): + game_1 = session.get(Game, 1) + play_1 = session.get(Play, 1) + play_1.hit, play_1.batter_final = 1, 1 + play_2 = complete_play(session, play_1) + + assert play_2.play_num == 2 + + play_2_ghost_1 = await doubles(session, None, game_1, play_2, double_type='***') + + assert play_2_ghost_1.double == 1 + assert play_2_ghost_1.on_first_final == 4 + assert play_2_ghost_1.rbi == 1 + + play_2_ghost_2 = await doubles(session, None, game_1, play_2, double_type='**') + + assert play_2_ghost_2.double == 1 + assert play_2_ghost_2.rbi == 0 + assert play_2_ghost_2.on_first_final == 3 diff --git a/tests/factory.py b/tests/factory.py index 3774dfc..2d8dcc3 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -159,7 +159,8 @@ def session_fixture(): catcher=all_lineups[10], pa=1, so=1, - outs=1 + outs=1, + complete=True ) game_1_play_2 = Play( game=game_1, diff --git a/tests/gameplay_models/test_game_model.py b/tests/gameplay_models/test_game_model.py index 6a41bff..661df7e 100644 --- a/tests/gameplay_models/test_game_model.py +++ b/tests/gameplay_models/test_game_model.py @@ -3,6 +3,7 @@ from sqlalchemy import delete as sadelete from sqlalchemy.sql.functions import sum, count from sqlmodel import Session, delete +from command_logic.logic_gameplay import complete_play, singles, undo_play from in_game.gameplay_models import Game, Lineup, GameCardsetLink, Play, select from in_game.gameplay_queries import get_channel_game_or_none, get_active_games_by_team from tests.factory import session_fixture @@ -138,6 +139,17 @@ def test_sum_function(session: Session): assert False == False +def test_current_play_or_none(session: Session): + game_1 = session.get(Game, 1) + this_play = game_1.initialize_play(session) + + assert this_play.play_num == 2 + + this_play.complete = True + session.add(this_play) + session.commit() + + def test_initialize_play(session: Session): game_1 = session.get(Game, 1) game_3 = session.get(Game, 3) @@ -155,4 +167,36 @@ def test_initialize_play(session: Session): assert g3_play.play_num == 1 assert g3_play.starting_outs == 0 assert len(play_count) == 3 + + +async def test_undo_play(session: Session): + game_1 = session.get(Game, 1) + this_play = game_1.initialize_play(session) + + assert this_play.play_num == 2 + + all_play_ids = session.exec(select(Play.id, Play.play_num).where(Play.game == game_1)).all() + assert len(all_play_ids) == 2 + assert all_play_ids[0][0] == 1 + assert all_play_ids[1][1] == 2 + + await singles(session, None, this_play, '*') + play_3 = complete_play(session, this_play) + + await singles(session, None, play_3, '*') + play_4 = complete_play(session, play_3) + on_second_play_4 = play_4.on_second + + await singles(session, None, play_4, '*') + play_5 = complete_play(session, play_4) + + assert len(play_5.game.plays) == 5 + assert play_5.on_base_code == 7 + + new_play = undo_play(session, this_play) + + assert len(new_play.game.plays) == 4 + assert new_play.play_num == 4 + assert new_play.on_second == on_second_play_4 + diff --git a/tests/gameplay_models/test_lineup_model.py b/tests/gameplay_models/test_lineup_model.py index 03936e9..e610c7b 100644 --- a/tests/gameplay_models/test_lineup_model.py +++ b/tests/gameplay_models/test_lineup_model.py @@ -2,7 +2,7 @@ import pytest from sqlmodel import Session, select from in_game.gameplay_models import Game, Lineup -from in_game.gameplay_queries import get_game_lineups, get_one_lineup +from in_game.gameplay_queries import get_game_lineups, get_one_lineup, get_sorted_lineups from tests.factory import session_fixture @@ -60,13 +60,20 @@ def test_get_one_lineup(session: Session): assert str(exc_info) == "" -# def test_lineup_substitution(session: Session, new_games_with_lineups: list[Game]): -# game_1 = new_games_with_lineups[0] -# game_2 = new_games_with_lineups[1] +def test_order_lineups_by_position(session: Session): + this_game = session.get(Game, 1) + all_lineups = get_sorted_lineups(session, this_game, this_game.home_team) + + assert all_lineups[0].position == 'P' + assert all_lineups[1].position == 'C' + assert all_lineups[2].position == '1B' + assert all_lineups[3].position == '2B' + assert all_lineups[4].position == '3B' + assert all_lineups[5].position == 'SS' + assert all_lineups[6].position == 'LF' + assert all_lineups[7].position == 'CF' + assert all_lineups[8].position == 'RF' -# session.add(game_1) -# session.add(game_2) -# session.commit() diff --git a/tests/gameplay_models/test_play_model.py b/tests/gameplay_models/test_play_model.py index c9fd00d..3a87227 100644 --- a/tests/gameplay_models/test_play_model.py +++ b/tests/gameplay_models/test_play_model.py @@ -1,7 +1,7 @@ import pytest from sqlmodel import Session, select, func -from command_logic.logic_gameplay import complete_play, singles +from command_logic.logic_gameplay import complete_play, singles, undo_play from db_calls_gameplay import advance_runners from in_game.gameplay_models import Lineup, Play, Game from in_game.gameplay_queries import get_last_team_play @@ -77,3 +77,33 @@ def test_query_scalars(session: Session): assert outs == 1 + +async def test_undo_play(session: Session): + game_1 = session.get(Game, 1) + play_2 = game_1.initialize_play(session) + + assert play_2.play_num == 2 + + play_2 = await singles(session, None, play_2, single_type='*') + play_3 = complete_play(session, play_2) + + assert play_3.play_num == 3 + assert play_3.on_first == play_2.batter + + play_3 = await singles(session, None, play_3, single_type='**') + play_4 = complete_play(session, play_3) + all_plays = session.exec(select(Play).where(Play.game == game_1)).all() + + assert play_4.play_num == 4 + assert play_4.on_first == play_3.batter + assert len(all_plays) == 4 + + undone_play = undo_play(session, play_4) + + assert undone_play.play_num == 3 + + all_plays = session.exec(select(Play).where(Play.game == game_1)).all() + + assert len(all_plays) == 3 + + diff --git a/utilities/dropdown.py b/utilities/dropdown.py index c2feefa..d00e353 100644 --- a/utilities/dropdown.py +++ b/utilities/dropdown.py @@ -1,7 +1,12 @@ import discord import logging -class DropdownOption(discord.ui.Select): +from sqlmodel import Session + +from in_game.gameplay_models import Lineup, Play +from in_game.gameplay_queries import get_sorted_lineups + +class DropdownOptions(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 = [ @@ -38,9 +43,124 @@ class DropdownView(discord.ui.View): """ https://discordpy.readthedocs.io/en/latest/interactions/api.html#select """ - def __init__(self, dropdown_objects: list[DropdownOption], timeout: float = 300.0): + def __init__(self, dropdown_objects: list[discord.ui.Select], timeout: float = 300.0): super().__init__(timeout=timeout) # self.add_item(Dropdown()) for x in dropdown_objects: self.add_item(x) + + +class SelectViewDefense(discord.ui.Select): + def __init__(self, options: list, this_play: Play, base_embed: discord.Embed, session: Session, sorted_lineups: list[Lineup]): + self.embed = base_embed + self.session = session + self.play = this_play + self.sorted_lineups = sorted_lineups + super().__init__(options=options) + + async def callback(self, interaction: discord.Interaction): + logging.info(f'SelectViewDefense - selection: {self.values[0]}') + + this_lineup = self.session.get(Lineup, self.values[0]) + self.embed.set_image(url=this_lineup.player.image) + + select_player_options = [ + discord.SelectOption(label=f'{x.position} - {x.player.name}', value=f'{x.id}', default=this_lineup.position == x.position) for x in self.sorted_lineups + ] + player_dropdown = SelectViewDefense( + options=select_player_options, + this_play=self.play, + base_embed=self.embed, + session=self.session, + sorted_lineups=self.sorted_lineups + ) + new_view = DropdownView( + dropdown_objects=[player_dropdown], + timeout=60 + ) + + await interaction.response.edit_message(content=None, embed=self.embed, view=new_view) + + + +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): + logging.info(f'SelectPackChoice - selection: {self.values[0]}') + pack_vals = self.values[0].split('-') + logging.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: + logging.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) +