feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8

Merged
cal merged 11 commits from feature/uncapped-hit-decision-tree into main 2026-02-12 15:37:34 +00:00
7 changed files with 905 additions and 3 deletions
Showing only changes of commit defa06653d - Show all commits

View File

@ -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.

View File

@ -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:

View File

@ -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}")

View File

@ -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"}
)

View 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');

View File

@ -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
}

View File

@ -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'
}