CLAUDE: Fix resolution phase control and add demo mode

Bug fix: During resolution phase (dice rolling), isMyTurn was false
for both players, preventing anyone from seeing the dice roller.
Now the batting team has control during resolution since they read
their card.

Demo mode: myTeamId now returns whichever team needs to act,
allowing single-player testing of both sides.

Changes:
- Add creator_discord_id to GameState (backend + frontend types)
- Add get_current_user_optional dependency for optional auth
- Update quick-create to capture creator's discord_id
- Fix isMyTurn to give batting team control during resolution
- Demo mode: myTeamId returns active team based on phase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-13 23:47:21 -06:00
parent 581bc33f15
commit 38fb76c849
6 changed files with 90 additions and 17 deletions

View File

@ -0,0 +1,49 @@
"""
FastAPI dependencies for authentication and database access.
These dependencies can be injected into route handlers using FastAPI's Depends() mechanism.
"""
import logging
from fastapi import Request
from jose import JWTError
from app.utils.auth import verify_token
from app.utils.cookies import ACCESS_TOKEN_COOKIE
logger = logging.getLogger(f"{__name__}.dependencies")
async def get_current_user_optional(request: Request) -> dict | None:
"""
Get current authenticated user from cookie (optional).
This dependency returns the user dict if authenticated, or None if not.
Use this when authentication is optional but you want to capture user info
if available (e.g., for tracking game creators).
Args:
request: FastAPI request object
Returns:
User dict with discord_id, user_id, username if authenticated, None otherwise
"""
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
if not token:
return None
try:
payload = verify_token(token)
return {
"discord_id": payload.get("discord_id"),
"user_id": payload.get("user_id"),
"username": payload.get("username"),
}
except JWTError:
logger.debug("Invalid token in optional auth check")
return None
except Exception as e:
logger.debug(f"Error verifying token in optional auth: {e}")
return None

View File

@ -1,9 +1,10 @@
import logging import logging
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from app.api.dependencies import get_current_user_optional
from app.core.game_engine import game_engine from app.core.game_engine import game_engine
from app.core.state_manager import state_manager from app.core.state_manager import state_manager
from app.database.operations import DatabaseOperations from app.database.operations import DatabaseOperations
@ -325,13 +326,16 @@ async def create_game(request: CreateGameRequest):
@router.post("/quick-create", response_model=CreateGameResponse) @router.post("/quick-create", response_model=CreateGameResponse)
async def quick_create_game(): async def quick_create_game(user: dict | None = Depends(get_current_user_optional)):
""" """
Quick-create endpoint for testing - creates a game with pre-configured lineups. Quick-create endpoint for testing - creates a game with pre-configured lineups.
Uses the lineup configuration from the most recent game (Team 35 vs Team 38). Uses the lineup configuration from the most recent game (Team 35 vs Team 38).
This eliminates the 2-minute lineup configuration process during testing. This eliminates the 2-minute lineup configuration process during testing.
The authenticated user's discord_id is stored as creator_discord_id, allowing
them to control the home team regardless of actual team ownership.
Returns: Returns:
CreateGameResponse with game_id CreateGameResponse with game_id
""" """
@ -344,16 +348,21 @@ async def quick_create_game():
away_team_id = 38 away_team_id = 38
league_id = "sba" league_id = "sba"
# Get creator's discord_id from authenticated user
creator_discord_id = user.get("discord_id") if user else None
logger.info( logger.info(
f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id}" f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id} (creator: {creator_discord_id})"
) )
# Create game in state manager # Create game in state manager
# Creator controls both teams for solo testing
state = await state_manager.create_game( state = await state_manager.create_game(
game_id=game_id, game_id=game_id,
league_id=league_id, league_id=league_id,
home_team_id=home_team_id, home_team_id=home_team_id,
away_team_id=away_team_id, away_team_id=away_team_id,
creator_discord_id=creator_discord_id,
) )
# Save to database # Save to database

View File

@ -70,6 +70,7 @@ class StateManager:
home_team_is_ai: bool = False, home_team_is_ai: bool = False,
away_team_is_ai: bool = False, away_team_is_ai: bool = False,
auto_mode: bool = False, auto_mode: bool = False,
creator_discord_id: str | None = None,
) -> GameState: ) -> GameState:
""" """
Create a new game state in memory. Create a new game state in memory.
@ -111,6 +112,7 @@ class StateManager:
home_team_is_ai=home_team_is_ai, home_team_is_ai=home_team_is_ai,
away_team_is_ai=away_team_is_ai, away_team_is_ai=away_team_is_ai,
auto_mode=auto_mode, auto_mode=auto_mode,
creator_discord_id=creator_discord_id,
current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts
) )

