Merge pull request 'feat: Uncapped hit decision tree, x-check workflow, baserunner UI' (#8) from feature/uncapped-hit-decision-tree into main

Reviewed-on: #8
This commit is contained in:
cal 2026-02-12 15:37:33 +00:00
commit ffcbe248bd
42 changed files with 10519 additions and 1103 deletions

File diff suppressed because it is too large Load Diff

View File

@ -286,13 +286,14 @@ The `start.sh` script handles this automatically based on mode.
**Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10)
Backend is production-ready for frontend integration:
- ✅ All 15 WebSocket event handlers implemented
- ✅ All 20 WebSocket event handlers implemented
- ✅ Strategic decisions (defensive/offensive)
- ✅ Manual outcome workflow (dice rolling + card reading)
- ✅ Player substitutions (3 types)
- ✅ Box score statistics (materialized views)
- ✅ Position ratings integration (PD league)
- ✅ 730/731 tests passing (99.9%)
- ✅ Uncapped hit interactive decision tree (SINGLE_UNCAPPED, DOUBLE_UNCAPPED)
- ✅ 2481/2481 tests passing (100%)
**Next Phase**: Vue 3 + Nuxt 3 frontend implementation with Socket.io client

View File

@ -38,7 +38,7 @@ uv run python -m app.main # Start server at localhost:8000
### Testing
```bash
uv run pytest tests/unit/ -v # All unit tests (836 passing)
uv run pytest tests/unit/ -v # All unit tests (2481 passing)
uv run python -m terminal_client # Interactive REPL
```
@ -144,4 +144,4 @@ uv run pytest tests/unit/ -q # Must show all passing
---
**Tests**: 836 passing | **Phase**: 3E-Final Complete | **Updated**: 2025-01-27
**Tests**: 2481 passing | **Phase**: 3E-Final Complete | **Updated**: 2026-02-11

View File

@ -139,4 +139,4 @@ uv run python -m terminal_client
---
**Tests**: 739/739 passing | **Last Updated**: 2025-01-19
**Tests**: 2481/2481 passing | **Last Updated**: 2026-02-11

View File

