From defa06653dcda49440cf0fef99e4c772802df9be Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 7 Feb 2026 17:21:19 -0600 Subject: [PATCH] CLAUDE: Add interactive x-check workflow foundation (steps 1-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes: - Add PendingXCheck model for interactive x-check state - Extend decision_phase/pending_decision validators with 4 new phases - Add initiate_x_check() to roll dice and present chart to player - Add submit_x_check_result() to process player selection - Add resolve_x_check_from_selection() to resolve from player input - Add WebSocket handlers for x-check workflow - Modify resolve_manual_play() to route X_CHECK to interactive flow - All 986 unit tests passing Frontend changes: - Extend DecisionPhase type with x-check/DECIDE phases - Add XCheckData, DecideAdvanceData, DecideThrowData, DecideSpeedCheckData interfaces - Add PendingXCheck to GameState - Add 4 new client→server WebSocket events Next: Implement XCheckWizard component and GameplayPanel integration Co-Authored-By: Claude Sonnet 4.5 --- backend/app/core/game_engine.py | 301 +++++++++++++++++- backend/app/core/play_resolver.py | 160 ++++++++++ backend/app/models/game_models.py | 119 ++++++- backend/app/websocket/handlers.py | 142 +++++++++ .../tests/unit/components/Game/fix-tests.js | 63 ++++ frontend-sba/types/game.ts | 88 ++++- frontend-sba/types/websocket.ts | 35 ++ 7 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 frontend-sba/tests/unit/components/Game/fix-tests.js diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index df6b7b5..4e416e1 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -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. diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index dbada3e..e5817f3 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -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: diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index a113d4e..2f7397a 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -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}") diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index c96d027..7a5cbe5 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -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"} + ) diff --git a/frontend-sba/tests/unit/components/Game/fix-tests.js b/frontend-sba/tests/unit/components/Game/fix-tests.js new file mode 100644 index 0000000..10d7f9f --- /dev/null +++ b/frontend-sba/tests/unit/components/Game/fix-tests.js @@ -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'); diff --git a/frontend-sba/types/game.ts b/frontend-sba/types/game.ts index edbbea7..2f75e51 100644 --- a/frontend-sba/types/game.ts +++ b/frontend-sba/types/game.ts @@ -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 +} diff --git a/frontend-sba/types/websocket.ts b/frontend-sba/types/websocket.ts index dbbf1d3..1743654 100644 --- a/frontend-sba/types/websocket.ts +++ b/frontend-sba/types/websocket.ts @@ -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' +}