Merge pull request 'CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7)' (#9) from feature/uncapped-hit-decision-ui into main
Reviewed-on: #9
This commit is contained in:
commit
87ae3c112a
@ -932,11 +932,13 @@ class GameEngine:
|
|||||||
# Check if SPD is in any column - if so, pre-roll d20
|
# Check if SPD is in any column - if so, pre-roll d20
|
||||||
spd_d20 = None
|
spd_d20 = None
|
||||||
if "SPD" in chart_row:
|
if "SPD" in chart_row:
|
||||||
spd_d20 = dice_system.roll_d20(
|
spd_d20_result = dice_system.roll_d20(
|
||||||
|
league_id=state.league_id,
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
team_id=state.get_fielding_team_id(),
|
team_id=state.get_fielding_team_id(),
|
||||||
player_id=None,
|
player_id=None,
|
||||||
)
|
)
|
||||||
|
spd_d20 = spd_d20_result.roll if hasattr(spd_d20_result, 'roll') else spd_d20_result
|
||||||
|
|
||||||
# Get defender at this position
|
# Get defender at this position
|
||||||
defender = state.get_defender_for_position(position, state_manager)
|
defender = state.get_defender_for_position(position, state_manager)
|
||||||
@ -1157,128 +1159,130 @@ class GameEngine:
|
|||||||
outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED
|
outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED
|
||||||
hit_location: Outfield position (LF, CF, RF)
|
hit_location: Outfield position (LF, CF, RF)
|
||||||
ab_roll: The at-bat roll for audit trail
|
ab_roll: The at-bat roll for audit trail
|
||||||
|
|
||||||
|
Note: Caller must hold the game lock (called from resolve_manual_play
|
||||||
|
which runs inside the handler's game_lock context).
|
||||||
"""
|
"""
|
||||||
async with state_manager.game_lock(game_id):
|
state = state_manager.get_state(game_id)
|
||||||
state = state_manager.get_state(game_id)
|
if not state:
|
||||||
if not state:
|
raise ValueError(f"Game {game_id} not found")
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
game_validator.validate_game_active(state)
|
game_validator.validate_game_active(state)
|
||||||
|
|
||||||
is_single = outcome == PlayOutcome.SINGLE_UNCAPPED
|
is_single = outcome == PlayOutcome.SINGLE_UNCAPPED
|
||||||
hit_type = "single" if is_single else "double"
|
hit_type = "single" if is_single else "double"
|
||||||
batter_base = 1 if is_single else 2
|
batter_base = 1 if is_single else 2
|
||||||
|
|
||||||
# Default hit_location to CF if not provided
|
# Default hit_location to CF if not provided
|
||||||
location = hit_location or "CF"
|
location = hit_location or "CF"
|
||||||
|
|
||||||
auto_runners: list[tuple[int, int, int]] = []
|
auto_runners: list[tuple[int, int, int]] = []
|
||||||
|
|
||||||
if is_single:
|
if is_single:
|
||||||
# R3 always scores on any single
|
# R3 always scores on any single
|
||||||
if state.on_third:
|
if state.on_third:
|
||||||
auto_runners.append((3, 4, state.on_third.lineup_id))
|
auto_runners.append((3, 4, state.on_third.lineup_id))
|
||||||
|
|
||||||
# Identify lead and trail runners
|
# Identify lead and trail runners
|
||||||
if state.on_second:
|
if state.on_second:
|
||||||
# Lead = R2 attempting HOME
|
# Lead = R2 attempting HOME
|
||||||
lead_base = 2
|
lead_base = 2
|
||||||
lead_lid = state.on_second.lineup_id
|
lead_lid = state.on_second.lineup_id
|
||||||
lead_target = 4 # HOME
|
lead_target = 4 # HOME
|
||||||
|
|
||||||
# Trail = R1 if exists, else batter
|
|
||||||
if state.on_first:
|
|
||||||
trail_base = 1
|
|
||||||
trail_lid = state.on_first.lineup_id
|
|
||||||
trail_target = 3 # R1 attempting 3rd
|
|
||||||
else:
|
|
||||||
trail_base = 0 # batter
|
|
||||||
trail_lid = state.current_batter.lineup_id
|
|
||||||
trail_target = 2 # batter attempting 2nd
|
|
||||||
elif state.on_first:
|
|
||||||
# No R2, Lead = R1 attempting 3RD
|
|
||||||
lead_base = 1
|
|
||||||
lead_lid = state.on_first.lineup_id
|
|
||||||
lead_target = 3
|
|
||||||
|
|
||||||
# Trail = batter attempting 2nd
|
|
||||||
trail_base = 0
|
|
||||||
trail_lid = state.current_batter.lineup_id
|
|
||||||
trail_target = 2
|
|
||||||
else:
|
|
||||||
# Should not reach here (_uncapped_needs_decision checks)
|
|
||||||
raise ValueError("SINGLE_UNCAPPED with no R1 or R2 should use fallback")
|
|
||||||
else:
|
|
||||||
# DOUBLE_UNCAPPED
|
|
||||||
# R3 and R2 always score on any double
|
|
||||||
if state.on_third:
|
|
||||||
auto_runners.append((3, 4, state.on_third.lineup_id))
|
|
||||||
if state.on_second:
|
|
||||||
auto_runners.append((2, 4, state.on_second.lineup_id))
|
|
||||||
|
|
||||||
|
# Trail = R1 if exists, else batter
|
||||||
if state.on_first:
|
if state.on_first:
|
||||||
# Lead = R1 attempting HOME
|
trail_base = 1
|
||||||
lead_base = 1
|
trail_lid = state.on_first.lineup_id
|
||||||
lead_lid = state.on_first.lineup_id
|
trail_target = 3 # R1 attempting 3rd
|
||||||
lead_target = 4 # HOME
|
|
||||||
|
|
||||||
# Trail = batter attempting 3RD
|
|
||||||
trail_base = 0
|
|
||||||
trail_lid = state.current_batter.lineup_id
|
|
||||||
trail_target = 3
|
|
||||||
else:
|
else:
|
||||||
# Should not reach here
|
trail_base = 0 # batter
|
||||||
raise ValueError("DOUBLE_UNCAPPED with no R1 should use fallback")
|
trail_lid = state.current_batter.lineup_id
|
||||||
|
trail_target = 2 # batter attempting 2nd
|
||||||
|
elif state.on_first:
|
||||||
|
# No R2, Lead = R1 attempting 3RD
|
||||||
|
lead_base = 1
|
||||||
|
lead_lid = state.on_first.lineup_id
|
||||||
|
lead_target = 3
|
||||||
|
|
||||||
# Create pending uncapped hit state
|
# Trail = batter attempting 2nd
|
||||||
pending = PendingUncappedHit(
|
trail_base = 0
|
||||||
hit_type=hit_type,
|
trail_lid = state.current_batter.lineup_id
|
||||||
hit_location=location,
|
trail_target = 2
|
||||||
ab_roll_id=ab_roll.roll_id,
|
else:
|
||||||
lead_runner_base=lead_base,
|
# Should not reach here (_uncapped_needs_decision checks)
|
||||||
lead_runner_lineup_id=lead_lid,
|
raise ValueError("SINGLE_UNCAPPED with no R1 or R2 should use fallback")
|
||||||
lead_target_base=lead_target,
|
else:
|
||||||
trail_runner_base=trail_base,
|
# DOUBLE_UNCAPPED
|
||||||
trail_runner_lineup_id=trail_lid,
|
# R3 and R2 always score on any double
|
||||||
trail_target_base=trail_target,
|
if state.on_third:
|
||||||
auto_runners=auto_runners,
|
auto_runners.append((3, 4, state.on_third.lineup_id))
|
||||||
batter_base=batter_base,
|
if state.on_second:
|
||||||
batter_lineup_id=state.current_batter.lineup_id,
|
auto_runners.append((2, 4, state.on_second.lineup_id))
|
||||||
)
|
|
||||||
|
|
||||||
# Store in state
|
if state.on_first:
|
||||||
state.pending_uncapped_hit = pending
|
# Lead = R1 attempting HOME
|
||||||
state.decision_phase = "awaiting_uncapped_lead_advance"
|
lead_base = 1
|
||||||
state.pending_decision = "uncapped_lead_advance"
|
lead_lid = state.on_first.lineup_id
|
||||||
|
lead_target = 4 # HOME
|
||||||
|
|
||||||
state_manager.update_state(game_id, state)
|
# Trail = batter attempting 3RD
|
||||||
|
trail_base = 0
|
||||||
|
trail_lid = state.current_batter.lineup_id
|
||||||
|
trail_target = 3
|
||||||
|
else:
|
||||||
|
# Should not reach here
|
||||||
|
raise ValueError("DOUBLE_UNCAPPED with no R1 should use fallback")
|
||||||
|
|
||||||
logger.info(
|
# Create pending uncapped hit state
|
||||||
f"Uncapped {hit_type} initiated for game {game_id}: "
|
pending = PendingUncappedHit(
|
||||||
f"lead=base{lead_base}→{lead_target}, trail=base{trail_base}→{trail_target}"
|
hit_type=hit_type,
|
||||||
)
|
hit_location=location,
|
||||||
|
ab_roll_id=ab_roll.roll_id,
|
||||||
|
lead_runner_base=lead_base,
|
||||||
|
lead_runner_lineup_id=lead_lid,
|
||||||
|
lead_target_base=lead_target,
|
||||||
|
trail_runner_base=trail_base,
|
||||||
|
trail_runner_lineup_id=trail_lid,
|
||||||
|
trail_target_base=trail_target,
|
||||||
|
auto_runners=auto_runners,
|
||||||
|
batter_base=batter_base,
|
||||||
|
batter_lineup_id=state.current_batter.lineup_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Check if offensive team is AI
|
# Store in state
|
||||||
if state.is_batting_team_ai():
|
state.pending_uncapped_hit = pending
|
||||||
advance = await ai_opponent.decide_uncapped_lead_advance(state, pending)
|
state.decision_phase = "awaiting_uncapped_lead_advance"
|
||||||
await self.submit_uncapped_lead_advance(game_id, advance)
|
state.pending_decision = "uncapped_lead_advance"
|
||||||
return
|
|
||||||
|
|
||||||
# Emit decision_required for offensive team
|
state_manager.update_state(game_id, state)
|
||||||
await self._emit_decision_required(
|
|
||||||
game_id=game_id,
|
logger.info(
|
||||||
state=state,
|
f"Uncapped {hit_type} initiated for game {game_id}: "
|
||||||
phase="awaiting_uncapped_lead_advance",
|
f"lead=base{lead_base}→{lead_target}, trail=base{trail_base}→{trail_target}"
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
)
|
||||||
data={
|
|
||||||
"hit_type": hit_type,
|
# Check if offensive team is AI
|
||||||
"hit_location": location,
|
if state.is_batting_team_ai():
|
||||||
"lead_runner_base": lead_base,
|
advance = await ai_opponent.decide_uncapped_lead_advance(state, pending)
|
||||||
"lead_runner_lineup_id": lead_lid,
|
await self.submit_uncapped_lead_advance(game_id, advance)
|
||||||
"lead_target_base": lead_target,
|
return
|
||||||
"auto_runners": auto_runners,
|
|
||||||
},
|
# Emit decision_required for offensive team
|
||||||
)
|
await self._emit_decision_required(
|
||||||
|
game_id=game_id,
|
||||||
|
state=state,
|
||||||
|
phase="awaiting_uncapped_lead_advance",
|
||||||
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
|
"hit_type": hit_type,
|
||||||
|
"hit_location": location,
|
||||||
|
"lead_runner_base": lead_base,
|
||||||
|
"lead_runner_lineup_id": lead_lid,
|
||||||
|
"lead_target_base": lead_target,
|
||||||
|
"auto_runners": auto_runners,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_uncapped_lead_advance(
|
async def submit_uncapped_lead_advance(
|
||||||
self, game_id: UUID, advance: bool
|
self, game_id: UUID, advance: bool
|
||||||
@ -1288,60 +1292,61 @@ class GameEngine:
|
|||||||
|
|
||||||
If NO: fallback to standard SI*/DO** advancement, finalize immediately.
|
If NO: fallback to standard SI*/DO** advancement, finalize immediately.
|
||||||
If YES: transition to awaiting_uncapped_defensive_throw.
|
If YES: transition to awaiting_uncapped_defensive_throw.
|
||||||
|
|
||||||
|
Caller must hold the game lock (acquired by WebSocket handler).
|
||||||
"""
|
"""
|
||||||
async with state_manager.game_lock(game_id):
|
state = state_manager.get_state(game_id)
|
||||||
state = state_manager.get_state(game_id)
|
if not state:
|
||||||
if not state:
|
raise ValueError(f"Game {game_id} not found")
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
pending = state.pending_uncapped_hit
|
pending = state.pending_uncapped_hit
|
||||||
if not pending:
|
if not pending:
|
||||||
raise ValueError("No pending uncapped hit")
|
raise ValueError("No pending uncapped hit")
|
||||||
|
|
||||||
if state.decision_phase != "awaiting_uncapped_lead_advance":
|
if state.decision_phase != "awaiting_uncapped_lead_advance":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Wrong phase: expected awaiting_uncapped_lead_advance, "
|
f"Wrong phase: expected awaiting_uncapped_lead_advance, "
|
||||||
f"got {state.decision_phase}"
|
f"got {state.decision_phase}"
|
||||||
)
|
|
||||||
|
|
||||||
pending.lead_advance = advance
|
|
||||||
|
|
||||||
if not advance:
|
|
||||||
# Lead runner declines → fallback to standard advancement, finalize
|
|
||||||
ab_roll = state.pending_manual_roll
|
|
||||||
if not ab_roll:
|
|
||||||
raise ValueError("No pending manual roll found")
|
|
||||||
|
|
||||||
result = self._build_uncapped_fallback_result(state, pending, ab_roll)
|
|
||||||
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Lead runner advances → ask defensive team about throwing
|
|
||||||
state.decision_phase = "awaiting_uncapped_defensive_throw"
|
|
||||||
state.pending_decision = "uncapped_defensive_throw"
|
|
||||||
state_manager.update_state(game_id, state)
|
|
||||||
|
|
||||||
# Check if defensive team is AI
|
|
||||||
if state.is_fielding_team_ai():
|
|
||||||
will_throw = await ai_opponent.decide_uncapped_defensive_throw(
|
|
||||||
state, pending
|
|
||||||
)
|
|
||||||
await self.submit_uncapped_defensive_throw(game_id, will_throw)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._emit_decision_required(
|
|
||||||
game_id=game_id,
|
|
||||||
state=state,
|
|
||||||
phase="awaiting_uncapped_defensive_throw",
|
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
|
||||||
data={
|
|
||||||
"lead_runner_base": pending.lead_runner_base,
|
|
||||||
"lead_target_base": pending.lead_target_base,
|
|
||||||
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
|
||||||
"hit_location": pending.hit_location,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pending.lead_advance = advance
|
||||||
|
|
||||||
|
if not advance:
|
||||||
|
# Lead runner declines → fallback to standard advancement, finalize
|
||||||
|
ab_roll = state.pending_manual_roll
|
||||||
|
if not ab_roll:
|
||||||
|
raise ValueError("No pending manual roll found")
|
||||||
|
|
||||||
|
result = self._build_uncapped_fallback_result(state, pending, ab_roll)
|
||||||
|
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Lead runner advances → ask defensive team about throwing
|
||||||
|
state.decision_phase = "awaiting_uncapped_defensive_throw"
|
||||||
|
state.pending_decision = "uncapped_defensive_throw"
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
|
# Check if defensive team is AI
|
||||||
|
if state.is_fielding_team_ai():
|
||||||
|
will_throw = await ai_opponent.decide_uncapped_defensive_throw(
|
||||||
|
state, pending
|
||||||
|
)
|
||||||
|
await self.submit_uncapped_defensive_throw(game_id, will_throw)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._emit_decision_required(
|
||||||
|
game_id=game_id,
|
||||||
|
state=state,
|
||||||
|
phase="awaiting_uncapped_defensive_throw",
|
||||||
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
|
"lead_runner_base": pending.lead_runner_base,
|
||||||
|
"lead_target_base": pending.lead_target_base,
|
||||||
|
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
||||||
|
"hit_location": pending.hit_location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_uncapped_defensive_throw(
|
async def submit_uncapped_defensive_throw(
|
||||||
self, game_id: UUID, will_throw: bool
|
self, game_id: UUID, will_throw: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -1351,96 +1356,98 @@ class GameEngine:
|
|||||||
If NO: lead runner safe, standard advancement, finalize.
|
If NO: lead runner safe, standard advancement, finalize.
|
||||||
If YES and trail runner exists: transition to awaiting_uncapped_trail_advance.
|
If YES and trail runner exists: transition to awaiting_uncapped_trail_advance.
|
||||||
If YES and no trail: roll d20, transition to awaiting_uncapped_safe_out.
|
If YES and no trail: roll d20, transition to awaiting_uncapped_safe_out.
|
||||||
|
|
||||||
|
Caller must hold the game lock (acquired by WebSocket handler).
|
||||||
"""
|
"""
|
||||||
async with state_manager.game_lock(game_id):
|
state = state_manager.get_state(game_id)
|
||||||
state = state_manager.get_state(game_id)
|
if not state:
|
||||||
if not state:
|
raise ValueError(f"Game {game_id} not found")
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
pending = state.pending_uncapped_hit
|
pending = state.pending_uncapped_hit
|
||||||
if not pending:
|
if not pending:
|
||||||
raise ValueError("No pending uncapped hit")
|
raise ValueError("No pending uncapped hit")
|
||||||
|
|
||||||
if state.decision_phase != "awaiting_uncapped_defensive_throw":
|
if state.decision_phase != "awaiting_uncapped_defensive_throw":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Wrong phase: expected awaiting_uncapped_defensive_throw, "
|
f"Wrong phase: expected awaiting_uncapped_defensive_throw, "
|
||||||
f"got {state.decision_phase}"
|
f"got {state.decision_phase}"
|
||||||
)
|
)
|
||||||
|
|
||||||
pending.defensive_throw = will_throw
|
pending.defensive_throw = will_throw
|
||||||
|
|
||||||
if not will_throw:
|
if not will_throw:
|
||||||
# Defense declines throw → lead runner advances safely, finalize
|
# Defense declines throw → lead runner advances safely, finalize
|
||||||
ab_roll = state.pending_manual_roll
|
ab_roll = state.pending_manual_roll
|
||||||
if not ab_roll:
|
if not ab_roll:
|
||||||
raise ValueError("No pending manual roll found")
|
raise ValueError("No pending manual roll found")
|
||||||
|
|
||||||
result = self._build_uncapped_no_throw_result(state, pending, ab_roll)
|
result = self._build_uncapped_no_throw_result(state, pending, ab_roll)
|
||||||
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
await self._finalize_uncapped_hit(state, pending, ab_roll, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Defense throws → check for trail runner
|
||||||
|
has_trail = pending.trail_runner_base is not None
|
||||||
|
|
||||||
|
if not has_trail:
|
||||||
|
# No trail runner → roll d20 for lead runner speed check
|
||||||
|
d20 = dice_system.roll_d20(
|
||||||
|
league_id=state.league_id,
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=state.get_fielding_team_id(),
|
||||||
|
player_id=None,
|
||||||
|
)
|
||||||
|
pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else d20
|
||||||
|
pending.speed_check_runner = "lead"
|
||||||
|
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||||
|
state.pending_decision = "uncapped_safe_out"
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
|
# Check if offensive team is AI
|
||||||
|
if state.is_batting_team_ai():
|
||||||
|
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||||
|
await self.submit_uncapped_safe_out(game_id, result)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Defense throws → check for trail runner
|
await self._emit_decision_required(
|
||||||
has_trail = pending.trail_runner_base is not None
|
game_id=game_id,
|
||||||
|
state=state,
|
||||||
|
phase="awaiting_uncapped_safe_out",
|
||||||
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
|
"d20_roll": d20.roll if hasattr(d20, 'roll') else d20,
|
||||||
|
"runner": "lead",
|
||||||
|
"runner_base": pending.lead_runner_base,
|
||||||
|
"target_base": pending.lead_target_base,
|
||||||
|
"runner_lineup_id": pending.lead_runner_lineup_id,
|
||||||
|
"hit_location": pending.hit_location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Trail runner exists → ask offensive about trail advance
|
||||||
|
state.decision_phase = "awaiting_uncapped_trail_advance"
|
||||||
|
state.pending_decision = "uncapped_trail_advance"
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
if not has_trail:
|
# Check if offensive team is AI
|
||||||
# No trail runner → roll d20 for lead runner speed check
|
if state.is_batting_team_ai():
|
||||||
d20 = dice_system.roll_d20(
|
advance = await ai_opponent.decide_uncapped_trail_advance(
|
||||||
game_id=game_id,
|
state, pending
|
||||||
team_id=state.get_fielding_team_id(),
|
|
||||||
player_id=None,
|
|
||||||
)
|
)
|
||||||
pending.speed_check_d20 = d20
|
await self.submit_uncapped_trail_advance(game_id, advance)
|
||||||
pending.speed_check_runner = "lead"
|
return
|
||||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
|
||||||
state.pending_decision = "uncapped_safe_out"
|
|
||||||
state_manager.update_state(game_id, state)
|
|
||||||
|
|
||||||
# Check if offensive team is AI
|
await self._emit_decision_required(
|
||||||
if state.is_batting_team_ai():
|
game_id=game_id,
|
||||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
state=state,
|
||||||
await self.submit_uncapped_safe_out(game_id, result)
|
phase="awaiting_uncapped_trail_advance",
|
||||||
return
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
await self._emit_decision_required(
|
"trail_runner_base": pending.trail_runner_base,
|
||||||
game_id=game_id,
|
"trail_target_base": pending.trail_target_base,
|
||||||
state=state,
|
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
||||||
phase="awaiting_uncapped_safe_out",
|
"hit_location": pending.hit_location,
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
},
|
||||||
data={
|
)
|
||||||
"d20_roll": d20,
|
|
||||||
"runner": "lead",
|
|
||||||
"runner_base": pending.lead_runner_base,
|
|
||||||
"target_base": pending.lead_target_base,
|
|
||||||
"runner_lineup_id": pending.lead_runner_lineup_id,
|
|
||||||
"hit_location": pending.hit_location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Trail runner exists → ask offensive about trail advance
|
|
||||||
state.decision_phase = "awaiting_uncapped_trail_advance"
|
|
||||||
state.pending_decision = "uncapped_trail_advance"
|
|
||||||
state_manager.update_state(game_id, state)
|
|
||||||
|
|
||||||
# Check if offensive team is AI
|
|
||||||
if state.is_batting_team_ai():
|
|
||||||
advance = await ai_opponent.decide_uncapped_trail_advance(
|
|
||||||
state, pending
|
|
||||||
)
|
|
||||||
await self.submit_uncapped_trail_advance(game_id, advance)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._emit_decision_required(
|
|
||||||
game_id=game_id,
|
|
||||||
state=state,
|
|
||||||
phase="awaiting_uncapped_trail_advance",
|
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
|
||||||
data={
|
|
||||||
"trail_runner_base": pending.trail_runner_base,
|
|
||||||
"trail_target_base": pending.trail_target_base,
|
|
||||||
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
|
||||||
"hit_location": pending.hit_location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def submit_uncapped_trail_advance(
|
async def submit_uncapped_trail_advance(
|
||||||
self, game_id: UUID, advance: bool
|
self, game_id: UUID, advance: bool
|
||||||
@ -1450,137 +1457,39 @@ class GameEngine:
|
|||||||
|
|
||||||
If NO: roll d20 for lead runner only, transition to awaiting_uncapped_safe_out.
|
If NO: roll d20 for lead runner only, transition to awaiting_uncapped_safe_out.
|
||||||
If YES: transition to awaiting_uncapped_throw_target.
|
If YES: transition to awaiting_uncapped_throw_target.
|
||||||
|
|
||||||
|
Caller must hold the game lock (acquired by WebSocket handler).
|
||||||
"""
|
"""
|
||||||
async with state_manager.game_lock(game_id):
|
state = state_manager.get_state(game_id)
|
||||||
state = state_manager.get_state(game_id)
|
if not state:
|
||||||
if not state:
|
raise ValueError(f"Game {game_id} not found")
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
pending = state.pending_uncapped_hit
|
pending = state.pending_uncapped_hit
|
||||||
if not pending:
|
if not pending:
|
||||||
raise ValueError("No pending uncapped hit")
|
raise ValueError("No pending uncapped hit")
|
||||||
|
|
||||||
if state.decision_phase != "awaiting_uncapped_trail_advance":
|
if state.decision_phase != "awaiting_uncapped_trail_advance":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Wrong phase: expected awaiting_uncapped_trail_advance, "
|
f"Wrong phase: expected awaiting_uncapped_trail_advance, "
|
||||||
f"got {state.decision_phase}"
|
f"got {state.decision_phase}"
|
||||||
)
|
)
|
||||||
|
|
||||||
pending.trail_advance = advance
|
pending.trail_advance = advance
|
||||||
|
|
||||||
if not advance:
|
if not advance:
|
||||||
# Trail declines → roll d20 for lead runner
|
# Trail declines → roll d20 for lead runner
|
||||||
d20 = dice_system.roll_d20(
|
|
||||||
game_id=game_id,
|
|
||||||
team_id=state.get_fielding_team_id(),
|
|
||||||
player_id=None,
|
|
||||||
)
|
|
||||||
pending.speed_check_d20 = d20
|
|
||||||
pending.speed_check_runner = "lead"
|
|
||||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
|
||||||
state.pending_decision = "uncapped_safe_out"
|
|
||||||
state_manager.update_state(game_id, state)
|
|
||||||
|
|
||||||
if state.is_batting_team_ai():
|
|
||||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
|
||||||
await self.submit_uncapped_safe_out(game_id, result)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._emit_decision_required(
|
|
||||||
game_id=game_id,
|
|
||||||
state=state,
|
|
||||||
phase="awaiting_uncapped_safe_out",
|
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
|
||||||
data={
|
|
||||||
"d20_roll": d20,
|
|
||||||
"runner": "lead",
|
|
||||||
"runner_base": pending.lead_runner_base,
|
|
||||||
"target_base": pending.lead_target_base,
|
|
||||||
"runner_lineup_id": pending.lead_runner_lineup_id,
|
|
||||||
"hit_location": pending.hit_location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Both runners advance → defense picks throw target
|
|
||||||
state.decision_phase = "awaiting_uncapped_throw_target"
|
|
||||||
state.pending_decision = "uncapped_throw_target"
|
|
||||||
state_manager.update_state(game_id, state)
|
|
||||||
|
|
||||||
if state.is_fielding_team_ai():
|
|
||||||
target = await ai_opponent.decide_uncapped_throw_target(
|
|
||||||
state, pending
|
|
||||||
)
|
|
||||||
await self.submit_uncapped_throw_target(game_id, target)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._emit_decision_required(
|
|
||||||
game_id=game_id,
|
|
||||||
state=state,
|
|
||||||
phase="awaiting_uncapped_throw_target",
|
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
|
||||||
data={
|
|
||||||
"lead_runner_base": pending.lead_runner_base,
|
|
||||||
"lead_target_base": pending.lead_target_base,
|
|
||||||
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
|
||||||
"trail_runner_base": pending.trail_runner_base,
|
|
||||||
"trail_target_base": pending.trail_target_base,
|
|
||||||
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
|
||||||
"hit_location": pending.hit_location,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def submit_uncapped_throw_target(
|
|
||||||
self, game_id: UUID, target: str
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Submit defensive decision: throw for lead or trail runner?
|
|
||||||
|
|
||||||
LEAD: trail auto-advances, roll d20 for lead → awaiting_uncapped_safe_out.
|
|
||||||
TRAIL: lead auto-advances, roll d20 for trail → awaiting_uncapped_safe_out.
|
|
||||||
"""
|
|
||||||
async with state_manager.game_lock(game_id):
|
|
||||||
state = state_manager.get_state(game_id)
|
|
||||||
if not state:
|
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
pending = state.pending_uncapped_hit
|
|
||||||
if not pending:
|
|
||||||
raise ValueError("No pending uncapped hit")
|
|
||||||
|
|
||||||
if state.decision_phase != "awaiting_uncapped_throw_target":
|
|
||||||
raise ValueError(
|
|
||||||
f"Wrong phase: expected awaiting_uncapped_throw_target, "
|
|
||||||
f"got {state.decision_phase}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if target not in ("lead", "trail"):
|
|
||||||
raise ValueError(f"throw_target must be 'lead' or 'trail', got '{target}'")
|
|
||||||
|
|
||||||
pending.throw_target = target
|
|
||||||
|
|
||||||
# Roll d20 for the targeted runner
|
|
||||||
d20 = dice_system.roll_d20(
|
d20 = dice_system.roll_d20(
|
||||||
|
league_id=state.league_id,
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
team_id=state.get_fielding_team_id(),
|
team_id=state.get_fielding_team_id(),
|
||||||
player_id=None,
|
player_id=None,
|
||||||
)
|
)
|
||||||
pending.speed_check_d20 = d20
|
pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else d20
|
||||||
pending.speed_check_runner = target
|
pending.speed_check_runner = "lead"
|
||||||
|
|
||||||
state.decision_phase = "awaiting_uncapped_safe_out"
|
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||||
state.pending_decision = "uncapped_safe_out"
|
state.pending_decision = "uncapped_safe_out"
|
||||||
state_manager.update_state(game_id, state)
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
# Determine which runner info to send
|
|
||||||
if target == "lead":
|
|
||||||
runner_base = pending.lead_runner_base
|
|
||||||
target_base = pending.lead_target_base
|
|
||||||
runner_lid = pending.lead_runner_lineup_id
|
|
||||||
else:
|
|
||||||
runner_base = pending.trail_runner_base
|
|
||||||
target_base = pending.trail_target_base
|
|
||||||
runner_lid = pending.trail_runner_lineup_id
|
|
||||||
|
|
||||||
if state.is_batting_team_ai():
|
if state.is_batting_team_ai():
|
||||||
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||||
await self.submit_uncapped_safe_out(game_id, result)
|
await self.submit_uncapped_safe_out(game_id, result)
|
||||||
@ -1592,14 +1501,116 @@ class GameEngine:
|
|||||||
phase="awaiting_uncapped_safe_out",
|
phase="awaiting_uncapped_safe_out",
|
||||||
timeout_seconds=self.DECISION_TIMEOUT,
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
data={
|
data={
|
||||||
"d20_roll": d20,
|
"d20_roll": d20.roll if hasattr(d20, 'roll') else d20,
|
||||||
"runner": target,
|
"runner": "lead",
|
||||||
"runner_base": runner_base,
|
"runner_base": pending.lead_runner_base,
|
||||||
"target_base": target_base,
|
"target_base": pending.lead_target_base,
|
||||||
"runner_lineup_id": runner_lid,
|
"runner_lineup_id": pending.lead_runner_lineup_id,
|
||||||
"hit_location": pending.hit_location,
|
"hit_location": pending.hit_location,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Both runners advance → defense picks throw target
|
||||||
|
state.decision_phase = "awaiting_uncapped_throw_target"
|
||||||
|
state.pending_decision = "uncapped_throw_target"
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
|
if state.is_fielding_team_ai():
|
||||||
|
target = await ai_opponent.decide_uncapped_throw_target(
|
||||||
|
state, pending
|
||||||
|
)
|
||||||
|
await self.submit_uncapped_throw_target(game_id, target)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._emit_decision_required(
|
||||||
|
game_id=game_id,
|
||||||
|
state=state,
|
||||||
|
phase="awaiting_uncapped_throw_target",
|
||||||
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
|
"lead_runner_base": pending.lead_runner_base,
|
||||||
|
"lead_target_base": pending.lead_target_base,
|
||||||
|
"lead_runner_lineup_id": pending.lead_runner_lineup_id,
|
||||||
|
"trail_runner_base": pending.trail_runner_base,
|
||||||
|
"trail_target_base": pending.trail_target_base,
|
||||||
|
"trail_runner_lineup_id": pending.trail_runner_lineup_id,
|
||||||
|
"hit_location": pending.hit_location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def submit_uncapped_throw_target(
|
||||||
|
self, game_id: UUID, target: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Submit defensive decision: throw for lead or trail runner?
|
||||||
|
|
||||||
|
LEAD: trail auto-advances, roll d20 for lead → awaiting_uncapped_safe_out.
|
||||||
|
TRAIL: lead auto-advances, roll d20 for trail → awaiting_uncapped_safe_out.
|
||||||
|
|
||||||
|
Caller must hold the game lock (acquired by WebSocket handler).
|
||||||
|
"""
|
||||||
|
state = state_manager.get_state(game_id)
|
||||||
|
if not state:
|
||||||
|
raise ValueError(f"Game {game_id} not found")
|
||||||
|
|
||||||
|
pending = state.pending_uncapped_hit
|
||||||
|
if not pending:
|
||||||
|
raise ValueError("No pending uncapped hit")
|
||||||
|
|
||||||
|
if state.decision_phase != "awaiting_uncapped_throw_target":
|
||||||
|
raise ValueError(
|
||||||
|
f"Wrong phase: expected awaiting_uncapped_throw_target, "
|
||||||
|
f"got {state.decision_phase}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if target not in ("lead", "trail"):
|
||||||
|
raise ValueError(f"throw_target must be 'lead' or 'trail', got '{target}'")
|
||||||
|
|
||||||
|
pending.throw_target = target
|
||||||
|
|
||||||
|
# Roll d20 for the targeted runner
|
||||||
|
d20 = dice_system.roll_d20(
|
||||||
|
league_id=state.league_id,
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=state.get_fielding_team_id(),
|
||||||
|
player_id=None,
|
||||||
|
)
|
||||||
|
pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else d20
|
||||||
|
pending.speed_check_runner = target
|
||||||
|
|
||||||
|
state.decision_phase = "awaiting_uncapped_safe_out"
|
||||||
|
state.pending_decision = "uncapped_safe_out"
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
|
||||||
|
# Determine which runner info to send
|
||||||
|
if target == "lead":
|
||||||
|
runner_base = pending.lead_runner_base
|
||||||
|
target_base = pending.lead_target_base
|
||||||
|
runner_lid = pending.lead_runner_lineup_id
|
||||||
|
else:
|
||||||
|
runner_base = pending.trail_runner_base
|
||||||
|
target_base = pending.trail_target_base
|
||||||
|
runner_lid = pending.trail_runner_lineup_id
|
||||||
|
|
||||||
|
if state.is_batting_team_ai():
|
||||||
|
result = await ai_opponent.decide_uncapped_safe_out(state, pending)
|
||||||
|
await self.submit_uncapped_safe_out(game_id, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._emit_decision_required(
|
||||||
|
game_id=game_id,
|
||||||
|
state=state,
|
||||||
|
phase="awaiting_uncapped_safe_out",
|
||||||
|
timeout_seconds=self.DECISION_TIMEOUT,
|
||||||
|
data={
|
||||||
|
"d20_roll": d20.roll if hasattr(d20, 'roll') else d20,
|
||||||
|
"runner": target,
|
||||||
|
"runner_base": runner_base,
|
||||||
|
"target_base": target_base,
|
||||||
|
"runner_lineup_id": runner_lid,
|
||||||
|
"hit_location": pending.hit_location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_uncapped_safe_out(
|
async def submit_uncapped_safe_out(
|
||||||
self, game_id: UUID, result: str
|
self, game_id: UUID, result: str
|
||||||
@ -1609,36 +1620,37 @@ class GameEngine:
|
|||||||
|
|
||||||
Finalizes the uncapped hit play with the accumulated decisions.
|
Finalizes the uncapped hit play with the accumulated decisions.
|
||||||
|
|
||||||
|
Caller must hold the game lock (acquired by WebSocket handler).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_id: Game ID
|
game_id: Game ID
|
||||||
result: "safe" or "out"
|
result: "safe" or "out"
|
||||||
"""
|
"""
|
||||||
async with state_manager.game_lock(game_id):
|
state = state_manager.get_state(game_id)
|
||||||
state = state_manager.get_state(game_id)
|
if not state:
|
||||||
if not state:
|
raise ValueError(f"Game {game_id} not found")
|
||||||
raise ValueError(f"Game {game_id} not found")
|
|
||||||
|
|
||||||
pending = state.pending_uncapped_hit
|
pending = state.pending_uncapped_hit
|
||||||
if not pending:
|
if not pending:
|
||||||
raise ValueError("No pending uncapped hit")
|
raise ValueError("No pending uncapped hit")
|
||||||
|
|
||||||
if state.decision_phase != "awaiting_uncapped_safe_out":
|
if state.decision_phase != "awaiting_uncapped_safe_out":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Wrong phase: expected awaiting_uncapped_safe_out, "
|
f"Wrong phase: expected awaiting_uncapped_safe_out, "
|
||||||
f"got {state.decision_phase}"
|
f"got {state.decision_phase}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if result not in ("safe", "out"):
|
if result not in ("safe", "out"):
|
||||||
raise ValueError(f"result must be 'safe' or 'out', got '{result}'")
|
raise ValueError(f"result must be 'safe' or 'out', got '{result}'")
|
||||||
|
|
||||||
pending.speed_check_result = result
|
pending.speed_check_result = result
|
||||||
|
|
||||||
ab_roll = state.pending_manual_roll
|
ab_roll = state.pending_manual_roll
|
||||||
if not ab_roll:
|
if not ab_roll:
|
||||||
raise ValueError("No pending manual roll found")
|
raise ValueError("No pending manual roll found")
|
||||||
|
|
||||||
play_result = self._build_uncapped_play_result(state, pending, ab_roll)
|
play_result = self._build_uncapped_play_result(state, pending, ab_roll)
|
||||||
await self._finalize_uncapped_hit(state, pending, ab_roll, play_result)
|
await self._finalize_uncapped_hit(state, pending, ab_roll, play_result)
|
||||||
|
|
||||||
def _build_uncapped_fallback_result(
|
def _build_uncapped_fallback_result(
|
||||||
self,
|
self,
|
||||||
@ -1860,25 +1872,27 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
outs_recorded += 1
|
outs_recorded += 1
|
||||||
|
|
||||||
# If trail runner is R1 and R1 attempted advance, batter-runner
|
# Determine batter's final base position
|
||||||
# auto-advances regardless of R1's outcome
|
|
||||||
batter_base = pending.batter_base
|
batter_base = pending.batter_base
|
||||||
if (
|
if pending.trail_runner_base == 0:
|
||||||
|
# Trail IS the batter-runner
|
||||||
|
if checked_runner == "trail":
|
||||||
|
# Defense threw at batter-runner
|
||||||
|
if is_safe and pending.trail_target_base:
|
||||||
|
batter_base = pending.trail_target_base
|
||||||
|
elif not is_safe:
|
||||||
|
batter_base = None # batter is out (handled by outs_recorded)
|
||||||
|
elif pending.trail_advance and pending.throw_target is not None:
|
||||||
|
# Batter attempted advance, defense threw at lead instead
|
||||||
|
# Batter auto-advances to target (non-targeted runner is safe)
|
||||||
|
batter_base = pending.trail_target_base
|
||||||
|
elif (
|
||||||
pending.trail_runner_base == 1
|
pending.trail_runner_base == 1
|
||||||
and pending.trail_advance
|
and pending.trail_advance
|
||||||
and pending.trail_runner_base is not None
|
|
||||||
):
|
):
|
||||||
|
# Trail is R1 (not batter) and R1 attempted advance
|
||||||
# Batter auto-advances one extra base
|
# Batter auto-advances one extra base
|
||||||
batter_base = min(pending.batter_base + 1, 3)
|
batter_base = min(pending.batter_base + 1, 3)
|
||||||
elif (
|
|
||||||
pending.trail_runner_base == 0
|
|
||||||
and checked_runner == "trail"
|
|
||||||
):
|
|
||||||
# Trail IS the batter - result already handled above
|
|
||||||
if is_safe and pending.trail_target_base:
|
|
||||||
batter_base = pending.trail_target_base
|
|
||||||
elif not is_safe:
|
|
||||||
batter_base = None # batter is out (handled by outs_recorded)
|
|
||||||
|
|
||||||
outcome = (
|
outcome = (
|
||||||
PlayOutcome.SINGLE_UNCAPPED
|
PlayOutcome.SINGLE_UNCAPPED
|
||||||
@ -1922,8 +1936,9 @@ class GameEngine:
|
|||||||
|
|
||||||
Clears pending state, calls _finalize_play for DB write and state update.
|
Clears pending state, calls _finalize_play for DB write and state update.
|
||||||
"""
|
"""
|
||||||
# Clear pending uncapped hit
|
# Clear pending uncapped hit and the manual roll (one-time use)
|
||||||
state.pending_uncapped_hit = None
|
state.pending_uncapped_hit = None
|
||||||
|
state.pending_manual_roll = None
|
||||||
state.pending_decision = None
|
state.pending_decision = None
|
||||||
state.decision_phase = "idle"
|
state.decision_phase = "idle"
|
||||||
|
|
||||||
@ -2417,11 +2432,42 @@ class GameEngine:
|
|||||||
# Note: We don't delete dice rolls from the rolls table - they're kept for auditing
|
# Note: We don't delete dice rolls from the rolls table - they're kept for auditing
|
||||||
# and don't affect game state reconstruction
|
# and don't affect game state reconstruction
|
||||||
|
|
||||||
# 5. Clear in-memory roll tracking for this game
|
# 5. Recalculate scores from remaining plays and update games table.
|
||||||
|
# recover_game() reads scores from the games table, which are stale after
|
||||||
|
# play deletion. We must update them before recovery.
|
||||||
|
remaining_plays = await self.db_ops.get_plays(game_id)
|
||||||
|
home_score = 0
|
||||||
|
away_score = 0
|
||||||
|
for play in remaining_plays:
|
||||||
|
if play.half == "top":
|
||||||
|
away_score += play.runs_scored
|
||||||
|
else:
|
||||||
|
home_score += play.runs_scored
|
||||||
|
|
||||||
|
if remaining_plays:
|
||||||
|
last_play = remaining_plays[-1]
|
||||||
|
current_inning = last_play.inning
|
||||||
|
current_half = last_play.half
|
||||||
|
else:
|
||||||
|
current_inning = 1
|
||||||
|
current_half = "top"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Recalculated scores after rollback: home={home_score}, away={away_score}"
|
||||||
|
)
|
||||||
|
await self.db_ops.update_game_state(
|
||||||
|
game_id=game_id,
|
||||||
|
inning=current_inning,
|
||||||
|
half=current_half,
|
||||||
|
home_score=home_score,
|
||||||
|
away_score=away_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Clear in-memory roll tracking for this game
|
||||||
if game_id in self._rolls_this_inning:
|
if game_id in self._rolls_this_inning:
|
||||||
del self._rolls_this_inning[game_id]
|
del self._rolls_this_inning[game_id]
|
||||||
|
|
||||||
# 6. Recover game state by replaying remaining plays
|
# 7. Recover game state by replaying remaining plays
|
||||||
logger.info(f"Recovering game state for {game_id}")
|
logger.info(f"Recovering game state for {game_id}")
|
||||||
new_state = await state_manager.recover_game(game_id)
|
new_state = await state_manager.recover_game(game_id)
|
||||||
|
|
||||||
|
|||||||
@ -560,9 +560,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
# Using the old state reference would overwrite those updates!
|
# Using the old state reference would overwrite those updates!
|
||||||
state = state_manager.get_state(game_id)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
# Clear pending roll only AFTER successful validation (one-time use)
|
# Clear pending roll only if NOT entering an interactive workflow.
|
||||||
state.pending_manual_roll = None
|
# Uncapped hit and x-check workflows need pending_manual_roll
|
||||||
state_manager.update_state(game_id, state)
|
# to build their final PlayResult when decisions complete.
|
||||||
|
interactive_decision_phases = {
|
||||||
|
"awaiting_x_check_result",
|
||||||
|
"awaiting_uncapped_lead_advance",
|
||||||
|
"awaiting_uncapped_defensive_throw",
|
||||||
|
"awaiting_uncapped_trail_advance",
|
||||||
|
"awaiting_uncapped_throw_target",
|
||||||
|
"awaiting_uncapped_safe_out",
|
||||||
|
}
|
||||||
|
if state.decision_phase not in interactive_decision_phases:
|
||||||
|
state.pending_manual_roll = None
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
except GameValidationError as e:
|
except GameValidationError as e:
|
||||||
# Game engine validation error (e.g., missing hit location)
|
# Game engine validation error (e.g., missing hit location)
|
||||||
await manager.emit_to_user(
|
await manager.emit_to_user(
|
||||||
@ -604,6 +615,30 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if the outcome initiated an interactive workflow (x-check or uncapped hit).
|
||||||
|
# If so, skip play_resolved broadcast - the play isn't actually resolved yet.
|
||||||
|
# The decision_required event and game_state_update will drive the UI instead.
|
||||||
|
interactive_phases = {
|
||||||
|
"awaiting_x_check_result",
|
||||||
|
"awaiting_uncapped_lead_advance",
|
||||||
|
"awaiting_uncapped_defensive_throw",
|
||||||
|
"awaiting_uncapped_trail_advance",
|
||||||
|
"awaiting_uncapped_throw_target",
|
||||||
|
"awaiting_uncapped_safe_out",
|
||||||
|
}
|
||||||
|
updated_state = state_manager.get_state(game_id)
|
||||||
|
if updated_state and updated_state.decision_phase in interactive_phases:
|
||||||
|
logger.info(
|
||||||
|
f"Manual outcome initiated interactive workflow ({updated_state.decision_phase}) "
|
||||||
|
f"for game {game_id} - skipping play_resolved, broadcasting state update"
|
||||||
|
)
|
||||||
|
await manager.broadcast_to_game(
|
||||||
|
str(game_id),
|
||||||
|
"game_state_update",
|
||||||
|
updated_state.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Build play result data
|
# Build play result data
|
||||||
play_result_data = {
|
play_result_data = {
|
||||||
"game_id": str(game_id),
|
"game_id": str(game_id),
|
||||||
@ -2119,18 +2154,22 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
|
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
|
||||||
return
|
return
|
||||||
|
|
||||||
await game_engine.submit_uncapped_lead_advance(game_id, advance)
|
async with state_manager.game_lock(game_id):
|
||||||
|
await game_engine.submit_uncapped_lead_advance(game_id, advance)
|
||||||
|
|
||||||
# Broadcast updated state
|
# Broadcast updated state
|
||||||
state = state_manager.get_state(game_id)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
await manager.broadcast_to_game(
|
await manager.broadcast_to_game(
|
||||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Uncapped lead advance validation failed: {e}")
|
logger.warning(f"Uncapped lead advance validation failed: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": str(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:
|
except DatabaseError as e:
|
||||||
logger.error(f"Database error in submit_uncapped_lead_advance: {e}")
|
logger.error(f"Database error in submit_uncapped_lead_advance: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||||
@ -2177,17 +2216,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'will_throw' (bool)"})
|
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'will_throw' (bool)"})
|
||||||
return
|
return
|
||||||
|
|
||||||
await game_engine.submit_uncapped_defensive_throw(game_id, will_throw)
|
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)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
await manager.broadcast_to_game(
|
await manager.broadcast_to_game(
|
||||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Uncapped defensive throw validation failed: {e}")
|
logger.warning(f"Uncapped defensive throw validation failed: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": str(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:
|
except DatabaseError as e:
|
||||||
logger.error(f"Database error in submit_uncapped_defensive_throw: {e}")
|
logger.error(f"Database error in submit_uncapped_defensive_throw: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||||
@ -2234,17 +2277,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
|
await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"})
|
||||||
return
|
return
|
||||||
|
|
||||||
await game_engine.submit_uncapped_trail_advance(game_id, advance)
|
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)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
await manager.broadcast_to_game(
|
await manager.broadcast_to_game(
|
||||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Uncapped trail advance validation failed: {e}")
|
logger.warning(f"Uncapped trail advance validation failed: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": str(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:
|
except DatabaseError as e:
|
||||||
logger.error(f"Database error in submit_uncapped_trail_advance: {e}")
|
logger.error(f"Database error in submit_uncapped_trail_advance: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||||
@ -2292,17 +2339,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await game_engine.submit_uncapped_throw_target(game_id, target)
|
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)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
await manager.broadcast_to_game(
|
await manager.broadcast_to_game(
|
||||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Uncapped throw target validation failed: {e}")
|
logger.warning(f"Uncapped throw target validation failed: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": str(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:
|
except DatabaseError as e:
|
||||||
logger.error(f"Database error in submit_uncapped_throw_target: {e}")
|
logger.error(f"Database error in submit_uncapped_throw_target: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||||
@ -2350,17 +2401,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await game_engine.submit_uncapped_safe_out(game_id, result)
|
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)
|
state = state_manager.get_state(game_id)
|
||||||
if state:
|
if state:
|
||||||
await manager.broadcast_to_game(
|
await manager.broadcast_to_game(
|
||||||
str(game_id), "game_state_update", state.model_dump(mode="json")
|
str(game_id), "game_state_update", state.model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Uncapped safe/out validation failed: {e}")
|
logger.warning(f"Uncapped safe/out validation failed: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": str(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:
|
except DatabaseError as e:
|
||||||
logger.error(f"Database error in submit_uncapped_safe_out: {e}")
|
logger.error(f"Database error in submit_uncapped_safe_out: {e}")
|
||||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||||
|
|||||||
@ -113,9 +113,15 @@
|
|||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
:dice-color="diceColor"
|
:dice-color="diceColor"
|
||||||
|
:user-team-id="myTeamId"
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
|
@submit-uncapped-lead-advance="handleUncappedLeadAdvance"
|
||||||
|
@submit-uncapped-defensive-throw="handleUncappedDefensiveThrow"
|
||||||
|
@submit-uncapped-trail-advance="handleUncappedTrailAdvance"
|
||||||
|
@submit-uncapped-throw-target="handleUncappedThrowTarget"
|
||||||
|
@submit-uncapped-safe-out="handleUncappedSafeOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Play-by-Play Feed (below gameplay on mobile) -->
|
<!-- Play-by-Play Feed (below gameplay on mobile) -->
|
||||||
@ -183,9 +189,15 @@
|
|||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
:dice-color="diceColor"
|
:dice-color="diceColor"
|
||||||
|
:user-team-id="myTeamId"
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
|
@submit-uncapped-lead-advance="handleUncappedLeadAdvance"
|
||||||
|
@submit-uncapped-defensive-throw="handleUncappedDefensiveThrow"
|
||||||
|
@submit-uncapped-trail-advance="handleUncappedTrailAdvance"
|
||||||
|
@submit-uncapped-throw-target="handleUncappedThrowTarget"
|
||||||
|
@submit-uncapped-safe-out="handleUncappedSafeOut"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -447,18 +459,27 @@ const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('conne
|
|||||||
|
|
||||||
// Determine which team the user controls
|
// Determine which team the user controls
|
||||||
// For demo/testing: user controls whichever team needs to act
|
// For demo/testing: user controls whichever team needs to act
|
||||||
|
const DEFENSIVE_PHASES = [
|
||||||
|
'awaiting_defensive',
|
||||||
|
'awaiting_uncapped_defensive_throw',
|
||||||
|
'awaiting_uncapped_throw_target',
|
||||||
|
]
|
||||||
|
|
||||||
const myTeamId = computed(() => {
|
const myTeamId = computed(() => {
|
||||||
if (!gameState.value) return null
|
if (!gameState.value) return null
|
||||||
|
|
||||||
|
const phase = gameState.value.decision_phase
|
||||||
|
const isDefensivePhase = DEFENSIVE_PHASES.includes(phase)
|
||||||
|
|
||||||
// Return the team that currently needs to make a decision
|
// Return the team that currently needs to make a decision
|
||||||
if (gameState.value.half === 'top') {
|
if (gameState.value.half === 'top') {
|
||||||
// Top: away bats, home fields
|
// Top: away bats, home fields
|
||||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
return isDefensivePhase
|
||||||
? gameState.value.home_team_id
|
? gameState.value.home_team_id
|
||||||
: gameState.value.away_team_id
|
: gameState.value.away_team_id
|
||||||
} else {
|
} else {
|
||||||
// Bottom: home bats, away fields
|
// Bottom: home bats, away fields
|
||||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
return isDefensivePhase
|
||||||
? gameState.value.away_team_id
|
? gameState.value.away_team_id
|
||||||
: gameState.value.home_team_id
|
: gameState.value.home_team_id
|
||||||
}
|
}
|
||||||
@ -576,6 +597,11 @@ const showGameplay = computed(() => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show for uncapped hit decisions (both teams see the wizard)
|
||||||
|
if (gameStore.needsUncappedDecision) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return gameState.value?.status === 'active' &&
|
return gameState.value?.status === 'active' &&
|
||||||
isMyTurn.value &&
|
isMyTurn.value &&
|
||||||
!needsDefensiveDecision.value &&
|
!needsDefensiveDecision.value &&
|
||||||
@ -656,6 +682,32 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|||||||
gameStore.setPendingStealAttempts(attempts)
|
gameStore.setPendingStealAttempts(attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Methods - Uncapped Hit Decision Tree
|
||||||
|
const handleUncappedLeadAdvance = (advance: boolean) => {
|
||||||
|
console.log('[GamePlay] Uncapped lead advance:', advance)
|
||||||
|
actions.submitUncappedLeadAdvance(advance)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedDefensiveThrow = (willThrow: boolean) => {
|
||||||
|
console.log('[GamePlay] Uncapped defensive throw:', willThrow)
|
||||||
|
actions.submitUncappedDefensiveThrow(willThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedTrailAdvance = (advance: boolean) => {
|
||||||
|
console.log('[GamePlay] Uncapped trail advance:', advance)
|
||||||
|
actions.submitUncappedTrailAdvance(advance)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedThrowTarget = (target: 'lead' | 'trail') => {
|
||||||
|
console.log('[GamePlay] Uncapped throw target:', target)
|
||||||
|
actions.submitUncappedThrowTarget(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedSafeOut = (result: 'safe' | 'out') => {
|
||||||
|
console.log('[GamePlay] Uncapped safe/out:', result)
|
||||||
|
actions.submitUncappedSafeOut(result)
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleHold = (base: number) => {
|
const handleToggleHold = (base: number) => {
|
||||||
defensiveSetup.toggleHold(base)
|
defensiveSetup.toggleHold(base)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- State: Uncapped Hit Pending -->
|
||||||
|
<div v-else-if="workflowState === 'uncapped_hit_pending'" class="state-uncapped-hit">
|
||||||
|
<UncappedHitWizard
|
||||||
|
:phase="currentUncappedPhase!"
|
||||||
|
:data="uncappedHitData"
|
||||||
|
:readonly="!isUncappedInteractive"
|
||||||
|
:pending-uncapped-hit="pendingUncappedHit"
|
||||||
|
@submit-lead-advance="handleUncappedLeadAdvance"
|
||||||
|
@submit-defensive-throw="handleUncappedDefensiveThrow"
|
||||||
|
@submit-trail-advance="handleUncappedTrailAdvance"
|
||||||
|
@submit-throw-target="handleUncappedThrowTarget"
|
||||||
|
@submit-safe-out="handleUncappedSafeOut"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- State: X-Check Result Pending -->
|
<!-- State: X-Check Result Pending -->
|
||||||
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
|
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
|
||||||
<div v-if="!isXCheckInteractive" class="state-message">
|
<div v-if="!isXCheckInteractive" class="state-message">
|
||||||
@ -119,12 +134,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { RollData, PlayResult, PlayOutcome, XCheckData } from '~/types'
|
import type { RollData, PlayResult, PlayOutcome, XCheckData, DecisionPhase, UncappedHitData } from '~/types'
|
||||||
import { useGameStore } from '~/store/game'
|
import { useGameStore } from '~/store/game'
|
||||||
import DiceRoller from './DiceRoller.vue'
|
import DiceRoller from './DiceRoller.vue'
|
||||||
import OutcomeWizard from './OutcomeWizard.vue'
|
import OutcomeWizard from './OutcomeWizard.vue'
|
||||||
import PlayResultDisplay from './PlayResult.vue'
|
import PlayResultDisplay from './PlayResult.vue'
|
||||||
import XCheckWizard from './XCheckWizard.vue'
|
import XCheckWizard from './XCheckWizard.vue'
|
||||||
|
import UncappedHitWizard from './UncappedHitWizard.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gameId: string
|
gameId: string
|
||||||
@ -153,6 +169,11 @@ const emit = defineEmits<{
|
|||||||
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
|
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
|
||||||
dismissResult: []
|
dismissResult: []
|
||||||
submitXCheckResult: [{ resultCode: string; errorResult: string }]
|
submitXCheckResult: [{ resultCode: string; errorResult: string }]
|
||||||
|
submitUncappedLeadAdvance: [advance: boolean]
|
||||||
|
submitUncappedDefensiveThrow: [willThrow: boolean]
|
||||||
|
submitUncappedTrailAdvance: [advance: boolean]
|
||||||
|
submitUncappedThrowTarget: [target: 'lead' | 'trail']
|
||||||
|
submitUncappedSafeOut: [result: 'safe' | 'out']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Store access
|
// Store access
|
||||||
@ -165,6 +186,41 @@ const isSubmitting = ref(false)
|
|||||||
// X-Check data from store
|
// X-Check data from store
|
||||||
const xCheckData = computed(() => gameStore.xCheckData)
|
const xCheckData = computed(() => gameStore.xCheckData)
|
||||||
|
|
||||||
|
// Uncapped hit data from store
|
||||||
|
const uncappedHitData = computed(() => gameStore.uncappedHitData)
|
||||||
|
const pendingUncappedHit = computed(() => {
|
||||||
|
const hit = gameStore.gameState?.pending_uncapped_hit
|
||||||
|
return (hit ?? null) as import('~/types').PendingUncappedHit | null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current uncapped hit phase
|
||||||
|
const currentUncappedPhase = computed<DecisionPhase | null>(() => {
|
||||||
|
if (gameStore.needsUncappedLeadAdvance) return 'awaiting_uncapped_lead_advance'
|
||||||
|
if (gameStore.needsUncappedDefensiveThrow) return 'awaiting_uncapped_defensive_throw'
|
||||||
|
if (gameStore.needsUncappedTrailAdvance) return 'awaiting_uncapped_trail_advance'
|
||||||
|
if (gameStore.needsUncappedThrowTarget) return 'awaiting_uncapped_throw_target'
|
||||||
|
if (gameStore.needsUncappedSafeOut) return 'awaiting_uncapped_safe_out'
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Offensive phases: user's team must be the batting team
|
||||||
|
// Defensive phases: user's team must be the fielding team
|
||||||
|
const UNCAPPED_OFFENSIVE_PHASES: DecisionPhase[] = [
|
||||||
|
'awaiting_uncapped_lead_advance',
|
||||||
|
'awaiting_uncapped_trail_advance',
|
||||||
|
'awaiting_uncapped_safe_out',
|
||||||
|
]
|
||||||
|
|
||||||
|
const isUncappedInteractive = computed(() => {
|
||||||
|
if (!currentUncappedPhase.value || !props.userTeamId || !gameStore.gameState) return false
|
||||||
|
const isOffensivePhase = UNCAPPED_OFFENSIVE_PHASES.includes(currentUncappedPhase.value)
|
||||||
|
if (isOffensivePhase) {
|
||||||
|
return props.userTeamId === gameStore.battingTeamId
|
||||||
|
} else {
|
||||||
|
return props.userTeamId === gameStore.fieldingTeamId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Determine if current user should have interactive mode
|
// Determine if current user should have interactive mode
|
||||||
// Uses active_team_id from x-check data (set by backend to indicate which team should interact)
|
// Uses active_team_id from x-check data (set by backend to indicate which team should interact)
|
||||||
const isXCheckInteractive = computed(() => {
|
const isXCheckInteractive = computed(() => {
|
||||||
@ -174,7 +230,7 @@ const isXCheckInteractive = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Workflow state computation
|
// Workflow state computation
|
||||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'x_check_result_pending'
|
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'uncapped_hit_pending' | 'x_check_result_pending'
|
||||||
|
|
||||||
const workflowState = computed<WorkflowState>(() => {
|
const workflowState = computed<WorkflowState>(() => {
|
||||||
// Show result if we have one
|
// Show result if we have one
|
||||||
@ -182,6 +238,11 @@ const workflowState = computed<WorkflowState>(() => {
|
|||||||
return 'result'
|
return 'result'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show uncapped hit wizard if in any uncapped phase
|
||||||
|
if (gameStore.needsUncappedDecision) {
|
||||||
|
return 'uncapped_hit_pending'
|
||||||
|
}
|
||||||
|
|
||||||
// Show x-check result selection if awaiting
|
// Show x-check result selection if awaiting
|
||||||
if (gameStore.needsXCheckResult && xCheckData.value) {
|
if (gameStore.needsXCheckResult && xCheckData.value) {
|
||||||
return 'x_check_result_pending'
|
return 'x_check_result_pending'
|
||||||
@ -210,6 +271,7 @@ const workflowState = computed<WorkflowState>(() => {
|
|||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
if (error.value) return 'status-error'
|
if (error.value) return 'status-error'
|
||||||
if (workflowState.value === 'result') return 'status-success'
|
if (workflowState.value === 'result') return 'status-success'
|
||||||
|
if (workflowState.value === 'uncapped_hit_pending') return 'status-active'
|
||||||
if (workflowState.value === 'x_check_result_pending') return 'status-active'
|
if (workflowState.value === 'x_check_result_pending') return 'status-active'
|
||||||
if (workflowState.value === 'submitted') return 'status-processing'
|
if (workflowState.value === 'submitted') return 'status-processing'
|
||||||
if (workflowState.value === 'rolled') return 'status-active'
|
if (workflowState.value === 'rolled') return 'status-active'
|
||||||
@ -220,6 +282,9 @@ const statusClass = computed(() => {
|
|||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
if (error.value) return 'Error'
|
if (error.value) return 'Error'
|
||||||
if (workflowState.value === 'result') return 'Play Complete'
|
if (workflowState.value === 'result') return 'Play Complete'
|
||||||
|
if (workflowState.value === 'uncapped_hit_pending') {
|
||||||
|
return isUncappedInteractive.value ? 'Uncapped Hit Decision' : 'Waiting for Decision'
|
||||||
|
}
|
||||||
if (workflowState.value === 'x_check_result_pending') {
|
if (workflowState.value === 'x_check_result_pending') {
|
||||||
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
|
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
|
||||||
}
|
}
|
||||||
@ -258,6 +323,26 @@ const handleDismissResult = () => {
|
|||||||
emit('dismissResult')
|
emit('dismissResult')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUncappedLeadAdvance = (advance: boolean) => {
|
||||||
|
emit('submitUncappedLeadAdvance', advance)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedDefensiveThrow = (willThrow: boolean) => {
|
||||||
|
emit('submitUncappedDefensiveThrow', willThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedTrailAdvance = (advance: boolean) => {
|
||||||
|
emit('submitUncappedTrailAdvance', advance)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedThrowTarget = (target: 'lead' | 'trail') => {
|
||||||
|
emit('submitUncappedThrowTarget', target)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncappedSafeOut = (result: 'safe' | 'out') => {
|
||||||
|
emit('submitUncappedSafeOut', result)
|
||||||
|
}
|
||||||
|
|
||||||
const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => {
|
const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => {
|
||||||
error.value = null
|
error.value = null
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
@ -358,6 +443,11 @@ const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }
|
|||||||
@apply space-y-6;
|
@apply space-y-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* State: Uncapped Hit */
|
||||||
|
.state-uncapped-hit {
|
||||||
|
@apply space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
/* State: X-Check */
|
/* State: X-Check */
|
||||||
.state-x-check {
|
.state-x-check {
|
||||||
@apply space-y-4;
|
@apply space-y-4;
|
||||||
|
|||||||
536
frontend-sba/components/Gameplay/UncappedHitWizard.vue
Normal file
536
frontend-sba/components/Gameplay/UncappedHitWizard.vue
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
<template>
|
||||||
|
<div class="uncapped-wizard" :class="{ 'read-only': readonly }">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="wizard-header">
|
||||||
|
<div class="hit-badge" :class="hitBadgeClass">
|
||||||
|
{{ hitTypeLabel }}
|
||||||
|
</div>
|
||||||
|
<div v-if="hitLocation" class="hit-location">
|
||||||
|
to {{ hitLocation }}
|
||||||
|
</div>
|
||||||
|
<p v-if="readonly" class="waiting-message">
|
||||||
|
Waiting for {{ isDefensivePhase ? 'defense' : 'offense' }} to decide...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-scoring runners info -->
|
||||||
|
<div v-if="autoRunnersDisplay.length > 0" class="auto-runners">
|
||||||
|
<div v-for="(info, idx) in autoRunnersDisplay" :key="idx" class="auto-runner-line">
|
||||||
|
<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ info }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 1: Lead Runner Advance (OFFENSE) -->
|
||||||
|
<div v-if="phase === 'awaiting_uncapped_lead_advance'" class="phase-content">
|
||||||
|
<div class="phase-label offense-label">Offense Decides</div>
|
||||||
|
<div class="runner-info">
|
||||||
|
<span class="runner-name">{{ leadRunnerName }}</span>
|
||||||
|
<span class="runner-movement">{{ baseLabel(leadData?.lead_runner_base) }} → {{ 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,6 +243,90 @@ export function useGameActions(gameId?: string) {
|
|||||||
uiStore.showInfo('Submitting speed check result...', 2000)
|
uiStore.showInfo('Submitting speed check result...', 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uncapped Hit Decision Tree
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit lead runner advance decision (offensive player)
|
||||||
|
*/
|
||||||
|
function submitUncappedLeadAdvance(advance: boolean) {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Submitting uncapped lead advance:', advance)
|
||||||
|
|
||||||
|
socket.value!.emit('submit_uncapped_lead_advance', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
advance,
|
||||||
|
})
|
||||||
|
|
||||||
|
uiStore.showInfo('Submitting lead runner decision...', 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit defensive throw decision (defensive player)
|
||||||
|
*/
|
||||||
|
function submitUncappedDefensiveThrow(willThrow: boolean) {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Submitting uncapped defensive throw:', willThrow)
|
||||||
|
|
||||||
|
socket.value!.emit('submit_uncapped_defensive_throw', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
will_throw: willThrow,
|
||||||
|
})
|
||||||
|
|
||||||
|
uiStore.showInfo('Submitting throw decision...', 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit trail runner advance decision (offensive player)
|
||||||
|
*/
|
||||||
|
function submitUncappedTrailAdvance(advance: boolean) {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Submitting uncapped trail advance:', advance)
|
||||||
|
|
||||||
|
socket.value!.emit('submit_uncapped_trail_advance', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
advance,
|
||||||
|
})
|
||||||
|
|
||||||
|
uiStore.showInfo('Submitting trail runner decision...', 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit throw target selection (defensive player)
|
||||||
|
*/
|
||||||
|
function submitUncappedThrowTarget(target: 'lead' | 'trail') {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Submitting uncapped throw target:', target)
|
||||||
|
|
||||||
|
socket.value!.emit('submit_uncapped_throw_target', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
|
||||||
|
uiStore.showInfo('Submitting throw target...', 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit safe/out result (offensive player)
|
||||||
|
*/
|
||||||
|
function submitUncappedSafeOut(result: 'safe' | 'out') {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Submitting uncapped safe/out:', result)
|
||||||
|
|
||||||
|
socket.value!.emit('submit_uncapped_safe_out', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
|
||||||
|
uiStore.showInfo('Submitting speed check result...', 2000)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Substitution Actions
|
// Substitution Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -449,6 +533,13 @@ export function useGameActions(gameId?: string) {
|
|||||||
submitDecideThrow,
|
submitDecideThrow,
|
||||||
submitDecideResult,
|
submitDecideResult,
|
||||||
|
|
||||||
|
// Uncapped hit decision tree
|
||||||
|
submitUncappedLeadAdvance,
|
||||||
|
submitUncappedDefensiveThrow,
|
||||||
|
submitUncappedTrailAdvance,
|
||||||
|
submitUncappedThrowTarget,
|
||||||
|
submitUncappedSafeOut,
|
||||||
|
|
||||||
// Substitutions
|
// Substitutions
|
||||||
submitSubstitution,
|
submitSubstitution,
|
||||||
|
|
||||||
|
|||||||
@ -470,6 +470,16 @@ export function useWebSocket() {
|
|||||||
console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500))
|
console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500))
|
||||||
gameStore.setGameState(gameState)
|
gameStore.setGameState(gameState)
|
||||||
console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter)
|
console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter)
|
||||||
|
|
||||||
|
// Clear interactive workflow data when the workflow has completed
|
||||||
|
if (!gameState.pending_uncapped_hit && gameStore.uncappedHitData) {
|
||||||
|
gameStore.clearUncappedHitData()
|
||||||
|
gameStore.clearDecisionPrompt()
|
||||||
|
}
|
||||||
|
if (!gameState.pending_x_check && gameStore.xCheckData) {
|
||||||
|
gameStore.clearXCheckData()
|
||||||
|
gameStore.clearDecisionPrompt()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
state.socketInstance.on('game_state_sync', (data) => {
|
state.socketInstance.on('game_state_sync', (data) => {
|
||||||
@ -497,22 +507,39 @@ export function useWebSocket() {
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
state.socketInstance.on('decision_required', (prompt) => {
|
state.socketInstance.on('decision_required', (prompt) => {
|
||||||
console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type)
|
console.log('[WebSocket] Decision required:', prompt.phase)
|
||||||
gameStore.setDecisionPrompt(prompt)
|
gameStore.setDecisionPrompt(prompt)
|
||||||
|
|
||||||
// Handle x-check specific decision types
|
// Route phase-specific data to appropriate store slots
|
||||||
if (prompt.type === 'x_check_result' && prompt.data) {
|
if (prompt.data) {
|
||||||
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
|
switch (prompt.phase) {
|
||||||
gameStore.setXCheckData(prompt.data)
|
// X-Check phases
|
||||||
} else if (prompt.type === 'decide_advance' && prompt.data) {
|
case 'awaiting_x_check_result':
|
||||||
console.log('[WebSocket] DECIDE advance decision')
|
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
|
||||||
gameStore.setDecideData(prompt.data)
|
gameStore.setXCheckData(prompt.data as any)
|
||||||
} else if (prompt.type === 'decide_throw' && prompt.data) {
|
break
|
||||||
console.log('[WebSocket] DECIDE throw decision')
|
case 'awaiting_decide_advance':
|
||||||
gameStore.setDecideData(prompt.data)
|
console.log('[WebSocket] DECIDE advance decision')
|
||||||
} else if (prompt.type === 'decide_speed_check' && prompt.data) {
|
gameStore.setDecideData(prompt.data as any)
|
||||||
console.log('[WebSocket] DECIDE speed check decision')
|
break
|
||||||
gameStore.setDecideData(prompt.data)
|
case 'awaiting_decide_throw':
|
||||||
|
console.log('[WebSocket] DECIDE throw decision')
|
||||||
|
gameStore.setDecideData(prompt.data as any)
|
||||||
|
break
|
||||||
|
case 'awaiting_decide_result':
|
||||||
|
console.log('[WebSocket] DECIDE speed check decision')
|
||||||
|
gameStore.setDecideData(prompt.data as any)
|
||||||
|
break
|
||||||
|
// Uncapped hit phases
|
||||||
|
case 'awaiting_uncapped_lead_advance':
|
||||||
|
case 'awaiting_uncapped_defensive_throw':
|
||||||
|
case 'awaiting_uncapped_trail_advance':
|
||||||
|
case 'awaiting_uncapped_throw_target':
|
||||||
|
case 'awaiting_uncapped_safe_out':
|
||||||
|
console.log('[WebSocket] Uncapped hit decision:', prompt.phase)
|
||||||
|
gameStore.setUncappedHitData(prompt.data as any)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -604,6 +631,7 @@ export function useWebSocket() {
|
|||||||
gameStore.clearPendingDecisions()
|
gameStore.clearPendingDecisions()
|
||||||
gameStore.clearXCheckData()
|
gameStore.clearXCheckData()
|
||||||
gameStore.clearDecideData()
|
gameStore.clearDecideData()
|
||||||
|
gameStore.clearUncappedHitData()
|
||||||
|
|
||||||
uiStore.showSuccess(data.description, 5000)
|
uiStore.showSuccess(data.description, 5000)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type {
|
|||||||
DecideAdvanceData,
|
DecideAdvanceData,
|
||||||
DecideThrowData,
|
DecideThrowData,
|
||||||
DecideSpeedCheckData,
|
DecideSpeedCheckData,
|
||||||
|
UncappedHitData,
|
||||||
} from '~/types'
|
} from '~/types'
|
||||||
|
|
||||||
export const useGameStore = defineStore('game', () => {
|
export const useGameStore = defineStore('game', () => {
|
||||||
@ -44,6 +45,9 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const xCheckData = ref<XCheckData | null>(null)
|
const xCheckData = ref<XCheckData | null>(null)
|
||||||
const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null)
|
const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null)
|
||||||
|
|
||||||
|
// Uncapped hit workflow state
|
||||||
|
const uncappedHitData = ref<UncappedHitData | null>(null)
|
||||||
|
|
||||||
// Decision state (local pending decisions before submission)
|
// Decision state (local pending decisions before submission)
|
||||||
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
|
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
|
||||||
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
|
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
|
||||||
@ -156,6 +160,39 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
gameState.value?.decision_phase === 'awaiting_decide_result'
|
gameState.value?.decision_phase === 'awaiting_decide_result'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const needsUncappedLeadAdvance = computed(() => {
|
||||||
|
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_lead_advance' ||
|
||||||
|
gameState.value?.decision_phase === 'awaiting_uncapped_lead_advance'
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsUncappedDefensiveThrow = computed(() => {
|
||||||
|
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_defensive_throw' ||
|
||||||
|
gameState.value?.decision_phase === 'awaiting_uncapped_defensive_throw'
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsUncappedTrailAdvance = computed(() => {
|
||||||
|
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_trail_advance' ||
|
||||||
|
gameState.value?.decision_phase === 'awaiting_uncapped_trail_advance'
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsUncappedThrowTarget = computed(() => {
|
||||||
|
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_throw_target' ||
|
||||||
|
gameState.value?.decision_phase === 'awaiting_uncapped_throw_target'
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsUncappedSafeOut = computed(() => {
|
||||||
|
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_safe_out' ||
|
||||||
|
gameState.value?.decision_phase === 'awaiting_uncapped_safe_out'
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsUncappedDecision = computed(() => {
|
||||||
|
return needsUncappedLeadAdvance.value ||
|
||||||
|
needsUncappedDefensiveThrow.value ||
|
||||||
|
needsUncappedTrailAdvance.value ||
|
||||||
|
needsUncappedThrowTarget.value ||
|
||||||
|
needsUncappedSafeOut.value
|
||||||
|
})
|
||||||
|
|
||||||
const canRollDice = computed(() => {
|
const canRollDice = computed(() => {
|
||||||
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
|
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
|
||||||
})
|
})
|
||||||
@ -388,6 +425,20 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
decideData.value = null
|
decideData.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set uncapped hit data (from decision_required event)
|
||||||
|
*/
|
||||||
|
function setUncappedHitData(data: UncappedHitData | null) {
|
||||||
|
uncappedHitData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear uncapped hit data after resolution
|
||||||
|
*/
|
||||||
|
function clearUncappedHitData() {
|
||||||
|
uncappedHitData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset game store (when leaving game)
|
* Reset game store (when leaving game)
|
||||||
*/
|
*/
|
||||||
@ -409,6 +460,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
decisionHistory.value = []
|
decisionHistory.value = []
|
||||||
xCheckData.value = null
|
xCheckData.value = null
|
||||||
decideData.value = null
|
decideData.value = null
|
||||||
|
uncappedHitData.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -464,6 +516,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
decisionHistory: readonly(decisionHistory),
|
decisionHistory: readonly(decisionHistory),
|
||||||
xCheckData: readonly(xCheckData),
|
xCheckData: readonly(xCheckData),
|
||||||
decideData: readonly(decideData),
|
decideData: readonly(decideData),
|
||||||
|
uncappedHitData: readonly(uncappedHitData),
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
gameId,
|
gameId,
|
||||||
@ -496,6 +549,12 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
needsDecideAdvance,
|
needsDecideAdvance,
|
||||||
needsDecideThrow,
|
needsDecideThrow,
|
||||||
needsDecideResult,
|
needsDecideResult,
|
||||||
|
needsUncappedLeadAdvance,
|
||||||
|
needsUncappedDefensiveThrow,
|
||||||
|
needsUncappedTrailAdvance,
|
||||||
|
needsUncappedThrowTarget,
|
||||||
|
needsUncappedSafeOut,
|
||||||
|
needsUncappedDecision,
|
||||||
canRollDice,
|
canRollDice,
|
||||||
canSubmitOutcome,
|
canSubmitOutcome,
|
||||||
recentPlays,
|
recentPlays,
|
||||||
@ -526,6 +585,8 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
clearXCheckData,
|
clearXCheckData,
|
||||||
setDecideData,
|
setDecideData,
|
||||||
clearDecideData,
|
clearDecideData,
|
||||||
|
setUncappedHitData,
|
||||||
|
clearUncappedHitData,
|
||||||
resetGame,
|
resetGame,
|
||||||
getActiveLineup,
|
getActiveLineup,
|
||||||
getBenchPlayers,
|
getBenchPlayers,
|
||||||
|
|||||||
@ -49,6 +49,12 @@ export type DecisionPhase =
|
|||||||
| 'awaiting_decide_advance'
|
| 'awaiting_decide_advance'
|
||||||
| 'awaiting_decide_throw'
|
| 'awaiting_decide_throw'
|
||||||
| 'awaiting_decide_result'
|
| 'awaiting_decide_result'
|
||||||
|
// Uncapped hit decision tree phases
|
||||||
|
| 'awaiting_uncapped_lead_advance'
|
||||||
|
| 'awaiting_uncapped_defensive_throw'
|
||||||
|
| 'awaiting_uncapped_trail_advance'
|
||||||
|
| 'awaiting_uncapped_throw_target'
|
||||||
|
| 'awaiting_uncapped_safe_out'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lineup player state - represents a player in the game
|
* Lineup player state - represents a player in the game
|
||||||
@ -138,6 +144,9 @@ export interface GameState {
|
|||||||
// Interactive x-check workflow
|
// Interactive x-check workflow
|
||||||
pending_x_check: PendingXCheck | null
|
pending_x_check: PendingXCheck | null
|
||||||
|
|
||||||
|
// Uncapped hit decision tree
|
||||||
|
pending_uncapped_hit: PendingUncappedHit | null
|
||||||
|
|
||||||
// Play history
|
// Play history
|
||||||
play_count: number
|
play_count: number
|
||||||
last_play_result: string | null
|
last_play_result: string | null
|
||||||
@ -314,6 +323,7 @@ export interface DecisionPrompt {
|
|||||||
timeout_seconds: number
|
timeout_seconds: number
|
||||||
options?: string[]
|
options?: string[]
|
||||||
message?: string
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -440,3 +450,98 @@ export interface PendingXCheck {
|
|||||||
decide_throw: string | null
|
decide_throw: string | null
|
||||||
decide_d20: number | null
|
decide_d20: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending Uncapped Hit State (on GameState)
|
||||||
|
* Persisted for reconnection recovery.
|
||||||
|
* Backend: PendingUncappedHit (game_models.py)
|
||||||
|
*/
|
||||||
|
export interface PendingUncappedHit {
|
||||||
|
hit_type: string
|
||||||
|
hit_location: string
|
||||||
|
lead_runner_base: number
|
||||||
|
lead_runner_lineup_id: number
|
||||||
|
lead_target_base: number
|
||||||
|
auto_runners: number[][]
|
||||||
|
lead_advance: boolean | null
|
||||||
|
will_throw: boolean | null
|
||||||
|
trail_runner_base: number | null
|
||||||
|
trail_runner_lineup_id: number | null
|
||||||
|
trail_target_base: number | null
|
||||||
|
trail_advance: boolean | null
|
||||||
|
throw_target: string | null
|
||||||
|
d20_roll: number | null
|
||||||
|
safe_out_result: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Lead runner advance decision data
|
||||||
|
* Sent with awaiting_uncapped_lead_advance
|
||||||
|
*/
|
||||||
|
export interface UncappedLeadAdvanceData {
|
||||||
|
hit_type: string
|
||||||
|
hit_location: string
|
||||||
|
lead_runner_base: number
|
||||||
|
lead_runner_lineup_id: number
|
||||||
|
lead_target_base: number
|
||||||
|
auto_runners: number[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Defensive throw decision data
|
||||||
|
* Sent with awaiting_uncapped_defensive_throw
|
||||||
|
*/
|
||||||
|
export interface UncappedDefensiveThrowData {
|
||||||
|
lead_runner_base: number
|
||||||
|
lead_target_base: number
|
||||||
|
lead_runner_lineup_id: number
|
||||||
|
hit_location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: Trail runner advance decision data
|
||||||
|
* Sent with awaiting_uncapped_trail_advance
|
||||||
|
*/
|
||||||
|
export interface UncappedTrailAdvanceData {
|
||||||
|
trail_runner_base: number
|
||||||
|
trail_target_base: number
|
||||||
|
trail_runner_lineup_id: number
|
||||||
|
hit_location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: Throw target selection data
|
||||||
|
* Sent with awaiting_uncapped_throw_target
|
||||||
|
*/
|
||||||
|
export interface UncappedThrowTargetData {
|
||||||
|
lead_runner_base: number
|
||||||
|
lead_target_base: number
|
||||||
|
lead_runner_lineup_id: number
|
||||||
|
trail_runner_base: number
|
||||||
|
trail_target_base: number
|
||||||
|
trail_runner_lineup_id: number
|
||||||
|
hit_location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5: Safe/out resolution data
|
||||||
|
* Sent with awaiting_uncapped_safe_out
|
||||||
|
*/
|
||||||
|
export interface UncappedSafeOutData {
|
||||||
|
d20_roll: number
|
||||||
|
runner: string
|
||||||
|
runner_base: number
|
||||||
|
target_base: number
|
||||||
|
runner_lineup_id: number
|
||||||
|
hit_location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all uncapped hit phase data types
|
||||||
|
*/
|
||||||
|
export type UncappedHitData =
|
||||||
|
| UncappedLeadAdvanceData
|
||||||
|
| UncappedDefensiveThrowData
|
||||||
|
| UncappedTrailAdvanceData
|
||||||
|
| UncappedThrowTargetData
|
||||||
|
| UncappedSafeOutData
|
||||||
|
|||||||
@ -36,6 +36,14 @@ export type {
|
|||||||
DecideThrowData,
|
DecideThrowData,
|
||||||
DecideSpeedCheckData,
|
DecideSpeedCheckData,
|
||||||
PendingXCheck,
|
PendingXCheck,
|
||||||
|
// Uncapped hit workflow types
|
||||||
|
PendingUncappedHit,
|
||||||
|
UncappedLeadAdvanceData,
|
||||||
|
UncappedDefensiveThrowData,
|
||||||
|
UncappedTrailAdvanceData,
|
||||||
|
UncappedThrowTargetData,
|
||||||
|
UncappedSafeOutData,
|
||||||
|
UncappedHitData,
|
||||||
} from './game'
|
} from './game'
|
||||||
|
|
||||||
// Player types
|
// Player types
|
||||||
@ -76,6 +84,12 @@ export type {
|
|||||||
SubmitDecideAdvanceRequest,
|
SubmitDecideAdvanceRequest,
|
||||||
SubmitDecideThrowRequest,
|
SubmitDecideThrowRequest,
|
||||||
SubmitDecideResultRequest,
|
SubmitDecideResultRequest,
|
||||||
|
// Uncapped hit workflow request types
|
||||||
|
SubmitUncappedLeadAdvanceRequest,
|
||||||
|
SubmitUncappedDefensiveThrowRequest,
|
||||||
|
SubmitUncappedTrailAdvanceRequest,
|
||||||
|
SubmitUncappedThrowTargetRequest,
|
||||||
|
SubmitUncappedSafeOutRequest,
|
||||||
// Event types
|
// Event types
|
||||||
ConnectedEvent,
|
ConnectedEvent,
|
||||||
GameJoinedEvent,
|
GameJoinedEvent,
|
||||||
|
|||||||
@ -58,6 +58,13 @@ export interface ClientToServerEvents {
|
|||||||
submit_decide_throw: (data: SubmitDecideThrowRequest) => void
|
submit_decide_throw: (data: SubmitDecideThrowRequest) => void
|
||||||
submit_decide_result: (data: SubmitDecideResultRequest) => void
|
submit_decide_result: (data: SubmitDecideResultRequest) => void
|
||||||
|
|
||||||
|
// Uncapped hit decision tree
|
||||||
|
submit_uncapped_lead_advance: (data: SubmitUncappedLeadAdvanceRequest) => void
|
||||||
|
submit_uncapped_defensive_throw: (data: SubmitUncappedDefensiveThrowRequest) => void
|
||||||
|
submit_uncapped_trail_advance: (data: SubmitUncappedTrailAdvanceRequest) => void
|
||||||
|
submit_uncapped_throw_target: (data: SubmitUncappedThrowTargetRequest) => void
|
||||||
|
submit_uncapped_safe_out: (data: SubmitUncappedSafeOutRequest) => void
|
||||||
|
|
||||||
// Substitutions
|
// Substitutions
|
||||||
request_pinch_hitter: (data: PinchHitterRequest) => void
|
request_pinch_hitter: (data: PinchHitterRequest) => void
|
||||||
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
|
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
|
||||||
@ -394,3 +401,28 @@ export interface SubmitDecideResultRequest {
|
|||||||
game_id: string
|
game_id: string
|
||||||
outcome: 'safe' | 'out'
|
outcome: 'safe' | 'out'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitUncappedLeadAdvanceRequest {
|
||||||
|
game_id: string
|
||||||
|
advance: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitUncappedDefensiveThrowRequest {
|
||||||
|
game_id: string
|
||||||
|
will_throw: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitUncappedTrailAdvanceRequest {
|
||||||
|
game_id: string
|
||||||
|
advance: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitUncappedThrowTargetRequest {
|
||||||
|
game_id: string
|
||||||
|
target: 'lead' | 'trail'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitUncappedSafeOutRequest {
|
||||||
|
game_id: string
|
||||||
|
result: 'safe' | 'out'
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user