@ -117,6 +117,65 @@ class AIOpponent:
)
return decision
# ========================================================================
# UNCAPPED HIT DECISIONS
# ========================================================================
async def decide_uncapped_lead_advance(
self, state: GameState, pending: "PendingUncappedHit"
) -> bool:
"""
AI decision: should lead runner attempt advance on uncapped hit?
Conservative default: don't risk the runner.
"""
logger.debug(f"AI uncapped lead advance decision for game {state.game_id}")
return False
async def decide_uncapped_defensive_throw(
self, state: GameState, pending: "PendingUncappedHit"
) -> bool:
"""
AI decision: should defense throw to the base?
Aggressive default: always challenge the runner.
"""
logger.debug(f"AI uncapped defensive throw decision for game {state.game_id}")
return True
async def decide_uncapped_trail_advance(
self, state: GameState, pending: "PendingUncappedHit"
) -> bool:
"""
AI decision: should trail runner attempt advance on uncapped hit?
Conservative default: don't risk the trail runner.
"""
logger.debug(f"AI uncapped trail advance decision for game {state.game_id}")
return False
async def decide_uncapped_throw_target(
self, state: GameState, pending: "PendingUncappedHit"
) -> str:
"""
AI decision: throw at lead or trail runner?
Default: target the lead runner (higher-value out).
"""
logger.debug(f"AI uncapped throw target decision for game {state.game_id}")
return "lead"
async def decide_uncapped_safe_out(
self, state: GameState, pending: "PendingUncappedHit"
) -> str:
"""
AI decision: declare runner safe or out?
Offensive AI always wants the runner safe.
"""
logger.debug(f"AI uncapped safe/out decision for game {state.game_id}")
return "safe"
def _should_attempt_steal(self, state: GameState) -> bool:
"""
Determine if AI should attempt a steal (Week 9).

File diff suppressed because it is too large Load Diff

View File

@ -520,8 +520,9 @@ class PlayResolver:
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as SINGLE_1
# Fallback path: used when GameEngine determines no interactive decision
# is needed (no eligible runners). Interactive workflow is handled by
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
runners_advanced = self._advance_on_single_1(state)
runs_scored = sum(
1 for adv in runners_advanced if adv.to_base == 4
@ -533,7 +534,7 @@ class PlayResolver:
runs_scored=runs_scored,
batter_result=1,
runners_advanced=runners_advanced,
description="Single to center (uncapped)",
description="Single (uncapped, no eligible runners)",
ab_roll=ab_roll,
is_hit=True,
)
@ -588,8 +589,9 @@ class PlayResolver:
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as DOUBLE_2
# Fallback path: used when GameEngine determines no interactive decision
# is needed (no R1). Interactive workflow is handled by
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
runners_advanced = self._advance_on_double_2(state)
runs_scored = sum(
1 for adv in runners_advanced if adv.to_base == 4
@ -601,7 +603,7 @@ class PlayResolver:
runs_scored=runs_scored,
batter_result=2,
runners_advanced=runners_advanced,
description="Double (uncapped)",
description="Double (uncapped, no eligible runners)",
ab_roll=ab_roll,
is_hit=True,
)
@ -1026,6 +1028,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,225 @@ 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
# ============================================================================
# PENDING UNCAPPED HIT STATE
# ============================================================================
class PendingUncappedHit(BaseModel):
"""
Intermediate state for interactive uncapped hit resolution.
Stores all uncapped hit workflow data as offensive/defensive players
make runner advancement decisions via WebSocket.
Workflow:
1. System identifies eligible runners stores lead/trail info
2. Offensive player decides if lead runner attempts advance
3. Defensive player decides if they throw to base
4. If trail runner exists, offensive decides trail advance
5. If both advance, defensive picks throw target
6. d20 speed check offensive declares safe/out from card
7. Play finalizes with accumulated decisions
Attributes:
hit_type: "single" or "double"
hit_location: Outfield position (LF, CF, RF)
ab_roll_id: Reference to original AbRoll for audit trail
lead_runner_base: Base of lead runner (1 or 2)
lead_runner_lineup_id: Lineup ID of lead runner
lead_target_base: Base lead runner is attempting (3 or 4=HOME)
trail_runner_base: Base of trail runner (0=batter, 1=R1), None if no trail
trail_runner_lineup_id: Lineup ID of trail runner, None if no trail
trail_target_base: Base trail runner attempts, None if no trail
auto_runners: Auto-scoring runners [(from_base, to_base, lineup_id)]
batter_base: Minimum base batter reaches (1 for single, 2 for double)
batter_lineup_id: Batter's lineup ID
lead_advance: Offensive decision - does lead runner attempt advance?
defensive_throw: Defensive decision - throw to base?
trail_advance: Offensive decision - does trail runner attempt advance?
throw_target: Defensive decision - throw at "lead" or "trail"?
speed_check_d20: d20 roll for speed check
speed_check_runner: Which runner is being checked ("lead" or "trail")
speed_check_result: "safe" or "out"
"""
# Hit context
hit_type: str # "single" or "double"
hit_location: str # "LF", "CF", or "RF"
ab_roll_id: str
# Lead runner
lead_runner_base: int # 1 or 2
lead_runner_lineup_id: int
lead_target_base: int # 3 or 4 (HOME)
# Trail runner (None if no trail)
trail_runner_base: int | None = None # 0=batter, 1=R1
trail_runner_lineup_id: int | None = None
trail_target_base: int | None = None
# Auto-scoring runners (recorded before decision tree)
auto_runners: list[tuple[int, int, int]] = Field(default_factory=list)
# [(from_base, to_base, lineup_id), ...] e.g. R3 scores, R2 scores on double
# Batter destination (minimum base)
batter_base: int # 1 for single, 2 for double
batter_lineup_id: int
# Decisions (filled progressively)
lead_advance: bool | None = None
defensive_throw: bool | None = None
trail_advance: bool | None = None
throw_target: str | None = None # "lead" or "trail"
# Speed check
speed_check_d20: int | None = Field(default=None, ge=1, le=20)
speed_check_runner: str | None = None # "lead" or "trail"
speed_check_result: str | None = None # "safe" or "out"
@field_validator("hit_type")
@classmethod
def validate_hit_type(cls, v: str) -> str:
"""Ensure hit_type is valid"""
valid = ["single", "double"]
if v not in valid:
raise ValueError(f"hit_type must be one of {valid}")
return v
@field_validator("hit_location")
@classmethod
def validate_hit_location(cls, v: str) -> str:
"""Ensure hit_location is an outfield position"""
valid = ["LF", "CF", "RF"]
if v not in valid:
raise ValueError(f"hit_location must be one of {valid}")
return v
@field_validator("throw_target")
@classmethod
def validate_throw_target(cls, v: str | None) -> str | None:
"""Ensure throw_target is valid"""
if v is not None:
valid = ["lead", "trail"]
if v not in valid:
raise ValueError(f"throw_target must be one of {valid}")
return v
@field_validator("speed_check_result")
@classmethod
def validate_speed_check_result(cls, v: str | None) -> str | None:
"""Ensure speed_check_result is valid"""
if v is not None:
valid = ["safe", "out"]
if v not in valid:
raise ValueError(f"speed_check_result must be one of {valid}")
return v
model_config = ConfigDict(frozen=False)
# ============================================================================
# GAME STATE
# ============================================================================
@ -380,7 +599,7 @@ class GameState(BaseModel):
current_batter: Snapshot - LineupPlayerState for current batter (required)
current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional)
current_catcher: Snapshot - LineupPlayerState for current catcher (optional)
current_on_base_code: Snapshot - bit field of occupied bases (1=1st, 2=2nd, 4=3rd)
current_on_base_code: Snapshot - sequential chart encoding (0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded)
pending_decision: Type of decision awaiting ('defensive', 'offensive', 'result_selection')
decisions_this_play: Accumulated decisions for current play
play_count: Total plays so far
@ -447,7 +666,7 @@ class GameState(BaseModel):
current_catcher: LineupPlayerState | None = None
current_on_base_code: int = Field(
default=0, ge=0
) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded
) # Sequential chart encoding: 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
# Decision tracking
pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection'
@ -466,6 +685,12 @@ class GameState(BaseModel):
None # AbRoll stored when dice rolled in manual mode
)
# Interactive x-check workflow
pending_x_check: PendingXCheck | None = None
# Interactive uncapped hit workflow
pending_uncapped_hit: PendingUncappedHit | None = None
# Play tracking
play_count: int = Field(default=0, ge=0)
last_play_result: str | None = None
@ -501,7 +726,21 @@ 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",
"uncapped_lead_advance",
"uncapped_defensive_throw",
"uncapped_trail_advance",
"uncapped_throw_target",
"uncapped_safe_out",
]
if v not in valid:
raise ValueError(f"pending_decision must be one of {valid}")
return v
@ -516,6 +755,15 @@ class GameState(BaseModel):
"awaiting_offensive",
"resolving",
"completed",
"awaiting_x_check_result",
"awaiting_decide_advance",
"awaiting_decide_throw",
"awaiting_decide_result",
"awaiting_uncapped_lead_advance",
"awaiting_uncapped_defensive_throw",
"awaiting_uncapped_trail_advance",
"awaiting_uncapped_throw_target",
"awaiting_uncapped_safe_out",
]
if v not in valid:
raise ValueError(f"decision_phase must be one of {valid}")
@ -592,26 +840,35 @@ class GameState(BaseModel):
"""
Calculate on-base code from current runner positions.
Returns bit field where:
- Bit 0 (value 1): runner on first
- Bit 1 (value 2): runner on second
- Bit 2 (value 4): runner on third
- Value 7: bases loaded (1 + 2 + 4)
Examples:
Returns sequential chart encoding matching the official rulebook charts:
0 = empty bases
1 = runner on first only
3 = runners on first and second
7 = bases loaded
1 = runner on 1st only
2 = runner on 2nd only
3 = runner on 3rd only
4 = runners on 1st and 2nd
5 = runners on 1st and 3rd
6 = runners on 2nd and 3rd
7 = bases loaded (1st, 2nd, and 3rd)
"""
code = 0
if self.on_first:
code |= 1 # Bit 0
if self.on_second:
code |= 2 # Bit 1
if self.on_third:
code |= 4 # Bit 2
return code
r1 = self.on_first is not None
r2 = self.on_second is not None
r3 = self.on_third is not None
if r1 and r2 and r3:
return 7 # Loaded
if r2 and r3:
return 6 # R2+R3
if r1 and r3:
return 5 # R1+R3
if r1 and r2:
return 4 # R1+R2
if r3:
return 3 # R3 only
if r2:
return 2 # R2 only
if r1:
return 1 # R1 only
return 0 # Empty
def get_runner_at_base(self, base: int) -> LineupPlayerState | None:
"""Get runner at specified base (1, 2, or 3)"""
@ -835,5 +1092,6 @@ __all__ = [
"TeamLineupState",
"DefensiveDecision",
"OffensiveDecision",
"PendingUncappedHit",
"GameState",
]

View File

@ -23,7 +23,7 @@ Broadcast to All Players
```
app/websocket/
├── connection_manager.py # Connection lifecycle & broadcasting
└── handlers.py # Event handler registration (15 handlers)
└── handlers.py # Event handler registration (20 handlers)
```
## ConnectionManager
@ -43,7 +43,7 @@ await manager.broadcast_to_game(game_id, event, data)
await manager.emit_to_user(sid, event, data)
```
## Event Handlers (15 Total)
## Event Handlers (20 Total)
### Connection Events
- `connect` - JWT authentication
@ -68,6 +68,13 @@ await manager.emit_to_user(sid, event, data)
- `submit_pitching_change` - Pitcher substitution
- `submit_defensive_replacement` - Field substitution
### Uncapped Hit Decisions
- `submit_uncapped_lead_advance` - Lead runner advance choice (offensive)
- `submit_uncapped_defensive_throw` - Throw to base choice (defensive)
- `submit_uncapped_trail_advance` - Trail runner advance choice (offensive)
- `submit_uncapped_throw_target` - Throw at lead or trail (defensive)
- `submit_uncapped_safe_out` - Declare safe or out from card (offensive)
### Lineup
- `get_lineup` - Get team lineup
@ -116,4 +123,4 @@ await manager.emit_to_user(sid, "error", {"message": str(e)})
---
**Handlers**: 15/15 implemented | **Updated**: 2025-01-19
**Handlers**: 20/20 implemented | **Updated**: 2026-02-11

View File

@ -1933,3 +1933,437 @@ 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"}
)
# ============================================================================
# INTERACTIVE UNCAPPED HIT HANDLERS
# ============================================================================
@sio.event
async def submit_uncapped_lead_advance(sid, data):
"""
Submit offensive decision: will lead runner attempt advance on uncapped hit?
Event data:
game_id: UUID of the game
advance: bool - True if runner attempts advance
Emits:
decision_required: Next phase if more decisions needed
game_state_update: Broadcast when play finalizes
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
)
return
try:
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
advance = data.get("advance")
if advance is None or not isinstance(advance, bool):
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
return
await game_engine.submit_uncapped_lead_advance(game_id, advance)
# Broadcast updated state
state = state_manager.get_state(game_id)
if state:
await manager.broadcast_to_game(
str(game_id), "game_state_update", state.model_dump(mode="json")
)
except ValueError as e:
logger.warning(f"Uncapped lead advance validation failed: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
except DatabaseError as e:
logger.error(f"Database error in submit_uncapped_lead_advance: {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_uncapped_lead_advance: {e}")
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})
@sio.event
async def submit_uncapped_defensive_throw(sid, data):
"""
Submit defensive decision: will you throw to the base?
Event data:
game_id: UUID of the game
will_throw: bool - True if defense throws
Emits:
decision_required: Next phase if more decisions needed
game_state_update: Broadcast when play finalizes
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
)
return
try:
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
will_throw = data.get("will_throw")
if will_throw is None or not isinstance(will_throw, bool):
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'will_throw' (bool)"})
return
await game_engine.submit_uncapped_defensive_throw(game_id, will_throw)
state = state_manager.get_state(game_id)
if state:
await manager.broadcast_to_game(
str(game_id), "game_state_update", state.model_dump(mode="json")
)
except ValueError as e:
logger.warning(f"Uncapped defensive throw validation failed: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
except DatabaseError as e:
logger.error(f"Database error in submit_uncapped_defensive_throw: {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_uncapped_defensive_throw: {e}")
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})
@sio.event
async def submit_uncapped_trail_advance(sid, data):
"""
Submit offensive decision: will trail runner attempt advance?
Event data:
game_id: UUID of the game
advance: bool - True if trail runner attempts advance
Emits:
decision_required: Next phase if more decisions needed
game_state_update: Broadcast when play finalizes
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
)
return
try:
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
advance = data.get("advance")
if advance is None or not isinstance(advance, bool):
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
return
await game_engine.submit_uncapped_trail_advance(game_id, advance)
state = state_manager.get_state(game_id)
if state:
await manager.broadcast_to_game(
str(game_id), "game_state_update", state.model_dump(mode="json")
)
except ValueError as e:
logger.warning(f"Uncapped trail advance validation failed: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
except DatabaseError as e:
logger.error(f"Database error in submit_uncapped_trail_advance: {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_uncapped_trail_advance: {e}")
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})
@sio.event
async def submit_uncapped_throw_target(sid, data):
"""
Submit defensive decision: throw for lead or trail runner?
Event data:
game_id: UUID of the game
target: str - "lead" or "trail"
Emits:
decision_required: awaiting_uncapped_safe_out with d20 roll
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
)
return
try:
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
target = data.get("target")
if target not in ("lead", "trail"):
await manager.emit_to_user(
sid, "error", {"message": "target must be 'lead' or 'trail'"}
)
return
await game_engine.submit_uncapped_throw_target(game_id, target)
state = state_manager.get_state(game_id)
if state:
await manager.broadcast_to_game(
str(game_id), "game_state_update", state.model_dump(mode="json")
)
except ValueError as e:
logger.warning(f"Uncapped throw target validation failed: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
except DatabaseError as e:
logger.error(f"Database error in submit_uncapped_throw_target: {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_uncapped_throw_target: {e}")
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})
@sio.event
async def submit_uncapped_safe_out(sid, data):
"""
Submit offensive declaration: is the runner safe or out?
Event data:
game_id: UUID of the game
result: str - "safe" or "out"
Emits:
game_state_update: Broadcast when play finalizes
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
)
return
try:
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
result = data.get("result")
if result not in ("safe", "out"):
await manager.emit_to_user(
sid, "error", {"message": "result must be 'safe' or 'out'"}
)
return
await game_engine.submit_uncapped_safe_out(game_id, result)
state = state_manager.get_state(game_id)
if state:
await manager.broadcast_to_game(
str(game_id), "game_state_update", state.model_dump(mode="json")
)
except ValueError as e:
logger.warning(f"Uncapped safe/out validation failed: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
except DatabaseError as e:
logger.error(f"Database error in submit_uncapped_safe_out: {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_uncapped_safe_out: {e}")
await manager.emit_to_user(sid, "error", {"message": "Invalid request"})

View File

@ -55,7 +55,7 @@ See `backend/CLAUDE.md` → "Testing Policy" section for full details.
### Current Test Baseline
**Must maintain or improve:**
- ✅ Unit tests: **979/979 passing (100%)**
- ✅ Unit tests: **2481/2481 passing (100%)**
- ✅ Integration tests: **32/32 passing (100%)**
- ⏱️ Unit execution: **~4 seconds**
- ⏱️ Integration execution: **~5 seconds**
@ -320,10 +320,10 @@ All major test infrastructure issues have been resolved. The test suite is now s
## Test Coverage
**Current Status** (as of 2025-11-27):
- ✅ **979 unit tests passing** (100%)
**Current Status** (as of 2026-02-11):
- ✅ **2481 unit tests passing** (100%)
- ✅ **32 integration tests passing** (100%)
- **Total: 1,011 tests passing**
- **Total: 2,513 tests passing**
**Coverage by Module**:
```
@ -333,7 +333,7 @@ app/core/state_manager.py ✅ Well covered
app/core/dice.py ✅ Well covered
app/models/ ✅ Well covered
app/database/operations.py ✅ 32 integration tests (session injection pattern)
app/websocket/handlers.py ✅ 148 WebSocket handler tests
app/websocket/handlers.py ✅ 171 WebSocket handler tests
app/middleware/ ✅ Rate limiting, exceptions tested
```
@ -520,4 +520,4 @@ Transactions: db_ops = DatabaseOperations(session) → Multiple ops, single comm
---
**Summary**: All 1,011 tests passing (979 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.
**Summary**: All 2,513 tests passing (2481 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.

View File

@ -0,0 +1,666 @@
"""
Invariant Tests for Play Resolver Advancement
Structural invariant tests that verify correctness properties that must hold
across ALL combinations of play outcomes and on-base situations. These catch
bugs like lost runners, base collisions, backward movement, and incorrect
run counting without needing to specify every individual expected result.
These tests use @pytest.mark.parametrize to exhaustively cover the full matrix
of (outcome, on_base_code) combinations handled by play_resolver.resolve_outcome().
Invariants tested:
1. Conservation of players - every runner and batter must be accounted for
2. No base collisions - no two runners end up on the same base (1-3)
3. Runners never go backward - to_base >= from_base for non-outs
4. Batter reaches minimum base for hit type
5. Runs scored equals count of runners reaching base 4
6. Walk/HBP: only forced (consecutive from 1st) runners advance
7. Outs recorded is non-negative and bounded
8. Hit flag correctness - hits are hits, outs are outs
Author: Claude
Date: 2025-02-08
"""
import pytest
from itertools import product
from uuid import uuid4
import pendulum
from app.config import PlayOutcome
from app.core.play_resolver import PlayResolver, PlayResult, RunnerAdvancementData
from app.core.roll_types import AbRoll, RollType
from app.models.game_models import (
DefensiveDecision,
GameState,
LineupPlayerState,
OffensiveDecision,
)
# =============================================================================
# Test Fixtures & Helpers
# =============================================================================
def make_player(lineup_id: int, batting_order: int = 1) -> LineupPlayerState:
"""Create a LineupPlayerState with unique IDs for testing."""
return LineupPlayerState(
lineup_id=lineup_id,
card_id=lineup_id * 100,
position="CF",
batting_order=batting_order,
)
def make_ab_roll(game_id=None) -> AbRoll:
"""Create a mock AbRoll for testing."""
return AbRoll(
roll_type=RollType.AB,
roll_id="test_invariant",
timestamp=pendulum.now("UTC"),
league_id="sba",
game_id=game_id,
d6_one=3,
d6_two_a=2,
d6_two_b=4,
chaos_d20=10,
resolution_d20=10,
)
def make_state_with_runners(on_base_code: int) -> GameState:
"""
Create a GameState with runners placed according to on_base_code.
On-base code is a bit field:
bit 0 (value 1) = runner on 1st (lineup_id=10)
bit 1 (value 2) = runner on 2nd (lineup_id=20)
bit 2 (value 4) = runner on 3rd (lineup_id=30)
The batter always has lineup_id=1.
"""
return GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=make_player(1, batting_order=1),
on_first=make_player(10, batting_order=2) if on_base_code & 1 else None,
on_second=make_player(20, batting_order=3) if on_base_code & 2 else None,
on_third=make_player(30, batting_order=4) if on_base_code & 4 else None,
)
def count_initial_runners(on_base_code: int) -> int:
"""Count how many runners are on base for a given on_base_code."""
return bin(on_base_code).count("1")
# =============================================================================
# Outcome Categories
# =============================================================================
# Outcomes handled directly by play_resolver.resolve_outcome() with simple advancement
# (no hit_location required, no delegation to runner_advancement.py)
HIT_OUTCOMES = [
PlayOutcome.SINGLE_1,
PlayOutcome.SINGLE_2,
PlayOutcome.DOUBLE_2,
PlayOutcome.DOUBLE_3,
PlayOutcome.TRIPLE,
PlayOutcome.HOMERUN,
]
WALK_OUTCOMES = [
PlayOutcome.WALK,
PlayOutcome.HIT_BY_PITCH,
]
SIMPLE_OUT_OUTCOMES = [
PlayOutcome.STRIKEOUT,
PlayOutcome.LINEOUT,
PlayOutcome.POPOUT,
]
# Interrupt plays (runners advance 1 base, batter stays)
INTERRUPT_ADVANCE_OUTCOMES = [
PlayOutcome.WILD_PITCH,
PlayOutcome.PASSED_BALL,
]
# All outcomes that can be tested without hit_location or special setup
# Groundballs and flyballs are excluded because they delegate to runner_advancement.py
# and require hit_location logic - they have their own exhaustive tests.
SIMPLE_OUTCOMES = HIT_OUTCOMES + WALK_OUTCOMES + SIMPLE_OUT_OUTCOMES + INTERRUPT_ADVANCE_OUTCOMES
# All 8 possible on-base codes
ALL_ON_BASE_CODES = list(range(8))
# Minimum base the batter must reach for each hit type
BATTER_MINIMUM_BASE = {
PlayOutcome.SINGLE_1: 1,
PlayOutcome.SINGLE_2: 1,
PlayOutcome.DOUBLE_2: 2,
PlayOutcome.DOUBLE_3: 2,
PlayOutcome.TRIPLE: 3,
PlayOutcome.HOMERUN: 4,
}
def resolve(outcome: PlayOutcome, on_base_code: int) -> PlayResult:
"""
Helper to resolve a play outcome with a given on-base situation.
Creates all necessary objects and calls resolve_outcome() directly.
Returns the PlayResult for invariant checking.
"""
resolver = PlayResolver(league_id="sba", auto_mode=False)
state = make_state_with_runners(on_base_code)
ab_roll = make_ab_roll(state.game_id)
return resolver.resolve_outcome(
outcome=outcome,
hit_location=None,
state=state,
defensive_decision=DefensiveDecision(),
offensive_decision=OffensiveDecision(),
ab_roll=ab_roll,
)
# Generate the full test matrix: (outcome, on_base_code)
FULL_MATRIX = list(product(SIMPLE_OUTCOMES, ALL_ON_BASE_CODES))
# Human-readable IDs for parametrize
MATRIX_IDS = [f"{outcome.value}__obc{obc}" for outcome, obc in FULL_MATRIX]
# =============================================================================
# Invariant 1: Conservation of Players
# =============================================================================
class TestConservationOfPlayers:
"""
Every runner who started on base must be accounted for in the result.
After a play resolves, every initial runner must appear in exactly one of:
- runners_advanced (moved to a new base or scored)
- still implicitly on their original base (if not in runners_advanced)
- recorded as out (is_out=True in runners_advanced)
For hits and walks, no runners should be lost.
For outs where only the batter is out, runners should still be tracked.
"""
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_runs_scored_not_negative(self, outcome, on_base_code):
"""Runs scored must never be negative."""
result = resolve(outcome, on_base_code)
assert result.runs_scored >= 0, (
f"Negative runs scored: {result.runs_scored}"
)
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_outs_recorded_not_negative(self, outcome, on_base_code):
"""Outs recorded must never be negative."""
result = resolve(outcome, on_base_code)
assert result.outs_recorded >= 0, (
f"Negative outs recorded: {result.outs_recorded}"
)
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_runs_scored_bounded_by_runners_plus_batter(self, outcome, on_base_code):
"""
Runs scored cannot exceed the number of runners on base + batter.
Maximum possible: 3 runners + 1 batter = 4 (grand slam).
"""
n_runners = count_initial_runners(on_base_code)
result = resolve(outcome, on_base_code)
max_possible = n_runners + 1 # runners + batter
assert result.runs_scored <= max_possible, (
f"Runs scored ({result.runs_scored}) exceeds max possible "
f"({max_possible}) for on_base_code={on_base_code}"
)
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_runs_scored_matches_runners_reaching_home(self, outcome, on_base_code):
"""
Runs scored must equal the count of runner advancements to base 4,
plus 1 if the batter also reaches base 4 (home run).
"""
result = resolve(outcome, on_base_code)
runners_scoring = sum(
1 for adv in result.runners_advanced if adv.to_base == 4 and not adv.is_out
)
batter_scores = 1 if result.batter_result == 4 else 0
assert result.runs_scored == runners_scoring + batter_scores, (
f"runs_scored ({result.runs_scored}) != runners reaching home "
f"({runners_scoring}) + batter scoring ({batter_scores}). "
f"Advances: {[(a.from_base, a.to_base, a.is_out) for a in result.runners_advanced]}, "
f"batter_result={result.batter_result}"
)
# =============================================================================
# Invariant 2: No Base Collisions
# =============================================================================
class TestNoBaseCollisions:
"""
After a play resolves, no two runners (including batter) should occupy
the same base (1, 2, or 3). Base 4 (home) and base 0 (out) can have
multiple entries.
"""
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_no_two_runners_on_same_base(self, outcome, on_base_code):
"""No two runners should end up on the same base (1-3)."""
result = resolve(outcome, on_base_code)
# Collect all final base positions (excluding home=4 and out=0)
final_bases = []
# Batter's final position
if result.batter_result is not None and 1 <= result.batter_result <= 3:
final_bases.append(("batter", result.batter_result))
# Runners who moved
moved_from_bases = set()
for adv in result.runners_advanced:
moved_from_bases.add(adv.from_base)
if not adv.is_out and 1 <= adv.to_base <= 3:
final_bases.append((f"runner_from_{adv.from_base}", adv.to_base))
# Runners who didn't move (still on their original base)
initial_runners = []
if on_base_code & 1:
initial_runners.append((1, 10))
if on_base_code & 2:
initial_runners.append((2, 20))
if on_base_code & 4:
initial_runners.append((3, 30))
for base, _lid in initial_runners:
if base not in moved_from_bases:
final_bases.append((f"unmoved_on_{base}", base))
# Check for duplicates
occupied = {}
for label, base in final_bases:
if base in occupied:
pytest.fail(
f"Base collision on base {base}: "
f"{occupied[base]} and {label} both occupy it. "
f"Outcome={outcome.value}, on_base_code={on_base_code}, "
f"batter_result={result.batter_result}, "
f"advances={[(a.from_base, a.to_base, a.is_out) for a in result.runners_advanced]}"
)
occupied[base] = label
# =============================================================================
# Invariant 3: Runners Never Go Backward
# =============================================================================
class TestRunnersNeverGoBackward:
"""
A runner's destination base must be >= their starting base, unless
they are recorded as out (to_base=0).
"""
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_runners_advance_forward_or_out(self, outcome, on_base_code):
"""Every runner movement must go forward (higher base) or be an out."""
result = resolve(outcome, on_base_code)
for adv in result.runners_advanced:
if adv.is_out:
# Outs can go to base 0 (removed)
continue
assert adv.to_base >= adv.from_base, (
f"Runner went backward: base {adv.from_base}{adv.to_base}. "
f"Outcome={outcome.value}, on_base_code={on_base_code}"
)
# =============================================================================
# Invariant 4: Batter Reaches Minimum Base for Hit Type
# =============================================================================
class TestBatterMinimumBase:
"""
On hits, the batter must reach at least the base corresponding to the
hit type (single=1, double=2, triple=3, homerun=4).
"""
HIT_MATRIX = list(product(HIT_OUTCOMES, ALL_ON_BASE_CODES))
HIT_IDS = [f"{o.value}__obc{c}" for o, c in HIT_MATRIX]
@pytest.mark.parametrize("outcome, on_base_code", HIT_MATRIX, ids=HIT_IDS)
def test_batter_reaches_minimum_base(self, outcome, on_base_code):
"""Batter must reach at least the minimum base for their hit type."""
result = resolve(outcome, on_base_code)
min_base = BATTER_MINIMUM_BASE[outcome]
assert result.batter_result is not None, (
f"Batter result is None for hit {outcome.value}"
)
assert result.batter_result >= min_base, (
f"Batter reached base {result.batter_result} but minimum for "
f"{outcome.value} is {min_base}"
)
# =============================================================================
# Invariant 5: Walk/HBP Forced Advancement Rules
# =============================================================================
class TestWalkForcedAdvancement:
"""
On a walk or HBP, the batter goes to 1st and only FORCED runners advance.
A runner is forced if all bases between them and 1st (inclusive) are occupied.
Examples:
- R1 is always forced (batter takes 1st)
- R2 is forced only if R1 is also on base
- R3 is forced only if R1 AND R2 are on base (bases loaded)
"""
WALK_MATRIX = list(product(WALK_OUTCOMES, ALL_ON_BASE_CODES))
WALK_IDS = [f"{o.value}__obc{c}" for o, c in WALK_MATRIX]
@pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS)
def test_batter_to_first(self, outcome, on_base_code):
"""On walk/HBP, batter always reaches 1st base."""
result = resolve(outcome, on_base_code)
assert result.batter_result == 1, (
f"Batter should reach 1st on {outcome.value}, got {result.batter_result}"
)
@pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS)
def test_no_outs_on_walk(self, outcome, on_base_code):
"""Walks/HBP never record outs."""
result = resolve(outcome, on_base_code)
assert result.outs_recorded == 0, (
f"Walk/HBP should record 0 outs, got {result.outs_recorded}"
)
@pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS)
def test_only_forced_runners_advance(self, outcome, on_base_code):
"""
Only runners forced by the batter taking 1st should advance.
Forced chain: 1st must be occupied for anyone to be forced.
If 1st is empty, no runners advance at all.
"""
result = resolve(outcome, on_base_code)
has_r1 = bool(on_base_code & 1)
has_r2 = bool(on_base_code & 2)
has_r3 = bool(on_base_code & 4)
if not has_r1:
# No runner on 1st → no one is forced → no advancements
assert len(result.runners_advanced) == 0, (
f"No runner on 1st, but runners advanced: "
f"{[(a.from_base, a.to_base) for a in result.runners_advanced]}"
)
else:
# R1 is forced to 2nd
r1_advanced = any(a.from_base == 1 for a in result.runners_advanced)
assert r1_advanced, "R1 should be forced to advance on walk"
if has_r2:
# R2 is forced to 3rd (since R1 pushes to 2nd)
r2_advanced = any(a.from_base == 2 for a in result.runners_advanced)
assert r2_advanced, "R2 should be forced to advance (R1 and R2 both on)"
if has_r3:
# R3 is forced home (bases loaded)
r3_advanced = any(a.from_base == 3 for a in result.runners_advanced)
assert r3_advanced, "R3 should be forced home (bases loaded)"
assert result.runs_scored == 1, (
f"Bases loaded walk should score 1 run, got {result.runs_scored}"
)
else:
# R2 not on base → not forced, should not advance
r2_advanced = any(a.from_base == 2 for a in result.runners_advanced)
assert not r2_advanced, (
"R2 should NOT advance (not forced - no consecutive chain)"
)
@pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS)
def test_forced_runners_advance_exactly_one_base(self, outcome, on_base_code):
"""Forced runners on a walk advance exactly 1 base."""
result = resolve(outcome, on_base_code)
for adv in result.runners_advanced:
expected_to = adv.from_base + 1
assert adv.to_base == expected_to, (
f"Forced runner from base {adv.from_base} should go to "
f"{expected_to}, went to {adv.to_base}"
)
# =============================================================================
# Invariant 6: Simple Outs Record Exactly 1 Out, 0 Runs
# =============================================================================
class TestSimpleOuts:
"""
Strikeouts, lineouts, and popouts always record exactly 1 out,
0 runs, and no runner advancement.
"""
OUT_MATRIX = list(product(SIMPLE_OUT_OUTCOMES, ALL_ON_BASE_CODES))
OUT_IDS = [f"{o.value}__obc{c}" for o, c in OUT_MATRIX]
@pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS)
def test_one_out_recorded(self, outcome, on_base_code):
"""Simple outs always record exactly 1 out."""
result = resolve(outcome, on_base_code)
assert result.outs_recorded == 1, (
f"{outcome.value} should record 1 out, got {result.outs_recorded}"
)
@pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS)
def test_zero_runs(self, outcome, on_base_code):
"""Simple outs never score runs."""
result = resolve(outcome, on_base_code)
assert result.runs_scored == 0, (
f"{outcome.value} should score 0 runs, got {result.runs_scored}"
)
@pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS)
def test_batter_is_out(self, outcome, on_base_code):
"""Batter result should be None (out) for simple outs."""
result = resolve(outcome, on_base_code)
assert result.batter_result is None, (
f"Batter should be out for {outcome.value}, got base {result.batter_result}"
)
@pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS)
def test_no_runner_advancement(self, outcome, on_base_code):
"""Simple outs should not advance any runners."""
result = resolve(outcome, on_base_code)
assert len(result.runners_advanced) == 0, (
f"No runners should advance on {outcome.value}, "
f"got {[(a.from_base, a.to_base) for a in result.runners_advanced]}"
)
# =============================================================================
# Invariant 7: Hit Flag Correctness
# =============================================================================
class TestHitFlagCorrectness:
"""
The is_hit, is_out, and is_walk flags on PlayResult must match the
outcome type.
"""
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_is_hit_flag(self, outcome, on_base_code):
"""is_hit should be True for hit outcomes, False otherwise."""
result = resolve(outcome, on_base_code)
if outcome in HIT_OUTCOMES:
assert result.is_hit is True, (
f"{outcome.value} should have is_hit=True"
)
else:
assert result.is_hit is False, (
f"{outcome.value} should have is_hit=False"
)
@pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS)
def test_is_out_flag(self, outcome, on_base_code):
"""is_out should be True for out outcomes, False for hits/walks."""
result = resolve(outcome, on_base_code)
if outcome in SIMPLE_OUT_OUTCOMES:
assert result.is_out is True, (
f"{outcome.value} should have is_out=True"
)
WALK_MATRIX_LOCAL = list(product(WALK_OUTCOMES, ALL_ON_BASE_CODES))
WALK_IDS_LOCAL = [f"{o.value}__obc{c}" for o, c in WALK_MATRIX_LOCAL]
@pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX_LOCAL, ids=WALK_IDS_LOCAL)
def test_walk_is_walk_flag(self, outcome, on_base_code):
"""
WALK should have is_walk=True.
HBP should have is_walk=False (different stat category).
"""
result = resolve(outcome, on_base_code)
if outcome == PlayOutcome.WALK:
assert result.is_walk is True, "WALK should have is_walk=True"
elif outcome == PlayOutcome.HIT_BY_PITCH:
assert result.is_walk is False, "HBP should have is_walk=False"
# =============================================================================
# Invariant 8: Interrupt Plays (WP/PB)
# =============================================================================
class TestInterruptPlays:
"""
Wild pitches and passed balls advance all runners exactly 1 base.
The batter does NOT advance (stays at plate).
"""
INT_MATRIX = list(product(INTERRUPT_ADVANCE_OUTCOMES, ALL_ON_BASE_CODES))
INT_IDS = [f"{o.value}__obc{c}" for o, c in INT_MATRIX]
@pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS)
def test_batter_stays_at_plate(self, outcome, on_base_code):
"""On WP/PB, batter stays at plate (result is None)."""
result = resolve(outcome, on_base_code)
assert result.batter_result is None, (
f"Batter should stay at plate on {outcome.value}, "
f"got base {result.batter_result}"
)
@pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS)
def test_no_outs_recorded(self, outcome, on_base_code):
"""WP/PB never record outs."""
result = resolve(outcome, on_base_code)
assert result.outs_recorded == 0
@pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS)
def test_all_runners_advance_one_base(self, outcome, on_base_code):
"""Every runner on base advances exactly 1 base."""
result = resolve(outcome, on_base_code)
n_runners = count_initial_runners(on_base_code)
assert len(result.runners_advanced) == n_runners, (
f"Expected {n_runners} runner movements, got {len(result.runners_advanced)}"
)
for adv in result.runners_advanced:
expected_to = min(adv.from_base + 1, 4)
assert adv.to_base == expected_to, (
f"Runner from base {adv.from_base} should advance to "
f"{expected_to}, went to {adv.to_base}"
)
@pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS)
def test_runner_on_third_scores(self, outcome, on_base_code):
"""If runner on 3rd, they score (base 4) on WP/PB."""
result = resolve(outcome, on_base_code)
has_r3 = bool(on_base_code & 4)
if has_r3:
r3_scores = any(
a.from_base == 3 and a.to_base == 4
for a in result.runners_advanced
)
assert r3_scores, "Runner on 3rd should score on WP/PB"
# =============================================================================
# Invariant 9: Hits Never Record Outs
# =============================================================================
class TestHitsNeverRecordOuts:
"""
Hit outcomes (singles, doubles, triples, homers) should never
record any outs.
"""
HIT_MATRIX = list(product(HIT_OUTCOMES, ALL_ON_BASE_CODES))
HIT_IDS = [f"{o.value}__obc{c}" for o, c in HIT_MATRIX]
@pytest.mark.parametrize("outcome, on_base_code", HIT_MATRIX, ids=HIT_IDS)
def test_zero_outs_on_hits(self, outcome, on_base_code):
"""Hits never record outs."""
result = resolve(outcome, on_base_code)
assert result.outs_recorded == 0, (
f"{outcome.value} should record 0 outs, got {result.outs_recorded}"
)
# =============================================================================
# Invariant 10: Home Run - Everyone Scores
# =============================================================================
class TestHomeRunEveryoneScores:
"""On a home run, every runner on base scores plus the batter."""
@pytest.mark.parametrize("on_base_code", ALL_ON_BASE_CODES)
def test_homerun_all_score(self, on_base_code):
"""Home run: runs = runners on base + 1 (batter)."""
result = resolve(PlayOutcome.HOMERUN, on_base_code)
n_runners = count_initial_runners(on_base_code)
assert result.runs_scored == n_runners + 1, (
f"HR with {n_runners} runners should score {n_runners + 1}, "
f"got {result.runs_scored}"
)
assert result.batter_result == 4, "Batter should reach home (4) on HR"
# =============================================================================
# Invariant 11: Triple - All Runners Score
# =============================================================================
class TestTripleAllRunnersScore:
"""On a triple, all runners on base score. Batter reaches 3rd."""
@pytest.mark.parametrize("on_base_code", ALL_ON_BASE_CODES)
def test_triple_runners_all_score(self, on_base_code):
"""Triple: all runners score, batter to 3rd."""
result = resolve(PlayOutcome.TRIPLE, on_base_code)
n_runners = count_initial_runners(on_base_code)
assert result.runs_scored == n_runners, (
f"Triple with {n_runners} runners should score {n_runners}, "
f"got {result.runs_scored}"
)
assert result.batter_result == 3, "Batter should reach 3rd on triple"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,215 @@
"""
Shared fixtures and helpers for truth table tests.
Provides factory functions for GameState, AbRoll, and a resolve() helper
that all truth table test files use.
"""
import pytest
from uuid import uuid4
import pendulum
from app.config import PlayOutcome
from app.core.play_resolver import PlayResolver, PlayResult
from app.core.roll_types import AbRoll, RollType
from app.models.game_models import (
DefensiveDecision,
GameState,
LineupPlayerState,
OffensiveDecision,
)
# =============================================================================
# Player & State Factories
# =============================================================================
# Lineup IDs by base position (consistent across all truth table tests)
BATTER_LID = 1
R1_LID = 10
R2_LID = 20
R3_LID = 30
def make_player(lineup_id: int, batting_order: int = 1) -> LineupPlayerState:
"""Create a LineupPlayerState with unique IDs for testing."""
return LineupPlayerState(
lineup_id=lineup_id,
card_id=lineup_id * 100,
position="CF",
batting_order=batting_order,
)
def make_ab_roll(game_id=None) -> AbRoll:
"""Create a mock AbRoll for testing."""
return AbRoll(
roll_type=RollType.AB,
roll_id="test_truth_table",
timestamp=pendulum.now("UTC"),
league_id="sba",
game_id=game_id,
d6_one=3,
d6_two_a=2,
d6_two_b=4,
chaos_d20=10,
resolution_d20=10,
)
def make_state(on_base_code: int, outs: int = 0) -> GameState:
"""
Create a GameState with runners placed according to on_base_code.
Uses sequential chart encoding (matching official rulebook charts):
0 = empty 4 = R1+R2
1 = R1 5 = R1+R3
2 = R2 6 = R2+R3
3 = R3 7 = R1+R2+R3 (loaded)
"""
# Sequential encoding → which bases are occupied
r1_on = on_base_code in (1, 4, 5, 7)
r2_on = on_base_code in (2, 4, 6, 7)
r3_on = on_base_code in (3, 5, 6, 7)
state = GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
outs=outs,
current_batter=make_player(BATTER_LID, batting_order=1),
on_first=make_player(R1_LID, batting_order=2) if r1_on else None,
on_second=make_player(R2_LID, batting_order=3) if r2_on else None,
on_third=make_player(R3_LID, batting_order=4) if r3_on else None,
)
# Set current_on_base_code for chart-based lookups (groundballs, x-checks)
state.current_on_base_code = on_base_code
return state
# =============================================================================
# On-Base Code Reference (Sequential Chart Encoding)
# =============================================================================
# 0 = empty 4 = R1+R2
# 1 = R1 5 = R1+R3
# 2 = R2 6 = R2+R3
# 3 = R3 7 = R1+R2+R3 (loaded)
OBC_LABELS = {
0: "empty",
1: "R1",
2: "R2",
3: "R3",
4: "R1_R2",
5: "R1_R3",
6: "R2_R3",
7: "loaded",
}
# =============================================================================
# Resolve Helpers
# =============================================================================
def resolve_simple(outcome: PlayOutcome, on_base_code: int) -> PlayResult:
"""
Resolve a play that doesn't need hit_location or special defensive setup.
Used for: hits, walks, HBP, strikeouts, lineouts, popouts, WP, PB.
"""
resolver = PlayResolver(league_id="sba", auto_mode=False)
state = make_state(on_base_code)
ab_roll = make_ab_roll(state.game_id)
return resolver.resolve_outcome(
outcome=outcome,
hit_location=None,
state=state,
defensive_decision=DefensiveDecision(),
offensive_decision=OffensiveDecision(),
ab_roll=ab_roll,
)
def resolve_with_location(
outcome: PlayOutcome,
on_base_code: int,
hit_location: str,
infield_depth: str = "normal",
outs: int = 0,
) -> PlayResult:
"""
Resolve a play that requires hit_location and/or defensive positioning.
Used for: groundballs, flyballs.
"""
resolver = PlayResolver(league_id="sba", auto_mode=False)
state = make_state(on_base_code, outs=outs)
ab_roll = make_ab_roll(state.game_id)
return resolver.resolve_outcome(
outcome=outcome,
hit_location=hit_location,
state=state,
defensive_decision=DefensiveDecision(infield_depth=infield_depth),
offensive_decision=OffensiveDecision(),
ab_roll=ab_roll,
)
# =============================================================================
# Assertion Helper
# =============================================================================
def assert_play_result(
result: PlayResult,
expected_batter: int | None,
expected_movements: list[tuple[int, int]],
expected_runs: int,
expected_outs: int,
context: str = "",
):
"""
Assert that a PlayResult matches expected values.
Args:
result: The actual PlayResult from resolve_outcome()
expected_batter: Expected batter_result (None=out, 1-4=base reached)
expected_movements: Expected runner movements as [(from_base, to_base), ...]
Only includes runners that MOVED. Order doesn't matter.
Movements with to_base=0 indicate runner is out.
expected_runs: Expected runs scored
expected_outs: Expected outs recorded
context: Description for error messages (e.g. "SINGLE_1 obc=3")
"""
prefix = f"[{context}] " if context else ""
# Check batter result
assert result.batter_result == expected_batter, (
f"{prefix}batter_result: expected {expected_batter}, got {result.batter_result}"
)
# Check runner movements (order-independent, excluding "hold" movements
# where from_base == to_base, which are informational only)
actual_movements = {
(a.from_base, a.to_base) for a in result.runners_advanced
if a.from_base != a.to_base
}
expected_set = set(expected_movements)
assert actual_movements == expected_set, (
f"{prefix}runner movements: expected {sorted(expected_set)}, "
f"got {sorted(actual_movements)}"
)
# Check runs scored
assert result.runs_scored == expected_runs, (
f"{prefix}runs_scored: expected {expected_runs}, got {result.runs_scored}"
)
# Check outs recorded
assert result.outs_recorded == expected_outs, (
f"{prefix}outs_recorded: expected {expected_outs}, got {result.outs_recorded}"
)

