Compare commits

...

6 Commits

Author SHA1 Message Date
cal
87ae3c112a Merge pull request 'CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7)' (#9) from feature/uncapped-hit-decision-ui into main
Reviewed-on: #9
2026-02-12 20:13:36 +00:00
Cal Corum
fa3fadd14c CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7)
Frontend: Full 5-phase interactive wizard for uncapped hit decisions
(lead advance, defensive throw, trail advance, throw target, safe/out)
with mobile-first design, offense/defense role switching, and auto-
clearing on workflow completion.

Backend fixes:
- Remove nested asyncio.Lock acquisition causing deadlocks in all
  submit_uncapped_* methods and initiate_uncapped_hit (non-re-entrant)
- Preserve pending_manual_roll during interactive workflows
- Add league_id to all dice_system.roll_d20() calls
- Extract D20Roll.roll int for state serialization
- Fix batter-runner not advancing when non-targeted in throw
- Fix rollback_plays not recalculating scores from remaining plays

Files: 10 modified, 1 new (UncappedHitWizard.vue)
Tests: 2481/2481 passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:57 -06:00
cal
ffcbe248bd Merge pull request 'feat: Uncapped hit decision tree, x-check workflow, baserunner UI' (#8) from feature/uncapped-hit-decision-tree into main
Reviewed-on: #8
2026-02-12 15:37:33 +00:00
Cal Corum
529c5b1b99 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>
2026-02-12 09:33:58 -06:00
cal
3ef8d329f8 Merge pull request 'Fix double play bug after state recovery' (#4) from feature/gameplay-ui-improvements into main
Reviewed-on: #4
2026-02-07 22:05:45 +00:00
cal
9ba611bfee Merge pull request 'feature/gameplay-ui-improvements' (#2) from feature/gameplay-ui-improvements into main
Reviewed-on: #2
2026-02-07 05:01:16 +00:00
24 changed files with 4236 additions and 254 deletions

View File

@ -286,13 +286,14 @@ The `start.sh` script handles this automatically based on mode.
**Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10) **Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10)
Backend is production-ready for frontend integration: Backend is production-ready for frontend integration:
- ✅ All 15 WebSocket event handlers implemented - ✅ All 20 WebSocket event handlers implemented
- ✅ Strategic decisions (defensive/offensive) - ✅ Strategic decisions (defensive/offensive)
- ✅ Manual outcome workflow (dice rolling + card reading) - ✅ Manual outcome workflow (dice rolling + card reading)
- ✅ Player substitutions (3 types) - ✅ Player substitutions (3 types)
- ✅ Box score statistics (materialized views) - ✅ Box score statistics (materialized views)
- ✅ Position ratings integration (PD league) - ✅ 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 **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 ### Testing
```bash ```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 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 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: def _should_attempt_steal(self, state: GameState) -> bool:
""" """
Determine if AI should attempt a steal (Week 9). Determine if AI should attempt a steal (Week 9).

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -450,6 +450,124 @@ class PendingXCheck(BaseModel):
model_config = ConfigDict(frozen=False) # Allow mutation during workflow 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 # GAME STATE
# ============================================================================ # ============================================================================
@ -570,6 +688,9 @@ class GameState(BaseModel):
# Interactive x-check workflow # Interactive x-check workflow
pending_x_check: PendingXCheck | None = None pending_x_check: PendingXCheck | None = None
# Interactive uncapped hit workflow
pending_uncapped_hit: PendingUncappedHit | None = None
# Play tracking # Play tracking
play_count: int = Field(default=0, ge=0) play_count: int = Field(default=0, ge=0)
last_play_result: str | None = None last_play_result: str | None = None
@ -614,6 +735,11 @@ class GameState(BaseModel):
"decide_advance", "decide_advance",
"decide_throw", "decide_throw",
"decide_result", "decide_result",
"uncapped_lead_advance",
"uncapped_defensive_throw",
"uncapped_trail_advance",
"uncapped_throw_target",
"uncapped_safe_out",
] ]
if v not in valid: if v not in valid:
raise ValueError(f"pending_decision must be one of {valid}") raise ValueError(f"pending_decision must be one of {valid}")
@ -633,6 +759,11 @@ class GameState(BaseModel):
"awaiting_decide_advance", "awaiting_decide_advance",
"awaiting_decide_throw", "awaiting_decide_throw",
"awaiting_decide_result", "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: if v not in valid:
raise ValueError(f"decision_phase must be one of {valid}") raise ValueError(f"decision_phase must be one of {valid}")
@ -961,5 +1092,6 @@ __all__ = [
"TeamLineupState", "TeamLineupState",
"DefensiveDecision", "DefensiveDecision",
"OffensiveDecision", "OffensiveDecision",
"PendingUncappedHit",
"GameState", "GameState",
] ]

View File

@ -23,7 +23,7 @@ Broadcast to All Players
``` ```
app/websocket/ app/websocket/
├── connection_manager.py # Connection lifecycle & broadcasting ├── connection_manager.py # Connection lifecycle & broadcasting
└── handlers.py # Event handler registration (15 handlers) └── handlers.py # Event handler registration (20 handlers)
``` ```
## ConnectionManager ## ConnectionManager
@ -43,7 +43,7 @@ await manager.broadcast_to_game(game_id, event, data)
await manager.emit_to_user(sid, event, data) await manager.emit_to_user(sid, event, data)
``` ```
## Event Handlers (15 Total) ## Event Handlers (20 Total)
### Connection Events ### Connection Events
- `connect` - JWT authentication - `connect` - JWT authentication
@ -68,6 +68,13 @@ await manager.emit_to_user(sid, event, data)
- `submit_pitching_change` - Pitcher substitution - `submit_pitching_change` - Pitcher substitution
- `submit_defensive_replacement` - Field 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 ### Lineup
- `get_lineup` - Get team 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

