Merge pull request 'fix/position-validation-lineup' (#10) from fix/position-validation-lineup into main
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
Reviewed-on: #10
This commit is contained in:
commit
df97b76294
244
cogs/gameplay.py
244
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')
|
||||
|
||||
|
||||
0
command_logic/__init__.py
Normal file
0
command_logic/__init__.py
Normal file
64
command_logic/play_context.py
Normal file
64
command_logic/play_context.py
Normal file
@ -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}")
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user