View File

@ -0,0 +1,493 @@
"""
Truth Table Tests: Groundball Outcomes
Verifies exact runner advancement for every (groundball_type, on_base_code,
infield_depth, hit_location) combination.
Groundball types: GROUNDBALL_A, GROUNDBALL_B, GROUNDBALL_C
Infield depth: "normal" (infield back), "infield_in", "corners_in"
Hit locations: 1B, 2B, SS, 3B, P, C (all infield positions)
Chart routing:
- 2 outs: Always Result 1 (batter out, runners hold) regardless of other factors
- Infield In: Applied when infield_in AND obc in {3,5,6,7} (runner on 3rd)
- Corners In: Applied when corners_in AND obc in {3,5,6,7} AND hit to corner (1B,3B,P,C)
- Infield Back: Default for all other scenarios
Result types (from runner_advancement.py):
1: Batter out, runners hold
2: Double play at 2nd and 1st (R1 out, batter out, others advance)
3: Batter out, all runners advance 1 base
4: Batter safe at 1st, R1 forced out at 2nd, others advance
5: Conditional on middle infield (2B/SS=Result 3, else=Result 1)
6: Conditional on right side (1B/2B=Result 3, else=Result 1)
7: Batter out, forced runners only advance
8: Same as Result 7
9: Batter out, R3 holds, R12nd
10: Double play at home and 1st (R3 out, batter out, others advance)
11: Batter safe at 1st, lead runner out, others advance
12: DECIDE opportunity (conservative default: batter out, runners hold;
1B/2BResult 3, 3BResult 1)
On-base codes (sequential chart encoding):
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
Author: Claude
Date: 2025-02-08
"""
import pytest
from app.config import PlayOutcome
from .conftest import OBC_LABELS, assert_play_result, resolve_with_location
# =============================================================================
# Infield Back Chart (Normal Defense)
# =============================================================================
# Reference: runner_advancement.py _apply_infield_back_chart()
#
# obc 0 (empty): A=1, B=1, C=1
# obc 1 (R1): A=2, B=4, C=3
# obc 2 (R2): A=6, B=6, C=3
# obc 3 (R3): A=5, B=5, C=3
# obc 4 (R1+R2): A=2, B=4, C=3
# obc 5 (R1+R3): A=2, B=4, C=3
# obc 6 (R2+R3): A=5, B=5, C=3
# obc 7 (loaded): A=2, B=4, C=3
#
# Results 5 and 6 are conditional on hit location:
# Result 5: 2B/SS → all advance (Result 3), else → hold (Result 1)
# Result 6: 1B/2B → all advance (Result 3), else → hold (Result 1)
# =============================================================================
# Truth Table: Infield Back (Normal Defense) - 0 outs
# =============================================================================
# Each entry: (outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs)
INFIELD_BACK_TRUTH_TABLE = [
# =========================================================================
# OBC 0 (Empty): All groundballs → Result 1 (batter out, runners hold)
# =========================================================================
(PlayOutcome.GROUNDBALL_A, 0, "SS", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 0, "SS", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_C, 0, "SS", "normal", None, [], 0, 1),
# =========================================================================
# OBC 1 (R1): A=2 (DP), B=4 (FC at 2nd), C=3 (advance)
# =========================================================================
# Result 2: R1 out at 2nd, batter out (DP). 0 outs → can turn DP.
(PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [(1, 0)], 0, 2),
# Result 4: R1 forced out at 2nd, batter safe at 1st
(PlayOutcome.GROUNDBALL_B, 1, "SS", "normal", 1, [(1, 0)], 0, 1),
# Result 3: Batter out, R1→2nd
(PlayOutcome.GROUNDBALL_C, 1, "SS", "normal", None, [(1, 2)], 0, 1),
# =========================================================================
# OBC 2 (R2): A=6 (conditional right), B=6, C=3 (advance)
# =========================================================================
# Result 6: 1B/2B → advance (Result 3), else → hold (Result 1)
# Hit to SS (not right side) → Result 1: batter out, R2 holds
(PlayOutcome.GROUNDBALL_A, 2, "SS", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 2, "SS", "normal", None, [], 0, 1),
# Hit to 2B (right side) → Result 3: batter out, R2→3rd
(PlayOutcome.GROUNDBALL_A, 2, "2B", "normal", None, [(2, 3)], 0, 1),
(PlayOutcome.GROUNDBALL_B, 2, "2B", "normal", None, [(2, 3)], 0, 1),
# Hit to 1B (right side) → Result 3: batter out, R2→3rd
(PlayOutcome.GROUNDBALL_A, 2, "1B", "normal", None, [(2, 3)], 0, 1),
(PlayOutcome.GROUNDBALL_B, 2, "1B", "normal", None, [(2, 3)], 0, 1),
# Result 3: Batter out, R2→3rd (unconditional)
(PlayOutcome.GROUNDBALL_C, 2, "SS", "normal", None, [(2, 3)], 0, 1),
# =========================================================================
# OBC 3 (R3): A=5 (conditional MIF), B=5, C=3 (advance)
# =========================================================================
# Result 5: 2B/SS → advance (Result 3), else → hold (Result 1)
# Hit to SS (MIF) → Result 3: batter out, R3 scores
(PlayOutcome.GROUNDBALL_A, 3, "SS", "normal", None, [(3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_B, 3, "SS", "normal", None, [(3, 4)], 1, 1),
# Hit to 2B (MIF) → Result 3: batter out, R3 scores
(PlayOutcome.GROUNDBALL_A, 3, "2B", "normal", None, [(3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_B, 3, "2B", "normal", None, [(3, 4)], 1, 1),
# Hit to 1B (not MIF) → Result 1: batter out, R3 holds
(PlayOutcome.GROUNDBALL_A, 3, "1B", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 3, "1B", "normal", None, [], 0, 1),
# Hit to 3B (not MIF) → Result 1: batter out, R3 holds
(PlayOutcome.GROUNDBALL_A, 3, "3B", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 3, "3B", "normal", None, [], 0, 1),
# Result 3: Batter out, R3 scores (unconditional)
(PlayOutcome.GROUNDBALL_C, 3, "SS", "normal", None, [(3, 4)], 1, 1),
# =========================================================================
# OBC 4 (R1+R2): A=2 (DP), B=4 (FC at 2nd), C=3 (advance)
# =========================================================================
# Result 2: DP at 2nd+1st. R1 out, batter out, R2 scores (per chart rules)
(PlayOutcome.GROUNDBALL_A, 4, "SS", "normal", None, [(1, 0), (2, 4)], 1, 2),
# Result 4: R1 forced out at 2nd, batter safe at 1st, R2→3rd
(PlayOutcome.GROUNDBALL_B, 4, "SS", "normal", 1, [(1, 0), (2, 3)], 0, 1),
# Result 3: Batter out, R1→2nd, R2→3rd
(PlayOutcome.GROUNDBALL_C, 4, "SS", "normal", None, [(1, 2), (2, 3)], 0, 1),
# =========================================================================
# OBC 5 (R1+R3): A=2 (DP), B=4 (FC at 2nd), C=3 (advance)
# =========================================================================
# Result 2: DP at 2nd+1st. R1 out, batter out, R3 scores
(PlayOutcome.GROUNDBALL_A, 5, "SS", "normal", None, [(1, 0), (3, 4)], 1, 2),
# Result 4: R1 forced out at 2nd, batter safe at 1st, R3 scores
(PlayOutcome.GROUNDBALL_B, 5, "SS", "normal", 1, [(1, 0), (3, 4)], 1, 1),
# Result 3: Batter out, R1→2nd, R3 scores
(PlayOutcome.GROUNDBALL_C, 5, "SS", "normal", None, [(1, 2), (3, 4)], 1, 1),
# =========================================================================
# OBC 6 (R2+R3): A=5 (conditional MIF), B=5, C=3 (advance)
# =========================================================================
# Result 5: 2B/SS → advance (Result 3), else → hold (Result 1)
# Hit to SS (MIF) → Result 3: batter out, R2→3rd, R3 scores
(PlayOutcome.GROUNDBALL_A, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_B, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1),
# Hit to 1B (not MIF) → Result 1: batter out, R2+R3 hold
(PlayOutcome.GROUNDBALL_A, 6, "1B", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 6, "1B", "normal", None, [], 0, 1),
# Result 3: Batter out, R2→3rd, R3 scores (unconditional)
(PlayOutcome.GROUNDBALL_C, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1),
# =========================================================================
# OBC 7 (Loaded): A=2 (DP), B=4 (FC at 2nd), C=3 (advance)
# =========================================================================
# Result 2: DP at 2nd+1st. R1 out, batter out, R2→3rd (actually R2 scores from 2nd), R3 scores
(PlayOutcome.GROUNDBALL_A, 7, "SS", "normal", None, [(1, 0), (2, 4), (3, 4)], 2, 2),
# Result 4: R1 forced out at 2nd, batter safe at 1st, R2→3rd, R3 scores
(PlayOutcome.GROUNDBALL_B, 7, "SS", "normal", 1, [(1, 0), (2, 3), (3, 4)], 1, 1),
# Result 3: Batter out, R1→2nd, R2→3rd, R3 scores
(PlayOutcome.GROUNDBALL_C, 7, "SS", "normal", None, [(1, 2), (2, 3), (3, 4)], 1, 1),
]
INFIELD_BACK_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}"
for outcome, obc, loc, depth, *_ in INFIELD_BACK_TRUTH_TABLE
]
# =============================================================================
# Truth Table: Infield In - 0 outs
# =============================================================================
# Only applies when obc in {3, 5, 6, 7} (runner on 3rd)
# Reference: runner_advancement.py _apply_infield_in_chart()
#
# obc 3 (R3): A=7, B=1, C varies by location
# obc 5 (R1+R3): A=7, B=9, C=12(SS/P/C) or 8(else)
# obc 6 (R2+R3): A=7, B=1, C=8
# obc 7 (loaded): A=10, B=11, C=11
#
# Result 7: Batter out, forced runners only advance
# Result 8: Same as Result 7
# Result 9: Batter out, R3 holds, R1→2nd
# Result 10: DP at home+1st (R3 out, batter out, R2→3rd, R1→2nd)
# Result 11: Batter safe at 1st, lead runner out, others advance
# Result 12: DECIDE (conservative default: SS/P/C → batter out runners hold,
# 1B/2B → Result 3, 3B → Result 1)
INFIELD_IN_TRUTH_TABLE = [
# =========================================================================
# OBC 3 (R3) with Infield In: A=7, B=1, C=12 (DECIDE)
# =========================================================================
# Result 7: Batter out, forced only. R3 NOT forced (no R1+R2), so R3 holds.
(PlayOutcome.GROUNDBALL_A, 3, "SS", "infield_in", None, [], 0, 1),
# Result 1: Batter out, runners hold
(PlayOutcome.GROUNDBALL_B, 3, "SS", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): SS → conservative (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "SS", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): P → conservative (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "P", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): 1B → Result 3 (batter out, R3 scores)
(PlayOutcome.GROUNDBALL_C, 3, "1B", "infield_in", None, [(3, 4)], 1, 1),
# Result 12 (DECIDE): 2B → Result 3 (batter out, R3 scores)
(PlayOutcome.GROUNDBALL_C, 3, "2B", "infield_in", None, [(3, 4)], 1, 1),
# Result 12 (DECIDE): 3B → Result 1 (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "3B", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): C → conservative (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "C", "infield_in", None, [], 0, 1),
# =========================================================================
# OBC 5 (R1+R3) with Infield In: A=7, B=9, C varies
# =========================================================================
# Result 7: Batter out, forced only. R1 forced→2nd, R3 NOT forced→holds.
(PlayOutcome.GROUNDBALL_A, 5, "SS", "infield_in", None, [(1, 2)], 0, 1),
# Result 9: Batter out, R3 holds, R1→2nd
(PlayOutcome.GROUNDBALL_B, 5, "SS", "infield_in", None, [(1, 2)], 0, 1),
# Result 12 (DECIDE): SS → conservative (batter out, R1+R3 hold)
(PlayOutcome.GROUNDBALL_C, 5, "SS", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): P → conservative (batter out, R1+R3 hold)
(PlayOutcome.GROUNDBALL_C, 5, "P", "infield_in", None, [], 0, 1),
# Result 12 (DECIDE): C → conservative (batter out, R1+R3 hold)
(PlayOutcome.GROUNDBALL_C, 5, "C", "infield_in", None, [], 0, 1),
# Result 8: 1B → batter out, forced only. R1 forced→2nd, R3 NOT forced→holds.
(PlayOutcome.GROUNDBALL_C, 5, "1B", "infield_in", None, [(1, 2)], 0, 1),
# Result 8: 2B → same as above
(PlayOutcome.GROUNDBALL_C, 5, "2B", "infield_in", None, [(1, 2)], 0, 1),
# Result 8: 3B → same as above
(PlayOutcome.GROUNDBALL_C, 5, "3B", "infield_in", None, [(1, 2)], 0, 1),
# =========================================================================
# OBC 6 (R2+R3) with Infield In: A=7, B=1, C=8
# =========================================================================
# Result 7: Batter out, forced only. R2 NOT forced (no R1), R3 NOT forced. Both hold.
(PlayOutcome.GROUNDBALL_A, 6, "SS", "infield_in", None, [], 0, 1),
# Result 1: Batter out, runners hold
(PlayOutcome.GROUNDBALL_B, 6, "SS", "infield_in", None, [], 0, 1),
# Result 8: Same as Result 7. Batter out, no forced runners. R2+R3 hold.
(PlayOutcome.GROUNDBALL_C, 6, "SS", "infield_in", None, [], 0, 1),
# =========================================================================
# OBC 7 (Loaded) with Infield In: A=10, B=11, C=11
# =========================================================================
# Result 10: DP at home+1st. R3 out at home, batter out. R2→3rd, R1→2nd. 0 runs (R3 out).
(PlayOutcome.GROUNDBALL_A, 7, "SS", "infield_in", None, [(3, 0), (2, 3), (1, 2)], 0, 2),
# Result 11: Batter safe at 1st, lead runner (R3) out. R2→3rd, R1→2nd.
(PlayOutcome.GROUNDBALL_B, 7, "SS", "infield_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1),
# Result 11: Same as above
(PlayOutcome.GROUNDBALL_C, 7, "SS", "infield_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1),
]
INFIELD_IN_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}"
for outcome, obc, loc, depth, *_ in INFIELD_IN_TRUTH_TABLE
]
# =============================================================================
# Truth Table: Corners In
# =============================================================================
# Corners In uses Infield In rules when hit to corner positions (1B, 3B, P, C)
# and Infield Back rules when hit to middle infield (2B, SS).
# Only applies when obc in {3, 5, 6, 7} (runner on 3rd).
CORNERS_IN_TRUTH_TABLE = [
# =========================================================================
# OBC 3 (R3) with Corners In
# =========================================================================
# Hit to 1B (corner) → Infield In chart: A=7, B=1, C=12
# Result 7: Batter out, forced only. R3 NOT forced→holds.
(PlayOutcome.GROUNDBALL_A, 3, "1B", "corners_in", None, [], 0, 1),
# Result 1: Batter out, runners hold
(PlayOutcome.GROUNDBALL_B, 3, "1B", "corners_in", None, [], 0, 1),
# Result 12 (DECIDE): 1B → Result 3 (batter out, R3 scores)
(PlayOutcome.GROUNDBALL_C, 3, "1B", "corners_in", None, [(3, 4)], 1, 1),
# Hit to 3B (corner) → Infield In chart
(PlayOutcome.GROUNDBALL_A, 3, "3B", "corners_in", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 3, "3B", "corners_in", None, [], 0, 1),
# Result 12 (DECIDE): 3B → Result 1 (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "3B", "corners_in", None, [], 0, 1),
# Hit to P (corner) → Infield In chart
(PlayOutcome.GROUNDBALL_A, 3, "P", "corners_in", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 3, "P", "corners_in", None, [], 0, 1),
# Result 12 (DECIDE): P → conservative (batter out, R3 holds)
(PlayOutcome.GROUNDBALL_C, 3, "P", "corners_in", None, [], 0, 1),
# Hit to SS (middle) → Infield Back chart: A=5, B=5, C=3
# Result 5: SS is MIF → Result 3 (batter out, R3 scores)
(PlayOutcome.GROUNDBALL_A, 3, "SS", "corners_in", None, [(3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_B, 3, "SS", "corners_in", None, [(3, 4)], 1, 1),
# Result 3: Batter out, R3 scores
(PlayOutcome.GROUNDBALL_C, 3, "SS", "corners_in", None, [(3, 4)], 1, 1),
# Hit to 2B (middle) → Infield Back chart: A=5, B=5, C=3
# Result 5: 2B is MIF → Result 3 (batter out, R3 scores)
(PlayOutcome.GROUNDBALL_A, 3, "2B", "corners_in", None, [(3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_B, 3, "2B", "corners_in", None, [(3, 4)], 1, 1),
(PlayOutcome.GROUNDBALL_C, 3, "2B", "corners_in", None, [(3, 4)], 1, 1),
# =========================================================================
# OBC 7 (Loaded) with Corners In - corner hit
# =========================================================================
# Hit to 3B (corner) → Infield In chart: A=10, B=11, C=11
# Result 10: DP at home+1st
(PlayOutcome.GROUNDBALL_A, 7, "3B", "corners_in", None, [(3, 0), (2, 3), (1, 2)], 0, 2),
# Result 11: Batter safe, lead runner (R3) out
(PlayOutcome.GROUNDBALL_B, 7, "3B", "corners_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1),
(PlayOutcome.GROUNDBALL_C, 7, "3B", "corners_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1),
# Hit to SS (middle) → Infield Back chart: A=2, B=4, C=3
# Result 2: DP at 2nd+1st, R1 out, batter out, R2+R3 advance/score
(PlayOutcome.GROUNDBALL_A, 7, "SS", "corners_in", None, [(1, 0), (2, 4), (3, 4)], 2, 2),
# Result 4: R1 out at 2nd, batter safe, R2→3rd, R3 scores
(PlayOutcome.GROUNDBALL_B, 7, "SS", "corners_in", 1, [(1, 0), (2, 3), (3, 4)], 1, 1),
# Result 3: Batter out, R1→2nd, R2→3rd, R3 scores
(PlayOutcome.GROUNDBALL_C, 7, "SS", "corners_in", None, [(1, 2), (2, 3), (3, 4)], 1, 1),
]
CORNERS_IN_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}"
for outcome, obc, loc, depth, *_ in CORNERS_IN_TRUTH_TABLE
]
# =============================================================================
# Truth Table: 2 Outs Override
# =============================================================================
# With 2 outs, ALL groundballs → Result 1 (batter out, runners hold)
# regardless of groundball type, defense, or hit location.
TWO_OUTS_TRUTH_TABLE = [
# Sample across different obc/depth/location combos to verify override
(PlayOutcome.GROUNDBALL_A, 0, "SS", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_A, 5, "SS", "infield_in", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_A, 7, "SS", "infield_in", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_B, 3, "1B", "corners_in", None, [], 0, 1),
(PlayOutcome.GROUNDBALL_C, 7, "3B", "infield_in", None, [], 0, 1),
]
TWO_OUTS_IDS = [
f"2outs_{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}"
for outcome, obc, loc, depth, *_ in TWO_OUTS_TRUTH_TABLE
]
# =============================================================================
# Truth Table: Result 2 DP behavior with 1 out
# =============================================================================
# Result 2 with 1 out: Still a valid DP (0+2=2 outs, inning not over, runners advance)
# This tests the DP at 1 out specifically.
ONE_OUT_DP_TRUTH_TABLE = [
# R1 only, 1 out: DP at 2nd+1st. Total outs = 1+2 = 3 → inning over.
# With 3 outs, runners do NOT advance (inning ends).
(PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [(1, 0)], 0, 2),
# R1+R3, 1 out: DP at 2nd+1st. Total outs = 3 → inning over, R3 does NOT score.
(PlayOutcome.GROUNDBALL_A, 5, "SS", "normal", None, [(1, 0)], 0, 2),
# Loaded, 1 out: DP at 2nd+1st. Total outs = 3 → inning over, no runs score.
(PlayOutcome.GROUNDBALL_A, 7, "SS", "normal", None, [(1, 0)], 0, 2),
]
ONE_OUT_DP_IDS = [
f"1out_{outcome.value}__{OBC_LABELS[obc]}__{loc}"
for outcome, obc, loc, depth, *_ in ONE_OUT_DP_TRUTH_TABLE
]
# =============================================================================
# Tests: Infield Back (Normal Defense)
# =============================================================================
class TestInfieldBackTruthTable:
"""Verify groundball advancement with normal (infield back) defense at 0 outs."""
@pytest.mark.parametrize(
"outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs",
INFIELD_BACK_TRUTH_TABLE,
ids=INFIELD_BACK_IDS,
)
def test_infield_back(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs):
"""Verify groundball with infield back produces correct advancement."""
result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}",
)
# =============================================================================
# Tests: Infield In
# =============================================================================
class TestInfieldInTruthTable:
"""Verify groundball advancement with infield in defense at 0 outs."""
@pytest.mark.parametrize(
"outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs",
INFIELD_IN_TRUTH_TABLE,
ids=INFIELD_IN_IDS,
)
def test_infield_in(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs):
"""Verify groundball with infield in produces correct advancement."""
result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}",
)
# =============================================================================
# Tests: Corners In
# =============================================================================
class TestCornersInTruthTable:
"""Verify groundball advancement with corners in defense at 0 outs."""
@pytest.mark.parametrize(
"outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs",
CORNERS_IN_TRUTH_TABLE,
ids=CORNERS_IN_IDS,
)
def test_corners_in(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs):
"""Verify groundball with corners in produces correct advancement."""
result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}",
)
# =============================================================================
# Tests: 2 Outs Override
# =============================================================================
class TestTwoOutsOverride:
"""Verify all groundballs produce Result 1 with 2 outs regardless of other factors."""
@pytest.mark.parametrize(
"outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs",
TWO_OUTS_TRUTH_TABLE,
ids=TWO_OUTS_IDS,
)
def test_two_outs_always_result_1(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs):
"""With 2 outs, all groundballs should produce batter out, runners hold."""
result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=2)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"2outs {outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}",
)
# =============================================================================
# Tests: 1 Out Double Play
# =============================================================================
class TestOneOutDoublePlay:
"""Verify DP behavior at 1 out (inning ends, runners don't advance)."""
@pytest.mark.parametrize(
"outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs",
ONE_OUT_DP_TRUTH_TABLE,
ids=ONE_OUT_DP_IDS,
)
def test_one_out_dp(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs):
"""DP with 1 out ends the inning; no runners should advance or score."""
result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=1)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"1out {outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location}",
)

