Compare commits
No commits in common. "main" and "feature/baserunner-horizontal-layout" have entirely different histories.
main
...
feature/ba
@ -286,14 +286,13 @@ The `start.sh` script handles this automatically based on mode.
|
||||
**Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10)
|
||||
|
||||
Backend is production-ready for frontend integration:
|
||||
- ✅ All 20 WebSocket event handlers implemented
|
||||
- ✅ All 15 WebSocket event handlers implemented
|
||||
- ✅ Strategic decisions (defensive/offensive)
|
||||
- ✅ Manual outcome workflow (dice rolling + card reading)
|
||||
- ✅ Player substitutions (3 types)
|
||||
- ✅ Box score statistics (materialized views)
|
||||
- ✅ Position ratings integration (PD league)
|
||||
- ✅ Uncapped hit interactive decision tree (SINGLE_UNCAPPED, DOUBLE_UNCAPPED)
|
||||
- ✅ 2481/2481 tests passing (100%)
|
||||
- ✅ 730/731 tests passing (99.9%)
|
||||
|
||||
**Next Phase**: Vue 3 + Nuxt 3 frontend implementation with Socket.io client
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ uv run python -m app.main # Start server at localhost:8000
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
uv run pytest tests/unit/ -v # All unit tests (2481 passing)
|
||||
uv run pytest tests/unit/ -v # All unit tests (836 passing)
|
||||
uv run python -m terminal_client # Interactive REPL
|
||||
```
|
||||
|
||||
@ -144,4 +144,4 @@ uv run pytest tests/unit/ -q # Must show all passing
|
||||
|
||||
---
|
||||
|
||||
**Tests**: 2481 passing | **Phase**: 3E-Final Complete | **Updated**: 2026-02-11
|
||||
**Tests**: 836 passing | **Phase**: 3E-Final Complete | **Updated**: 2025-01-27
|
||||
|
||||
@ -139,4 +139,4 @@ uv run python -m terminal_client
|
||||
|
||||
---
|
||||
|
||||
**Tests**: 2481/2481 passing | **Last Updated**: 2026-02-11
|
||||
**Tests**: 739/739 passing | **Last Updated**: 2025-01-19
|
||||
|
||||
@ -117,65 +117,6 @@ class AIOpponent:
|
||||
)
|
||||
return decision
|
||||
|
||||
# ========================================================================
|
||||
# UNCAPPED HIT DECISIONS
|
||||
# ========================================================================
|
||||
|
||||
async def decide_uncapped_lead_advance(
|
||||
self, state: GameState, pending: "PendingUncappedHit"
|
||||
) -> bool:
|
||||
"""
|
||||
AI decision: should lead runner attempt advance on uncapped hit?
|
||||
|
||||
Conservative default: don't risk the runner.
|
||||
"""
|
||||
logger.debug(f"AI uncapped lead advance decision for game {state.game_id}")
|
||||
return False
|
||||
|
||||
async def decide_uncapped_defensive_throw(
|
||||
self, state: GameState, pending: "PendingUncappedHit"
|
||||
) -> bool:
|
||||
"""
|
||||
AI decision: should defense throw to the base?
|
||||
|
||||
Aggressive default: always challenge the runner.
|
||||
"""
|
||||
logger.debug(f"AI uncapped defensive throw decision for game {state.game_id}")
|
||||
return True
|
||||
|
||||
async def decide_uncapped_trail_advance(
|
||||
self, state: GameState, pending: "PendingUncappedHit"
|
||||
) -> bool:
|
||||
"""
|
||||
AI decision: should trail runner attempt advance on uncapped hit?
|
||||
|
||||
Conservative default: don't risk the trail runner.
|
||||
"""
|
||||
logger.debug(f"AI uncapped trail advance decision for game {state.game_id}")
|
||||
return False
|
||||
|
||||
async def decide_uncapped_throw_target(
|
||||
self, state: GameState, pending: "PendingUncappedHit"
|
||||
) -> str:
|
||||
"""
|
||||
AI decision: throw at lead or trail runner?
|
||||
|
||||
Default: target the lead runner (higher-value out).
|
||||
"""
|
||||
logger.debug(f"AI uncapped throw target decision for game {state.game_id}")
|
||||
return "lead"
|
||||
|
||||
async def decide_uncapped_safe_out(
|
||||
self, state: GameState, pending: "PendingUncappedHit"
|
||||
) -> str:
|
||||
"""
|
||||
AI decision: declare runner safe or out?
|
||||
|
||||
Offensive AI always wants the runner safe.
|
||||
"""
|
||||
logger.debug(f"AI uncapped safe/out decision for game {state.game_id}")
|
||||
return "safe"
|
||||
|
||||
def _should_attempt_steal(self, state: GameState) -> bool:
|
||||
"""
|
||||
Determine if AI should attempt a steal (Week 9).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -520,9 +520,8 @@ class PlayResolver:
|
||||
f"{'3B' if state.on_third else ''}"
|
||||
)
|
||||
|
||||
# Fallback path: used when GameEngine determines no interactive decision
|
||||
# is needed (no eligible runners). Interactive workflow is handled by
|
||||
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
|
||||
# TODO Phase 3: Implement uncapped hit decision tree
|
||||
# For now, treat as SINGLE_1
|
||||
runners_advanced = self._advance_on_single_1(state)
|
||||
runs_scored = sum(
|
||||
1 for adv in runners_advanced if adv.to_base == 4
|
||||
@ -534,7 +533,7 @@ class PlayResolver:
|
||||
runs_scored=runs_scored,
|
||||
batter_result=1,
|
||||
runners_advanced=runners_advanced,
|
||||
description="Single (uncapped, no eligible runners)",
|
||||
description="Single to center (uncapped)",
|
||||
ab_roll=ab_roll,
|
||||
is_hit=True,
|
||||
)
|
||||
@ -589,9 +588,8 @@ class PlayResolver:
|
||||
f"{'3B' if state.on_third else ''}"
|
||||
)
|
||||
|
||||
# Fallback path: used when GameEngine determines no interactive decision
|
||||
# is needed (no R1). Interactive workflow is handled by
|
||||
# GameEngine.initiate_uncapped_hit() which intercepts before reaching here.
|
||||
# TODO Phase 3: Implement uncapped hit decision tree
|
||||
# For now, treat as DOUBLE_2
|
||||
runners_advanced = self._advance_on_double_2(state)
|
||||
runs_scored = sum(
|
||||
1 for adv in runners_advanced if adv.to_base == 4
|
||||
@ -603,7 +601,7 @@ class PlayResolver:
|
||||
runs_scored=runs_scored,
|
||||
batter_result=2,
|
||||
runners_advanced=runners_advanced,
|
||||
description="Double (uncapped, no eligible runners)",
|
||||
description="Double (uncapped)",
|
||||
ab_roll=ab_roll,
|
||||
is_hit=True,
|
||||
)
|
||||
|
||||
@ -450,124 +450,6 @@ class PendingXCheck(BaseModel):
|
||||
model_config = ConfigDict(frozen=False) # Allow mutation during workflow
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PENDING UNCAPPED HIT STATE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PendingUncappedHit(BaseModel):
|
||||
"""
|
||||
Intermediate state for interactive uncapped hit resolution.
|
||||
|
||||
Stores all uncapped hit workflow data as offensive/defensive players
|
||||
make runner advancement decisions via WebSocket.
|
||||
|
||||
Workflow:
|
||||
1. System identifies eligible runners → stores lead/trail info
|
||||
2. Offensive player decides if lead runner attempts advance
|
||||
3. Defensive player decides if they throw to base
|
||||
4. If trail runner exists, offensive decides trail advance
|
||||
5. If both advance, defensive picks throw target
|
||||
6. d20 speed check → offensive declares safe/out from card
|
||||
7. Play finalizes with accumulated decisions
|
||||
|
||||
Attributes:
|
||||
hit_type: "single" or "double"
|
||||
hit_location: Outfield position (LF, CF, RF)
|
||||
ab_roll_id: Reference to original AbRoll for audit trail
|
||||
lead_runner_base: Base of lead runner (1 or 2)
|
||||
lead_runner_lineup_id: Lineup ID of lead runner
|
||||
lead_target_base: Base lead runner is attempting (3 or 4=HOME)
|
||||
trail_runner_base: Base of trail runner (0=batter, 1=R1), None if no trail
|
||||
trail_runner_lineup_id: Lineup ID of trail runner, None if no trail
|
||||
trail_target_base: Base trail runner attempts, None if no trail
|
||||
auto_runners: Auto-scoring runners [(from_base, to_base, lineup_id)]
|
||||
batter_base: Minimum base batter reaches (1 for single, 2 for double)
|
||||
batter_lineup_id: Batter's lineup ID
|
||||
lead_advance: Offensive decision - does lead runner attempt advance?
|
||||
defensive_throw: Defensive decision - throw to base?
|
||||
trail_advance: Offensive decision - does trail runner attempt advance?
|
||||
throw_target: Defensive decision - throw at "lead" or "trail"?
|
||||
speed_check_d20: d20 roll for speed check
|
||||
speed_check_runner: Which runner is being checked ("lead" or "trail")
|
||||
speed_check_result: "safe" or "out"
|
||||
"""
|
||||
|
||||
# Hit context
|
||||
hit_type: str # "single" or "double"
|
||||
hit_location: str # "LF", "CF", or "RF"
|
||||
ab_roll_id: str
|
||||
|
||||
# Lead runner
|
||||
lead_runner_base: int # 1 or 2
|
||||
lead_runner_lineup_id: int
|
||||
lead_target_base: int # 3 or 4 (HOME)
|
||||
|
||||
# Trail runner (None if no trail)
|
||||
trail_runner_base: int | None = None # 0=batter, 1=R1
|
||||
trail_runner_lineup_id: int | None = None
|
||||
trail_target_base: int | None = None
|
||||
|
||||
# Auto-scoring runners (recorded before decision tree)
|
||||
auto_runners: list[tuple[int, int, int]] = Field(default_factory=list)
|
||||
# [(from_base, to_base, lineup_id), ...] e.g. R3 scores, R2 scores on double
|
||||
|
||||
# Batter destination (minimum base)
|
||||
batter_base: int # 1 for single, 2 for double
|
||||
batter_lineup_id: int
|
||||
|
||||
# Decisions (filled progressively)
|
||||
lead_advance: bool | None = None
|
||||
defensive_throw: bool | None = None
|
||||
trail_advance: bool | None = None
|
||||
throw_target: str | None = None # "lead" or "trail"
|
||||
|
||||
# Speed check
|
||||
speed_check_d20: int | None = Field(default=None, ge=1, le=20)
|
||||
speed_check_runner: str | None = None # "lead" or "trail"
|
||||
speed_check_result: str | None = None # "safe" or "out"
|
||||
|
||||
@field_validator("hit_type")
|
||||
@classmethod
|
||||
def validate_hit_type(cls, v: str) -> str:
|
||||
"""Ensure hit_type is valid"""
|
||||
valid = ["single", "double"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"hit_type must be one of {valid}")
|
||||
return v
|
||||
|
||||
@field_validator("hit_location")
|
||||
@classmethod
|
||||
def validate_hit_location(cls, v: str) -> str:
|
||||
"""Ensure hit_location is an outfield position"""
|
||||
valid = ["LF", "CF", "RF"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"hit_location must be one of {valid}")
|
||||
return v
|
||||
|
||||
@field_validator("throw_target")
|
||||
@classmethod
|
||||
def validate_throw_target(cls, v: str | None) -> str | None:
|
||||
"""Ensure throw_target is valid"""
|
||||
if v is not None:
|
||||
valid = ["lead", "trail"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"throw_target must be one of {valid}")
|
||||
return v
|
||||
|
||||
@field_validator("speed_check_result")
|
||||
@classmethod
|
||||
def validate_speed_check_result(cls, v: str | None) -> str | None:
|
||||
"""Ensure speed_check_result is valid"""
|
||||
if v is not None:
|
||||
valid = ["safe", "out"]
|
||||
if v not in valid:
|
||||
raise ValueError(f"speed_check_result must be one of {valid}")
|
||||
return v
|
||||
|
||||
model_config = ConfigDict(frozen=False)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GAME STATE
|
||||
# ============================================================================
|
||||
@ -688,9 +570,6 @@ class GameState(BaseModel):
|
||||
# Interactive x-check workflow
|
||||
pending_x_check: PendingXCheck | None = None
|
||||
|
||||
# Interactive uncapped hit workflow
|
||||
pending_uncapped_hit: PendingUncappedHit | None = None
|
||||
|
||||
# Play tracking
|
||||
play_count: int = Field(default=0, ge=0)
|
||||
last_play_result: str | None = None
|
||||
@ -735,11 +614,6 @@ class GameState(BaseModel):
|
||||
"decide_advance",
|
||||
"decide_throw",
|
||||
"decide_result",
|
||||
"uncapped_lead_advance",
|
||||
"uncapped_defensive_throw",
|
||||
"uncapped_trail_advance",
|
||||
"uncapped_throw_target",
|
||||
"uncapped_safe_out",
|
||||
]
|
||||
if v not in valid:
|
||||
raise ValueError(f"pending_decision must be one of {valid}")
|
||||
@ -759,11 +633,6 @@ class GameState(BaseModel):
|
||||
"awaiting_decide_advance",
|
||||
"awaiting_decide_throw",
|
||||
"awaiting_decide_result",
|
||||
"awaiting_uncapped_lead_advance",
|
||||
"awaiting_uncapped_defensive_throw",
|
||||
"awaiting_uncapped_trail_advance",
|
||||
"awaiting_uncapped_throw_target",
|
||||
"awaiting_uncapped_safe_out",
|
||||
]
|
||||
if v not in valid:
|
||||
raise ValueError(f"decision_phase must be one of {valid}")
|
||||
@ -1092,6 +961,5 @@ __all__ = [
|
||||
"TeamLineupState",
|
||||
"DefensiveDecision",
|
||||
"OffensiveDecision",
|
||||
"PendingUncappedHit",
|
||||
"GameState",
|
||||
]
|
||||
|
||||
@ -23,7 +23,7 @@ Broadcast to All Players
|
||||
```
|
||||
app/websocket/
|
||||
├── connection_manager.py # Connection lifecycle & broadcasting
|
||||
└── handlers.py # Event handler registration (20 handlers)
|
||||
└── handlers.py # Event handler registration (15 handlers)
|
||||
```
|
||||
|
||||
## ConnectionManager
|
||||
@ -43,7 +43,7 @@ await manager.broadcast_to_game(game_id, event, data)
|
||||
await manager.emit_to_user(sid, event, data)
|
||||
```
|
||||
|
||||
## Event Handlers (20 Total)
|
||||
## Event Handlers (15 Total)
|
||||
|
||||
### Connection Events
|
||||
- `connect` - JWT authentication
|
||||
@ -68,13 +68,6 @@ await manager.emit_to_user(sid, event, data)
|
||||
- `submit_pitching_change` - Pitcher substitution
|
||||
- `submit_defensive_replacement` - Field substitution
|
||||
|
||||
### Uncapped Hit Decisions
|
||||
- `submit_uncapped_lead_advance` - Lead runner advance choice (offensive)
|
||||
- `submit_uncapped_defensive_throw` - Throw to base choice (defensive)
|
||||
- `submit_uncapped_trail_advance` - Trail runner advance choice (offensive)
|
||||
- `submit_uncapped_throw_target` - Throw at lead or trail (defensive)
|
||||
- `submit_uncapped_safe_out` - Declare safe or out from card (offensive)
|
||||
|
||||
### Lineup
|
||||
- `get_lineup` - Get team lineup
|
||||
|
||||
@ -123,4 +116,4 @@ await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
|
||||
---
|
||||
|
||||
**Handlers**: 20/20 implemented | **Updated**: 2026-02-11
|
||||
**Handlers**: 15/15 implemented | **Updated**: 2025-01-19
|
||||
|
||||
@ -560,20 +560,9 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
# Using the old state reference would overwrite those updates!
|
||||
state = state_manager.get_state(game_id)
|
||||
if state:
|
||||
# Clear pending roll only if NOT entering an interactive workflow.
|
||||
# Uncapped hit and x-check workflows need pending_manual_roll
|
||||
# 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)
|
||||
# Clear pending roll only AFTER successful validation (one-time use)
|
||||
state.pending_manual_roll = None
|
||||
state_manager.update_state(game_id, state)
|
||||
except GameValidationError as e:
|
||||
# Game engine validation error (e.g., missing hit location)
|
||||
await manager.emit_to_user(
|
||||
@ -615,30 +604,6 @@ 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
|
||||
play_result_data = {
|
||||
"game_id": str(game_id),
|
||||
@ -2110,315 +2075,3 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "DECIDE workflow not yet implemented"}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# INTERACTIVE UNCAPPED HIT HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
@sio.event
|
||||
async def submit_uncapped_lead_advance(sid, data):
|
||||
"""
|
||||
Submit offensive decision: will lead runner attempt advance on uncapped hit?
|
||||
|
||||
Event data:
|
||||
game_id: UUID of the game
|
||||
advance: bool - True if runner attempts advance
|
||||
|
||||
Emits:
|
||||
decision_required: Next phase if more decisions needed
|
||||
game_state_update: Broadcast when play finalizes
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
await manager.update_activity(sid)
|
||||
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
game_id_str = data.get("game_id")
|
||||
if not game_id_str:
|
||||
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
|
||||
return
|
||||
|
||||
try:
|
||||
game_id = UUID(game_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"})
|
||||
return
|
||||
|
||||
advance = data.get("advance")
|
||||
if advance is None or not isinstance(advance, bool):
|
||||
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
|
||||
return
|
||||
|
||||
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"})
|
||||
|
||||
@ -55,7 +55,7 @@ See `backend/CLAUDE.md` → "Testing Policy" section for full details.
|
||||
### Current Test Baseline
|
||||
|
||||
**Must maintain or improve:**
|
||||
- ✅ Unit tests: **2481/2481 passing (100%)**
|
||||
- ✅ Unit tests: **979/979 passing (100%)**
|
||||
- ✅ Integration tests: **32/32 passing (100%)**
|
||||
- ⏱️ Unit execution: **~4 seconds**
|
||||
- ⏱️ Integration execution: **~5 seconds**
|
||||
@ -320,10 +320,10 @@ All major test infrastructure issues have been resolved. The test suite is now s
|
||||
|
||||
## Test Coverage
|
||||
|
||||
**Current Status** (as of 2026-02-11):
|
||||
- ✅ **2481 unit tests passing** (100%)
|
||||
**Current Status** (as of 2025-11-27):
|
||||
- ✅ **979 unit tests passing** (100%)
|
||||
- ✅ **32 integration tests passing** (100%)
|
||||
- **Total: 2,513 tests passing**
|
||||
- **Total: 1,011 tests passing**
|
||||
|
||||
**Coverage by Module**:
|
||||
```
|
||||
@ -333,7 +333,7 @@ app/core/state_manager.py ✅ Well covered
|
||||
app/core/dice.py ✅ Well covered
|
||||
app/models/ ✅ Well covered
|
||||
app/database/operations.py ✅ 32 integration tests (session injection pattern)
|
||||
app/websocket/handlers.py ✅ 171 WebSocket handler tests
|
||||
app/websocket/handlers.py ✅ 148 WebSocket handler tests
|
||||
app/middleware/ ✅ Rate limiting, exceptions tested
|
||||
```
|
||||
|
||||
@ -520,4 +520,4 @@ Transactions: db_ops = DatabaseOperations(session) → Multiple ops, single comm
|
||||
|
||||
---
|
||||
|
||||
**Summary**: All 2,513 tests passing (2481 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.
|
||||
**Summary**: All 1,011 tests passing (979 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,124 +0,0 @@
|
||||
"""
|
||||
Truth Table Tests: Uncapped Hit Fallback Outcomes
|
||||
|
||||
Verifies that SINGLE_UNCAPPED and DOUBLE_UNCAPPED produce the correct fallback
|
||||
advancement when no eligible runners exist for the interactive decision tree.
|
||||
|
||||
When GameEngine determines no decision is needed (no eligible lead runner),
|
||||
the PlayResolver handles the outcome directly:
|
||||
- SINGLE_UNCAPPED → SINGLE_1 equivalent (R3 scores, R2→3rd, R1→2nd)
|
||||
- DOUBLE_UNCAPPED → DOUBLE_2 equivalent (R1→3rd, R2/R3 score)
|
||||
|
||||
The interactive decision tree (handled by GameEngine) is tested separately
|
||||
in test_uncapped_hit_workflow.py.
|
||||
|
||||
Fallback conditions:
|
||||
SINGLE_UNCAPPED: No R1 AND no R2 → on_base_codes 0 (empty), 3 (R3 only)
|
||||
DOUBLE_UNCAPPED: No R1 → on_base_codes 0 (empty), 2 (R2), 3 (R3), 6 (R2+R3)
|
||||
|
||||
On-base codes (sequential chart encoding):
|
||||
0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-02-11
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import PlayOutcome
|
||||
|
||||
from .conftest import OBC_LABELS, assert_play_result, resolve_simple
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Truth Table
|
||||
# =============================================================================
|
||||
# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs)
|
||||
|
||||
UNCAPPED_FALLBACK_TRUTH_TABLE = [
|
||||
# =========================================================================
|
||||
# SINGLE_UNCAPPED fallback: Same as SINGLE_1 (R3 scores, R2→3rd, R1→2nd)
|
||||
# Only these on_base_codes reach PlayResolver (no R1 AND no R2):
|
||||
# 0 = empty, 3 = R3 only
|
||||
# =========================================================================
|
||||
(PlayOutcome.SINGLE_UNCAPPED, 0, 1, [], 0, 0), # Empty - just batter to 1st
|
||||
(PlayOutcome.SINGLE_UNCAPPED, 3, 1, [(3, 4)], 1, 0), # R3 scores
|
||||
|
||||
# =========================================================================
|
||||
# DOUBLE_UNCAPPED fallback: Same as DOUBLE_2 (all runners +2 bases)
|
||||
# Only these on_base_codes reach PlayResolver (no R1):
|
||||
# 0 = empty, 2 = R2, 3 = R3, 6 = R2+R3
|
||||
# =========================================================================
|
||||
(PlayOutcome.DOUBLE_UNCAPPED, 0, 2, [], 0, 0), # Empty - batter to 2nd
|
||||
(PlayOutcome.DOUBLE_UNCAPPED, 2, 2, [(2, 4)], 1, 0), # R2 scores (+2 = home)
|
||||
(PlayOutcome.DOUBLE_UNCAPPED, 3, 2, [(3, 4)], 1, 0), # R3 scores (+2 = home)
|
||||
(PlayOutcome.DOUBLE_UNCAPPED, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 both score
|
||||
]
|
||||
|
||||
# Generate human-readable test IDs
|
||||
UNCAPPED_IDS = [
|
||||
f"{outcome.value}__{OBC_LABELS[obc]}"
|
||||
for outcome, obc, *_ in UNCAPPED_FALLBACK_TRUTH_TABLE
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestUncappedFallbackTruthTable:
|
||||
"""
|
||||
Verify that uncapped hit outcomes without eligible runners produce
|
||||
the correct standard advancement (SINGLE_1 / DOUBLE_2 equivalent).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs",
|
||||
UNCAPPED_FALLBACK_TRUTH_TABLE,
|
||||
ids=UNCAPPED_IDS,
|
||||
)
|
||||
def test_uncapped_fallback_advancement(
|
||||
self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs
|
||||
):
|
||||
"""
|
||||
Verify that an uncapped hit with no eligible runners for the decision
|
||||
tree produces the exact expected batter result, runner movements,
|
||||
runs, and outs — equivalent to the standard SINGLE_1 / DOUBLE_2 rules.
|
||||
"""
|
||||
result = resolve_simple(outcome, obc)
|
||||
assert_play_result(
|
||||
result,
|
||||
expected_batter=exp_batter,
|
||||
expected_movements=exp_moves,
|
||||
expected_runs=exp_runs,
|
||||
expected_outs=exp_outs,
|
||||
context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})",
|
||||
)
|
||||
|
||||
|
||||
class TestUncappedFallbackCompleteness:
|
||||
"""Verify the truth table covers all fallback on_base_codes."""
|
||||
|
||||
def test_single_uncapped_fallback_codes(self):
|
||||
"""
|
||||
SINGLE_UNCAPPED should only reach PlayResolver for obc 0 and 3
|
||||
(empty and R3 only — no R1 or R2 to trigger decision tree).
|
||||
"""
|
||||
entries = [
|
||||
row for row in UNCAPPED_FALLBACK_TRUTH_TABLE
|
||||
if row[0] == PlayOutcome.SINGLE_UNCAPPED
|
||||
]
|
||||
obcs = {row[1] for row in entries}
|
||||
assert obcs == {0, 3}, f"Expected {{0, 3}}, got {obcs}"
|
||||
|
||||
def test_double_uncapped_fallback_codes(self):
|
||||
"""
|
||||
DOUBLE_UNCAPPED should only reach PlayResolver for obc 0, 2, 3, 6
|
||||
(no R1 to trigger decision tree).
|
||||
"""
|
||||
entries = [
|
||||
row for row in UNCAPPED_FALLBACK_TRUTH_TABLE
|
||||
if row[0] == PlayOutcome.DOUBLE_UNCAPPED
|
||||
]
|
||||
obcs = {row[1] for row in entries}
|
||||
assert obcs == {0, 2, 3, 6}, f"Expected {{0, 2, 3, 6}}, got {obcs}"
|
||||
@ -1,385 +0,0 @@
|
||||
"""
|
||||
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"]
|
||||
@ -113,15 +113,9 @@
|
||||
:outs="gameState?.outs ?? 0"
|
||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||
:dice-color="diceColor"
|
||||
:user-team-id="myTeamId"
|
||||
@roll-dice="handleRollDice"
|
||||
@submit-outcome="handleSubmitOutcome"
|
||||
@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) -->
|
||||
@ -189,15 +183,9 @@
|
||||
:outs="gameState?.outs ?? 0"
|
||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||
:dice-color="diceColor"
|
||||
:user-team-id="myTeamId"
|
||||
@roll-dice="handleRollDice"
|
||||
@submit-outcome="handleSubmitOutcome"
|
||||
@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>
|
||||
|
||||
@ -459,27 +447,18 @@ const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('conne
|
||||
|
||||
// Determine which team the user controls
|
||||
// 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(() => {
|
||||
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
|
||||
if (gameState.value.half === 'top') {
|
||||
// Top: away bats, home fields
|
||||
return isDefensivePhase
|
||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
||||
? gameState.value.home_team_id
|
||||
: gameState.value.away_team_id
|
||||
} else {
|
||||
// Bottom: home bats, away fields
|
||||
return isDefensivePhase
|
||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
||||
? gameState.value.away_team_id
|
||||
: gameState.value.home_team_id
|
||||
}
|
||||
@ -597,11 +576,6 @@ const showGameplay = computed(() => {
|
||||
return true
|
||||
}
|
||||
|
||||
// Show for uncapped hit decisions (both teams see the wizard)
|
||||
if (gameStore.needsUncappedDecision) {
|
||||
return true
|
||||
}
|
||||
|
||||
return gameState.value?.status === 'active' &&
|
||||
isMyTurn.value &&
|
||||
!needsDefensiveDecision.value &&
|
||||
@ -682,32 +656,6 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
|
||||
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) => {
|
||||
defensiveSetup.toggleHold(base)
|
||||
}
|
||||
|
||||
@ -78,21 +78,6 @@
|
||||
</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 -->
|
||||
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
|
||||
<div v-if="!isXCheckInteractive" class="state-message">
|
||||
@ -134,13 +119,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { RollData, PlayResult, PlayOutcome, XCheckData, DecisionPhase, UncappedHitData } from '~/types'
|
||||
import type { RollData, PlayResult, PlayOutcome, XCheckData } from '~/types'
|
||||
import { useGameStore } from '~/store/game'
|
||||
import DiceRoller from './DiceRoller.vue'
|
||||
import OutcomeWizard from './OutcomeWizard.vue'
|
||||
import PlayResultDisplay from './PlayResult.vue'
|
||||
import XCheckWizard from './XCheckWizard.vue'
|
||||
import UncappedHitWizard from './UncappedHitWizard.vue'
|
||||
|
||||
interface Props {
|
||||
gameId: string
|
||||
@ -169,11 +153,6 @@ const emit = defineEmits<{
|
||||
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
|
||||
dismissResult: []
|
||||
submitXCheckResult: [{ resultCode: string; errorResult: string }]
|
||||
submitUncappedLeadAdvance: [advance: boolean]
|
||||
submitUncappedDefensiveThrow: [willThrow: boolean]
|
||||
submitUncappedTrailAdvance: [advance: boolean]
|
||||
submitUncappedThrowTarget: [target: 'lead' | 'trail']
|
||||
submitUncappedSafeOut: [result: 'safe' | 'out']
|
||||
}>()
|
||||
|
||||
// Store access
|
||||
@ -186,41 +165,6 @@ const isSubmitting = ref(false)
|
||||
// X-Check data from store
|
||||
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
|
||||
// Uses active_team_id from x-check data (set by backend to indicate which team should interact)
|
||||
const isXCheckInteractive = computed(() => {
|
||||
@ -230,7 +174,7 @@ const isXCheckInteractive = computed(() => {
|
||||
})
|
||||
|
||||
// Workflow state computation
|
||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'uncapped_hit_pending' | 'x_check_result_pending'
|
||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'x_check_result_pending'
|
||||
|
||||
const workflowState = computed<WorkflowState>(() => {
|
||||
// Show result if we have one
|
||||
@ -238,11 +182,6 @@ const workflowState = computed<WorkflowState>(() => {
|
||||
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
|
||||
if (gameStore.needsXCheckResult && xCheckData.value) {
|
||||
return 'x_check_result_pending'
|
||||
@ -271,7 +210,6 @@ const workflowState = computed<WorkflowState>(() => {
|
||||
const statusClass = computed(() => {
|
||||
if (error.value) return 'status-error'
|
||||
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 === 'submitted') return 'status-processing'
|
||||
if (workflowState.value === 'rolled') return 'status-active'
|
||||
@ -282,9 +220,6 @@ const statusClass = computed(() => {
|
||||
const statusText = computed(() => {
|
||||
if (error.value) return 'Error'
|
||||
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') {
|
||||
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
|
||||
}
|
||||
@ -323,26 +258,6 @@ const handleDismissResult = () => {
|
||||
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 }) => {
|
||||
error.value = null
|
||||
isSubmitting.value = true
|
||||
@ -443,11 +358,6 @@ const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
/* State: Uncapped Hit */
|
||||
.state-uncapped-hit {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
/* State: X-Check */
|
||||
.state-x-check {
|
||||
@apply space-y-4;
|
||||
|
||||
@ -1,536 +0,0 @@
|
||||
<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) }} → {{ 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) }} → {{ 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 }} → {{ 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 }} → {{ 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 → {{ 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>
|
||||
@ -243,90 +243,6 @@ export function useGameActions(gameId?: string) {
|
||||
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
|
||||
// ============================================================================
|
||||
@ -533,13 +449,6 @@ export function useGameActions(gameId?: string) {
|
||||
submitDecideThrow,
|
||||
submitDecideResult,
|
||||
|
||||
// Uncapped hit decision tree
|
||||
submitUncappedLeadAdvance,
|
||||
submitUncappedDefensiveThrow,
|
||||
submitUncappedTrailAdvance,
|
||||
submitUncappedThrowTarget,
|
||||
submitUncappedSafeOut,
|
||||
|
||||
// Substitutions
|
||||
submitSubstitution,
|
||||
|
||||
|
||||
@ -470,16 +470,6 @@ export function useWebSocket() {
|
||||
console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500))
|
||||
gameStore.setGameState(gameState)
|
||||
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) => {
|
||||
@ -507,39 +497,22 @@ export function useWebSocket() {
|
||||
// ========================================
|
||||
|
||||
state.socketInstance.on('decision_required', (prompt) => {
|
||||
console.log('[WebSocket] Decision required:', prompt.phase)
|
||||
console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type)
|
||||
gameStore.setDecisionPrompt(prompt)
|
||||
|
||||
// Route phase-specific data to appropriate store slots
|
||||
if (prompt.data) {
|
||||
switch (prompt.phase) {
|
||||
// X-Check phases
|
||||
case 'awaiting_x_check_result':
|
||||
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
|
||||
gameStore.setXCheckData(prompt.data as any)
|
||||
break
|
||||
case 'awaiting_decide_advance':
|
||||
console.log('[WebSocket] DECIDE advance decision')
|
||||
gameStore.setDecideData(prompt.data as any)
|
||||
break
|
||||
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
|
||||
}
|
||||
// Handle x-check specific decision types
|
||||
if (prompt.type === 'x_check_result' && prompt.data) {
|
||||
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
|
||||
gameStore.setXCheckData(prompt.data)
|
||||
} else if (prompt.type === 'decide_advance' && prompt.data) {
|
||||
console.log('[WebSocket] DECIDE advance decision')
|
||||
gameStore.setDecideData(prompt.data)
|
||||
} else if (prompt.type === 'decide_throw' && prompt.data) {
|
||||
console.log('[WebSocket] DECIDE throw decision')
|
||||
gameStore.setDecideData(prompt.data)
|
||||
} else if (prompt.type === 'decide_speed_check' && prompt.data) {
|
||||
console.log('[WebSocket] DECIDE speed check decision')
|
||||
gameStore.setDecideData(prompt.data)
|
||||
}
|
||||
})
|
||||
|
||||
@ -631,7 +604,6 @@ export function useWebSocket() {
|
||||
gameStore.clearPendingDecisions()
|
||||
gameStore.clearXCheckData()
|
||||
gameStore.clearDecideData()
|
||||
gameStore.clearUncappedHitData()
|
||||
|
||||
uiStore.showSuccess(data.description, 5000)
|
||||
})
|
||||
|
||||
@ -20,7 +20,6 @@ import type {
|
||||
DecideAdvanceData,
|
||||
DecideThrowData,
|
||||
DecideSpeedCheckData,
|
||||
UncappedHitData,
|
||||
} from '~/types'
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
@ -45,9 +44,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
const xCheckData = ref<XCheckData | 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)
|
||||
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
|
||||
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
|
||||
@ -160,39 +156,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
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(() => {
|
||||
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
|
||||
})
|
||||
@ -425,20 +388,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
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)
|
||||
*/
|
||||
@ -460,7 +409,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
decisionHistory.value = []
|
||||
xCheckData.value = null
|
||||
decideData.value = null
|
||||
uncappedHitData.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@ -516,7 +464,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
decisionHistory: readonly(decisionHistory),
|
||||
xCheckData: readonly(xCheckData),
|
||||
decideData: readonly(decideData),
|
||||
uncappedHitData: readonly(uncappedHitData),
|
||||
|
||||
// Getters
|
||||
gameId,
|
||||
@ -549,12 +496,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
needsDecideAdvance,
|
||||
needsDecideThrow,
|
||||
needsDecideResult,
|
||||
needsUncappedLeadAdvance,
|
||||
needsUncappedDefensiveThrow,
|
||||
needsUncappedTrailAdvance,
|
||||
needsUncappedThrowTarget,
|
||||
needsUncappedSafeOut,
|
||||
needsUncappedDecision,
|
||||
canRollDice,
|
||||
canSubmitOutcome,
|
||||
recentPlays,
|
||||
@ -585,8 +526,6 @@ export const useGameStore = defineStore('game', () => {
|
||||
clearXCheckData,
|
||||
setDecideData,
|
||||
clearDecideData,
|
||||
setUncappedHitData,
|
||||
clearUncappedHitData,
|
||||
resetGame,
|
||||
getActiveLineup,
|
||||
getBenchPlayers,
|
||||
|
||||
@ -43,7 +43,17 @@ describe("DefensiveSetup", () => {
|
||||
|
||||
expect(wrapper.text()).toContain("Infield Depth");
|
||||
expect(wrapper.text()).toContain("Outfield Depth");
|
||||
expect(wrapper.text()).toContain("Current Setup");
|
||||
expect(wrapper.text()).toContain("Hold Runners");
|
||||
});
|
||||
|
||||
it("shows hint text directing users to runner pills", () => {
|
||||
const wrapper = mount(DefensiveSetup, {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
"Tap the H icons on the runner pills above",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -85,19 +95,18 @@ describe("DefensiveSetup", () => {
|
||||
});
|
||||
|
||||
describe("Hold Runners Display", () => {
|
||||
it('shows "None" when no runners held in preview', () => {
|
||||
it('shows "None" when no runners held', () => {
|
||||
const wrapper = mount(DefensiveSetup, {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
// Check preview section shows "None" for holding
|
||||
expect(wrapper.text()).toContain("Holding:None");
|
||||
expect(wrapper.text()).toContain("None");
|
||||
});
|
||||
|
||||
it("displays holding status in preview for held runners", () => {
|
||||
it("shows held bases as amber badges when runners are held", () => {
|
||||
/**
|
||||
* The preview section should show a comma-separated list of held bases.
|
||||
* Hold runner UI has moved to the runner pills themselves.
|
||||
* When the composable has held runners, the DefensiveSetup should
|
||||
* display them as read-only amber pill badges.
|
||||
*/
|
||||
const { syncFromDecision } = useDefensiveSetup();
|
||||
syncFromDecision({
|
||||
@ -110,8 +119,11 @@ describe("DefensiveSetup", () => {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
// Preview should show the held bases
|
||||
expect(wrapper.text()).toContain("Holding:1st, 3rd");
|
||||
expect(wrapper.text()).toContain("1st");
|
||||
expect(wrapper.text()).toContain("3rd");
|
||||
// Verify amber badges exist
|
||||
const badges = wrapper.findAll(".bg-amber-100");
|
||||
expect(badges.length).toBe(2);
|
||||
});
|
||||
|
||||
it("displays holding status in preview for multiple runners", () => {
|
||||
@ -129,7 +141,9 @@ describe("DefensiveSetup", () => {
|
||||
props: defaultProps,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
|
||||
expect(wrapper.text()).toContain("1st");
|
||||
expect(wrapper.text()).toContain("2nd");
|
||||
expect(wrapper.text()).toContain("3rd");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
||||
import type { RollData, PlayResult } from '~/types'
|
||||
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
||||
import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue'
|
||||
import PlayResultDisplay from '~/components/Gameplay/PlayResult.vue'
|
||||
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
|
||||
|
||||
describe('GameplayPanel', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
const createRollData = (): RollData => ({
|
||||
roll_id: 'test-roll-123',
|
||||
d6_one: 3,
|
||||
@ -47,16 +44,7 @@ describe('GameplayPanel', () => {
|
||||
canSubmitOutcome: false,
|
||||
}
|
||||
|
||||
const mountPanel = (propsOverride = {}) => {
|
||||
return mount(GameplayPanel, {
|
||||
global: { plugins: [pinia] },
|
||||
props: { ...defaultProps, ...propsOverride },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
@ -66,14 +54,18 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders gameplay panel container', () => {
|
||||
const wrapper = mountPanel()
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Gameplay')
|
||||
})
|
||||
|
||||
it('renders panel header with status', () => {
|
||||
const wrapper = mountPanel()
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.panel-header').exists()).toBe(true)
|
||||
expect(wrapper.find('.status-indicator').exists()).toBe(true)
|
||||
@ -87,14 +79,21 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Workflow State: Idle', () => {
|
||||
it('shows idle state when canRollDice is false', () => {
|
||||
const wrapper = mountPanel({ canRollDice: false })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Waiting for strategic decisions')
|
||||
})
|
||||
|
||||
it('displays idle status indicator', () => {
|
||||
const wrapper = mountPanel()
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-idle').exists()).toBe(true)
|
||||
expect(wrapper.find('.status-text').text()).toBe('Waiting')
|
||||
@ -107,33 +106,63 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Workflow State: Ready to Roll', () => {
|
||||
it('shows ready state when canRollDice is true and my turn', () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-ready').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Your turn! Roll the dice')
|
||||
})
|
||||
|
||||
it('shows waiting message when not my turn', () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Waiting for opponent to roll dice')
|
||||
})
|
||||
|
||||
it('renders DiceRoller component when my turn', () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(DiceRoller).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays active status when ready and my turn', () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-active').exists()).toBe(true)
|
||||
expect(wrapper.find('.status-text').text()).toBe('Your Turn')
|
||||
})
|
||||
|
||||
it('displays opponent turn status when ready but not my turn', () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: false })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-text').text()).toBe('Opponent Turn')
|
||||
})
|
||||
@ -145,9 +174,12 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Workflow State: Rolled', () => {
|
||||
it('shows rolled state when pendingRoll exists', () => {
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-rolled').exists()).toBe(true)
|
||||
@ -155,9 +187,12 @@ describe('GameplayPanel', () => {
|
||||
|
||||
it('renders DiceRoller with roll results', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: rollData,
|
||||
canSubmitOutcome: true,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: rollData,
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
const diceRoller = wrapper.findComponent(DiceRoller)
|
||||
@ -167,18 +202,24 @@ describe('GameplayPanel', () => {
|
||||
})
|
||||
|
||||
it('renders OutcomeWizard component', () => {
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays active status when outcome entry active', () => {
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-active').exists()).toBe(true)
|
||||
@ -192,32 +233,50 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Workflow State: Result', () => {
|
||||
it('shows result state when lastPlayResult exists', () => {
|
||||
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
lastPlayResult: createPlayResult(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-result').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders PlayResultDisplay component', () => {
|
||||
it('renders PlayResult component', () => {
|
||||
const playResult = createPlayResult()
|
||||
const wrapper = mountPanel({ lastPlayResult: playResult })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
lastPlayResult: playResult,
|
||||
},
|
||||
})
|
||||
|
||||
const resultComponent = wrapper.findComponent(PlayResultDisplay)
|
||||
expect(resultComponent.exists()).toBe(true)
|
||||
expect(resultComponent.props('result')).toEqual(playResult)
|
||||
const playResultComponent = wrapper.findComponent(PlayResultComponent)
|
||||
expect(playResultComponent.exists()).toBe(true)
|
||||
expect(playResultComponent.props('result')).toEqual(playResult)
|
||||
})
|
||||
|
||||
it('displays success status when result shown', () => {
|
||||
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
lastPlayResult: createPlayResult(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-success').exists()).toBe(true)
|
||||
expect(wrapper.find('.status-text').text()).toBe('Play Complete')
|
||||
})
|
||||
|
||||
it('prioritizes result state over other states', () => {
|
||||
const wrapper = mountPanel({
|
||||
canRollDice: true,
|
||||
pendingRoll: createRollData(),
|
||||
lastPlayResult: createPlayResult(),
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
pendingRoll: createRollData(),
|
||||
lastPlayResult: createPlayResult(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-result').exists()).toBe(true)
|
||||
@ -232,7 +291,13 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Event Emission', () => {
|
||||
it('emits rollDice when DiceRoller emits roll', async () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
const diceRoller = wrapper.findComponent(DiceRoller)
|
||||
await diceRoller.vm.$emit('roll')
|
||||
@ -241,10 +306,13 @@ describe('GameplayPanel', () => {
|
||||
expect(wrapper.emitted('rollDice')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits submitOutcome when OutcomeWizard submits', async () => {
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
it('emits submitOutcome when ManualOutcomeEntry submits', async () => {
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
const outcomeWizard = wrapper.findComponent(OutcomeWizard)
|
||||
@ -255,11 +323,16 @@ describe('GameplayPanel', () => {
|
||||
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])
|
||||
})
|
||||
|
||||
it('emits dismissResult when PlayResultDisplay emits dismiss', async () => {
|
||||
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
|
||||
it('emits dismissResult when PlayResult emits dismiss', async () => {
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
lastPlayResult: createPlayResult(),
|
||||
},
|
||||
})
|
||||
|
||||
const resultComponent = wrapper.findComponent(PlayResultDisplay)
|
||||
await resultComponent.vm.$emit('dismiss')
|
||||
const playResult = wrapper.findComponent(PlayResultComponent)
|
||||
await playResult.vm.$emit('dismiss')
|
||||
|
||||
expect(wrapper.emitted('dismissResult')).toBeTruthy()
|
||||
expect(wrapper.emitted('dismissResult')).toHaveLength(1)
|
||||
@ -272,7 +345,9 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Workflow State Transitions', () => {
|
||||
it('transitions from idle to ready when canRollDice becomes true', async () => {
|
||||
const wrapper = mountPanel()
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
||||
|
||||
@ -283,7 +358,13 @@ describe('GameplayPanel', () => {
|
||||
})
|
||||
|
||||
it('transitions from ready to rolled when pendingRoll set', async () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-ready').exists()).toBe(true)
|
||||
|
||||
@ -298,9 +379,12 @@ describe('GameplayPanel', () => {
|
||||
})
|
||||
|
||||
it('transitions from rolled to result when lastPlayResult set', async () => {
|
||||
const wrapper = mountPanel({
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pendingRoll: createRollData(),
|
||||
canSubmitOutcome: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-rolled').exists()).toBe(true)
|
||||
@ -315,7 +399,12 @@ describe('GameplayPanel', () => {
|
||||
})
|
||||
|
||||
it('transitions from result to idle when result dismissed', async () => {
|
||||
const wrapper = mountPanel({ lastPlayResult: createPlayResult() })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
lastPlayResult: createPlayResult(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-result').exists()).toBe(true)
|
||||
|
||||
@ -332,7 +421,9 @@ describe('GameplayPanel', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles multiple rapid state changes', async () => {
|
||||
const wrapper = mountPanel()
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.setProps({ canRollDice: true, isMyTurn: true })
|
||||
await wrapper.setProps({ pendingRoll: createRollData(), canSubmitOutcome: true })
|
||||
@ -342,26 +433,39 @@ describe('GameplayPanel', () => {
|
||||
})
|
||||
|
||||
it('handles missing gameId gracefully', () => {
|
||||
const wrapper = mountPanel({ gameId: '' })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
gameId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles all props being null/false', () => {
|
||||
const wrapper = mountPanel({
|
||||
gameId: 'test',
|
||||
isMyTurn: false,
|
||||
canRollDice: false,
|
||||
pendingRoll: null,
|
||||
lastPlayResult: null,
|
||||
canSubmitOutcome: false,
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
gameId: 'test',
|
||||
isMyTurn: false,
|
||||
canRollDice: false,
|
||||
pendingRoll: null,
|
||||
lastPlayResult: null,
|
||||
canSubmitOutcome: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears error when rolling dice', async () => {
|
||||
const wrapper = mountPanel({ canRollDice: true, isMyTurn: true })
|
||||
const wrapper = mount(GameplayPanel, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRollDice: true,
|
||||
isMyTurn: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Manually set error (would normally come from failed operation)
|
||||
wrapper.vm.error = 'Test error'
|
||||
|
||||
@ -49,12 +49,6 @@ export type DecisionPhase =
|
||||
| 'awaiting_decide_advance'
|
||||
| 'awaiting_decide_throw'
|
||||
| '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
|
||||
@ -144,9 +138,6 @@ export interface GameState {
|
||||
// Interactive x-check workflow
|
||||
pending_x_check: PendingXCheck | null
|
||||
|
||||
// Uncapped hit decision tree
|
||||
pending_uncapped_hit: PendingUncappedHit | null
|
||||
|
||||
// Play history
|
||||
play_count: number
|
||||
last_play_result: string | null
|
||||
@ -323,7 +314,6 @@ export interface DecisionPrompt {
|
||||
timeout_seconds: number
|
||||
options?: string[]
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -450,98 +440,3 @@ export interface PendingXCheck {
|
||||
decide_throw: string | 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
|
||||
|
||||
@ -36,14 +36,6 @@ export type {
|
||||
DecideThrowData,
|
||||
DecideSpeedCheckData,
|
||||
PendingXCheck,
|
||||
// Uncapped hit workflow types
|
||||
PendingUncappedHit,
|
||||
UncappedLeadAdvanceData,
|
||||
UncappedDefensiveThrowData,
|
||||
UncappedTrailAdvanceData,
|
||||
UncappedThrowTargetData,
|
||||
UncappedSafeOutData,
|
||||
UncappedHitData,
|
||||
} from './game'
|
||||
|
||||
// Player types
|
||||
@ -84,12 +76,6 @@ export type {
|
||||
SubmitDecideAdvanceRequest,
|
||||
SubmitDecideThrowRequest,
|
||||
SubmitDecideResultRequest,
|
||||
// Uncapped hit workflow request types
|
||||
SubmitUncappedLeadAdvanceRequest,
|
||||
SubmitUncappedDefensiveThrowRequest,
|
||||
SubmitUncappedTrailAdvanceRequest,
|
||||
SubmitUncappedThrowTargetRequest,
|
||||
SubmitUncappedSafeOutRequest,
|
||||
// Event types
|
||||
ConnectedEvent,
|
||||
GameJoinedEvent,
|
||||
|
||||
@ -58,13 +58,6 @@ export interface ClientToServerEvents {
|
||||
submit_decide_throw: (data: SubmitDecideThrowRequest) => 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
|
||||
request_pinch_hitter: (data: PinchHitterRequest) => void
|
||||
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
|
||||
@ -401,28 +394,3 @@ export interface SubmitDecideResultRequest {
|
||||
game_id: string
|
||||
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'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user