diff --git a/cogs/gameplay.py b/cogs/gameplay.py index 8fdd086..2003a58 100644 --- a/cogs/gameplay.py +++ b/cogs/gameplay.py @@ -8,7 +8,7 @@ from discord.ext import commands, tasks import pygsheets from api_calls import db_get -from command_logic.logic_gameplay import flyballs, get_lineups_from_sheets, checks_log_interaction, complete_play, singles +from command_logic.logic_gameplay import doubles, flyballs, get_lineups_from_sheets, checks_log_interaction, complete_play, singles from exceptions import GameNotFoundException, TeamNotFoundException, PlayNotFoundException, GameException from helpers import PD_PLAYERS_ROLE_NAME, team_role, user_has_role, random_gif, random_from_list @@ -42,9 +42,11 @@ class Gameplay(commands.Cog): await self.bot.wait_until_ready() async def cog_command_error(self, ctx, error): + logging.error(msg=error, stack_info=True) await ctx.send(f'{error}\n\nRun !help to see the command requirements') async def slash_error(self, ctx, error): + logging.error(msg=error, stack_info=True) await ctx.send(f'{error[:1600]}') group_new_game = app_commands.Group(name='new-game', description='Start a new baseball game') @@ -378,7 +380,9 @@ class Gameplay(commands.Cog): this_play = await singles(session, interaction, this_game, this_play, single_type) logging.info(f'log single {single_type} - this_play: {this_play}') - if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_first or this_play.on_second) and single_type == 'uncapped'): + 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, @@ -390,6 +394,28 @@ class Gameplay(commands.Cog): 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_play = await doubles(session, interaction, this_game, this_play, double_type) + logging.info(f'log double {double_type} - this_play: {this_play}') + + complete_play(session, this_play) + + 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) + ) + async def setup(bot): await bot.add_cog(Gameplay(bot)) \ No newline at end of file diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 5126b2f..6f37062 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -10,7 +10,7 @@ from exceptions import * 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 utilities.buttons import ButtonOptions, Confirm +from utilities.buttons import ButtonOptions, Confirm, ask_confirm from utilities.embeds import image_embed from utilities.pages import Pagination @@ -587,7 +587,7 @@ async def flyballs(session: Session, interaction: discord.Interaction, this_game if this_play.starting_outs < 2 and this_play.on_third: logging.debug(f'calling of embed') - await show_outfield_cards(interaction, this_play) + await show_outfield_cards(session, interaction, this_play) logging.debug(f'done with of embed') runner = this_play.on_second.player @@ -640,7 +640,8 @@ async def flyballs(session: Session, interaction: discord.Interaction, this_game 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): - outfielder = await show_outfield_cards(interaction, this_play) + 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 TO_BASE = { 2: 'to second', @@ -655,19 +656,17 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact # Either there is no AI team or the AI is pitching if not this_game.ai_team or not this_play.ai_is_batting: - view = Confirm(responders=[interaction.user], timeout=60, label_type='yes') - question = await interaction.channel.send( - f'Is **{lead_runner.name}** being sent {TO_BASE[lead_base]}?', view=view + is_lead_running = await ask_confirm( + interaction=interaction, + question=f'Is **{lead_runner.player.name}** being sent {TO_BASE[lead_base]}?', + label_type='yes' ) - await view.wait() - - # Human runner is attempting uncapped advance - if view.value: - await question.delete() + if is_lead_running: throw_resp = None if this_game.ai_team: throw_resp = this_play.managerai.throw_at_uncapped(session, this_game) + logging.info(f'throw_resp: {throw_resp}') if throw_resp.cutoff: await interaction.channel.send(f'The {def_team.sname} will cut off the throw {TO_BASE[lead_base]}') @@ -682,18 +681,14 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact return this_play else: - view = Confirm(responders=[interaction.user], timeout=60, label_type='yes') - question = await interaction.channel.send( - f'Is the defense throwing {TO_BASE[lead_base]} for {lead_runner.player.name}?', view=view - ) - await view.wait() + throw_for_lead = await ask_confirm( + interaction=interaction, + question=f'Is the defense throwing {TO_BASE[lead_base]} for {lead_runner.player.name}?', + label_type='yes' + ) - # Human defense is throwing for lead runner - if view.value: - await question.delete() - # Human defense is cutting off the throw - else: + if not throw_for_lead: await question.delete() if this_play.on_second == lead_runner: this_play.rbi += 1 @@ -702,24 +697,28 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact return this_play # Human runner is advancing, defense is throwing - view = Confirm(responders=[interaction.user], timeout=60, label_type='yes') - question = await interaction.channel.send( - f'Will {trail_runner.player.name} be sent {TO_BASE[trail_base]} as the trail runner?', view=view + trail_advancing = await ask_confirm( + interaction=interaction, + question=f'Is **{trail_runner.player.name}** being sent {TO_BASE[trail_base]} as the trail runner?', + label_type='yes' ) - await view.wait() # Trail runner is advancing - if view.value: - await question.delete() + if trail_advancing: view = Confirm(responders=[interaction.user], timeout=60, label_type='yes') view.confirm.label = 'Home Plate' if lead_base == 4 else 'Third Base' view.cancel.label = 'Third Base' if trail_base == 3 else 'Second Base' - play_at_trail = False - if this_game.ai_team and throw_resp.at_trail_runner: - question = await interaction.channel.send( - f'The {def_team.sname} will throw for the trail runner if both:\n- Trail safe range is {throw_resp.trail_max_safe} or lower\n- Trail runner\'s safe range lower by at least {abs(throw_resp.trail_max_safe_delta)}.\n\nIs the throw going {TO_BASE[lead_base]} or {TO_BASE[trail_base]}?' - ) + ai_throw_lead = False + if this_game.ai_team: + if throw_resp.at_trail_runner: + question = await interaction.channel.send( + f'The {def_team.sname} will throw for the trail runner if both:\n- {trail_runner.player.name}\'s safe range is {throw_resp.trail_max_safe} or lower\n- {trail_runner.player.name}\'s safe range is lower than {lead_runner.player.name}\'s by at least {abs(throw_resp.trail_max_safe_delta)}.\n\nIs the throw going {TO_BASE[lead_base]} or {TO_BASE[trail_base]}?', + view=view + ) + else: + await interaction.channel.send(f'**{outfielder.player.name}** will throw {TO_BASE[lead_base]}!') + ai_throw_lead = True else: question = await interaction.channel.send( @@ -727,25 +726,39 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact view=view ) - await view.wait() + if not ai_throw_lead: + await view.wait() + elif ai_throw_lead: + view.value = True # Throw is going to lead runner if view.value: - await question.delete() + try: + await question.delete() + except (discord.NotFound, UnboundLocalError): + pass + if this_play.on_first == trail_runner: + this_play.on_first_final += 1 + elif this_play.batter == trail_runner: + this_play.batter_final += 1 + else: + log_exception(LineupsMissingException, f'Could not find trail runner to advance') # Throw is going to trail runner else: - play_at_trail = True - await question.delete() + try: + await question.delete() + except (discord.NotFound, UnboundLocalError): + pass - view = Confirm(responders=[interaction.user], timeout=60, label_type='yes') - question = await interaction.channel.send( - content=f'Was {trail_runner.player.name} thrown out {AT_BASE[trail_base]}?', view=view + runner_thrown_out = await ask_confirm( + interaction=interaction, + question='Was **{trail_runner.player.name}** thrown out {AT_BASE[trail_base]}?', + label_type='yes' ) - await view.wait() # Trail runner is thrown out - if view.value: + if runner_thrown_out: # Log out on play this_play.outs += 1 @@ -755,8 +768,6 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact else: this_play.batter_final = None - await question.delete() - # Advance lead runner extra base if this_play.on_second == lead_runner: this_play.rbi += 1 @@ -770,48 +781,142 @@ async def check_uncapped_advance(session: Session, interaction: discord.Interact log_run_scored(session, lead_runner, this_play) return this_play - - # Ball is going to lead base, advance trail runner - if this_play.on_first == trail_runner: - this_play.on_first_final += 1 - elif this_play.batter == trail_runner: - this_play.batter_final += 1 - - else: - log_exception(LineupsMissingException, f'Could not find trail runner to advance') - # Ball is going to lead base, ask if safe - Confirm(responders=[interaction.user], timeout=60, label_type='yes') - question = await interaction.channel.send( - content=f'Was {lead_runner.player.name} thrown out {AT_BASE[lead_base]}?', view=view + runner_thrown_out = await ask_confirm( + interaction=interaction, + question=f'Was **{lead_runner.player.name}** thrown out {AT_BASE[lead_base]}?', + label_type='yes' ) - await view.wait() # Lead runner is thrown out - if view.value: - await question.delete() - runner_out = True + if runner_thrown_out: + logging.info(f'Lead runner is thrown out.') this_play.outs += 1 # Lead runner is safe else: - runner_out = False + logging.info(f'Lead runner is safe.') if this_play.on_second == lead_runner: - this_play.on_second_final = None if runner_out else lead_base + logging.info(f'setting lead runner on_second_final') + this_play.on_second_final = None if runner_thrown_out else lead_base elif this_play.on_first == lead_runner: - this_play.on_first_final = None if runner_out else lead_base + logging.info(f'setting lead runner on_first') + this_play.on_first_final = None if runner_thrown_out else lead_base else: log_exception(LineupsMissingException, f'Could not find lead runner to set final destination') # Human lead runner is not advancing else: - await question.delete() return this_play elif this_play.ai_is_batting: - pass + run_resp = this_play.managerai.uncapped_advance(session, this_game, lead_base, trail_base) + + is_lead_running = await ask_confirm( + interaction=interaction, + question=f'**{lead_runner.player.name}** will advance {TO_BASE[lead_base]} if the safe range is {run_resp.min_safe} or higher.\n\nIs **{lead_runner.player.name}** attempting to advance?', + label_type='yes' + ) + + if not is_lead_running: + return this_play + + is_defense_throwing = await ask_confirm( + interaction=interaction, + question=f'Is the defense throwing {TO_BASE[lead_base]} for {lead_runner.player.name}?', + label_type='yes' + ) + + # Human defense is throwing for lead runner + if not is_defense_throwing: + if this_play.on_second == lead_runner: + this_play.rbi += 1 + this_play.on_second_final = 4 + log_run_scored(session, lead_runner, this_play) + + elif this_play.on_first == lead_runner: + this_play.on_first_final = 3 + + return this_play + + # Human throw is not being cut off + if run_resp.send_trail: + await interaction.channel.send( + f'**{trail_runner.player.name}** is advancing {TO_BASE[trail_base]} as the trail runner!', + ) + is_throwing_lead = await ask_confirm( + interaction=interaction, + question=f'Is the throw going {TO_BASE[lead_base]} or {TO_BASE[trail_base]}?', + label_type='yes', + custom_confirm_label='Home Plate' if lead_base == 4 else 'Third Base', + custom_cancel_label='Third Base' if trail_base == 3 else 'Second Base' + ) + + # Trail runner advances, throwing for lead runner + if is_throwing_lead: + if this_play.on_first == trail_runner: + this_play.on_first_final += 1 + elif this_play.batter == trail_runner: + this_play.batter_final += 1 + else: + log_exception(LineupsMissingException, f'Could not find trail runner to advance') + + # Throw is going to trail runner + else: + is_trail_out = await ask_confirm( + interaction=interaction, + question=f'Was **{trail_runner.player.name}** thrown out {AT_BASE[trail_base]}?', + label_type='yes' + ) + + if is_trail_out: + # Log out on play + this_play.outs += 1 + + # Remove trail runner + if this_play.on_first == trail_runner: + this_play.on_first_final = None + else: + this_play.batter_final = None + + # Advance lead runner extra base + if this_play.on_second == lead_runner: + this_play.rbi += 1 + this_play.on_second_final = 4 + log_run_scored(session, lead_runner, this_play) + + elif this_play.on_first == lead_runner: + this_play.on_first_final += 1 + if this_play.on_first_final > 3: + this_play.rbi += 1 + log_run_scored(session, lead_runner, this_play) + + return this_play + + # Ball is going to lead base, ask if safe + is_lead_out = await ask_confirm( + interaction=interaction, + question=f'Was **{lead_runner.player.name}** thrown out {AT_BASE[lead_base]}?', + label_type='yes', + ) + + # Lead runner is thrown out + if is_lead_out: + logging.info(f'Lead runner is thrown out.') + this_play.outs += 1 + + if this_play.on_second == lead_runner: + logging.info(f'setting lead runner on_second_final') + this_play.on_second_final = None if is_lead_out else lead_base + elif this_play.on_first == lead_runner: + logging.info(f'setting lead runner on_first') + this_play.on_first_final = None if is_lead_out else lead_base + else: + log_exception(LineupsMissingException, f'Could not find lead runner to set final destination') + + return this_play async def singles(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, single_type: Literal['*', '**', 'ballpark', 'uncapped']) -> Play: @@ -828,15 +933,15 @@ async def singles(session: Session, interaction: discord.Interaction, this_game: this_play.bp1b = 1 if single_type == 'ballpark' else 0 elif single_type == 'uncapped': - advance_runners(this_play.id, 1) + advance_runners(session, this_play, 1) if this_play.on_base_code in [1, 2, 4, 5, 6, 7]: if this_play.on_second: - lead_runner = this_play.on_second.player + lead_runner = this_play.on_second lead_base = 4 if this_play.on_first: - trail_runner = this_play.on_first.player + trail_runner = this_play.on_first trail_base = 3 else: @@ -856,4 +961,29 @@ async def singles(session: Session, interaction: discord.Interaction, this_game: session.refresh(this_play) return this_play + + +async def doubles(session: Session, interaction: discord.Interaction, this_game: Game, this_play: Play, double_type: Literal['**', '***', 'uncapped']) -> Play: + """ + Commits this_play + """ + this_play.hit, this_play.double, this_play.batter_final = 1, 1, 2 + + if double_type == '**': + this_play = advance_runners(session, this_play, num_bases=2) + + elif double_type == '***': + this_play = advance_runners(session, this_play, num_bases=3) + + elif double_type == 'uncapped': + 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) + + session.add(this_play) + session.commit() + + session.refresh(this_play) + return this_play \ No newline at end of file diff --git a/in_game/gameplay_models.py b/in_game/gameplay_models.py index 6d66d87..40cf38d 100644 --- a/in_game/gameplay_models.py +++ b/in_game/gameplay_models.py @@ -10,7 +10,7 @@ from sqlalchemy import func, desc from api_calls import db_get, db_post from exceptions import * -from in_game.managerai_responses import JumpResponse, TagResponse, ThrowResponse +from in_game.managerai_responses import JumpResponse, TagResponse, ThrowResponse, UncappedRunResponse sqlite_url = 'sqlite:///storage/gameplay.db' @@ -460,6 +460,53 @@ class ManagerAi(ManagerAiBase, table=True): return this_resp + def uncapped_advance(self, session: Session, this_game: Game, lead_base: int, trail_base: int) -> UncappedRunResponse: + this_resp = UncappedRunResponse() + this_play = this_game.current_play_or_none(session) + if this_play is None: + raise KeyError(f'No game found while checking uncapped_advance_lead') + + ai_rd = this_play.ai_run_diff() + aggression = self.ahead_aggression - 5 if ai_rd > 0 else self.behind_aggression - 5 + + if ai_rd > 4: + if lead_base == 4: + this_resp.min_safe = 16 - this_play.starting_outs - aggression + this_resp.send_trail = True + this_resp.trail_min_safe = 10 - aggression - this_play.starting_outs - this_play.outs + elif lead_base == 3: + this_resp.min_safe = 14 + (this_play.starting_outs * 2) - aggression + if this_play.starting_outs + this_play.outs >= 2: + this_resp.send_trail = False + elif ai_rd > 1 or ai_rd < -2: + if lead_base == 4: + this_resp.min_safe = 12 - this_play.starting_outs - aggression + this_resp.send_trail = True + this_resp.trail_min_safe = 10 - aggression - this_play.starting_outs - this_play.outs + elif lead_base == 3: + this_resp.min_safe = 12 + (this_play.starting_outs * 2) - (aggression * 2) + if this_play.starting_outs + this_play.outs >= 2: + this_resp.send_trail = False + else: + if lead_base == 4: + this_resp.min_safe = 10 - this_play.starting_outs - aggression + this_resp.send_trail = True + this_resp.trail_min_safe = 2 + elif lead_base == 3: + this_resp.min_safe = 14 + (this_play.starting_outs * 2) - aggression + if this_play.starting_outs + this_play.outs >= 2: + this_resp.send_trail = False + + if this_resp.min_safe > 20: + this_resp.min_safe = 20 + if this_resp.min_safe < 1: + this_resp.min_safe = 1 + if this_resp.trail_min_safe > 20: + this_resp.min_safe = 20 + if this_resp.trail_min_safe < 1: + this_resp.min_safe = 1 + + return this_resp class CardsetBase(SQLModel): @@ -618,8 +665,8 @@ class PlayBase(SQLModel): on_third_final: int | None = Field(default=None) # None = out, 1-4 = base batter_final: int | None = Field(default=None) # None = out, 1-4 = base - pa: int = Field(default=0, ge=0, le=1) - ab: int = Field(default=0, ge=0, le=1) + pa: int = Field(default=1, ge=0, le=1) + ab: int = Field(default=1, ge=0, le=1) run: int = Field(default=0, ge=0, le=1) e_run: int = Field(default=0, ge=0, le=1) hit: int = Field(default=0, ge=0, le=1) diff --git a/in_game/managerai_responses.py b/in_game/managerai_responses.py index 1898180..04c26ed 100644 --- a/in_game/managerai_responses.py +++ b/in_game/managerai_responses.py @@ -14,6 +14,12 @@ class TagResponse(RunResponse): pass +class UncappedRunResponse(RunResponse): + send_trail: bool = False + trail_min_safe: int = 10 + trail_min_safe_delta: int = 0 + + class ThrowResponse(pydantic.BaseModel): cutoff: bool = False # Stops on True at_lead_runner: bool = True diff --git a/tests/gameplay_models/test_play_model.py b/tests/gameplay_models/test_play_model.py index e7ebe45..c9fd00d 100644 --- a/tests/gameplay_models/test_play_model.py +++ b/tests/gameplay_models/test_play_model.py @@ -1,6 +1,8 @@ import pytest from sqlmodel import Session, select, func +from command_logic.logic_gameplay import complete_play, singles +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 from tests.factory import session_fixture @@ -18,7 +20,7 @@ def test_create_play(session: Session): assert play_1.pitcher_id == 20 -def test_get_current_play(session: Session): +def test_get_current_play(session: Session): game_1 = session.get(Game, 1) curr_play = game_1.current_play_or_none(session) @@ -75,5 +77,3 @@ def test_query_scalars(session: Session): assert outs == 1 - -# TODO: test get_ai_note \ No newline at end of file diff --git a/utilities/buttons.py b/utilities/buttons.py index 0801ddc..f57a61e 100644 --- a/utilities/buttons.py +++ b/utilities/buttons.py @@ -109,3 +109,27 @@ class ButtonOptions(discord.ui.View): self.value = self.options[4] self.clear_items() self.stop() + + +async def ask_confirm(interaction: discord.Interaction, question: str, label_type: Literal['yes', 'confirm'] = 'confirm', timeout: int = 60, delete_question: bool = True, custom_confirm_label: str = None, custom_cancel_label: str = None) -> bool: + """ + button_callbacks: keys are button values, values are async functions + """ + view = Confirm(responders=[interaction.user], timeout=timeout, label_type=label_type) + if custom_confirm_label: + view.confirm.label = custom_confirm_label + if custom_cancel_label: + view.cancel.label = custom_cancel_label + + question = await interaction.channel.send(question, view=view) + await view.wait() + + if view.value: + if delete_question: + await question.delete() + return True + + else: + if delete_question: + await question.delete() + return False