View File

@ -0,0 +1,169 @@
"""
Truth Table Tests: Hit Outcomes
Verifies exact runner advancement for every (hit_type, on_base_code) combination.
Hit types tested:
SINGLE_1: R3 scores, R23rd, R12nd
SINGLE_2: R3 scores, R2 scores, R13rd
DOUBLE_2: All runners advance exactly 2 bases (capped at home)
DOUBLE_3: All runners advance exactly 3 bases (all score from any base)
TRIPLE: All runners score, batter to 3rd
HOMERUN: All runners score, batter scores
On-base codes (sequential chart encoding):
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
Each entry: (outcome, obc, batter_result, runner_movements, runs, outs)
Author: Claude
Date: 2025-02-08
"""
import pytest
from app.config import PlayOutcome
from .conftest import OBC_LABELS, assert_play_result, resolve_simple
# =============================================================================
# Truth Table
# =============================================================================
# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs)
HITS_TRUTH_TABLE = [
# =========================================================================
# SINGLE_1: R3 scores, R2→3rd, R1→2nd
# =========================================================================
(PlayOutcome.SINGLE_1, 0, 1, [], 0, 0), # Empty
(PlayOutcome.SINGLE_1, 1, 1, [(1, 2)], 0, 0), # R1→2nd
(PlayOutcome.SINGLE_1, 2, 1, [(2, 3)], 0, 0), # R2→3rd
(PlayOutcome.SINGLE_1, 3, 1, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.SINGLE_1, 4, 1, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2→3rd
(PlayOutcome.SINGLE_1, 5, 1, [(1, 2), (3, 4)], 1, 0), # R1→2nd, R3 scores
(PlayOutcome.SINGLE_1, 6, 1, [(2, 3), (3, 4)], 1, 0), # R2→3rd, R3 scores
(PlayOutcome.SINGLE_1, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0), # R1→2nd, R2→3rd, R3 scores
# =========================================================================
# SINGLE_2: R3 scores, R2 scores, R1→3rd
# =========================================================================
(PlayOutcome.SINGLE_2, 0, 1, [], 0, 0), # Empty
(PlayOutcome.SINGLE_2, 1, 1, [(1, 3)], 0, 0), # R1→3rd
(PlayOutcome.SINGLE_2, 2, 1, [(2, 4)], 1, 0), # R2 scores
(PlayOutcome.SINGLE_2, 3, 1, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.SINGLE_2, 4, 1, [(1, 3), (2, 4)], 1, 0), # R1→3rd, R2 scores
(PlayOutcome.SINGLE_2, 5, 1, [(1, 3), (3, 4)], 1, 0), # R1→3rd, R3 scores
(PlayOutcome.SINGLE_2, 6, 1, [(2, 4), (3, 4)], 2, 0), # R2+R3 score
(PlayOutcome.SINGLE_2, 7, 1, [(1, 3), (2, 4), (3, 4)], 2, 0), # R1→3rd, R2+R3 score
# =========================================================================
# DOUBLE_2: All runners advance exactly 2 bases (capped at 4=home)
# R1→3rd (1+2), R2→home (2+2), R3→home (3+2→4)
# =========================================================================
(PlayOutcome.DOUBLE_2, 0, 2, [], 0, 0), # Empty
(PlayOutcome.DOUBLE_2, 1, 2, [(1, 3)], 0, 0), # R1→3rd
(PlayOutcome.DOUBLE_2, 2, 2, [(2, 4)], 1, 0), # R2 scores
(PlayOutcome.DOUBLE_2, 3, 2, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.DOUBLE_2, 4, 2, [(1, 3), (2, 4)], 1, 0), # R1→3rd, R2 scores
(PlayOutcome.DOUBLE_2, 5, 2, [(1, 3), (3, 4)], 1, 0), # R1→3rd, R3 scores
(PlayOutcome.DOUBLE_2, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 score
(PlayOutcome.DOUBLE_2, 7, 2, [(1, 3), (2, 4), (3, 4)], 2, 0), # R1→3rd, R2+R3 score
# =========================================================================
# DOUBLE_3: All runners advance exactly 3 bases (all score from any base)
# R1→home (1+3), R2→home (2+3→4), R3→home (3+3→4)
# =========================================================================
(PlayOutcome.DOUBLE_3, 0, 2, [], 0, 0), # Empty
(PlayOutcome.DOUBLE_3, 1, 2, [(1, 4)], 1, 0), # R1 scores
(PlayOutcome.DOUBLE_3, 2, 2, [(2, 4)], 1, 0), # R2 scores
(PlayOutcome.DOUBLE_3, 3, 2, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.DOUBLE_3, 4, 2, [(1, 4), (2, 4)], 2, 0), # R1+R2 score
(PlayOutcome.DOUBLE_3, 5, 2, [(1, 4), (3, 4)], 2, 0), # R1+R3 score
(PlayOutcome.DOUBLE_3, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 score
(PlayOutcome.DOUBLE_3, 7, 2, [(1, 4), (2, 4), (3, 4)], 3, 0), # All score
# =========================================================================
# TRIPLE: All runners score, batter to 3rd
# =========================================================================
(PlayOutcome.TRIPLE, 0, 3, [], 0, 0), # Empty
(PlayOutcome.TRIPLE, 1, 3, [(1, 4)], 1, 0), # R1 scores
(PlayOutcome.TRIPLE, 2, 3, [(2, 4)], 1, 0), # R2 scores
(PlayOutcome.TRIPLE, 3, 3, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.TRIPLE, 4, 3, [(1, 4), (2, 4)], 2, 0), # R1+R2 score
(PlayOutcome.TRIPLE, 5, 3, [(1, 4), (3, 4)], 2, 0), # R1+R3 score
(PlayOutcome.TRIPLE, 6, 3, [(2, 4), (3, 4)], 2, 0), # R2+R3 score
(PlayOutcome.TRIPLE, 7, 3, [(1, 4), (2, 4), (3, 4)], 3, 0), # All score
# =========================================================================
# HOMERUN: All runners score + batter scores (batter_result=4)
# runs = number of runners + 1 (batter)
# =========================================================================
(PlayOutcome.HOMERUN, 0, 4, [], 1, 0), # Solo HR
(PlayOutcome.HOMERUN, 1, 4, [(1, 4)], 2, 0), # 2-run HR
(PlayOutcome.HOMERUN, 2, 4, [(2, 4)], 2, 0), # 2-run HR
(PlayOutcome.HOMERUN, 3, 4, [(3, 4)], 2, 0), # 2-run HR
(PlayOutcome.HOMERUN, 4, 4, [(1, 4), (2, 4)], 3, 0), # 3-run HR
(PlayOutcome.HOMERUN, 5, 4, [(1, 4), (3, 4)], 3, 0), # 3-run HR
(PlayOutcome.HOMERUN, 6, 4, [(2, 4), (3, 4)], 3, 0), # 3-run HR
(PlayOutcome.HOMERUN, 7, 4, [(1, 4), (2, 4), (3, 4)], 4, 0), # Grand slam
]
# Generate human-readable test IDs
HITS_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}"
for outcome, obc, *_ in HITS_TRUTH_TABLE
]
# =============================================================================
# Tests
# =============================================================================
class TestHitsTruthTable:
"""Verify every hit outcome × on-base code produces the exact expected result."""
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
HITS_TRUTH_TABLE,
ids=HITS_IDS,
)
def test_hit_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs):
"""
Verify that a hit outcome with a given on-base situation produces
the exact expected batter result, runner movements, runs, and outs.
"""
result = resolve_simple(outcome, obc)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
)
class TestHitsTruthTableCompleteness:
"""Verify the truth table covers every hit outcome × on-base code."""
def test_all_hit_outcomes_covered(self):
"""Every hit outcome must have exactly 8 entries (one per on-base code)."""
hit_outcomes = {
PlayOutcome.SINGLE_1,
PlayOutcome.SINGLE_2,
PlayOutcome.DOUBLE_2,
PlayOutcome.DOUBLE_3,
PlayOutcome.TRIPLE,
PlayOutcome.HOMERUN,
}
for outcome in hit_outcomes:
entries = [row for row in HITS_TRUTH_TABLE if row[0] == outcome]
obcs = {row[1] for row in entries}
assert len(entries) == 8, (
f"{outcome.value} has {len(entries)} entries, expected 8"
)
assert obcs == set(range(8)), (
f"{outcome.value} missing on-base codes: {set(range(8)) - obcs}"
)

View File

@ -0,0 +1,182 @@
"""
Truth Table Tests: Simple Outs & Interrupt Plays
Simple outs: STRIKEOUT, LINEOUT, POPOUT
- Always 1 out, 0 runs, no runner movement, batter is out
Interrupt plays: WILD_PITCH, PASSED_BALL
- All runners advance exactly 1 base, batter stays at plate (not a PA)
- 0 outs recorded
On-base codes (sequential chart encoding):
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
Author: Claude
Date: 2025-02-08
"""
import pytest
from app.config import PlayOutcome
from .conftest import OBC_LABELS, assert_play_result, resolve_simple
# =============================================================================
# Truth Table: Simple Outs
# =============================================================================
# These are trivial (same result for all on-base codes) but included for
# completeness and to catch regressions if someone adds runner movement logic.
SIMPLE_OUTS_TRUTH_TABLE = [
# =========================================================================
# STRIKEOUT: 1 out, 0 runs, no movement (all 8 on-base codes)
# =========================================================================
(PlayOutcome.STRIKEOUT, 0, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 1, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 2, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 3, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 4, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 5, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 6, None, [], 0, 1),
(PlayOutcome.STRIKEOUT, 7, None, [], 0, 1),
# =========================================================================
# LINEOUT: 1 out, 0 runs, no movement
# =========================================================================
(PlayOutcome.LINEOUT, 0, None, [], 0, 1),
(PlayOutcome.LINEOUT, 1, None, [], 0, 1),
(PlayOutcome.LINEOUT, 2, None, [], 0, 1),
(PlayOutcome.LINEOUT, 3, None, [], 0, 1),
(PlayOutcome.LINEOUT, 4, None, [], 0, 1),
(PlayOutcome.LINEOUT, 5, None, [], 0, 1),
(PlayOutcome.LINEOUT, 6, None, [], 0, 1),
(PlayOutcome.LINEOUT, 7, None, [], 0, 1),
# =========================================================================
# POPOUT: 1 out, 0 runs, no movement
# =========================================================================
(PlayOutcome.POPOUT, 0, None, [], 0, 1),
(PlayOutcome.POPOUT, 1, None, [], 0, 1),
(PlayOutcome.POPOUT, 2, None, [], 0, 1),
(PlayOutcome.POPOUT, 3, None, [], 0, 1),
(PlayOutcome.POPOUT, 4, None, [], 0, 1),
(PlayOutcome.POPOUT, 5, None, [], 0, 1),
(PlayOutcome.POPOUT, 6, None, [], 0, 1),
(PlayOutcome.POPOUT, 7, None, [], 0, 1),
]
SIMPLE_OUTS_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}"
for outcome, obc, *_ in SIMPLE_OUTS_TRUTH_TABLE
]
# =============================================================================
# Truth Table: Interrupt Plays (WP/PB)
# =============================================================================
# All runners advance exactly 1 base (capped at 4=home). Batter stays at plate.
INTERRUPTS_TRUTH_TABLE = [
# =========================================================================
# WILD_PITCH: All runners advance 1 base, batter stays (None)
# =========================================================================
(PlayOutcome.WILD_PITCH, 0, None, [], 0, 0), # Empty
(PlayOutcome.WILD_PITCH, 1, None, [(1, 2)], 0, 0), # R1→2nd
(PlayOutcome.WILD_PITCH, 2, None, [(2, 3)], 0, 0), # R2→3rd
(PlayOutcome.WILD_PITCH, 3, None, [(3, 4)], 1, 0), # R3 scores
(PlayOutcome.WILD_PITCH, 4, None, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2→3rd
(PlayOutcome.WILD_PITCH, 5, None, [(1, 2), (3, 4)], 1, 0), # R1→2nd, R3 scores
(PlayOutcome.WILD_PITCH, 6, None, [(2, 3), (3, 4)], 1, 0), # R2→3rd, R3 scores
(PlayOutcome.WILD_PITCH, 7, None, [(1, 2), (2, 3), (3, 4)], 1, 0), # All advance, R3 scores
# =========================================================================
# PASSED_BALL: Same advancement as wild pitch
# =========================================================================
(PlayOutcome.PASSED_BALL, 0, None, [], 0, 0),
(PlayOutcome.PASSED_BALL, 1, None, [(1, 2)], 0, 0),
(PlayOutcome.PASSED_BALL, 2, None, [(2, 3)], 0, 0),
(PlayOutcome.PASSED_BALL, 3, None, [(3, 4)], 1, 0),
(PlayOutcome.PASSED_BALL, 4, None, [(1, 2), (2, 3)], 0, 0),
(PlayOutcome.PASSED_BALL, 5, None, [(1, 2), (3, 4)], 1, 0),
(PlayOutcome.PASSED_BALL, 6, None, [(2, 3), (3, 4)], 1, 0),
(PlayOutcome.PASSED_BALL, 7, None, [(1, 2), (2, 3), (3, 4)], 1, 0),
]
INTERRUPTS_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}"
for outcome, obc, *_ in INTERRUPTS_TRUTH_TABLE
]
# =============================================================================
# Tests: Simple Outs
# =============================================================================
class TestSimpleOutsTruthTable:
"""Verify every simple out × on-base code produces exact expected result."""
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
SIMPLE_OUTS_TRUTH_TABLE,
ids=SIMPLE_OUTS_IDS,
)
def test_simple_out(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs):
"""Simple outs: 1 out, 0 runs, no runner movement regardless of base situation."""
result = resolve_simple(outcome, obc)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
)
# =============================================================================
# Tests: Interrupt Plays
# =============================================================================
class TestInterruptsTruthTable:
"""Verify every WP/PB × on-base code produces exact expected result."""
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
INTERRUPTS_TRUTH_TABLE,
ids=INTERRUPTS_IDS,
)
def test_interrupt_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs):
"""WP/PB: all runners advance 1 base, batter stays at plate."""
result = resolve_simple(outcome, obc)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
)
# =============================================================================
# Completeness
# =============================================================================
class TestSimpleOutsCompleteness:
"""Verify truth tables cover all outcomes × all on-base codes."""
def test_simple_outs_complete(self):
"""Every simple out outcome must have exactly 8 entries."""
for outcome in [PlayOutcome.STRIKEOUT, PlayOutcome.LINEOUT, PlayOutcome.POPOUT]:
entries = [r for r in SIMPLE_OUTS_TRUTH_TABLE if r[0] == outcome]
obcs = {r[1] for r in entries}
assert len(entries) == 8, f"{outcome.value} has {len(entries)} entries"
assert obcs == set(range(8)), f"{outcome.value} missing obcs: {set(range(8)) - obcs}"
def test_interrupts_complete(self):
"""Every interrupt outcome must have exactly 8 entries."""
for outcome in [PlayOutcome.WILD_PITCH, PlayOutcome.PASSED_BALL]:
entries = [r for r in INTERRUPTS_TRUTH_TABLE if r[0] == outcome]
obcs = {r[1] for r in entries}
assert len(entries) == 8, f"{outcome.value} has {len(entries)} entries"
assert obcs == set(range(8)), f"{outcome.value} missing obcs: {set(range(8)) - obcs}"

View File

@ -0,0 +1,124 @@
"""
Truth Table Tests: Uncapped Hit Fallback Outcomes
Verifies that SINGLE_UNCAPPED and DOUBLE_UNCAPPED produce the correct fallback
advancement when no eligible runners exist for the interactive decision tree.
When GameEngine determines no decision is needed (no eligible lead runner),
the PlayResolver handles the outcome directly:
- SINGLE_UNCAPPED SINGLE_1 equivalent (R3 scores, R23rd, R12nd)
- DOUBLE_UNCAPPED DOUBLE_2 equivalent (R13rd, R2/R3 score)
The interactive decision tree (handled by GameEngine) is tested separately
in test_uncapped_hit_workflow.py.
Fallback conditions:
SINGLE_UNCAPPED: No R1 AND no R2 on_base_codes 0 (empty), 3 (R3 only)
DOUBLE_UNCAPPED: No R1 on_base_codes 0 (empty), 2 (R2), 3 (R3), 6 (R2+R3)
On-base codes (sequential chart encoding):
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
Author: Claude
Date: 2025-02-11
"""
import pytest
from app.config import PlayOutcome
from .conftest import OBC_LABELS, assert_play_result, resolve_simple
# =============================================================================
# Truth Table
# =============================================================================
# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs)
UNCAPPED_FALLBACK_TRUTH_TABLE = [
# =========================================================================
# SINGLE_UNCAPPED fallback: Same as SINGLE_1 (R3 scores, R2→3rd, R1→2nd)
# Only these on_base_codes reach PlayResolver (no R1 AND no R2):
# 0 = empty, 3 = R3 only
# =========================================================================
(PlayOutcome.SINGLE_UNCAPPED, 0, 1, [], 0, 0), # Empty - just batter to 1st
(PlayOutcome.SINGLE_UNCAPPED, 3, 1, [(3, 4)], 1, 0), # R3 scores
# =========================================================================
# DOUBLE_UNCAPPED fallback: Same as DOUBLE_2 (all runners +2 bases)
# Only these on_base_codes reach PlayResolver (no R1):
# 0 = empty, 2 = R2, 3 = R3, 6 = R2+R3
# =========================================================================
(PlayOutcome.DOUBLE_UNCAPPED, 0, 2, [], 0, 0), # Empty - batter to 2nd
(PlayOutcome.DOUBLE_UNCAPPED, 2, 2, [(2, 4)], 1, 0), # R2 scores (+2 = home)
(PlayOutcome.DOUBLE_UNCAPPED, 3, 2, [(3, 4)], 1, 0), # R3 scores (+2 = home)
(PlayOutcome.DOUBLE_UNCAPPED, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 both score
]
# Generate human-readable test IDs
UNCAPPED_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}"
for outcome, obc, *_ in UNCAPPED_FALLBACK_TRUTH_TABLE
]
# =============================================================================
# Tests
# =============================================================================
class TestUncappedFallbackTruthTable:
"""
Verify that uncapped hit outcomes without eligible runners produce
the correct standard advancement (SINGLE_1 / DOUBLE_2 equivalent).
"""
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
UNCAPPED_FALLBACK_TRUTH_TABLE,
ids=UNCAPPED_IDS,
)
def test_uncapped_fallback_advancement(
self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs
):
"""
Verify that an uncapped hit with no eligible runners for the decision
tree produces the exact expected batter result, runner movements,
runs, and outs equivalent to the standard SINGLE_1 / DOUBLE_2 rules.
"""
result = resolve_simple(outcome, obc)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
)
class TestUncappedFallbackCompleteness:
"""Verify the truth table covers all fallback on_base_codes."""
def test_single_uncapped_fallback_codes(self):
"""
SINGLE_UNCAPPED should only reach PlayResolver for obc 0 and 3
(empty and R3 only no R1 or R2 to trigger decision tree).
"""
entries = [
row for row in UNCAPPED_FALLBACK_TRUTH_TABLE
if row[0] == PlayOutcome.SINGLE_UNCAPPED
]
obcs = {row[1] for row in entries}
assert obcs == {0, 3}, f"Expected {{0, 3}}, got {obcs}"
def test_double_uncapped_fallback_codes(self):
"""
DOUBLE_UNCAPPED should only reach PlayResolver for obc 0, 2, 3, 6
(no R1 to trigger decision tree).
"""
entries = [
row for row in UNCAPPED_FALLBACK_TRUTH_TABLE
if row[0] == PlayOutcome.DOUBLE_UNCAPPED
]
obcs = {row[1] for row in entries}
assert obcs == {0, 2, 3, 6}, f"Expected {{0, 2, 3, 6}}, got {obcs}"

View File

@ -0,0 +1,129 @@
"""
Truth Table Tests: Walk & HBP Outcomes
Verifies exact runner advancement for every (walk_type, on_base_code) combination.
Walk advancement rule: Batter goes to 1st. Only FORCED runners advance.
A runner is forced when all bases between them and 1st (inclusive) are occupied.
WALK: batter to 1st, forced runners advance, is_walk=True
HIT_BY_PITCH: batter to 1st, forced runners advance, is_walk=False
On-base codes (sequential chart encoding):
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
Author: Claude
Date: 2025-02-08
"""
import pytest
from app.config import PlayOutcome
from .conftest import OBC_LABELS, assert_play_result, resolve_simple
# =============================================================================
# Truth Table
# =============================================================================
# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs)
WALKS_TRUTH_TABLE = [
# =========================================================================
# WALK: Batter to 1st, forced runners advance 1 base
#
# Forced chain: R1 always forced. R2 forced only if R1 present.
# R3 forced only if R1 AND R2 present (bases loaded).
# =========================================================================
(PlayOutcome.WALK, 0, 1, [], 0, 0), # Empty - just batter
(PlayOutcome.WALK, 1, 1, [(1, 2)], 0, 0), # R1 forced→2nd
(PlayOutcome.WALK, 2, 1, [], 0, 0), # R2 NOT forced (no R1)
(PlayOutcome.WALK, 3, 1, [], 0, 0), # R3 NOT forced (no R1)
(PlayOutcome.WALK, 4, 1, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2 forced→3rd
(PlayOutcome.WALK, 5, 1, [(1, 2)], 0, 0), # R1 forced→2nd, R3 NOT forced
(PlayOutcome.WALK, 6, 1, [], 0, 0), # R2+R3 NOT forced (no R1)
(PlayOutcome.WALK, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0), # Loaded: all forced, R3 scores
# =========================================================================
# HIT_BY_PITCH: Same advancement as walk, different stat classification
# =========================================================================
(PlayOutcome.HIT_BY_PITCH, 0, 1, [], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 1, 1, [(1, 2)], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 2, 1, [], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 3, 1, [], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 4, 1, [(1, 2), (2, 3)], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 5, 1, [(1, 2)], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 6, 1, [], 0, 0),
(PlayOutcome.HIT_BY_PITCH, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0),
]
WALKS_IDS = [
f"{outcome.value}__{OBC_LABELS[obc]}"
for outcome, obc, *_ in WALKS_TRUTH_TABLE
]
# =============================================================================
# Tests
# =============================================================================
class TestWalksTruthTable:
"""Verify every walk/HBP × on-base code produces the exact expected result."""
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
WALKS_TRUTH_TABLE,
ids=WALKS_IDS,
)
def test_walk_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs):
"""
Verify that a walk/HBP with a given on-base situation produces
the exact expected batter result, runner movements, runs, and outs.
"""
result = resolve_simple(outcome, obc)
assert_play_result(
result,
expected_batter=exp_batter,
expected_movements=exp_moves,
expected_runs=exp_runs,
expected_outs=exp_outs,
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
)
@pytest.mark.parametrize(
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
WALKS_TRUTH_TABLE,
ids=WALKS_IDS,
)
def test_walk_stat_flags(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs):
"""
Verify stat classification: WALK has is_walk=True, HBP has is_walk=False.
Neither is counted as a hit.
"""
result = resolve_simple(outcome, obc)
assert result.is_hit is False, f"{outcome.value} should not be a hit"
assert result.is_out is False, f"{outcome.value} should not be an out"
if outcome == PlayOutcome.WALK:
assert result.is_walk is True, "WALK should have is_walk=True"
else:
assert result.is_walk is False, "HBP should have is_walk=False"
class TestWalksTruthTableCompleteness:
"""Verify the truth table covers every walk outcome × on-base code."""
def test_all_walk_outcomes_covered(self):
"""Every walk outcome must have exactly 8 entries."""
walk_outcomes = {PlayOutcome.WALK, PlayOutcome.HIT_BY_PITCH}
for outcome in walk_outcomes:
entries = [row for row in WALKS_TRUTH_TABLE if row[0] == outcome]
obcs = {row[1] for row in entries}
assert len(entries) == 8, (
f"{outcome.value} has {len(entries)} entries, expected 8"
)
assert obcs == set(range(8)), (
f"{outcome.value} missing on-base codes: {set(range(8)) - obcs}"
)

