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:
Cal Corum 2026-02-12 09:33:58 -06:00
parent 2a70df74bf
commit 529c5b1b99
15 changed files with 3102 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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, R23rd, R12nd)
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:

View File

@ -520,8 +520,9 @@ class PlayResolver:
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as SINGLE_1
# Fallback path: used when GameEngine determines no interactive decision
# is needed (no eligible runners). Interactive workflow is handled by
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
runners_advanced = self._advance_on_single_1(state)
runs_scored = sum(
1 for adv in runners_advanced if adv.to_base == 4
@ -533,7 +534,7 @@ class PlayResolver:
runs_scored=runs_scored,
batter_result=1,
runners_advanced=runners_advanced,
description="Single to center (uncapped)",
description="Single (uncapped, no eligible runners)",
ab_roll=ab_roll,
is_hit=True,
)
@ -588,8 +589,9 @@ class PlayResolver:
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as DOUBLE_2
# Fallback path: used when GameEngine determines no interactive decision
# is needed (no R1). Interactive workflow is handled by
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
runners_advanced = self._advance_on_double_2(state)
runs_scored = sum(
1 for adv in runners_advanced if adv.to_base == 4
@ -601,7 +603,7 @@ class PlayResolver:
runs_scored=runs_scored,
batter_result=2,
runners_advanced=runners_advanced,
description="Double (uncapped)",
description="Double (uncapped, no eligible runners)",
ab_roll=ab_roll,
is_hit=True,
)

View File

@ -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",
]

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,12 +1,15 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
import type { RollData, PlayResult } from '~/types'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue'
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
import PlayResultDisplay from '~/components/Gameplay/PlayResult.vue'
describe('GameplayPanel', () => {
let pinia: ReturnType<typeof createPinia>
const createRollData = (): RollData => ({
roll_id: 'test-roll-123',
d6_one: 3,
@ -44,7 +47,16 @@ describe('GameplayPanel', () => {
canSubmitOutcome: false,
}
const mountPanel = (propsOverride = {}) => {
return mount(GameplayPanel, {
global: { plugins: [pinia] },
props: { ...defaultProps, ...propsOverride },
})
}
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.clearAllTimers()
})
@ -54,18 +66,14 @@ describe('GameplayPanel', () => {
describe('Rendering', () => {
it('renders gameplay panel container', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
expect(wrapper.text()).toContain('Gameplay')
})
it('renders panel header with status', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.panel-header').exists()).toBe(true)
expect(wrapper.find('.status-indicator').exists()).toBe(true)
@ -79,21 +87,14 @@ describe('GameplayPanel', () => {
describe('Workflow State: Idle', () => {
it('shows idle state when canRollDice is false', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: false,
},
})
const wrapper = mountPanel({ canRollDice: false })
expect(wrapper.find('.state-idle').exists()).toBe(true)
expect(wrapper.text()).toContain('Waiting for strategic decisions')
})
it('displays idle status indicator', () => {
const wrapper = mount(GameplayPanel, {
props: defaultProps,
})
const wrapper = mountPanel()
expect(wrapper.find('.status-idle').exists()).toBe(true)
expect(wrapper.find('.status-text').text()).toBe('Waiting')
@ -106,63 +107,33 @@ describe('GameplayPanel', () => {
describe('Workflow State: Ready to Roll', () => {
it('shows ready state when canRollDice is true and my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.find('.state-ready').exists()).toBe(true)
expect(wrapper.text()).toContain('Your turn! Roll the dice')
})
it('shows waiting message when not my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: false,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
expect(wrapper.text()).toContain('Waiting for opponent to roll dice')
})
it('renders DiceRoller component when my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.findComponent(DiceRoller).exists()).toBe(true)
})
it('displays active status when ready and my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: true,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
expect(wrapper.find('.status-active').exists()).toBe(true)
expect(wrapper.find('.status-text').text()).toBe('Your Turn')
})
it('displays opponent turn status when ready but not my turn', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
canRollDice: true,
isMyTurn: false,
},
})
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
expect(wrapper.find('.status-text').text()).toBe('Opponent Turn')
})
@ -174,12 +145,9 @@ describe('GameplayPanel', () => {
describe('Workflow State: Rolled', () => {
it('shows rolled state when pendingRoll exists', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
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,
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,
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,
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,
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,
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,
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: {
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'