@ -560,9 +560,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
# Using the old state reference would overwrite those updates! # Using the old state reference would overwrite those updates!
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if state: if state:
# Clear pending roll only AFTER successful validation (one-time use) # Clear pending roll only if NOT entering an interactive workflow.
state.pending_manual_roll = None # Uncapped hit and x-check workflows need pending_manual_roll
state_manager.update_state(game_id, state) # to build their final PlayResult when decisions complete.
interactive_decision_phases = {
"awaiting_x_check_result",
"awaiting_uncapped_lead_advance",
"awaiting_uncapped_defensive_throw",
"awaiting_uncapped_trail_advance",
"awaiting_uncapped_throw_target",
"awaiting_uncapped_safe_out",
}
if state.decision_phase not in interactive_decision_phases:
state.pending_manual_roll = None
state_manager.update_state(game_id, state)
except GameValidationError as e: except GameValidationError as e:
# Game engine validation error (e.g., missing hit location) # Game engine validation error (e.g., missing hit location)
await manager.emit_to_user( await manager.emit_to_user(
@ -604,6 +615,30 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
}, },
) )
# Check if the outcome initiated an interactive workflow (x-check or uncapped hit).
# If so, skip play_resolved broadcast - the play isn't actually resolved yet.
# The decision_required event and game_state_update will drive the UI instead.
interactive_phases = {
"awaiting_x_check_result",
"awaiting_uncapped_lead_advance",
"awaiting_uncapped_defensive_throw",
"awaiting_uncapped_trail_advance",
"awaiting_uncapped_throw_target",
"awaiting_uncapped_safe_out",
}
updated_state = state_manager.get_state(game_id)
if updated_state and updated_state.decision_phase in interactive_phases:
logger.info(
f"Manual outcome initiated interactive workflow ({updated_state.decision_phase}) "
f"for game {game_id} - skipping play_resolved, broadcasting state update"
)
await manager.broadcast_to_game(
str(game_id),
"game_state_update",
updated_state.model_dump(mode="json"),
)
return
# Build play result data # Build play result data
play_result_data = { play_result_data = {
"game_id": str(game_id), "game_id": str(game_id),
@ -2075,3 +2110,315 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
await manager.emit_to_user( await manager.emit_to_user(
sid, "error", {"message": "DECIDE workflow not yet implemented"} 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
async with state_manager.game_lock(game_id):
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 asyncio.TimeoutError:
logger.error(f"Lock timeout in submit_uncapped_lead_advance for game {game_id}")
await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"})
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
async with state_manager.game_lock(game_id):
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 asyncio.TimeoutError:
logger.error(f"Lock timeout in submit_uncapped_defensive_throw for game {game_id}")
await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"})
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
async with state_manager.game_lock(game_id):
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 asyncio.TimeoutError:
logger.error(f"Lock timeout in submit_uncapped_trail_advance for game {game_id}")
await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"})
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
async with state_manager.game_lock(game_id):
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 asyncio.TimeoutError:
logger.error(f"Lock timeout in submit_uncapped_throw_target for game {game_id}")
await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"})
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
async with state_manager.game_lock(game_id):
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 asyncio.TimeoutError:
logger.error(f"Lock timeout in submit_uncapped_safe_out for game {game_id}")
await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"})
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 ### Current Test Baseline
**Must maintain or improve:** **Must maintain or improve:**
- ✅ Unit tests: **979/979 passing (100%)** - ✅ Unit tests: **2481/2481 passing (100%)**
- ✅ Integration tests: **32/32 passing (100%)** - ✅ Integration tests: **32/32 passing (100%)**
- ⏱️ Unit execution: **~4 seconds** - ⏱️ Unit execution: **~4 seconds**
- ⏱️ Integration execution: **~5 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 ## Test Coverage
**Current Status** (as of 2025-11-27): **Current Status** (as of 2026-02-11):
- ✅ **979 unit tests passing** (100%) - ✅ **2481 unit tests passing** (100%)
- ✅ **32 integration tests passing** (100%) - ✅ **32 integration tests passing** (100%)
- **Total: 1,011 tests passing** - **Total: 2,513 tests passing**
**Coverage by Module**: **Coverage by Module**:
``` ```
@ -333,7 +333,7 @@ app/core/state_manager.py ✅ Well covered
app/core/dice.py ✅ Well covered app/core/dice.py ✅ Well covered
app/models/ ✅ Well covered app/models/ ✅ Well covered
app/database/operations.py ✅ 32 integration tests (session injection pattern) 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 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

@ -113,9 +113,15 @@
:outs="gameState?.outs ?? 0" :outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
:dice-color="diceColor" :dice-color="diceColor"
:user-team-id="myTeamId"
@roll-dice="handleRollDice" @roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome" @submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult" @dismiss-result="handleDismissResult"
@submit-uncapped-lead-advance="handleUncappedLeadAdvance"
@submit-uncapped-defensive-throw="handleUncappedDefensiveThrow"
@submit-uncapped-trail-advance="handleUncappedTrailAdvance"
@submit-uncapped-throw-target="handleUncappedThrowTarget"
@submit-uncapped-safe-out="handleUncappedSafeOut"
/> />
<!-- Play-by-Play Feed (below gameplay on mobile) --> <!-- Play-by-Play Feed (below gameplay on mobile) -->
@ -183,9 +189,15 @@
:outs="gameState?.outs ?? 0" :outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
:dice-color="diceColor" :dice-color="diceColor"
:user-team-id="myTeamId"
@roll-dice="handleRollDice" @roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome" @submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult" @dismiss-result="handleDismissResult"
@submit-uncapped-lead-advance="handleUncappedLeadAdvance"
@submit-uncapped-defensive-throw="handleUncappedDefensiveThrow"
@submit-uncapped-trail-advance="handleUncappedTrailAdvance"
@submit-uncapped-throw-target="handleUncappedThrowTarget"
@submit-uncapped-safe-out="handleUncappedSafeOut"
/> />
</div> </div>
@ -447,18 +459,27 @@ const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('conne
// Determine which team the user controls // Determine which team the user controls
// For demo/testing: user controls whichever team needs to act // For demo/testing: user controls whichever team needs to act
const DEFENSIVE_PHASES = [
'awaiting_defensive',
'awaiting_uncapped_defensive_throw',
'awaiting_uncapped_throw_target',
]
const myTeamId = computed(() => { const myTeamId = computed(() => {
if (!gameState.value) return null if (!gameState.value) return null
const phase = gameState.value.decision_phase
const isDefensivePhase = DEFENSIVE_PHASES.includes(phase)
// Return the team that currently needs to make a decision // Return the team that currently needs to make a decision
if (gameState.value.half === 'top') { if (gameState.value.half === 'top') {
// Top: away bats, home fields // Top: away bats, home fields
return gameState.value.decision_phase === 'awaiting_defensive' return isDefensivePhase
? gameState.value.home_team_id ? gameState.value.home_team_id
: gameState.value.away_team_id : gameState.value.away_team_id
} else { } else {
// Bottom: home bats, away fields // Bottom: home bats, away fields
return gameState.value.decision_phase === 'awaiting_defensive' return isDefensivePhase
? gameState.value.away_team_id ? gameState.value.away_team_id
: gameState.value.home_team_id : gameState.value.home_team_id
} }
@ -576,6 +597,11 @@ const showGameplay = computed(() => {
return true return true
} }
// Show for uncapped hit decisions (both teams see the wizard)
if (gameStore.needsUncappedDecision) {
return true
}
return gameState.value?.status === 'active' && return gameState.value?.status === 'active' &&
isMyTurn.value && isMyTurn.value &&
!needsDefensiveDecision.value && !needsDefensiveDecision.value &&
@ -656,6 +682,32 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
gameStore.setPendingStealAttempts(attempts) gameStore.setPendingStealAttempts(attempts)
} }
// Methods - Uncapped Hit Decision Tree
const handleUncappedLeadAdvance = (advance: boolean) => {
console.log('[GamePlay] Uncapped lead advance:', advance)
actions.submitUncappedLeadAdvance(advance)
}
const handleUncappedDefensiveThrow = (willThrow: boolean) => {
console.log('[GamePlay] Uncapped defensive throw:', willThrow)
actions.submitUncappedDefensiveThrow(willThrow)
}
const handleUncappedTrailAdvance = (advance: boolean) => {
console.log('[GamePlay] Uncapped trail advance:', advance)
actions.submitUncappedTrailAdvance(advance)
}
const handleUncappedThrowTarget = (target: 'lead' | 'trail') => {
console.log('[GamePlay] Uncapped throw target:', target)
actions.submitUncappedThrowTarget(target)
}
const handleUncappedSafeOut = (result: 'safe' | 'out') => {
console.log('[GamePlay] Uncapped safe/out:', result)
actions.submitUncappedSafeOut(result)
}
const handleToggleHold = (base: number) => { const handleToggleHold = (base: number) => {
defensiveSetup.toggleHold(base) defensiveSetup.toggleHold(base)
} }

View File

@ -78,6 +78,21 @@
</div> </div>
</div> </div>
<!-- State: Uncapped Hit Pending -->
<div v-else-if="workflowState === 'uncapped_hit_pending'" class="state-uncapped-hit">
<UncappedHitWizard
:phase="currentUncappedPhase!"
:data="uncappedHitData"
:readonly="!isUncappedInteractive"
:pending-uncapped-hit="pendingUncappedHit"
@submit-lead-advance="handleUncappedLeadAdvance"
@submit-defensive-throw="handleUncappedDefensiveThrow"
@submit-trail-advance="handleUncappedTrailAdvance"
@submit-throw-target="handleUncappedThrowTarget"
@submit-safe-out="handleUncappedSafeOut"
/>
</div>
<!-- State: X-Check Result Pending --> <!-- State: X-Check Result Pending -->
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check"> <div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
<div v-if="!isXCheckInteractive" class="state-message"> <div v-if="!isXCheckInteractive" class="state-message">
@ -119,12 +134,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome, XCheckData } from '~/types' import type { RollData, PlayResult, PlayOutcome, XCheckData, DecisionPhase, UncappedHitData } from '~/types'
import { useGameStore } from '~/store/game' import { useGameStore } from '~/store/game'
import DiceRoller from './DiceRoller.vue' import DiceRoller from './DiceRoller.vue'
import OutcomeWizard from './OutcomeWizard.vue' import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue' import PlayResultDisplay from './PlayResult.vue'
import XCheckWizard from './XCheckWizard.vue' import XCheckWizard from './XCheckWizard.vue'
import UncappedHitWizard from './UncappedHitWizard.vue'
interface Props { interface Props {
gameId: string gameId: string
@ -153,6 +169,11 @@ const emit = defineEmits<{
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }] submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
dismissResult: [] dismissResult: []
submitXCheckResult: [{ resultCode: string; errorResult: string }] submitXCheckResult: [{ resultCode: string; errorResult: string }]
submitUncappedLeadAdvance: [advance: boolean]
submitUncappedDefensiveThrow: [willThrow: boolean]
submitUncappedTrailAdvance: [advance: boolean]
submitUncappedThrowTarget: [target: 'lead' | 'trail']
submitUncappedSafeOut: [result: 'safe' | 'out']
}>() }>()
// Store access // Store access
@ -165,6 +186,41 @@ const isSubmitting = ref(false)
// X-Check data from store // X-Check data from store
const xCheckData = computed(() => gameStore.xCheckData) const xCheckData = computed(() => gameStore.xCheckData)
// Uncapped hit data from store
const uncappedHitData = computed(() => gameStore.uncappedHitData)
const pendingUncappedHit = computed(() => {
const hit = gameStore.gameState?.pending_uncapped_hit
return (hit ?? null) as import('~/types').PendingUncappedHit | null
})
// Current uncapped hit phase
const currentUncappedPhase = computed<DecisionPhase | null>(() => {
if (gameStore.needsUncappedLeadAdvance) return 'awaiting_uncapped_lead_advance'
if (gameStore.needsUncappedDefensiveThrow) return 'awaiting_uncapped_defensive_throw'
if (gameStore.needsUncappedTrailAdvance) return 'awaiting_uncapped_trail_advance'
if (gameStore.needsUncappedThrowTarget) return 'awaiting_uncapped_throw_target'
if (gameStore.needsUncappedSafeOut) return 'awaiting_uncapped_safe_out'
return null
})
// Offensive phases: user's team must be the batting team
// Defensive phases: user's team must be the fielding team
const UNCAPPED_OFFENSIVE_PHASES: DecisionPhase[] = [
'awaiting_uncapped_lead_advance',
'awaiting_uncapped_trail_advance',
'awaiting_uncapped_safe_out',
]
const isUncappedInteractive = computed(() => {
if (!currentUncappedPhase.value || !props.userTeamId || !gameStore.gameState) return false
const isOffensivePhase = UNCAPPED_OFFENSIVE_PHASES.includes(currentUncappedPhase.value)
if (isOffensivePhase) {
return props.userTeamId === gameStore.battingTeamId
} else {
return props.userTeamId === gameStore.fieldingTeamId
}
})
// Determine if current user should have interactive mode // Determine if current user should have interactive mode
// Uses active_team_id from x-check data (set by backend to indicate which team should interact) // Uses active_team_id from x-check data (set by backend to indicate which team should interact)
const isXCheckInteractive = computed(() => { const isXCheckInteractive = computed(() => {
@ -174,7 +230,7 @@ const isXCheckInteractive = computed(() => {
}) })
// Workflow state computation // Workflow state computation
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'x_check_result_pending' type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'uncapped_hit_pending' | 'x_check_result_pending'
const workflowState = computed<WorkflowState>(() => { const workflowState = computed<WorkflowState>(() => {
// Show result if we have one // Show result if we have one
@ -182,6 +238,11 @@ const workflowState = computed<WorkflowState>(() => {
return 'result' return 'result'
} }
// Show uncapped hit wizard if in any uncapped phase
if (gameStore.needsUncappedDecision) {
return 'uncapped_hit_pending'
}
// Show x-check result selection if awaiting // Show x-check result selection if awaiting
if (gameStore.needsXCheckResult && xCheckData.value) { if (gameStore.needsXCheckResult && xCheckData.value) {
return 'x_check_result_pending' return 'x_check_result_pending'
@ -210,6 +271,7 @@ const workflowState = computed<WorkflowState>(() => {
const statusClass = computed(() => { const statusClass = computed(() => {
if (error.value) return 'status-error' if (error.value) return 'status-error'
if (workflowState.value === 'result') return 'status-success' if (workflowState.value === 'result') return 'status-success'
if (workflowState.value === 'uncapped_hit_pending') return 'status-active'
if (workflowState.value === 'x_check_result_pending') return 'status-active' if (workflowState.value === 'x_check_result_pending') return 'status-active'
if (workflowState.value === 'submitted') return 'status-processing' if (workflowState.value === 'submitted') return 'status-processing'
if (workflowState.value === 'rolled') return 'status-active' if (workflowState.value === 'rolled') return 'status-active'
@ -220,6 +282,9 @@ const statusClass = computed(() => {
const statusText = computed(() => { const statusText = computed(() => {
if (error.value) return 'Error' if (error.value) return 'Error'
if (workflowState.value === 'result') return 'Play Complete' if (workflowState.value === 'result') return 'Play Complete'
if (workflowState.value === 'uncapped_hit_pending') {
return isUncappedInteractive.value ? 'Uncapped Hit Decision' : 'Waiting for Decision'
}
if (workflowState.value === 'x_check_result_pending') { if (workflowState.value === 'x_check_result_pending') {
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense' return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
} }
@ -258,6 +323,26 @@ const handleDismissResult = () => {
emit('dismissResult') emit('dismissResult')
} }
const handleUncappedLeadAdvance = (advance: boolean) => {
emit('submitUncappedLeadAdvance', advance)
}
const handleUncappedDefensiveThrow = (willThrow: boolean) => {
emit('submitUncappedDefensiveThrow', willThrow)
}
const handleUncappedTrailAdvance = (advance: boolean) => {
emit('submitUncappedTrailAdvance', advance)
}
const handleUncappedThrowTarget = (target: 'lead' | 'trail') => {
emit('submitUncappedThrowTarget', target)
}
const handleUncappedSafeOut = (result: 'safe' | 'out') => {
emit('submitUncappedSafeOut', result)
}
const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => { const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => {
error.value = null error.value = null
isSubmitting.value = true isSubmitting.value = true
@ -358,6 +443,11 @@ const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }
@apply space-y-6; @apply space-y-6;
} }
/* State: Uncapped Hit */
.state-uncapped-hit {
@apply space-y-4;
}
/* State: X-Check */ /* State: X-Check */
.state-x-check { .state-x-check {
@apply space-y-4; @apply space-y-4;

View File

@ -0,0 +1,536 @@
<template>
<div class="uncapped-wizard" :class="{ 'read-only': readonly }">
<!-- Header -->
<div class="wizard-header">
<div class="hit-badge" :class="hitBadgeClass">
{{ hitTypeLabel }}
</div>
<div v-if="hitLocation" class="hit-location">
to {{ hitLocation }}
</div>
<p v-if="readonly" class="waiting-message">
Waiting for {{ isDefensivePhase ? 'defense' : 'offense' }} to decide...
</p>
</div>
<!-- Auto-scoring runners info -->
<div v-if="autoRunnersDisplay.length > 0" class="auto-runners">
<div v-for="(info, idx) in autoRunnersDisplay" :key="idx" class="auto-runner-line">
<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span>{{ info }}</span>
</div>
</div>
<!-- Phase 1: Lead Runner Advance (OFFENSE) -->
<div v-if="phase === 'awaiting_uncapped_lead_advance'" class="phase-content">
<div class="phase-label offense-label">Offense Decides</div>
<div class="runner-info">
<span class="runner-name">{{ leadRunnerName }}</span>
<span class="runner-movement">{{ baseLabel(leadData?.lead_runner_base) }} &rarr; {{ baseLabel(leadData?.lead_target_base) }}</span>
</div>
<div class="decision-buttons">
<button
class="decision-btn advance-btn"
:disabled="readonly"
@click="$emit('submitLeadAdvance', true)"
>
Advance
</button>
<button
class="decision-btn hold-btn"
:disabled="readonly"
@click="$emit('submitLeadAdvance', false)"
>
Hold
</button>
</div>
</div>
<!-- Phase 2: Defensive Throw (DEFENSE) -->
<div v-else-if="phase === 'awaiting_uncapped_defensive_throw'" class="phase-content">
<div class="phase-label defense-label">Defense Decides</div>
<div class="runner-info">
<span class="runner-name">{{ leadRunnerNameFromThrow }}</span>
<span class="runner-movement">attempting {{ baseLabel(throwData?.lead_target_base) }}</span>
</div>
<div class="decision-buttons">
<button
class="decision-btn throw-btn"
:disabled="readonly"
@click="$emit('submitDefensiveThrow', true)"
>
Throw
</button>
<button
class="decision-btn letgo-btn"
:disabled="readonly"
@click="$emit('submitDefensiveThrow', false)"
>
Let it Go
</button>
</div>
</div>
<!-- Phase 3: Trail Runner Advance (OFFENSE) -->
<div v-else-if="phase === 'awaiting_uncapped_trail_advance'" class="phase-content">
<div class="phase-label offense-label">Offense Decides</div>
<div class="runner-info">
<span class="runner-name">{{ trailRunnerName }}</span>
<span class="runner-movement">{{ baseLabel(trailData?.trail_runner_base) }} &rarr; {{ baseLabel(trailData?.trail_target_base) }}</span>
</div>
<div class="decision-buttons">
<button
class="decision-btn advance-btn"
:disabled="readonly"
@click="$emit('submitTrailAdvance', true)"
>
Advance
</button>
<button
class="decision-btn hold-btn"
:disabled="readonly"
@click="$emit('submitTrailAdvance', false)"
>
Hold
</button>
</div>
</div>
<!-- Phase 4: Throw Target (DEFENSE) -->
<div v-else-if="phase === 'awaiting_uncapped_throw_target'" class="phase-content">
<div class="phase-label defense-label">Defense Decides</div>
<p class="phase-description">Both runners are advancing. Choose your throw target:</p>
<div class="target-options">
<button
class="target-btn"
:disabled="readonly"
@click="$emit('submitThrowTarget', 'lead')"
>
<div class="target-label">Lead Runner</div>
<div class="target-detail">
{{ leadRunnerNameFromTarget }} &rarr; {{ baseLabel(targetData?.lead_target_base) }}
</div>
</button>
<button
class="target-btn"
:disabled="readonly"
@click="$emit('submitThrowTarget', 'trail')"
>
<div class="target-label">Trail Runner</div>
<div class="target-detail">
{{ trailRunnerNameFromTarget }} &rarr; {{ baseLabel(targetData?.trail_target_base) }}
</div>
</button>
</div>
</div>
<!-- Phase 5: Safe/Out Result (OFFENSE) -->
<div v-else-if="phase === 'awaiting_uncapped_safe_out'" class="phase-content">
<div class="phase-label offense-label">Offense Decides</div>
<div class="d20-display">
<div class="d20-label">Speed Check d20</div>
<div class="d20-value">{{ safeOutData?.d20_roll }}</div>
</div>
<div class="runner-info">
<span class="runner-name">{{ safeOutRunnerName }}</span>
<span class="runner-movement">
{{ safeOutData?.runner === 'lead' ? 'Lead' : 'Trail' }} runner &rarr; {{ baseLabel(safeOutData?.target_base) }}
</span>
</div>
<p class="phase-description">Check runner's speed rating on card vs d20 roll.</p>
<div class="decision-buttons">
<button
class="decision-btn safe-btn"
:disabled="readonly"
@click="$emit('submitSafeOut', 'safe')"
>
SAFE
</button>
<button
class="decision-btn out-btn"
:disabled="readonly"
@click="$emit('submitSafeOut', 'out')"
>
OUT
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {
DecisionPhase,
UncappedLeadAdvanceData,
UncappedDefensiveThrowData,
UncappedTrailAdvanceData,
UncappedThrowTargetData,
UncappedSafeOutData,
UncappedHitData,
PendingUncappedHit,
} from '~/types'
import { useGameStore } from '~/store/game'
interface Props {
phase: DecisionPhase
data: UncappedHitData | null
readonly: boolean
pendingUncappedHit: PendingUncappedHit | null
}
const props = defineProps<Props>()
defineEmits<{
submitLeadAdvance: [advance: boolean]
submitDefensiveThrow: [willThrow: boolean]
submitTrailAdvance: [advance: boolean]
submitThrowTarget: [target: 'lead' | 'trail']
submitSafeOut: [result: 'safe' | 'out']
}>()
const gameStore = useGameStore()
// Defensive phases (defense decides)
const DEFENSIVE_PHASES: DecisionPhase[] = [
'awaiting_uncapped_defensive_throw',
'awaiting_uncapped_throw_target',
]
const isDefensivePhase = computed(() => DEFENSIVE_PHASES.includes(props.phase))
// Typed data accessors per phase
const leadData = computed(() => {
if (props.phase === 'awaiting_uncapped_lead_advance') {
return props.data as UncappedLeadAdvanceData | null
}
return null
})
const throwData = computed(() => {
if (props.phase === 'awaiting_uncapped_defensive_throw') {
return props.data as UncappedDefensiveThrowData | null
}
return null
})
const trailData = computed(() => {
if (props.phase === 'awaiting_uncapped_trail_advance') {
return props.data as UncappedTrailAdvanceData | null
}
return null
})
const targetData = computed(() => {
if (props.phase === 'awaiting_uncapped_throw_target') {
return props.data as UncappedThrowTargetData | null
}
return null
})
const safeOutData = computed(() => {
if (props.phase === 'awaiting_uncapped_safe_out') {
return props.data as UncappedSafeOutData | null
}
return null
})
// Hit type from phase data or pending state
const hitType = computed(() => {
if (leadData.value) return leadData.value.hit_type
return props.pendingUncappedHit?.hit_type ?? ''
})
const hitLocation = computed(() => {
const loc = leadData.value?.hit_location
?? throwData.value?.hit_location
?? trailData.value?.hit_location
?? targetData.value?.hit_location
?? safeOutData.value?.hit_location
?? props.pendingUncappedHit?.hit_location
return loc ?? ''
})
const hitTypeLabel = computed(() => {
const ht = hitType.value
if (ht === 'single' || ht === 'single_uncapped') return 'SINGLE UNCAPPED'
if (ht === 'double' || ht === 'double_uncapped') return 'DOUBLE UNCAPPED'
return 'UNCAPPED HIT'
})
const hitBadgeClass = computed(() => {
const ht = hitType.value
if (ht === 'single' || ht === 'single_uncapped') return 'badge-single'
return 'badge-double'
})
// Auto-scoring runners display (from phase 1 data or pending state)
const autoRunnersDisplay = computed(() => {
const autos = leadData.value?.auto_runners ?? props.pendingUncappedHit?.auto_runners ?? []
return autos.map((runner) => {
const from = runner[0]
const to = runner[1]
const lid = runner[2]
const player = lid != null ? gameStore.findPlayerInLineup(lid) : undefined
const name = player?.player?.name ?? `#${lid ?? '?'}`
if (to === 4) return `${name} scores automatically`
return `${name}: ${baseLabel(from)}${baseLabel(to)} automatically`
})
})
// Player name lookups
const leadRunnerName = computed(() => {
const lid = leadData.value?.lead_runner_lineup_id
if (!lid) return 'Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
const leadRunnerNameFromThrow = computed(() => {
const lid = throwData.value?.lead_runner_lineup_id
if (!lid) return 'Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
const trailRunnerName = computed(() => {
const lid = trailData.value?.trail_runner_lineup_id
if (!lid) return 'Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
const leadRunnerNameFromTarget = computed(() => {
const lid = targetData.value?.lead_runner_lineup_id
if (!lid) return 'Lead Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
const trailRunnerNameFromTarget = computed(() => {
const lid = targetData.value?.trail_runner_lineup_id
if (!lid) return 'Trail Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
const safeOutRunnerName = computed(() => {
const lid = safeOutData.value?.runner_lineup_id
if (!lid) return 'Runner'
const player = gameStore.findPlayerInLineup(lid)
return player?.player?.name ?? `#${lid}`
})
// Base label helper
function baseLabel(base: number | undefined | null): string {
if (base === undefined || base === null) return '?'
if (base === 0) return 'Home'
if (base === 4) return 'Home'
return `${base}B`
}
</script>
<style scoped>
.uncapped-wizard {
@apply bg-white rounded-lg shadow-lg p-6 space-y-5 max-w-4xl mx-auto;
}
.uncapped-wizard.read-only {
@apply opacity-75;
}
/* Header */
.wizard-header {
@apply text-center pb-4 border-b border-gray-200 space-y-2;
}
.hit-badge {
@apply inline-block px-4 py-1 rounded-full text-sm font-bold uppercase tracking-wide;
}
.badge-single {
@apply bg-green-100 text-green-800;
}
.badge-double {
@apply bg-blue-100 text-blue-800;
}
.hit-location {
@apply text-sm text-gray-600 font-medium;
}
.waiting-message {
@apply mt-2 text-sm text-orange-600 font-medium;
}
/* Auto runners */
.auto-runners {
@apply bg-green-50 border border-green-200 rounded-lg p-3 space-y-1;
}
.auto-runner-line {
@apply flex items-center gap-2 text-sm text-green-800;
}
/* Phase content */
.phase-content {
@apply space-y-4;
}
.phase-label {
@apply text-xs font-bold uppercase tracking-wider text-center py-1 rounded;
}
.offense-label {
@apply bg-blue-100 text-blue-800;
}
.defense-label {
@apply bg-red-100 text-red-800;
}
.phase-description {
@apply text-sm text-gray-600 text-center;
}
/* Runner info */
.runner-info {
@apply flex flex-col items-center gap-1;
}
.runner-name {
@apply text-lg font-bold text-gray-900;
}
.runner-movement {
@apply text-sm text-gray-600;
}
/* Decision buttons */
.decision-buttons {
@apply grid grid-cols-2 gap-3;
}
.decision-btn {
@apply px-6 py-4 font-bold text-lg rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.advance-btn {
@apply bg-green-600 text-white hover:bg-green-700;
}
.hold-btn {
@apply bg-gray-300 text-gray-800 hover:bg-gray-400;
}
.throw-btn {
@apply bg-red-600 text-white hover:bg-red-700;
}
.letgo-btn {
@apply bg-gray-300 text-gray-800 hover:bg-gray-400;
}
.safe-btn {
@apply bg-green-600 text-white hover:bg-green-700;
}
.out-btn {
@apply bg-red-600 text-white hover:bg-red-700;
}
/* Target options (phase 4) */
.target-options {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
}
.target-btn {
@apply flex flex-col items-center p-5 border-2 border-gray-300 rounded-lg hover:border-red-500 hover:bg-red-50 transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
}
.target-label {
@apply text-lg font-bold text-gray-900 mb-1;
}
.target-detail {
@apply text-sm text-gray-600;
}
/* D20 display (phase 5) */
.d20-display {
@apply text-center;
}
.d20-label {
@apply text-sm font-medium text-gray-600 mb-2;
}
.d20-value {
@apply text-4xl font-bold rounded-lg px-6 py-3 shadow-md bg-blue-100 text-blue-900 inline-block;
}
/* Mobile responsive */
@media (max-width: 640px) {
.uncapped-wizard {
@apply p-4 space-y-4;
}
.decision-buttons {
@apply grid-cols-1;
}
.decision-btn {
@apply w-full;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.uncapped-wizard {
@apply bg-gray-800;
}
.wizard-header {
@apply border-gray-600;
}
.hit-location {
@apply text-gray-400;
}
.auto-runners {
@apply bg-green-900/30 border-green-700;
}
.auto-runner-line {
@apply text-green-300;
}
.runner-name {
@apply text-gray-100;
}
.runner-movement {
@apply text-gray-400;
}
.phase-description {
@apply text-gray-400;
}
.target-btn {
@apply border-gray-600 hover:border-red-400 hover:bg-red-900/30;
}
.target-label {
@apply text-gray-100;
}
.target-detail {
@apply text-gray-400;
}
}
</style>

View File

@ -243,6 +243,90 @@ export function useGameActions(gameId?: string) {
uiStore.showInfo('Submitting speed check result...', 2000) uiStore.showInfo('Submitting speed check result...', 2000)
} }
// ============================================================================
// Uncapped Hit Decision Tree
// ============================================================================
/**
* Submit lead runner advance decision (offensive player)
*/
function submitUncappedLeadAdvance(advance: boolean) {
if (!validateConnection()) return
console.log('[GameActions] Submitting uncapped lead advance:', advance)
socket.value!.emit('submit_uncapped_lead_advance', {
game_id: currentGameId.value!,
advance,
})
uiStore.showInfo('Submitting lead runner decision...', 2000)
}
/**
* Submit defensive throw decision (defensive player)
*/
function submitUncappedDefensiveThrow(willThrow: boolean) {
if (!validateConnection()) return
console.log('[GameActions] Submitting uncapped defensive throw:', willThrow)
socket.value!.emit('submit_uncapped_defensive_throw', {
game_id: currentGameId.value!,
will_throw: willThrow,
})
uiStore.showInfo('Submitting throw decision...', 2000)
}
/**
* Submit trail runner advance decision (offensive player)
*/
function submitUncappedTrailAdvance(advance: boolean) {
if (!validateConnection()) return
console.log('[GameActions] Submitting uncapped trail advance:', advance)
socket.value!.emit('submit_uncapped_trail_advance', {
game_id: currentGameId.value!,
advance,
})
uiStore.showInfo('Submitting trail runner decision...', 2000)
}
/**
* Submit throw target selection (defensive player)
*/
function submitUncappedThrowTarget(target: 'lead' | 'trail') {
if (!validateConnection()) return
console.log('[GameActions] Submitting uncapped throw target:', target)
socket.value!.emit('submit_uncapped_throw_target', {
game_id: currentGameId.value!,
target,
})
uiStore.showInfo('Submitting throw target...', 2000)
}
/**
* Submit safe/out result (offensive player)
*/
function submitUncappedSafeOut(result: 'safe' | 'out') {
if (!validateConnection()) return
console.log('[GameActions] Submitting uncapped safe/out:', result)
socket.value!.emit('submit_uncapped_safe_out', {
game_id: currentGameId.value!,
result,
})
uiStore.showInfo('Submitting speed check result...', 2000)
}
// ============================================================================ // ============================================================================
// Substitution Actions // Substitution Actions
// ============================================================================ // ============================================================================
@ -449,6 +533,13 @@ export function useGameActions(gameId?: string) {
submitDecideThrow, submitDecideThrow,
submitDecideResult, submitDecideResult,
// Uncapped hit decision tree
submitUncappedLeadAdvance,
submitUncappedDefensiveThrow,
submitUncappedTrailAdvance,
submitUncappedThrowTarget,
submitUncappedSafeOut,
// Substitutions // Substitutions
submitSubstitution, submitSubstitution,

View File

@ -470,6 +470,16 @@ export function useWebSocket() {
console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500)) console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500))
gameStore.setGameState(gameState) gameStore.setGameState(gameState)
console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter) console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter)
// Clear interactive workflow data when the workflow has completed
if (!gameState.pending_uncapped_hit && gameStore.uncappedHitData) {
gameStore.clearUncappedHitData()
gameStore.clearDecisionPrompt()
}
if (!gameState.pending_x_check && gameStore.xCheckData) {
gameStore.clearXCheckData()
gameStore.clearDecisionPrompt()
}
}) })
state.socketInstance.on('game_state_sync', (data) => { state.socketInstance.on('game_state_sync', (data) => {
@ -497,22 +507,39 @@ export function useWebSocket() {
// ======================================== // ========================================
state.socketInstance.on('decision_required', (prompt) => { state.socketInstance.on('decision_required', (prompt) => {
console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type) console.log('[WebSocket] Decision required:', prompt.phase)
gameStore.setDecisionPrompt(prompt) gameStore.setDecisionPrompt(prompt)
// Handle x-check specific decision types // Route phase-specific data to appropriate store slots
if (prompt.type === 'x_check_result' && prompt.data) { if (prompt.data) {
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position) switch (prompt.phase) {
gameStore.setXCheckData(prompt.data) // X-Check phases
} else if (prompt.type === 'decide_advance' && prompt.data) { case 'awaiting_x_check_result':
console.log('[WebSocket] DECIDE advance decision') console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
gameStore.setDecideData(prompt.data) gameStore.setXCheckData(prompt.data as any)
} else if (prompt.type === 'decide_throw' && prompt.data) { break
console.log('[WebSocket] DECIDE throw decision') case 'awaiting_decide_advance':
gameStore.setDecideData(prompt.data) console.log('[WebSocket] DECIDE advance decision')
} else if (prompt.type === 'decide_speed_check' && prompt.data) { gameStore.setDecideData(prompt.data as any)
console.log('[WebSocket] DECIDE speed check decision') break
gameStore.setDecideData(prompt.data) case 'awaiting_decide_throw':
console.log('[WebSocket] DECIDE throw decision')
gameStore.setDecideData(prompt.data as any)
break
case 'awaiting_decide_result':
console.log('[WebSocket] DECIDE speed check decision')
gameStore.setDecideData(prompt.data as any)
break
// Uncapped hit phases
case 'awaiting_uncapped_lead_advance':
case 'awaiting_uncapped_defensive_throw':
case 'awaiting_uncapped_trail_advance':
case 'awaiting_uncapped_throw_target':
case 'awaiting_uncapped_safe_out':
console.log('[WebSocket] Uncapped hit decision:', prompt.phase)
gameStore.setUncappedHitData(prompt.data as any)
break
}
} }
}) })
@ -604,6 +631,7 @@ export function useWebSocket() {
gameStore.clearPendingDecisions() gameStore.clearPendingDecisions()
gameStore.clearXCheckData() gameStore.clearXCheckData()
gameStore.clearDecideData() gameStore.clearDecideData()
gameStore.clearUncappedHitData()
uiStore.showSuccess(data.description, 5000) uiStore.showSuccess(data.description, 5000)
}) })

View File

@ -20,6 +20,7 @@ import type {
DecideAdvanceData, DecideAdvanceData,
DecideThrowData, DecideThrowData,
DecideSpeedCheckData, DecideSpeedCheckData,
UncappedHitData,
} from '~/types' } from '~/types'
export const useGameStore = defineStore('game', () => { export const useGameStore = defineStore('game', () => {
@ -44,6 +45,9 @@ export const useGameStore = defineStore('game', () => {
const xCheckData = ref<XCheckData | null>(null) const xCheckData = ref<XCheckData | null>(null)
const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null) const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null)
// Uncapped hit workflow state
const uncappedHitData = ref<UncappedHitData | null>(null)
// Decision state (local pending decisions before submission) // Decision state (local pending decisions before submission)
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null) const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null) const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
@ -156,6 +160,39 @@ export const useGameStore = defineStore('game', () => {
gameState.value?.decision_phase === 'awaiting_decide_result' gameState.value?.decision_phase === 'awaiting_decide_result'
}) })
const needsUncappedLeadAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_lead_advance' ||
gameState.value?.decision_phase === 'awaiting_uncapped_lead_advance'
})
const needsUncappedDefensiveThrow = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_defensive_throw' ||
gameState.value?.decision_phase === 'awaiting_uncapped_defensive_throw'
})
const needsUncappedTrailAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_trail_advance' ||
gameState.value?.decision_phase === 'awaiting_uncapped_trail_advance'
})
const needsUncappedThrowTarget = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_throw_target' ||
gameState.value?.decision_phase === 'awaiting_uncapped_throw_target'
})
const needsUncappedSafeOut = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_safe_out' ||
gameState.value?.decision_phase === 'awaiting_uncapped_safe_out'
})
const needsUncappedDecision = computed(() => {
return needsUncappedLeadAdvance.value ||
needsUncappedDefensiveThrow.value ||
needsUncappedTrailAdvance.value ||
needsUncappedThrowTarget.value ||
needsUncappedSafeOut.value
})
const canRollDice = computed(() => { const canRollDice = computed(() => {
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
}) })
@ -388,6 +425,20 @@ export const useGameStore = defineStore('game', () => {
decideData.value = null decideData.value = null
} }
/**
* Set uncapped hit data (from decision_required event)
*/
function setUncappedHitData(data: UncappedHitData | null) {
uncappedHitData.value = data
}
/**
* Clear uncapped hit data after resolution
*/
function clearUncappedHitData() {
uncappedHitData.value = null
}
/** /**
* Reset game store (when leaving game) * Reset game store (when leaving game)
*/ */
@ -409,6 +460,7 @@ export const useGameStore = defineStore('game', () => {
decisionHistory.value = [] decisionHistory.value = []
xCheckData.value = null xCheckData.value = null
decideData.value = null decideData.value = null
uncappedHitData.value = null
} }
/** /**
@ -464,6 +516,7 @@ export const useGameStore = defineStore('game', () => {
decisionHistory: readonly(decisionHistory), decisionHistory: readonly(decisionHistory),
xCheckData: readonly(xCheckData), xCheckData: readonly(xCheckData),
decideData: readonly(decideData), decideData: readonly(decideData),
uncappedHitData: readonly(uncappedHitData),
// Getters // Getters
gameId, gameId,
@ -496,6 +549,12 @@ export const useGameStore = defineStore('game', () => {
needsDecideAdvance, needsDecideAdvance,
needsDecideThrow, needsDecideThrow,
needsDecideResult, needsDecideResult,
needsUncappedLeadAdvance,
needsUncappedDefensiveThrow,
needsUncappedTrailAdvance,
needsUncappedThrowTarget,
needsUncappedSafeOut,
needsUncappedDecision,
canRollDice, canRollDice,
canSubmitOutcome, canSubmitOutcome,
recentPlays, recentPlays,
@ -526,6 +585,8 @@ export const useGameStore = defineStore('game', () => {
clearXCheckData, clearXCheckData,
setDecideData, setDecideData,
clearDecideData, clearDecideData,
setUncappedHitData,
clearUncappedHitData,
resetGame, resetGame,
getActiveLineup, getActiveLineup,
getBenchPlayers, getBenchPlayers,

View File

@ -43,17 +43,7 @@ describe("DefensiveSetup", () => {
expect(wrapper.text()).toContain("Infield Depth"); expect(wrapper.text()).toContain("Infield Depth");
expect(wrapper.text()).toContain("Outfield Depth"); expect(wrapper.text()).toContain("Outfield Depth");
expect(wrapper.text()).toContain("Hold Runners"); expect(wrapper.text()).toContain("Current Setup");
});
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",
);
}); });
}); });
@ -95,18 +85,19 @@ describe("DefensiveSetup", () => {
}); });
describe("Hold Runners Display", () => { describe("Hold Runners Display", () => {
it('shows "None" when no runners held', () => { it('shows "None" when no runners held in preview', () => {
const wrapper = mount(DefensiveSetup, { const wrapper = mount(DefensiveSetup, {
props: defaultProps, 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 * The preview section should show a comma-separated list of held bases.
* display them as read-only amber pill badges. * Hold runner UI has moved to the runner pills themselves.
*/ */
const { syncFromDecision } = useDefensiveSetup(); const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({ syncFromDecision({
@ -119,11 +110,8 @@ describe("DefensiveSetup", () => {
props: defaultProps, props: defaultProps,
}); });
expect(wrapper.text()).toContain("1st"); // Preview should show the held bases
expect(wrapper.text()).toContain("3rd"); expect(wrapper.text()).toContain("Holding:1st, 3rd");
// Verify amber badges exist
const badges = wrapper.findAll(".bg-amber-100");
expect(badges.length).toBe(2);
}); });
it("displays holding status in preview for multiple runners", () => { it("displays holding status in preview for multiple runners", () => {
@ -141,9 +129,7 @@ describe("DefensiveSetup", () => {
props: defaultProps, props: defaultProps,
}); });
expect(wrapper.text()).toContain("1st"); expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
expect(wrapper.text()).toContain("2nd");
expect(wrapper.text()).toContain("3rd");
}); });
}); });

View File

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

View File

@ -49,6 +49,12 @@ export type DecisionPhase =
| 'awaiting_decide_advance' | 'awaiting_decide_advance'
| 'awaiting_decide_throw' | 'awaiting_decide_throw'
| 'awaiting_decide_result' | 'awaiting_decide_result'
// Uncapped hit decision tree phases
| 'awaiting_uncapped_lead_advance'
| 'awaiting_uncapped_defensive_throw'
| 'awaiting_uncapped_trail_advance'
| 'awaiting_uncapped_throw_target'
| 'awaiting_uncapped_safe_out'
/** /**
* Lineup player state - represents a player in the game * Lineup player state - represents a player in the game
@ -138,6 +144,9 @@ export interface GameState {
// Interactive x-check workflow // Interactive x-check workflow
pending_x_check: PendingXCheck | null pending_x_check: PendingXCheck | null
// Uncapped hit decision tree
pending_uncapped_hit: PendingUncappedHit | null
// Play history // Play history
play_count: number play_count: number
last_play_result: string | null last_play_result: string | null
@ -314,6 +323,7 @@ export interface DecisionPrompt {
timeout_seconds: number timeout_seconds: number
options?: string[] options?: string[]
message?: string message?: string
data?: Record<string, unknown>
} }
/** /**
@ -440,3 +450,98 @@ export interface PendingXCheck {
decide_throw: string | null decide_throw: string | null
decide_d20: number | null decide_d20: number | null
} }
/**
* Pending Uncapped Hit State (on GameState)
* Persisted for reconnection recovery.
* Backend: PendingUncappedHit (game_models.py)
*/
export interface PendingUncappedHit {
hit_type: string
hit_location: string
lead_runner_base: number
lead_runner_lineup_id: number
lead_target_base: number
auto_runners: number[][]
lead_advance: boolean | null
will_throw: boolean | null
trail_runner_base: number | null
trail_runner_lineup_id: number | null
trail_target_base: number | null
trail_advance: boolean | null
throw_target: string | null
d20_roll: number | null
safe_out_result: string | null
}
/**
* Phase 1: Lead runner advance decision data
* Sent with awaiting_uncapped_lead_advance
*/
export interface UncappedLeadAdvanceData {
hit_type: string
hit_location: string
lead_runner_base: number
lead_runner_lineup_id: number
lead_target_base: number
auto_runners: number[][]
}
/**
* Phase 2: Defensive throw decision data
* Sent with awaiting_uncapped_defensive_throw
*/
export interface UncappedDefensiveThrowData {
lead_runner_base: number
lead_target_base: number
lead_runner_lineup_id: number
hit_location: string
}
/**
* Phase 3: Trail runner advance decision data
* Sent with awaiting_uncapped_trail_advance
*/
export interface UncappedTrailAdvanceData {
trail_runner_base: number
trail_target_base: number
trail_runner_lineup_id: number
hit_location: string
}
/**
* Phase 4: Throw target selection data
* Sent with awaiting_uncapped_throw_target
*/
export interface UncappedThrowTargetData {
lead_runner_base: number
lead_target_base: number
lead_runner_lineup_id: number
trail_runner_base: number
trail_target_base: number
trail_runner_lineup_id: number
hit_location: string
}
/**
* Phase 5: Safe/out resolution data
* Sent with awaiting_uncapped_safe_out
*/
export interface UncappedSafeOutData {
d20_roll: number
runner: string
runner_base: number
target_base: number
runner_lineup_id: number
hit_location: string
}
/**
* Union of all uncapped hit phase data types
*/
export type UncappedHitData =
| UncappedLeadAdvanceData
| UncappedDefensiveThrowData
| UncappedTrailAdvanceData
| UncappedThrowTargetData
| UncappedSafeOutData

View File

@ -36,6 +36,14 @@ export type {
DecideThrowData, DecideThrowData,
DecideSpeedCheckData, DecideSpeedCheckData,
PendingXCheck, PendingXCheck,
// Uncapped hit workflow types
PendingUncappedHit,
UncappedLeadAdvanceData,
UncappedDefensiveThrowData,
UncappedTrailAdvanceData,
UncappedThrowTargetData,
UncappedSafeOutData,
UncappedHitData,
} from './game' } from './game'
// Player types // Player types
@ -76,6 +84,12 @@ export type {
SubmitDecideAdvanceRequest, SubmitDecideAdvanceRequest,
SubmitDecideThrowRequest, SubmitDecideThrowRequest,
SubmitDecideResultRequest, SubmitDecideResultRequest,
// Uncapped hit workflow request types
SubmitUncappedLeadAdvanceRequest,
SubmitUncappedDefensiveThrowRequest,
SubmitUncappedTrailAdvanceRequest,
SubmitUncappedThrowTargetRequest,
SubmitUncappedSafeOutRequest,
// Event types // Event types
ConnectedEvent, ConnectedEvent,
GameJoinedEvent, GameJoinedEvent,

View File

@ -58,6 +58,13 @@ export interface ClientToServerEvents {
submit_decide_throw: (data: SubmitDecideThrowRequest) => void submit_decide_throw: (data: SubmitDecideThrowRequest) => void
submit_decide_result: (data: SubmitDecideResultRequest) => void submit_decide_result: (data: SubmitDecideResultRequest) => void
// Uncapped hit decision tree
submit_uncapped_lead_advance: (data: SubmitUncappedLeadAdvanceRequest) => void
submit_uncapped_defensive_throw: (data: SubmitUncappedDefensiveThrowRequest) => void
submit_uncapped_trail_advance: (data: SubmitUncappedTrailAdvanceRequest) => void
submit_uncapped_throw_target: (data: SubmitUncappedThrowTargetRequest) => void
submit_uncapped_safe_out: (data: SubmitUncappedSafeOutRequest) => void
// Substitutions // Substitutions
request_pinch_hitter: (data: PinchHitterRequest) => void request_pinch_hitter: (data: PinchHitterRequest) => void
request_defensive_replacement: (data: DefensiveReplacementRequest) => void request_defensive_replacement: (data: DefensiveReplacementRequest) => void
@ -394,3 +401,28 @@ export interface SubmitDecideResultRequest {
game_id: string game_id: string
outcome: 'safe' | 'out' outcome: 'safe' | 'out'
} }
export interface SubmitUncappedLeadAdvanceRequest {
game_id: string
advance: boolean
}
export interface SubmitUncappedDefensiveThrowRequest {
game_id: string
will_throw: boolean
}
export interface SubmitUncappedTrailAdvanceRequest {
game_id: string
advance: boolean
}
export interface SubmitUncappedThrowTargetRequest {
game_id: string
target: 'lead' | 'trail'
}
export interface SubmitUncappedSafeOutRequest {
game_id: string
result: 'safe' | 'out'
}