View File

@ -0,0 +1,465 @@
"""
Unit tests for PendingXCheck model.
Tests the interactive x-check state model including validation,
field constraints, and workflow state tracking.
"""
import pytest
from pydantic import ValidationError
from app.models.game_models import PendingXCheck
class TestPendingXCheckCreation:
"""Test PendingXCheck model creation and basic validation."""
def test_create_minimal_pending_x_check(self):
"""Should create PendingXCheck with required fields only."""
pending = PendingXCheck(
position="SS",
ab_roll_id="test123",
d20_roll=12,
d6_individual=[3, 4, 5],
d6_total=12,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=42,
)
assert pending.position == "SS"
assert pending.ab_roll_id == "test123"
assert pending.d20_roll == 12
assert pending.d6_individual == [3, 4, 5]
assert pending.d6_total == 12
assert pending.chart_row == ["G1", "G2", "G3", "SI1", "SI2"]
assert pending.chart_type == "infield"
assert pending.defender_lineup_id == 42
# Optional fields should be None
assert pending.spd_d20 is None
assert pending.selected_result is None
assert pending.error_result is None
assert pending.decide_runner_base is None
assert pending.decide_target_base is None
assert pending.decide_advance is None
assert pending.decide_throw is None
assert pending.decide_d20 is None
def test_create_with_spd_d20(self):
"""Should create PendingXCheck with SPD d20 pre-rolled."""
pending = PendingXCheck(
position="C",
ab_roll_id="test456",
d20_roll=10,
d6_individual=[2, 3, 4],
d6_total=9,
chart_row=["G1", "SPD", "G3", "SI1", "SI2"],
chart_type="catcher",
spd_d20=15,
defender_lineup_id=99,
)
assert pending.spd_d20 == 15
assert "SPD" in pending.chart_row
def test_create_with_result_selection(self):
"""Should create PendingXCheck with player selections."""
pending = PendingXCheck(
position="LF",
ab_roll_id="test789",
d20_roll=18,
d6_individual=[5, 5, 6],
d6_total=16,
chart_row=["F1", "F2", "F2", "F3", "F3"],
chart_type="outfield",
defender_lineup_id=7,
selected_result="F2",
error_result="E1",
)
assert pending.selected_result == "F2"
assert pending.error_result == "E1"
def test_create_with_decide_data(self):
"""Should create PendingXCheck with DECIDE workflow data."""
pending = PendingXCheck(
position="2B",
ab_roll_id="test999",
d20_roll=8,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=14,
selected_result="G2",
error_result="NO",
decide_runner_base=2,
decide_target_base=3,
decide_advance=True,
decide_throw="runner",
decide_d20=17,
)
assert pending.decide_runner_base == 2
assert pending.decide_target_base == 3
assert pending.decide_advance is True
assert pending.decide_throw == "runner"
assert pending.decide_d20 == 17
class TestPendingXCheckValidation:
"""Test field validation for PendingXCheck."""
def test_d20_roll_must_be_1_to_20(self):
"""Should reject d20 values outside 1-20 range."""
with pytest.raises(ValidationError) as exc_info:
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=0, # Invalid
d6_individual=[1, 2, 3],
d6_total=6,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
assert "d20_roll" in str(exc_info.value)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=21, # Invalid
d6_individual=[1, 2, 3],
d6_total=6,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
def test_d6_individual_must_have_exactly_3_dice(self):
"""Should reject d6_individual with wrong number of dice."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[1, 2], # Too few
d6_total=3,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[1, 2, 3, 4], # Too many
d6_total=10,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
def test_d6_individual_values_must_be_1_to_6(self):
"""Should reject d6 values outside 1-6 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[0, 2, 3], # 0 invalid
d6_total=5,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[1, 7, 3], # 7 invalid
d6_total=11,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
def test_d6_total_must_be_3_to_18(self):
"""Should reject d6_total outside 3-18 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[1, 1, 1],
d6_total=2, # Invalid
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[6, 6, 6],
d6_total=19, # Invalid
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
def test_chart_row_must_have_exactly_5_columns(self):
"""Should reject chart_row with wrong number of columns."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3"], # Too few
chart_type="infield",
defender_lineup_id=1,
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2", "Extra"], # Too many
chart_type="infield",
defender_lineup_id=1,
)
def test_chart_type_must_be_valid(self):
"""Should reject invalid chart_type values."""
with pytest.raises(ValidationError) as exc_info:
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="invalid", # Must be infield/outfield/catcher
defender_lineup_id=1,
)
assert "chart_type" in str(exc_info.value)
def test_error_result_must_be_valid(self):
"""Should reject invalid error_result values."""
with pytest.raises(ValidationError) as exc_info:
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
error_result="INVALID", # Must be NO/E1/E2/E3/RP
)
assert "error_result" in str(exc_info.value)
def test_decide_throw_must_be_valid(self):
"""Should reject invalid decide_throw values."""
with pytest.raises(ValidationError) as exc_info:
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_throw="invalid", # Must be runner/first
)
assert "decide_throw" in str(exc_info.value)
def test_decide_runner_base_must_be_1_to_3(self):
"""Should reject decide_runner_base outside 1-3 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_runner_base=0, # Invalid
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_runner_base=4, # Invalid (home is target, not source)
)
def test_decide_target_base_must_be_2_to_4(self):
"""Should reject decide_target_base outside 2-4 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_target_base=1, # Invalid (can't advance backwards)
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_target_base=5, # Invalid
)
def test_spd_d20_must_be_1_to_20(self):
"""Should reject spd_d20 outside 1-20 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="C",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "SPD", "G3", "SI1", "SI2"],
chart_type="catcher",
defender_lineup_id=1,
spd_d20=0, # Invalid
)
with pytest.raises(ValidationError):
PendingXCheck(
position="C",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "SPD", "G3", "SI1", "SI2"],
chart_type="catcher",
defender_lineup_id=1,
spd_d20=21, # Invalid
)
def test_decide_d20_must_be_1_to_20(self):
"""Should reject decide_d20 outside 1-20 range."""
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_d20=0, # Invalid
)
with pytest.raises(ValidationError):
PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
decide_d20=21, # Invalid
)
class TestPendingXCheckMutability:
"""Test that PendingXCheck allows mutation during workflow."""
def test_can_update_selected_result(self):
"""Should allow updating selected_result after creation."""
pending = PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
assert pending.selected_result is None
# Should be able to mutate
pending.selected_result = "G2"
assert pending.selected_result == "G2"
def test_can_update_error_result(self):
"""Should allow updating error_result after creation."""
pending = PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
pending.error_result = "E1"
assert pending.error_result == "E1"
def test_can_update_decide_fields(self):
"""Should allow updating DECIDE fields during workflow."""
pending = PendingXCheck(
position="SS",
ab_roll_id="test",
d20_roll=10,
d6_individual=[3, 3, 3],
d6_total=9,
chart_row=["G1", "G2", "G3", "SI1", "SI2"],
chart_type="infield",
defender_lineup_id=1,
)
# Simulate DECIDE workflow
pending.decide_runner_base = 2
pending.decide_target_base = 3
pending.decide_advance = True
pending.decide_throw = "first"
assert pending.decide_runner_base == 2
assert pending.decide_target_base == 3
assert pending.decide_advance is True
assert pending.decide_throw == "first"

View File

@ -0,0 +1,385 @@
"""
Tests: Uncapped Hit WebSocket Handlers
Verifies the 5 new WebSocket event handlers for uncapped hit decisions:
- submit_uncapped_lead_advance
- submit_uncapped_defensive_throw
- submit_uncapped_trail_advance
- submit_uncapped_throw_target
- submit_uncapped_safe_out
Tests cover:
- Missing/invalid game_id handling
- Missing/invalid field-specific input validation
- Successful submission forwarding to game engine
- State broadcast after successful submission
- ValueError propagation from game engine
Author: Claude
Date: 2025-02-11
"""
import pytest
from uuid import uuid4
from unittest.mock import AsyncMock, MagicMock, patch
from app.models.game_models import GameState, LineupPlayerState
from .conftest import get_handler, sio_with_mocks
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_game_state():
"""Create a mock active game state for handler tests."""
return GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=LineupPlayerState(
lineup_id=1, card_id=100, position="CF", batting_order=1
),
status="active",
inning=1,
half="top",
outs=0,
)
# =============================================================================
# Tests: submit_uncapped_lead_advance
# =============================================================================
class TestSubmitUncappedLeadAdvance:
"""Tests for the submit_uncapped_lead_advance WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
await handler("test_sid", {"advance": True})
mocks["manager"].emit_to_user.assert_called_once()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "game_id" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_invalid_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not a valid UUID."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
await handler("test_sid", {"game_id": "not-a-uuid", "advance": True})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_advance_field(self, sio_with_mocks):
"""Handler emits error when advance field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "advance" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_invalid_advance_type(self, sio_with_mocks):
"""Handler emits error when advance is not a bool."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "advance": "yes"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["game_engine"].submit_uncapped_lead_advance.assert_called_once_with(
mock_game_state.game_id, True
)
mocks["manager"].broadcast_to_game.assert_called_once()
@pytest.mark.asyncio
async def test_value_error_from_engine(self, sio_with_mocks):
"""Handler emits error when game engine raises ValueError."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock(
side_effect=ValueError("Wrong phase")
)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "Wrong phase" in call_args[2]["message"]
# =============================================================================
# Tests: submit_uncapped_defensive_throw
# =============================================================================
class TestSubmitUncappedDefensiveThrow:
"""Tests for the submit_uncapped_defensive_throw WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
await handler("test_sid", {"will_throw": True})
mocks["manager"].emit_to_user.assert_called_once()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_will_throw_field(self, sio_with_mocks):
"""Handler emits error when will_throw field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "will_throw" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_defensive_throw = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "will_throw": False})
mocks["game_engine"].submit_uncapped_defensive_throw.assert_called_once_with(
mock_game_state.game_id, False
)
mocks["manager"].broadcast_to_game.assert_called_once()
# =============================================================================
# Tests: submit_uncapped_trail_advance
# =============================================================================
class TestSubmitUncappedTrailAdvance:
"""Tests for the submit_uncapped_trail_advance WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
await handler("test_sid", {"advance": True})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_missing_advance_field(self, sio_with_mocks):
"""Handler emits error when advance field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "advance" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_trail_advance = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["game_engine"].submit_uncapped_trail_advance.assert_called_once_with(
mock_game_state.game_id, True
)
# =============================================================================
# Tests: submit_uncapped_throw_target
# =============================================================================
class TestSubmitUncappedThrowTarget:
"""Tests for the submit_uncapped_throw_target WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
await handler("test_sid", {"target": "lead"})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_target(self, sio_with_mocks):
"""Handler emits error when target is not 'lead' or 'trail'."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "target": "middle"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "lead" in call_args[2]["message"] or "trail" in call_args[2]["message"]
@pytest.mark.asyncio
async def test_missing_target(self, sio_with_mocks):
"""Handler emits error when target field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
@pytest.mark.asyncio
async def test_successful_submission_lead(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with target='lead' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_throw_target = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "target": "lead"})
mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with(
mock_game_state.game_id, "lead"
)
@pytest.mark.asyncio
async def test_successful_submission_trail(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with target='trail' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_throw_target = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "target": "trail"})
mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with(
mock_game_state.game_id, "trail"
)
# =============================================================================
# Tests: submit_uncapped_safe_out
# =============================================================================
class TestSubmitUncappedSafeOut:
"""Tests for the submit_uncapped_safe_out WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
await handler("test_sid", {"result": "safe"})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_result(self, sio_with_mocks):
"""Handler emits error when result is not 'safe' or 'out'."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "result": "maybe"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_result(self, sio_with_mocks):
"""Handler emits error when result field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
@pytest.mark.asyncio
async def test_successful_safe(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with result='safe' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "result": "safe"})
mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with(
mock_game_state.game_id, "safe"
)
mocks["manager"].broadcast_to_game.assert_called_once()
@pytest.mark.asyncio
async def test_successful_out(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with result='out' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "result": "out"})
mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with(
mock_game_state.game_id, "out"
)
@pytest.mark.asyncio
async def test_value_error_propagation(self, sio_with_mocks):
"""Handler emits error when game engine raises ValueError."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock(
side_effect=ValueError("No pending uncapped hit")
)
await handler("test_sid", {"game_id": game_id, "result": "safe"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "No pending uncapped hit" in call_args[2]["message"]

View File

@ -22,7 +22,7 @@
Infield Depth
</label>
<ButtonGroup
v-model="localSetup.infield_depth"
v-model="infieldDepth"
:options="infieldDepthOptions"
:disabled="!isActive"
size="md"
@ -37,7 +37,7 @@
Outfield Depth
</label>
<ButtonGroup
v-model="localSetup.outfield_depth"
v-model="outfieldDepth"
:options="outfieldDepthOptions"
:disabled="!isActive"
size="md"
@ -45,33 +45,6 @@
/>
</div>
<!-- Hold Runners -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Hold Runners
</label>
<div class="space-y-2">
<ToggleSwitch
v-model="holdFirst"
label="Hold runner at 1st base"
:disabled="!isActive"
size="md"
/>
<ToggleSwitch
v-model="holdSecond"
label="Hold runner at 2nd base"
:disabled="!isActive"
size="md"
/>
<ToggleSwitch
v-model="holdThird"
label="Hold runner at 3rd base"
:disabled="!isActive"
size="md"
/>
</div>
</div>
<!-- Visual Preview -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
@ -113,14 +86,14 @@ import { ref, computed, watch } from 'vue'
import type { DefensiveDecision, GameState } from '~/types/game'
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
import type { ButtonGroupOption } from '~/components/UI/ButtonGroup.vue'
import ToggleSwitch from '~/components/UI/ToggleSwitch.vue'
import ActionButton from '~/components/UI/ActionButton.vue'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
interface Props {
gameId: string
isActive: boolean
currentSetup?: DefensiveDecision
gameState?: GameState // Added for smart filtering
gameState?: GameState
}
const props = withDefaults(defineProps<Props>(), {
@ -131,27 +104,10 @@ const emit = defineEmits<{
submit: [setup: DefensiveDecision]
}>()
const { infieldDepth, outfieldDepth, holdRunnersArray, getDecision, syncFromDecision } = useDefensiveSetup()
// Local state
const submitting = ref(false)
const localSetup = ref<DefensiveDecision>({
infield_depth: props.currentSetup?.infield_depth || 'normal',
outfield_depth: props.currentSetup?.outfield_depth || 'normal',
hold_runners: props.currentSetup?.hold_runners || [],
})
// Hold runner toggles
const holdFirst = ref(localSetup.value.hold_runners.includes(1))
const holdSecond = ref(localSetup.value.hold_runners.includes(2))
const holdThird = ref(localSetup.value.hold_runners.includes(3))
// Watch hold toggles and update hold_runners array
watch([holdFirst, holdSecond, holdThird], () => {
const runners: number[] = []
if (holdFirst.value) runners.push(1)
if (holdSecond.value) runners.push(2)
if (holdThird.value) runners.push(3)
localSetup.value.hold_runners = runners
})
// Dynamic options based on game state
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
@ -194,18 +150,19 @@ const outfieldDepthOptions = computed<ButtonGroupOption[]>(() => {
// Display helpers
const infieldDisplay = computed(() => {
const option = infieldDepthOptions.value.find(opt => opt.value === localSetup.value.infield_depth)
const option = infieldDepthOptions.value.find(opt => opt.value === infieldDepth.value)
return option?.label || 'Normal'
})
const outfieldDisplay = computed(() => {
const option = outfieldDepthOptions.value.find(opt => opt.value === localSetup.value.outfield_depth)
const option = outfieldDepthOptions.value.find(opt => opt.value === outfieldDepth.value)
return option?.label || 'Normal'
})
const holdingDisplay = computed(() => {
if (localSetup.value.hold_runners.length === 0) return 'None'
return localSetup.value.hold_runners.map(base => {
const arr = holdRunnersArray.value
if (arr.length === 0) return 'None'
return arr.map(base => {
if (base === 1) return '1st'
if (base === 2) return '2nd'
if (base === 3) return '3rd'
@ -213,19 +170,8 @@ const holdingDisplay = computed(() => {
}).join(', ')
})
// Check if setup has changed from initial (for display only)
const hasChanges = computed(() => {
if (!props.currentSetup) return true
return (
localSetup.value.infield_depth !== props.currentSetup.infield_depth ||
localSetup.value.outfield_depth !== props.currentSetup.outfield_depth ||
JSON.stringify(localSetup.value.hold_runners) !== JSON.stringify(props.currentSetup.hold_runners)
)
})
const submitButtonText = computed(() => {
if (!props.isActive) return 'Wait for Your Turn'
if (!hasChanges.value) return 'Submit (Keep Setup)'
return 'Submit Defensive Setup'
})
@ -235,19 +181,16 @@ const handleSubmit = async () => {
submitting.value = true
try {
emit('submit', { ...localSetup.value })
emit('submit', getDecision())
} finally {
submitting.value = false
}
}
// Watch for prop changes and update local state
// Sync composable state from prop when it changes (e.g. server-confirmed state)
watch(() => props.currentSetup, (newSetup) => {
if (newSetup) {
localSetup.value = { ...newSetup }
holdFirst.value = newSetup.hold_runners.includes(1)
holdSecond.value = newSetup.hold_runners.includes(2)
holdThird.value = newSetup.hold_runners.includes(3)
syncFromDecision(newSetup)
}
}, { deep: true })
</script>

View File

@ -79,6 +79,9 @@
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
:hold-runners="defensiveSetup.holdRunnersArray.value"
:hold-interactive="holdInteractive"
@toggle-hold="handleToggleHold"
/>
<!-- Decision Panel (Phase F3) -->
@ -146,6 +149,9 @@
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
:hold-runners="defensiveSetup.holdRunnersArray.value"
:hold-interactive="holdInteractive"
@toggle-hold="handleToggleHold"
/>
<!-- Decision Panel (Phase F3) -->
@ -328,6 +334,7 @@ import { useAuthStore } from '~/store/auth'
import { useUiStore } from '~/store/ui'
import { useWebSocket } from '~/composables/useWebSocket'
import { useGameActions } from '~/composables/useGameActions'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
@ -363,6 +370,9 @@ const actions = useGameActions(props.gameId)
// Destructure undoLastPlay for the undo button
const { undoLastPlay } = actions
// Defensive setup composable (shared with DefensiveSetup.vue and RunnersOnBase)
const defensiveSetup = useDefensiveSetup()
// Game state from store
const gameState = computed(() => {
const state = gameStore.gameState
@ -531,6 +541,9 @@ const decisionPhase = computed(() => {
return 'idle'
})
// Hold runner toggles are interactive only during defensive decision phase
const holdInteractive = computed(() => needsDefensiveDecision.value && isMyTurn.value)
// Phase F6: Conditional panel rendering
const showDecisions = computed(() => {
// Don't show decision panels if there's a result pending dismissal
@ -643,6 +656,10 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
gameStore.setPendingStealAttempts(attempts)
}
const handleToggleHold = (base: number) => {
defensiveSetup.toggleHold(base)
}
// Undo handler
const handleUndoLastPlay = () => {
console.log('[GamePlay] Undoing last play')
@ -715,6 +732,18 @@ watch(gameState, (state, oldState) => {
}
}, { immediate: true })
// Reset defensive setup composable when entering a new defensive decision phase
watch(needsDefensiveDecision, (needs) => {
if (needs) {
// Sync from existing setup if available, otherwise reset to defaults
if (pendingDefensiveSetup.value) {
defensiveSetup.syncFromDecision(pendingDefensiveSetup.value)
} else {
defensiveSetup.reset()
}
}
})
// Quality of Life: Auto-submit default decisions when bases are empty
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
// Only auto-submit if it's the player's turn and bases are empty

View File

@ -1,73 +1,62 @@
<template>
<div
:class="[
'runner-card',
'runner-pill',
runner ? 'occupied' : 'empty',
isExpanded ? 'expanded' : ''
isSelected ? 'selected' : '',
isHeld ? 'held' : ''
]"
@click="handleClick"
>
<!-- Summary (always visible) -->
<div class="runner-summary">
<template v-if="runner">
<!-- Occupied base -->
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
:alt="runnerName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-sm">
{{ base }}
</div>
</div>
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">{{ runnerName }}</div>
<div class="text-xs text-gray-600">#{{ runnerNumber }} {{ base }}</div>
</div>
<div class="text-xs text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="['h-4 w-4 transition-transform', isExpanded ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-sm font-bold">{{ base }}</span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
</template>
</div>
<!-- Expanded view (full card) -->
<div v-if="runner && isExpanded" class="runner-expanded">
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden matchup-card-blue">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
<span class="font-bold text-white/90">RUNNER</span>
<span class="text-white/70">{{ base }}</span>
<span class="truncate flex-1 text-right font-bold">{{ runnerName }}</span>
</div>
<div class="p-0">
<img
v-if="runnerPlayer?.image"
:src="runnerPlayer.image"
:alt="`${runnerName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ getRunnerInitials }}</span>
</div>
<template v-if="runner">
<!-- Occupied base -->
<div class="w-8 h-8 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
:alt="runnerName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-xs">
{{ base }}
</div>
</div>
</div>
<div class="ml-2 flex-1 min-w-0">
<div class="text-xs font-bold text-gray-900 truncate">{{ runnerName }}</div>
<div class="text-[10px] text-gray-500">{{ base }}</div>
</div>
<!-- Hold runner icon -->
<button
v-if="holdInteractive || isHeld"
type="button"
:class="[
'hold-icon flex-shrink-0 w-10 rounded-lg flex items-center justify-center transition-all duration-200 px-1 self-stretch gap-0.5',
holdInteractive ? 'cursor-pointer' : 'cursor-default',
isHeld
? 'bg-amber-500 text-white shadow-sm ring-1 ring-amber-400'
: 'bg-gray-200 text-gray-400 hover:bg-gray-300'
]"
:title="isHeld ? 'Release runner' : 'Hold runner'"
:disabled="!holdInteractive"
@click.stop="handleToggleHold"
>
<template v-if="isHeld">
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">HELD</span>
</template>
<template v-else>
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">NOT</span>
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">HELD</span>
</template>
</button>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-8 h-8 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-[10px] font-bold">{{ base }}</span>
</div>
<div class="ml-2 text-xs text-gray-400">Empty</div>
</template>
</div>
</template>
@ -79,13 +68,20 @@ import { useGameStore } from '~/store/game'
interface Props {
base: '1B' | '2B' | '3B'
runner: LineupPlayerState | null
isExpanded: boolean
isSelected: boolean
teamColor: string
isHeld?: boolean
holdInteractive?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
isHeld: false,
holdInteractive: false,
})
const emit = defineEmits<{
click: []
toggleHold: []
}>()
const gameStore = useGameStore()
@ -102,86 +98,42 @@ const runnerName = computed(() => {
return runnerPlayer.value.name
})
const runnerNumber = computed(() => {
// Try to extract jersey number from player data if available
// For now, default to a placeholder based on lineup_id
return props.runner?.lineup_id?.toString().padStart(2, '0') ?? '00'
})
const getRunnerInitials = computed(() => {
if (!runnerPlayer.value) return '?'
const parts = runnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return runnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function handleClick() {
if (props.runner) {
emit('click')
}
}
function handleToggleHold() {
if (props.holdInteractive) {
emit('toggleHold')
}
}
</script>
<style scoped>
.runner-card {
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-300;
.runner-pill {
@apply flex items-center p-2 rounded-lg bg-white border border-gray-200
shadow-sm transition-all duration-200 cursor-default;
}
.runner-card.empty {
@apply bg-gray-50 border-gray-300;
.runner-pill.occupied {
@apply cursor-pointer hover:bg-red-50;
}
.runner-card.occupied {
@apply cursor-pointer;
.runner-pill.selected {
@apply ring-2 ring-red-500 bg-red-50 shadow-md;
}
.runner-card.occupied:hover:not(.expanded) {
@apply transform translate-x-1;
background: rgba(255, 255, 255, 0.95);
.runner-pill.held {
@apply border-amber-400;
}
.runner-card.expanded {
@apply transform scale-105 z-10;
.runner-pill.empty {
@apply bg-gray-50 opacity-60;
}
.runner-summary {
@apply p-2 flex items-center;
}
.runner-expanded {
@apply overflow-hidden;
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease-out;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes expandHeight {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 800px;
opacity: 1;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
.hold-icon:disabled {
@apply opacity-70;
}
</style>

View File

@ -1,91 +1,85 @@
<template>
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
<div v-if="hasRunners" class="runners-on-base-container">
<!-- Split Layout: Runners List | Catcher Card -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 bg-white/20 rounded-lg shadow-md overflow-hidden">
<!-- LEFT: Runners List (with expandable cards) -->
<div class="border-r border-gray-200/50 p-4">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Runners on Base
</h3>
<div class="space-y-2">
<!-- 1st Base -->
<RunnerCard
base="1B"
:runner="runners.first"
:is-expanded="selectedRunner === 'first'"
:team-color="battingTeamColor"
@click="toggleRunner('first')"
/>
<!-- 2nd Base -->
<RunnerCard
base="2B"
:runner="runners.second"
:is-expanded="selectedRunner === 'second'"
:team-color="battingTeamColor"
@click="toggleRunner('second')"
/>
<!-- 3rd Base -->
<RunnerCard
base="3B"
:runner="runners.third"
:is-expanded="selectedRunner === 'third'"
:team-color="battingTeamColor"
@click="toggleRunner('third')"
/>
</div>
<!-- TOP ROW: Runner pills + Catcher summary -->
<div class="flex gap-2 items-stretch">
<!-- Runner pills (flex, equal width) -->
<div class="flex-1 flex gap-2">
<RunnerCard
v-for="(key, idx) in baseKeys"
:key="key"
class="flex-1"
:base="baseLabels[idx]"
:runner="runners[key]"
:is-selected="selectedRunner === key"
:team-color="'#ef4444'"
:is-held="holdRunners.includes(baseNameToNumber[key])"
:hold-interactive="holdInteractive"
@click="toggleRunner(key)"
@toggle-hold="emit('toggleHold', baseNameToNumber[key])"
/>
</div>
<!-- RIGHT: Catcher Card -->
<div class="p-4 bg-gray-50/10">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Catcher
</h3>
<!-- Collapsed state - minimal card -->
<!-- Catcher summary pill (fixed width, full row height, clickable) -->
<div class="w-28 md:w-36 flex-shrink-0">
<div
v-if="!hasSelection"
class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300"
:class="[
'catcher-pill h-full flex flex-col items-center justify-center p-2 rounded-lg border shadow-sm text-center cursor-pointer transition-all duration-200',
selectedRunner === 'catcher' ? 'bg-blue-50 border-blue-500 ring-2 ring-blue-500' : 'bg-white border-gray-200 hover:bg-blue-50'
]"
@click="toggleCatcher"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
<img
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
:src="catcherPlayer.headshot || catcherPlayer.image"
:alt="catcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
C
</div>
</div>
<div class="flex-1">
<div class="text-sm font-bold text-gray-900">{{ catcherName }}</div>
<div class="text-xs text-gray-600">#{{ catcherNumber }} C</div>
<div class="w-10 h-10 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
<img
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
:src="catcherPlayer.headshot || catcherPlayer.image"
:alt="catcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
C
</div>
</div>
<div class="mt-3 text-xs text-gray-500 text-center">
Click a runner to see matchup
<div class="text-xs font-bold text-gray-900 mt-1 truncate w-full px-1">{{ catcherName }}</div>
<div class="text-[10px] text-gray-500">C</div>
</div>
</div>
</div>
<!-- EXPANDED ROW: Runner card + Catcher card side by side (when a runner or catcher is selected) -->
<Transition name="expand">
<div v-if="hasSelection && displayedRunnerPlayer" class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Lead/Selected Runner full card (RED) -->
<div class="matchup-card-red bg-gradient-to-b from-red-900 to-red-950 border-2 border-red-600 rounded-xl overflow-hidden shadow-lg">
<div class="bg-red-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90" :style="{ color: battingTeamColor }">
{{ battingTeamAbbrev }}
</span>
<span class="text-white/70">{{ selectedBase }}</span>
<span class="truncate flex-1 text-right font-bold">{{ selectedRunnerName }}</span>
</div>
<div class="p-0">
<img
v-if="displayedRunnerPlayer?.image"
:src="displayedRunnerPlayer.image"
:alt="`${selectedRunnerName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-red-700 to-red-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ selectedRunnerInitials }}</span>
</div>
</div>
</div>
<!-- Expanded state - full card (when runner selected) -->
<div
v-else
class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in"
>
<!-- Card Header -->
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<!-- Catcher full card (BLUE) -->
<div class="matchup-card-blue bg-gradient-to-b from-blue-900 to-blue-950 border-2 border-blue-600 rounded-xl overflow-hidden shadow-lg">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
{{ fieldingTeamAbbrev }}
</span>
<span class="text-white/70">C</span>
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
</div>
<!-- Card Image -->
<div class="p-0">
<img
v-if="catcherPlayer?.image"
@ -93,18 +87,18 @@
:alt="`${catcherName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-green-700 to-green-900 flex items-center justify-center">
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ getCatcherInitials }}</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
@ -121,6 +115,8 @@ interface Props {
fieldingTeamColor?: string
battingTeamAbbrev?: string
fieldingTeamAbbrev?: string
holdRunners?: number[]
holdInteractive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -129,10 +125,36 @@ const props = withDefaults(defineProps<Props>(), {
fieldingTeamColor: '#10b981',
battingTeamAbbrev: '',
fieldingTeamAbbrev: '',
holdRunners: () => [],
holdInteractive: false,
})
const emit = defineEmits<{
toggleHold: [base: number]
}>()
const gameStore = useGameStore()
const selectedRunner = ref<'first' | 'second' | 'third' | null>(null)
const selectedRunner = ref<'first' | 'second' | 'third' | 'catcher' | null>(null)
// Helper constants for iteration (3B, 2B, 1B order - left to right)
const baseKeys = ['third', 'second', 'first'] as const
const baseLabels: ('1B' | '2B' | '3B')[] = ['3B', '2B', '1B']
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
const baseNameToNumber: Record<string, number> = { first: 1, second: 2, third: 3 }
// Auto-select lead runner on mount
onMounted(() => {
if (!selectedRunner.value) {
// Select lead runner (third second first priority)
if (props.runners.third) {
selectedRunner.value = 'third'
} else if (props.runners.second) {
selectedRunner.value = 'second'
} else if (props.runners.first) {
selectedRunner.value = 'first'
}
}
})
// Check if any runners on base
const hasRunners = computed(() => {
@ -165,6 +187,54 @@ const getCatcherInitials = computed(() => {
return catcherPlayer.value.name.substring(0, 2).toUpperCase()
})
// Lead runner determination (for catcher-only selection)
const leadRunnerBase = computed(() => {
if (props.runners.third) return 'third'
if (props.runners.second) return 'second'
if (props.runners.first) return 'first'
return null
})
// Displayed runner: selected runner OR lead runner (when catcher selected)
const displayedRunnerBase = computed(() => {
if (selectedRunner.value === 'catcher') {
return leadRunnerBase.value
}
return selectedRunner.value
})
// Selected runner data resolution (mirrors catcher pattern)
const selectedRunnerState = computed(() => {
if (!displayedRunnerBase.value || displayedRunnerBase.value === 'catcher') return null
return props.runners[displayedRunnerBase.value]
})
const selectedRunnerLineup = computed(() => {
if (!selectedRunnerState.value) return null
return gameStore.findPlayerInLineup(selectedRunnerState.value.lineup_id)
})
const selectedRunnerPlayer = computed(() => selectedRunnerLineup.value?.player ?? null)
// For display: use displayedRunnerBase instead of selectedRunner
const displayedRunnerPlayer = computed(() => selectedRunnerPlayer.value)
const selectedRunnerName = computed(() => selectedRunnerPlayer.value?.name ?? 'Unknown Runner')
const selectedBase = computed(() => {
if (!displayedRunnerBase.value || displayedRunnerBase.value === 'catcher') return ''
return baseNameToLabel[displayedRunnerBase.value] ?? ''
})
const selectedRunnerInitials = computed(() => {
if (!selectedRunnerPlayer.value) return '?'
const parts = selectedRunnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return selectedRunnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function toggleRunner(base: 'first' | 'second' | 'third') {
// Can't select empty base
if (!props.runners[base]) return
@ -176,6 +246,22 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
selectedRunner.value = base
}
}
function toggleCatcher() {
// Toggle catcher selection
if (selectedRunner.value === 'catcher') {
selectedRunner.value = null
} else {
selectedRunner.value = 'catcher'
}
}
// Auto-deselect when the selected runner's base becomes empty
watch(() => props.runners, (newRunners) => {
if (selectedRunner.value && selectedRunner.value !== 'catcher' && !newRunners[selectedRunner.value]) {
selectedRunner.value = null
}
}, { deep: true })
</script>
<style scoped>
@ -183,21 +269,50 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
@apply mb-6;
}
.matchup-card {
animation: pulseGlowGreen 2s ease-in-out infinite;
/* Expanded row animations */
.expand-enter-active {
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes pulseGlowGreen {
.expand-leave-active {
animation: expandHeight 0.3s cubic-bezier(0.4, 0, 0.2, 1) reverse;
}
.matchup-card-red {
animation: pulseGlowRed 2s ease-in-out infinite;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowRed {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 15px 2px rgba(239, 68, 68, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 30px 8px rgba(239, 68, 68, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
.fade-in {
animation: fadeIn 0.3s ease-out;
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes expandHeight {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 800px;
opacity: 1;
}
}
@keyframes fadeIn {

View File

@ -78,6 +78,24 @@
</div>
</div>
<!-- State: X-Check Result Pending -->
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
<div v-if="!isXCheckInteractive" class="state-message">
<svg class="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<div class="message-text">
Waiting for defense to select x-check result...
</div>
</div>
<XCheckWizard
v-else-if="xCheckData"
:x-check-data="xCheckData"
:readonly="!isXCheckInteractive"
@submit="handleXCheckSubmit"
/>
</div>
<!-- State: Result (Play completed) -->
<div v-else-if="workflowState === 'result'" class="state-result">
<PlayResultDisplay
@ -101,10 +119,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome } from '~/types'
import type { RollData, PlayResult, PlayOutcome, XCheckData } from '~/types'
import { useGameStore } from '~/store/game'
import DiceRoller from './DiceRoller.vue'
import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue'
import XCheckWizard from './XCheckWizard.vue'
interface Props {
gameId: string
@ -117,26 +137,44 @@ interface Props {
hasRunners?: boolean
// Dice color from home team (hex without #)
diceColor?: string
// User's team ID (for determining interactive mode in x-check)
userTeamId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
diceColor: 'cc0000', // Default red
userTeamId: null,
})
const emit = defineEmits<{
rollDice: []
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
dismissResult: []
submitXCheckResult: [{ resultCode: string; errorResult: string }]
}>()
// Store access
const gameStore = useGameStore()
// Local state
const error = ref<string | null>(null)
const isSubmitting = ref(false)
// X-Check data from store
const xCheckData = computed(() => gameStore.xCheckData)
// Determine if current user should have interactive mode
// Uses active_team_id from x-check data (set by backend to indicate which team should interact)
const isXCheckInteractive = computed(() => {
if (!xCheckData.value || !props.userTeamId) return false
// Backend sets active_team_id to indicate which team should have interactive controls
return xCheckData.value.active_team_id === props.userTeamId
})
// Workflow state computation
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'x_check_result_pending'
const workflowState = computed<WorkflowState>(() => {
// Show result if we have one
@ -144,6 +182,11 @@ const workflowState = computed<WorkflowState>(() => {
return 'result'
}
// Show x-check result selection if awaiting
if (gameStore.needsXCheckResult && xCheckData.value) {
return 'x_check_result_pending'
}
// Show submitted/processing state
if (isSubmitting.value) {
return 'submitted'
@ -167,6 +210,7 @@ const workflowState = computed<WorkflowState>(() => {
const statusClass = computed(() => {
if (error.value) return 'status-error'
if (workflowState.value === 'result') return 'status-success'
if (workflowState.value === 'x_check_result_pending') return 'status-active'
if (workflowState.value === 'submitted') return 'status-processing'
if (workflowState.value === 'rolled') return 'status-active'
if (workflowState.value === 'ready_to_roll' && props.isMyTurn) return 'status-active'
@ -176,6 +220,9 @@ const statusClass = computed(() => {
const statusText = computed(() => {
if (error.value) return 'Error'
if (workflowState.value === 'result') return 'Play Complete'
if (workflowState.value === 'x_check_result_pending') {
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
}
if (workflowState.value === 'submitted') return 'Processing'
if (workflowState.value === 'rolled') return 'Enter Outcome'
if (workflowState.value === 'ready_to_roll') {
@ -210,6 +257,17 @@ const handleDismissResult = () => {
error.value = null
emit('dismissResult')
}
const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => {
error.value = null
isSubmitting.value = true
emit('submitXCheckResult', payload)
// Reset submitting state after a delay
setTimeout(() => {
isSubmitting.value = false
}, 3000)
}
</script>
<style scoped>
@ -300,6 +358,11 @@ const handleDismissResult = () => {
@apply space-y-6;
}
/* State: X-Check */
.state-x-check {
@apply space-y-4;
}
/* State: Result */
.state-result {
@apply space-y-4;

View File

@ -0,0 +1,506 @@
<template>
<div class="x-check-wizard" :class="{ 'read-only': readonly }">
<!-- Header -->
<div class="wizard-header">
<h3 class="wizard-title">
X-Check: {{ xCheckData.position }} ({{ getPositionName(xCheckData.position) }})
</h3>
<p v-if="readonly" class="waiting-message">
Waiting for opponent to select result...
</p>
</div>
<!-- Dice Display -->
<div class="dice-display">
<div class="dice-group">
<div class="dice-label">Range Roll (d20)</div>
<div class="dice-value d20">{{ xCheckData.d20_roll }}</div>
</div>
<div class="dice-group">
<div class="dice-label">Error Roll (3d6)</div>
<div class="dice-value d6">
{{ xCheckData.d6_total }}
<span class="dice-breakdown">({{ xCheckData.d6_individual.join(' + ') }})</span>
</div>
</div>
</div>
<!-- SPD d20 (click to reveal if present) -->
<div v-if="xCheckData.spd_d20 !== null" class="spd-container">
<button
v-if="!spdRevealed"
class="spd-reveal-button"
:disabled="readonly"
@click="spdRevealed = true"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Tap to Reveal SPD d20
</button>
<div v-else class="spd-revealed">
<div class="dice-label">Speed Check d20</div>
<div class="dice-value d20">{{ xCheckData.spd_d20 }}</div>
</div>
</div>
<!-- Defensive Positioning Note -->
<div v-if="defensivePositioningNote" class="positioning-note">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ defensivePositioningNote }}
</div>
<!-- Chart Row Selection -->
<div class="chart-selection">
<h4 class="selection-title">Select Range Result</h4>
<div class="chart-row">
<button
v-for="(resultCode, index) in xCheckData.chart_row"
:key="index"
class="chart-column"
:class="{
selected: selectedColumn === index && !showHashChoice && !showSpdChoice,
disabled: readonly,
}"
:disabled="readonly"
@click="selectColumn(index, resultCode)"
>
<div class="column-header">Range {{ index + 1 }}</div>
<div class="column-result">{{ resultCode }}</div>
<div class="column-label">{{ getResultLabel(resultCode) }}</div>
</button>
</div>
</div>
<!-- Hash Result Sub-Choice (G2#/G3# G2 or SI2) -->
<div v-if="showHashChoice" class="sub-choice">
<h4 class="sub-choice-title">Speed Test Result</h4>
<p class="sub-choice-description">
Check batter's speed rating on their card. Did they pass the speed test?
</p>
<div class="sub-choice-buttons">
<button
v-for="option in hashOptions"
:key="option"
class="sub-choice-button"
:class="{ selected: selectedResult === option }"
:disabled="readonly"
@click="selectHashResult(option)"
>
<div class="option-code">{{ option }}</div>
<div class="option-label">{{ getResultLabel(option) }}</div>
</button>
</div>
</div>
<!-- SPD Result Sub-Choice -->
<div v-if="showSpdChoice" class="sub-choice">
<h4 class="sub-choice-title">Speed Check</h4>
<p class="sub-choice-description">
Check batter's speed rating vs. the d20 roll. Safe or out?
</p>
<div class="sub-choice-buttons">
<button
class="sub-choice-button"
:class="{ selected: selectedResult === 'G3' }"
:disabled="readonly"
@click="selectSpdResult('G3')"
>
<div class="option-code">G3</div>
<div class="option-label">Out (failed check)</div>
</button>
<button
class="sub-choice-button"
:class="{ selected: selectedResult === 'SI1' }"
:disabled="readonly"
@click="selectSpdResult('SI1')"
>
<div class="option-code">SI1</div>
<div class="option-label">Safe (passed check)</div>
</button>
</div>
</div>
<!-- Error Selection -->
<div class="error-selection">
<h4 class="selection-title">Select Error Result</h4>
<p class="selection-description">
Check defender's error rating on their card based on 3d6 total ({{ xCheckData.d6_total }})
</p>
<div class="error-row">
<button
v-for="errorCode in ERROR_OPTIONS"
:key="errorCode"
class="error-button"
:class="{
selected: selectedError === errorCode,
disabled: readonly,
'no-error': errorCode === 'NO',
}"
:disabled="readonly"
@click="selectError(errorCode)"
>
<div class="error-code">{{ errorCode }}</div>
<div class="error-label">{{ getErrorLabel(errorCode) }}</div>
</button>
</div>
</div>
<!-- Submit Button -->
<div class="wizard-actions">
<button
class="submit-button"
:disabled="!canSubmit || readonly"
@click="handleSubmit"
>
<svg v-if="!readonly" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ readonly ? 'Waiting...' : 'Submit Result' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { XCheckData } from '~/types/game'
import {
getResultLabel,
getErrorLabel,
isHashResult,
isSpdResult,
getHashConversions,
} from '~/constants/xCheckResults'
interface Props {
xCheckData: XCheckData
readonly: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [payload: { resultCode: string; errorResult: string }]
}>()
// State
const selectedColumn = ref<number | null>(null)
const selectedResult = ref<string | null>(null)
const selectedError = ref<string>('NO') // Default to no error
const showHashChoice = ref(false)
const showSpdChoice = ref(false)
const hashOptions = ref<string[]>([])
const spdRevealed = ref(false)
const ERROR_OPTIONS = ['NO', 'E1', 'E2', 'E3', 'RP']
// Position name mapping
const POSITION_NAMES: Record<string, string> = {
P: 'Pitcher',
C: 'Catcher',
'1B': 'First Base',
'2B': 'Second Base',
'3B': 'Third Base',
SS: 'Shortstop',
LF: 'Left Field',
CF: 'Center Field',
RF: 'Right Field',
}
function getPositionName(position: string): string {
return POSITION_NAMES[position] || position
}
// Defensive positioning note (if applicable)
const defensivePositioningNote = computed(() => {
// TODO: Get from game state - check if playing in affects range
// For now, just return null
return null
})
// Selection handlers
function selectColumn(index: number, resultCode: string) {
if (props.readonly) return
selectedColumn.value = index
// Check if this is a hash result (G2#/G3#)
if (isHashResult(resultCode)) {
const conversions = getHashConversions(resultCode)
if (conversions) {
showHashChoice.value = true
showSpdChoice.value = false
hashOptions.value = conversions
selectedResult.value = null // Reset until player picks
}
}
// Check if this is SPD
else if (isSpdResult(resultCode)) {
showSpdChoice.value = true
showHashChoice.value = false
selectedResult.value = null // Reset until player picks
}
// Simple result - select immediately
else {
showHashChoice.value = false
showSpdChoice.value = false
selectedResult.value = resultCode
}
}
function selectHashResult(option: string) {
if (props.readonly) return
selectedResult.value = option
showHashChoice.value = false
}
function selectSpdResult(option: string) {
if (props.readonly) return
selectedResult.value = option
showSpdChoice.value = false
}
function selectError(errorCode: string) {
if (props.readonly) return
selectedError.value = errorCode
}
// Can submit when both result and error are selected
const canSubmit = computed(() => {
return selectedResult.value !== null && selectedError.value !== null
})
function handleSubmit() {
if (!canSubmit.value || props.readonly) return
emit('submit', {
resultCode: selectedResult.value!,
errorResult: selectedError.value,
})
}
</script>
<style scoped>
.x-check-wizard {
@apply bg-white rounded-lg shadow-lg p-6 space-y-6 max-w-4xl mx-auto;
}
.x-check-wizard.read-only {
@apply opacity-75;
}
/* Header */
.wizard-header {
@apply text-center pb-4 border-b border-gray-200;
}
.wizard-title {
@apply text-2xl font-bold text-gray-900;
}
.waiting-message {
@apply mt-2 text-sm text-orange-600 font-medium;
}
/* Dice Display */
.dice-display {
@apply flex justify-center gap-8;
}
.dice-group {
@apply text-center;
}
.dice-label {
@apply text-sm font-medium text-gray-600 mb-2;
}
.dice-value {
@apply text-4xl font-bold rounded-lg px-6 py-3 shadow-md;
}
.dice-value.d20 {
@apply bg-blue-100 text-blue-900;
}
.dice-value.d6 {
@apply bg-green-100 text-green-900;
}
.dice-breakdown {
@apply text-lg text-gray-600 ml-2;
}
/* SPD Container */
.spd-container {
@apply text-center py-4 bg-yellow-50 rounded-lg border-2 border-yellow-200;
}
.spd-reveal-button {
@apply inline-flex items-center gap-2 px-6 py-3 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.spd-revealed {
@apply space-y-2;
}
/* Positioning Note */
.positioning-note {
@apply flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-800 rounded-lg text-sm;
}
/* Chart Selection */
.chart-selection {
@apply space-y-4;
}
.selection-title {
@apply text-lg font-semibold text-gray-900;
}
.selection-description {
@apply text-sm text-gray-600;
}
.chart-row {
@apply grid grid-cols-5 gap-3;
}
.chart-column {
@apply flex flex-col items-center p-4 border-2 border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
}
.chart-column.selected {
@apply border-blue-600 bg-blue-100 ring-2 ring-blue-500;
}
.chart-column.disabled {
@apply hover:border-gray-300 hover:bg-white;
}
.column-header {
@apply text-xs font-medium text-gray-500 mb-1;
}
.column-result {
@apply text-2xl font-bold text-gray-900 mb-1;
}
.column-label {
@apply text-xs text-gray-600 text-center;
}
/* Sub-Choice */
.sub-choice {
@apply p-6 bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg border-2 border-purple-300 space-y-4;
}
.sub-choice-title {
@apply text-lg font-bold text-purple-900;
}
.sub-choice-description {
@apply text-sm text-gray-700;
}
.sub-choice-buttons {
@apply flex gap-4 justify-center;
}
.sub-choice-button {
@apply flex flex-col items-center p-6 border-2 border-purple-300 rounded-lg hover:border-purple-600 hover:bg-purple-100 transition-all cursor-pointer min-w-[150px] disabled:cursor-not-allowed disabled:opacity-50;
}
.sub-choice-button.selected {
@apply border-purple-700 bg-purple-200 ring-2 ring-purple-600;
}
.option-code {
@apply text-3xl font-bold text-purple-900 mb-2;
}
.option-label {
@apply text-sm text-gray-700 text-center;
}
/* Error Selection */
.error-selection {
@apply space-y-4;
}
.error-row {
@apply flex gap-3 justify-center flex-wrap;
}
.error-button {
@apply flex flex-col items-center px-6 py-4 border-2 border-gray-300 rounded-lg hover:border-red-500 hover:bg-red-50 transition-all cursor-pointer min-w-[100px] disabled:cursor-not-allowed disabled:opacity-50;
}
.error-button.selected {
@apply border-red-600 bg-red-100 ring-2 ring-red-500;
}
.error-button.no-error {
@apply border-green-300 hover:border-green-500 hover:bg-green-50;
}
.error-button.no-error.selected {
@apply border-green-600 bg-green-100 ring-2 ring-green-500;
}
.error-code {
@apply text-xl font-bold text-gray-900 mb-1;
}
.error-label {
@apply text-xs text-gray-600 text-center;
}
/* Actions */
.wizard-actions {
@apply pt-4 border-t border-gray-200;
}
.submit-button {
@apply w-full inline-flex items-center justify-center gap-2 px-6 py-4 bg-blue-600 text-white font-bold text-lg rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.x-check-wizard {
@apply p-4 space-y-4;
}
.dice-display {
@apply flex-col gap-4;
}
.chart-row {
@apply grid-cols-2 gap-2;
}
.chart-column {
@apply p-3;
}
.column-result {
@apply text-xl;
}
.error-row {
@apply grid grid-cols-3 gap-2;
}
.error-button {
@apply px-4 py-3 min-w-0;
}
.sub-choice-buttons {
@apply flex-col;
}
}
</style>

View File

@ -0,0 +1,63 @@
import { ref, computed } from 'vue'
import type { DefensiveDecision } from '~/types/game'
// Module-level singleton state (shared across all consumers)
const holdRunners = ref<Set<number>>(new Set())
const infieldDepth = ref<'infield_in' | 'normal' | 'corners_in'>('normal')
const outfieldDepth = ref<'normal' | 'shallow'>('normal')
export function useDefensiveSetup() {
/** Reactive array of held base numbers (for prop passing) */
const holdRunnersArray = computed<number[]>(() => Array.from(holdRunners.value).sort())
/** Check if a specific base is held */
function isHeld(base: number): boolean {
return holdRunners.value.has(base)
}
/** Toggle hold on a base (1, 2, or 3) */
function toggleHold(base: number) {
const next = new Set(holdRunners.value)
if (next.has(base)) {
next.delete(base)
} else {
next.add(base)
}
holdRunners.value = next
}
/** Reset all defensive setup to defaults */
function reset() {
holdRunners.value = new Set()
infieldDepth.value = 'normal'
outfieldDepth.value = 'normal'
}
/** Sync state from an existing DefensiveDecision (e.g. from props/server) */
function syncFromDecision(decision: DefensiveDecision) {
holdRunners.value = new Set(decision.hold_runners)
infieldDepth.value = decision.infield_depth
outfieldDepth.value = decision.outfield_depth
}
/** Build a DefensiveDecision from current working state */
function getDecision(): DefensiveDecision {
return {
infield_depth: infieldDepth.value,
outfield_depth: outfieldDepth.value,
hold_runners: holdRunnersArray.value,
}
}
return {
holdRunners,
holdRunnersArray,
infieldDepth,
outfieldDepth,
isHeld,
toggleHold,
reset,
syncFromDecision,
getDecision,
}
}

View File

@ -174,6 +174,75 @@ export function useGameActions(gameId?: string) {
uiStore.showInfo('Submitting outcome...', 2000)
}
// ============================================================================
// X-Check Interactive Workflow
// ============================================================================
/**
* Submit x-check result selection (result code + error)
*/
function submitXCheckResult(resultCode: string, errorResult: string) {
if (!validateConnection()) return
console.log('[GameActions] Submitting x-check result:', resultCode, errorResult)
socket.value!.emit('submit_x_check_result', {
game_id: currentGameId.value!,
result_code: resultCode,
error_result: errorResult,
})
uiStore.showInfo('Submitting x-check result...', 2000)
}
/**
* Submit DECIDE advance decision (offensive player)
*/
function submitDecideAdvance(advance: boolean) {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE advance:', advance)
socket.value!.emit('submit_decide_advance', {
game_id: currentGameId.value!,
advance,
})
uiStore.showInfo('Submitting advance decision...', 2000)
}
/**
* Submit DECIDE throw target (defensive player)
*/
function submitDecideThrow(target: 'runner' | 'first') {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE throw:', target)
socket.value!.emit('submit_decide_throw', {
game_id: currentGameId.value!,
target,
})
uiStore.showInfo('Submitting throw decision...', 2000)
}
/**
* Submit DECIDE speed check result (offensive player)
*/
function submitDecideResult(outcome: 'safe' | 'out') {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE result:', outcome)
socket.value!.emit('submit_decide_result', {
game_id: currentGameId.value!,
outcome,
})
uiStore.showInfo('Submitting speed check result...', 2000)
}
// ============================================================================
// Substitution Actions
// ============================================================================
@ -374,6 +443,12 @@ export function useGameActions(gameId?: string) {
rollDice,
submitManualOutcome,
// X-Check interactive workflow
submitXCheckResult,
submitDecideAdvance,
submitDecideThrow,
submitDecideResult,
// Substitutions
submitSubstitution,

View File

@ -497,8 +497,23 @@ export function useWebSocket() {
// ========================================
state.socketInstance.on('decision_required', (prompt) => {
console.log('[WebSocket] Decision required:', prompt.phase)
console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type)
gameStore.setDecisionPrompt(prompt)
// Handle x-check specific decision types
if (prompt.type === 'x_check_result' && prompt.data) {
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
gameStore.setXCheckData(prompt.data)
} else if (prompt.type === 'decide_advance' && prompt.data) {
console.log('[WebSocket] DECIDE advance decision')
gameStore.setDecideData(prompt.data)
} else if (prompt.type === 'decide_throw' && prompt.data) {
console.log('[WebSocket] DECIDE throw decision')
gameStore.setDecideData(prompt.data)
} else if (prompt.type === 'decide_speed_check' && prompt.data) {
console.log('[WebSocket] DECIDE speed check decision')
gameStore.setDecideData(prompt.data)
}
})
state.socketInstance.on('defensive_decision_submitted', (data) => {
@ -587,6 +602,8 @@ export function useWebSocket() {
// Clear pending decisions since the play is complete and we'll need new ones for next batter
gameStore.clearPendingDecisions()
gameStore.clearXCheckData()
gameStore.clearDecideData()
uiStore.showSuccess(data.description, 5000)
})

View File

@ -0,0 +1,87 @@
/**
* X-Check Result Code Labels and Descriptions
*
* Display labels for all possible result codes that can appear in
* defensive x-check chart rows (5 columns).
*/
export const X_CHECK_RESULT_LABELS: Record<string, string> = {
// Groundball results
G1: 'Groundball Out (best)',
G2: 'Groundball Out (good)',
G3: 'Groundball Out (weak)',
'G2#': 'Groundball (speed test)',
'G3#': 'Groundball (speed test)',
// Singles
SI1: 'Single (clean)',
SI2: 'Single (through)',
// Doubles
DO2: 'Double (2-base)',
DO3: 'Double (3-base)',
// Triples
TR3: 'Triple',
// Flyball results
F1: 'Flyout (deep)',
F2: 'Flyout (medium)',
F3: 'Flyout (shallow)',
// Catcher-specific
SPD: 'Speed Check',
FO: 'Fly Out',
PO: 'Pop Out',
}
export const X_CHECK_ERROR_LABELS: Record<string, string> = {
NO: 'No Error',
E1: 'Error (+1 base)',
E2: 'Error (+2 bases)',
E3: 'Error (+3 bases)',
RP: 'Rare Play (+3 bases)',
}
/**
* Hash result conversions (player mentally resolves based on batter speed)
*/
export const HASH_CONVERSIONS: Record<string, string[]> = {
'G2#': ['G2', 'SI2'],
'G3#': ['G3', 'SI2'],
}
/**
* Get display label for a result code
*/
export function getResultLabel(code: string): string {
return X_CHECK_RESULT_LABELS[code] || code
}
/**
* Get display label for an error result
*/
export function getErrorLabel(code: string): string {
return X_CHECK_ERROR_LABELS[code] || code
}
/**
* Check if a result code is a hash result (requires speed test)
*/
export function isHashResult(code: string): boolean {
return code.endsWith('#')
}
/**
* Check if a result code is SPD (speed check)
*/
export function isSpdResult(code: string): boolean {
return code === 'SPD'
}
/**
* Get conversion options for a hash result
*/
export function getHashConversions(code: string): string[] | null {
return HASH_CONVERSIONS[code] || null
}

View File

@ -16,6 +16,10 @@ import type {
RollData,
Lineup,
BenchPlayer,
XCheckData,
DecideAdvanceData,
DecideThrowData,
DecideSpeedCheckData,
} from '~/types'
export const useGameStore = defineStore('game', () => {
@ -36,6 +40,10 @@ export const useGameStore = defineStore('game', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// X-Check workflow state
const xCheckData = ref<XCheckData | null>(null)
const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null)
// Decision state (local pending decisions before submission)
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
@ -128,6 +136,26 @@ export const useGameStore = defineStore('game', () => {
gameState.value?.decision_phase === 'awaiting_stolen_base'
})
const needsXCheckResult = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_x_check_result' ||
gameState.value?.decision_phase === 'awaiting_x_check_result'
})
const needsDecideAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_advance' ||
gameState.value?.decision_phase === 'awaiting_decide_advance'
})
const needsDecideThrow = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_throw' ||
gameState.value?.decision_phase === 'awaiting_decide_throw'
})
const needsDecideResult = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_result' ||
gameState.value?.decision_phase === 'awaiting_decide_result'
})
const canRollDice = computed(() => {
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
})
@ -332,6 +360,34 @@ export const useGameStore = defineStore('game', () => {
pendingStealAttempts.value = []
}
/**
* Set x-check data (from decision_required event)
*/
function setXCheckData(data: XCheckData | null) {
xCheckData.value = data
}
/**
* Clear x-check data after resolution
*/
function clearXCheckData() {
xCheckData.value = null
}
/**
* Set DECIDE data (from decision_required event)
*/
function setDecideData(data: DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null) {
decideData.value = data
}
/**
* Clear DECIDE data after resolution
*/
function clearDecideData() {
decideData.value = null
}
/**
* Reset game store (when leaving game)
*/
@ -351,6 +407,8 @@ export const useGameStore = defineStore('game', () => {
pendingOffensiveDecision.value = null
pendingStealAttempts.value = []
decisionHistory.value = []
xCheckData.value = null
decideData.value = null
}
/**
@ -404,6 +462,8 @@ export const useGameStore = defineStore('game', () => {
pendingOffensiveDecision: readonly(pendingOffensiveDecision),
pendingStealAttempts: readonly(pendingStealAttempts),
decisionHistory: readonly(decisionHistory),
xCheckData: readonly(xCheckData),
decideData: readonly(decideData),
// Getters
gameId,
@ -432,6 +492,10 @@ export const useGameStore = defineStore('game', () => {
needsDefensiveDecision,
needsOffensiveDecision,
needsStolenBaseDecision,
needsXCheckResult,
needsDecideAdvance,
needsDecideThrow,
needsDecideResult,
canRollDice,
canSubmitOutcome,
recentPlays,
@ -458,6 +522,10 @@ export const useGameStore = defineStore('game', () => {
setPendingStealAttempts,
addDecisionToHistory,
clearPendingDecisions,
setXCheckData,
clearXCheckData,
setDecideData,
clearDecideData,
resetGame,
getActiveLineup,
getBenchPlayers,

View File

@ -1,335 +1,295 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DefensiveSetup from '~/components/Decisions/DefensiveSetup.vue'
import type { DefensiveDecision } from '~/types/game'
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
import type { DefensiveDecision } from "~/types/game";
describe('DefensiveSetup', () => {
const defaultProps = {
gameId: 'test-game-123',
isActive: true,
}
describe("DefensiveSetup", () => {
const defaultProps = {
gameId: "test-game-123",
isActive: true,
};
describe('Rendering', () => {
it('renders component with header', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
beforeEach(() => {
// Reset the singleton composable state before each test
const { reset } = useDefensiveSetup();
reset();
});
expect(wrapper.text()).toContain('Defensive Setup')
})
describe("Rendering", () => {
it("renders component with header", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('shows opponent turn indicator when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
expect(wrapper.text()).toContain("Defensive Setup");
});
expect(wrapper.text()).toContain("Opponent's Turn")
})
it("shows opponent turn indicator when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
it('renders all form sections', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.text()).toContain("Opponent's Turn");
});
expect(wrapper.text()).toContain('Infield Depth')
expect(wrapper.text()).toContain('Outfield Depth')
expect(wrapper.text()).toContain('Hold Runners')
})
})
it("renders all form sections", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
describe('Initial Values', () => {
it('uses default values when no currentSetup provided', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.text()).toContain("Infield Depth");
expect(wrapper.text()).toContain("Outfield Depth");
expect(wrapper.text()).toContain("Current Setup");
});
});
// Check preview shows defaults
expect(wrapper.text()).toContain('Normal')
})
describe("Initial Values", () => {
it("uses default values when no currentSetup provided", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('uses provided currentSetup values', () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'back',
outfield_depth: 'normal',
hold_runners: [1, 3],
}
// Check preview shows defaults
expect(wrapper.text()).toContain("Normal");
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
it("syncs composable from provided currentSetup via watcher", async () => {
/**
* When currentSetup prop is provided, the component should sync the
* composable state to match it. This verifies the prop->composable sync.
*/
const currentSetup: DefensiveDecision = {
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
};
expect(wrapper.vm.localSetup.infield_depth).toBe('back')
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 3])
})
})
mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
});
describe('Hold Runners', () => {
it('initializes hold runner toggles from currentSetup', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [1, 2],
},
},
})
// The composable should be synced from the prop via the watcher
const { holdRunnersArray, infieldDepth, outfieldDepth } =
useDefensiveSetup();
// Watcher fires on prop change, check initial sync happens
expect(infieldDepth.value).toBe("normal");
expect(outfieldDepth.value).toBe("normal");
});
});
expect(wrapper.vm.holdFirst).toBe(true)
expect(wrapper.vm.holdSecond).toBe(true)
expect(wrapper.vm.holdThird).toBe(false)
})
describe("Hold Runners Display", () => {
it('shows "None" when no runners held in preview', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('updates hold_runners array when toggles change', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
// Check preview section shows "None" for holding
expect(wrapper.text()).toContain("Holding:None");
});
wrapper.vm.holdFirst = true
wrapper.vm.holdThird = true
await wrapper.vm.$nextTick()
it("displays holding status in preview for held runners", () => {
/**
* The preview section should show a comma-separated list of held bases.
* Hold runner UI has moved to the runner pills themselves.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
});
expect(wrapper.vm.localSetup.hold_runners).toContain(1)
expect(wrapper.vm.localSetup.hold_runners).toContain(3)
expect(wrapper.vm.localSetup.hold_runners).not.toContain(2)
})
})
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
describe('Preview Display', () => {
it('displays current infield depth in preview', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
currentSetup: {
infield_depth: 'infield_in',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
// Preview should show the held bases
expect(wrapper.text()).toContain("Holding:1st, 3rd");
});
expect(wrapper.text()).toContain('Infield In')
})
it("displays holding status in preview for multiple runners", () => {
/**
* The preview section should show a comma-separated list of held bases.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 2, 3],
});
it('displays holding status for multiple runners', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [1, 2, 3],
},
},
})
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
const holdingText = wrapper.vm.holdingDisplay
expect(holdingText).toContain('1st')
expect(holdingText).toContain('2nd')
expect(holdingText).toContain('3rd')
})
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
});
});
it('shows "None" when no runners held', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
describe("Preview Display", () => {
it("displays current infield depth in preview", () => {
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [],
});
expect(wrapper.vm.holdingDisplay).toBe('None')
})
})
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
},
});
describe('Form Submission', () => {
it('emits submit event with current setup', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.text()).toContain("Infield In");
});
});
wrapper.vm.localSetup = {
infield_depth: 'in',
outfield_depth: 'normal',
hold_runners: [2],
}
describe("Form Submission", () => {
it("emits submit event with composable state", async () => {
/**
* On submit, the component should call getDecision() from the composable
* and emit the full DefensiveDecision.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [2],
});
await wrapper.find('form').trigger('submit.prevent')
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.emitted('submit')).toBeTruthy()
const emitted = wrapper.emitted('submit')![0][0] as DefensiveDecision
expect(emitted.infield_depth).toBe('in')
expect(emitted.outfield_depth).toBe('normal')
expect(emitted.hold_runners).toEqual([2])
})
await wrapper.find("form").trigger("submit.prevent");
it('does not submit when not active', async () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
expect(wrapper.emitted("submit")).toBeTruthy();
const emitted = wrapper.emitted(
"submit",
)![0][0] as DefensiveDecision;
expect(emitted.infield_depth).toBe("normal");
expect(emitted.outfield_depth).toBe("normal");
expect(emitted.hold_runners).toEqual([2]);
});
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.emitted('submit')).toBeFalsy()
})
it("does not submit when not active", async () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
it('allows submit with no changes (keep setup)', async () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
}
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeFalsy();
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
it("allows submit with default setup", async () => {
/**
* Submitting with defaults should emit a valid DefensiveDecision
* with normal depth and no held runners.
*/
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
await wrapper.find('form').trigger('submit.prevent')
// Component allows submitting same setup to confirm player's choice
expect(wrapper.emitted('submit')).toBeTruthy()
const emitted = wrapper.emitted('submit')![0][0] as DefensiveDecision
expect(emitted).toEqual(currentSetup)
})
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeTruthy();
const emitted = wrapper.emitted(
"submit",
)![0][0] as DefensiveDecision;
expect(emitted.infield_depth).toBe("normal");
expect(emitted.outfield_depth).toBe("normal");
expect(emitted.hold_runners).toEqual([]);
});
it('shows loading state during submission', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
it("shows loading state during submission", async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Trigger submission
wrapper.vm.submitting = true
await wrapper.vm.$nextTick()
// Trigger submission
wrapper.vm.submitting = true;
await wrapper.vm.$nextTick();
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true)
})
})
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true);
});
});
describe('Submit Button State', () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
describe("Submit Button State", () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
})
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
});
it('shows "Submit (Keep Setup)" when setup unchanged', () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
}
it('shows "Submit Defensive Setup" when active', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
});
});
expect(wrapper.vm.submitButtonText).toBe('Submit (Keep Setup)')
})
describe("Prop Updates", () => {
it("syncs composable state when currentSetup prop changes", async () => {
/**
* When the parent updates the currentSetup prop (e.g. from server state),
* the composable should be synced to match.
*/
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('shows "Submit Defensive Setup" when active with changes', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
const newSetup: DefensiveDecision = {
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [1, 2, 3],
};
wrapper.vm.localSetup.infield_depth = 'back'
expect(wrapper.vm.submitButtonText).toBe('Submit Defensive Setup')
})
})
await wrapper.setProps({ currentSetup: newSetup });
describe('Change Detection', () => {
it('detects infield depth changes', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
const { infieldDepth, outfieldDepth, holdRunnersArray } =
useDefensiveSetup();
expect(infieldDepth.value).toBe("infield_in");
expect(outfieldDepth.value).toBe("normal");
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
});
});
expect(wrapper.vm.hasChanges).toBe(false)
wrapper.vm.localSetup.infield_depth = 'back'
expect(wrapper.vm.hasChanges).toBe(true)
})
describe("Disabled State", () => {
it("disables depth controls when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
it('detects hold runners changes', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
expect(wrapper.vm.hasChanges).toBe(false)
wrapper.vm.localSetup.hold_runners = [1]
expect(wrapper.vm.hasChanges).toBe(true)
})
})
describe('Prop Updates', () => {
it('updates local state when currentSetup prop changes', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
const newSetup: DefensiveDecision = {
infield_depth: 'double_play',
outfield_depth: 'normal',
hold_runners: [1, 2, 3],
}
await wrapper.setProps({ currentSetup: newSetup })
expect(wrapper.vm.localSetup.infield_depth).toBe('double_play')
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 2, 3])
})
})
describe('Disabled State', () => {
it('disables all controls when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
const buttonGroups = wrapper.findAllComponents({ name: 'ButtonGroup' })
buttonGroups.forEach(bg => {
expect(bg.props('disabled')).toBe(true)
})
const toggles = wrapper.findAllComponents({ name: 'ToggleSwitch' })
toggles.forEach(toggle => {
expect(toggle.props('disabled')).toBe(true)
})
})
})
})
const buttonGroups = wrapper.findAllComponents({
name: "ButtonGroup",
});
buttonGroups.forEach((bg) => {
expect(bg.props("disabled")).toBe(true);
});
});
});
});

