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:
commit
ffcbe248bd
2317
.claude/X_CHECK_INTERACTIVE_WORKFLOW.md
Normal file
2317
.claude/X_CHECK_INTERACTIVE_WORKFLOW.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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.
|
||||
|
||||
666
backend/tests/unit/core/test_play_resolver_invariants.py
Normal file
666
backend/tests/unit/core/test_play_resolver_invariants.py
Normal 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"
|
||||
1115
backend/tests/unit/core/test_uncapped_hit_workflow.py
Normal file
1115
backend/tests/unit/core/test_uncapped_hit_workflow.py
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/tests/unit/core/truth_tables/__init__.py
Normal file
0
backend/tests/unit/core/truth_tables/__init__.py
Normal file
215
backend/tests/unit/core/truth_tables/conftest.py
Normal file
215
backend/tests/unit/core/truth_tables/conftest.py
Normal 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}"
|
||||
)
|
||||
493
backend/tests/unit/core/truth_tables/test_tt_groundballs.py
Normal file
493
backend/tests/unit/core/truth_tables/test_tt_groundballs.py
Normal 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, R1→2nd
|
||||
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/2B→Result 3, 3B→Result 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}",
|
||||
)
|
||||
169
backend/tests/unit/core/truth_tables/test_tt_hits.py
Normal file
169
backend/tests/unit/core/truth_tables/test_tt_hits.py
Normal 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, R2→3rd, R1→2nd
|
||||
SINGLE_2: R3 scores, R2 scores, R1→3rd
|
||||
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}"
|
||||
)
|
||||
182
backend/tests/unit/core/truth_tables/test_tt_simple_outs.py
Normal file
182
backend/tests/unit/core/truth_tables/test_tt_simple_outs.py
Normal 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}"
|
||||
124
backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py
Normal file
124
backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py
Normal 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, R2→3rd, R1→2nd)
|
||||
- DOUBLE_UNCAPPED → DOUBLE_2 equivalent (R1→3rd, 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}"
|
||||
129
backend/tests/unit/core/truth_tables/test_tt_walks.py
Normal file
129
backend/tests/unit/core/truth_tables/test_tt_walks.py
Normal 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}"
|
||||
)
|
||||
465
backend/tests/unit/models/test_pending_x_check.py
Normal file
465
backend/tests/unit/models/test_pending_x_check.py
Normal 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"
|
||||
385
backend/tests/unit/websocket/test_uncapped_hit_handlers.py
Normal file
385
backend/tests/unit/websocket/test_uncapped_hit_handlers.py
Normal 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"]
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
506
frontend-sba/components/Gameplay/XCheckWizard.vue
Normal file
506
frontend-sba/components/Gameplay/XCheckWizard.vue
Normal 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>
|
||||
63
frontend-sba/composables/useDefensiveSetup.ts
Normal file
63
frontend-sba/composables/useDefensiveSetup.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
87
frontend-sba/constants/xCheckResults.ts
Normal file
87
frontend-sba/constants/xCheckResults.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
63
frontend-sba/tests/unit/components/Game/fix-tests.js
Normal file
63
frontend-sba/tests/unit/components/Game/fix-tests.js
Normal file
@ -0,0 +1,63 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Read RunnerCard.spec.ts
|
||||
let content = fs.readFileSync('RunnerCard.spec.ts', 'utf8');
|
||||
|
||||
// Replace all gameStore.setLineup calls with proper setup
|
||||
content = content.replace(/gameStore\.setLineup\('home',/g, `gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: 'active',
|
||||
inning: 1,
|
||||
half: 'top',
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: 'NYY',
|
||||
away_team_abbrev: 'BOS',
|
||||
home_team_dice_color: '3b82f6',
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: 'idle',
|
||||
play_count: 0
|
||||
});
|
||||
|
||||
gameStore.updateLineup(1,`);
|
||||
|
||||
fs.writeFileSync('RunnerCard.spec.ts', content);
|
||||
|
||||
// Read RunnersOnBase.spec.ts
|
||||
content = fs.readFileSync('RunnersOnBase.spec.ts', 'utf8');
|
||||
|
||||
// Replace all gameStore.setLineup calls
|
||||
content = content.replace(/gameStore\.setLineup\('home',/g, `gameStore.setGameState({
|
||||
id: 1,
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
status: 'active',
|
||||
inning: 1,
|
||||
half: 'top',
|
||||
outs: 0,
|
||||
home_score: 0,
|
||||
away_score: 0,
|
||||
home_team_abbrev: 'NYY',
|
||||
away_team_abbrev: 'BOS',
|
||||
home_team_dice_color: '3b82f6',
|
||||
current_batter: null,
|
||||
current_pitcher: null,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
decision_phase: 'idle',
|
||||
play_count: 0
|
||||
});
|
||||
|
||||
gameStore.updateLineup(1,`);
|
||||
|
||||
fs.writeFileSync('RunnersOnBase.spec.ts', content);
|
||||
|
||||
console.log('Fixed test files');
|
||||
@ -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'
|
||||
|
||||
153
frontend-sba/tests/unit/composables/useDefensiveSetup.spec.ts
Normal file
153
frontend-sba/tests/unit/composables/useDefensiveSetup.spec.ts
Normal 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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -15,6 +15,10 @@ import type {
|
||||
DefensiveDecision,
|
||||
OffensiveDecision,
|
||||
ManualOutcomeSubmission,
|
||||
XCheckData,
|
||||
DecideAdvanceData,
|
||||
DecideThrowData,
|
||||
DecideSpeedCheckData,
|
||||
} from './game'
|
||||
|
||||
import type {
|
||||
@ -48,6 +52,12 @@ export interface ClientToServerEvents {
|
||||
roll_dice: (data: RollDiceRequest) => void
|
||||
submit_manual_outcome: (data: SubmitManualOutcomeRequest) => void
|
||||
|
||||
// Interactive x-check workflow
|
||||
submit_x_check_result: (data: SubmitXCheckResultRequest) => void
|
||||
submit_decide_advance: (data: SubmitDecideAdvanceRequest) => void
|
||||
submit_decide_throw: (data: SubmitDecideThrowRequest) => void
|
||||
submit_decide_result: (data: SubmitDecideResultRequest) => void
|
||||
|
||||
// Substitutions
|
||||
request_pinch_hitter: (data: PinchHitterRequest) => void
|
||||
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
|
||||
@ -359,3 +369,28 @@ export interface TypedSocket {
|
||||
readonly connected: boolean
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive X-Check Request Types
|
||||
*/
|
||||
|
||||
export interface SubmitXCheckResultRequest {
|
||||
game_id: string
|
||||
result_code: string // G1, G2, SI2, F1, etc.
|
||||
error_result: string // NO, E1, E2, E3, RP
|
||||
}
|
||||
|
||||
export interface SubmitDecideAdvanceRequest {
|
||||
game_id: string
|
||||
advance: boolean
|
||||
}
|
||||
|
||||
export interface SubmitDecideThrowRequest {
|
||||
game_id: string
|
||||
target: 'runner' | 'first'
|
||||
}
|
||||
|
||||
export interface SubmitDecideResultRequest {
|
||||
game_id: string
|
||||
outcome: 'safe' | 'out'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user