View File

@ -396,6 +396,9 @@ class GameState(BaseModel):
home_team_is_ai: bool = False home_team_is_ai: bool = False
away_team_is_ai: bool = False away_team_is_ai: bool = False
# Creator (for demo/testing - creator can control home team)
creator_discord_id: str | None = None
# Resolution mode # Resolution mode
auto_mode: bool = ( auto_mode: bool = (
False # True = auto-generate outcomes (PD only), False = manual submissions False # True = auto-generate outcomes (PD only), False = manual submissions

View File

@ -438,20 +438,23 @@ const isLoading = ref(true)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting') const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
const showSubstitutions = ref(false) const showSubstitutions = ref(false)
// Determine which team (if any) the current user owns in this game // Determine which team the user controls
// For demo/testing: user controls whichever team needs to act
const myTeamId = computed(() => { const myTeamId = computed(() => {
if (!gameState.value) return null if (!gameState.value) return null
const userTeamIds = authStore.userTeams.map(t => t.id) // Return the team that currently needs to make a decision
if (gameState.value.half === 'top') {
if (userTeamIds.includes(gameState.value.home_team_id)) { // Top: away bats, home fields
return gameState.value.home_team_id return gameState.value.decision_phase === 'awaiting_defensive'
? gameState.value.home_team_id
: gameState.value.away_team_id
} else {
// Bottom: home bats, away fields
return gameState.value.decision_phase === 'awaiting_defensive'
? gameState.value.away_team_id
: gameState.value.home_team_id
} }
if (userTeamIds.includes(gameState.value.away_team_id)) {
return gameState.value.away_team_id
}
return null // Spectator - doesn't own either team
}) })
// Dynamic ScoreBoard height tracking // Dynamic ScoreBoard height tracking
@ -483,13 +486,13 @@ const currentTeam = computed(() => {
return gameState.value?.half === 'top' ? 'away' : 'home' return gameState.value?.half === 'top' ? 'away' : 'home'
}) })
// Determine if it's the current user's turn to act
const isMyTurn = computed(() => { const isMyTurn = computed(() => {
if (!myTeamId.value || !gameState.value) return false if (!myTeamId.value || !gameState.value) return false
// Determine which team needs to act based on decision phase // During decision phases, check which team needs to decide
if (needsDefensiveDecision.value) { if (needsDefensiveDecision.value) {
// Fielding team makes defensive decisions // Fielding team makes defensive decisions
// Top of inning: home fields, Bottom: away fields
const fieldingTeamId = gameState.value.half === 'top' const fieldingTeamId = gameState.value.half === 'top'
? gameState.value.home_team_id ? gameState.value.home_team_id
: gameState.value.away_team_id : gameState.value.away_team_id
@ -498,14 +501,18 @@ const isMyTurn = computed(() => {
if (needsOffensiveDecision.value) { if (needsOffensiveDecision.value) {
// Batting team makes offensive decisions // Batting team makes offensive decisions
// Top of inning: away bats, Bottom: home bats
const battingTeamId = gameState.value.half === 'top' const battingTeamId = gameState.value.half === 'top'
? gameState.value.away_team_id ? gameState.value.away_team_id
: gameState.value.home_team_id : gameState.value.home_team_id
return myTeamId.value === battingTeamId return myTeamId.value === battingTeamId
} }
return false // During resolution phase (dice rolling, outcome submission),
// the BATTING team has control (they read their card)
const battingTeamId = gameState.value.half === 'top'
? gameState.value.away_team_id
: gameState.value.home_team_id
return myTeamId.value === battingTeamId
}) })
const decisionPhase = computed(() => { const decisionPhase = computed(() => {

View File

@ -72,6 +72,9 @@ export interface GameState {
home_team_is_ai: boolean home_team_is_ai: boolean
away_team_is_ai: boolean away_team_is_ai: boolean
// Creator (for demo/testing - creator can control home team)
creator_discord_id: string | null
// Resolution mode // Resolution mode
auto_mode: boolean auto_mode: boolean