View File

@ -27,12 +27,12 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.empty").exists()).toBe(true);
expect(wrapper.find(".runner-pill.empty").exists()).toBe(true);
expect(wrapper.text()).toContain("Empty");
});
@ -42,7 +42,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -56,7 +56,7 @@ describe("RunnerCard", () => {
props: {
base: "3B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -71,7 +71,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -131,13 +131,13 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-card.empty").exists()).toBe(false);
expect(wrapper.find(".runner-pill.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-pill.empty").exists()).toBe(false);
});
it("displays runner name", () => {
@ -146,7 +146,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -160,7 +160,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -168,27 +168,13 @@ describe("RunnerCard", () => {
expect(wrapper.text()).toContain("2B");
});
it("displays runner number based on lineup_id", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("#01");
});
it("displays player headshot when available", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -206,7 +192,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#ff0000",
},
});
@ -217,28 +203,13 @@ describe("RunnerCard", () => {
);
});
it("shows chevron icon when occupied", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.exists()).toBe(true);
});
it("emits click event when clicked", async () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -248,7 +219,7 @@ describe("RunnerCard", () => {
});
});
describe("expanded state", () => {
describe("selected state", () => {
beforeEach(() => {
const gameStore = useGameStore();
gameStore.setGameState({
@ -259,19 +230,18 @@ describe("RunnerCard", () => {
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
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, [
{
@ -291,149 +261,32 @@ describe("RunnerCard", () => {
]);
});
it("does not show expanded view when collapsed", () => {
it("does not apply selected class when not selected", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").exists()).toBe(false);
expect(wrapper.find(".runner-pill.selected").exists()).toBe(false);
});
it("shows expanded view when isExpanded is true", () => {
it("applies selected class when isSelected is true", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
isSelected: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").exists()).toBe(true);
});
it("displays full player card image when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const cardImg = wrapper.find(
'.runner-expanded img[alt="Mike Trout card"]',
);
expect(cardImg.exists()).toBe(true);
expect(cardImg.attributes("src")).toBe(
"https://example.com/trout-card.jpg",
);
});
it("shows player initials when no card image available", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "",
headshot: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("MT");
});
it('displays "RUNNER" label in expanded header', () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").text()).toContain("RUNNER");
});
it("applies expanded class when isExpanded is true", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.expanded").exists()).toBe(true);
});
it("rotates chevron when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.classes()).toContain("rotate-90");
expect(wrapper.find(".runner-pill.selected").exists()).toBe(true);
});
});
@ -444,119 +297,198 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("Unknown Runner");
});
});
it("extracts initials from first and last name", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Aaron Donald Judge",
image: "",
},
},
]);
describe("hold runner icon", () => {
it("does not show hold icon by default", () => {
/**
* When neither isHeld nor holdInteractive is set, the hold icon
* should not appear keeps the pill clean for non-defensive contexts.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
isSelected: false,
teamColor: "#3b82f6",
},
});
// Should use first and last name (A + J)
expect(wrapper.text()).toContain("AJ");
expect(wrapper.find(".hold-icon").exists()).toBe(false);
});
it("handles single-word names", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Pele",
image: "",
},
},
]);
it("shows hold icon when holdInteractive is true", () => {
/**
* During the defensive decision phase, holdInteractive is true
* and the icon should appear even when the runner is not held.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
isSelected: false,
teamColor: "#3b82f6",
holdInteractive: true,
isHeld: false,
},
});
expect(wrapper.text()).toContain("PE");
expect(wrapper.find(".hold-icon").exists()).toBe(true);
});
it("shows hold icon when isHeld is true (read-only)", () => {
/**
* After submission, isHeld shows the current hold state as a
* non-interactive indicator even when holdInteractive is false.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: false,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.exists()).toBe(true);
expect(icon.attributes("disabled")).toBeDefined();
});
it("applies amber styling when held", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: true,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.classes()).toContain("bg-amber-500");
});
it("applies gray styling when not held", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.classes()).toContain("bg-gray-200");
});
it("emits toggleHold when clicked in interactive mode", async () => {
/**
* Clicking the hold icon should emit toggleHold so the parent
* can update the composable state.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
});
it("does not emit toggleHold when not interactive", async () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: false,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toBeUndefined();
});
it("does not emit click (selection) when hold icon is clicked", async () => {
/**
* The hold icon uses @click.stop so tapping it should NOT trigger
* the pill's selection behavior only the hold toggle.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
expect(wrapper.emitted("click")).toBeUndefined();
});
it("applies held class to the pill when isHeld", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
},
});
expect(wrapper.find(".runner-pill.held").exists()).toBe(true);
});
it("does not show hold icon on empty bases", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: null,
isSelected: false,
teamColor: "#3b82f6",
holdInteractive: true,
},
});
expect(wrapper.find(".hold-icon").exists()).toBe(false);
});
});
@ -567,7 +499,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -581,7 +513,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -595,7 +527,7 @@ describe("RunnerCard", () => {
props: {
base: "3B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});

View File

@ -159,9 +159,10 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards.length).toBeGreaterThanOrEqual(3);
expect(runnerCards[0].props("base")).toBe("1B");
// Order is now 3B, 2B, 1B (left to right)
expect(runnerCards[0].props("base")).toBe("3B");
expect(runnerCards[1].props("base")).toBe("2B");
expect(runnerCards[2].props("base")).toBe("3B");
expect(runnerCards[2].props("base")).toBe("1B");
});
it("passes runner data to RunnerCard components", () => {
@ -182,9 +183,10 @@ describe("RunnersOnBase", () => {
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards[0].props("runner")).toEqual(mockRunnerFirst);
expect(runnerCards[1].props("runner")).toEqual(mockRunnerSecond);
expect(runnerCards[2].props("runner")).toBeNull();
// Order is now 3B, 2B, 1B (left to right)
expect(runnerCards[0].props("runner")).toBeNull(); // 3B
expect(runnerCards[1].props("runner")).toEqual(mockRunnerSecond); // 2B
expect(runnerCards[2].props("runner")).toEqual(mockRunnerFirst); // 1B
});
it("passes team color to RunnerCard components", () => {
@ -205,14 +207,15 @@ describe("RunnersOnBase", () => {
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
// Runner cards now use hardcoded red (#ef4444) instead of battingTeamColor
runnerCards.forEach((card) => {
expect(card.props("teamColor")).toBe("#ff0000");
expect(card.props("teamColor")).toBe("#ef4444");
});
});
});
describe("catcher display", () => {
it("shows collapsed catcher card by default", () => {
it("shows catcher summary pill by default", () => {
const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] },
props: {
@ -229,11 +232,10 @@ describe("RunnersOnBase", () => {
},
});
// Collapsed state shows border-l-4, expanded state shows .matchup-card
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
true,
);
expect(wrapper.find(".matchup-card").exists()).toBe(false);
// Catcher summary pill is always visible
expect(wrapper.find(".catcher-pill").exists()).toBe(true);
// Expanded view not shown by default
expect(wrapper.findAll(".matchup-card")).toHaveLength(0);
});
it("displays catcher name", () => {
@ -278,7 +280,7 @@ describe("RunnersOnBase", () => {
});
describe("runner selection", () => {
it("expands catcher card when runner is selected", async () => {
it("shows expanded detail row when runner is selected", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
@ -336,14 +338,13 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard);
await runnerCards[0].trigger("click");
// When runner selected, collapsed state hidden and expanded state shown
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
false,
);
expect(wrapper.find(".matchup-card").exists()).toBe(true);
// When runner selected, expanded detail row shows both runner + catcher cards
// matchup-card-red is runner (red), matchup-card-blue is catcher (blue)
expect(wrapper.find(".matchup-card-red").exists()).toBe(true); // Runner full card
expect(wrapper.find(".matchup-card-blue").exists()).toBe(true); // Catcher full card
});
it("collapses catcher card when runner is deselected", async () => {
it("hides expanded detail row when runner is deselected", async () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
@ -400,16 +401,19 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard);
// Click to expand
// Click to show expanded view
await runnerCards[0].trigger("click");
expect(wrapper.find(".matchup-card").exists()).toBe(true);
await wrapper.vm.$nextTick();
expect(wrapper.find(".matchup-card-red").exists()).toBe(true); // Runner (red)
expect(wrapper.find(".matchup-card-blue").exists()).toBe(true); // Catcher (blue)
// Click again to collapse
// Click again to toggle selection off - Transition may keep elements during animation
await runnerCards[0].trigger("click");
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe(
true,
);
expect(wrapper.find(".matchup-card").exists()).toBe(false);
await wrapper.vm.$nextTick();
// Check that runner is no longer selected (internal state)
expect(runnerCards[0].props("isSelected")).toBe(false);
// Catcher summary pill remains visible
expect(wrapper.find(".catcher-pill").exists()).toBe(true);
});
it("switches selection when clicking different runner", async () => {
@ -482,15 +486,19 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard);
// Select first runner
await runnerCards[0].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(true);
expect(runnerCards[1].props("isExpanded")).toBe(false);
// Lead runner (2nd base, which is index 1 in 3B-2B-1B order) is auto-selected on mount
await wrapper.vm.$nextTick();
expect(runnerCards[1].props("isSelected")).toBe(true); // 2B auto-selected
// Select second runner
// Click 1B runner (index 2)
await runnerCards[2].trigger("click");
expect(runnerCards[1].props("isSelected")).toBe(false); // 2B deselected
expect(runnerCards[2].props("isSelected")).toBe(true); // 1B selected
// Click 2B runner again (index 1)
await runnerCards[1].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(false);
expect(runnerCards[1].props("isExpanded")).toBe(true);
expect(runnerCards[2].props("isSelected")).toBe(false); // 1B deselected
expect(runnerCards[1].props("isSelected")).toBe(true); // 2B selected
});
});
@ -571,7 +579,8 @@ describe("RunnersOnBase", () => {
});
const runnerCards = wrapper.findAllComponents(RunnerCard);
expect(runnerCards[0].props("teamColor")).toBe("#3b82f6");
// Runner cards now use red (#ef4444) instead of blue
expect(runnerCards[0].props("teamColor")).toBe("#ef4444");
});
});
});

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

@ -1,12 +1,15 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
import type { RollData, PlayResult } from '~/types'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue'
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
import PlayResultDisplay from '~/components/Gameplay/PlayResult.vue'
describe('GameplayPanel', () => {
let pinia: ReturnType<typeof createPinia>
const createRollData = (): RollData => ({
roll_id: 'test-roll-123',
d6_one: 3,
@ -44,7 +47,16 @@ describe('GameplayPanel', () => {
canSubmitOutcome: false,
}
const mountPanel = (propsOverride = {}) => {
return mount(GameplayPanel, {
global: { plugins: [pinia] },
props: { ...defaultProps, ...propsOverride },
})
}
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.clearAllTimers()
})
@ -54,18 +66,14 @@ describe('GameplayPanel', () => {
describe('Rendering', () => {
it('renders gameplay panel container', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
expect(wrapper.text()).toContain('Gameplay')
})
it('renders panel header with status', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.panel-header').exists()).toBe(true)
expect(wrapper.find('.status-indicator').exists()).toBe(true)
@ -79,21 +87,14 @@ describe('GameplayPanel', () => {
describe('Workflow State: Idle', () => {
it('shows idle state when canRollDice is false', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: false,
},
})
const wrapper = mountPanel({ canRollDice: false })
expect(wrapper.find('.state-idle').exists()).toBe(true)
expect(wrapper.text()).toContain('Waiting for strategic decisions')
})
it('displays idle status indicator', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.status-idle').exists()).toBe(true)
expect(wrapper.find('.status-text').text()).toBe('Waiting')
@ -106,63 +107,33 @@ describe('GameplayPanel', () => {
describe('Workflow State: Ready to Roll', () => {
it('shows ready state when canRollDice is true and my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.find('.state-ready').exists()).toBe(true)
expect(wrapper.text()).toContain('Your turn! Roll the dice')
})
it('shows waiting message when not my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: false,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
expect(wrapper.text()).toContain('Waiting for opponent to roll dice')
})
it('renders DiceRoller component when my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.findComponent(DiceRoller).exists()).toBe(true)
})
it('displays active status when ready and my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.find('.status-active').exists()).toBe(true)
expect(wrapper.find('.status-text').text()).toBe('Your Turn')
})
it('displays opponent turn status when ready but not my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: false,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
expect(wrapper.find('.status-text').text()).toBe('Opponent Turn')
})
@ -174,12 +145,9 @@ describe('GameplayPanel', () => {
describe('Workflow State: Rolled', () => {
it('shows rolled state when pendingRoll exists', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: createRollData(),
canSubmitOutcome: true,
},
const wrapper = mountPanel({
pendingRoll: createRollData(),
canSubmitOutcome: true,
})
expect(wrapper.find('.state-rolled').exists()).toBe(true)
@ -187,12 +155,9 @@ describe('GameplayPanel', () => {
it('renders DiceRoller with roll results', () => {
const rollData = createRollData()
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: rollData,
canSubmitOutcome: true,
},
const wrapper = mountPanel({
pendingRoll: rollData,
canSubmitOutcome: true,
})
const diceRoller = wrapper.findComponent(DiceRoller)
@ -202,24 +167,18 @@ describe('GameplayPanel', () => {
})
it('renders OutcomeWizard component', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: createRollData(),
canSubmitOutcome: true,
},
const wrapper = mountPanel({
pendingRoll: createRollData(),
canSubmitOutcome: true,
})
expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true)
})
it('displays active status when outcome entry active', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: createRollData(),
canSubmitOutcome: true,
},
const wrapper = mountPanel({
pendingRoll: createRollData(),
canSubmitOutcome: true,
})
expect(wrapper.find('.status-active').exists()).toBe(true)
@ -233,50 +192,32 @@ describe('GameplayPanel', () => {
describe('Workflow State: Result', () => {
it('shows result state when lastPlayResult exists', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
lastPlayResult: createPlayResult(),
},
})
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
expect(wrapper.find('.state-result').exists()).toBe(true)
})
it('renders PlayResult component', () => {
it('renders PlayResultDisplay component', () => {
const playResult = createPlayResult()
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
lastPlayResult: playResult,
},
})
const wrapper = mountPanel({ lastPlayResult: playResult })
const playResultComponent = wrapper.findComponent(PlayResultComponent)
expect(playResultComponent.exists()).toBe(true)
expect(playResultComponent.props('result')).toEqual(playResult)
const resultComponent = wrapper.findComponent(PlayResultDisplay)
expect(resultComponent.exists()).toBe(true)
expect(resultComponent.props('result')).toEqual(playResult)
})
it('displays success status when result shown', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
lastPlayResult: createPlayResult(),
},
})
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
expect(wrapper.find('.status-success').exists()).toBe(true)
expect(wrapper.find('.status-text').text()).toBe('Play Complete')
})
it('prioritizes result state over other states', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
pendingRoll: createRollData(),
lastPlayResult: createPlayResult(),
},
const wrapper = mountPanel({
canRollDice: true,
pendingRoll: createRollData(),
lastPlayResult: createPlayResult(),
})
expect(wrapper.find('.state-result').exists()).toBe(true)
@ -291,13 +232,7 @@ describe('GameplayPanel', () => {
describe('Event Emission', () => {
it('emits rollDice when DiceRoller emits roll', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
const diceRoller = wrapper.findComponent(DiceRoller)
await diceRoller.vm.$emit('roll')
@ -306,13 +241,10 @@ describe('GameplayPanel', () => {
expect(wrapper.emitted('rollDice')).toHaveLength(1)
})
it('emits submitOutcome when ManualOutcomeEntry submits', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: createRollData(),
canSubmitOutcome: true,
},
it('emits submitOutcome when OutcomeWizard submits', async () => {
const wrapper = mountPanel({
pendingRoll: createRollData(),
canSubmitOutcome: true,
})
const outcomeWizard = wrapper.findComponent(OutcomeWizard)
@ -323,16 +255,11 @@ describe('GameplayPanel', () => {
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])
})
it('emits dismissResult when PlayResult emits dismiss', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
lastPlayResult: createPlayResult(),
},
})
it('emits dismissResult when PlayResultDisplay emits dismiss', async () => {
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
const playResult = wrapper.findComponent(PlayResultComponent)
await playResult.vm.$emit('dismiss')
const resultComponent = wrapper.findComponent(PlayResultDisplay)
await resultComponent.vm.$emit('dismiss')
expect(wrapper.emitted('dismissResult')).toBeTruthy()
expect(wrapper.emitted('dismissResult')).toHaveLength(1)
@ -345,9 +272,7 @@ describe('GameplayPanel', () => {
describe('Workflow State Transitions', () => {
it('transitions from idle to ready when canRollDice becomes true', async () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.state-idle').exists()).toBe(true)
@ -358,13 +283,7 @@ describe('GameplayPanel', () => {
})
it('transitions from ready to rolled when pendingRoll set', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.find('.state-ready').exists()).toBe(true)
@ -379,12 +298,9 @@ describe('GameplayPanel', () => {
})
it('transitions from rolled to result when lastPlayResult set', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
pendingRoll: createRollData(),
canSubmitOutcome: true,
},
const wrapper = mountPanel({
pendingRoll: createRollData(),
canSubmitOutcome: true,
})
expect(wrapper.find('.state-rolled').exists()).toBe(true)
@ -399,12 +315,7 @@ describe('GameplayPanel', () => {
})
it('transitions from result to idle when result dismissed', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
lastPlayResult: createPlayResult(),
},
})
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
expect(wrapper.find('.state-result').exists()).toBe(true)
@ -421,9 +332,7 @@ describe('GameplayPanel', () => {
describe('Edge Cases', () => {
it('handles multiple rapid state changes', async () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
await wrapper.setProps({ canRollDice: true, isMyTurn: true })
await wrapper.setProps({ pendingRoll: createRollData(), canSubmitOutcome: true })
@ -433,39 +342,26 @@ describe('GameplayPanel', () => {
})
it('handles missing gameId gracefully', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
gameId: '',
},
})
const wrapper = mountPanel({ gameId: '' })
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
})
it('handles all props being null/false', () => {
const wrapper = mount(GameplayPanel, {
props: {
gameId: 'test',
isMyTurn: false,
canRollDice: false,
pendingRoll: null,
lastPlayResult: null,
canSubmitOutcome: false,
},
const wrapper = mountPanel({
gameId: 'test',
isMyTurn: false,
canRollDice: false,
pendingRoll: null,
lastPlayResult: null,
canSubmitOutcome: false,
})
expect(wrapper.find('.state-idle').exists()).toBe(true)
})
it('clears error when rolling dice', async () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
// Manually set error (would normally come from failed operation)
wrapper.vm.error = 'Test error'

View File

@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
describe('useDefensiveSetup', () => {
beforeEach(() => {
const { reset } = useDefensiveSetup()
reset()
})
describe('singleton behavior', () => {
it('returns the same state across multiple calls', () => {
/**
* The composable is a module-level singleton multiple calls to
* useDefensiveSetup() should return refs pointing to the same state.
*/
const a = useDefensiveSetup()
const b = useDefensiveSetup()
a.toggleHold(1)
expect(b.isHeld(1)).toBe(true)
expect(b.holdRunnersArray.value).toEqual([1])
})
})
describe('toggleHold', () => {
it('adds a base when not held', () => {
const { toggleHold, isHeld } = useDefensiveSetup()
toggleHold(1)
expect(isHeld(1)).toBe(true)
})
it('removes a base when already held', () => {
const { toggleHold, isHeld } = useDefensiveSetup()
toggleHold(2)
expect(isHeld(2)).toBe(true)
toggleHold(2)
expect(isHeld(2)).toBe(false)
})
it('can hold multiple bases independently', () => {
const { toggleHold, isHeld, holdRunnersArray } = useDefensiveSetup()
toggleHold(1)
toggleHold(3)
expect(isHeld(1)).toBe(true)
expect(isHeld(2)).toBe(false)
expect(isHeld(3)).toBe(true)
expect(holdRunnersArray.value).toEqual([1, 3])
})
})
describe('holdRunnersArray', () => {
it('returns sorted array of held base numbers', () => {
/**
* holdRunnersArray should always be sorted so the output is
* deterministic regardless of toggle order.
*/
const { toggleHold, holdRunnersArray } = useDefensiveSetup()
toggleHold(3)
toggleHold(1)
expect(holdRunnersArray.value).toEqual([1, 3])
})
it('returns empty array when nothing is held', () => {
const { holdRunnersArray } = useDefensiveSetup()
expect(holdRunnersArray.value).toEqual([])
})
})
describe('reset', () => {
it('clears all hold state and resets depths to defaults', () => {
const { toggleHold, infieldDepth, outfieldDepth, holdRunnersArray, reset } = useDefensiveSetup()
toggleHold(1)
toggleHold(2)
infieldDepth.value = 'infield_in'
outfieldDepth.value = 'shallow'
reset()
expect(holdRunnersArray.value).toEqual([])
expect(infieldDepth.value).toBe('normal')
expect(outfieldDepth.value).toBe('normal')
})
})
describe('syncFromDecision', () => {
it('sets all state from a DefensiveDecision object', () => {
const { syncFromDecision, infieldDepth, outfieldDepth, holdRunnersArray } = useDefensiveSetup()
syncFromDecision({
infield_depth: 'corners_in',
outfield_depth: 'shallow',
hold_runners: [1, 3],
})
expect(infieldDepth.value).toBe('corners_in')
expect(outfieldDepth.value).toBe('shallow')
expect(holdRunnersArray.value).toEqual([1, 3])
})
it('clears previously held runners not in new decision', () => {
const { toggleHold, syncFromDecision, isHeld } = useDefensiveSetup()
toggleHold(1)
toggleHold(2)
toggleHold(3)
syncFromDecision({
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [2],
})
expect(isHeld(1)).toBe(false)
expect(isHeld(2)).toBe(true)
expect(isHeld(3)).toBe(false)
})
})
describe('getDecision', () => {
it('returns a valid DefensiveDecision from current state', () => {
const { toggleHold, infieldDepth, getDecision } = useDefensiveSetup()
infieldDepth.value = 'infield_in'
toggleHold(1)
toggleHold(3)
const decision = getDecision()
expect(decision).toEqual({
infield_depth: 'infield_in',
outfield_depth: 'normal',
hold_runners: [1, 3],
})
})
it('returns defaults when nothing has been set', () => {
const { getDecision } = useDefensiveSetup()
expect(getDecision()).toEqual({
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
})
})
})
})

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: readonly number[] | number[]
chart_row: readonly string[] | 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

@ -30,6 +30,12 @@ export type {
GameListItem,
CreateGameRequest,
CreateGameResponse,
// X-Check workflow types
XCheckData,
DecideAdvanceData,
DecideThrowData,
DecideSpeedCheckData,
PendingXCheck,
} from './game'
// Player types
@ -65,6 +71,11 @@ export type {
GetLineupRequest,
GetBoxScoreRequest,
RequestGameStateRequest,
// X-Check workflow request types
SubmitXCheckResultRequest,
SubmitDecideAdvanceRequest,
SubmitDecideThrowRequest,
SubmitDecideResultRequest,
// Event types
ConnectedEvent,
GameJoinedEvent,

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