CLAUDE: Implement uncapped hit interactive decision tree (Issue #6)
Add full multi-step decision workflow for SINGLE_UNCAPPED and DOUBLE_UNCAPPED outcomes, replacing the previous stub that fell through to basic single/double advancement. The decision tree follows the same interactive pattern as X-Check resolution with 5 phases: lead runner advance, defensive throw, trail runner advance, throw target selection, and safe/out speed check. - game_models.py: PendingUncappedHit model, 5 new decision phases - game_engine.py: initiate_uncapped_hit(), 5 submit methods, 3 result builders - handlers.py: 5 new WebSocket event handlers - ai_opponent.py: 5 AI decision stubs (conservative defaults) - play_resolver.py: Updated TODO comments for fallback paths - 80 new backend tests (2481 total): workflow (49), handlers (23), truth tables (8) - Fix GameplayPanel.spec.ts: add missing Pinia setup, fix component references Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a70df74bf
commit
529c5b1b99
@ -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).
|
||||
|
||||
@ -31,6 +31,7 @@ from app.models.game_models import (
|
||||
DefensiveDecision,
|
||||
GameState,
|
||||
OffensiveDecision,
|
||||
PendingUncappedHit,
|
||||
PendingXCheck,
|
||||
)
|
||||
from app.services import PlayStatCalculator
|
||||
@ -62,8 +63,28 @@ class GameEngine:
|
||||
self._connection_manager = connection_manager
|
||||
logger.info("WebSocket connection manager configured for game engine")
|
||||
|
||||
# Phases where the OFFENSIVE team (batting) decides
|
||||
_OFFENSIVE_PHASES = {
|
||||
"awaiting_offensive",
|
||||
"awaiting_uncapped_lead_advance",
|
||||
"awaiting_uncapped_trail_advance",
|
||||
"awaiting_uncapped_safe_out",
|
||||
}
|
||||
# Phases where the DEFENSIVE team (fielding) decides
|
||||
_DEFENSIVE_PHASES = {
|
||||
"awaiting_defensive",
|
||||
"awaiting_uncapped_defensive_throw",
|
||||
"awaiting_uncapped_throw_target",
|
||||
}
|
||||
|
||||
async def _emit_decision_required(
|
||||
self, game_id: UUID, state: GameState, phase: str, timeout_seconds: int = 300
|
||||
self,
|
||||
game_id: UUID,
|
||||
state: GameState,
|
||||
phase: str,
|
||||
timeout_seconds: int = 300,
|
||||
data: dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Emit decision_required event to notify frontend a decision is needed.
|
||||
@ -71,34 +92,45 @@ class GameEngine:
|
||||
Args:
|
||||
game_id: Game identifier
|
||||
state: Current game state
|
||||
phase: Decision phase ('awaiting_defensive' or 'awaiting_offensive')
|
||||
phase: Decision phase (e.g. 'awaiting_defensive', 'awaiting_uncapped_lead_advance')
|
||||
timeout_seconds: Decision timeout in seconds (default 5 minutes)
|
||||
data: Optional extra data dict to include in the payload
|
||||
**kwargs: Absorbs legacy keyword args (e.g. decision_type from x-check)
|
||||
"""
|
||||
if not self._connection_manager:
|
||||
logger.warning("No connection manager - cannot emit decision_required event")
|
||||
return
|
||||
|
||||
# Handle legacy kwarg from x-check calls
|
||||
if "decision_type" in kwargs and not phase:
|
||||
phase = kwargs["decision_type"]
|
||||
|
||||
# Determine which team needs to decide
|
||||
if phase == "awaiting_defensive":
|
||||
# Fielding team = home if top, away if bottom
|
||||
if phase in self._DEFENSIVE_PHASES:
|
||||
role = "home" if state.half == "top" else "away"
|
||||
elif phase == "awaiting_offensive":
|
||||
# Batting team = away if top, home if bottom
|
||||
elif phase in self._OFFENSIVE_PHASES:
|
||||
role = "away" if state.half == "top" else "home"
|
||||
elif phase == "awaiting_x_check_result":
|
||||
# X-check: defensive player selects result
|
||||
role = "home" if state.half == "top" else "away"
|
||||
else:
|
||||
logger.warning(f"Unknown decision phase for emission: {phase}")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"phase": phase,
|
||||
"role": role,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"message": f"{role.title()} team: {phase.replace('_', ' ').replace('awaiting ', '').title()} decision required",
|
||||
}
|
||||
if data:
|
||||
payload["data"] = data
|
||||
|
||||
try:
|
||||
await self._connection_manager.broadcast_to_game(
|
||||
str(game_id),
|
||||
"decision_required",
|
||||
{
|
||||
"phase": phase,
|
||||
"role": role,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"message": f"{role.title()} team: {phase.replace('_', ' ').title()} decision required"
|
||||
}
|
||||
payload,
|
||||
)
|
||||
logger.info(f"Emitted decision_required for game {game_id}: phase={phase}, role={role}")
|
||||
except (ConnectionError, OSError) as e:
|
||||
@ -778,6 +810,25 @@ class GameEngine:
|
||||
x_check_details=None, # Will be populated when resolved
|
||||
)
|
||||
|
||||
# Check for uncapped hit outcomes - route to interactive decision tree
|
||||
if outcome in (PlayOutcome.SINGLE_UNCAPPED, PlayOutcome.DOUBLE_UNCAPPED):
|
||||
if self._uncapped_needs_decision(state, outcome):
|
||||
await self.initiate_uncapped_hit(game_id, outcome, hit_location, ab_roll)
|
||||
# Return placeholder - actual resolution happens through decision workflow
|
||||
return PlayResult(
|
||||
outcome=outcome,
|
||||
outs_recorded=0,
|
||||
runs_scored=0,
|
||||
batter_result=None,
|
||||
runners_advanced=[],
|
||||
description=f"Uncapped {'single' if outcome == PlayOutcome.SINGLE_UNCAPPED else 'double'} - awaiting runner decisions",
|
||||
ab_roll=ab_roll,
|
||||
hit_location=hit_location,
|
||||
is_hit=True,
|
||||
is_out=False,
|
||||
is_walk=False,
|
||||
)
|
||||
|
||||
# NOTE: Business rule validation (e.g., when hit_location is required based on
|
||||
# game state) is handled in PlayResolver, not here. The transport layer should
|
||||
# not make business logic decisions about contextual requirements.
|
||||
@ -1064,6 +1115,828 @@ class GameEngine:
|
||||
f"result={pending.selected_result}, error={pending.error_result}"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# INTERACTIVE UNCAPPED HIT WORKFLOW
|
||||
# ============================================================================
|
||||
|
||||
def _uncapped_needs_decision(self, state: GameState, outcome: PlayOutcome) -> bool:
|
||||
"""
|
||||
Determine if an uncapped hit requires interactive runner decisions.
|
||||
|
||||
SINGLE_UNCAPPED: needs decision if R1 or R2 exists (eligible lead runner)
|
||||
DOUBLE_UNCAPPED: needs decision if R1 exists (R1 is lead attempting HOME)
|
||||
|
||||
Args:
|
||||
state: Current game state
|
||||
outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED
|
||||
|
||||
Returns:
|
||||
True if interactive decision tree is needed
|
||||
"""
|
||||
if outcome == PlayOutcome.SINGLE_UNCAPPED:
|
||||
return state.on_first is not None or state.on_second is not None
|
||||
if outcome == PlayOutcome.DOUBLE_UNCAPPED:
|
||||
return state.on_first is not None
|
||||
return False
|
||||
|
||||
async def initiate_uncapped_hit(
|
||||
self,
|
||||
game_id: UUID,
|
||||
outcome: PlayOutcome,
|
||||
hit_location: str | None,
|
||||
ab_roll: "AbRoll",
|
||||
) -> None:
|
||||
"""
|
||||
Initiate interactive uncapped hit decision workflow.
|
||||
|
||||
Identifies lead/trail runners, records auto-scoring runners,
|
||||
creates PendingUncappedHit, and emits first decision prompt.
|
||||
|
||||
Args:
|
||||
game_id: Game ID
|
||||
outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED
|
||||
hit_location: Outfield position (LF, CF, RF)
|
||||
ab_roll: The at-bat roll for audit trail
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
|
||||
is_single = outcome == PlayOutcome.SINGLE_UNCAPPED
|
||||
hit_type = "single" if is_single else "double"
|
||||
batter_base = 1 if is_single else 2
|
||||
|
||||
# Default hit_location to CF if not provided
|
||||
location = hit_location or "CF"
|
||||
|
||||
auto_runners: list[tuple[int, int, int]] = []
|
||||
|
||||
if is_single:
|
||||
# R3 always scores on any single
|
||||
if state.on_third:
|
||||
auto_runners.append((3, 4, state.on_third.lineup_id))
|
||||
|
||||
# Identify lead and trail runners
|
||||
if state.on_second:
|
||||
# Lead = R2 attempting HOME
|
||||
lead_base = 2
|
||||
lead_lid = state.on_second.lineup_id
|
||||
lead_target = 4 # HOME
|
||||
|
||||
# Trail = R1 if exists, else batter
|
||||
if state.on_first:
|
||||
trail_base = 1
|
||||
trail_lid = state.on_first.lineup_id
|
||||
trail_target = 3 # R1 attempting 3rd
|
||||
else:
|
||||
trail_base = 0 # batter
|
||||
trail_lid = state.current_batter.lineup_id
|
||||
trail_target = 2 # batter attempting 2nd
|
||||
elif state.on_first:
|
||||
# No R2, Lead = R1 attempting 3RD
|
||||
lead_base = 1
|
||||
lead_lid = state.on_first.lineup_id
|
||||
lead_target = 3
|
||||
|
||||
# Trail = batter attempting 2nd
|
||||
trail_base = 0
|
||||
trail_lid = state.current_batter.lineup_id
|
||||
trail_target = 2
|
||||
else:
|
||||
# Should not reach here (_uncapped_needs_decision checks)
|
||||
raise ValueError("SINGLE_UNCAPPED with no R1 or R2 should use fallback")
|
||||
else:
|
||||
# DOUBLE_UNCAPPED
|
||||
# R3 and R2 always score on any double
|
||||
if state.on_third:
|
||||
auto_runners.append((3, 4, state.on_third.lineup_id))
|
||||
if state.on_second:
|
||||
auto_runners.append((2, 4, state.on_second.lineup_id))
|
||||
|
||||
if state.on_first:
|
||||
# Lead = R1 attempting HOME
|
||||
lead_base = 1
|
||||
lead_lid = state.on_first.lineup_id
|
||||
lead_target = 4 # HOME
|
||||
|
||||
# Trail = batter attempting 3RD
|
||||
trail_base = 0
|
||||
trail_lid = state.current_batter.lineup_id
|
||||
trail_target = 3
|
||||
else:
|
||||
# Should not reach here
|
||||
raise ValueError("DOUBLE_UNCAPPED with no R1 should use fallback")
|
||||
|
||||
# Create pending uncapped hit state
|
||||
pending = PendingUncappedHit(
|
||||
hit_type=hit_type,
|
||||
hit_location=location,
|
||||
ab_roll_id=ab_roll.roll_id,
|
||||
lead_runner_base=lead_base,
|
||||
lead_runner_lineup_id=lead_lid,
|
||||
lead_target_base=lead_target,
|
||||
trail_runner_base=trail_base,
|
||||
trail_runner_lineup_id=trail_lid,
|
||||
trail_target_base=trail_target,
|
||||
auto_runners=auto_runners,
|
||||
batter_base=batter_base,
|
||||
batter_lineup_id=state.current_batter.lineup_id,
|
||||
)
|
||||
|
||||
# Store in state
|
||||
state.pending_uncapped_hit = pending
|
||||
state.decision_phase = "awaiting_uncapped_lead_advance"
|
||||
state.pending_decision = "uncapped_lead_advance"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
logger.info(
|
||||
f"Uncapped {hit_type} initiated for game {game_id}: "
|
||||
f"lead=base{lead_base}→{lead_target}, trail=base{trail_base}→{trail_target}"
|
||||
)
|
||||
|
||||
# Check if offensive team is AI
|
||||
if state.is_batting_team_ai():
|
||||
advance = await ai_opponent.decide_uncapped_lead_advance(state, pending)
|
||||
await self.submit_uncapped_lead_advance(game_id, advance)
|
||||
return
|
||||
|
||||
# Emit decision_required for offensive team
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_lead_advance",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"hit_type": hit_type,
|
||||
"hit_location": location,
|
||||
"lead_runner_base": lead_base,
|
||||
"lead_runner_lineup_id": lead_lid,
|
||||
"lead_target_base": lead_target,
|
||||
"auto_runners": auto_runners,
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_uncapped_lead_advance(
|
||||
self, game_id: UUID, advance: bool
|
||||
) -> None:
|
||||
"""
|
||||
Submit offensive decision: will lead runner attempt advance?
|
||||
|
||||
If NO: fallback to standard SI*/DO** advancement, finalize immediately.
|
||||
If YES: transition to awaiting_uncapped_defensive_throw.
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
pending = state.pending_uncapped_hit
|
||||
if not pending:
|
||||
raise ValueError("No pending uncapped hit")
|
||||
|
||||
if state.decision_phase != "awaiting_uncapped_lead_advance":
|
||||
raise ValueError(
|
||||
f"Wrong phase: expected awaiting_uncapped_lead_advance, "
|
||||
f"got {state.decision_phase}"
|
||||
)
|
||||
|
||||
pending.lead_advance = advance
|
||||
|
||||
if not advance:
|
||||
# Lead runner declines → fallback to standard advancement, finalize
|
||||
ab_roll = state.pending_manual_roll
|
||||
if not ab_roll:
|
||||
raise ValueError("No pending manual roll found")
|
||||
|
||||
result = self._build_uncapped_fallback_result(state, pending, ab_roll)
|
||||
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
||||
return
|
||||
|
||||
# Lead runner advances → ask defensive team about throwing
|
||||
state.decision_phase = "awaiting_uncapped_defensive_throw"
|
||||
state.pending_decision = "uncapped_defensive_throw"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Check if defensive team is AI
|
||||
if state.is_fielding_team_ai():
|
||||
will_throw = await ai_opponent.decide_uncapped_defensive_throw(
|
||||
state, pending
|
||||
)
|
||||
await self.submit_uncapped_defensive_throw(game_id, will_throw)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_defensive_throw",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"lead_runner_base": pending.lead_runner_base,
|
||||
"lead_target_base": pending.lead_target_base,
|
||||
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_uncapped_defensive_throw(
|
||||
self, game_id: UUID, will_throw: bool
|
||||
) -> None:
|
||||
"""
|
||||
Submit defensive decision: will you throw to the base?
|
||||
|
||||
If NO: lead runner safe, standard advancement, finalize.
|
||||
If YES and trail runner exists: transition to awaiting_uncapped_trail_advance.
|
||||
If YES and no trail: roll d20, transition to awaiting_uncapped_safe_out.
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
pending = state.pending_uncapped_hit
|
||||
if not pending:
|
||||
raise ValueError("No pending uncapped hit")
|
||||
|
||||
if state.decision_phase != "awaiting_uncapped_defensive_throw":
|
||||
raise ValueError(
|
||||
f"Wrong phase: expected awaiting_uncapped_defensive_throw, "
|
||||
f"got {state.decision_phase}"
|
||||
)
|
||||
|
||||
pending.defensive_throw = will_throw
|
||||
|
||||
if not will_throw:
|
||||
# Defense declines throw → lead runner advances safely, finalize
|
||||
ab_roll = state.pending_manual_roll
|
||||
if not ab_roll:
|
||||
raise ValueError("No pending manual roll found")
|
||||
|
||||
result = self._build_uncapped_no_throw_result(state, pending, ab_roll)
|
||||
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
||||
return
|
||||
|
||||
# Defense throws → check for trail runner
|
||||
has_trail = pending.trail_runner_base is not None
|
||||
|
||||
if not has_trail:
|
||||
# No trail runner → roll d20 for lead runner speed check
|
||||
d20 = dice_system.roll_d20(
|
||||
game_id=game_id,
|
||||
team_id=state.get_fielding_team_id(),
|
||||
player_id=None,
|
||||
)
|
||||
pending.speed_check_d20 = d20
|
||||
pending.speed_check_runner = "lead"
|
||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||
state.pending_decision = "uncapped_safe_out"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Check if offensive team is AI
|
||||
if state.is_batting_team_ai():
|
||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||
await self.submit_uncapped_safe_out(game_id, result)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_safe_out",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"d20_roll": d20,
|
||||
"runner": "lead",
|
||||
"runner_base": pending.lead_runner_base,
|
||||
"target_base": pending.lead_target_base,
|
||||
"runner_lineup_id": pending.lead_runner_lineup_id,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Trail runner exists → ask offensive about trail advance
|
||||
state.decision_phase = "awaiting_uncapped_trail_advance"
|
||||
state.pending_decision = "uncapped_trail_advance"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Check if offensive team is AI
|
||||
if state.is_batting_team_ai():
|
||||
advance = await ai_opponent.decide_uncapped_trail_advance(
|
||||
state, pending
|
||||
)
|
||||
await self.submit_uncapped_trail_advance(game_id, advance)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_trail_advance",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"trail_runner_base": pending.trail_runner_base,
|
||||
"trail_target_base": pending.trail_target_base,
|
||||
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_uncapped_trail_advance(
|
||||
self, game_id: UUID, advance: bool
|
||||
) -> None:
|
||||
"""
|
||||
Submit offensive decision: will trail runner attempt advance?
|
||||
|
||||
If NO: roll d20 for lead runner only, transition to awaiting_uncapped_safe_out.
|
||||
If YES: transition to awaiting_uncapped_throw_target.
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
pending = state.pending_uncapped_hit
|
||||
if not pending:
|
||||
raise ValueError("No pending uncapped hit")
|
||||
|
||||
if state.decision_phase != "awaiting_uncapped_trail_advance":
|
||||
raise ValueError(
|
||||
f"Wrong phase: expected awaiting_uncapped_trail_advance, "
|
||||
f"got {state.decision_phase}"
|
||||
)
|
||||
|
||||
pending.trail_advance = advance
|
||||
|
||||
if not advance:
|
||||
# Trail declines → roll d20 for lead runner
|
||||
d20 = dice_system.roll_d20(
|
||||
game_id=game_id,
|
||||
team_id=state.get_fielding_team_id(),
|
||||
player_id=None,
|
||||
)
|
||||
pending.speed_check_d20 = d20
|
||||
pending.speed_check_runner = "lead"
|
||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||
state.pending_decision = "uncapped_safe_out"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
if state.is_batting_team_ai():
|
||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||
await self.submit_uncapped_safe_out(game_id, result)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_safe_out",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"d20_roll": d20,
|
||||
"runner": "lead",
|
||||
"runner_base": pending.lead_runner_base,
|
||||
"target_base": pending.lead_target_base,
|
||||
"runner_lineup_id": pending.lead_runner_lineup_id,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Both runners advance → defense picks throw target
|
||||
state.decision_phase = "awaiting_uncapped_throw_target"
|
||||
state.pending_decision = "uncapped_throw_target"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
if state.is_fielding_team_ai():
|
||||
target = await ai_opponent.decide_uncapped_throw_target(
|
||||
state, pending
|
||||
)
|
||||
await self.submit_uncapped_throw_target(game_id, target)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_throw_target",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"lead_runner_base": pending.lead_runner_base,
|
||||
"lead_target_base": pending.lead_target_base,
|
||||
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
||||
"trail_runner_base": pending.trail_runner_base,
|
||||
"trail_target_base": pending.trail_target_base,
|
||||
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_uncapped_throw_target(
|
||||
self, game_id: UUID, target: str
|
||||
) -> None:
|
||||
"""
|
||||
Submit defensive decision: throw for lead or trail runner?
|
||||
|
||||
LEAD: trail auto-advances, roll d20 for lead → awaiting_uncapped_safe_out.
|
||||
TRAIL: lead auto-advances, roll d20 for trail → awaiting_uncapped_safe_out.
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
pending = state.pending_uncapped_hit
|
||||
if not pending:
|
||||
raise ValueError("No pending uncapped hit")
|
||||
|
||||
if state.decision_phase != "awaiting_uncapped_throw_target":
|
||||
raise ValueError(
|
||||
f"Wrong phase: expected awaiting_uncapped_throw_target, "
|
||||
f"got {state.decision_phase}"
|
||||
)
|
||||
|
||||
if target not in ("lead", "trail"):
|
||||
raise ValueError(f"throw_target must be 'lead' or 'trail', got '{target}'")
|
||||
|
||||
pending.throw_target = target
|
||||
|
||||
# Roll d20 for the targeted runner
|
||||
d20 = dice_system.roll_d20(
|
||||
game_id=game_id,
|
||||
team_id=state.get_fielding_team_id(),
|
||||
player_id=None,
|
||||
)
|
||||
pending.speed_check_d20 = d20
|
||||
pending.speed_check_runner = target
|
||||
|
||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||
state.pending_decision = "uncapped_safe_out"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Determine which runner info to send
|
||||
if target == "lead":
|
||||
runner_base = pending.lead_runner_base
|
||||
target_base = pending.lead_target_base
|
||||
runner_lid = pending.lead_runner_lineup_id
|
||||
else:
|
||||
runner_base = pending.trail_runner_base
|
||||
target_base = pending.trail_target_base
|
||||
runner_lid = pending.trail_runner_lineup_id
|
||||
|
||||
if state.is_batting_team_ai():
|
||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||
await self.submit_uncapped_safe_out(game_id, result)
|
||||
return
|
||||
|
||||
await self._emit_decision_required(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
phase="awaiting_uncapped_safe_out",
|
||||
timeout_seconds=self.DECISION_TIMEOUT,
|
||||
data={
|
||||
"d20_roll": d20,
|
||||
"runner": target,
|
||||
"runner_base": runner_base,
|
||||
"target_base": target_base,
|
||||
"runner_lineup_id": runner_lid,
|
||||
"hit_location": pending.hit_location,
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_uncapped_safe_out(
|
||||
self, game_id: UUID, result: str
|
||||
) -> None:
|
||||
"""
|
||||
Submit offensive declaration: is the runner safe or out?
|
||||
|
||||
Finalizes the uncapped hit play with the accumulated decisions.
|
||||
|
||||
Args:
|
||||
game_id: Game ID
|
||||
result: "safe" or "out"
|
||||
"""
|
||||
async with state_manager.game_lock(game_id):
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
pending = state.pending_uncapped_hit
|
||||
if not pending:
|
||||
raise ValueError("No pending uncapped hit")
|
||||
|
||||
if state.decision_phase != "awaiting_uncapped_safe_out":
|
||||
raise ValueError(
|
||||
f"Wrong phase: expected awaiting_uncapped_safe_out, "
|
||||
f"got {state.decision_phase}"
|
||||
)
|
||||
|
||||
if result not in ("safe", "out"):
|
||||
raise ValueError(f"result must be 'safe' or 'out', got '{result}'")
|
||||
|
||||
pending.speed_check_result = result
|
||||
|
||||
ab_roll = state.pending_manual_roll
|
||||
if not ab_roll:
|
||||
raise ValueError("No pending manual roll found")
|
||||
|
||||
play_result = self._build_uncapped_play_result(state, pending, ab_roll)
|
||||
await self._finalize_uncapped_hit(state, pending, ab_roll, play_result)
|
||||
|
||||
def _build_uncapped_fallback_result(
|
||||
self,
|
||||
state: GameState,
|
||||
pending: PendingUncappedHit,
|
||||
ab_roll: "AbRoll",
|
||||
) -> PlayResult:
|
||||
"""
|
||||
Build PlayResult when lead runner declines advance (standard advancement).
|
||||
|
||||
Single: SINGLE_1 equivalent (R3 scores, R2→3rd, R1→2nd)
|
||||
Double: DOUBLE_2 equivalent (all runners +2 bases)
|
||||
"""
|
||||
from app.core.play_resolver import PlayResolver, RunnerAdvancementData
|
||||
|
||||
resolver = PlayResolver(league_id=state.league_id, auto_mode=False)
|
||||
|
||||
if pending.hit_type == "single":
|
||||
runners_advanced = resolver._advance_on_single_1(state)
|
||||
outcome = PlayOutcome.SINGLE_UNCAPPED
|
||||
batter_base = 1
|
||||
desc = "Single (uncapped) - runner holds"
|
||||
else:
|
||||
runners_advanced = resolver._advance_on_double_2(state)
|
||||
outcome = PlayOutcome.DOUBLE_UNCAPPED
|
||||
batter_base = 2
|
||||
desc = "Double (uncapped) - runner holds"
|
||||
|
||||
runs_scored = sum(1 for adv in runners_advanced if adv.to_base == 4)
|
||||
|
||||
return PlayResult(
|
||||
outcome=outcome,
|
||||
outs_recorded=0,
|
||||
runs_scored=runs_scored,
|
||||
batter_result=batter_base,
|
||||
runners_advanced=runners_advanced,
|
||||
description=desc,
|
||||
ab_roll=ab_roll,
|
||||
hit_location=pending.hit_location,
|
||||
is_hit=True,
|
||||
is_out=False,
|
||||
is_walk=False,
|
||||
)
|
||||
|
||||
def _build_uncapped_no_throw_result(
|
||||
self,
|
||||
state: GameState,
|
||||
pending: PendingUncappedHit,
|
||||
ab_roll: "AbRoll",
|
||||
) -> PlayResult:
|
||||
"""
|
||||
Build PlayResult when defense declines to throw.
|
||||
|
||||
Lead runner advances safely. Trail runner and batter get standard advancement.
|
||||
"""
|
||||
from app.core.play_resolver import RunnerAdvancementData
|
||||
|
||||
runners_advanced: list[RunnerAdvancementData] = []
|
||||
runs_scored = 0
|
||||
|
||||
# Auto-scoring runners (R3 on single, R3+R2 on double)
|
||||
for from_base, to_base, lid in pending.auto_runners:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(from_base=from_base, to_base=to_base, lineup_id=lid)
|
||||
)
|
||||
if to_base == 4:
|
||||
runs_scored += 1
|
||||
|
||||
# Lead runner advances to target (safe, no throw)
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.lead_runner_base,
|
||||
to_base=pending.lead_target_base,
|
||||
lineup_id=pending.lead_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if pending.lead_target_base == 4:
|
||||
runs_scored += 1
|
||||
|
||||
# Trail runner gets standard advancement (one base advance from current)
|
||||
if pending.trail_runner_base is not None and pending.trail_runner_base > 0:
|
||||
# Trail is a runner on base, advance one base
|
||||
trail_dest = pending.trail_runner_base + 1
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.trail_runner_base,
|
||||
to_base=trail_dest,
|
||||
lineup_id=pending.trail_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if trail_dest == 4:
|
||||
runs_scored += 1
|
||||
|
||||
# Batter goes to minimum base
|
||||
batter_base = pending.batter_base
|
||||
|
||||
outcome = (
|
||||
PlayOutcome.SINGLE_UNCAPPED
|
||||
if pending.hit_type == "single"
|
||||
else PlayOutcome.DOUBLE_UNCAPPED
|
||||
)
|
||||
|
||||
return PlayResult(
|
||||
outcome=outcome,
|
||||
outs_recorded=0,
|
||||
runs_scored=runs_scored,
|
||||
batter_result=batter_base,
|
||||
runners_advanced=runners_advanced,
|
||||
description=f"{'Single' if pending.hit_type == 'single' else 'Double'} (uncapped) - no throw, runner advances",
|
||||
ab_roll=ab_roll,
|
||||
hit_location=pending.hit_location,
|
||||
is_hit=True,
|
||||
is_out=False,
|
||||
is_walk=False,
|
||||
)
|
||||
|
||||
def _build_uncapped_play_result(
|
||||
self,
|
||||
state: GameState,
|
||||
pending: PendingUncappedHit,
|
||||
ab_roll: "AbRoll",
|
||||
) -> PlayResult:
|
||||
"""
|
||||
Build final PlayResult from accumulated uncapped hit decisions.
|
||||
|
||||
Handles all combinations of lead/trail advance with safe/out outcomes.
|
||||
"""
|
||||
from app.core.play_resolver import RunnerAdvancementData
|
||||
|
||||
runners_advanced: list[RunnerAdvancementData] = []
|
||||
runs_scored = 0
|
||||
outs_recorded = 0
|
||||
|
||||
# Auto-scoring runners always score
|
||||
for from_base, to_base, lid in pending.auto_runners:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(from_base=from_base, to_base=to_base, lineup_id=lid)
|
||||
)
|
||||
if to_base == 4:
|
||||
runs_scored += 1
|
||||
|
||||
checked_runner = pending.speed_check_runner # "lead" or "trail"
|
||||
is_safe = pending.speed_check_result == "safe"
|
||||
|
||||
# Determine the non-targeted runner's outcome
|
||||
if pending.throw_target is not None:
|
||||
# Both runners attempted - defense chose a target
|
||||
non_target = "trail" if pending.throw_target == "lead" else "lead"
|
||||
|
||||
# Non-targeted runner auto-advances (safe)
|
||||
if non_target == "lead":
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.lead_runner_base,
|
||||
to_base=pending.lead_target_base,
|
||||
lineup_id=pending.lead_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if pending.lead_target_base == 4:
|
||||
runs_scored += 1
|
||||
else:
|
||||
# Trail auto-advances
|
||||
if pending.trail_runner_base is not None and pending.trail_runner_base > 0:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.trail_runner_base,
|
||||
to_base=pending.trail_target_base,
|
||||
lineup_id=pending.trail_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if pending.trail_target_base == 4:
|
||||
runs_scored += 1
|
||||
|
||||
# Targeted runner (or sole runner if no throw_target)
|
||||
if checked_runner == "lead":
|
||||
if is_safe:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.lead_runner_base,
|
||||
to_base=pending.lead_target_base,
|
||||
lineup_id=pending.lead_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if pending.lead_target_base == 4:
|
||||
runs_scored += 1
|
||||
else:
|
||||
# Runner is out
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.lead_runner_base,
|
||||
to_base=0,
|
||||
lineup_id=pending.lead_runner_lineup_id,
|
||||
is_out=True,
|
||||
)
|
||||
)
|
||||
outs_recorded += 1
|
||||
elif checked_runner == "trail":
|
||||
if is_safe:
|
||||
if pending.trail_runner_base is not None:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.trail_runner_base,
|
||||
to_base=pending.trail_target_base,
|
||||
lineup_id=pending.trail_runner_lineup_id,
|
||||
)
|
||||
)
|
||||
if pending.trail_target_base == 4:
|
||||
runs_scored += 1
|
||||
else:
|
||||
# Trail runner out
|
||||
if pending.trail_runner_base is not None:
|
||||
runners_advanced.append(
|
||||
RunnerAdvancementData(
|
||||
from_base=pending.trail_runner_base,
|
||||
to_base=0,
|
||||
lineup_id=pending.trail_runner_lineup_id,
|
||||
is_out=True,
|
||||
)
|
||||
)
|
||||
outs_recorded += 1
|
||||
|
||||
# If trail runner is R1 and R1 attempted advance, batter-runner
|
||||
# auto-advances regardless of R1's outcome
|
||||
batter_base = pending.batter_base
|
||||
if (
|
||||
pending.trail_runner_base == 1
|
||||
and pending.trail_advance
|
||||
and pending.trail_runner_base is not None
|
||||
):
|
||||
# Batter auto-advances one extra base
|
||||
batter_base = min(pending.batter_base + 1, 3)
|
||||
elif (
|
||||
pending.trail_runner_base == 0
|
||||
and checked_runner == "trail"
|
||||
):
|
||||
# Trail IS the batter - result already handled above
|
||||
if is_safe and pending.trail_target_base:
|
||||
batter_base = pending.trail_target_base
|
||||
elif not is_safe:
|
||||
batter_base = None # batter is out (handled by outs_recorded)
|
||||
|
||||
outcome = (
|
||||
PlayOutcome.SINGLE_UNCAPPED
|
||||
if pending.hit_type == "single"
|
||||
else PlayOutcome.DOUBLE_UNCAPPED
|
||||
)
|
||||
|
||||
# Build description
|
||||
desc_parts = [
|
||||
f"{'Single' if pending.hit_type == 'single' else 'Double'} (uncapped) to {pending.hit_location}"
|
||||
]
|
||||
if pending.speed_check_result:
|
||||
runner_label = "lead" if checked_runner == "lead" else "trail"
|
||||
desc_parts.append(
|
||||
f"{runner_label} runner {'safe' if is_safe else 'out'} (d20={pending.speed_check_d20})"
|
||||
)
|
||||
|
||||
return PlayResult(
|
||||
outcome=outcome,
|
||||
outs_recorded=outs_recorded,
|
||||
runs_scored=runs_scored,
|
||||
batter_result=batter_base,
|
||||
runners_advanced=runners_advanced,
|
||||
description=" - ".join(desc_parts),
|
||||
ab_roll=ab_roll,
|
||||
hit_location=pending.hit_location,
|
||||
is_hit=True,
|
||||
is_out=outs_recorded > 0,
|
||||
is_walk=False,
|
||||
)
|
||||
|
||||
async def _finalize_uncapped_hit(
|
||||
self,
|
||||
state: GameState,
|
||||
pending: PendingUncappedHit,
|
||||
ab_roll: "AbRoll",
|
||||
result: PlayResult,
|
||||
) -> None:
|
||||
"""
|
||||
Finalize an uncapped hit play.
|
||||
|
||||
Clears pending state, calls _finalize_play for DB write and state update.
|
||||
"""
|
||||
# Clear pending uncapped hit
|
||||
state.pending_uncapped_hit = None
|
||||
state.pending_decision = None
|
||||
state.decision_phase = "idle"
|
||||
|
||||
state_manager.update_state(state.game_id, state)
|
||||
|
||||
log_suffix = f" (uncapped {pending.hit_type} to {pending.hit_location})"
|
||||
await self._finalize_play(state, result, ab_roll, log_suffix)
|
||||
|
||||
logger.info(
|
||||
f"Uncapped {pending.hit_type} finalized for game {state.game_id}: "
|
||||
f"{result.description}"
|
||||
)
|
||||
|
||||
# Placeholder methods for DECIDE workflow (to be implemented in step 9-10)
|
||||
|
||||
async def submit_decide_advance(self, game_id: UUID, advance: bool) -> None:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -450,6 +450,124 @@ class PendingXCheck(BaseModel):
|
||||
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
|
||||
# ============================================================================
|
||||
@ -570,6 +688,9 @@ class GameState(BaseModel):
|
||||
# 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
|
||||
@ -614,6 +735,11 @@ class GameState(BaseModel):
|
||||
"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}")
|
||||
@ -633,6 +759,11 @@ class GameState(BaseModel):
|
||||
"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}")
|
||||
@ -961,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
|
||||
|
||||
@ -2075,3 +2075,295 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
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.
|
||||
|
||||
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
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}"
|
||||
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"]
|
||||
@ -43,17 +43,7 @@ describe("DefensiveSetup", () => {
|
||||
|
||||
expect(wrapper.text()).toContain("Infield Depth");
|
||||
expect(wrapper.text()).toContain("Outfield Depth");
|
||||
expect(wrapper.text()).toContain("Hold Runners");
|
||||
});
|
||||
|
||||
it("shows hint text directing users to runner pills", () => {
|
||||
const wrapper = mount(DefensiveSetup, {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
"Tap the H icons on the runner pills above",
|
||||
);
|
||||
expect(wrapper.text()).toContain("Current Setup");
|
||||
});
|
||||
});
|
||||
|
||||
@ -95,18 +85,19 @@ describe("DefensiveSetup", () => {
|
||||
});
|
||||
|
||||
describe("Hold Runners Display", () => {
|
||||
it('shows "None" when no runners held', () => {
|
||||
it('shows "None" when no runners held in preview', () => {
|
||||
const wrapper = mount(DefensiveSetup, {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("None");
|
||||
// Check preview section shows "None" for holding
|
||||
expect(wrapper.text()).toContain("Holding:None");
|
||||
});
|
||||
|
||||
it("shows held bases as amber badges when runners are held", () => {
|
||||
it("displays holding status in preview for held runners", () => {
|
||||
/**
|
||||
* When the composable has held runners, the DefensiveSetup should
|
||||
* display them as read-only amber pill badges.
|
||||
* 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({
|
||||
@ -119,11 +110,8 @@ describe("DefensiveSetup", () => {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("1st");
|
||||
expect(wrapper.text()).toContain("3rd");
|
||||
// Verify amber badges exist
|
||||
const badges = wrapper.findAll(".bg-amber-100");
|
||||
expect(badges.length).toBe(2);
|
||||
// Preview should show the held bases
|
||||
expect(wrapper.text()).toContain("Holding:1st, 3rd");
|
||||
});
|
||||
|
||||
it("displays holding status in preview for multiple runners", () => {
|
||||
@ -141,9 +129,7 @@ describe("DefensiveSetup", () => {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("1st");
|
||||
expect(wrapper.text()).toContain("2nd");
|
||||
expect(wrapper.text()).toContain("3rd");
|
||||
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user