diff --git a/cogs/gameplay.py b/cogs/gameplay.py index 752a560..224bb2f 100644 --- a/cogs/gameplay.py +++ b/cogs/gameplay.py @@ -15,7 +15,7 @@ from sqlmodel import func, or_ from api_calls import db_get from command_logic.logic_gameplay import bunts, chaos, complete_game, defender_dropdown_view, doubles, flyballs, frame_checks, get_full_roster_from_sheets, checks_log_interaction, complete_play, get_scorebug_embed, groundballs, hit_by_pitch, homeruns, is_game_over, lineouts, manual_end_game, new_game_checks, new_game_conflicts, popouts, read_lineup, relief_pitcher_dropdown_view, select_ai_reliever, show_defense_cards, singles, starting_pitcher_dropdown_view, steals, strikeouts, sub_batter_dropdown_view, substitute_player, triples, undo_play, update_game_settings, walks, xchecks, activate_last_play -from play_lock import release_play_lock, safe_play_lock +from command_logic.play_context import locked_play from dice import ab_roll from exceptions import * import gauntlets @@ -1180,10 +1180,7 @@ class Gameplay(commands.Cog): @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def end_game_command(self, interaction: discord.Interaction): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='end-game') - - with safe_play_lock(session, this_play): - # await interaction.edit_original_response(content='Let\'s see, I didn\'t think this game was over...') + async with locked_play(session, interaction, 'end-game') as (this_game, owner_team, this_play): await manual_end_game(session, interaction, this_game, current_play=this_play) group_substitution = app_commands.Group(name='substitute', description='Make a substitution in active game') @@ -1268,200 +1265,185 @@ 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='log flyball') + async with locked_play(session, interaction, 'log flyball') as (this_game, owner_team, this_play): + logger.info(f'log flyball {flyball_type} - this_play: {this_play}') + this_play = await flyballs(session, interaction, this_play, flyball_type) - logger.info(f'log flyball {flyball_type} - this_play: {this_play}') - this_play = await flyballs(session, interaction, this_play, flyball_type) - - await self.complete_and_post_play( - session, - interaction, - this_play, - buffer_message='Flyball logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type in ['b', 'ballpark']) or (this_play.on_third and flyball_type == 'b?')) else None - ) + await self.complete_and_post_play( + session, + interaction, + this_play, + buffer_message='Flyball logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type in ['b', 'ballpark']) or (this_play.on_third and flyball_type == 'b?')) else None + ) @group_log.command(name='frame-pitch', description=f'Walk/strikeout split; determined by home plate umpire') async def log_frame_check(self, interaction: discord.Interaction): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log frame-check') + async with locked_play(session, interaction, 'log frame-check') as (this_game, owner_team, this_play): + logger.info(f'log frame-check - this_play: {this_play}') + this_play = await frame_checks(session, interaction, this_play) - logger.info(f'log frame-check - this_play: {this_play}') - this_play = await frame_checks(session, interaction, this_play) - - await self.complete_and_post_play( - session, - interaction, - this_play, - buffer_message='Frame check logged' - ) + await self.complete_and_post_play( + session, + interaction, + this_play, + buffer_message='Frame check logged' + ) @group_log.command(name='lineout', description='Lineouts: one out, ballpark, max outs') async def log_lineout(self, interaction: discord.Interaction, lineout_type: Literal['one-out', 'ballpark', 'max-outs']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log lineout') + async with locked_play(session, interaction, 'log lineout') as (this_game, owner_team, this_play): + logger.info(f'log lineout - this_play: {this_play}') + this_play = await lineouts(session, interaction, this_play, lineout_type) - logger.info(f'log lineout - this_play: {this_play}') - this_play = await lineouts(session, interaction, this_play, lineout_type) - - await self.complete_and_post_play(session, interaction, this_play, buffer_message='Lineout logged' if this_play.on_base_code > 3 else None) + await self.complete_and_post_play(session, interaction, this_play, buffer_message='Lineout logged' if this_play.on_base_code > 3 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='log single') + async with locked_play(session, interaction, 'log single') as (this_game, owner_team, this_play): + logger.info(f'log single {single_type} - this_play: {this_play}') + this_play = await singles(session, interaction, this_play, single_type) - logger.info(f'log single {single_type} - this_play: {this_play}') - this_play = await singles(session, interaction, this_play, single_type) - - await self.complete_and_post_play(session, interaction, this_play, buffer_message='Single logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None) + await self.complete_and_post_play(session, interaction, this_play, buffer_message='Single logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None) @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='log double') + async with locked_play(session, interaction, 'log double') as (this_game, owner_team, this_play): + logger.info(f'log double {double_type} - this_play: {this_play}') + this_play = await doubles(session, interaction, this_play, double_type) - logger.info(f'log double {double_type} - this_play: {this_play}') - this_play = await doubles(session, interaction, this_play, double_type) - - 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) + 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') + async with locked_play(session, interaction, 'log triple') as (this_game, owner_team, this_play): + logger.info(f'log triple - this_play: {this_play}') + this_play = await triples(session, interaction, this_play) - logger.info(f'log triple - this_play: {this_play}') - this_play = await triples(session, interaction, this_play) - - await self.complete_and_post_play(session, interaction, 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') + async with locked_play(session, interaction, 'log homerun') as (this_game, owner_team, this_play): + logger.info(f'log homerun {homerun_type} - this_play: {this_play}') + this_play = await homeruns(session, interaction, this_play, homerun_type) - logger.info(f'log homerun {homerun_type} - this_play: {this_play}') - this_play = await homeruns(session, interaction, this_play, homerun_type) - - await self.complete_and_post_play(session, interaction, 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') + async with locked_play(session, interaction, 'log walk') as (this_game, owner_team, this_play): + logger.info(f'log walk {walk_type} - this_play: {this_play}') + this_play = await walks(session, interaction, this_play, walk_type) - logger.info(f'log walk {walk_type} - this_play: {this_play}') - this_play = await walks(session, interaction, this_play, walk_type) - - await self.complete_and_post_play(session, interaction, 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') + async with locked_play(session, interaction, 'log strikeout') as (this_game, owner_team, this_play): + logger.info(f'log strikeout - this_play: {this_play}') + this_play = await strikeouts(session, interaction, this_play) - logger.info(f'log strikeout - this_play: {this_play}') - this_play = await strikeouts(session, interaction, this_play) - - await self.complete_and_post_play(session, interaction, 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') + async with locked_play(session, interaction, 'log popout') as (this_game, owner_team, this_play): + logger.info(f'log popout - this_play: {this_play}') + this_play = await popouts(session, interaction, this_play) - logger.info(f'log popout - this_play: {this_play}') - this_play = await popouts(session, interaction, this_play) - - await self.complete_and_post_play(session, interaction, this_play) + await self.complete_and_post_play(session, interaction, this_play) @group_log.command(name='groundball', description='Groundballs: a, b, c') async def log_groundball(self, interaction: discord.Interaction, groundball_type: Literal['a', 'b', 'c']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name=f'log groundball {groundball_type}') + async with locked_play(session, interaction, f'log groundball {groundball_type}') as (this_game, owner_team, this_play): + logger.info(f'log groundball {groundball_type} - this_play: {this_play}') + this_play = await groundballs(session, interaction, this_play, groundball_type) - logger.info(f'log groundball {groundball_type} - this_play: {this_play}') - this_play = await groundballs(session, interaction, this_play, groundball_type) - - await self.complete_and_post_play(session, interaction, 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') + async with locked_play(session, interaction, 'log hit-by-pitch') as (this_game, owner_team, this_play): + logger.info(f'log hit-by-pitch - this_play: {this_play}') + this_play = await hit_by_pitch(session, interaction, this_play) - logger.info(f'log hit-by-pitch - this_play: {this_play}') - this_play = await hit_by_pitch(session, interaction, this_play) - - await self.complete_and_post_play(session, interaction, this_play) + await self.complete_and_post_play(session, interaction, this_play) @group_log.command(name='chaos', description='Chaos: wild-pitch, passed-ball, balk, pickoff') async def log_chaos(self, interaction: discord.Interaction, chaos_type: Literal['wild-pitch', 'passed-ball', 'balk', 'pickoff']): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log hit-by-pitch') + async with locked_play(session, interaction, 'log chaos') as (this_game, owner_team, this_play): + if this_play.on_base_code == 0: + await interaction.edit_original_response( + content=f'There cannot be chaos when the bases are empty.' + ) + return - if this_play.on_base_code == 0: - await interaction.edit_original_response( - content=f'There cannot be chaos when the bases are empty.' - ) - return - - logger.info(f'log chaos - this_play: {this_play}') - this_play = await chaos(session, interaction, this_play, chaos_type) + logger.info(f'log chaos - this_play: {this_play}') + this_play = await chaos(session, interaction, this_play, chaos_type) - await self.complete_and_post_play(session, interaction, this_play) + await self.complete_and_post_play(session, interaction, this_play) @group_log.command(name='bunt', description='Bunts: sacrifice, bad, popout, double-play, defense') 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') + async with locked_play(session, interaction, 'log bunt') as (this_game, owner_team, this_play): + if this_play.on_base_code == 0: + await interaction.edit_original_response( + content=f'You cannot bunt when the bases are empty.' + ) + return + elif this_play.starting_outs == 2: + await interaction.edit_original_response( + content=f'You cannot bunt with two outs.' + ) + return - if this_play.on_base_code == 0: - await interaction.edit_original_response( - content=f'You cannot bunt when the bases are empty.' - ) - return - elif this_play.starting_outs == 2: - await interaction.edit_original_response( - content=f'You cannot bunt with two outs.' - ) - return - - logger.info(f'log bunt - this_play: {this_play}') - this_play = await bunts(session, interaction, this_play, bunt_type) + logger.info(f'log bunt - this_play: {this_play}') + this_play = await bunts(session, interaction, this_play, bunt_type) - await self.complete_and_post_play(session, interaction, this_play) + await self.complete_and_post_play(session, interaction, this_play) @group_log.command(name='stealing', description='Running: stolen-base, caught-stealing') @app_commands.describe(to_base='Base the runner is advancing to; 2 for 2nd, 3 for 3rd, 4 for Home') async def log_stealing(self, interaction: discord.Interaction, running_type: Literal['stolen-base', 'caught-stealing', 'steal-plus-overthrow'], to_base: Literal[2, 3, 4]): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log stealing') + async with locked_play(session, interaction, 'log stealing') as (this_game, owner_team, this_play): + if (to_base == 2 and this_play.on_first is None) or (to_base == 3 and this_play.on_second is None) or (to_base == 4 and this_play.on_third is None): + logger.info(f'Illegal steal attempt') + await interaction.edit_original_response( + content=f'I don\'t see a runner there.' + ) + return - if (to_base == 2 and this_play.on_first is None) or (to_base == 3 and this_play.on_second is None) or (to_base == 4 and this_play.on_third is None): - logger.info(f'Illegal steal attempt') - await interaction.edit_original_response( - content=f'I don\'t see a runner there.' - ) - return - - if (to_base == 3 and this_play.on_third is not None) or (to_base == 2 and this_play.on_second is not None): - logger.info(f'Stealing runner is blocked') - if to_base == 3: - content = f'{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}' - else: - content = f'{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}' + if (to_base == 3 and this_play.on_third is not None) or (to_base == 2 and this_play.on_second is not None): + logger.info(f'Stealing runner is blocked') + if to_base == 3: + content = f'{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}' + else: + content = f'{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}' - await interaction.edit_original_response( - content=content - ) - return + await interaction.edit_original_response( + content=content + ) + return - logger.info(f'log stealing - this_play: {this_play}') - this_play = await steals(session, interaction, this_play, running_type, to_base) + logger.info(f'log stealing - this_play: {this_play}') + this_play = await steals(session, interaction, this_play, running_type, to_base) - await self.complete_and_post_play(session, interaction, this_play) + await self.complete_and_post_play(session, interaction, this_play) @group_log.command(name='xcheck', description='Defender makes an x-check') @app_commands.choices(position=[ @@ -1477,23 +1459,23 @@ class Gameplay(commands.Cog): ]) async def log_xcheck_command(self, interaction: discord.Interaction, position: Choice[str]): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log xcheck') + async with locked_play(session, interaction, 'log xcheck') as (this_game, owner_team, this_play): + logger.info(f'log xcheck - this_play: {this_play}') + this_play = await xchecks(session, interaction, this_play, position.value) - logger.info(f'log xcheck - this_play: {this_play}') - this_play = await xchecks(session, interaction, this_play, position.value) - - await self.complete_and_post_play(session, interaction, this_play, buffer_message='X-Check logged') + await self.complete_and_post_play(session, interaction, this_play, buffer_message='X-Check logged') @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') + async with locked_play(session, interaction, 'log undo-play') as (this_game, owner_team, this_play): + logger.info(f'log undo-play - this_play: {this_play}') + original_play = this_play + this_play = undo_play(session, this_play) + original_play.locked = False # prevent finally from committing deleted row - logger.info(f'log undo-play - this_play: {this_play}') - this_play = undo_play(session, this_play) - - await self.post_play(session, interaction, 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') diff --git a/command_logic/__init__.py b/command_logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/command_logic/play_context.py b/command_logic/play_context.py new file mode 100644 index 0000000..a2e1976 --- /dev/null +++ b/command_logic/play_context.py @@ -0,0 +1,64 @@ +""" +Async context manager for play locking. Wraps checks_log_interaction() +and guarantees lock release on exception, early return, or normal exit. + +This module sits above play_lock and logic_gameplay to avoid circular imports. +""" +import logging +from contextlib import asynccontextmanager +from sqlmodel import Session + +from play_lock import release_play_lock +from command_logic.logic_gameplay import checks_log_interaction + +logger = logging.getLogger("discord_app") + + +@asynccontextmanager +async def locked_play(session: Session, interaction, command_name: str): + """ + Async context manager that acquires a play lock via checks_log_interaction + and guarantees release when the block exits. + + Usage: + with Session(engine) as session: + async with locked_play(session, interaction, 'log flyball') as (game, team, play): + play = await flyballs(session, interaction, play, flyball_type) + await self.complete_and_post_play(session, interaction, play, ...) + + Behavior: + - If checks_log_interaction raises (PlayLockedException, etc.), no lock + was acquired and the exception propagates naturally. + - If complete_play() was called, play.complete=True, so finally is a no-op. + - If the block raises an exception, the lock is released and the exception re-raised. + - If the block returns early (e.g. validation failure), the lock is released. + """ + # If this raises, no lock was acquired - exception propagates naturally + this_game, owner_team, this_play = await checks_log_interaction( + session, interaction, command_name=command_name, lock_play=True + ) + + try: + yield this_game, owner_team, this_play + except Exception as e: + if this_play.locked and not this_play.complete: + logger.error( + f"Exception in '{command_name}' on play {this_play.id}, releasing lock: {e}" + ) + try: + release_play_lock(session, this_play) + except Exception as release_err: + logger.error(f"Failed to release lock on play {this_play.id}: {release_err}") + raise + finally: + # Catches early returns (the primary bug fix) + # No-op if complete_play() already set complete=True and locked=False + if this_play.locked and not this_play.complete: + logger.warning( + f"Play {this_play.id} still locked after '{command_name}', " + f"releasing (likely early return)" + ) + try: + release_play_lock(session, this_play) + except Exception as release_err: + logger.error(f"Failed to force release lock on play {this_play.id}: {release_err}") diff --git a/tests/test_play_locking.py b/tests/test_play_locking.py index 48e1dda..a0bb5cf 100644 --- a/tests/test_play_locking.py +++ b/tests/test_play_locking.py @@ -11,6 +11,7 @@ Tests the play locking system including: import pytest from unittest.mock import MagicMock, AsyncMock, patch from command_logic.logic_gameplay import checks_log_interaction, release_play_lock, safe_play_lock +from command_logic.play_context import locked_play from exceptions import PlayLockedException @@ -315,3 +316,164 @@ def test_safe_play_lock_allows_normal_completion(mock_session): pass # Completed play should not be unlocked by context manager + + +# --- locked_play context manager tests --- + + +@pytest.mark.asyncio +async def test_locked_play_releases_on_exception(mock_session, mock_interaction, mock_game, mock_team): + """ + Verify locked_play releases the lock when an exception is raised inside the block. + + This ensures that if command processing throws (e.g. a dice roll function errors), + the play lock is released so the game isn't permanently stuck. + """ + mock_play = MagicMock() + mock_play.id = 700 + mock_play.locked = True + mock_play.complete = False + + def fake_release(session, play): + play.locked = False + + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + return_value=(mock_game, mock_team, mock_play), + ): + with patch("command_logic.play_context.release_play_lock", side_effect=fake_release) as mock_release: + with pytest.raises(ValueError): + async with locked_play(mock_session, mock_interaction, "log test") as (g, t, p): + raise ValueError("processing error") + + # Called once in except block; finally sees locked=False and skips + mock_release.assert_called_once_with(mock_session, mock_play) + + +@pytest.mark.asyncio +async def test_locked_play_releases_on_early_return(mock_session, mock_interaction, mock_game, mock_team): + """ + Verify locked_play releases the lock when the block exits normally without + calling complete_play (i.e. an early return from validation). + + This is the primary bug fix — commands like log_chaos, log_sac_bunt, and + log_stealing had early returns after lock acquisition that left the lock stuck. + """ + mock_play = MagicMock() + mock_play.id = 701 + mock_play.locked = True + mock_play.complete = False + + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + return_value=(mock_game, mock_team, mock_play), + ): + with patch("command_logic.play_context.release_play_lock") as mock_release: + async with locked_play(mock_session, mock_interaction, "log bunt") as (g, t, p): + # Simulate early return: block exits without calling complete_play + pass + + # Lock should be released because play.locked=True and play.complete=False + mock_release.assert_called_once_with(mock_session, mock_play) + + +@pytest.mark.asyncio +async def test_locked_play_skips_release_when_complete(mock_session, mock_interaction, mock_game, mock_team): + """ + Verify locked_play does NOT attempt to release the lock when complete_play() + has already run (play.complete=True, play.locked=False). + + This is the normal success path — complete_play handles the lock release, + so the context manager should be a no-op. + """ + mock_play = MagicMock() + mock_play.id = 702 + mock_play.locked = True + mock_play.complete = False + + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + return_value=(mock_game, mock_team, mock_play), + ): + with patch("command_logic.play_context.release_play_lock") as mock_release: + async with locked_play(mock_session, mock_interaction, "log single") as (g, t, p): + # Simulate complete_play() having been called + mock_play.locked = False + mock_play.complete = True + + # Should NOT attempt release since play is already complete + mock_release.assert_not_called() + + +@pytest.mark.asyncio +async def test_locked_play_propagates_checks_exception(mock_session, mock_interaction): + """ + Verify that when checks_log_interaction raises PlayLockedException (because + the play is already locked by another command), the exception propagates + without any cleanup attempt — the lock was never ours. + """ + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + side_effect=PlayLockedException("Play is already being processed. Please wait."), + ): + with patch("command_logic.play_context.release_play_lock") as mock_release: + with pytest.raises(PlayLockedException): + async with locked_play(mock_session, mock_interaction, "log walk") as (g, t, p): + pass # Should never reach here + + # No release attempted — lock was never acquired + mock_release.assert_not_called() + + +@pytest.mark.asyncio +async def test_locked_play_reraises_original_exception(mock_session, mock_interaction, mock_game, mock_team): + """ + Verify that the original exception is preserved and re-raised after + the lock is released. The caller (global error handler) should see + the real error, not a lock-release error. + """ + mock_play = MagicMock() + mock_play.id = 704 + mock_play.locked = True + mock_play.complete = False + + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + return_value=(mock_game, mock_team, mock_play), + ): + with patch("command_logic.play_context.release_play_lock"): + with pytest.raises(RuntimeError, match="dice roll failed"): + async with locked_play(mock_session, mock_interaction, "log xcheck") as (g, t, p): + raise RuntimeError("dice roll failed") + + +@pytest.mark.asyncio +async def test_locked_play_handles_release_failure(mock_session, mock_interaction, mock_game, mock_team): + """ + Verify that if release_play_lock itself fails (e.g. DB connection lost), + the original exception is still propagated rather than being masked by + the release failure. + """ + mock_play = MagicMock() + mock_play.id = 705 + mock_play.locked = True + mock_play.complete = False + + with patch( + "command_logic.play_context.checks_log_interaction", + new_callable=AsyncMock, + return_value=(mock_game, mock_team, mock_play), + ): + with patch( + "command_logic.play_context.release_play_lock", + side_effect=Exception("DB connection lost"), + ): + # The original ValueError should propagate, not the release failure + with pytest.raises(ValueError, match="original error"): + async with locked_play(mock_session, mock_interaction, "log chaos") as (g, t, p): + raise ValueError("original error")