feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8
@ -27,7 +27,12 @@ from app.core.state_manager import state_manager
|
||||
from app.core.validators import ValidationError, game_validator
|
||||
from app.database.operations import DatabaseOperations
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.models.game_models import DefensiveDecision, GameState, OffensiveDecision
|
||||
from app.models.game_models import (
|
||||
DefensiveDecision,
|
||||
GameState,
|
||||
OffensiveDecision,
|
||||
PendingXCheck,
|
||||
)
|
||||
from app.services import PlayStatCalculator
|
||||
from app.services.lineup_service import lineup_service
|
||||
from app.services.position_rating_service import position_rating_service
|
||||
@ -751,6 +756,28 @@ class GameEngine:
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
|
||||
# Check for X_CHECK outcome - route to interactive workflow
|
||||
if outcome == PlayOutcome.X_CHECK:
|
||||
if not hit_location:
|
||||
raise ValueError("X_CHECK outcome requires hit_location (position)")
|
||||
# Initiate interactive x-check workflow
|
||||
await self.initiate_x_check(game_id, hit_location, ab_roll)
|
||||
# Return a placeholder result - actual resolution happens when player selects
|
||||
return PlayResult(
|
||||
outcome=PlayOutcome.X_CHECK,
|
||||
outs_recorded=0,
|
||||
runs_scored=0,
|
||||
batter_result=None,
|
||||
runners_advanced=[],
|
||||
description=f"X-Check initiated at {hit_location}",
|
||||
ab_roll=ab_roll,
|
||||
hit_location=hit_location,
|
||||
is_hit=False,
|
||||
is_out=False,
|
||||
is_walk=False,
|
||||
x_check_details=None, # Will be populated when resolved
|
||||
)
|
||||
|
||||
# NOTE: Business rule validation (e.g., when hit_location is required based on
|
||||
# game state) is handled in PlayResolver, not here. The transport layer should
|
||||
# not make business logic decisions about contextual requirements.
|
||||
@ -786,6 +813,278 @@ class GameEngine:
|
||||
|
||||
return result
|
||||
|
||||
# ============================================================================
|
||||
# INTERACTIVE X-CHECK WORKFLOW
|
||||
# ============================================================================
|
||||
|
||||
async def initiate_x_check(
|
||||
self,
|
||||
game_id: UUID,
|
||||
position: str,
|
||||
ab_roll: "AbRoll",
|
||||
) -> None:
|
||||
"""
|
||||
Initiate interactive x-check workflow.
|
||||
|
||||
Rolls x-check dice (1d20 + 3d6 + optional SPD d20), looks up the chart row
|
||||
for the d20 result, stores everything in pending_x_check, and emits
|
||||
decision_required to the defensive player.
|
||||
|
||||
Args:
|
||||
game_id: Game ID
|
||||
position: Position being checked (SS, LF, 3B, etc.)
|
||||
ab_roll: The at-bat roll for audit trail
|
||||
|
||||
Raises:
|
||||
ValueError: If game not found or position invalid
|
||||
"""
|
||||
from app.config.common_x_check_tables import (
|
||||
CATCHER_DEFENSE_TABLE,
|
||||
INFIELD_DEFENSE_TABLE,
|
||||
OUTFIELD_DEFENSE_TABLE,
|
||||
)
|
||||
from app.models.game_models import PendingXCheck
|
||||
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
|
||||
# Roll x-check dice
|
||||
fielding_roll = dice_system.roll_fielding(
|
||||
game_id=game_id,
|
||||
team_id=state.get_fielding_team_id(),
|
||||
player_id=None, # Will be set when we know defender
|
||||
position=position,
|
||||
)
|
||||
|
||||
# Determine chart type and table
|
||||
if position in ["P", "C", "1B", "2B", "3B", "SS"]:
|
||||
if position == "C":
|
||||
chart_type = "catcher"
|
||||
table = CATCHER_DEFENSE_TABLE
|
||||
else:
|
||||
chart_type = "infield"
|
||||
table = INFIELD_DEFENSE_TABLE
|
||||
elif position in ["LF", "CF", "RF"]:
|
||||
chart_type = "outfield"
|
||||
table = OUTFIELD_DEFENSE_TABLE
|
||||
else:
|
||||
raise ValueError(f"Invalid position for x-check: {position}")
|
||||
|
||||
# Get chart row for d20 result
|
||||
row_index = fielding_roll.d20 - 1
|
||||
chart_row = table[row_index]
|
||||
|
||||
# Check if SPD is in any column - if so, pre-roll d20
|
||||
spd_d20 = None
|
||||
if "SPD" in chart_row:
|
||||
spd_d20 = dice_system.roll_d20(
|
||||
game_id=game_id,
|
||||
team_id=state.get_fielding_team_id(),
|
||||
player_id=None,
|
||||
)
|
||||
|
||||
# Get defender at this position
|
||||
defender = state.get_defender_for_position(position, state_manager)
|
||||
if not defender:
|
||||
raise ValueError(f"No defender found at position {position}")
|
||||
|
||||
# Create pending x-check state
|
||||
pending = PendingXCheck(
|
||||
position=position,
|
||||
ab_roll_id=ab_roll.roll_id,
|
||||
d20_roll=fielding_roll.d20,
|
||||
d6_individual=[
|
||||
fielding_roll.d6_one,
|
||||
fielding_roll.d6_two,
|
||||
fielding_roll.d6_three,
|
||||
],
|
||||
d6_total=fielding_roll.error_total,
|
||||
chart_row=chart_row,
|
||||
chart_type=chart_type,
|
||||
spd_d20=spd_d20,
|
||||
defender_lineup_id=defender.lineup_id,
|
||||
)
|
||||
|
||||
# Store in state
|
||||
state.pending_x_check = pending
|
||||
state.decision_phase = "awaiting_x_check_result"
|
||||
state.pending_decision = "x_check_result"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
logger.info(
|
||||
f"X-check initiated for game {game_id} at {position}: "
|
||||
f"d20={fielding_roll.d20}, 3d6={fielding_roll.error_total}"
|
||||
)
|
||||
|
||||
# Emit decision_required to ALL players (transparency)
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
decision_type="awaiting_x_check_result",
|
||||
timeout_seconds=self.DECISION_TIMEOUT * 2, # Longer timeout for x-check
|
||||
data={
|
||||
"position": position,
|
||||
"d20_roll": fielding_roll.d20,
|
||||
"d6_total": fielding_roll.error_total,
|
||||
"d6_individual": [
|
||||
fielding_roll.d6_one,
|
||||
fielding_roll.d6_two,
|
||||
fielding_roll.d6_three,
|
||||
],
|
||||
"chart_row": chart_row,
|
||||
"chart_type": chart_type,
|
||||
"spd_d20": spd_d20,
|
||||
"defender_lineup_id": defender.lineup_id,
|
||||
"active_team_id": state.get_fielding_team_id(),
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_x_check_result(
|
||||
self,
|
||||
game_id: UUID,
|
||||
result_code: str,
|
||||
error_result: str,
|
||||
) -> None:
|
||||
"""
|
||||
Submit x-check result selection from defensive player.
|
||||
|
||||
Validates the selection, resolves the play using the selected result,
|
||||
checks for DECIDE situations, and either finalizes the play or enters
|
||||
DECIDE workflow.
|
||||
|
||||
Args:
|
||||
game_id: Game ID
|
||||
result_code: Result code selected by player (G1, G2, SI2, F1, etc.)
|
||||
error_result: Error result selected by player (NO, E1, E2, E3, RP)
|
||||
|
||||
Raises:
|
||||
ValueError: If no pending x-check or invalid inputs
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
if not state.pending_x_check:
|
||||
raise ValueError("No pending x-check to submit result for")
|
||||
|
||||
# Validate result_code is in the chart row
|
||||
if result_code not in state.pending_x_check.chart_row:
|
||||
raise ValueError(
|
||||
f"Invalid result_code '{result_code}' not in chart row: "
|
||||
f"{state.pending_x_check.chart_row}"
|
||||
)
|
||||
|
||||
# Store selections
|
||||
state.pending_x_check.selected_result = result_code
|
||||
state.pending_x_check.error_result = error_result
|
||||
|
||||
# Get the original ab_roll from pending_manual_roll
|
||||
ab_roll = state.pending_manual_roll
|
||||
if not ab_roll:
|
||||
raise ValueError("No pending manual roll found for x-check")
|
||||
|
||||
# Get decisions
|
||||
defensive_decision = DefensiveDecision(
|
||||
**state.decisions_this_play.get("defensive", {})
|
||||
)
|
||||
offensive_decision = OffensiveDecision(
|
||||
**state.decisions_this_play.get("offensive", {})
|
||||
)
|
||||
|
||||
# Resolve using player-provided result
|
||||
resolver = PlayResolver(
|
||||
league_id=state.league_id,
|
||||
auto_mode=False, # Always manual for interactive x-check
|
||||
state_manager=state_manager,
|
||||
)
|
||||
|
||||
result = resolver.resolve_x_check_from_selection(
|
||||
position=state.pending_x_check.position,
|
||||
result_code=result_code,
|
||||
error_result=error_result,
|
||||
state=state,
|
||||
defensive_decision=defensive_decision,
|
||||
ab_roll=ab_roll,
|
||||
)
|
||||
|
||||
# Check if DECIDE situation exists
|
||||
# TODO: Implement DECIDE detection in runner advancement
|
||||
# For now, assume no DECIDE and finalize directly
|
||||
has_decide = False # Placeholder
|
||||
|
||||
if has_decide:
|
||||
# Enter DECIDE workflow
|
||||
# TODO: Set decide_runner_base, decide_target_base
|
||||
# TODO: Set decision_phase = "awaiting_decide_advance"
|
||||
# TODO: Emit decision_required for DECIDE
|
||||
logger.info(f"X-check for game {game_id} entering DECIDE workflow")
|
||||
else:
|
||||
# No DECIDE - finalize the play
|
||||
await self._finalize_x_check(state, state.pending_x_check, result)
|
||||
|
||||
log_suffix = f" (x-check at {state.pending_x_check.position})"
|
||||
await self._finalize_play(state, result, ab_roll, log_suffix)
|
||||
|
||||
# Clear pending x-check
|
||||
state.pending_x_check = None
|
||||
state.pending_decision = None
|
||||
state.decision_phase = "idle"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
logger.info(
|
||||
f"X-check resolved for game {game_id}: {result.description}"
|
||||
)
|
||||
|
||||
async def _finalize_x_check(
|
||||
self,
|
||||
state: GameState,
|
||||
pending: "PendingXCheck",
|
||||
result: PlayResult,
|
||||
) -> None:
|
||||
"""
|
||||
Common finalization path for x-check plays.
|
||||
|
||||
Handles state updates and logging specific to x-check resolution.
|
||||
|
||||
Args:
|
||||
state: Game state
|
||||
pending: Pending x-check data
|
||||
result: Play result
|
||||
"""
|
||||
# Currently just logs - can be extended for x-check-specific finalization
|
||||
logger.debug(
|
||||
f"Finalizing x-check: position={pending.position}, "
|
||||
f"result={pending.selected_result}, error={pending.error_result}"
|
||||
)
|
||||
|
||||
# Placeholder methods for DECIDE workflow (to be implemented in step 9-10)
|
||||
|
||||
async def submit_decide_advance(self, game_id: UUID, advance: bool) -> None:
|
||||
"""Submit offensive player's DECIDE advance decision."""
|
||||
# TODO: Implement in step 10
|
||||
raise NotImplementedError("DECIDE workflow not yet implemented")
|
||||
|
||||
async def submit_decide_throw(
|
||||
self, game_id: UUID, target: str
|
||||
) -> None: # "runner" | "first"
|
||||
"""Submit defensive player's throw target choice."""
|
||||
# TODO: Implement in step 10
|
||||
raise NotImplementedError("DECIDE workflow not yet implemented")
|
||||
|
||||
async def submit_decide_result(
|
||||
self, game_id: UUID, outcome: str
|
||||
) -> None: # "safe" | "out"
|
||||
"""Submit speed check result for DECIDE throw on runner."""
|
||||
# TODO: Implement in step 10
|
||||
raise NotImplementedError("DECIDE workflow not yet implemented")
|
||||
|
||||
def _apply_play_result(self, state: GameState, result: PlayResult) -> None:
|
||||
"""
|
||||
Apply play result to in-memory game state.
|
||||
|
||||
@ -1026,6 +1026,166 @@ class PlayResolver:
|
||||
x_check_details=x_check_details,
|
||||
)
|
||||
|
||||
def resolve_x_check_from_selection(
|
||||
self,
|
||||
position: str,
|
||||
result_code: str,
|
||||
error_result: str,
|
||||
state: GameState,
|
||||
defensive_decision: DefensiveDecision,
|
||||
ab_roll: AbRoll,
|
||||
) -> PlayResult:
|
||||
"""
|
||||
Resolve X-Check play using player-provided result selection.
|
||||
|
||||
This is used for interactive x-check workflow where the defensive player
|
||||
has already seen the dice, chart row, and selected the result code and
|
||||
error result from their physical card.
|
||||
|
||||
Skips:
|
||||
- Dice rolling (already done in initiate_x_check)
|
||||
- Defense table lookup (player selected from chart row)
|
||||
- SPD test (player mentally resolved)
|
||||
- Hash conversion (player mentally resolved)
|
||||
- Error chart lookup (player selected from physical card)
|
||||
|
||||
Performs:
|
||||
- Map result_code + error_result to PlayOutcome
|
||||
- Get runner advancement
|
||||
- Build PlayResult and XCheckResult
|
||||
|
||||
Args:
|
||||
position: Position being checked (SS, LF, 3B, etc.)
|
||||
result_code: Result code selected by player (G1, G2, SI2, F1, DO2, etc.)
|
||||
error_result: Error selected by player (NO, E1, E2, E3, RP)
|
||||
state: Current game state
|
||||
defensive_decision: Defensive positioning
|
||||
ab_roll: Original at-bat roll for audit trail
|
||||
|
||||
Returns:
|
||||
PlayResult with x_check_details populated
|
||||
|
||||
Raises:
|
||||
ValueError: If invalid result_code or error_result
|
||||
"""
|
||||
logger.info(
|
||||
f"Resolving interactive X-Check: position={position}, "
|
||||
f"result={result_code}, error={error_result}"
|
||||
)
|
||||
|
||||
# Validate error_result
|
||||
valid_errors = ["NO", "E1", "E2", "E3", "RP"]
|
||||
if error_result not in valid_errors:
|
||||
raise ValueError(
|
||||
f"Invalid error_result '{error_result}', must be one of {valid_errors}"
|
||||
)
|
||||
|
||||
# Get defender info from state
|
||||
defender = None
|
||||
if self.state_manager:
|
||||
defender = state.get_defender_for_position(position, self.state_manager)
|
||||
|
||||
if defender:
|
||||
defender_id = defender.lineup_id
|
||||
# Get range/error for XCheckResult (informational only)
|
||||
if defender.position_rating:
|
||||
defender_range = defender.position_rating.range
|
||||
defender_error_rating = defender.position_rating.error
|
||||
else:
|
||||
defender_range = 3 # Default
|
||||
defender_error_rating = 15 # Default
|
||||
else:
|
||||
defender_id = 0
|
||||
defender_range = 3
|
||||
defender_error_rating = 15
|
||||
|
||||
# Adjust range for playing in (for XCheckResult display)
|
||||
adjusted_range = self._adjust_range_for_defensive_position(
|
||||
base_range=defender_range,
|
||||
position=position,
|
||||
defensive_decision=defensive_decision,
|
||||
)
|
||||
|
||||
# Determine final outcome from player's selections
|
||||
final_outcome, hit_type = self._determine_final_x_check_outcome(
|
||||
converted_result=result_code, error_result=error_result
|
||||
)
|
||||
|
||||
# Get runner advancement
|
||||
defender_in = adjusted_range > defender_range
|
||||
|
||||
advancement = self._get_x_check_advancement(
|
||||
converted_result=result_code,
|
||||
error_result=error_result,
|
||||
state=state,
|
||||
defender_in=defender_in,
|
||||
hit_location=position,
|
||||
defensive_decision=defensive_decision,
|
||||
)
|
||||
|
||||
# Convert AdvancementResult to RunnerAdvancementData
|
||||
runners_advanced = [
|
||||
RunnerAdvancementData(
|
||||
from_base=movement.from_base,
|
||||
to_base=movement.to_base,
|
||||
lineup_id=movement.lineup_id,
|
||||
is_out=movement.is_out,
|
||||
)
|
||||
for movement in advancement.movements
|
||||
if movement.from_base > 0 # Exclude batter
|
||||
]
|
||||
|
||||
# Extract batter result
|
||||
batter_movement = next(
|
||||
(m for m in advancement.movements if m.from_base == 0), None
|
||||
)
|
||||
batter_result = (
|
||||
batter_movement.to_base
|
||||
if batter_movement and not batter_movement.is_out
|
||||
else None
|
||||
)
|
||||
|
||||
runs_scored = advancement.runs_scored
|
||||
outs_recorded = advancement.outs_recorded
|
||||
|
||||
# Create XCheckResult (using pending_x_check dice values from state)
|
||||
pending = state.pending_x_check
|
||||
if not pending:
|
||||
raise ValueError("No pending_x_check found in state")
|
||||
|
||||
x_check_details = XCheckResult(
|
||||
position=position,
|
||||
d20_roll=pending.d20_roll,
|
||||
d6_roll=pending.d6_total,
|
||||
defender_range=adjusted_range,
|
||||
defender_error_rating=defender_error_rating,
|
||||
defender_id=defender_id,
|
||||
base_result=result_code, # Player's selection is the "final" result
|
||||
converted_result=result_code,
|
||||
error_result=error_result,
|
||||
final_outcome=final_outcome,
|
||||
hit_type=hit_type,
|
||||
spd_test_roll=pending.spd_d20, # May be None
|
||||
spd_test_target=None, # Player resolved mentally
|
||||
spd_test_passed=None, # Player resolved mentally
|
||||
)
|
||||
|
||||
# Create PlayResult
|
||||
return PlayResult(
|
||||
outcome=final_outcome,
|
||||
outs_recorded=outs_recorded,
|
||||
runs_scored=runs_scored,
|
||||
batter_result=batter_result,
|
||||
runners_advanced=runners_advanced,
|
||||
description=f"X-Check {position}: {result_code} + {error_result} = {final_outcome.value}",
|
||||
ab_roll=ab_roll,
|
||||
hit_location=position,
|
||||
is_hit=final_outcome.is_hit(),
|
||||
is_out=final_outcome.is_out(),
|
||||
is_walk=final_outcome.is_walk(),
|
||||
x_check_details=x_check_details,
|
||||
)
|
||||
|
||||
def _adjust_range_for_defensive_position(
|
||||
self, base_range: int, position: str, defensive_decision: DefensiveDecision
|
||||
) -> int:
|
||||
|
||||
@ -349,6 +349,107 @@ class XCheckResult:
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PENDING X-CHECK STATE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PendingXCheck(BaseModel):
|
||||
"""
|
||||
Intermediate state for interactive x-check resolution.
|
||||
|
||||
Stores all x-check workflow data including dice rolls, chart information,
|
||||
and player selections as the defensive player progresses through the
|
||||
interactive workflow.
|
||||
|
||||
Workflow:
|
||||
1. System rolls dice, looks up chart row → stores position, dice, chart
|
||||
2. Defensive player selects result + error → stores selected_result, error_result
|
||||
3. System checks for DECIDE situations → stores decide_* fields if applicable
|
||||
4. Play resolves → PendingXCheck cleared
|
||||
|
||||
Attributes:
|
||||
position: Position being checked (SS, LF, 3B, etc.)
|
||||
ab_roll_id: Reference to the original AbRoll for audit trail
|
||||
d20_roll: 1-20 (chart row selector)
|
||||
d6_individual: [d6_1, d6_2, d6_3] for transparency
|
||||
d6_total: Sum of 3d6 (error chart reference)
|
||||
chart_row: 5 column values for this d20 row
|
||||
chart_type: Type of defense table used
|
||||
spd_d20: Pre-rolled d20 if any column is SPD (click-to-reveal)
|
||||
defender_lineup_id: Player making the defensive play
|
||||
selected_result: Result code chosen by defensive player
|
||||
error_result: Error type chosen by defensive player
|
||||
decide_runner_base: Base the runner is on for DECIDE
|
||||
decide_target_base: Base runner wants to reach for DECIDE
|
||||
decide_advance: Offensive player's DECIDE choice
|
||||
decide_throw: Defensive player's throw target choice
|
||||
decide_d20: Speed check d20 for DECIDE throw on runner
|
||||
"""
|
||||
|
||||
# Initial state (set when x-check initiated)
|
||||
position: str
|
||||
ab_roll_id: str
|
||||
d20_roll: int = Field(ge=1, le=20)
|
||||
d6_individual: list[int] = Field(min_length=3, max_length=3)
|
||||
d6_total: int = Field(ge=3, le=18)
|
||||
chart_row: list[str] = Field(min_length=5, max_length=5)
|
||||
chart_type: str # "infield" | "outfield" | "catcher"
|
||||
spd_d20: int | None = Field(default=None, ge=1, le=20)
|
||||
defender_lineup_id: int
|
||||
|
||||
# Result selection (set after defensive player selects)
|
||||
selected_result: str | None = None
|
||||
error_result: str | None = None
|
||||
|
||||
# DECIDE workflow (set during DECIDE flow)
|
||||
decide_runner_base: int | None = Field(default=None, ge=1, le=3)
|
||||
decide_target_base: int | None = Field(default=None, ge=2, le=4)
|
||||
decide_advance: bool | None = None
|
||||
decide_throw: str | None = None # "runner" | "first"
|
||||
decide_d20: int | None = Field(default=None, ge=1, le=20)
|
||||
|
||||
@field_validator("chart_type")
|
||||
@classmethod
|
||||
def validate_chart_type(cls, v: str) -> str:
|
||||
"""Ensure chart_type is valid"""
|
||||
valid = ["infield", "outfield", "catcher"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"chart_type must be one of {valid}")
|
||||
return v
|
||||
|
||||
@field_validator("d6_individual")
|
||||
@classmethod
|
||||
def validate_d6_individual(cls, v: list[int]) -> list[int]:
|
||||
"""Ensure each d6 is 1-6"""
|
||||
for die in v:
|
||||
if not 1 <= die <= 6:
|
||||
raise ValueError(f"Each d6 must be 1-6, got {die}")
|
||||
return v
|
||||
|
||||
@field_validator("error_result")
|
||||
@classmethod
|
||||
def validate_error_result(cls, v: str | None) -> str | None:
|
||||
"""Ensure error_result is valid"""
|
||||
if v is not None:
|
||||
valid = ["NO", "E1", "E2", "E3", "RP"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"error_result must be one of {valid}")
|
||||
return v
|
||||
|
||||
@field_validator("decide_throw")
|
||||
@classmethod
|
||||
def validate_decide_throw(cls, v: str | None) -> str | None:
|
||||
"""Ensure decide_throw is valid"""
|
||||
if v is not None:
|
||||
valid = ["runner", "first"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"decide_throw must be one of {valid}")
|
||||
return v
|
||||
|
||||
model_config = ConfigDict(frozen=False) # Allow mutation during workflow
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GAME STATE
|
||||
# ============================================================================
|
||||
@ -466,6 +567,9 @@ class GameState(BaseModel):
|
||||
None # AbRoll stored when dice rolled in manual mode
|
||||
)
|
||||
|
||||
# Interactive x-check workflow
|
||||
pending_x_check: PendingXCheck | None = None
|
||||
|
||||
# Play tracking
|
||||
play_count: int = Field(default=0, ge=0)
|
||||
last_play_result: str | None = None
|
||||
@ -501,7 +605,16 @@ class GameState(BaseModel):
|
||||
def validate_pending_decision(cls, v: str | None) -> str | None:
|
||||
"""Ensure pending_decision is valid"""
|
||||
if v is not None:
|
||||
valid = ["defensive", "offensive", "result_selection", "substitution"]
|
||||
valid = [
|
||||
"defensive",
|
||||
"offensive",
|
||||
"result_selection",
|
||||
"substitution",
|
||||
"x_check_result",
|
||||
"decide_advance",
|
||||
"decide_throw",
|
||||
"decide_result",
|
||||
]
|
||||
if v not in valid:
|
||||
raise ValueError(f"pending_decision must be one of {valid}")
|
||||
return v
|
||||
@ -516,6 +629,10 @@ class GameState(BaseModel):
|
||||
"awaiting_offensive",
|
||||
"resolving",
|
||||
"completed",
|
||||
"awaiting_x_check_result",
|
||||
"awaiting_decide_advance",
|
||||
"awaiting_decide_throw",
|
||||
"awaiting_decide_result",
|
||||
]
|
||||
if v not in valid:
|
||||
raise ValueError(f"decision_phase must be one of {valid}")
|
||||
|
||||
@ -1933,3 +1933,145 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid rollback request"}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# INTERACTIVE X-CHECK HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
@sio.event
|
||||
async def submit_x_check_result(sid, data):
|
||||
"""
|
||||
Submit x-check result selection from defensive player.
|
||||
|
||||
After x-check is initiated, the defensive player sees dice + chart row
|
||||
and selects the result code and error result.
|
||||
|
||||
Event data:
|
||||
game_id: UUID of the game
|
||||
result_code: Result code selected (G1, G2, SI2, F1, etc.)
|
||||
error_result: Error selected (NO, E1, E2, E3, RP)
|
||||
|
||||
Emits:
|
||||
play_resolved: Broadcast to game room if no DECIDE
|
||||
decision_required: Broadcast if DECIDE situation
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"},
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
if not game_id_str:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Missing game_id"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
game_id = UUID(game_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid game_id format"}
|
||||
)
|
||||
return
|
||||
|
||||
# Validate inputs
|
||||
result_code = data.get("result_code")
|
||||
error_result = data.get("error_result")
|
||||
|
||||
if not result_code:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Missing result_code"}
|
||||
)
|
||||
return
|
||||
|
||||
if not error_result:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Missing error_result"}
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "decision"):
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{
|
||||
"message": "Too many x-check submissions. Please wait.",
|
||||
"code": "GAME_RATE_LIMITED",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"X-check result submitted for game {game_id}: "
|
||||
f"result={result_code}, error={error_result}"
|
||||
)
|
||||
|
||||
# Process through game engine
|
||||
try:
|
||||
await game_engine.submit_x_check_result(
|
||||
game_id=game_id,
|
||||
result_code=result_code,
|
||||
error_result=error_result,
|
||||
)
|
||||
|
||||
# Get updated state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Game {game_id} not found"}
|
||||
)
|
||||
return
|
||||
|
||||
# Broadcast updated state
|
||||
await manager.broadcast_to_game(
|
||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"X-check result processed successfully for game {game_id}"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"X-check result validation failed: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error in submit_x_check_result: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
|
||||
except (TypeError, AttributeError) as e:
|
||||
logger.warning(f"Invalid data in submit_x_check_result: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})
|
||||
|
||||
@sio.event
|
||||
async def submit_decide_advance(sid, data):
|
||||
"""Submit DECIDE advance decision (placeholder for step 10)."""
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "DECIDE workflow not yet implemented"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
async def submit_decide_throw(sid, data):
|
||||
"""Submit DECIDE throw target (placeholder for step 10)."""
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "DECIDE workflow not yet implemented"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
async def submit_decide_result(sid, data):
|
||||
"""Submit DECIDE speed check result (placeholder for step 10)."""
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "DECIDE workflow not yet implemented"}
|
||||
)
|
||||
|
||||
63
frontend-sba/tests/unit/components/Game/fix-tests.js
Normal file
63
frontend-sba/tests/unit/components/Game/fix-tests.js
Normal file
@ -0,0 +1,63 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Read RunnerCard.spec.ts
|
||||
let content = fs.readFileSync('RunnerCard.spec.ts', 'utf8');
|
||||
|
||||
// Replace all gameStore.setLineup calls with proper setup
|
||||
content = content.replace(/gameStore\.setLineup\('home',/g, `gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: 'active',
|
||||
inning: 1,
|
||||
half: 'top',
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: 'NYY',
|
||||
away_team_abbrev: 'BOS',
|
||||
home_team_dice_color: '3b82f6',
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: 'idle',
|
||||
play_count: 0
|
||||
});
|
||||
|
||||
gameStore.updateLineup(1,`);
|
||||
|
||||
fs.writeFileSync('RunnerCard.spec.ts', content);
|
||||
|
||||
// Read RunnersOnBase.spec.ts
|
||||
content = fs.readFileSync('RunnersOnBase.spec.ts', 'utf8');
|
||||
|
||||
// Replace all gameStore.setLineup calls
|
||||
content = content.replace(/gameStore\.setLineup\('home',/g, `gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: 'active',
|
||||
inning: 1,
|
||||
half: 'top',
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: 'NYY',
|
||||
away_team_abbrev: 'BOS',
|
||||
home_team_dice_color: '3b82f6',
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: 'idle',
|
||||
play_count: 0
|
||||
});
|
||||
|
||||
gameStore.updateLineup(1,`);
|
||||
|
||||
fs.writeFileSync('RunnersOnBase.spec.ts', content);
|
||||
|
||||
console.log('Fixed test files');
|
||||
@ -38,7 +38,17 @@ export type LeagueId = 'sba' | 'pd'
|
||||
* Standardized naming (2025-01-21): Uses backend convention 'awaiting_*'
|
||||
* for clarity about what action is pending.
|
||||
*/
|
||||
export type DecisionPhase = 'awaiting_defensive' | 'awaiting_stolen_base' | 'awaiting_offensive' | 'resolution' | 'complete'
|
||||
export type DecisionPhase =
|
||||
| 'awaiting_defensive'
|
||||
| 'awaiting_stolen_base'
|
||||
| 'awaiting_offensive'
|
||||
| 'resolution'
|
||||
| 'complete'
|
||||
// Interactive x-check workflow phases
|
||||
| 'awaiting_x_check_result'
|
||||
| 'awaiting_decide_advance'
|
||||
| 'awaiting_decide_throw'
|
||||
| 'awaiting_decide_result'
|
||||
|
||||
/**
|
||||
* Lineup player state - represents a player in the game
|
||||
@ -125,6 +135,9 @@ export interface GameState {
|
||||
// Manual mode
|
||||
pending_manual_roll: RollData | null
|
||||
|
||||
// Interactive x-check workflow
|
||||
pending_x_check: PendingXCheck | null
|
||||
|
||||
// Play history
|
||||
play_count: number
|
||||
last_play_result: string | null
|
||||
@ -354,3 +367,76 @@ export interface CreateGameResponse {
|
||||
status: GameStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive X-Check Data
|
||||
* Sent with decision_required event when x-check is initiated
|
||||
*/
|
||||
export interface XCheckData {
|
||||
position: string
|
||||
d20_roll: number
|
||||
d6_total: number
|
||||
d6_individual: number[]
|
||||
chart_row: string[] // 5 values: ["G1", "G2", "G3#", "SI1", "SI2"]
|
||||
chart_type: 'infield' | 'outfield' | 'catcher'
|
||||
spd_d20: number | null // Pre-rolled, shown on click-to-reveal
|
||||
defender_lineup_id: number
|
||||
active_team_id: number // Which team can interact (transparency model)
|
||||
}
|
||||
|
||||
/**
|
||||
* DECIDE Advance Data
|
||||
* Sent when offensive player must decide if runner advances
|
||||
*/
|
||||
export interface DecideAdvanceData {
|
||||
runner_base: number // 1, 2, or 3
|
||||
target_base: number // 2, 3, or 4
|
||||
runner_lineup_id: number
|
||||
active_team_id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* DECIDE Throw Data
|
||||
* Sent when defensive player chooses throw target
|
||||
*/
|
||||
export interface DecideThrowData {
|
||||
runner_base: number
|
||||
target_base: number
|
||||
runner_lineup_id: number
|
||||
active_team_id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* DECIDE Speed Check Data
|
||||
* Sent when offensive player must resolve speed check
|
||||
*/
|
||||
export interface DecideSpeedCheckData {
|
||||
d20_roll: number
|
||||
runner_lineup_id: number
|
||||
runner_base: number
|
||||
target_base: number
|
||||
active_team_id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending X-Check State (on GameState)
|
||||
* Persisted for reconnection recovery
|
||||
*/
|
||||
export interface PendingXCheck {
|
||||
position: string
|
||||
ab_roll_id: string
|
||||
d20_roll: number
|
||||
d6_total: number
|
||||
d6_individual: number[]
|
||||
chart_row: string[]
|
||||
chart_type: string
|
||||
spd_d20: number | null
|
||||
defender_lineup_id: number
|
||||
selected_result: string | null
|
||||
error_result: string | null
|
||||
decide_runner_base: number | null
|
||||
decide_target_base: number | null
|
||||
decide_advance: boolean | null
|
||||
decide_throw: string | null
|
||||
decide_d20: number | null
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ import type {
|
||||
DefensiveDecision,
|
||||
OffensiveDecision,
|
||||
ManualOutcomeSubmission,
|
||||
XCheckData,
|
||||
DecideAdvanceData,
|
||||
DecideThrowData,
|
||||
DecideSpeedCheckData,
|
||||
} from './game'
|
||||
|
||||
import type {
|
||||
@ -48,6 +52,12 @@ export interface ClientToServerEvents {
|
||||
roll_dice: (data: RollDiceRequest) => void
|
||||
submit_manual_outcome: (data: SubmitManualOutcomeRequest) => void
|
||||
|
||||
// Interactive x-check workflow
|
||||
submit_x_check_result: (data: SubmitXCheckResultRequest) => void
|
||||
submit_decide_advance: (data: SubmitDecideAdvanceRequest) => void
|
||||
submit_decide_throw: (data: SubmitDecideThrowRequest) => void
|
||||
submit_decide_result: (data: SubmitDecideResultRequest) => void
|
||||
|
||||
// Substitutions
|
||||
request_pinch_hitter: (data: PinchHitterRequest) => void
|
||||
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
|
||||
@ -359,3 +369,28 @@ export interface TypedSocket {
|
||||
readonly connected: boolean
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive X-Check Request Types
|
||||
*/
|
||||
|
||||
export interface SubmitXCheckResultRequest {
|
||||
game_id: string
|
||||
result_code: string // G1, G2, SI2, F1, etc.
|
||||
error_result: string // NO, E1, E2, E3, RP
|
||||
}
|
||||
|
||||
export interface SubmitDecideAdvanceRequest {
|
||||
game_id: string
|
||||
advance: boolean
|
||||
}
|
||||
|
||||
export interface SubmitDecideThrowRequest {
|
||||
game_id: string
|
||||
target: 'runner' | 'first'
|
||||
}
|
||||
|
||||
export interface SubmitDecideResultRequest {
|
||||
game_id: string
|
||||
outcome: 'safe' | 'out'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user