Compare commits

...

10 Commits

Author SHA1 Message Date
Cal Corum
701098881a CLAUDE: Add validation to prevent null team metadata in game creation
- Add validation in create_game() and quick_create_game() to ensure both
  teams are successfully fetched from SBA API before creating game
- Raise HTTP 400 with clear error message if team data cannot be fetched
- Add warning logs in get_teams_by_ids() when teams are missing from result
- Prevents games from being created with null team display info (names,
  abbreviations, colors, thumbnails)

Root cause: get_teams_by_ids() silently returned empty dict on API failures,
and game creation endpoints didn't validate the result.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:19:15 -06:00
Cal Corum
31139c5d4d
Merge pull request #7 from calcorum/feature/dice-display-logic
Redesign dice display with team colors and consolidate player cards
2026-01-24 00:32:30 -06:00
Cal Corum
2b8fea36a8 CLAUDE: Redesign dice display with team colors and consolidate player cards
Backend:
- Add home_team_dice_color and away_team_dice_color to GameState model
- Extract dice_color from game metadata in StateManager (default: cc0000)
- Add runners_on_base param to roll_ab for chaos check skipping

Frontend - Dice Display:
- Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes
- Apply home team's dice_color to d6 dice, white for resolution d20
- Show chaos d20 in amber only when WP/PB check triggered
- Add automatic text contrast based on color luminance
- Reduce blank space and remove info bubble from dice results

Frontend - Player Cards:
- Consolidate pitcher/batter cards to single location below diamond
- Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher)
- New card header format: [Team] Position [Name] with full card image
- Remove redundant card displays from GameBoard and GameplayPanel
- Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+)

Tests:
- Add DiceShapes.spec.ts with 34 tests for color calculations and rendering
- Update DiceRoller.spec.ts for new DiceShapes integration
- Fix test_roll_dice_success for new runners_on_base parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:16:32 -06:00
Cal Corum
be31e2ccb4 CLAUDE: Complete in-game UI overhaul with player cards and outcome wizard
Features:
- PlayerCardModal: Tap any player to view full playing card image
- OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check)
- GameBoard: Expandable view showing all 9 fielder positions
- Post-roll card display: Shows batter/pitcher card based on d6 roll
- CurrentSituation: Tappable player cards with modal integration

Bug fixes:
- Fix batter not advancing after play (state_manager recovery logic)
- Add dark mode support for buttons and panels (partial - iOS issue noted)

New files:
- PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue
- outcomeFlow.ts constants for outcome category mapping
- TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:23:38 -06:00
Cal Corum
52706bed40 CLAUDE: Mobile drag-drop lineup builder and touch-friendly UI improvements
- Add vuedraggable for mobile-friendly lineup building
- Add touch delay and threshold settings for better mobile UX
- Add drag ghost/chosen/dragging visual states
- Add replacement mode visual feedback when dragging over occupied slots
- Add getBench action to useGameActions for substitution panel
- Add BN (bench) to valid positions in LineupPlayerState
- Update lineup service to load full lineup (active + bench)
- Add touch-manipulation CSS to UI components (ActionButton, ButtonGroup, ToggleSwitch)
- Add select-none to prevent text selection during touch interactions
- Add mobile touch patterns documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:17:16 -06:00
Cal Corum
e058bc4a6c CLAUDE: RosterLink refactor for bench players with cached player data
- Add player_positions JSONB column to roster_links (migration 006)
- Add player_data JSONB column to cache name/image/headshot (migration 007)
- Add is_pitcher/is_batter computed properties for two-way player support
- Update lineup submission to populate RosterLink with all players + positions
- Update get_bench handler to use cached data (no runtime API calls)
- Add BenchPlayer type to frontend with proper filtering
- Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow,
  PositionSelector, UnifiedLineupTab
- Add integration tests for get_bench_players

Bench players now load instantly without API dependency, and properly
filter batters vs pitchers (including CP closer position).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:15:12 -06:00
Cal Corum
64325d7163 CLAUDE: Fix game recovery to load team display info and add score text outline
Backend:
- Add game_metadata to load_game_state() return dict in DatabaseOperations
- Populate team display fields (name, color, thumbnail) in _rebuild_state_from_data()
  so recovered games show team colors/names

Frontend:
- Add text-outline CSS for score visibility on any background (light logos, gradients)
- Handle thumbnail 404 with @error event, show enhanced shadow when no thumbnail
- Apply consistent outline across mobile and desktop layouts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:03:59 -06:00
Cal Corum
d60b7a2d60 CLAUDE: Store team display info in DB and fix lineup auto-start
Backend:
- Add game_metadata to create_game() and quick_create_game() endpoints
- Fetch team display info (lname, sname, abbrev, color, thumbnail) from
  SBA API at game creation time and store in DB
- Populate GameState with team display fields from game_metadata
- Fix submit_team_lineup to cache lineup in state_manager after DB write
  so auto-start correctly detects both teams ready

Frontend:
- Read team colors/names/thumbnails from gameState instead of useState
- Remove useState approach that failed across SSR navigation
- Fix create.vue redirect from legacy /games/lineup/[id] to /games/[id]
- Update game.vue header to show team names from gameState

Docs:
- Update CLAUDE.md to note dev mode has broken auth, always use prod

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 08:43:26 -06:00
Cal Corum
ff3f1746d6 CLAUDE: Add team color gradient to scoreboard and fix sticky tabs
- ScoreBoard: Dynamic gradient using team colors (away left, home right)
  with dark center blend and 20% overlay for text readability
- Fetch team colors from API using cached season from schedule state
- Fix sticky tabs by removing overflow-auto from game layout main
- Move play-by-play below gameplay panel on mobile layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 23:14:46 -06:00
Cal Corum
3a91a5d477 CLAUDE: Fix connection status indicator showing disconnected while playing
Use useWebSocket composable directly as source of truth for connection
status instead of gameStore.isConnected which could get out of sync.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:59:33 -06:00
62 changed files with 6887 additions and 1257 deletions

View File

@ -92,13 +92,15 @@ strat-gameplay-webapp/
The entire stack runs in Docker with a single command. No local Python or Node.js required.
```bash
# Development (hot-reload enabled)
./start.sh dev
> **⚠️ ALWAYS USE PROD MODE**: Discord OAuth does not work in dev mode due to cookie/CORS configuration. Since this system isn't live yet, always build and run with `prod` mode for testing.
# Production (optimized build)
```bash
# Production (optimized build) - USE THIS
./start.sh prod
# Development - DO NOT USE (auth broken)
# ./start.sh dev
# Stop all services
./start.sh stop
@ -116,8 +118,8 @@ The entire stack runs in Docker with a single command. No local Python or Node.j
| Mode | Backend | Frontend | Use Case |
|------|---------|----------|----------|
| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | Active development |
| `prod` | Production build | SSR optimized build | Demo/deployment |
| `prod` | Production build | SSR optimized build | **Always use this** - auth works correctly |
| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | ❌ Auth broken - do not use |
### Service URLs

View File

@ -0,0 +1,33 @@
"""add player_positions to roster_links
Revision ID: 006
Revises: 62bd3195c64c
Create Date: 2026-01-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = '006'
down_revision: Union[str, None] = '62bd3195c64c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add player_positions column for storing player's natural positions
# This allows the frontend to filter bench players by position type (batters vs pitchers)
# Example values: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"] (two-way player)
op.add_column(
'roster_links',
sa.Column('player_positions', JSONB, nullable=True, server_default='[]')
)
def downgrade() -> None:
op.drop_column('roster_links', 'player_positions')

View File

@ -0,0 +1,31 @@
"""Add player_data JSONB column to roster_links for caching player names/images.
Revision ID: 007
Revises: 006
Create Date: 2026-01-17
This stores player name, image, and headshot at lineup submission time,
eliminating the need for runtime API calls when loading bench players.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision = "007"
down_revision = "006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"roster_links",
sa.Column("player_data", JSONB, nullable=True, server_default="{}")
)
def downgrade() -> None:
op.drop_column("roster_links", "player_data")

View File

@ -320,12 +320,54 @@ async def create_game(request: CreateGameRequest):
status_code=400, detail="Home and away teams must be different"
)
# Fetch team display info from SBA API and build game_metadata
# This persists team data so it's always available regardless of season
teams_data = await sba_api_client.get_teams_by_ids(
[request.home_team_id, request.away_team_id], season=request.season
)
# Validate that we successfully fetched both teams
missing_teams = [
tid for tid in [request.home_team_id, request.away_team_id]
if tid not in teams_data
]
if missing_teams:
logger.error(
f"Failed to fetch team data for IDs {missing_teams} in season {request.season}"
)
raise HTTPException(
status_code=400,
detail=f"Could not fetch team data for team IDs: {missing_teams}. "
f"Verify teams exist in season {request.season}."
)
home_team_data = teams_data.get(request.home_team_id, {})
away_team_data = teams_data.get(request.away_team_id, {})
game_metadata = {
"home_team": {
"lname": home_team_data.get("lname"),
"sname": home_team_data.get("sname"),
"abbrev": home_team_data.get("abbrev"),
"color": home_team_data.get("color"),
"thumbnail": home_team_data.get("thumbnail"),
},
"away_team": {
"lname": away_team_data.get("lname"),
"sname": away_team_data.get("sname"),
"abbrev": away_team_data.get("abbrev"),
"color": away_team_data.get("color"),
"thumbnail": away_team_data.get("thumbnail"),
},
}
# Create game in state manager (in-memory)
state = await state_manager.create_game(
game_id=game_id,
league_id=request.league_id,
home_team_id=request.home_team_id,
away_team_id=request.away_team_id,
game_metadata=game_metadata,
)
# Save to database
@ -337,6 +379,7 @@ async def create_game(request: CreateGameRequest):
away_team_id=request.away_team_id,
game_mode="friendly" if not request.is_ai_opponent else "ai",
visibility="public",
game_metadata=game_metadata,
)
logger.info(
@ -407,6 +450,47 @@ async def quick_create_game(
f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})"
)
# Fetch team display info from SBA API and build game_metadata
# This persists team data so it's always available regardless of season
teams_data = await sba_api_client.get_teams_by_ids(
[home_team_id, away_team_id], season=13
)
# Validate that we successfully fetched both teams
missing_teams = [
tid for tid in [home_team_id, away_team_id]
if tid not in teams_data
]
if missing_teams:
logger.error(
f"Quick-create: Failed to fetch team data for IDs {missing_teams}"
)
raise HTTPException(
status_code=400,
detail=f"Could not fetch team data for team IDs: {missing_teams}. "
f"Verify teams exist in season 13."
)
home_team_data = teams_data.get(home_team_id, {})
away_team_data = teams_data.get(away_team_id, {})
game_metadata = {
"home_team": {
"lname": home_team_data.get("lname"),
"sname": home_team_data.get("sname"),
"abbrev": home_team_data.get("abbrev"),
"color": home_team_data.get("color"),
"thumbnail": home_team_data.get("thumbnail"),
},
"away_team": {
"lname": away_team_data.get("lname"),
"sname": away_team_data.get("sname"),
"abbrev": away_team_data.get("abbrev"),
"color": away_team_data.get("color"),
"thumbnail": away_team_data.get("thumbnail"),
},
}
# Create game in state manager
state = await state_manager.create_game(
game_id=game_id,
@ -414,6 +498,7 @@ async def quick_create_game(
home_team_id=home_team_id,
away_team_id=away_team_id,
creator_discord_id=creator_discord_id,
game_metadata=game_metadata,
)
# Save to database
@ -426,6 +511,7 @@ async def quick_create_game(
game_mode="friendly",
visibility="public",
schedule_game_id=schedule_game_id,
game_metadata=game_metadata,
)
if use_custom_teams:
@ -717,6 +803,12 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
)
class BenchPlayerRequest(BaseModel):
"""Single bench player in lineup request"""
player_id: int = Field(..., description="SBA player ID")
class SubmitTeamLineupRequest(BaseModel):
"""Request model for submitting a single team's lineup"""
@ -724,6 +816,9 @@ class SubmitTeamLineupRequest(BaseModel):
lineup: list[LineupPlayerRequest] = Field(
..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)"
)
bench: list[BenchPlayerRequest] = Field(
default_factory=list, description="Bench players (not in starting lineup)"
)
@field_validator("lineup")
@classmethod
@ -1008,7 +1103,49 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
detail=f"Lineup already submitted for team {request.team_id}"
)
# Process lineup
# Step 1: Collect all player IDs (starters + bench) for batch API fetch
all_player_ids = [p.player_id for p in request.lineup]
all_player_ids.extend([p.player_id for p in request.bench])
# Step 2: Fetch all player data from SBA API to get positions
player_data = {}
if all_player_ids:
try:
player_data = await sba_api_client.get_players_batch(all_player_ids)
logger.info(f"Fetched {len(player_data)}/{len(all_player_ids)} players from SBA API")
except Exception as e:
logger.warning(f"Failed to fetch player data from SBA API: {e}")
# Continue - roster entries will have empty positions
# Step 3: Add ALL players to RosterLink with their natural positions and cached data
db_ops = DatabaseOperations()
roster_count = 0
for player_id in all_player_ids:
player_positions: list[str] = []
cached_player_data: dict | None = None
if player_id in player_data:
player = player_data[player_id]
player_positions = player.get_positions()
# Cache essential player data to avoid runtime API calls
cached_player_data = {
"name": player.name,
"image": player.get_image_url(),
"headshot": player.headshot or "",
}
await db_ops.add_sba_roster_player(
game_id=game_uuid,
player_id=player_id,
team_id=request.team_id,
player_positions=player_positions,
player_data=cached_player_data,
)
roster_count += 1
logger.info(f"Added {roster_count} players to roster for team {request.team_id}")
# Step 4: Add only STARTERS to active Lineup table
player_count = 0
for player in request.lineup:
await lineup_service.add_sba_player_to_lineup(
@ -1021,7 +1158,20 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
)
player_count += 1
logger.info(f"Added {player_count} players to team {request.team_id} lineup")
logger.info(f"Added {player_count} starters to team {request.team_id} lineup")
# Note: Bench players are NOT added to Lineup - they're derived from
# RosterLink players not in active Lineup via get_bench_players()
# Load lineup from DB and cache in state_manager for subsequent checks
team_lineup = await lineup_service.load_team_lineup_with_player_data(
game_id=game_uuid,
team_id=request.team_id,
league_id=state.league_id,
)
if team_lineup:
state_manager.set_lineup(game_uuid, request.team_id, team_lineup)
logger.info(f"Cached lineup for team {request.team_id} in state_manager")
# Check if both teams now have lineups
home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id)

View File

@ -18,6 +18,7 @@ class TeamResponse(BaseModel):
sname: str
lname: str
color: str | None = None
thumbnail: str | None = None
manager_legacy: str | None = None
gmid: str | None = None
gmid2: str | None = None
@ -46,6 +47,7 @@ async def get_teams(season: int = Query(..., description="Season number (e.g., 3
sname=team["sname"],
lname=team["lname"],
color=team.get("color"),
thumbnail=team.get("thumbnail"),
manager_legacy=team.get("manager_legacy"),
gmid=team.get("gmid"),
gmid2=team.get("gmid2"),

View File

@ -56,6 +56,7 @@ class DiceSystem:
game_id: UUID | None = None,
team_id: int | None = None,
player_id: int | None = None,
runners_on_base: bool = True,
) -> AbRoll:
"""
Roll at-bat dice: 1d6 + 2d6 + 2d20
@ -65,11 +66,14 @@ class DiceSystem:
- chaos_d20 == 2: 5% chance - Passed ball check (use resolution_d20 for confirmation)
- chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits)
If runners_on_base is False, the chaos check is skipped (WP/PB meaningless without runners).
Args:
league_id: 'sba' or 'pd'
game_id: Optional UUID of game in progress
team_id: Optional team ID for auditing
player_id: Optional player/card ID for auditing (polymorphic)
runners_on_base: Whether there are runners on base (affects chaos check)
Returns:
AbRoll with all dice results
@ -80,6 +84,9 @@ class DiceSystem:
chaos_d20 = self._roll_d20()
resolution_d20 = self._roll_d20() # Always roll, used for WP/PB or splits
# Skip chaos check if no runners on base (WP/PB is meaningless)
chaos_check_skipped = not runners_on_base
roll = AbRoll(
roll_id=self._generate_roll_id(),
roll_type=RollType.AB,
@ -94,8 +101,9 @@ class DiceSystem:
chaos_d20=chaos_d20,
resolution_d20=resolution_d20,
d6_two_total=0, # Calculated in __post_init__
check_wild_pitch=False,
check_passed_ball=False,
check_wild_pitch=False, # Calculated in __post_init__
check_passed_ball=False, # Calculated in __post_init__
chaos_check_skipped=chaos_check_skipped,
)
self._roll_history.append(roll)

View File

@ -679,8 +679,15 @@ class GameEngine:
state_manager=state_manager,
)
# Check if there are runners on base (affects chaos check)
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
# Roll dice
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id)
ab_roll = dice_system.roll_ab(
league_id=state.league_id,
game_id=game_id,
runners_on_base=runners_on_base,
)
# Use forced outcome if provided (for testing), otherwise need to implement chart lookup
if forced_outcome is None:

View File

@ -198,8 +198,15 @@ class PlayResolver:
logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}")
# Check if there are runners on base (affects chaos check)
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
# Roll dice
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=state.game_id)
ab_roll = dice_system.roll_ab(
league_id=state.league_id,
game_id=state.game_id,
runners_on_base=runners_on_base,
)
# Generate outcome from ratings
outcome, hit_location = self.result_chart.get_outcome( # type: ignore

View File

@ -84,11 +84,20 @@ class AbRoll(DiceRoll):
default=False
) # chaos_d20 == 2 (still needs resolution_d20 to confirm)
# Flag to indicate chaos check was skipped (no runners on base)
chaos_check_skipped: bool = field(default=False)
def __post_init__(self):
"""Calculate derived values"""
self.d6_two_total = self.d6_two_a + self.d6_two_b
self.check_wild_pitch = self.chaos_d20 == 1
self.check_passed_ball = self.chaos_d20 == 2
# Only check for WP/PB if chaos check wasn't skipped (runners on base)
if self.chaos_check_skipped:
self.check_wild_pitch = False
self.check_passed_ball = False
else:
self.check_wild_pitch = self.chaos_d20 == 1
self.check_passed_ball = self.chaos_d20 == 2
def to_dict(self) -> dict:
base = super().to_dict()
@ -102,6 +111,7 @@ class AbRoll(DiceRoll):
"resolution_d20": self.resolution_d20,
"check_wild_pitch": self.check_wild_pitch,
"check_passed_ball": self.check_passed_ball,
"chaos_check_skipped": self.chaos_check_skipped,
}
)
return base

View File

@ -94,6 +94,7 @@ class StateManager:
away_team_is_ai: bool = False,
auto_mode: bool = False,
creator_discord_id: str | None = None,
game_metadata: dict | None = None,
) -> GameState:
"""
Create a new game state in memory.
@ -106,6 +107,7 @@ class StateManager:
home_team_is_ai: Whether home team is AI-controlled
away_team_is_ai: Whether away team is AI-controlled
auto_mode: True = auto-generate outcomes (PD only), False = manual submissions
game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail)
Returns:
Newly created GameState
@ -127,6 +129,10 @@ class StateManager:
lineup_id=0, card_id=0, position="DH", batting_order=None
)
# Extract team display info from metadata
home_meta = game_metadata.get("home_team", {}) if game_metadata else {}
away_meta = game_metadata.get("away_team", {}) if game_metadata else {}
state = GameState(
game_id=game_id,
league_id=league_id,
@ -137,6 +143,17 @@ class StateManager:
auto_mode=auto_mode,
creator_discord_id=creator_discord_id,
current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts
# Team display info from metadata
home_team_name=home_meta.get("lname"),
home_team_abbrev=home_meta.get("abbrev"),
home_team_color=home_meta.get("color"),
home_team_dice_color=home_meta.get("dice_color", "cc0000"), # Default red
home_team_thumbnail=home_meta.get("thumbnail"),
away_team_name=away_meta.get("lname"),
away_team_abbrev=away_meta.get("abbrev"),
away_team_color=away_meta.get("color"),
away_team_dice_color=away_meta.get("dice_color", "cc0000"), # Default red
away_team_thumbnail=away_meta.get("thumbnail"),
)
self._states[game_id] = state
@ -374,6 +391,11 @@ class StateManager:
if current_pitcher and current_catcher:
break
# Extract team display info from game_metadata (stored at game creation)
game_metadata = game.get("game_metadata") or {}
home_meta = game_metadata.get("home_team", {})
away_meta = game_metadata.get("away_team", {})
state = GameState(
game_id=game["id"],
league_id=game["league_id"],
@ -390,6 +412,17 @@ class StateManager:
current_batter=current_batter_placeholder,
current_pitcher=current_pitcher,
current_catcher=current_catcher,
# Team display info from metadata
home_team_name=home_meta.get("lname"),
home_team_abbrev=home_meta.get("abbrev"),
home_team_color=home_meta.get("color"),
home_team_dice_color=home_meta.get("dice_color", "cc0000"), # Default red
home_team_thumbnail=home_meta.get("thumbnail"),
away_team_name=away_meta.get("lname"),
away_team_abbrev=away_meta.get("abbrev"),
away_team_color=away_meta.get("color"),
away_team_dice_color=away_meta.get("dice_color", "cc0000"), # Default red
away_team_thumbnail=away_meta.get("thumbnail"),
)
# Get last completed play to recover runner state and batter indices
@ -531,6 +564,17 @@ class StateManager:
f"Recovery: ✓ Set current_batter to idx={next_batter_idx}, "
f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}"
)
# CRITICAL: Advance the batter index so _prepare_next_play works correctly
# _prepare_next_play reads the index, sets the batter, then advances.
# Since we've already set the current batter, we need to advance the index
# so the NEXT call to _prepare_next_play will set the correct next batter.
if batting_team_id == away_team_id:
state.away_team_batter_idx = (next_batter_idx + 1) % 9
logger.info(f"Recovery: Advanced away_team_batter_idx to {state.away_team_batter_idx}")
else:
state.home_team_batter_idx = (next_batter_idx + 1) % 9
logger.info(f"Recovery: Advanced home_team_batter_idx to {state.home_team_batter_idx}")
else:
logger.warning(
f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order "

View File

@ -103,6 +103,7 @@ class DatabaseOperations:
away_team_is_ai: bool = False,
ai_difficulty: str | None = None,
schedule_game_id: int | None = None,
game_metadata: dict | None = None,
) -> Game:
"""
Create new game in database.
@ -118,6 +119,7 @@ class DatabaseOperations:
away_team_is_ai: Whether away team is AI
ai_difficulty: AI difficulty if applicable
schedule_game_id: External schedule game ID for linking (SBA, PD, etc.)
game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail)
Returns:
Created Game model
@ -137,6 +139,7 @@ class DatabaseOperations:
away_team_is_ai=away_team_is_ai,
ai_difficulty=ai_difficulty,
schedule_game_id=schedule_game_id,
game_metadata=game_metadata or {},
status="pending",
)
session.add(game)
@ -287,12 +290,12 @@ class DatabaseOperations:
position=position,
batting_order=batting_order,
is_starter=is_starter,
is_active=True,
is_active=is_starter, # Bench players (is_starter=False) are inactive
)
session.add(lineup)
await session.flush()
await session.refresh(lineup)
logger.debug(f"Added SBA player {player_id} to lineup in game {game_id}")
logger.debug(f"Added SBA player {player_id} to lineup in game {game_id} (active={is_starter})")
return lineup
async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
@ -322,6 +325,35 @@ class DatabaseOperations:
)
return lineups
async def get_full_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]:
"""
Get full lineup for team including bench players.
Args:
game_id: Game identifier
team_id: Team identifier
Returns:
List of all Lineup models (active + bench), active sorted by batting order first
"""
async with self._get_session() as session:
result = await session.execute(
select(Lineup)
.where(
Lineup.game_id == game_id,
Lineup.team_id == team_id,
)
.order_by(Lineup.is_active.desc(), Lineup.batting_order)
)
lineups = list(result.scalars().all())
active_count = sum(1 for l in lineups if l.is_active)
bench_count = len(lineups) - active_count
logger.debug(
f"Retrieved {len(lineups)} lineup entries for team {team_id} "
f"({active_count} active, {bench_count} bench)"
)
return lineups
async def create_substitution(
self,
game_id: UUID,
@ -522,6 +554,7 @@ class DatabaseOperations:
"current_half": game.current_half,
"home_score": game.home_score,
"away_score": game.away_score,
"game_metadata": game.game_metadata, # Team display info
},
"lineups": [
{
@ -636,7 +669,12 @@ class DatabaseOperations:
)
async def add_sba_roster_player(
self, game_id: UUID, player_id: int, team_id: int
self,
game_id: UUID,
player_id: int,
team_id: int,
player_positions: list[str] | None = None,
player_data: dict | None = None,
) -> SbaRosterLinkData:
"""
Add an SBA player to game roster.
@ -645,6 +683,9 @@ class DatabaseOperations:
game_id: Game identifier
player_id: Player identifier
team_id: Team identifier
player_positions: List of natural positions (e.g., ["SS", "2B", "3B"] or ["SP", "RP"])
Used for substitution UI filtering (batters vs pitchers)
player_data: Cached player info {name, image, headshot} to avoid runtime API calls
Returns:
SbaRosterLinkData with populated id
@ -654,18 +695,27 @@ class DatabaseOperations:
"""
async with self._get_session() as session:
roster_link = RosterLink(
game_id=game_id, player_id=player_id, team_id=team_id
game_id=game_id,
player_id=player_id,
team_id=team_id,
player_positions=player_positions or [],
player_data=player_data or {},
)
session.add(roster_link)
await session.flush()
await session.refresh(roster_link)
logger.info(f"Added SBA player {player_id} to roster for game {game_id}")
logger.info(
f"Added SBA player {player_id} to roster for game {game_id} "
f"(positions: {player_positions or []})"
)
return SbaRosterLinkData(
id=roster_link.id,
game_id=roster_link.game_id,
player_id=roster_link.player_id,
team_id=roster_link.team_id,
player_positions=roster_link.player_positions or [],
player_data=roster_link.player_data,
)
async def get_pd_roster(
@ -713,7 +763,7 @@ class DatabaseOperations:
team_id: Optional team filter
Returns:
List of SbaRosterLinkData
List of SbaRosterLinkData with player_positions
"""
async with self._get_session() as session:
query = select(RosterLink).where(
@ -732,10 +782,111 @@ class DatabaseOperations:
game_id=link.game_id,
player_id=link.player_id,
team_id=link.team_id,
player_positions=link.player_positions or [],
player_data=link.player_data,
)
for link in roster_links
]
async def get_bench_players(
self, game_id: UUID, team_id: int
) -> list[SbaRosterLinkData]:
"""
Get bench players for a team (roster players not in active lineup).
This queries RosterLink for players that are NOT in the active Lineup,
including their player_positions for UI filtering (batters vs pitchers).
Args:
game_id: Game identifier
team_id: Team identifier
Returns:
List of SbaRosterLinkData for bench players with is_pitcher/is_batter computed
"""
async with self._get_session() as session:
# Get player_ids that are in the active lineup
active_lineup_query = (
select(Lineup.player_id)
.where(
Lineup.game_id == game_id,
Lineup.team_id == team_id,
Lineup.is_active == True,
Lineup.player_id.is_not(None), # SBA players
)
)
# Get roster players NOT in active lineup
bench_query = (
select(RosterLink)
.where(
RosterLink.game_id == game_id,
RosterLink.team_id == team_id,
RosterLink.player_id.is_not(None), # SBA players
RosterLink.player_id.not_in(active_lineup_query),
)
)
result = await session.execute(bench_query)
bench_links = result.scalars().all()
logger.debug(
f"Retrieved {len(bench_links)} bench players for team {team_id} in game {game_id}"
)
return [
SbaRosterLinkData(
id=link.id,
game_id=link.game_id,
player_id=link.player_id,
team_id=link.team_id,
player_positions=link.player_positions or [],
player_data=link.player_data,
)
for link in bench_links
]
async def get_roster_player_data(
self, game_id: UUID, team_id: int
) -> dict[int, dict]:
"""
Get cached player data from RosterLink for all players on a team.
Returns a mapping of player_id -> player_data dict for quick lookup.
Used to avoid SBA API calls when loading lineups from database.
Args:
game_id: Game identifier
team_id: Team identifier
Returns:
Dict mapping player_id to player_data (name, image, headshot).
Empty dict if no roster data found.
"""
async with self._get_session() as session:
result = await session.execute(
select(RosterLink.player_id, RosterLink.player_data)
.where(
RosterLink.game_id == game_id,
RosterLink.team_id == team_id,
RosterLink.player_id.is_not(None),
RosterLink.player_data.is_not(None),
)
)
rows = result.all()
player_data_map = {
row.player_id: row.player_data
for row in rows
if row.player_id and row.player_data
}
logger.debug(
f"Retrieved cached player data for {len(player_data_map)} players "
f"from roster for team {team_id} in game {game_id}"
)
return player_data_map
async def remove_roster_entry(self, roster_id: int) -> None:
"""
Remove a roster entry by ID.

View File

@ -43,6 +43,11 @@ class RosterLink(Base):
SBA League: Uses player_id to track which players are rostered
Exactly one of card_id or player_id must be populated per row.
The player_positions field stores the player's natural positions from the API
for use in substitution UI filtering (batters vs pitchers). This supports
two-way players like Shohei Ohtani who have both pitching and batting positions.
Example: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"]
"""
__tablename__ = "roster_links"
@ -60,6 +65,14 @@ class RosterLink(Base):
player_id = Column(Integer, nullable=True) # SBA only
team_id = Column(Integer, nullable=False, index=True)
# Player's natural positions from API (for substitution UI filtering)
# Supports two-way players with both pitching and batting positions
player_positions = Column(JSONB, default=list)
# Cached player data (name, image, headshot) to avoid runtime API calls
# Populated at lineup submission time
player_data = Column(JSONB, default=dict)
# Relationships
game = relationship("Game", back_populates="roster_links")

View File

@ -60,7 +60,7 @@ class LineupPlayerState(BaseModel):
@classmethod
def validate_position(cls, v: str) -> str:
"""Ensure position is valid"""
valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH", "BN"]
if v not in valid_positions:
raise ValueError(f"Position must be one of {valid_positions}")
return v
@ -396,6 +396,18 @@ class GameState(BaseModel):
home_team_is_ai: bool = False
away_team_is_ai: bool = False
# Team display info (for UI - fetched from league API when game created)
home_team_name: str | None = None # e.g., "Chicago Cyclones"
home_team_abbrev: str | None = None # e.g., "CHC"
home_team_color: str | None = None # e.g., "ff5349" (no # prefix)
home_team_dice_color: str | None = None # Dice color, default "cc0000" (red)
home_team_thumbnail: str | None = None # Team logo URL
away_team_name: str | None = None
away_team_abbrev: str | None = None
away_team_color: str | None = None
away_team_dice_color: str | None = None # Dice color, default "cc0000" (red)
away_team_thumbnail: str | None = None
# Creator (for demo/testing - creator can control home team)
creator_discord_id: str | None = None

View File

@ -8,7 +8,7 @@ Provides league-specific type-safe models for roster operations:
from abc import ABC, abstractmethod
from uuid import UUID
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, computed_field, field_validator
class BaseRosterLinkData(BaseModel, ABC):
@ -62,9 +62,18 @@ class SbaRosterLinkData(BaseRosterLinkData):
Used for SBA league games where rosters are composed of players.
Players are identified directly by player_id without a card system.
The player_positions field stores the player's natural positions from the API
for use in substitution UI filtering (batters vs pitchers). This supports
two-way players like Shohei Ohtani who have both pitching and batting positions.
The player_data field caches essential SbaPlayer fields (name, image, headshot)
to avoid runtime API calls when loading bench players.
"""
player_id: int
player_positions: list[str] = []
player_data: dict | None = None # Cached SbaPlayer fields: {name, image, headshot}
@field_validator("player_id")
@classmethod
@ -79,6 +88,34 @@ class SbaRosterLinkData(BaseRosterLinkData):
def get_entity_type(self) -> str:
return "player"
# Pitcher positions used for filtering
# CP = Closer Pitcher (alternate notation for CL)
_PITCHER_POSITIONS: set[str] = {"P", "SP", "RP", "CL", "CP"}
@computed_field
@property
def is_pitcher(self) -> bool:
"""True if player has any pitching position (supports two-way players)
Examples:
- ["SP"] -> True
- ["SP", "DH"] -> True (two-way player like Ohtani)
- ["CF", "DH"] -> False
"""
return any(pos in self._PITCHER_POSITIONS for pos in self.player_positions)
@computed_field
@property
def is_batter(self) -> bool:
"""True if player has any non-pitching position (supports two-way players)
Examples:
- ["CF", "DH"] -> True
- ["SP", "DH"] -> True (two-way player like Ohtani)
- ["SP"] -> False (pitcher-only)
"""
return any(pos not in self._PITCHER_POSITIONS for pos in self.player_positions)
class RosterLinkCreate(BaseModel):
"""Request model for creating a roster link"""
@ -87,6 +124,7 @@ class RosterLinkCreate(BaseModel):
team_id: int
card_id: int | None = None
player_id: int | None = None
player_positions: list[str] = [] # Natural positions for substitution filtering
@field_validator("team_id")
@classmethod
@ -116,5 +154,8 @@ class RosterLinkCreate(BaseModel):
if self.player_id is None:
raise ValueError("player_id required for SBA roster")
return SbaRosterLinkData(
game_id=self.game_id, team_id=self.team_id, player_id=self.player_id
game_id=self.game_id,
team_id=self.team_id,
player_id=self.player_id,
player_positions=self.player_positions,
)

View File

@ -121,9 +121,10 @@ class LineupService:
"""
Load existing team lineup from database with player data.
1. Fetches active lineup from database
2. Fetches player data from SBA API (for SBA league)
3. Returns TeamLineupState with player info populated
1. Fetches full lineup (active + bench) from database
2. Tries to get player data from RosterLink cache first
3. Falls back to SBA API if cache misses (for SBA league)
4. Returns TeamLineupState with player info populated
Args:
game_id: Game identifier
@ -131,26 +132,49 @@ class LineupService:
league_id: League identifier ('sba' or 'pd')
Returns:
TeamLineupState with player data, or None if no lineup found
TeamLineupState with player data (including bench), or None if no lineup found
"""
# Step 1: Get lineup from database
lineup_entries = await self.db_ops.get_active_lineup(game_id, team_id)
# Step 1: Get full lineup from database (active + bench)
lineup_entries = await self.db_ops.get_full_lineup(game_id, team_id)
if not lineup_entries:
return None
# Step 2: Fetch player data for SBA league
player_data = {}
# Step 2: Get player data - try RosterLink cache first, then API fallback
player_data_cache: dict[int, dict] = {}
api_player_data: dict = {}
if league_id == "sba":
player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc]
if player_ids:
try:
player_data = await sba_api_client.get_players_batch(player_ids)
# Try RosterLink cache first
player_data_cache = await self.db_ops.get_roster_player_data(
game_id, team_id
)
cached_count = len(player_data_cache)
# Find players missing from cache
missing_ids = [
pid for pid in player_ids if pid not in player_data_cache
]
if missing_ids:
# Fall back to SBA API for missing players
try:
api_player_data = await sba_api_client.get_players_batch(
missing_ids
)
logger.info(
f"Loaded {cached_count} players from cache, "
f"{len(api_player_data)} from API for team {team_id}"
)
except Exception as e:
logger.warning(
f"Failed to fetch player data from API for team {team_id}: {e}"
)
else:
logger.info(
f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}"
)
except Exception as e:
logger.warning(
f"Failed to fetch player data for team {team_id}: {e}"
f"Loaded all {cached_count} players from RosterLink cache "
f"for team {team_id} (no API call needed)"
)
# Step 3: Build TeamLineupState with player data
@ -160,11 +184,18 @@ class LineupService:
player_image = None
player_headshot = None
if league_id == "sba" and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type]
player = player_data.get(p.player_id) # type: ignore[arg-type]
player_name = player.name
player_image = player.get_image_url()
player_headshot = player.headshot
if league_id == "sba" and p.player_id: # type: ignore[arg-type]
# Check cache first, then API data
if p.player_id in player_data_cache: # type: ignore[arg-type]
cached = player_data_cache[p.player_id] # type: ignore[arg-type]
player_name = cached.get("name")
player_image = cached.get("image")
player_headshot = cached.get("headshot")
elif p.player_id in api_player_data: # type: ignore[arg-type]
player = api_player_data[p.player_id] # type: ignore[arg-type]
player_name = player.name
player_image = player.get_image_url()
player_headshot = player.headshot
players.append(
LineupPlayerState(

View File

@ -184,6 +184,16 @@ class SbaApiClient:
team = await self.get_team_by_id(team_id, season)
if team:
result[team_id] = team
else:
logger.warning(f"Team {team_id} not found in cache for season {season}")
# Log if we couldn't find all requested teams
missing = [tid for tid in team_ids if tid not in result]
if missing:
logger.warning(
f"get_teams_by_ids: {len(missing)}/{len(team_ids)} teams not found: {missing}"
)
return result
async def get_player(self, player_id: int) -> SbaPlayer:

View File

@ -335,13 +335,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
# return
# Check if there are runners on base (affects chaos check)
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
# Roll dice
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id)
ab_roll = dice_system.roll_ab(
league_id=state.league_id,
game_id=game_id,
runners_on_base=runners_on_base,
)
logger.info(
f"Dice rolled for game {game_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}"
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}, "
f"chaos_skipped={ab_roll.chaos_check_skipped}"
)
# Store roll in game state for manual outcome validation
@ -363,6 +371,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"resolution_d20": ab_roll.resolution_d20,
"check_wild_pitch": ab_roll.check_wild_pitch,
"check_passed_ball": ab_roll.check_passed_ball,
"chaos_check_skipped": ab_roll.chaos_check_skipped,
"timestamp": ab_roll.timestamp.to_iso8601_string(),
"message": "Dice rolled - read your card and submit outcome",
},
@ -1394,6 +1403,116 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
sid, "error", {"message": "Invalid lineup request"}
)
@sio.event
async def get_bench(sid, data):
"""
Get bench players for a team (roster players not in active lineup).
This queries RosterLink for players that are NOT in the active Lineup,
including their natural positions for UI filtering (batters vs pitchers).
Supports two-way players with is_pitcher and is_batter computed properties.
Event data:
game_id: UUID of the game
team_id: int - team to get bench for
Emits:
bench_data: To requester with bench players including:
- player_positions: list of natural positions (e.g., ["SS", "2B"])
- is_pitcher: true if player has pitching positions
- is_batter: true if player has batting positions
error: To requester if validation fails
"""
await manager.update_activity(sid)
if not await rate_limiter.check_websocket_limit(sid):
await manager.emit_to_user(
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
)
return
try:
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
team_id = data.get("team_id")
if team_id is None:
await manager.emit_to_user(sid, "error", {"message": "Missing team_id"})
return
# Query bench players from RosterLink (roster players not in active lineup)
# Player data (name, image, headshot) is cached in RosterLink - no API call needed
db_ops = DatabaseOperations()
bench_roster = await db_ops.get_bench_players(game_id, team_id)
if bench_roster:
bench_players = []
for roster in bench_roster:
# Use cached player_data from RosterLink (populated at lineup submission)
pdata = roster.player_data or {}
player_name = pdata.get("name", f"Player #{roster.player_id}")
player_image = pdata.get("image", "")
player_headshot = pdata.get("headshot", "")
bench_players.append({
"roster_id": roster.id,
"player_id": roster.player_id,
"player_positions": roster.player_positions,
"is_pitcher": roster.is_pitcher,
"is_batter": roster.is_batter,
"player": {
"id": roster.player_id,
"name": player_name,
"image": player_image,
"headshot": player_headshot,
# Include positions for frontend filtering (legacy support)
"pos_1": roster.player_positions[0] if roster.player_positions else None,
"pos_2": roster.player_positions[1] if len(roster.player_positions) > 1 else None,
"pos_3": roster.player_positions[2] if len(roster.player_positions) > 2 else None,
},
})
await manager.emit_to_user(
sid,
"bench_data",
{
"game_id": str(game_id),
"team_id": team_id,
"players": bench_players,
},
)
logger.info(
f"Bench data sent for game {game_id}, team {team_id}: {len(bench_players)} players"
)
else:
await manager.emit_to_user(
sid,
"bench_data",
{"game_id": str(game_id), "team_id": team_id, "players": []},
)
logger.info(f"No bench players found for game {game_id}, team {team_id}")
except SQLAlchemyError as e:
logger.error(f"Database error in get_bench: {e}")
await manager.emit_to_user(
sid, "error", {"message": "Database error - please retry"}
)
except (ValueError, TypeError) as e:
logger.warning(f"Invalid data in get_bench request: {e}")
await manager.emit_to_user(
sid, "error", {"message": "Invalid bench request"}
)
@sio.event
async def submit_defensive_decision(sid, data):
"""

View File

@ -736,6 +736,233 @@ class TestDatabaseOperationsRoster:
roster = await db_ops.get_sba_roster(game_id)
assert len(roster) == 0
async def test_get_bench_players(self, db_ops, db_session):
"""
Test get_bench_players returns roster players NOT in active lineup.
This verifies the RosterLink refactor where:
- RosterLink contains ALL eligible players with player_positions
- Lineup contains only ACTIVE players
- Bench = RosterLink players NOT IN Lineup
Also tests computed is_pitcher/is_batter properties.
"""
game_id = uuid4()
team_id = 10
# Create game
await db_ops.create_game(
game_id=game_id,
league_id="sba",
home_team_id=team_id,
away_team_id=20,
game_mode="friendly",
visibility="public"
)
# Add players to RosterLink with player_positions
# Player 101: Pitcher only
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=101,
team_id=team_id,
player_positions=["SP", "RP"]
)
# Player 102: Batter only (shortstop)
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=102,
team_id=team_id,
player_positions=["SS", "2B"]
)
# Player 103: Two-way player (pitcher and DH)
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=103,
team_id=team_id,
player_positions=["SP", "DH"]
)
# Player 104: Outfielder (will be in active lineup)
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=104,
team_id=team_id,
player_positions=["CF", "RF"]
)
# Add player 104 to ACTIVE lineup (not bench)
await db_ops.add_sba_lineup_player(
game_id=game_id,
team_id=team_id,
player_id=104,
position="CF",
batting_order=1,
is_starter=True
)
await db_session.flush()
# Get bench players (should be 101, 102, 103 - NOT 104)
bench = await db_ops.get_bench_players(game_id, team_id)
# Verify count
assert len(bench) == 3
# Verify player IDs (104 should NOT be in bench)
bench_player_ids = {p.player_id for p in bench}
assert bench_player_ids == {101, 102, 103}
assert 104 not in bench_player_ids
# Verify computed properties for each player
bench_by_id = {p.player_id: p for p in bench}
# Player 101: Pitcher only
assert bench_by_id[101].is_pitcher is True
assert bench_by_id[101].is_batter is False
assert bench_by_id[101].player_positions == ["SP", "RP"]
# Player 102: Batter only
assert bench_by_id[102].is_pitcher is False
assert bench_by_id[102].is_batter is True
assert bench_by_id[102].player_positions == ["SS", "2B"]
# Player 103: Two-way player (BOTH is_pitcher AND is_batter)
assert bench_by_id[103].is_pitcher is True
assert bench_by_id[103].is_batter is True
assert bench_by_id[103].player_positions == ["SP", "DH"]
async def test_get_bench_players_empty(self, db_ops, db_session):
"""
Test get_bench_players returns empty list when all roster players are in lineup.
"""
game_id = uuid4()
team_id = 10
# Create game
await db_ops.create_game(
game_id=game_id,
league_id="sba",
home_team_id=team_id,
away_team_id=20,
game_mode="friendly",
visibility="public"
)
# Add player to roster
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=101,
team_id=team_id,
player_positions=["CF"]
)
# Add same player to active lineup
await db_ops.add_sba_lineup_player(
game_id=game_id,
team_id=team_id,
player_id=101,
position="CF",
batting_order=1,
is_starter=True
)
await db_session.flush()
# Get bench players (should be empty)
bench = await db_ops.get_bench_players(game_id, team_id)
assert len(bench) == 0
async def test_get_roster_player_data(self, db_ops, db_session):
"""
Test get_roster_player_data returns cached player data from RosterLink.
This verifies the optimization where lineup loading can use cached
player data from RosterLink instead of making SBA API calls.
"""
game_id = uuid4()
team_id = 10
# Create game
await db_ops.create_game(
game_id=game_id,
league_id="sba",
home_team_id=team_id,
away_team_id=20,
game_mode="friendly",
visibility="public"
)
# Add players to RosterLink with player_data
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=101,
team_id=team_id,
player_positions=["SP"],
player_data={"name": "John Pitcher", "image": "http://img/101.png", "headshot": "http://head/101.png"}
)
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=102,
team_id=team_id,
player_positions=["CF"],
player_data={"name": "Jane Outfielder", "image": "http://img/102.png", "headshot": ""}
)
# Player without player_data (should be excluded)
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=103,
team_id=team_id,
player_positions=["1B"],
player_data=None
)
await db_session.flush()
# Get roster player data
player_data = await db_ops.get_roster_player_data(game_id, team_id)
# Should have 2 players (103 excluded because player_data is None)
assert len(player_data) == 2
assert 101 in player_data
assert 102 in player_data
assert 103 not in player_data
# Verify data structure
assert player_data[101]["name"] == "John Pitcher"
assert player_data[101]["image"] == "http://img/101.png"
assert player_data[101]["headshot"] == "http://head/101.png"
assert player_data[102]["name"] == "Jane Outfielder"
async def test_get_roster_player_data_empty(self, db_ops, db_session):
"""
Test get_roster_player_data returns empty dict when no cached data exists.
"""
game_id = uuid4()
team_id = 10
# Create game
await db_ops.create_game(
game_id=game_id,
league_id="sba",
home_team_id=team_id,
away_team_id=20,
game_mode="friendly",
visibility="public"
)
# Add player WITHOUT player_data
await db_ops.add_sba_roster_player(
game_id=game_id,
player_id=101,
team_id=team_id,
player_positions=["CF"],
player_data=None
)
await db_session.flush()
# Get roster player data (should be empty)
player_data = await db_ops.get_roster_player_data(game_id, team_id)
assert len(player_data) == 0
class TestDatabaseOperationsRollback:
"""Tests for database rollback operations (delete_plays_after, etc.)"""

View File

@ -68,7 +68,8 @@ def mock_ab_roll():
resolution_d20=12,
d6_two_total=7,
check_wild_pitch=False,
check_passed_ball=False
check_passed_ball=False,
chaos_check_skipped=True, # No runners on base in mock_game_state
)
@ -118,10 +119,11 @@ async def test_roll_dice_success(mock_manager, mock_game_state, mock_ab_roll):
# Call handler
await roll_dice_handler('test_sid', {"game_id": str(mock_game_state.game_id)})
# Verify dice rolled
# Verify dice rolled (runners_on_base=False since mock_game_state has no runners)
mock_dice.roll_ab.assert_called_once_with(
league_id="sba",
game_id=mock_game_state.game_id
game_id=mock_game_state.game_id,
runners_on_base=False,
)
# Verify state updated with roll

View File

@ -0,0 +1,164 @@
# Mobile Text Selection Prevention Review
**Date**: 2026-01-17
**Purpose**: Review and apply text selection prevention patterns for mobile touch/drag interactions
## Pattern Applied
```css
/* Prevent text selection on all draggable/interactive elements AND their children */
:deep([draggable="true"]),
:deep([draggable="true"] *),
:deep(.sortable-item),
:deep(.sortable-item *),
.interactive-item,
.interactive-item * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
}
/* Touch action on containers only */
:deep([draggable="true"]),
:deep(.sortable-item) {
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
}
```
**Tailwind utilities used**: `select-none` and `touch-manipulation`
## Components Reviewed
### ✅ Already Compliant (No Changes Needed)
#### LineupBuilder.vue
- **Status**: Fully compliant
- **Reason**: Already has comprehensive text selection prevention for vuedraggable components
- **Implementation**: Uses `:deep()` selectors with complete iOS support
- **Details**:
- Prevents text selection on all draggable roster items
- Prevents text selection on lineup slot items
- Applies to all child elements using wildcard selectors
- Includes iOS-specific `-webkit-touch-callout: none` for callout menu prevention
- Uses `touch-action: manipulation` for proper touch optimization
### ✅ Updated Components
#### 1. UI/ToggleSwitch.vue
- **Changes**:
- Added `select-none` class to root container
- Added `<style scoped>` block with text selection prevention for button and all children
- Added `touch-action: manipulation` to button
- **Reason**: Toggle switches are frequently tapped on mobile and users were selecting text accidentally
#### 2. UI/ButtonGroup.vue
- **Changes**:
- Added `select-none` class to root container
- Added `<style scoped>` block with text selection prevention for all buttons and children
- Added `touch-action: manipulation` to buttons
- **Reason**: Button groups used for decisions (infield depth, outfield depth) are touch-intensive
#### 3. UI/ActionButton.vue
- **Changes**:
- Added `select-none touch-manipulation` Tailwind classes to button element
- Added `<style scoped>` block with text selection prevention for button and all children
- **Reason**: Primary action buttons (Submit, Roll Dice, etc.) are core mobile interactions
#### 4. Schedule/GameCard.vue
- **Changes**:
- Added `select-none touch-manipulation` classes to "Play This Game" button
- **Reason**: Game card buttons are frequently tapped to start games
#### 5. Substitutions/SubstitutionPanel.vue
- **Changes**:
- Added `select-none` class to tab navigation container
- Added `select-none` class to player selection grid
- Added `touch-manipulation` class to player buttons
- Updated CSS for `.tab-button` with user-select and touch-action properties
- Updated CSS for `.player-button` with user-select properties
- **Reason**: Tab navigation and player selection involve frequent tapping on mobile
#### 6. Decisions/OffensiveApproach.vue
- **Changes**:
- Added `select-none` class to action selection grid container
- Added `touch-manipulation` class to action buttons
- **Reason**: Action selection buttons (Swing Away, Steal, Hit and Run, etc.) are tapped frequently
### ⚠️ Components NOT Modified (No Touch/Drag Interactions)
#### Display/Read-Only Components
- **Game/ScoreBoard.vue** - Pure display, no interaction
- **Game/GameBoard.vue** - Visual diamond display, no dragging
- **Game/CurrentSituation.vue** - Player card display
- **Game/PlayByPlay.vue** - Text feed, needs text selection for copying plays
- **Game/GameStats.vue** - Tabular data display
#### Decision Input Components (Already Use UI Components)
- **Decisions/DecisionPanel.vue** - Container only, uses child components that were updated
- **Decisions/DefensiveSetup.vue** - Uses ButtonGroup and ToggleSwitch (already updated)
- **Decisions/StolenBaseInputs.vue** - Uses ToggleSwitch (already updated)
#### Gameplay Components
- **Gameplay/DiceRoller.vue** - Uses ActionButton (already updated)
- **Gameplay/ManualOutcomeEntry.vue** - Form inputs (needs text selection)
- **Gameplay/PlayResult.vue** - Display only
#### Substitution Components (Use Updated SubstitutionPanel)
- **Substitutions/PinchHitterSelector.vue** - Uses parent panel's classes
- **Substitutions/PitchingChangeSelector.vue** - Uses parent panel's classes
- **Substitutions/DefensiveReplacementSelector.vue** - Uses parent panel's classes
## Summary Statistics
- **Total Components Reviewed**: 33 Vue files
- **Components Updated**: 6
- **Components Already Compliant**: 1 (LineupBuilder.vue)
- **Components Skipped (Read-Only/Form Inputs)**: 26
## Testing Recommendations
Test on actual mobile devices:
1. **iPhone/iPad** (iOS Safari) - Test `-webkit-touch-callout` prevention
2. **Android** (Chrome Mobile) - Test general touch behavior
3. **Focus Areas**:
- Tap buttons rapidly without text selection
- Drag roster players in LineupBuilder without selecting text
- Toggle switches in defensive/offensive decisions
- Tap player cards in substitution panel
- Select actions in OffensiveApproach
## Pattern Rationale
### Why `select-none` on Interactive Elements?
- Mobile users often tap and hold slightly too long, triggering text selection
- Text selection on buttons/cards creates confusing blue highlight overlays
- Improves perceived responsiveness of the UI
### Why `touch-manipulation`?
- Optimizes touch events for browser (faster response)
- Allows pan/zoom gestures but disables double-tap-to-zoom on these elements
- Better UX for game controls
### Why NOT Apply to Everything?
- **Form inputs** need text selection for editing values
- **Play-by-play text** users may want to copy/paste plays
- **Score displays** may be useful to copy scores
- Only apply where text selection is **purely accidental** and **never intentional**
## Future Considerations
If new components are added with:
- Drag-and-drop functionality
- Touch-based sliders/toggles
- Clickable cards/buttons
- Tab navigation
Remember to apply this pattern immediately.
---
**Reviewed By**: Claude (Atlas - Principal Engineer)
**Review Type**: Mobile UX Enhancement
**Compliance**: Mobile-First Design Standards

View File

@ -0,0 +1,156 @@
# Mobile Touch Patterns
## Text Selection Prevention for Touch Interactions
When building components with drag-and-drop, touch gestures, or interactive elements on mobile, text selection can interfere with the user experience. Users attempting to drag items may accidentally select text instead.
### The Problem
On mobile devices:
- Touch-and-hold triggers text selection
- Dragging while text is selected feels broken
- iOS shows callout menus on long press
- Double-tap zoom can interfere with interactions
### The Solution
Apply a combination of CSS rules and Tailwind classes to prevent text selection on interactive elements.
## CSS Pattern
Add this to your component's `<style scoped>` section:
```css
/* Prevent text selection on all draggable/interactive elements AND their children */
:deep([draggable="true"]),
:deep([draggable="true"] *),
:deep(.sortable-item),
:deep(.sortable-item *),
.interactive-item,
.interactive-item * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
}
/* Touch action on containers only (not children) */
:deep([draggable="true"]),
:deep(.sortable-item) {
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
}
/* Apply to chosen/active drag states */
:deep(.sortable-chosen),
:deep(.sortable-chosen *) {
-webkit-user-select: none !important;
user-select: none !important;
}
```
## Tailwind Classes
Add these classes to interactive container elements:
```html
<div class="select-none touch-manipulation">
<!-- Interactive content -->
</div>
```
- `select-none` - Prevents text selection (`user-select: none`)
- `touch-manipulation` - Optimizes touch handling (`touch-action: manipulation`)
## When to Apply
Apply this pattern to:
1. **Draggable elements** - Any element using vuedraggable, SortableJS, or native drag-and-drop
2. **Interactive cards/list items** - Items users tap frequently
3. **Custom sliders/controls** - Touch-based UI controls
4. **Bottom sheets/modals** - Draggable overlays
5. **Swipeable elements** - Carousels, dismissible items
## When NOT to Apply
Do NOT apply to:
1. **Form inputs** - Text fields, textareas need selection
2. **Read-only content** - Articles, documentation, static text
3. **Copyable content** - Code blocks, IDs, URLs users might copy
## Vuedraggable Configuration
When using vuedraggable, also configure touch-friendly options:
```typescript
const dragOptions = {
animation: 200,
ghostClass: 'drag-ghost',
chosenClass: 'drag-chosen',
dragClass: 'drag-dragging',
// Touch settings for mobile
delay: 50, // Small delay before drag starts
delayOnTouchOnly: true, // Only apply delay on touch devices
touchStartThreshold: 3, // Pixels of movement before drag starts
}
```
## Example Implementation
```vue
<template>
<div class="select-none">
<draggable
:list="items"
item-key="id"
v-bind="dragOptions"
class="space-y-2"
>
<template #item="{ element }">
<div class="item-card select-none touch-manipulation cursor-grab active:cursor-grabbing">
<span class="font-medium">{{ element.name }}</span>
</div>
</template>
</draggable>
</div>
</template>
<style scoped>
/* Full pattern from above */
:deep([draggable="true"]),
:deep([draggable="true"] *) {
-webkit-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
}
:deep([draggable="true"]) {
touch-action: manipulation;
}
</style>
```
## Testing Checklist
When testing on mobile:
- [ ] Can drag items without text selection appearing
- [ ] No iOS callout menu on long press
- [ ] Drag feels responsive (not delayed)
- [ ] Can still scroll the page normally
- [ ] Form inputs still allow text selection
- [ ] No double-tap zoom interference
## References
- [MDN: user-select](https://developer.mozilla.org/en-US/docs/Web/CSS/user-select)
- [MDN: touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
- [SortableJS Options](https://github.com/SortableJS/Sortable#options)
- [Vuedraggable Documentation](https://github.com/SortableJS/vue.draggable.next)
---
**Created**: 2025-01-17
**Pattern Source**: LineupBuilder.vue mobile drag-and-drop implementation

View File

@ -0,0 +1,247 @@
# UI Overhaul Test Plan
## Overview
Testing the in-game UI improvements from feature/game-page-polish branch.
## Prerequisites
- Production build running (`./start.sh prod`)
- Access to https://gameplay-demo.manticorum.com
- Mobile device or browser dev tools for mobile testing
- An active game with both lineups submitted
---
## Test 1: CurrentSituation Player Cards
### 1.1 Desktop - Pitcher Card
- [x] Navigate to active game
- [x] Locate pitcher card in CurrentSituation component
- [x] **Click** pitcher card
- [x] **Expected**: PlayerCardModal opens showing pitcher's full playing card image
- [x] **Expected**: Modal shows player name, position
- [x] **Click** outside modal or X button
- [x] **Expected**: Modal closes
### 1.2 Desktop - Batter Card
- [x] **Click** batter card in CurrentSituation
- [x] **Expected**: PlayerCardModal opens showing batter's full playing card image
- [x] **Expected**: Shows batting order info
### 1.3 Mobile - Pitcher Card
- [x] Switch to mobile view (or use actual device)
- [x] **Tap** pitcher card
- [x] **Expected**: Modal slides up from bottom
- [x] **Swipe down** on modal
- [x] **Expected**: Modal closes
### 1.4 Mobile - Batter Card
- [x] **Tap** batter card
- [x] **Expected**: Modal slides up showing batter's card
---
## Test 2: GameBoard Interactivity
### 2.1 Expand/Collapse Field View
- [x] Locate expand button (bottom-right of diamond)
- [x] **Click** expand button
- [x] **Expected**: All 9 fielder positions appear (C, 1B, 2B, SS, 3B, LF, CF, RF + P already visible)
- [x] **Click** expand button again (now shows X)
- [x] **Expected**: Field collapses back to simplified view
### 2.2 Tappable Pitcher on Diamond
- [x] **Click** pitcher indicator on mound
- [x] **Expected**: PlayerCardModal opens with pitcher's card
### 2.3 Tappable Batter on Diamond
- [x] **Click** batter indicator at home plate
- [x] **Expected**: PlayerCardModal opens with batter's card
---
## Test 3: Post-Roll Card Display
### 3.1 Batter Card Display (d6 = 1-3)
- [x] Start a play (decisions submitted)
- [x] **Click** "Roll Dice" button
- [x] If d6_one is 1, 2, or 3:
- [x] **Expected**: Batter's card image appears inline
- [x] **Expected**: Shows "BATTER CARD" label in red
- [x] **Expected**: Full-width card image for readability
### 3.2 Pitcher Card Display (d6 = 4-6)
- [x] Roll dice again (or wait for next play)
- [x] If d6_one is 4, 5, or 6:
- [x] **Expected**: Pitcher's card image appears inline
- [x] **Expected**: Shows "PITCHER CARD" label in blue
- [x] **Expected**: Full-width card image for readability
### 3.3 Card Fallback
- [x] If player has no image:
- [x] **Expected**: Shows initials placeholder instead
---
## Test 4: OutcomeWizard Progressive Disclosure
### 4.1 Category Selection (Step 1)
- [x] After rolling dice, locate outcome selection area
- [x] **Expected**: Three large category buttons visible:
- [x] "On Base" (green)
- [x] "Out" (red)
- [x] "X-Check" (orange)
### 4.2 On Base Flow
- [x] **Click** "On Base"
- [x] **Expected**: Sub-categories appear (Single, Double, Triple, Home Run, Walk, HBP)
- [x] **Click** "Single"
- [x] **Expected**: Specific outcomes appear (Single (1 base), Single (2 bases), Single (uncapped))
- [x] **Click** "Back" button
- [x] **Expected**: Returns to sub-category selection
- [x] **Click** "Home Run"
- [x] **Expected**: Outcome submitted directly (no further steps needed)
### 4.3 Out Flow with Location
- [x] Start new play, roll dice
- [x] **Click** "Out"
- [x] **Click** "Groundout"
- [x] **Expected**: Specific groundout types appear (A, B, C)
- [x] **Click** "Groundball A"
- [x] **Expected**: Location picker appears (diamond with position buttons)
- [x] **Click** position button (e.g., "SS")
- [x] **Expected**: Outcome submitted with location
### 4.4 X-Check Flow
- [x] Start new play, roll dice
- [x] **Click** "X-Check"
- [x] **Expected**: Location picker appears immediately
- [x] **Click** any position
- [x] **Expected**: X-Check outcome submitted
### 4.5 Cancel Flow
- [x] Start outcome selection
- [x] **Click** "Cancel" button
- [x] **Expected**: Wizard resets to step 1
> **NOTE**: Future improvement needed - only show hit location picker when location actually matters for gameplay (e.g., fielder's choice, X-Check). Skip for outcomes where location is cosmetic only.
---
## Test 5: Mobile-Specific Tests
### 5.1 Touch Targets
- [x] All buttons are at least 44x44px
- [x] Easy to tap without accidental touches
### 5.2 Scrolling
- [x] During normal gameplay, minimal scrolling required
- [x] Outcome wizard fits on screen without scrolling
### 5.3 Swipe Gestures
- [x] PlayerCardModal: Swipe down to close
- [x] BottomSheet (if integrated): Swipe down to minimize
---
## Test 6: Dark Mode
### 6.1 PlayerCardModal
- [ ] Switch to dark mode (system preference)
- [ ] Open PlayerCardModal
- [ ] **Expected**: Dark background, readable text
### 6.2 OutcomeWizard
- [ ] **Expected**: Category buttons have appropriate dark mode colors
- [ ] **Expected**: Location picker readable in dark mode
### 6.3 Post-Roll Card Display
- [ ] **Expected**: Amber/orange styling visible in dark mode
---
## Test 7: Edge Cases
### 7.1 Missing Player Data
- [x] If player image URL is broken or missing:
- [x] **Expected**: Falls back to initials display
- [x] **Expected**: No JS errors in console
### 7.2 Rapid Interactions
- [x] Click multiple cards quickly
- [x] **Expected**: Only one modal opens at a time
### 7.3 Game State Transitions
- [x] Complete a play
- [x] **Expected**: Outcome wizard resets for next play
- [x] **Expected**: Post-roll card display clears
---
## Known Limitations
1. **BottomSheet not yet integrated** with DecisionPanel - decision panels still render inline
2. **Substitute button** in PlayerCardModal is hidden (showSubstituteButton=false)
3. **Dark mode button visibility on iOS** - ActionButton, DiceRoller buttons missing visible backgrounds/borders on iOS Safari/Brave. CSS gradient backgrounds and ring borders not rendering. Needs further investigation (possibly iOS-specific CSS issue or Tailwind purging).
---
## Test Results
| Test | Status | Notes |
| ------------------------- | ------ | ----- |
| 1.1 Desktop Pitcher Card | ✅ PASS | |
| 1.2 Desktop Batter Card | ✅ PASS | |
| 1.3 Mobile Pitcher Card | ✅ PASS | |
| 1.4 Mobile Batter Card | ✅ PASS | |
| 2.1 Expand/Collapse | ✅ PASS | Fielders show player initials |
| 2.2 Tappable Pitcher | ✅ PASS | |
| 2.3 Tappable Batter | ✅ PASS | |
| 3.1 Batter Card (d6 1-3) | ✅ PASS | Full-width layout |
| 3.2 Pitcher Card (d6 4-6) | ✅ PASS | Full-width layout |
| 3.3 Card Fallback | ✅ PASS | |
| 4.1 Category Selection | ✅ PASS | |
| 4.2 On Base Flow | ✅ PASS | |
| 4.3 Out Flow + Location | ✅ PASS | Fixed batter advancement bug |
| 4.4 X-Check Flow | ✅ PASS | |
| 4.5 Cancel Flow | ✅ PASS | |
| 5.1 Touch Targets | ✅ PASS | |
| 5.2 Scrolling | ✅ PASS | |
| 5.3 Swipe Gestures | ✅ PASS | |
| 6.1 Dark Mode Modal | ✅ PASS | |
| 6.2 Dark Mode Wizard | ✅ PASS | |
| 6.3 Dark Mode Post-Roll | ⚠️ ISSUE | Buttons missing visible styling on iOS - needs investigation |
| 7.1 Missing Player Data | ✅ PASS | |
| 7.2 Rapid Interactions | ✅ PASS | |
| 7.3 State Transitions | ✅ PASS | |
---
**Tester**: ******\_\_\_\_******
**Date**: ******\_\_\_\_******
**Build**: feature/game-page-polish

View File

@ -55,9 +55,13 @@ frontend-sba/
## Development
> **⚠️ ALWAYS USE PROD MODE**: Run the full stack via Docker with `./start.sh prod` from the project root. Dev mode (`./start.sh dev`) has broken Discord OAuth due to cookie/CORS issues. The system isn't live yet, so always use prod for testing.
```bash
npm install # First time
npm run dev # Dev server at http://localhost:3000
# From project root - use this for testing
./start.sh prod
# Local commands (for type checking only, not running)
npm run type-check # Check types
npm run lint # Lint code
```

File diff suppressed because it is too large Load Diff

View File

@ -21,13 +21,14 @@
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Select Action
</label>
<div class="grid grid-cols-1 gap-3">
<div class="grid grid-cols-1 gap-3 select-none">
<button
v-for="option in availableActions"
:key="option.value"
type="button"
:disabled="!isActive || option.disabled"
:class="getActionButtonClasses(option.value, option.disabled)"
class="touch-manipulation"
:title="option.disabledReason"
@click="selectAction(option.value)"
>

View File

@ -1,154 +1,70 @@
<template>
<div class="current-situation">
<!-- Mobile Layout (Stacked) -->
<div class="lg:hidden space-y-3">
<!-- Side-by-Side Card Layout -->
<div class="grid grid-cols-2 gap-4">
<!-- Current Pitcher Card -->
<div
<button
v-if="currentPitcher"
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md"
:class="[
'player-card pitcher-card card-transition',
pitcherCardClasses
]"
@click="openPlayerCard('pitcher')"
>
<div class="flex items-center gap-3">
<!-- Pitcher Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<img
v-if="pitcherPlayer?.headshot"
:src="pitcherPlayer.headshot"
:alt="pitcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg">
P
</div>
</div>
<!-- Card Header -->
<div class="card-header pitcher-header">
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
<span class="position-info">P</span>
<span class="player-name">{{ pitcherName }}</span>
</div>
<!-- Pitcher Info -->
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wide mb-0.5">
Pitching
</div>
<div class="text-base font-bold text-gray-900 dark:text-white truncate">
{{ pitcherName }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ currentPitcher.position }}
</div>
<!-- Card Image -->
<div class="card-image-container">
<img
v-if="pitcherPlayer?.image"
:src="pitcherPlayer.image"
:alt="`${pitcherName} card`"
class="card-image"
@error="handleImageError"
>
<div v-else class="card-placeholder pitcher-placeholder">
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
<span class="placeholder-label">No Card Image</span>
</div>
</div>
</div>
<!-- VS Indicator -->
<div class="flex items-center justify-center">
<div class="px-4 py-1 bg-gray-800 dark:bg-gray-700 text-white rounded-full text-xs font-bold shadow-lg">
VS
</div>
</div>
</button>
<!-- Current Batter Card -->
<div
<button
v-if="currentBatter"
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md"
:class="[
'player-card batter-card card-transition',
batterCardClasses
]"
@click="openPlayerCard('batter')"
>
<div class="flex items-center gap-3">
<!-- Batter Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<img
v-if="batterPlayer?.headshot"
:src="batterPlayer.headshot"
:alt="batterName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-red-500 flex items-center justify-center text-white font-bold text-lg">
B
</div>
</div>
<!-- Card Header -->
<div class="card-header batter-header">
<span class="team-abbrev">{{ batterTeamAbbrev }}</span>
<span class="position-info">{{ currentBatter.batting_order }}. {{ currentBatter.position }}</span>
<span class="player-name">{{ batterName }}</span>
</div>
<!-- Batter Info -->
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5">
At Bat
</div>
<div class="text-base font-bold text-gray-900 dark:text-white truncate">
{{ batterName }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ currentBatter.position }}
<span v-if="currentBatter.batting_order" class="ml-1"> Batting {{ currentBatter.batting_order }}</span>
</div>
<!-- Card Image -->
<div class="card-image-container">
<img
v-if="batterPlayer?.image"
:src="batterPlayer.image"
:alt="`${batterName} card`"
class="card-image"
@error="handleImageError"
>
<div v-else class="card-placeholder batter-placeholder">
<span class="placeholder-initials">{{ getPlayerFallbackInitial(batterPlayer) }}</span>
<span class="placeholder-label">No Card Image</span>
</div>
</div>
</div>
</div>
<!-- Desktop Layout (Side-by-Side) -->
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
<!-- Current Pitcher Card -->
<div
v-if="currentPitcher"
class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg"
>
<div class="flex items-start gap-4">
<!-- Pitcher Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<img
v-if="pitcherPlayer?.headshot"
:src="pitcherPlayer.headshot"
:alt="pitcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-blue-500 flex items-center justify-center text-white font-bold text-2xl">
P
</div>
</div>
<!-- Pitcher Details -->
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wide mb-1">
Pitching
</div>
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
{{ pitcherName }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ currentPitcher.position }}
</div>
</div>
</div>
</div>
<!-- Current Batter Card -->
<div
v-if="currentBatter"
class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg"
>
<div class="flex items-start gap-4">
<!-- Batter Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<img
v-if="batterPlayer?.headshot"
:src="batterPlayer.headshot"
:alt="batterName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-red-500 flex items-center justify-center text-white font-bold text-2xl">
B
</div>
</div>
<!-- Batter Details -->
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-1">
At Bat
</div>
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
{{ batterName }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ currentBatter.position }}
<span v-if="currentBatter.batting_order" class="ml-2"> Batting {{ currentBatter.batting_order }}</span>
</div>
</div>
</div>
</div>
</button>
</div>
<!-- Empty State -->
@ -164,22 +80,39 @@
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
</div>
<!-- Player Card Modal -->
<PlayerCardModal
:is-open="isPlayerCardOpen"
:player="selectedPlayerData"
:position="selectedPlayerPosition"
:team-name="selectedPlayerTeam"
:show-substitute-button="false"
@close="closePlayerCard"
/>
</div>
</template>
<script setup lang="ts">
import { computed, watch, toRefs } from 'vue'
import { computed, watch, toRefs, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import { useGameStore } from '~/store/game'
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
interface Props {
currentBatter?: LineupPlayerState | null
currentPitcher?: LineupPlayerState | null
activeCard?: 'batter' | 'pitcher' | null // Which card to highlight (after dice roll)
batterTeamAbbrev?: string
pitcherTeamAbbrev?: string
}
const props = withDefaults(defineProps<Props>(), {
currentBatter: null,
currentPitcher: null
currentPitcher: null,
activeCard: null,
batterTeamAbbrev: '',
pitcherTeamAbbrev: '',
})
// Debug: Watch for prop changes
@ -221,22 +154,212 @@ const pitcherName = computed(() => {
if (!props.currentPitcher) return 'Unknown Pitcher'
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
})
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
// Ignores common suffixes like Jr, Sr, II, III, IV
function getPlayerFallbackInitial(player: { name: string } | null): string {
if (!player) return '?'
const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v']
const parts = player.name.trim().split(/\s+/).filter(
part => !suffixes.includes(part.toLowerCase())
)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
}
// Handle image loading errors
function handleImageError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
// Show sibling placeholder
const placeholder = img.nextElementSibling
if (placeholder) {
(placeholder as HTMLElement).style.display = 'flex'
}
}
// Player card modal state
const isPlayerCardOpen = ref(false)
const selectedPlayerData = ref<{
id: number
name: string
image: string
headshot?: string
} | null>(null)
const selectedPlayerPosition = ref('')
const selectedPlayerTeam = ref('')
// Card highlight state based on activeCard prop
const isPitcherActive = computed(() => props.activeCard === 'pitcher')
const isBatterActive = computed(() => props.activeCard === 'batter')
const hasActiveCard = computed(() => props.activeCard !== null)
// Dynamic classes for pitcher card
const pitcherCardClasses = computed(() => ({
'card-active': isPitcherActive.value,
'card-inactive': hasActiveCard.value && !isPitcherActive.value,
}))
// Dynamic classes for batter card
const batterCardClasses = computed(() => ({
'card-active': isBatterActive.value,
'card-inactive': hasActiveCard.value && !isBatterActive.value,
}))
// Open player card modal for batter or pitcher
function openPlayerCard(type: 'batter' | 'pitcher') {
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
if (!player) return
selectedPlayerData.value = {
id: player.id,
name: player.name,
image: player.image || '',
headshot: player.headshot || undefined
}
selectedPlayerPosition.value = state?.position || ''
selectedPlayerTeam.value = type === 'batter' ? props.batterTeamAbbrev : props.pitcherTeamAbbrev
isPlayerCardOpen.value = true
}
function closePlayerCard() {
isPlayerCardOpen.value = false
selectedPlayerData.value = null
}
</script>
<style scoped>
/* Optional: Add subtle animations */
.current-situation > div {
animation: fadeIn 0.3s ease-in;
/* Card Container */
.player-card {
@apply rounded-xl overflow-hidden cursor-pointer;
@apply border-2 shadow-lg;
@apply flex flex-col;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
.pitcher-card {
@apply bg-gradient-to-b from-blue-900 to-blue-950 border-blue-600;
}
.batter-card {
@apply bg-gradient-to-b from-red-900 to-red-950 border-red-600;
}
/* Card Header */
.card-header {
@apply px-3 py-2 flex items-center gap-2 text-white;
@apply text-sm font-semibold;
}
.pitcher-header {
@apply bg-blue-800/80;
}
.batter-header {
@apply bg-red-800/80;
}
.team-abbrev {
@apply font-bold text-white/90;
}
.position-info {
@apply text-white/70;
}
.player-name {
@apply truncate flex-1 text-right font-bold;
}
/* Card Image Container */
.card-image-container {
@apply relative w-full;
}
.card-image {
@apply w-full h-auto object-contain;
}
/* Placeholder when no image */
.card-placeholder {
@apply w-full flex flex-col items-center justify-center;
@apply py-12;
}
.pitcher-placeholder {
@apply bg-gradient-to-br from-blue-700 to-blue-900;
}
.batter-placeholder {
@apply bg-gradient-to-br from-red-700 to-red-900;
}
.placeholder-initials {
@apply text-5xl font-bold text-white/60;
}
.placeholder-label {
@apply text-sm text-white/40 mt-2;
}
/* Card highlight transition */
.card-transition {
@apply transition-all duration-300 ease-in-out;
}
/* Active card styling - emphasized */
.card-active {
@apply scale-105 z-10;
animation: pulseGlow 2s ease-in-out infinite;
}
/* Inactive card styling - dimmed */
.card-inactive {
@apply opacity-50 scale-95;
}
/* Pulsing glow for active pitcher card */
.pitcher-card.card-active {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
to {
opacity: 1;
transform: translateY(0);
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
/* Pulsing glow for active batter card */
.batter-card.card-active {
animation: pulseGlowRed 2s ease-in-out infinite;
}
@keyframes pulseGlowRed {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(239, 68, 68, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(239, 68, 68, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
/* Responsive: Smaller cards on mobile */
@media (max-width: 640px) {
.card-header {
@apply px-2 py-1.5 text-xs;
}
.placeholder-initials {
@apply text-3xl;
}
.placeholder-label {
@apply text-xs;
}
}
</style>

View File

@ -31,9 +31,10 @@
</div>
<!-- Current Pitcher (on mound) -->
<div
<button
v-if="currentPitcher"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('pitcher')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-blue-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold">
@ -43,7 +44,7 @@
{{ getPitcherName }}
</div>
</div>
</div>
</button>
<!-- Home Plate -->
<div class="absolute bottom-[14%] left-1/2 -translate-x-1/2">
@ -52,9 +53,10 @@
<div class="w-8 h-8 bg-white rotate-45 shadow-xl border-2 border-gray-200"/>
<!-- Current Batter -->
<div
<button
v-if="currentBatter"
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32"
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('batter')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-red-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold mb-1">
@ -67,7 +69,7 @@
Batting {{ currentBatter.batting_order }}
</div>
</div>
</div>
</button>
</div>
</div>
@ -138,50 +140,123 @@
<div class="absolute inset-0 opacity-10 pointer-events-none">
<div class="absolute inset-0" style="background: repeating-linear-gradient(90deg, transparent 0px, transparent 20px, rgba(0,0,0,0.05) 20px, rgba(0,0,0,0.05) 40px)"/>
</div>
<!-- Expanded View: All 9 Fielder Positions -->
<template v-if="isExpanded">
<!-- Catcher (behind home) -->
<button
class="absolute bottom-[6%] left-1/2 -translate-x-1/2 fielder-button"
:class="{ 'fielder-active': getFielderInfo('C').exists }"
:title="getFielderInfo('C').name || 'Catcher'"
@click="openFielderCard('C')"
>
<span class="fielder-label">{{ getFielderInfo('C').initials }}</span>
</button>
<!-- First Baseman -->
<button
class="absolute top-[45%] right-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('1B').exists }"
:title="getFielderInfo('1B').name || 'First Base'"
@click="openFielderCard('1B')"
>
<span class="fielder-label">{{ getFielderInfo('1B').initials }}</span>
</button>
<!-- Second Baseman -->
<button
class="absolute top-[35%] right-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('2B').exists }"
:title="getFielderInfo('2B').name || 'Second Base'"
@click="openFielderCard('2B')"
>
<span class="fielder-label">{{ getFielderInfo('2B').initials }}</span>
</button>
<!-- Shortstop -->
<button
class="absolute top-[35%] left-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('SS').exists }"
:title="getFielderInfo('SS').name || 'Shortstop'"
@click="openFielderCard('SS')"
>
<span class="fielder-label">{{ getFielderInfo('SS').initials }}</span>
</button>
<!-- Third Baseman -->
<button
class="absolute top-[45%] left-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('3B').exists }"
:title="getFielderInfo('3B').name || 'Third Base'"
@click="openFielderCard('3B')"
>
<span class="fielder-label">{{ getFielderInfo('3B').initials }}</span>
</button>
<!-- Left Fielder -->
<button
class="absolute top-[15%] left-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('LF').exists }"
:title="getFielderInfo('LF').name || 'Left Field'"
@click="openFielderCard('LF')"
>
<span class="fielder-label">{{ getFielderInfo('LF').initials }}</span>
</button>
<!-- Center Fielder -->
<button
class="absolute top-[8%] left-1/2 -translate-x-1/2 fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('CF').exists }"
:title="getFielderInfo('CF').name || 'Center Field'"
@click="openFielderCard('CF')"
>
<span class="fielder-label">{{ getFielderInfo('CF').initials }}</span>
</button>
<!-- Right Fielder -->
<button
class="absolute top-[15%] right-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('RF').exists }"
:title="getFielderInfo('RF').name || 'Right Field'"
@click="openFielderCard('RF')"
>
<span class="fielder-label">{{ getFielderInfo('RF').initials }}</span>
</button>
</template>
<!-- Expand/Collapse Button -->
<button
class="absolute bottom-2 right-2 w-8 h-8 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center text-gray-700 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-400"
@click="toggleExpanded"
:title="isExpanded ? 'Collapse field view' : 'Expand to see all fielders'"
>
<svg v-if="!isExpanded" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Mobile-Friendly Info Panel Below Diamond -->
<div class="mt-4 lg:hidden">
<div class="grid grid-cols-2 gap-3">
<!-- Current Batter Card -->
<div
v-if="currentBatter"
class="bg-red-50 border-2 border-red-200 rounded-lg p-3"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
B
</div>
<div class="text-xs font-semibold text-red-900">AT BAT</div>
</div>
<div class="text-sm font-bold text-gray-900">{{ getBatterName }}</div>
<div class="text-xs text-gray-600">{{ currentBatter.position }} #{{ currentBatter.batting_order }}</div>
</div>
<!-- Current Pitcher Card -->
<div
v-if="currentPitcher"
class="bg-blue-50 border-2 border-blue-200 rounded-lg p-3"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
P
</div>
<div class="text-xs font-semibold text-blue-900">PITCHING</div>
</div>
<div class="text-sm font-bold text-gray-900">{{ getPitcherName }}</div>
<div class="text-xs text-gray-600">{{ currentPitcher.position }}</div>
</div>
</div>
</div>
<!-- Player Card Modal -->
<PlayerCardModal
:is-open="isPlayerCardOpen"
:player="selectedPlayerData"
:position="selectedPlayerPosition"
:show-substitute-button="false"
@close="closePlayerCard"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
interface Props {
runners?: {
@ -191,16 +266,29 @@ interface Props {
}
currentBatter?: LineupPlayerState | null
currentPitcher?: LineupPlayerState | null
fieldingLineup?: Lineup[]
}
const props = withDefaults(defineProps<Props>(), {
runners: () => ({ first: false, second: false, third: false }),
currentBatter: null,
currentPitcher: null
currentPitcher: null,
fieldingLineup: () => []
})
const gameStore = useGameStore()
// UI State
const isExpanded = ref(false)
const isPlayerCardOpen = ref(false)
const selectedPlayerData = ref<{
id: number
name: string
image: string
headshot?: string
} | null>(null)
const selectedPlayerPosition = ref('')
// Resolve player data from lineup using lineup_id
const batterPlayer = computed(() => {
if (!props.currentBatter) return null
@ -217,6 +305,66 @@ const pitcherPlayer = computed(() => {
// Helper to get player name with fallback
const getBatterName = computed(() => batterPlayer.value?.name ?? `Player #${props.currentBatter?.lineup_id}`)
const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${props.currentPitcher?.lineup_id}`)
// Toggle expanded view
function toggleExpanded() {
isExpanded.value = !isExpanded.value
}
// Open player card modal
function openPlayerCard(type: 'batter' | 'pitcher') {
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
if (!player) return
selectedPlayerData.value = {
id: player.id,
name: player.name,
image: player.image || '',
headshot: player.headshot || undefined
}
selectedPlayerPosition.value = state?.position || (type === 'pitcher' ? 'P' : '')
isPlayerCardOpen.value = true
}
function closePlayerCard() {
isPlayerCardOpen.value = false
selectedPlayerData.value = null
}
// Get fielder by position from lineup
function getFielderByPosition(position: string): Lineup | null {
return props.fieldingLineup.find(p => p.position === position) || null
}
// Open fielder card modal
function openFielderCard(position: string) {
const fielder = getFielderByPosition(position)
if (!fielder) return
selectedPlayerData.value = {
id: fielder.player.id,
name: fielder.player.name,
image: fielder.player.image || '',
headshot: fielder.player.headshot || undefined
}
selectedPlayerPosition.value = position
isPlayerCardOpen.value = true
}
// Get fielder display info (name initials and whether they exist)
function getFielderInfo(position: string): { initials: string; name: string; exists: boolean } {
const fielder = getFielderByPosition(position)
if (!fielder) {
return { initials: position, name: '', exists: false }
}
const nameParts = fielder.player.name.split(' ')
const initials = nameParts.length >= 2
? `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}`
: nameParts[0].substring(0, 2)
return { initials: initials.toUpperCase(), name: fielder.player.name, exists: true }
}
</script>
<style scoped>
@ -235,4 +383,31 @@ const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${pr
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Fielder buttons in expanded view */
.fielder-button {
@apply w-7 h-7 rounded-full border-2 border-white shadow-lg;
@apply flex items-center justify-center;
@apply bg-gray-500 text-white;
@apply cursor-pointer transition-all duration-200;
@apply hover:scale-110 hover:bg-gray-600;
@apply focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1;
}
.fielder-button.fielder-active {
@apply bg-blue-600 hover:bg-blue-700;
}
.fielder-button.fielder-outfield {
@apply bg-green-600;
}
.fielder-button.fielder-outfield.fielder-active {
@apply bg-emerald-600 hover:bg-emerald-700;
}
.fielder-label {
@apply text-[9px] font-bold leading-none;
}
</style>

View File

@ -62,27 +62,22 @@
<!-- Mobile Layout (Stacked) -->
<div class="lg:hidden space-y-6">
<!-- Current Situation -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
/>
<!-- Game Board -->
<GameBoard
:runners="runnersState"
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:fielding-lineup="fieldingLineup"
/>
<!-- Play-by-Play Feed -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-md">
<PlayByPlay
:plays="playHistory"
:limit="5"
:compact="true"
/>
</div>
<!-- Current Situation (below diamond, above gameplay panel) -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:active-card="activeCard"
:batter-team-abbrev="batterTeamAbbrev"
:pitcher-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Decision Panel (Phase F3) -->
<DecisionPanel
@ -112,31 +107,45 @@
:can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
:dice-color="diceColor"
@roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult"
/>
<!-- Play-by-Play Feed (below gameplay on mobile) -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-md">
<PlayByPlay
:plays="playHistory"
:limit="5"
:compact="true"
/>
</div>
</div>
<!-- Desktop Layout (Grid) -->
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
<!-- Left Column: Game State -->
<div class="lg:col-span-2 space-y-6">
<!-- Current Situation -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
/>
<!-- Game Board -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
<GameBoard
:runners="runnersState"
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:fielding-lineup="fieldingLineup"
/>
</div>
<!-- Current Situation (below diamond, above gameplay panel) -->
<CurrentSituation
:current-batter="gameState?.current_batter"
:current-pitcher="gameState?.current_pitcher"
:active-card="activeCard"
:batter-team-abbrev="batterTeamAbbrev"
:pitcher-team-abbrev="pitcherTeamAbbrev"
/>
<!-- Decision Panel (Phase F3) -->
<DecisionPanel
v-if="showDecisions"
@ -165,6 +174,7 @@
:can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
:dice-color="diceColor"
@roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult"
@ -293,34 +303,8 @@
</div>
</div>
<!-- Substitution Panel Modal (Phase F5) -->
<Teleport to="body">
<div
v-if="showSubstitutions"
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
@click.self="handleSubstitutionCancel"
>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<SubstitutionPanel
v-if="myTeamId"
:game-id="gameId"
:team-id="myTeamId"
:current-lineup="currentLineup"
:bench-players="benchPlayers"
:current-pitcher="currentPitcher"
:current-batter="currentBatter"
@pinch-hitter="handlePinchHitter"
@defensive-replacement="handleDefensiveReplacement"
@pitching-change="handlePitchingChange"
@cancel="handleSubstitutionCancel"
/>
</div>
</div>
</Teleport>
<!-- Floating Action Buttons -->
<div class="fixed bottom-6 right-6 flex flex-col gap-3 z-40">
<!-- Undo Last Play Button -->
<!-- Floating Action Button - Undo -->
<div class="fixed bottom-6 right-6 z-40">
<button
v-if="canUndo"
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
@ -332,18 +316,6 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<!-- Substitutions Button -->
<button
v-if="canMakeSubstitutions"
class="w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
aria-label="Open Substitutions"
@click="showSubstitutions = true"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
</div>
</div>
</template>
@ -359,7 +331,6 @@ import CurrentSituation from '~/components/Game/CurrentSituation.vue'
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
import type { DefensiveDecision, OffensiveDecision, PlayOutcome } from '~/types/game'
// Props
@ -415,10 +386,35 @@ const pendingRoll = computed(() => gameStore.pendingRoll)
const lastPlayResult = computed(() => gameStore.lastPlayResult)
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
// Home team's dice color for the dice display
const diceColor = computed(() => gameState.value?.home_team_dice_color ?? 'cc0000')
// Active card for highlighting based on dice roll (d6_one: 1-3 = batter, 4-6 = pitcher)
const activeCard = computed<'batter' | 'pitcher' | null>(() => {
if (!pendingRoll.value) return null
return pendingRoll.value.d6_one <= 3 ? 'batter' : 'pitcher'
})
// Team abbreviations for batter and pitcher cards
// Top of inning: away bats, home fields
// Bottom of inning: home bats, away fields
const batterTeamAbbrev = computed(() => {
if (!gameState.value) return ''
return gameState.value.half === 'top'
? gameState.value.away_team_abbrev ?? ''
: gameState.value.home_team_abbrev ?? ''
})
const pitcherTeamAbbrev = computed(() => {
if (!gameState.value) return ''
return gameState.value.half === 'top'
? gameState.value.home_team_abbrev ?? ''
: gameState.value.away_team_abbrev ?? ''
})
// Local UI state
const isLoading = ref(true)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
const showSubstitutions = ref(false)
// Determine which team the user controls
// For demo/testing: user controls whichever team needs to act
@ -460,6 +456,23 @@ const runnersData = computed(() => {
}
})
// Get active fielding lineup for GameBoard
const fieldingLineup = computed(() => {
if (!gameState.value) return []
// Top of inning: home team fields; Bottom: away team fields
const fieldingTeamId = gameState.value.half === 'top'
? gameState.value.home_team_id
: gameState.value.away_team_id
const lineup = fieldingTeamId === gameState.value.home_team_id
? gameStore.homeLineup
: gameStore.awayLineup
// Return only active players
return lineup.filter(p => p.is_active)
})
const currentTeam = computed(() => {
return gameState.value?.half === 'top' ? 'away' : 'home'
})
@ -537,42 +550,11 @@ const showGameplay = computed(() => {
!needsOffensiveDecision.value
})
const canMakeSubstitutions = computed(() => {
return gameState.value?.status === 'active' && isMyTurn.value
})
const canUndo = computed(() => {
// Can only undo if game is active and there are plays to undo
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
})
// Lineup helpers for substitutions
const currentLineup = computed(() => {
if (!myTeamId.value) return []
return myTeamId.value === gameState.value?.home_team_id
? gameStore.homeLineup.filter(l => l.is_active)
: gameStore.awayLineup.filter(l => l.is_active)
})
const benchPlayers = computed(() => {
if (!myTeamId.value) return []
return myTeamId.value === gameState.value?.home_team_id
? gameStore.homeLineup.filter(l => !l.is_active)
: gameStore.awayLineup.filter(l => !l.is_active)
})
const currentBatter = computed(() => {
const batterState = gameState.value?.current_batter
if (!batterState) return null
return gameStore.findPlayerInLineup(batterState.lineup_id)
})
const currentPitcher = computed(() => {
const pitcherState = gameState.value?.current_pitcher
if (!pitcherState) return null
return gameStore.findPlayerInLineup(pitcherState.lineup_id)
})
// Methods - Gameplay (Phase F4)
const handleRollDice = async () => {
console.log('[GamePlay] Rolling dice')
@ -642,58 +624,6 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
gameStore.setPendingStealAttempts(attempts)
}
// Methods - Substitutions (Phase F5)
const handlePinchHitter = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
console.log('[GamePlay] Submitting pinch hitter:', data)
try {
await actions.submitSubstitution(
'pinch_hitter',
data.playerOutLineupId,
data.playerInCardId,
data.teamId
)
showSubstitutions.value = false
} catch (error) {
console.error('[GamePlay] Failed to submit pinch hitter:', error)
}
}
const handleDefensiveReplacement = async (data: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
console.log('[GamePlay] Submitting defensive replacement:', data)
try {
await actions.submitSubstitution(
'defensive_replacement',
data.playerOutLineupId,
data.playerInCardId,
data.teamId,
data.newPosition
)
showSubstitutions.value = false
} catch (error) {
console.error('[GamePlay] Failed to submit defensive replacement:', error)
}
}
const handlePitchingChange = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
console.log('[GamePlay] Submitting pitching change:', data)
try {
await actions.submitSubstitution(
'pitching_change',
data.playerOutLineupId,
data.playerInCardId,
data.teamId
)
showSubstitutions.value = false
} catch (error) {
console.error('[GamePlay] Failed to submit pitching change:', error)
}
}
const handleSubstitutionCancel = () => {
console.log('[GamePlay] Cancelling substitution')
showSubstitutions.value = false
}
// Undo handler
const handleUndoLastPlay = () => {
console.log('[GamePlay] Undoing last play')

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import draggable from 'vuedraggable'
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
import ActionButton from '~/components/UI/ActionButton.vue'
@ -191,7 +192,185 @@ function getPlayerPositions(player: SbaPlayer): string[] {
return positions
}
// Drag handlers
// Vuedraggable configuration
const dragOptions = {
animation: 200,
ghostClass: 'drag-ghost',
chosenClass: 'drag-chosen',
dragClass: 'drag-dragging',
// Touch settings for mobile
delay: 50,
delayOnTouchOnly: true,
touchStartThreshold: 3,
}
// Track which slot is being hovered during drag (for replacement mode visual)
const dragHoverSlot = ref<number | null>(null)
const isDragging = ref(false)
// Check if a slot is in "replacement mode" (occupied and being hovered)
function isReplacementMode(slotIndex: number): boolean {
return isDragging.value && dragHoverSlot.value === slotIndex && !!currentLineup.value[slotIndex]?.player
}
// Get the player being replaced (for visual feedback)
function getReplacedPlayer(slotIndex: number): SbaPlayer | null {
if (isReplacementMode(slotIndex)) {
return currentLineup.value[slotIndex]?.player || null
}
return null
}
// Handle drag start - track that dragging is active
function handleDragStart() {
isDragging.value = true
}
// Handle drag end - clear all drag state
function handleDragEnd() {
isDragging.value = false
dragHoverSlot.value = null
}
// Handle move event - fires when dragging over a slot
function handleSlotMove(evt: any, slotIndex: number) {
dragHoverSlot.value = slotIndex
// Always allow the move
return true
}
// Handle mouse/touch leave on slot - clear hover state
function handleSlotLeave(slotIndex: number) {
if (dragHoverSlot.value === slotIndex) {
dragHoverSlot.value = null
}
}
// Clone player when dragging from roster (don't remove from roster)
function clonePlayer(player: SbaPlayer): SbaPlayer {
return { ...player }
}
// Handle when a player is added to a batting slot from roster or another slot
function handleSlotAdd(slotIndex: number, event: any) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
const player = event.item?._underlying_vm_ || event.clone?._underlying_vm_
if (!player) return
// If slot already has a player, swap logic would be needed
// But vuedraggable handles removal from source automatically for moves
lineup[slotIndex].player = player
// Auto-assign position
if (slotIndex === 9) {
lineup[slotIndex].position = 'P'
} else {
const availablePositions = getPlayerPositions(player)
if (availablePositions.length > 0) {
lineup[slotIndex].position = availablePositions[0]
}
}
}
// Handle when a player is removed from a slot (moved to another slot)
function handleSlotRemove(slotIndex: number) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
lineup[slotIndex].player = null
lineup[slotIndex].position = null
}
// Reactive slot arrays for vuedraggable - each slot is an array of 0-1 players
// These are synced with the lineup slots via watchers
const homeSlotArrays = ref<SbaPlayer[][]>(Array(10).fill(null).map(() => []))
const awaySlotArrays = ref<SbaPlayer[][]>(Array(10).fill(null).map(() => []))
// Current slot arrays based on active tab
const currentSlotArrays = computed(() =>
activeTab.value === 'home' ? homeSlotArrays.value : awaySlotArrays.value
)
// Get slot players array for vuedraggable
function getSlotPlayers(slotIndex: number): SbaPlayer[] {
return currentSlotArrays.value[slotIndex]
}
// Sync slot arrays when lineup changes (e.g., from populateLineupsFromData)
// Modifies arrays in-place to preserve vuedraggable's reference
function syncSlotArraysFromLineup(lineup: LineupSlot[], slotArrays: SbaPlayer[][]) {
lineup.forEach((slot, index) => {
const currentArr = slotArrays[index]
const shouldHavePlayer = !!slot.player
// Check if sync is needed
const currentPlayer = currentArr[0]
const isSame = shouldHavePlayer
? (currentPlayer && currentPlayer.id === slot.player!.id)
: (currentArr.length === 0)
if (!isSame) {
// Modify array in place to preserve vuedraggable's reference
currentArr.length = 0
if (slot.player) {
currentArr.push(slot.player)
}
}
})
}
// Watch lineup changes and sync to slot arrays
watch(homeLineup, (newLineup) => {
syncSlotArraysFromLineup(newLineup, homeSlotArrays.value)
}, { deep: true, immediate: true })
watch(awayLineup, (newLineup) => {
syncSlotArraysFromLineup(newLineup, awaySlotArrays.value)
}, { deep: true, immediate: true })
// Handle slot change event from vuedraggable
function handleSlotChange(slotIndex: number, event: any) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
const slotArrays = activeTab.value === 'home' ? homeSlotArrays.value : awaySlotArrays.value
const slotArray = slotArrays[slotIndex]
if (event.added) {
// Get the newly added player (last in array if multiple)
const addedPlayer = event.added.element as SbaPlayer
// CRITICAL: Enforce single player per slot
// If there are multiple players (dropped onto existing), keep only the new one
if (slotArray.length > 1) {
// Find and remove the old player(s), keeping only the newly added one
const playersToRemove = slotArray.filter((p: SbaPlayer) => p.id !== addedPlayer.id)
// Clear the array and add only the new player
slotArray.length = 0
slotArray.push(addedPlayer)
console.log(`[LineupBuilder] Slot ${slotIndex}: Replaced ${playersToRemove.map((p: SbaPlayer) => p.name).join(', ')} with ${addedPlayer.name}`)
}
// Update the lineup slot with the new player
lineup[slotIndex].player = addedPlayer
// Auto-assign position
if (slotIndex === 9) {
lineup[slotIndex].position = 'P'
} else {
const availablePositions = getPlayerPositions(addedPlayer)
if (availablePositions.length > 0) {
lineup[slotIndex].position = availablePositions[0]
}
}
}
if (event.removed) {
// Player was removed from this slot
lineup[slotIndex].player = null
lineup[slotIndex].position = null
}
}
// Legacy drag handler (kept for desktop native drag-drop fallback)
function handleRosterDrag(player: SbaPlayer, toSlotIndex: number, fromSlotIndex?: number) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
@ -347,6 +526,7 @@ async function fetchRoster(teamId: number) {
async function submitTeamLineup(team: 'home' | 'away') {
const teamId = team === 'home' ? homeTeamId.value : awayTeamId.value
const lineup = team === 'home' ? homeLineup.value : awayLineup.value
const availableRoster = team === 'home' ? availableHomeRoster.value : availableAwayRoster.value
const isSubmitting = team === 'home' ? submittingHome : submittingAway
const submitted = team === 'home' ? homeSubmitted : awaySubmitted
const canSubmitTeam = team === 'home' ? canSubmitHome.value : canSubmitAway.value
@ -355,7 +535,7 @@ async function submitTeamLineup(team: 'home' | 'away') {
isSubmitting.value = true
// Build request
// Build starting lineup request
const lineupRequest = lineup
.filter(s => s.player)
.map(s => ({
@ -364,9 +544,15 @@ async function submitTeamLineup(team: 'home' | 'away') {
batting_order: s.battingOrder
}))
// Build bench request (players not in starting lineup)
const benchRequest = availableRoster.map(p => ({
player_id: p.id
}))
const request = {
team_id: teamId,
lineup: lineupRequest
lineup: lineupRequest,
bench: benchRequest
}
console.log(`Submitting ${team} lineup:`, JSON.stringify(request, null, 2))
@ -722,58 +908,68 @@ onMounted(async () => {
</div>
<!-- Roster List -->
<div class="bg-gray-800/40 backdrop-blur-sm rounded-b-xl border border-gray-700/50 border-t-0 p-2 space-y-1.5 max-h-[calc(100vh-22rem)] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div
v-for="player in filteredRoster"
:key="player.id"
draggable="true"
class="bg-gray-700/60 hover:bg-gray-700 rounded-lg p-2.5 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-3 group hover:shadow-md hover:shadow-black/20 border border-transparent hover:border-gray-600/50"
@dragstart="(e) => e.dataTransfer?.setData('player', JSON.stringify(player))"
<div class="bg-gray-800/40 backdrop-blur-sm rounded-b-xl border border-gray-700/50 border-t-0 p-2 max-h-[calc(100vh-22rem)] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent select-none">
<draggable
:list="filteredRoster"
:group="{ name: 'lineup', pull: 'clone', put: false }"
:clone="clonePlayer"
:sort="false"
item-key="id"
v-bind="dragOptions"
class="space-y-1.5"
@start="handleDragStart"
@end="handleDragEnd"
>
<!-- Player Headshot -->
<div class="flex-shrink-0 relative">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center text-gray-300 text-sm font-bold ring-2 ring-gray-600/50">
{{ getPlayerFallbackInitial(player) }}
</div>
</div>
<template #item="{ element: player }">
<div
class="bg-gray-700/60 hover:bg-gray-700 rounded-lg p-2.5 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-3 group hover:shadow-md hover:shadow-black/20 border border-transparent hover:border-gray-600/50 select-none touch-manipulation"
>
<!-- Player Headshot -->
<div class="flex-shrink-0 relative">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center text-gray-300 text-sm font-bold ring-2 ring-gray-600/50">
{{ getPlayerFallbackInitial(player) }}
</div>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
:key="pos"
class="text-[10px] font-medium text-gray-400 bg-gray-800/80 px-1.5 py-0.5 rounded"
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
:key="pos"
class="text-[10px] font-medium text-gray-400 bg-gray-800/80 px-1.5 py-0.5 rounded"
>
{{ pos }}
</span>
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length > 3" class="text-[10px] text-gray-500">
+{{ getPlayerPositions(player).filter(p => p !== 'DH').length - 3 }}
</span>
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length === 0" class="text-[10px] text-gray-500">
DH
</span>
</div>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-7 h-7 rounded-full bg-gray-600/50 hover:bg-blue-600 text-gray-400 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(player)"
>
{{ pos }}
</span>
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length > 3" class="text-[10px] text-gray-500">
+{{ getPlayerPositions(player).filter(p => p !== 'DH').length - 3 }}
</span>
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length === 0" class="text-[10px] text-gray-500">
DH
</span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-7 h-7 rounded-full bg-gray-600/50 hover:bg-blue-600 text-gray-400 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(player)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</template>
</draggable>
<!-- Empty state -->
<div v-if="filteredRoster.length === 0" class="text-center py-8">
@ -822,96 +1018,117 @@ onMounted(async () => {
</div>
<!-- Batting order slots (1-9) -->
<div class="space-y-1.5 mb-6">
<div class="space-y-1.5 mb-6 select-none">
<div
v-for="(slot, index) in currentLineup.slice(0, 9)"
:key="index"
:class="[
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
slot.player ? 'border-gray-700/50' : 'border-gray-700/30'
slot.player ? 'border-gray-700/50' : 'border-gray-700/30',
isReplacementMode(index) ? 'ring-2 ring-amber-500/50 border-amber-500/50' : ''
]"
>
<div class="flex items-center gap-3 p-2.5">
<!-- Batting order number -->
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-gray-700/50 flex items-center justify-center">
<span class="text-sm font-bold text-gray-400">{{ index + 1 }}</span>
<div :class="[
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
isReplacementMode(index) ? 'bg-amber-900/50' : 'bg-gray-700/50'
]">
<span :class="[
'text-sm font-bold',
isReplacementMode(index) ? 'text-amber-400' : 'text-gray-400'
]">{{ index + 1 }}</span>
</div>
<!-- Player slot -->
<div
class="flex-1 min-w-0"
@drop.prevent="(e) => {
const playerData = e.dataTransfer?.getData('player')
const fromSlotData = e.dataTransfer?.getData('fromSlot')
if (playerData) {
const player = JSON.parse(playerData) as SbaPlayer
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
handleRosterDrag(player, index, fromSlot)
}
}"
@dragover.prevent
<!-- Player slot - vuedraggable drop zone -->
<draggable
:list="getSlotPlayers(index)"
:group="{ name: 'lineup', pull: true, put: true }"
item-key="id"
v-bind="dragOptions"
:class="[
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
isReplacementMode(index) ? 'replacement-mode' : ''
]"
@change="(e: any) => handleSlotChange(index, e)"
@start="handleDragStart"
@end="handleDragEnd"
:move="(evt: any) => handleSlotMove(evt, index)"
>
<div
v-if="slot.player"
class="bg-blue-900/50 hover:bg-blue-900/70 rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border border-blue-700/30"
draggable="true"
@dragstart="(e) => {
e.dataTransfer?.setData('player', JSON.stringify(slot.player))
e.dataTransfer?.setData('fromSlot', index.toString())
}"
>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
v-if="getPlayerPreviewImage(slot.player)"
:src="getPlayerPreviewImage(slot.player)!"
:alt="slot.player.name"
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-blue-600/30"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-blue-700 to-blue-800 flex items-center justify-center text-blue-200 text-sm font-bold ring-2 ring-blue-600/30">
{{ getPlayerFallbackInitial(slot.player) }}
</div>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ slot.player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(slot.player).filter(p => p !== 'DH').slice(0, 2)"
:key="pos"
class="text-[10px] font-medium text-blue-300/80 bg-blue-950/50 px-1.5 py-0.5 rounded"
>
{{ pos }}
</span>
</div>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-800/50 hover:bg-blue-600 text-blue-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(slot.player)"
<template #item="{ element: player }">
<div
:class="[
'rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border select-none touch-manipulation',
isReplacementMode(index)
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
: 'bg-blue-900/50 hover:bg-blue-900/70 border-blue-700/30'
]"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-blue-600/30"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-blue-700 to-blue-800 flex items-center justify-center text-blue-200 text-sm font-bold ring-2 ring-blue-600/30">
{{ getPlayerFallbackInitial(player) }}
</div>
</div>
<!-- Remove Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
@click.stop="removePlayer(index)"
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 2)"
:key="pos"
class="text-[10px] font-medium text-blue-300/80 bg-blue-950/50 px-1.5 py-0.5 rounded"
>
{{ pos }}
</span>
</div>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-800/50 hover:bg-blue-600 text-blue-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(player)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Remove Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
@click.stop="removePlayer(index)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<template #footer>
<!-- Replacement indicator when dragging over occupied slot -->
<div
v-if="isReplacementMode(index)"
class="absolute inset-0 flex items-center justify-center bg-amber-900/20 rounded-lg pointer-events-none z-10"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-else class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
Drop player here
</div>
</div>
<span class="text-xs font-medium text-amber-400 bg-amber-950/80 px-2 py-1 rounded">
Replacing {{ getReplacedPlayer(index)?.name }}
</span>
</div>
<!-- Empty slot placeholder -->
<div v-else-if="!slot.player" class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
Drop player here
</div>
</template>
</draggable>
<!-- Position selector -->
<div class="w-20 flex-shrink-0">
@ -943,7 +1160,7 @@ onMounted(async () => {
</div>
<!-- Pitcher slot (10) -->
<div class="mb-6">
<div class="mb-6 select-none">
<div class="flex items-center gap-2 mb-3">
<h3 class="text-sm font-semibold text-gray-300">Starting Pitcher</h3>
<span v-if="pitcherSlotDisabled" class="text-xs text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded-full">
@ -953,83 +1170,108 @@ onMounted(async () => {
<div
:class="[
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
pitcherSlotDisabled ? 'opacity-40 pointer-events-none border-gray-700/20' : 'border-gray-700/50'
pitcherSlotDisabled ? 'opacity-40 pointer-events-none border-gray-700/20' : 'border-gray-700/50',
isReplacementMode(9) ? 'ring-2 ring-amber-500/50 border-amber-500/50' : ''
]"
>
<div class="flex items-center gap-3 p-2.5">
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-green-900/30 flex items-center justify-center">
<span class="text-sm font-bold text-green-400">P</span>
<div :class="[
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
isReplacementMode(9) ? 'bg-amber-900/50' : 'bg-green-900/30'
]">
<span :class="[
'text-sm font-bold',
isReplacementMode(9) ? 'text-amber-400' : 'text-green-400'
]">P</span>
</div>
<div
class="flex-1 min-w-0"
@drop.prevent="(e) => {
const playerData = e.dataTransfer?.getData('player')
const fromSlotData = e.dataTransfer?.getData('fromSlot')
if (playerData) {
const player = JSON.parse(playerData) as SbaPlayer
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
handleRosterDrag(player, 9, fromSlot)
}
}"
@dragover.prevent
<!-- Pitcher slot - vuedraggable drop zone -->
<draggable
:list="getSlotPlayers(9)"
:group="{ name: 'lineup', pull: true, put: !pitcherSlotDisabled }"
item-key="id"
v-bind="dragOptions"
:class="[
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
isReplacementMode(9) ? 'replacement-mode' : ''
]"
@change="(e: any) => handleSlotChange(9, e)"
@start="handleDragStart"
@end="handleDragEnd"
:move="(evt: any) => handleSlotMove(evt, 9)"
>
<div
v-if="pitcherPlayer"
class="bg-green-900/40 hover:bg-green-900/60 rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border border-green-700/30"
draggable="true"
@dragstart="(e) => {
e.dataTransfer?.setData('player', JSON.stringify(pitcherPlayer))
e.dataTransfer?.setData('fromSlot', '9')
}"
>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
v-if="getPlayerPreviewImage(pitcherPlayer)"
:src="getPlayerPreviewImage(pitcherPlayer)!"
:alt="pitcherPlayer.name"
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-green-600/30"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-green-700 to-green-800 flex items-center justify-center text-green-200 text-sm font-bold ring-2 ring-green-600/30">
{{ getPlayerFallbackInitial(pitcherPlayer) }}
<template #item="{ element: player }">
<div
:class="[
'rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border select-none touch-manipulation',
isReplacementMode(9)
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
: 'bg-green-900/40 hover:bg-green-900/60 border-green-700/30'
]"
>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-green-600/30"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-green-700 to-green-800 flex items-center justify-center text-green-200 text-sm font-bold ring-2 ring-green-600/30">
{{ getPlayerFallbackInitial(player) }}
</div>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="text-[10px] text-green-400/80 mt-0.5">Pitcher</div>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-green-800/50 hover:bg-green-600 text-green-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(player)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Remove Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
@click.stop="removePlayer(9)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ pitcherPlayer.name }}</div>
<div class="text-[10px] text-green-400/80 mt-0.5">Pitcher</div>
</template>
<template #footer>
<!-- Replacement indicator when dragging over occupied pitcher slot -->
<div
v-if="isReplacementMode(9)"
class="absolute inset-0 flex items-center justify-center bg-amber-900/20 rounded-lg pointer-events-none z-10"
>
<span class="text-xs font-medium text-amber-400 bg-amber-950/80 px-2 py-1 rounded">
Replacing {{ getReplacedPlayer(9)?.name }}
</span>
</div>
<!-- Info Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-green-800/50 hover:bg-green-600 text-green-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
@click.stop="openPlayerPreview(pitcherPlayer)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Remove Button -->
<button
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
@click.stop="removePlayer(9)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-else class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
Drop pitcher here
</div>
</div>
<!-- Empty slot placeholder -->
<div v-else-if="!pitcherPlayer" class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
Drop pitcher here
</div>
</template>
</draggable>
<div class="w-20 flex-shrink-0">
<div class="text-green-500 text-xs text-center font-medium">P</div>
<div :class="[
'text-xs text-center font-medium',
isReplacementMode(9) ? 'text-amber-400' : 'text-green-500'
]">P</div>
</div>
</div>
</div>
@ -1188,3 +1430,79 @@ onMounted(async () => {
</Teleport>
</div>
</template>
<style scoped>
/* Prevent text selection on all draggable elements AND their children - critical for mobile UX */
:deep([draggable="true"]),
:deep([draggable="true"] *),
:deep(.sortable-item),
:deep(.sortable-item *),
.roster-item,
.roster-item *,
.lineup-slot-item,
.lineup-slot-item * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
}
/* Touch action on containers only (not children) */
:deep([draggable="true"]),
:deep(.sortable-item) {
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
}
/* Apply to the draggable container itself */
:deep(.sortable-chosen),
:deep(.sortable-chosen *) {
-webkit-user-select: none !important;
user-select: none !important;
}
/* Slot drop zone - constrain to single item height */
.slot-drop-zone {
position: relative;
max-height: 60px; /* Constrain to single player card height */
overflow: hidden; /* Hide stacking preview */
}
/* Replacement mode - show that an item will be replaced */
.slot-drop-zone.replacement-mode {
max-height: none; /* Allow replacement indicator to show */
}
/* Hide the ghost/preview when in replacement mode to prevent stacking visual */
.slot-drop-zone.replacement-mode :deep(.sortable-ghost) {
display: none !important;
}
/* Vuedraggable drag effect styles */
.drag-ghost {
@apply opacity-50 bg-blue-900/30 border-2 border-dashed border-blue-500;
}
.drag-chosen {
@apply ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-900;
}
.drag-dragging {
@apply opacity-75 scale-105 shadow-xl shadow-blue-500/20;
}
/* Ensure draggable containers have proper touch handling */
:deep(.sortable-drag) {
opacity: 0.8;
transform: scale(1.02);
}
:deep(.sortable-ghost) {
opacity: 0.4;
}
/* In replacement mode, style the ghost differently */
.slot-drop-zone.replacement-mode :deep(.sortable-fallback) {
opacity: 0 !important;
}
</style>

View File

@ -1,14 +1,37 @@
<template>
<div class="bg-gradient-to-r from-primary to-blue-600 text-white shadow-lg">
<div class="container mx-auto px-3 py-4">
<div class="relative text-white shadow-lg overflow-hidden">
<!-- Team colors gradient background -->
<div
class="absolute inset-0"
:style="gradientStyle"
/>
<!-- Dark overlay for text readability -->
<div class="absolute inset-0 bg-black/20" />
<!-- Content -->
<div class="relative container mx-auto px-3 py-4">
<!-- Mobile Layout (default) -->
<div class="lg:hidden">
<!-- Score Display with Game Situation -->
<div class="flex items-center justify-between">
<!-- Away Team -->
<div class="flex-1 text-center">
<div class="text-xs font-medium text-blue-100 mb-1">AWAY</div>
<div class="text-4xl font-bold tabular-nums">{{ awayScore }}</div>
<div class="flex-1 text-center relative">
<img
v-if="awayTeamThumbnail && !awayThumbnailFailed"
:src="awayTeamThumbnail"
alt=""
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
@error="awayThumbnailFailed = true"
>
<div class="relative">
<div
class="text-xs font-medium mb-1 text-outline"
:class="showAwayShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
>AWAY</div>
<div
class="text-4xl font-bold tabular-nums text-outline"
:class="showAwayShadow ? 'text-outline-strong' : ''"
>{{ awayScore }}</div>
</div>
</div>
<!-- Center: Inning + Runners/Outs -->
@ -69,9 +92,24 @@
</div>
<!-- Home Team -->
<div class="flex-1 text-center">
<div class="text-xs font-medium text-blue-100 mb-1">HOME</div>
<div class="text-4xl font-bold tabular-nums">{{ homeScore }}</div>
<div class="flex-1 text-center relative">
<img
v-if="homeTeamThumbnail && !homeThumbnailFailed"
:src="homeTeamThumbnail"
alt=""
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
@error="homeThumbnailFailed = true"
>
<div class="relative">
<div
class="text-xs font-medium mb-1 text-outline"
:class="showHomeShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
>HOME</div>
<div
class="text-4xl font-bold tabular-nums text-outline"
:class="showHomeShadow ? 'text-outline-strong' : ''"
>{{ homeScore }}</div>
</div>
</div>
</div>
</div>
@ -80,9 +118,24 @@
<div class="hidden lg:flex items-center justify-between">
<!-- Left: Away Team Score -->
<div class="flex items-center gap-4">
<div class="text-center min-w-[100px]">
<div class="text-sm font-medium text-blue-100">AWAY</div>
<div class="text-5xl font-bold tabular-nums">{{ awayScore }}</div>
<div class="text-center min-w-[100px] relative">
<img
v-if="awayTeamThumbnail && !awayThumbnailFailed"
:src="awayTeamThumbnail"
alt=""
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
@error="awayThumbnailFailed = true"
>
<div class="relative">
<div
class="text-sm font-medium text-outline"
:class="showAwayShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
>AWAY</div>
<div
class="text-5xl font-bold tabular-nums text-outline"
:class="showAwayShadow ? 'text-outline-strong' : ''"
>{{ awayScore }}</div>
</div>
</div>
</div>
@ -145,9 +198,24 @@
<!-- Right: Home Team Score -->
<div class="flex items-center gap-4">
<div class="text-center min-w-[100px]">
<div class="text-sm font-medium text-blue-100">HOME</div>
<div class="text-5xl font-bold tabular-nums">{{ homeScore }}</div>
<div class="text-center min-w-[100px] relative">
<img
v-if="homeTeamThumbnail && !homeThumbnailFailed"
:src="homeTeamThumbnail"
alt=""
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
@error="homeThumbnailFailed = true"
>
<div class="relative">
<div
class="text-sm font-medium text-outline"
:class="showHomeShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
>HOME</div>
<div
class="text-5xl font-bold tabular-nums text-outline"
:class="showHomeShadow ? 'text-outline-strong' : ''"
>{{ homeScore }}</div>
</div>
</div>
</div>
</div>
@ -156,6 +224,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { InningHalf } from '~/types/game'
interface Props {
@ -169,6 +238,10 @@ interface Props {
second: boolean
third: boolean
}
awayTeamColor?: string
homeTeamColor?: string
awayTeamThumbnail?: string | null
homeTeamThumbnail?: string | null
}
const props = withDefaults(defineProps<Props>(), {
@ -177,7 +250,32 @@ const props = withDefaults(defineProps<Props>(), {
inning: 1,
half: 'top',
outs: 0,
runners: () => ({ first: false, second: false, third: false })
runners: () => ({ first: false, second: false, third: false }),
awayTeamColor: undefined,
homeTeamColor: undefined,
awayTeamThumbnail: undefined,
homeTeamThumbnail: undefined
})
// Track thumbnail load failures
const awayThumbnailFailed = ref(false)
const homeThumbnailFailed = ref(false)
// Show enhanced shadow effect when no thumbnail (missing or failed to load)
// Even with thumbnails, we use a subtle outline for readability
const showAwayShadow = computed(() => !props.awayTeamThumbnail || awayThumbnailFailed.value)
const showHomeShadow = computed(() => !props.homeTeamThumbnail || homeThumbnailFailed.value)
// Generate gradient style from team colors
// Uses Option 7: Solid blocks with center blend (away 30% -> dark center 50% -> home 70%)
const gradientStyle = computed(() => {
const awayColor = props.awayTeamColor || '#1e40af' // Default: SBA blue
const homeColor = props.homeTeamColor || '#1e40af' // Default: SBA blue
const centerColor = '#1f2937' // gray-800
return {
background: `linear-gradient(to right, ${awayColor} 30%, ${centerColor} 50%, ${homeColor} 70%)`
}
})
</script>
@ -187,6 +285,25 @@ const props = withDefaults(defineProps<Props>(), {
font-variant-numeric: tabular-nums;
}
/* Text outline for readability on any background (light logos, gradients) */
.text-outline {
text-shadow:
-1px -1px 0 rgba(0, 0, 0, 0.5),
1px -1px 0 rgba(0, 0, 0, 0.5),
-1px 1px 0 rgba(0, 0, 0, 0.5),
1px 1px 0 rgba(0, 0, 0, 0.5);
}
/* Enhanced outline when no thumbnail - more prominent shadow */
.text-outline-strong {
text-shadow:
-1px -1px 0 rgba(0, 0, 0, 0.8),
1px -1px 0 rgba(0, 0, 0, 0.8),
-1px 1px 0 rgba(0, 0, 0, 0.8),
1px 1px 0 rgba(0, 0, 0, 0.8),
0 2px 8px rgba(0, 0, 0, 0.9);
}
/* Pulse animation for runners */
@keyframes pulse {
0%, 100% {

View File

@ -35,34 +35,46 @@
</div>
</div>
<!-- Main Dice Grid -->
<div class="dice-grid">
<!-- Dice Display Grid -->
<div class="dice-display" :class="{ 'dice-display-compact': !showChaosD20 }">
<!-- d6 One -->
<div class="dice-item dice-d6">
<div class="dice-label">d6 (One)</div>
<div class="dice-value">{{ pendingRoll.d6_one }}</div>
</div>
<DiceShapes
type="d6"
:value="pendingRoll.d6_one"
:color="effectiveDiceColor"
:size="dieSize"
label="d6 (One)"
/>
<!-- d6 Two (showing total) -->
<div class="dice-item dice-d6">
<div class="dice-label">d6 (Two)</div>
<div class="dice-value">{{ pendingRoll.d6_two_total }}</div>
<div class="dice-sublabel">
({{ pendingRoll.d6_two_a }} + {{ pendingRoll.d6_two_b }})
</div>
</div>
<DiceShapes
type="d6"
:value="pendingRoll.d6_two_total"
:color="effectiveDiceColor"
:size="dieSize"
label="d6 (Two)"
:sublabel="`(${pendingRoll.d6_two_a} + ${pendingRoll.d6_two_b})`"
/>
<!-- Chaos d20 -->
<div class="dice-item dice-d20">
<div class="dice-label">Chaos d20</div>
<div class="dice-value dice-value-large">{{ pendingRoll.chaos_d20 }}</div>
</div>
<!-- Chaos d20 - only shown when WP/PB check triggered -->
<DiceShapes
v-if="showChaosD20"
type="d20"
:value="pendingRoll.chaos_d20"
color="f59e0b"
:size="dieSize"
label="Chaos d20"
class="dice-chaos"
/>
<!-- Resolution d20 -->
<div class="dice-item dice-d20">
<div class="dice-label">Resolution d20</div>
<div class="dice-value dice-value-large">{{ pendingRoll.resolution_d20 }}</div>
</div>
<DiceShapes
type="d20"
:value="pendingRoll.resolution_d20"
color="ffffff"
:size="dieSize"
label="Resolution d20"
/>
</div>
<!-- Special Event Indicators -->
@ -80,18 +92,6 @@
Passed Ball Check
</div>
</div>
<!-- Card Reading Instructions -->
<div class="card-instructions">
<div class="instruction-icon">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="instruction-text">
Use these dice results to read the outcome from your player's card
</div>
</div>
</div>
</div>
</template>
@ -99,13 +99,17 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RollData } from '~/types'
import DiceShapes from './DiceShapes.vue'
interface Props {
canRoll: boolean
pendingRoll: RollData | null
diceColor?: string // Home team's dice_color (hex without #), default 'cc0000'
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
diceColor: 'cc0000', // Default red
})
const emit = defineEmits<{
roll: []
@ -114,6 +118,20 @@ const emit = defineEmits<{
// Local state
const isRolling = ref(false)
// Die size - responsive
const dieSize = 90
// Effective dice color (use prop or default)
const effectiveDiceColor = computed(() => props.diceColor || 'cc0000')
// Computed: Only show chaos d20 when WP/PB check triggered (chaos_d20 == 1 or 2)
// Hide when: bases were empty OR chaos_d20 >= 3 (no effect)
const showChaosD20 = computed(() => {
if (!props.pendingRoll) return false
// Show chaos d20 only if a WP or PB check was triggered
return props.pendingRoll.check_wild_pitch || props.pendingRoll.check_passed_ball
})
// Methods
const handleRoll = () => {
if (!props.canRoll || isRolling.value) return
@ -160,8 +178,8 @@ const formatTimestamp = (timestamp: string): string => {
/* Dice Results Container */
.dice-results {
@apply bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-6 shadow-xl;
@apply space-y-4;
@apply bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 shadow-xl;
@apply space-y-3;
animation: slideDown 0.3s ease-out;
}
@ -178,94 +196,56 @@ const formatTimestamp = (timestamp: string): string => {
/* Header */
.dice-header {
@apply flex justify-between items-center pb-3 border-b border-blue-500;
@apply flex justify-between items-center pb-3 border-b border-slate-700;
}
/* Dice Grid */
.dice-grid {
@apply grid grid-cols-2 gap-4;
/* Dice Display Grid */
.dice-display {
@apply flex justify-center items-start gap-6 pt-2 pb-4;
@apply flex-wrap;
}
@media (min-width: 640px) {
.dice-grid {
@apply grid-cols-4;
/* When only 3 dice shown, they center nicely */
.dice-display-compact {
@apply gap-8;
}
/* Chaos d20 styling */
.dice-chaos {
animation: pulseGlow 2s ease-in-out infinite;
}
@keyframes pulseGlow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
}
50% {
filter: drop-shadow(0 0 16px rgba(245, 158, 11, 0.7));
}
}
/* Individual Dice Items */
.dice-item {
@apply bg-white rounded-lg p-4 text-center shadow-md;
@apply flex flex-col items-center justify-center;
@apply min-h-[100px];
}
.dice-d6 {
@apply bg-gradient-to-br from-gray-50 to-gray-100;
}
.dice-d20 {
@apply bg-gradient-to-br from-yellow-50 to-yellow-100;
}
.dice-label {
@apply text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2;
}
.dice-value {
@apply text-3xl font-bold text-gray-900;
}
.dice-value-large {
@apply text-4xl;
}
.dice-sublabel {
@apply text-xs text-gray-500 mt-1;
}
/* Special Events */
.special-events {
@apply flex flex-wrap gap-2 pt-2;
@apply flex flex-wrap justify-center gap-3;
}
.special-event {
@apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold;
@apply flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold;
@apply shadow-md;
}
.wild-pitch {
@apply bg-yellow-400 text-yellow-900;
@apply bg-yellow-500 text-yellow-900;
}
.passed-ball {
@apply bg-orange-400 text-orange-900;
@apply bg-orange-500 text-orange-900;
}
/* Card Instructions */
.card-instructions {
@apply bg-blue-500 bg-opacity-50 rounded-lg p-4;
@apply flex items-start gap-3;
}
.instruction-icon {
@apply text-blue-200 flex-shrink-0;
}
.instruction-text {
@apply text-white text-sm font-medium;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.dice-results {
@apply from-blue-800 to-blue-900;
}
.dice-item {
@apply shadow-lg;
}
.card-instructions {
@apply bg-blue-700 bg-opacity-50;
/* Responsive adjustments */
@media (max-width: 480px) {
.dice-display {
@apply gap-4;
}
}
</style>

View File

@ -0,0 +1,177 @@
<template>
<!-- D6 Die Shape -->
<div v-if="type === 'd6'" class="die-container" :style="containerStyle">
<svg :viewBox="viewBox" class="die-shape">
<!-- Die body with rounded corners -->
<rect
x="4"
y="4"
:width="size - 8"
:height="size - 8"
rx="10"
ry="10"
:fill="fillColor"
:stroke="strokeColor"
stroke-width="2"
/>
<!-- Corner dots (decorative, suggesting die pips) -->
<circle :cx="size * 0.2" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.8" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.2" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.8" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
</svg>
<div class="die-value" :style="valueStyle">
<slot>{{ value }}</slot>
</div>
<div v-if="label" class="die-label">{{ label }}</div>
<div v-if="sublabel" class="die-sublabel">{{ sublabel }}</div>
</div>
<!-- D20 Die Shape (Hexagonal/Icosahedron-inspired) -->
<div v-else-if="type === 'd20'" class="die-container" :style="containerStyle">
<svg :viewBox="viewBox" class="die-shape">
<!-- Hexagonal shape suggesting a d20 -->
<polygon
:points="hexagonPoints"
:fill="fillColor"
:stroke="strokeColor"
stroke-width="2"
/>
<!-- Inner facet lines for 3D effect -->
<line
:x1="size * 0.5"
:y1="size * 0.1"
:x2="size * 0.5"
:y2="size * 0.35"
:stroke="facetColor"
stroke-width="1"
opacity="0.3"
/>
<line
:x1="size * 0.5"
:y1="size * 0.65"
:x2="size * 0.5"
:y2="size * 0.9"
:stroke="facetColor"
stroke-width="1"
opacity="0.3"
/>
</svg>
<div class="die-value die-value-large" :style="valueStyle">
<slot>{{ value }}</slot>
</div>
<div v-if="label" class="die-label">{{ label }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
type: 'd6' | 'd20'
value: number | string
color?: string // Hex color without # (e.g., "cc0000")
size?: number // Size in pixels (default 100)
label?: string
sublabel?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'cc0000', // Default red
size: 100,
})
// Computed styles
const viewBox = computed(() => `0 0 ${props.size} ${props.size}`)
const containerStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}))
const fillColor = computed(() => `#${props.color}`)
const strokeColor = computed(() => {
// Darken the fill color for stroke
return darkenColor(props.color, 0.2)
})
const dotColor = computed(() => {
// Use white or dark based on color luminance
return isLightColor(props.color) ? '#333333' : '#ffffff'
})
const facetColor = computed(() => {
return isLightColor(props.color) ? '#000000' : '#ffffff'
})
const valueStyle = computed(() => ({
color: isLightColor(props.color) ? '#1a1a1a' : '#ffffff',
}))
// Hexagon points for d20
const hexagonPoints = computed(() => {
const s = props.size
const cx = s / 2
const cy = s / 2
const r = s * 0.42 // Radius
// 6-sided hexagon rotated to have flat top
const points = []
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 2
const x = cx + r * Math.cos(angle)
const y = cy + r * Math.sin(angle)
points.push(`${x},${y}`)
}
return points.join(' ')
})
// Helper: Check if color is light (for text contrast)
function isLightColor(hex: string): boolean {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5
}
// Helper: Darken a hex color
function darkenColor(hex: string, amount: number): string {
const r = Math.max(0, Math.floor(parseInt(hex.slice(0, 2), 16) * (1 - amount)))
const g = Math.max(0, Math.floor(parseInt(hex.slice(2, 4), 16) * (1 - amount)))
const b = Math.max(0, Math.floor(parseInt(hex.slice(4, 6), 16) * (1 - amount)))
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
</script>
<style scoped>
.die-container {
@apply relative flex flex-col items-center justify-center;
}
.die-shape {
@apply absolute inset-0 w-full h-full;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
}
.die-value {
@apply relative z-10 font-bold text-3xl;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.die-value-large {
@apply text-4xl;
}
.die-label {
@apply absolute -bottom-6 left-1/2 -translate-x-1/2;
@apply text-xs font-semibold text-gray-300 uppercase tracking-wide whitespace-nowrap;
}
.die-sublabel {
@apply absolute -bottom-10 left-1/2 -translate-x-1/2;
@apply text-xs text-gray-400 whitespace-nowrap;
}
</style>

View File

@ -44,6 +44,7 @@
<DiceRoller
:can-roll="canRollDice"
:pending-roll="null"
:dice-color="diceColor"
@roll="handleRollDice"
/>
</div>
@ -54,15 +55,11 @@
<DiceRoller
:can-roll="false"
:pending-roll="pendingRoll"
:dice-color="diceColor"
/>
<div class="divider"/>
<ManualOutcomeEntry
:roll-data="pendingRoll"
<OutcomeWizard
:can-submit="canSubmitOutcome"
:outs="outs"
:has-runners="hasRunners"
@submit="handleSubmitOutcome"
@cancel="handleCancelOutcome"
/>
@ -106,7 +103,7 @@
import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome } from '~/types'
import DiceRoller from './DiceRoller.vue'
import ManualOutcomeEntry from './ManualOutcomeEntry.vue'
import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue'
interface Props {
@ -118,11 +115,14 @@ interface Props {
canSubmitOutcome: boolean
outs?: number
hasRunners?: boolean
// Dice color from home team (hex without #)
diceColor?: string
}
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
diceColor: 'cc0000', // Default red
})
const emit = defineEmits<{
@ -300,10 +300,6 @@ const handleDismissResult = () => {
@apply space-y-6;
}
.divider {
@apply border-t-2 border-gray-200;
}
/* State: Result */
.state-result {
@apply space-y-4;
@ -318,11 +314,12 @@ const handleDismissResult = () => {
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.panel-container {
@apply from-gray-800 to-gray-900 border-gray-700;
@apply from-gray-800 to-gray-900 border-gray-600;
@apply ring-1 ring-gray-700;
}
.panel-header {
@apply bg-gray-800 border-gray-700;
@apply bg-gray-800 border-gray-600;
}
.panel-title {
@ -345,10 +342,6 @@ const handleDismissResult = () => {
@apply text-blue-300;
}
.divider {
@apply border-gray-700;
}
.error-message {
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
}

View File

@ -0,0 +1,504 @@
<template>
<div class="outcome-wizard">
<!-- Progress Indicator -->
<div class="progress-bar">
<div class="progress-steps">
<div
v-for="step in totalSteps"
:key="step"
class="progress-step"
:class="{ active: currentStep >= step, current: currentStep === step }"
/>
</div>
<button
v-if="currentStep > 1"
class="back-button"
@click="goBack"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
</div>
<!-- Step 1: Category Selection -->
<div v-if="currentStep === 1" class="step-content">
<h3 class="step-title">Select Outcome Type</h3>
<div class="category-grid">
<button
v-for="(config, category) in CATEGORY_CONFIG"
:key="category"
class="category-button"
:class="[config.bgColor, config.borderColor]"
@click="selectCategory(category as OutcomeCategory)"
>
<span class="category-label" :class="config.color">{{ config.label }}</span>
<span class="category-description">{{ config.description }}</span>
</button>
</div>
</div>
<!-- Step 2: Sub-Category Selection (ON_BASE or OUT) -->
<div v-else-if="currentStep === 2 && selectedCategory !== 'X_CHECK'" class="step-content">
<h3 class="step-title">
{{ selectedCategory === 'ON_BASE' ? 'Select Hit Type' : 'Select Out Type' }}
</h3>
<div class="subcategory-grid">
<button
v-for="(config, subCategory) in currentSubCategories"
:key="subCategory"
class="subcategory-button"
@click="selectSubCategory(subCategory)"
>
{{ config.label }}
</button>
</div>
</div>
<!-- Step 3: Specific Outcome Selection (if multiple options) -->
<div v-else-if="currentStep === 3 && currentOutcomes.length > 1" class="step-content">
<h3 class="step-title">Select Specific Outcome</h3>
<div class="outcome-grid">
<button
v-for="outcome in currentOutcomes"
:key="outcome"
class="outcome-button"
@click="selectOutcome(outcome)"
>
{{ getOutcomeLabel(outcome) }}
</button>
</div>
</div>
<!-- Step 4: Hit Location Selection (when required) -->
<div v-else-if="showLocationStep" class="step-content">
<h3 class="step-title">Select Hit Location</h3>
<div class="location-field">
<!-- Diamond visualization for location selection -->
<div class="location-diamond">
<button
v-for="loc in HIT_LOCATIONS"
:key="loc.id"
class="location-button"
:class="getLocationClass(loc.id)"
:title="loc.position"
@click="selectLocation(loc.id)"
>
{{ loc.label }}
</button>
</div>
</div>
</div>
<!-- Cancel Button -->
<div class="action-bar">
<button
class="cancel-button"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PlayOutcome } from '~/types/game'
import {
type OutcomeCategory,
type OnBaseSubCategory,
type OutSubCategory,
CATEGORY_CONFIG,
ON_BASE_OUTCOMES,
OUT_OUTCOMES,
X_CHECK_OUTCOMES,
HIT_LOCATIONS,
requiresHitLocation,
getOutcomeLabel,
} from '~/constants/outcomeFlow'
interface Props {
canSubmit?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canSubmit: true,
})
const emit = defineEmits<{
submit: [{ outcome: PlayOutcome; hitLocation?: string }]
cancel: []
}>()
// Wizard state
const currentStep = ref(1)
const selectedCategory = ref<OutcomeCategory | null>(null)
const selectedSubCategory = ref<OnBaseSubCategory | OutSubCategory | null>(null)
const selectedOutcome = ref<PlayOutcome | null>(null)
const selectedLocation = ref<string | null>(null)
// Computed properties
const totalSteps = computed(() => {
if (!selectedCategory.value) return 3
if (selectedCategory.value === 'X_CHECK') return 2
return 4
})
const currentSubCategories = computed(() => {
if (selectedCategory.value === 'ON_BASE') return ON_BASE_OUTCOMES
if (selectedCategory.value === 'OUT') return OUT_OUTCOMES
return {}
})
const currentOutcomes = computed<PlayOutcome[]>(() => {
if (selectedCategory.value === 'X_CHECK') return X_CHECK_OUTCOMES
if (!selectedSubCategory.value) return []
if (selectedCategory.value === 'ON_BASE') {
return ON_BASE_OUTCOMES[selectedSubCategory.value as OnBaseSubCategory]?.outcomes || []
}
if (selectedCategory.value === 'OUT') {
return OUT_OUTCOMES[selectedSubCategory.value as OutSubCategory]?.outcomes || []
}
return []
})
const showLocationStep = computed(() => {
return selectedOutcome.value && requiresHitLocation(selectedOutcome.value)
})
// Methods
function selectCategory(category: OutcomeCategory) {
selectedCategory.value = category
if (category === 'X_CHECK') {
// X-Check goes directly to location selection
selectedOutcome.value = 'x_check'
currentStep.value = 2
} else {
currentStep.value = 2
}
}
function selectSubCategory(subCategory: string) {
selectedSubCategory.value = subCategory as OnBaseSubCategory | OutSubCategory
const outcomes = currentOutcomes.value
if (outcomes.length === 1) {
// Single outcome - auto-select and check if location needed
selectOutcome(outcomes[0])
} else {
currentStep.value = 3
}
}
function selectOutcome(outcome: PlayOutcome) {
selectedOutcome.value = outcome
if (requiresHitLocation(outcome)) {
currentStep.value = 4
} else {
// Submit directly
submitOutcome()
}
}
function selectLocation(location: string) {
selectedLocation.value = location
submitOutcome()
}
function submitOutcome() {
if (!selectedOutcome.value) return
if (!props.canSubmit) return
emit('submit', {
outcome: selectedOutcome.value,
hitLocation: selectedLocation.value || undefined,
})
// Reset wizard
resetWizard()
}
function goBack() {
if (currentStep.value === 4) {
// Going back from location
if (selectedCategory.value === 'X_CHECK') {
currentStep.value = 1
selectedCategory.value = null
selectedOutcome.value = null
} else if (currentOutcomes.value.length === 1) {
// Was auto-selected, go back to subcategory
currentStep.value = 2
selectedOutcome.value = null
} else {
currentStep.value = 3
}
} else if (currentStep.value === 3) {
currentStep.value = 2
selectedSubCategory.value = null
} else if (currentStep.value === 2) {
currentStep.value = 1
selectedCategory.value = null
selectedSubCategory.value = null
selectedOutcome.value = null
}
}
function handleCancel() {
resetWizard()
emit('cancel')
}
function resetWizard() {
currentStep.value = 1
selectedCategory.value = null
selectedSubCategory.value = null
selectedOutcome.value = null
selectedLocation.value = null
}
function getLocationClass(locationId: string): string {
// Position classes for diamond layout
const positionClasses: Record<string, string> = {
P: 'location-pitcher',
C: 'location-catcher',
'1B': 'location-first',
'2B': 'location-second',
SS: 'location-shortstop',
'3B': 'location-third',
LF: 'location-left',
CF: 'location-center',
RF: 'location-right',
}
return positionClasses[locationId] || ''
}
</script>
<style scoped>
.outcome-wizard {
@apply space-y-4;
}
/* Progress Bar */
.progress-bar {
@apply flex items-center justify-between mb-4;
}
.progress-steps {
@apply flex gap-1;
}
.progress-step {
@apply w-8 h-1.5 bg-gray-200 rounded-full transition-colors;
}
.progress-step.active {
@apply bg-blue-500;
}
.progress-step.current {
@apply bg-blue-600;
}
.back-button {
@apply flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-600;
@apply hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors;
}
/* Step Content */
.step-content {
@apply space-y-4;
}
.step-title {
@apply text-lg font-bold text-gray-900 text-center;
}
/* Category Grid */
.category-grid {
@apply grid grid-cols-1 gap-3;
}
@media (min-width: 640px) {
.category-grid {
@apply grid-cols-3;
}
}
.category-button {
@apply flex flex-col items-center justify-center p-6 rounded-xl border-2;
@apply transition-all duration-200 min-h-[100px];
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
.category-label {
@apply text-xl font-bold mb-1;
}
.category-description {
@apply text-sm text-gray-600;
}
/* Subcategory Grid */
.subcategory-grid {
@apply grid grid-cols-2 gap-3;
}
@media (min-width: 640px) {
.subcategory-grid {
@apply grid-cols-3;
}
}
.subcategory-button {
@apply p-4 rounded-xl border-2 border-gray-200 bg-white;
@apply font-semibold text-gray-700;
@apply hover:bg-gray-50 hover:border-gray-300;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
/* Outcome Grid */
.outcome-grid {
@apply grid grid-cols-1 gap-2;
}
@media (min-width: 640px) {
.outcome-grid {
@apply grid-cols-2;
}
}
.outcome-button {
@apply p-3 rounded-lg border-2 border-gray-200 bg-white;
@apply text-sm font-medium text-gray-700;
@apply hover:bg-blue-50 hover:border-blue-300;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400;
}
/* Location Field */
.location-field {
@apply flex justify-center;
}
.location-diamond {
@apply relative w-64 h-64 bg-gradient-to-br from-green-600 to-green-700 rounded-lg overflow-hidden;
}
.location-button {
@apply absolute w-10 h-10 rounded-full bg-white/90 hover:bg-white;
@apply text-xs font-bold text-gray-800;
@apply shadow-lg border-2 border-gray-300 hover:border-blue-400;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-blue-400;
transform: translate(-50%, -50%);
}
/* Location positions */
.location-pitcher {
top: 55%;
left: 50%;
}
.location-catcher {
top: 90%;
left: 50%;
}
.location-first {
top: 50%;
left: 75%;
}
.location-second {
top: 35%;
left: 60%;
}
.location-shortstop {
top: 35%;
left: 40%;
}
.location-third {
top: 50%;
left: 25%;
}
.location-left {
top: 15%;
left: 20%;
}
.location-center {
top: 10%;
left: 50%;
}
.location-right {
top: 15%;
left: 80%;
}
/* Action Bar */
.action-bar {
@apply flex justify-center pt-4 border-t border-gray-200;
}
.cancel-button {
@apply px-6 py-2 text-sm font-medium text-gray-600;
@apply hover:text-gray-900 hover:bg-gray-100;
@apply rounded-lg transition-colors;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.step-title {
@apply text-gray-100;
}
.progress-step {
@apply bg-gray-700;
}
.progress-step.active {
@apply bg-blue-400;
}
.back-button {
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
}
.category-description {
@apply text-gray-400;
}
.subcategory-button {
@apply bg-gray-800 border-gray-700 text-gray-300;
@apply hover:bg-gray-700 hover:border-gray-600;
}
.outcome-button {
@apply bg-gray-800 border-gray-700 text-gray-300;
@apply hover:bg-blue-900/30 hover:border-blue-600;
}
.location-button {
@apply bg-gray-200 hover:bg-white;
}
.action-bar {
@apply border-gray-700;
}
.cancel-button {
@apply text-gray-400 hover:text-gray-200 hover:bg-gray-800;
}
}
</style>

View File

@ -0,0 +1,344 @@
<template>
<div class="p-4 space-y-4">
<!-- Position Selector -->
<PositionSelector
v-if="showPositionSelector"
:mode="substitutionType"
v-model="selectedPosition"
:current-position="currentPosition"
:label="positionLabel"
:required="positionRequired"
/>
<!-- Available Players Section -->
<div v-if="!isPositionChangeOnly">
<div class="text-xs text-gray-400 mb-2">{{ playersLabel }}</div>
<!-- No players message -->
<div v-if="availablePlayers.length === 0" class="text-center py-6 text-gray-500 text-sm">
No players available
</div>
<!-- Player Grid -->
<div v-else class="grid grid-cols-2 gap-2">
<button
v-for="benchPlayer in availablePlayers"
:key="benchPlayer.roster_id"
:class="[
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
selectedPlayerId === benchPlayer.player_id
? 'bg-green-900/40 border-green-600/50 border ring-2 ring-green-500/30'
: 'bg-gray-700/60 hover:bg-green-900/40 border border-transparent hover:border-green-600/50'
]"
@click="selectPlayer(benchPlayer)"
>
<div class="flex items-center gap-2">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold',
selectedPlayerId === benchPlayer.player_id
? 'bg-gradient-to-br from-green-700 to-green-800'
: 'bg-gradient-to-br from-gray-600 to-gray-700'
]"
>
<template v-if="selectedPlayerId === benchPlayer.player_id">
</template>
<template v-else>
{{ getInitials(benchPlayer.player.name) }}
</template>
</div>
<div>
<div class="font-medium text-sm">{{ benchPlayer.player.name }}</div>
<div :class="[
'text-[10px]',
selectedPlayerId === benchPlayer.player_id ? 'text-green-400' : 'text-gray-400'
]">
{{ formatPositions(benchPlayer) }}
</div>
</div>
</div>
</button>
</div>
</div>
<!-- Emergency Players (Pitchers for defensive subs) -->
<div v-if="showEmergencySection && emergencyPlayers.length > 0">
<button
class="w-full flex items-center justify-between text-xs text-gray-500 hover:text-gray-400 py-2 border-t border-gray-700/50 transition-colors select-none"
@click="showEmergency = !showEmergency"
>
<span>
<span class="mr-1">{{ showEmergency ? '▼' : '▶' }}</span>
Show Pitchers
<span class="text-amber-500">(emergency)</span>
</span>
<span class="bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">
{{ emergencyPlayers.length }}
</span>
</button>
<div v-if="showEmergency" class="grid grid-cols-2 gap-2 mt-2">
<button
v-for="benchPlayer in emergencyPlayers"
:key="benchPlayer.roster_id"
:class="[
'rounded-lg p-2.5 text-left transition-all select-none touch-manipulation',
selectedPlayerId === benchPlayer.player_id
? 'bg-amber-900/40 border-amber-600/50 border'
: 'bg-amber-900/20 hover:bg-amber-900/40 border border-amber-700/30'
]"
@click="selectPlayer(benchPlayer)"
>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-amber-700 to-amber-800 flex items-center justify-center text-xs font-bold">
{{ getInitials(benchPlayer.player.name) }}
</div>
<div>
<div class="font-medium text-sm text-amber-200">{{ benchPlayer.player.name }}</div>
<div class="text-[10px] text-amber-400">{{ formatPositions(benchPlayer) }}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Position Change Confirm Button -->
<button
v-if="isPositionChangeOnly"
:disabled="!canSubmit"
:class="[
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
canSubmit
? 'bg-blue-600 hover:bg-blue-500 text-white'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
]"
@click="handleSubmit"
>
Confirm Position Change
</button>
<!-- Substitution Submit (auto-submits when player selected for non-defensive) -->
<div
v-if="!isPositionChangeOnly && substitutionType === 'defensive_replacement' && selectedPlayerId"
class="pt-2"
>
<button
:disabled="!canSubmit"
:class="[
'w-full py-2.5 font-semibold rounded-lg transition-colors select-none touch-manipulation',
canSubmit
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
]"
@click="handleSubmit"
>
Confirm Substitution
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { BenchPlayer } from '~/types'
import PositionSelector from './PositionSelector.vue'
type SubstitutionType = 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' | 'position_change'
interface Props {
substitutionType: SubstitutionType
benchPlayers: BenchPlayer[]
currentPosition?: string | null
teamId: number
playerOutLineupId: number
}
const props = withDefaults(defineProps<Props>(), {
currentPosition: null,
})
const emit = defineEmits<{
submit: [payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: SubstitutionType
}]
}>()
// Local state
const selectedPosition = ref<string | null>(getDefaultPosition())
const selectedPlayerId = ref<number | null>(null)
const showEmergency = ref(false)
// Helper to get default position based on substitution type
function getDefaultPosition(): string | null {
switch (props.substitutionType) {
case 'pinch_hitter':
return 'PH'
case 'pinch_runner':
return 'PR'
case 'relief_pitcher':
return 'P'
case 'defensive_replacement':
return null // Required selection
case 'position_change':
return null
default:
return null
}
}
// Is this a position change only (no player selection)?
const isPositionChangeOnly = computed(() => props.substitutionType === 'position_change')
// Should we show position selector?
const showPositionSelector = computed(() => {
// Always show for position change
if (props.substitutionType === 'position_change') return true
// Don't show for relief pitcher (fixed to P)
if (props.substitutionType === 'relief_pitcher') return false
// Show for everything else
return true
})
// Position selector label
const positionLabel = computed(() => {
if (props.substitutionType === 'position_change') {
return 'Change position to:'
}
return 'Position for new player:'
})
// Is position required?
const positionRequired = computed(() => {
return props.substitutionType === 'defensive_replacement'
})
// Players label
const playersLabel = computed(() => {
switch (props.substitutionType) {
case 'relief_pitcher':
return 'Available Relievers:'
case 'defensive_replacement':
return 'Available Position Players:'
case 'pinch_hitter':
return 'Available Batters:'
case 'pinch_runner':
return 'Available Runners:'
default:
return 'Available Players:'
}
})
// Show emergency section for defensive replacements and pinch hitters
const showEmergencySection = computed(() => {
return props.substitutionType === 'defensive_replacement' ||
props.substitutionType === 'pinch_hitter'
})
// Filter available players based on substitution type
// Uses is_pitcher/is_batter computed properties from backend
// Supports two-way players (is_pitcher=true AND is_batter=true)
const availablePlayers = computed(() => {
switch (props.substitutionType) {
case 'relief_pitcher':
// Only show players with pitching positions
return props.benchPlayers.filter(p => p.is_pitcher)
case 'defensive_replacement':
case 'pinch_hitter':
// Show players with batting positions (includes two-way players)
return props.benchPlayers.filter(p => p.is_batter)
default:
// Show all bench players
return props.benchPlayers
}
})
// Emergency players (pitcher-only players for pinch hitters/defensive subs)
// These are pitchers who DON'T have batting positions (not two-way players)
const emergencyPlayers = computed(() => {
if (props.substitutionType !== 'defensive_replacement' &&
props.substitutionType !== 'pinch_hitter') return []
// Show pitcher-only players (is_pitcher=true but is_batter=false)
return props.benchPlayers.filter(p => p.is_pitcher && !p.is_batter)
})
// Can submit the substitution?
const canSubmit = computed(() => {
if (props.substitutionType === 'position_change') {
return selectedPosition.value !== null && selectedPosition.value !== props.currentPosition
}
if (props.substitutionType === 'defensive_replacement') {
return selectedPosition.value !== null && selectedPlayerId.value !== null
}
// For PH/PR/relief, position has default, just need player
return selectedPlayerId.value !== null
})
// Get player initials
function getInitials(name: string): string {
const parts = name.split(' ')
if (parts.length >= 2) {
return parts[0][0] + parts[parts.length - 1][0]
}
return name.substring(0, 2).toUpperCase()
}
// Format positions for display
// Uses player_positions array from backend (populated from RosterLink)
function formatPositions(benchPlayer: BenchPlayer): string {
if (benchPlayer.player_positions && benchPlayer.player_positions.length > 0) {
return benchPlayer.player_positions.slice(0, 3).join(', ')
}
return 'N/A'
}
// Select a player
function selectPlayer(benchPlayer: BenchPlayer) {
selectedPlayerId.value = benchPlayer.player_id
// For non-defensive subs with default position, auto-submit
if (props.substitutionType !== 'defensive_replacement' &&
props.substitutionType !== 'position_change' &&
selectedPosition.value) {
handleSubmit()
}
}
// Submit the substitution
function handleSubmit() {
if (!canSubmit.value) return
const payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: SubstitutionType
} = {
playerOutLineupId: props.playerOutLineupId,
newPosition: selectedPosition.value!,
teamId: props.teamId,
type: props.substitutionType,
}
// Include player ID for actual substitutions (not position changes)
if (!isPositionChangeOnly.value && selectedPlayerId.value) {
payload.playerInCardId = selectedPlayerId.value
}
emit('submit', payload)
}
// Reset position when substitution type changes
watch(() => props.substitutionType, () => {
selectedPosition.value = getDefaultPosition()
selectedPlayerId.value = null
showEmergency.value = false
})
</script>

View File

@ -0,0 +1,205 @@
<template>
<div
:class="[
'rounded-lg border mb-1.5 transition-all',
containerClass
]"
>
<!-- Slot Header Row -->
<div class="flex items-center gap-3 p-2.5">
<!-- Order Number / Position Badge -->
<div :class="['w-8 h-8 rounded-lg flex items-center justify-center', badgeClass]">
<span class="text-sm font-bold">{{ displayBadge }}</span>
</div>
<!-- Player Avatar -->
<div
:class="[
'w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold',
avatarClass
]"
>
<img
v-if="player.player.headshot"
:src="player.player.headshot"
:alt="player.player.name"
class="w-full h-full rounded-full object-cover"
>
<span v-else>{{ initials }}</span>
</div>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ player.player.name }}</div>
<div :class="['text-xs', subtextClass]">
{{ player.position }}
<template v-if="statusText">
<span class="mx-1">{{ statusText }}</span>
</template>
</div>
</div>
<!-- Action Buttons -->
<template v-if="showActions && !isExpanded">
<button
class="px-3 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
@click="$emit('substitute')"
>
{{ substituteLabel }}
</button>
<button
v-if="showPositionChange"
class="px-2 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
title="Change Position"
@click="$emit('changePosition')"
>
</button>
</template>
<!-- Cancel Button (when expanded) -->
<button
v-if="isExpanded"
class="px-3 py-1.5 text-xs font-medium bg-red-900/50 hover:bg-red-900/70 text-red-300 rounded-lg transition-colors select-none touch-manipulation"
@click="$emit('cancel')"
>
Cancel
</button>
</div>
<!-- Slot for expanded content (accordion) -->
<div
v-if="isExpanded"
class="border-t border-gray-700/50"
>
<slot name="expanded" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Lineup } from '~/types'
type SlotState = 'normal' | 'at_bat' | 'on_base' | 'expanded' | 'position_change'
interface Props {
player: Lineup
battingOrder?: number | null
state?: SlotState
isExpanded?: boolean
showActions?: boolean
showPositionChange?: boolean
substituteLabel?: string
isPitcher?: boolean
basePosition?: '1B' | '2B' | '3B' | null // For runners on base display
}
const props = withDefaults(defineProps<Props>(), {
battingOrder: null,
state: 'normal',
isExpanded: false,
showActions: true,
showPositionChange: true,
substituteLabel: 'Substitute',
isPitcher: false,
basePosition: null,
})
defineEmits<{
substitute: []
changePosition: []
cancel: []
}>()
// Compute initials for avatar fallback
const initials = computed(() => {
const parts = props.player.player.name.split(' ')
if (parts.length >= 2) {
return parts[0][0] + parts[parts.length - 1][0]
}
return props.player.player.name.substring(0, 2).toUpperCase()
})
// Display badge (order number or base position)
const displayBadge = computed(() => {
if (props.basePosition) {
return props.basePosition
}
if (props.isPitcher) {
return 'P'
}
return props.battingOrder ?? props.player.batting_order ?? '?'
})
// Container styling based on state
const containerClass = computed(() => {
if (props.isExpanded) {
if (props.state === 'position_change') {
return 'bg-gray-800/60 border-blue-500/50 ring-2 ring-blue-500/30'
}
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
}
switch (props.state) {
case 'at_bat':
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
case 'on_base':
return 'bg-gray-800/60 border-green-700/50'
default:
return 'bg-gray-800/60 border-gray-700/50'
}
})
// Badge styling
const badgeClass = computed(() => {
if (props.basePosition) {
return 'bg-green-800/50 text-green-400'
}
if (props.isPitcher) {
return 'bg-blue-900/50 text-blue-400'
}
switch (props.state) {
case 'at_bat':
return 'bg-amber-900/50 text-amber-400'
default:
return 'bg-gray-700/50 text-gray-400'
}
})
// Avatar styling
const avatarClass = computed(() => {
switch (props.state) {
case 'at_bat':
return 'bg-gradient-to-br from-amber-700 to-amber-800'
case 'on_base':
return 'bg-gradient-to-br from-green-700 to-green-800 ring-2 ring-green-500/50'
default:
return 'bg-gradient-to-br from-gray-600 to-gray-700'
}
})
// Subtext styling
const subtextClass = computed(() => {
switch (props.state) {
case 'at_bat':
return 'text-amber-400'
case 'on_base':
return 'text-green-400'
default:
return 'text-gray-400'
}
})
// Status text (AT BAT, On 1st, etc.)
const statusText = computed(() => {
if (props.state === 'at_bat') {
return 'AT BAT'
}
if (props.basePosition) {
return `On ${props.basePosition}`
}
return null
})
</script>

View File

@ -0,0 +1,93 @@
<template>
<div class="position-selector">
<div class="text-xs text-gray-400 mb-2">
{{ label }}
<span v-if="required" class="text-red-400">(required)</span>
</div>
<div class="flex flex-wrap gap-1.5">
<button
v-for="pos in displayPositions"
:key="pos"
:class="[
'px-3 py-2 text-xs font-bold rounded-lg transition-colors select-none touch-manipulation',
getPositionClass(pos)
]"
:disabled="isDisabled(pos)"
@click="selectPosition(pos)"
>
{{ pos }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type SubstitutionMode = 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' | 'position_change'
interface Props {
mode: SubstitutionMode
modelValue: string | null
currentPosition?: string | null
label?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
currentPosition: null,
label: 'Position for new player:',
required: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Standard field positions
const FIELD_POSITIONS = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] as const
// Compute display positions based on mode
const displayPositions = computed(() => {
switch (props.mode) {
case 'pinch_hitter':
return ['PH', ...FIELD_POSITIONS]
case 'pinch_runner':
return ['PR', ...FIELD_POSITIONS]
case 'defensive_replacement':
case 'position_change':
return FIELD_POSITIONS
case 'relief_pitcher':
return ['P']
default:
return FIELD_POSITIONS
}
})
// Check if a position should be disabled
const isDisabled = (pos: string): boolean => {
// For position change, disable the current position
if (props.mode === 'position_change' && pos === props.currentPosition) {
return true
}
return false
}
// Get CSS class for position button
const getPositionClass = (pos: string): string => {
if (isDisabled(pos)) {
return 'bg-gray-600 text-gray-400 cursor-not-allowed'
}
if (props.modelValue === pos) {
return 'bg-blue-600 text-white'
}
return 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}
// Handle position selection
const selectPosition = (pos: string) => {
if (!isDisabled(pos)) {
emit('update:modelValue', pos)
}
}
</script>

View File

@ -0,0 +1,426 @@
<template>
<div class="unified-lineup-tab">
<!-- Pre-game: Show LineupBuilder -->
<template v-if="!isGameActive">
<LineupBuilder
:game-id="gameId"
:team-id="myTeamId"
@lineups-submitted="handleLineupSubmit"
/>
</template>
<!-- Mid-game: Read-only lineup with substitution actions -->
<template v-else>
<!-- Team Tabs -->
<div class="flex gap-2 mb-4 bg-gray-800/50 p-1 rounded-xl inline-flex">
<button
:class="[
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
]"
@click="viewingTeamId = awayTeamId"
>
{{ awayTeamName }}
</button>
<button
:class="[
'px-4 py-2 text-sm font-semibold rounded-lg transition-colors select-none',
!isViewingAwayTeam ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-700/50'
]"
@click="viewingTeamId = homeTeamId"
>
{{ homeTeamName }}
</button>
</div>
<!-- Context indicator -->
<p class="text-xs text-gray-400 mb-4">
Mid-game view
<template v-if="isMyTeam">
Your team is {{ isBatting ? 'BATTING' : 'FIELDING' }}
</template>
</p>
<!-- Batting Order Section -->
<div class="mb-4">
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">Batting Order</h2>
<LineupSlotRow
v-for="slot in battingOrderSlots"
:key="slot.lineup_id"
:player="slot"
:batting-order="slot.batting_order"
:state="getSlotState(slot)"
:is-expanded="expandedSlotId === slot.lineup_id && expandedMode !== 'position_change'"
:show-actions="canSubstitute(slot)"
:show-position-change="canChangePosition(slot)"
:substitute-label="getSubstituteLabel(slot)"
@substitute="openSubstitution(slot)"
@change-position="openPositionChange(slot)"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
:substitution-type="getSubstitutionType(slot)"
:bench-players="benchPlayers"
:current-position="slot.position"
:team-id="viewingTeamId"
:player-out-lineup-id="slot.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
<!-- Runners on Base Section (when batting) -->
<div v-if="isBatting && runnersOnBase.length > 0" class="mb-4">
<h2 class="text-sm font-semibold text-green-400 mb-2 uppercase tracking-wide flex items-center gap-2">
<span></span> Runners on Base
</h2>
<div class="bg-green-900/20 rounded-lg border border-green-700/30 p-2.5 space-y-2">
<LineupSlotRow
v-for="runner in runnersOnBase"
:key="`runner-${runner.lineup_id}`"
:player="runner"
:base-position="getBasePosition(runner)"
:state="'on_base'"
:is-expanded="expandedSlotId === runner.lineup_id && expandedMode === 'pinch_runner'"
:show-actions="canSubstitute(runner)"
:show-position-change="false"
substitute-label="Pinch Run"
@substitute="openPinchRunner(runner)"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="pinch_runner"
:bench-players="benchPlayers"
:current-position="runner.position"
:team-id="viewingTeamId"
:player-out-lineup-id="runner.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
</div>
<!-- Pitcher Section -->
<div class="mb-4">
<h2 class="text-sm font-semibold text-gray-400 mb-2 uppercase tracking-wide">
{{ isStartingPitcher ? 'Starting Pitcher' : 'Current Pitcher' }}
</h2>
<LineupSlotRow
v-if="currentPitcherLineup"
:player="currentPitcherLineup"
:is-pitcher="true"
:state="expandedSlotId === currentPitcherLineup.lineup_id ? 'expanded' : 'normal'"
:is-expanded="expandedSlotId === currentPitcherLineup.lineup_id"
:show-actions="canChangePitcher"
:show-position-change="false"
substitute-label="Change Pitcher"
@substitute="openPitchingChange"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="relief_pitcher"
:bench-players="benchPlayers"
:current-position="'P'"
:team-id="viewingTeamId"
:player-out-lineup-id="currentPitcherLineup.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
<!-- Position Change Panel (separate from substitution) -->
<div v-if="expandedMode === 'position_change' && positionChangeSlot" class="mb-4">
<h2 class="text-sm font-semibold text-blue-400 mb-2 uppercase tracking-wide">Position Change</h2>
<LineupSlotRow
:player="positionChangeSlot"
:state="'position_change'"
:is-expanded="true"
:show-actions="false"
@cancel="closeExpanded"
>
<template #expanded>
<InlineSubstitutionPanel
substitution-type="position_change"
:bench-players="[]"
:current-position="positionChangeSlot.position"
:team-id="viewingTeamId"
:player-out-lineup-id="positionChangeSlot.lineup_id"
@submit="handleSubstitutionSubmit"
/>
</template>
</LineupSlotRow>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGameStore } from '~/store/game'
import { useGameActions } from '~/composables/useGameActions'
import type { Lineup } from '~/types'
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
import LineupSlotRow from './LineupSlotRow.vue'
import InlineSubstitutionPanel from './InlineSubstitutionPanel.vue'
type ExpandedMode = 'substitution' | 'position_change' | 'pinch_runner' | null
interface Props {
gameId: string
myTeamId: number | null
homeTeamName?: string
awayTeamName?: string
}
const props = withDefaults(defineProps<Props>(), {
homeTeamName: 'Home',
awayTeamName: 'Away',
})
const emit = defineEmits<{
lineupsSubmitted: [result: unknown]
}>()
const gameStore = useGameStore()
const { submitSubstitution, getBench } = useGameActions()
// Local state
const viewingTeamId = ref<number>(props.myTeamId ?? gameStore.gameState?.away_team_id ?? 0)
const expandedSlotId = ref<number | null>(null)
const expandedMode = ref<ExpandedMode>(null)
// Computed - Game state
const isGameActive = computed(() => gameStore.gameStatus === 'active')
const homeTeamId = computed(() => gameStore.gameState?.home_team_id ?? 0)
const awayTeamId = computed(() => gameStore.gameState?.away_team_id ?? 0)
// Computed - Team viewing
const isViewingAwayTeam = computed(() => viewingTeamId.value === awayTeamId.value)
const isMyTeam = computed(() => viewingTeamId.value === props.myTeamId)
const isBatting = computed(() => viewingTeamId.value === gameStore.battingTeamId)
const isFielding = computed(() => viewingTeamId.value === gameStore.fieldingTeamId)
// Computed - Lineups
const currentTeamLineup = computed(() => {
return isViewingAwayTeam.value
? gameStore.awayLineup
: gameStore.homeLineup
})
const activeLineup = computed(() => {
return currentTeamLineup.value.filter(p => p.is_active)
})
const benchPlayers = computed(() => {
return isViewingAwayTeam.value
? gameStore.awayBench
: gameStore.homeBench
})
const battingOrderSlots = computed(() => {
return activeLineup.value
.filter(p => p.batting_order !== null && p.position !== 'P')
.sort((a, b) => (a.batting_order ?? 0) - (b.batting_order ?? 0))
})
// Computed - Runners on base
const runnersOnBase = computed(() => {
const runners: Array<Lineup & { base: '1B' | '2B' | '3B' }> = []
const state = gameStore.gameState
if (!state) return runners
// Get runners from game state and find their lineup data
if (state.on_first) {
const lineup = gameStore.findPlayerInLineup(state.on_first.lineup_id)
if (lineup) runners.push({ ...lineup, base: '1B' })
}
if (state.on_second) {
const lineup = gameStore.findPlayerInLineup(state.on_second.lineup_id)
if (lineup) runners.push({ ...lineup, base: '2B' })
}
if (state.on_third) {
const lineup = gameStore.findPlayerInLineup(state.on_third.lineup_id)
if (lineup) runners.push({ ...lineup, base: '3B' })
}
return runners
})
// Computed - Pitcher
const currentPitcherLineup = computed(() => {
// Get pitcher for the viewing team
return activeLineup.value.find(p => p.position === 'P')
})
const isStartingPitcher = computed(() => {
return currentPitcherLineup.value?.is_starter ?? true
})
// Note: For demo/testing, allow pitcher changes for the team being viewed
const canChangePitcher = computed(() => {
return isFielding.value
})
// Computed - Current batter
const currentBatterLineupId = computed(() => {
return gameStore.currentBatter?.lineup_id ?? null
})
// Computed - Position change slot
const positionChangeSlot = computed(() => {
if (expandedMode.value !== 'position_change' || !expandedSlotId.value) return null
return activeLineup.value.find(p => p.lineup_id === expandedSlotId.value) ?? null
})
// Get slot state for display
function getSlotState(slot: Lineup): 'normal' | 'at_bat' | 'on_base' | 'expanded' {
if (expandedSlotId.value === slot.lineup_id) return 'expanded'
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'at_bat'
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'on_base'
return 'normal'
}
// Get base position for a runner
function getBasePosition(runner: Lineup & { base?: '1B' | '2B' | '3B' }): '1B' | '2B' | '3B' | null {
return runner.base ?? null
}
// Can substitute at this slot?
// Note: For demo/testing, allow subs for whichever team is being viewed
// In production, could restrict to only your managed team
function canSubstitute(slot: Lineup): boolean {
if (!slot.is_active) return false
return true
}
// Can change position at this slot?
// Note: For demo/testing, allow position changes for the team being viewed
function canChangePosition(slot: Lineup): boolean {
if (!slot.is_active) return false
// Can change position when that team is fielding
return isFielding.value
}
// Get substitute button label
function getSubstituteLabel(slot: Lineup): string {
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) {
return 'Pinch Hit'
}
return 'Substitute'
}
// Get substitution type for a slot
function getSubstitutionType(slot: Lineup): 'pinch_hitter' | 'pinch_runner' | 'defensive_replacement' | 'relief_pitcher' {
if (slot.position === 'P') return 'relief_pitcher'
if (isBatting.value && slot.lineup_id === currentBatterLineupId.value) return 'pinch_hitter'
if (runnersOnBase.value.some(r => r.lineup_id === slot.lineup_id)) return 'pinch_runner'
return 'defensive_replacement'
}
// Open substitution panel for a slot
function openSubstitution(slot: Lineup) {
expandedSlotId.value = slot.lineup_id
expandedMode.value = 'substitution'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
// Open pinch runner panel
function openPinchRunner(runner: Lineup) {
expandedSlotId.value = runner.lineup_id
expandedMode.value = 'pinch_runner'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
// Open position change panel
function openPositionChange(slot: Lineup) {
expandedSlotId.value = slot.lineup_id
expandedMode.value = 'position_change'
}
// Open pitching change panel
function openPitchingChange() {
if (currentPitcherLineup.value) {
expandedSlotId.value = currentPitcherLineup.value.lineup_id
expandedMode.value = 'substitution'
// Fetch bench players when opening substitution panel
getBench(viewingTeamId.value)
}
}
// Close expanded panel
function closeExpanded() {
expandedSlotId.value = null
expandedMode.value = null
}
// Handle substitution submit
async function handleSubstitutionSubmit(payload: {
playerOutLineupId: number
playerInCardId?: number
newPosition: string
teamId: number
type: string
}) {
try {
// Map our type to backend substitution type
let backendType: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change'
switch (payload.type) {
case 'pinch_hitter':
case 'pinch_runner':
backendType = 'pinch_hitter' // Backend treats PR as PH for now
break
case 'relief_pitcher':
backendType = 'pitching_change'
break
case 'position_change':
// TODO: Position change endpoint
console.log('Position change not yet implemented:', payload)
closeExpanded()
return
default:
backendType = 'defensive_replacement'
}
if (payload.playerInCardId) {
await submitSubstitution({
game_id: props.gameId,
type: backendType,
player_out_lineup_id: payload.playerOutLineupId,
player_in_card_id: payload.playerInCardId,
team_id: payload.teamId,
new_position: payload.newPosition,
})
}
closeExpanded()
} catch (error) {
console.error('Substitution failed:', error)
// TODO: Show error toast
}
}
// Handle lineup submit from LineupBuilder
function handleLineupSubmit(result: unknown) {
emit('lineupsSubmitted', result)
}
</script>
<style scoped>
.unified-lineup-tab {
@apply w-full;
}
</style>

View File

@ -0,0 +1,358 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="isOpen"
class="player-card-modal-overlay"
@click.self="close"
>
<div
ref="modalRef"
class="player-card-modal"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Drag handle for mobile -->
<div class="drag-handle">
<div class="drag-indicator" />
</div>
<!-- Close button -->
<button
class="close-button"
@click="close"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Player card image -->
<div class="card-image-container">
<img
v-if="player?.image"
:src="player.image"
:alt="`${player.name} playing card`"
class="card-image"
@error="onImageError"
>
<div v-else class="card-placeholder">
<span class="placeholder-text">{{ playerInitials }}</span>
</div>
</div>
<!-- Player info -->
<div class="player-info">
<h2 class="player-name">{{ player?.name || 'Unknown Player' }}</h2>
<div class="player-details">
<span v-if="position" class="position-badge">{{ position }}</span>
<span v-if="teamName" class="team-name">{{ teamName }}</span>
</div>
</div>
<!-- Substitute button -->
<button
v-if="showSubstituteButton"
class="substitute-button"
@click="onSubstitute"
>
Substitute Player
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Lineup, BenchPlayer } from '~/types/player'
interface PlayerData {
id: number
name: string
image: string
headshot?: string
}
interface Props {
isOpen: boolean
player: PlayerData | null
position?: string
teamName?: string
showSubstituteButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
position: '',
teamName: '',
showSubstituteButton: false,
})
const emit = defineEmits<{
close: []
substitute: [playerId: number]
}>()
const modalRef = ref<HTMLElement | null>(null)
const touchStartY = ref(0)
const touchCurrentY = ref(0)
const isDragging = ref(false)
// Computed
const playerInitials = computed(() => {
if (!props.player?.name) return '?'
const parts = props.player.name.split(' ')
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return parts[0][0].toUpperCase()
})
// Methods
const close = () => {
emit('close')
}
const onSubstitute = () => {
if (props.player) {
emit('substitute', props.player.id)
}
}
const onImageError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
// Touch handling for swipe-to-close
const onTouchStart = (event: TouchEvent) => {
touchStartY.value = event.touches[0].clientY
isDragging.value = true
}
const onTouchMove = (event: TouchEvent) => {
if (!isDragging.value) return
touchCurrentY.value = event.touches[0].clientY
const deltaY = touchCurrentY.value - touchStartY.value
if (deltaY > 0 && modalRef.value) {
// Only allow dragging downward
modalRef.value.style.transform = `translateY(${deltaY}px)`
}
}
const onTouchEnd = () => {
if (!isDragging.value) return
isDragging.value = false
const deltaY = touchCurrentY.value - touchStartY.value
if (deltaY > 100) {
// Swipe down threshold reached - close the modal
close()
}
// Reset position
if (modalRef.value) {
modalRef.value.style.transform = ''
}
}
// Reset touch state when modal closes
watch(() => props.isOpen, (isOpen) => {
if (!isOpen) {
touchStartY.value = 0
touchCurrentY.value = 0
isDragging.value = false
}
})
</script>
<style scoped>
.player-card-modal-overlay {
@apply fixed inset-0 z-50 flex items-end justify-center;
@apply bg-black/60 backdrop-blur-sm;
}
@media (min-width: 768px) {
.player-card-modal-overlay {
@apply items-center;
}
}
.player-card-modal {
@apply relative bg-white rounded-t-2xl w-full max-w-md;
@apply pb-6 pt-2 px-4;
@apply shadow-2xl;
@apply transition-transform duration-200;
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.player-card-modal {
@apply rounded-2xl max-w-2xl;
max-height: 85vh;
}
}
@media (min-width: 1024px) {
.player-card-modal {
@apply max-w-3xl;
max-height: 90vh;
}
}
/* Drag handle */
.drag-handle {
@apply flex justify-center py-2 mb-2;
}
.drag-indicator {
@apply w-10 h-1 bg-gray-300 rounded-full;
}
@media (min-width: 768px) {
.drag-handle {
@apply hidden;
}
}
/* Close button */
.close-button {
@apply absolute top-3 right-3 p-2 rounded-full;
@apply bg-gray-100 text-gray-600;
@apply hover:bg-gray-200 transition-colors;
@apply min-w-[44px] min-h-[44px] flex items-center justify-center;
}
/* Card image */
.card-image-container {
@apply flex justify-center mb-4;
}
.card-image {
@apply max-w-full max-h-[50vh] object-contain rounded-lg shadow-lg;
}
@media (min-width: 768px) {
.card-image {
max-height: 60vh;
}
}
.card-placeholder {
@apply w-48 h-64 bg-gradient-to-br from-gray-200 to-gray-300;
@apply rounded-lg flex items-center justify-center;
@apply shadow-lg;
}
.placeholder-text {
@apply text-4xl font-bold text-gray-500;
}
/* Player info */
.player-info {
@apply text-center mb-4;
}
.player-name {
@apply text-xl font-bold text-gray-900 mb-1;
}
.player-details {
@apply flex items-center justify-center gap-2 flex-wrap;
}
.position-badge {
@apply px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-semibold;
}
.team-name {
@apply text-gray-600 text-sm;
}
/* Substitute button */
.substitute-button {
@apply w-full py-4 px-6 rounded-xl;
@apply bg-gradient-to-r from-orange-500 to-orange-600 text-white;
@apply font-bold text-lg;
@apply hover:from-orange-600 hover:to-orange-700;
@apply active:scale-95 transition-all;
@apply min-h-[52px];
}
/* Transition animations */
.modal-enter-active {
@apply transition-all duration-300 ease-out;
}
.modal-leave-active {
@apply transition-all duration-200 ease-in;
}
.modal-enter-from {
@apply opacity-0;
}
.modal-enter-from .player-card-modal {
@apply translate-y-full;
}
@media (min-width: 768px) {
.modal-enter-from .player-card-modal {
@apply translate-y-0 scale-95;
}
}
.modal-leave-to {
@apply opacity-0;
}
.modal-leave-to .player-card-modal {
@apply translate-y-full;
}
@media (min-width: 768px) {
.modal-leave-to .player-card-modal {
@apply translate-y-0 scale-95;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.player-card-modal {
@apply bg-gray-800;
}
.drag-indicator {
@apply bg-gray-600;
}
.close-button {
@apply bg-gray-700 text-gray-300 hover:bg-gray-600;
}
.card-placeholder {
@apply from-gray-700 to-gray-600;
}
.placeholder-text {
@apply text-gray-400;
}
.player-name {
@apply text-gray-100;
}
.position-badge {
@apply bg-blue-900 text-blue-200;
}
.team-name {
@apply text-gray-400;
}
}
</style>

View File

@ -49,7 +49,7 @@
<button
@click="handlePlayGame"
:disabled="isCreating"
class="w-full px-4 py-2 bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded-lg transition disabled:cursor-not-allowed"
class="w-full px-4 py-2 bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded-lg transition disabled:cursor-not-allowed select-none touch-manipulation"
>
{{ isCreating ? 'Creating...' : 'Play This Game' }}
</button>

View File

@ -11,7 +11,7 @@
</div>
<!-- Tab Navigation -->
<div class="tab-navigation">
<div class="tab-navigation select-none">
<button
v-for="tab in tabs"
:key="tab.type"
@ -75,11 +75,11 @@
<!-- Player Selection (if no player selected) -->
<div v-if="!selectedDefensivePlayer" class="player-selection">
<div class="selection-label">Select player to replace:</div>
<div class="player-grid">
<div class="player-grid select-none">
<button
v-for="player in activeFielders"
:key="player.lineup_id"
class="player-button"
class="player-button touch-manipulation"
@click="selectDefensivePlayer(player)"
>
<div class="player-name">{{ player.player.name }}</div>
@ -326,6 +326,9 @@ const handleCancel = () => {
.tab-button {
@apply flex items-center gap-2 px-4 py-3 font-semibold transition-all duration-200;
@apply border-b-2 -mb-px;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
.tab-inactive {
@ -398,6 +401,8 @@ const handleCancel = () => {
@apply hover:border-blue-400 hover:bg-blue-50;
@apply transition-all duration-150;
@apply text-left min-h-[70px];
-webkit-user-select: none;
user-select: none;
}
.player-name {

View File

@ -3,6 +3,7 @@
:type="type"
:disabled="disabled || loading"
:class="buttonClasses"
class="select-none touch-manipulation"
@click="handleClick"
>
<!-- Loading Spinner -->
@ -76,3 +77,47 @@ const handleClick = (event: MouseEvent) => {
}
}
</script>
<style scoped>
/* Prevent text selection on buttons - critical for mobile UX */
button,
button * {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
}
/* Dark mode - add visible borders since gradients may not render on some browsers */
@media (prefers-color-scheme: dark) {
button {
@apply ring-2 ring-offset-1 ring-offset-gray-900;
}
/* Variant-specific ring colors */
button[class*="from-green"] {
@apply ring-green-400;
}
button[class*="from-blue"], button[class*="from-primary"] {
@apply ring-blue-400;
}
button[class*="from-red"] {
@apply ring-red-400;
}
button[class*="from-yellow"] {
@apply ring-yellow-400;
}
button[class*="from-gray"] {
@apply ring-gray-400;
}
button:disabled {
@apply ring-gray-600;
}
}
</style>

View File

@ -0,0 +1,311 @@
<template>
<Teleport to="body">
<!-- Backdrop (only visible when expanded) -->
<Transition name="fade">
<div
v-if="isOpen && !isMinimized"
class="bottom-sheet-backdrop"
@click="minimize"
/>
</Transition>
<!-- Bottom Sheet Container -->
<Transition name="slide">
<div
v-if="isOpen"
ref="sheetRef"
class="bottom-sheet-container"
:class="{ 'is-minimized': isMinimized }"
:style="sheetStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Drag Handle -->
<div class="drag-handle" @click="toggleMinimize">
<div class="drag-indicator" />
<span v-if="isMinimized" class="minimize-label">{{ title }} - Tap to expand</span>
</div>
<!-- Sheet Content -->
<div v-show="!isMinimized" class="sheet-content">
<slot />
</div>
</div>
</Transition>
<!-- Floating Restore Button (shown when minimized) -->
<Transition name="pop">
<button
v-if="isOpen && isMinimized && showFloatingButton"
class="floating-restore-button"
@click="expand"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span class="button-label">{{ title }}</span>
</button>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface Props {
isOpen: boolean
title?: string
showFloatingButton?: boolean
minimizeThreshold?: number
startMinimized?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: 'Panel',
showFloatingButton: true,
minimizeThreshold: 100,
startMinimized: false,
})
const emit = defineEmits<{
close: []
minimize: []
expand: []
}>()
// State
const sheetRef = ref<HTMLElement | null>(null)
const isMinimized = ref(props.startMinimized)
const touchStartY = ref(0)
const touchCurrentY = ref(0)
const isDragging = ref(false)
const dragOffset = ref(0)
// Computed style for drag animation
const sheetStyle = computed(() => {
if (isDragging.value && dragOffset.value > 0) {
return {
transform: `translateY(${dragOffset.value}px)`,
transition: 'none',
}
}
return {}
})
// Touch handlers for swipe gestures
function onTouchStart(event: TouchEvent) {
touchStartY.value = event.touches[0].clientY
isDragging.value = true
}
function onTouchMove(event: TouchEvent) {
if (!isDragging.value) return
touchCurrentY.value = event.touches[0].clientY
const deltaY = touchCurrentY.value - touchStartY.value
// Only allow dragging down (positive delta)
if (deltaY > 0 && !isMinimized.value) {
dragOffset.value = deltaY
}
// Allow dragging up when minimized
if (deltaY < 0 && isMinimized.value) {
// Subtle feedback for upward drag
dragOffset.value = Math.max(deltaY / 2, -30)
}
}
function onTouchEnd() {
if (!isDragging.value) return
isDragging.value = false
const deltaY = touchCurrentY.value - touchStartY.value
// Swipe down to minimize
if (deltaY > props.minimizeThreshold && !isMinimized.value) {
minimize()
}
// Swipe up to expand
if (deltaY < -50 && isMinimized.value) {
expand()
}
// Reset drag offset
dragOffset.value = 0
}
// Actions
function minimize() {
isMinimized.value = true
emit('minimize')
}
function expand() {
isMinimized.value = false
emit('expand')
}
function toggleMinimize() {
if (isMinimized.value) {
expand()
} else {
minimize()
}
}
// Reset state when closed
watch(() => props.isOpen, (isOpen) => {
if (!isOpen) {
isDragging.value = false
dragOffset.value = 0
touchStartY.value = 0
touchCurrentY.value = 0
} else if (props.startMinimized) {
isMinimized.value = true
}
})
// Prevent body scroll when sheet is open and expanded
watch([() => props.isOpen, isMinimized], ([open, minimized]) => {
if (open && !minimized) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<style scoped>
/* Backdrop */
.bottom-sheet-backdrop {
@apply fixed inset-0 z-40 bg-black/40 backdrop-blur-sm;
}
/* Container */
.bottom-sheet-container {
@apply fixed bottom-0 left-0 right-0 z-50;
@apply bg-white rounded-t-2xl shadow-2xl;
@apply max-h-[85vh] overflow-hidden;
@apply transition-all duration-300 ease-out;
}
.bottom-sheet-container.is-minimized {
@apply max-h-14;
@apply shadow-lg;
}
/* Drag Handle */
.drag-handle {
@apply flex flex-col items-center py-3 cursor-grab active:cursor-grabbing;
@apply border-b border-gray-100;
}
.drag-indicator {
@apply w-10 h-1 bg-gray-300 rounded-full;
}
.minimize-label {
@apply mt-1 text-xs font-medium text-gray-500;
}
/* Sheet Content */
.sheet-content {
@apply max-h-[calc(85vh-56px)] overflow-y-auto;
@apply p-4;
}
/* Floating Restore Button */
.floating-restore-button {
@apply fixed bottom-20 left-1/2 -translate-x-1/2 z-50;
@apply flex items-center gap-2 px-4 py-2;
@apply bg-blue-600 hover:bg-blue-700 text-white;
@apply rounded-full shadow-lg;
@apply font-medium text-sm;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2;
}
.button-label {
@apply whitespace-nowrap;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity duration-300;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
.slide-enter-active {
@apply transition-transform duration-300 ease-out;
}
.slide-leave-active {
@apply transition-transform duration-200 ease-in;
}
.slide-enter-from,
.slide-leave-to {
@apply translate-y-full;
}
.pop-enter-active {
@apply transition-all duration-300 ease-out;
}
.pop-leave-active {
@apply transition-all duration-200 ease-in;
}
.pop-enter-from,
.pop-leave-to {
@apply opacity-0 scale-75 translate-y-4;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.bottom-sheet-container {
@apply bg-gray-800;
}
.drag-handle {
@apply border-gray-700;
}
.drag-indicator {
@apply bg-gray-600;
}
.minimize-label {
@apply text-gray-400;
}
}
/* Desktop adjustments */
@media (min-width: 768px) {
.bottom-sheet-container {
@apply max-w-lg left-1/2 -translate-x-1/2 right-auto;
@apply max-h-[70vh];
}
.bottom-sheet-container.is-minimized {
@apply translate-x-0 left-auto right-4 rounded-t-xl;
@apply max-w-xs;
}
.floating-restore-button {
@apply bottom-6 right-6 left-auto translate-x-0;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div :class="containerClasses">
<div :class="containerClasses" class="select-none">
<button
v-for="(option, index) in options"
:key="option.value"
@ -124,3 +124,19 @@ const handleSelect = (value: string) => {
}
}
</script>
<style scoped>
/* Prevent text selection on button group - critical for mobile UX */
:deep(button),
:deep(button *) {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
}
button {
touch-action: manipulation;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 select-none">
<!-- Toggle Switch -->
<button
type="button"
@ -109,3 +109,19 @@ const handleToggle = () => {
}
}
</script>
<style scoped>
/* Prevent text selection on toggle elements - critical for mobile UX */
:deep(button),
:deep(button *) {
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
}
button {
touch-action: manipulation;
}
</style>

View File

@ -315,6 +315,20 @@ export function useGameActions(gameId?: string) {
})
}
/**
* Get bench players for a team (for substitutions)
*/
function getBench(teamId: number) {
if (!validateConnection()) return
console.log('[GameActions] Requesting bench for team:', teamId)
socket.value!.emit('get_bench', {
game_id: currentGameId.value!,
team_id: teamId,
})
}
/**
* Get box score
*/
@ -368,6 +382,7 @@ export function useGameActions(gameId?: string) {
// Data requests
getLineup,
getBench,
getBoxScore,
requestGameState,
}

View File

@ -622,6 +622,11 @@ export function useWebSocket() {
gameStore.updateLineup(data.team_id, data.players)
})
state.socketInstance.on('bench_data', (data) => {
console.log('[WebSocket] Bench data received for team:', data.team_id, '- players:', data.players.length)
gameStore.setBench(data.team_id, data.players)
})
state.socketInstance.on('box_score_data', (data) => {
console.log('[WebSocket] Box score data received')
// Box score will be handled by dedicated component

View File

@ -0,0 +1,220 @@
/**
* Outcome Flow Constants
*
* Defines the hierarchical structure for the progressive disclosure
* outcome selection wizard. Three top-level categories branch into
* specific outcome types.
*
* Categories:
* - ON_BASE: Hits and walks that result in batter reaching base
* - OUT: Various types of outs
* - X_CHECK: Defensive X-Check resolution (errors result from this)
*/
import type { PlayOutcome } from '~/types/game'
/**
* Top-level outcome categories for step 1 selection
*/
export type OutcomeCategory = 'ON_BASE' | 'OUT' | 'X_CHECK'
/**
* Sub-categories for ON_BASE outcomes
*/
export type OnBaseSubCategory = 'SINGLE' | 'DOUBLE' | 'TRIPLE' | 'HOME_RUN' | 'WALK' | 'HBP'
/**
* Sub-categories for OUT outcomes
*/
export type OutSubCategory = 'STRIKEOUT' | 'GROUNDOUT' | 'FLYOUT' | 'LINEOUT' | 'POPOUT'
/**
* Category configuration with display info
*/
export interface CategoryConfig {
label: string
description: string
color: string
bgColor: string
borderColor: string
}
/**
* Sub-category configuration with outcomes
*/
export interface SubCategoryConfig {
label: string
outcomes: PlayOutcome[]
}
/**
* Category display configuration
*/
export const CATEGORY_CONFIG: Record<OutcomeCategory, CategoryConfig> = {
ON_BASE: {
label: 'On Base',
description: 'Hit, walk, or HBP',
color: 'text-green-700',
bgColor: 'bg-green-50 hover:bg-green-100',
borderColor: 'border-green-300',
},
OUT: {
label: 'Out',
description: 'Strikeout, groundout, flyout',
color: 'text-red-700',
bgColor: 'bg-red-50 hover:bg-red-100',
borderColor: 'border-red-300',
},
X_CHECK: {
label: 'X-Check',
description: 'Defensive play check',
color: 'text-orange-700',
bgColor: 'bg-orange-50 hover:bg-orange-100',
borderColor: 'border-orange-300',
},
}
/**
* ON_BASE sub-categories and their specific outcomes
*/
export const ON_BASE_OUTCOMES: Record<OnBaseSubCategory, SubCategoryConfig> = {
SINGLE: {
label: 'Single',
outcomes: ['single_1', 'single_2', 'single_uncapped'],
},
DOUBLE: {
label: 'Double',
outcomes: ['double_2', 'double_3', 'double_uncapped'],
},
TRIPLE: {
label: 'Triple',
outcomes: ['triple'],
},
HOME_RUN: {
label: 'Home Run',
outcomes: ['homerun'],
},
WALK: {
label: 'Walk',
outcomes: ['walk', 'intentional_walk'],
},
HBP: {
label: 'Hit By Pitch',
outcomes: ['hbp'],
},
}
/**
* OUT sub-categories and their specific outcomes
*/
export const OUT_OUTCOMES: Record<OutSubCategory, SubCategoryConfig> = {
STRIKEOUT: {
label: 'Strikeout',
outcomes: ['strikeout'],
},
GROUNDOUT: {
label: 'Groundout',
outcomes: ['groundball_a', 'groundball_b', 'groundball_c'],
},
FLYOUT: {
label: 'Flyout',
outcomes: ['flyout_a', 'flyout_b', 'flyout_bq', 'flyout_c'],
},
LINEOUT: {
label: 'Lineout',
outcomes: ['lineout'],
},
POPOUT: {
label: 'Popout',
outcomes: ['popout'],
},
}
/**
* X-CHECK outcome (singular - error is a result, not input)
*/
export const X_CHECK_OUTCOMES: PlayOutcome[] = ['x_check']
/**
* Outcome display labels for final selection
*/
export const OUTCOME_LABELS: Partial<Record<PlayOutcome, string>> = {
// Singles
single_1: 'Single (1 base)',
single_2: 'Single (2 bases)',
single_uncapped: 'Single (uncapped)',
// Doubles
double_2: 'Double (2 bases)',
double_3: 'Double (3 bases)',
double_uncapped: 'Double (uncapped)',
// Other hits
triple: 'Triple',
homerun: 'Home Run',
// Walks
walk: 'Walk',
intentional_walk: 'Intentional Walk',
hbp: 'Hit By Pitch',
// Outs
strikeout: 'Strikeout',
groundball_a: 'Groundball A',
groundball_b: 'Groundball B',
groundball_c: 'Groundball C',
flyout_a: 'Flyout A',
flyout_b: 'Flyout B',
flyout_bq: 'Flyout B*',
flyout_c: 'Flyout C',
lineout: 'Lineout',
popout: 'Popout',
// X-Check
x_check: 'X-Check',
}
/**
* Hit location positions for location selection step
*/
export const HIT_LOCATIONS = [
{ id: 'P', label: 'P', position: 'Pitcher' },
{ id: 'C', label: 'C', position: 'Catcher' },
{ id: '1B', label: '1B', position: 'First Base' },
{ id: '2B', label: '2B', position: 'Second Base' },
{ id: 'SS', label: 'SS', position: 'Shortstop' },
{ id: '3B', label: '3B', position: 'Third Base' },
{ id: 'LF', label: 'LF', position: 'Left Field' },
{ id: 'CF', label: 'CF', position: 'Center Field' },
{ id: 'RF', label: 'RF', position: 'Right Field' },
] as const
/**
* Outcomes that require hit location selection
*/
export const OUTCOMES_REQUIRING_LOCATION: PlayOutcome[] = [
'groundball_a',
'groundball_b',
'groundball_c',
'flyout_a',
'flyout_b',
'flyout_bq',
'flyout_c',
'lineout',
'popout',
'x_check',
]
/**
* Check if an outcome requires a hit location
*/
export function requiresHitLocation(outcome: PlayOutcome): boolean {
return OUTCOMES_REQUIRING_LOCATION.includes(outcome)
}
/**
* Get the display label for an outcome
*/
export function getOutcomeLabel(outcome: PlayOutcome): string {
return OUTCOME_LABELS[outcome] || outcome
}

View File

@ -24,8 +24,10 @@
<span>Back to Games</span>
</NuxtLink>
<!-- Logo -->
<div class="text-lg font-bold">SBA</div>
<!-- Matchup -->
<div class="text-sm font-bold text-center truncate max-w-[200px] sm:max-w-none sm:text-lg">
{{ matchupText }}
</div>
<!-- Connection Status -->
<div class="flex items-center space-x-3">
@ -55,7 +57,7 @@
</header>
<!-- Game Content (Full Width, No Container) -->
<main class="flex-1 overflow-auto">
<main class="flex-1">
<slot />
</main>
@ -68,14 +70,22 @@
<script setup lang="ts">
import { useAuthStore } from '~/store/auth'
import { useGameStore } from '~/store/game'
import { useWebSocket } from '~/composables/useWebSocket'
const authStore = useAuthStore()
const gameStore = useGameStore()
// WebSocket connection status
const isConnected = computed(() => gameStore.isConnected)
// WebSocket connection status - use composable directly as source of truth
const { isConnected } = useWebSocket()
// Auth is initialized by the auth plugin automatically
// Team names for header (from gameState, stored in DB at game creation)
const matchupText = computed(() => {
const gs = gameStore.gameState
if (gs?.away_team_name && gs?.home_team_name) {
return `${gs.away_team_name} @ ${gs.home_team_name}`
}
return 'SBA'
})
</script>
<style scoped>

View File

@ -14,7 +14,8 @@
"socket.io-client": "^4.8.1",
"vue": "^3.5.22",
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.10.0",
@ -13179,6 +13180,12 @@
}
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
@ -15697,6 +15704,18 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@ -23,7 +23,8 @@
"socket.io-client": "^4.8.1",
"vue": "^3.5.22",
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.10.0",

View File

@ -1,40 +1,41 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Sticky Header: ScoreBoard + Tabs -->
<div class="sticky top-0 z-30">
<!-- ScoreBoard -->
<ScoreBoard
:home-score="gameState?.home_score"
:away-score="gameState?.away_score"
:inning="gameState?.inning"
:half="gameState?.half"
:outs="gameState?.outs"
:runners="runnersState"
/>
<!-- ScoreBoard (scrolls with content) -->
<ScoreBoard
:home-score="gameState?.home_score"
:away-score="gameState?.away_score"
:inning="gameState?.inning"
:half="gameState?.half"
:outs="gameState?.outs"
:runners="runnersState"
:away-team-color="awayTeamColor"
:home-team-color="homeTeamColor"
:away-team-thumbnail="awayTeamThumbnail"
:home-team-thumbnail="homeTeamThumbnail"
/>
<!-- Tab Navigation -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="container mx-auto">
<div class="flex">
<button
v-for="tab in tabs"
:key="tab.id"
:class="[
'flex-1 py-3 px-4 text-sm font-medium text-center transition-colors relative',
activeTab === tab.id
? 'text-primary dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
]"
@click="activeTab = tab.id"
>
{{ tab.label }}
<!-- Active indicator -->
<span
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary dark:bg-blue-400"
/>
</button>
</div>
<!-- Tab Navigation (sticky below header) -->
<div class="sticky top-[52px] z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="container mx-auto">
<div class="flex">
<button
v-for="tab in tabs"
:key="tab.id"
:class="[
'flex-1 py-3 px-4 text-sm font-medium text-center transition-colors relative',
activeTab === tab.id
? 'text-primary dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
]"
@click="activeTab = tab.id"
>
{{ tab.label }}
<!-- Active indicator -->
<span
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary dark:bg-blue-400"
/>
</button>
</div>
</div>
</div>
@ -49,9 +50,11 @@
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
<LineupBuilder
<UnifiedLineupTab
:game-id="gameId"
:team-id="myManagedTeamId"
:my-team-id="myManagedTeamId"
:home-team-name="homeTeamName"
:away-team-name="awayTeamName"
@lineups-submitted="handleLineupsSubmitted"
/>
</div>
@ -70,7 +73,7 @@ import { useAuthStore } from '~/store/auth'
import { useUiStore } from '~/store/ui'
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
import GamePlay from '~/components/Game/GamePlay.vue'
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
import UnifiedLineupTab from '~/components/Lineup/UnifiedLineupTab.vue'
import GameStats from '~/components/Game/GameStats.vue'
definePageMeta({
@ -84,6 +87,8 @@ const gameStore = useGameStore()
const authStore = useAuthStore()
const uiStore = useUiStore()
// Note: Team display info now comes directly from gameState (stored in DB at game creation)
// Game ID from route
const gameId = computed(() => route.params.id as string)
@ -115,6 +120,28 @@ const runnersState = computed(() => {
}
})
// Team colors for ScoreBoard gradient (from gameState, stored in DB at creation)
const awayTeamColor = computed(() => {
const color = gameState.value?.away_team_color
// Add # prefix if color exists but doesn't have it
return color ? (color.startsWith('#') ? color : `#${color}`) : undefined
})
const homeTeamColor = computed(() => {
const color = gameState.value?.home_team_color
// Add # prefix if color exists but doesn't have it
return color ? (color.startsWith('#') ? color : `#${color}`) : undefined
})
// Team thumbnails for ScoreBoard
const awayTeamThumbnail = computed(() => gameState.value?.away_team_thumbnail)
const homeTeamThumbnail = computed(() => gameState.value?.home_team_thumbnail)
// Team names for UnifiedLineupTab
const awayTeamName = computed(() => gameState.value?.away_team_name ?? 'Away')
const homeTeamName = computed(() => gameState.value?.home_team_name ?? 'Home')
// Check if user is a manager of either team in this game
const isUserManager = computed(() => {
if (!gameState.value) return false

View File

@ -174,8 +174,8 @@ const handleCreateGame = async () => {
}
)
// Redirect to lineup builder
router.push(`/games/lineup/${response.game_id}`)
// Redirect to game page (lineup is a tab there)
router.push(`/games/${response.game_id}`)
} catch (err: any) {
error.value = err.data?.detail || err.message || 'Failed to create game'
console.error('Create game error:', err)

View File

@ -144,7 +144,7 @@
Final
</span>
</template>
<!-- Active webapp game: show "In Progress" link -->
<!-- Active webapp game: show "In Progress" link (team data stored in DB) -->
<template v-else-if="activeScheduleGameMap.get(game.id)">
<span class="flex-1"></span>
<NuxtLink
@ -158,7 +158,7 @@
<template v-else>
<span class="flex-1"></span>
<button
@click="handlePlayScheduledGame(game.home_team.id, game.away_team.id, game.id)"
@click="handlePlayScheduledGame(game)"
:disabled="isCreatingQuickGame"
class="px-2 py-1 text-xs bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded transition disabled:cursor-not-allowed"
>
@ -334,6 +334,7 @@
<script setup lang="ts">
import { useAuthStore } from '~/store/auth'
import type { SbaScheduledGame } from '~/types/schedule'
definePageMeta({
middleware: ['auth'], // Require authentication
@ -438,12 +439,14 @@ async function handleQuickCreate() {
}
// Create a game from a scheduled matchup
async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, scheduleGameId: number) {
// Note: Team display info is now stored in DB by backend - no need to pass via useState
async function handlePlayScheduledGame(scheduledGame: SbaScheduledGame) {
try {
isCreatingQuickGame.value = true
error.value = null
console.log(`[Games Page] Creating game: ${awayTeamId} @ ${homeTeamId} (schedule_game_id: ${scheduleGameId})`)
const { home_team, away_team, id: scheduleGameId } = scheduledGame
console.log(`[Games Page] Creating game: ${away_team.sname} @ ${home_team.sname} (schedule_game_id: ${scheduleGameId})`)
const response = await $fetch<{ game_id: string; message: string; status: string }>(
`${config.public.apiUrl}/api/games/quick-create`,
@ -451,8 +454,8 @@ async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, s
method: 'POST',
credentials: 'include',
body: {
home_team_id: homeTeamId,
away_team_id: awayTeamId,
home_team_id: home_team.id,
away_team_id: away_team.id,
schedule_game_id: scheduleGameId,
},
}
@ -460,7 +463,7 @@ async function handlePlayScheduledGame(homeTeamId: number, awayTeamId: number, s
console.log('[Games Page] Created game from schedule:', response)
// Redirect to game page
// Redirect to game page (team display info is stored in DB by backend)
router.push(`/games/${response.game_id}`)
} catch (err: any) {
console.error('[Games Page] Failed to create game from schedule:', err)

View File

@ -15,6 +15,7 @@ import type {
OffensiveDecision,
RollData,
Lineup,
BenchPlayer,
} from '~/types'
export const useGameStore = defineStore('game', () => {
@ -25,6 +26,8 @@ export const useGameStore = defineStore('game', () => {
const gameState = ref<GameState | null>(null)
const homeLineup = ref<Lineup[]>([])
const awayLineup = ref<Lineup[]>([])
const homeBench = ref<BenchPlayer[]>([])
const awayBench = ref<BenchPlayer[]>([])
const playHistory = ref<PlayResult[]>([])
const currentDecisionPrompt = ref<DecisionPrompt | null>(null)
const pendingRoll = ref<RollData | null>(null)
@ -186,6 +189,19 @@ export const useGameStore = defineStore('game', () => {
}
}
/**
* Set bench players for a specific team
* Bench players come from RosterLink (players not in active lineup)
* with is_pitcher/is_batter computed properties for UI filtering
*/
function setBench(teamId: number, bench: BenchPlayer[]) {
if (teamId === gameState.value?.home_team_id) {
homeBench.value = bench
} else if (teamId === gameState.value?.away_team_id) {
awayBench.value = bench
}
}
/**
* Set play history (replaces entire array - used for initial sync)
* O(1) operation - no deduplication needed
@ -323,6 +339,8 @@ export const useGameStore = defineStore('game', () => {
gameState.value = null
homeLineup.value = []
awayLineup.value = []
homeBench.value = []
awayBench.value = []
playHistory.value = []
currentDecisionPrompt.value = null
pendingRoll.value = null
@ -373,6 +391,8 @@ export const useGameStore = defineStore('game', () => {
gameState: readonly(gameState),
homeLineup: readonly(homeLineup),
awayLineup: readonly(awayLineup),
homeBench: readonly(homeBench),
awayBench: readonly(awayBench),
playHistory: readonly(playHistory),
currentDecisionPrompt: readonly(currentDecisionPrompt),
pendingRoll: readonly(pendingRoll),
@ -421,6 +441,7 @@ export const useGameStore = defineStore('game', () => {
updateGameState,
setLineups,
updateLineup,
setBench,
setPlayHistory,
addPlayToHistory,
setDecisionPrompt,

View File

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
import type { RollData } from '~/types'
describe('DiceRoller', () => {
@ -14,10 +15,17 @@ describe('DiceRoller', () => {
resolution_d20: 8,
check_wild_pitch: false,
check_passed_ball: false,
chaos_check_skipped: false,
timestamp: '2025-01-13T12:00:00Z',
...overrides,
})
const defaultProps = {
canRoll: true,
pendingRoll: null as RollData | null,
diceColor: 'cc0000', // Default red
}
beforeEach(() => {
vi.clearAllTimers()
vi.useFakeTimers()
@ -30,10 +38,7 @@ describe('DiceRoller', () => {
describe('Rendering', () => {
it('renders roll button when no pending roll', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
@ -45,6 +50,7 @@ describe('DiceRoller', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -55,27 +61,83 @@ describe('DiceRoller', () => {
expect(wrapper.text()).toContain('Dice Results')
})
it('displays all four dice values correctly', () => {
it('displays three dice when no WP/PB check triggered (chaos d20 hidden)', () => {
/**
* When chaos d20 doesn't trigger WP (1) or PB (2), it's hidden since values 3-20
* have no game effect. This reduces visual noise in the dice display.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 17,
chaos_d20: 17, // Not 1 or 2, so no check triggered
resolution_d20: 3,
check_wild_pitch: false,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceValues = wrapper.findAll('.dice-value')
expect(diceValues).toHaveLength(4)
expect(diceValues[0].text()).toBe('5')
expect(diceValues[1].text()).toBe('8')
expect(diceValues[2].text()).toBe('17')
expect(diceValues[3].text()).toBe('3')
// DiceShapes components are rendered for each die
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(3) // chaos d20 hidden when no check triggered
})
it('displays all four dice when wild pitch check triggered', () => {
/**
* Chaos d20 is shown when it triggers a Wild Pitch check (value == 1),
* since this affects gameplay and the user needs to see the dice value.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 1, // Triggers WP check
resolution_d20: 3,
check_wild_pitch: true,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(4) // chaos d20 shown for WP check
})
it('displays all four dice when passed ball check triggered', () => {
/**
* Chaos d20 is shown when it triggers a Passed Ball check (value == 2),
* since this affects gameplay and the user needs to see the dice value.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 2, // Triggers PB check
resolution_d20: 3,
check_wild_pitch: false,
check_passed_ball: true,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(4) // chaos d20 shown for PB check
})
it('displays d6_two component dice values', () => {
@ -87,6 +149,7 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -103,10 +166,7 @@ describe('DiceRoller', () => {
describe('Button States', () => {
it('enables button when canRoll is true', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
const button = wrapper.find('.roll-button')
@ -118,8 +178,8 @@ describe('DiceRoller', () => {
it('disables button when canRoll is false', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: null,
},
})
@ -131,10 +191,7 @@ describe('DiceRoller', () => {
it('shows loading state when rolling', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
@ -145,10 +202,7 @@ describe('DiceRoller', () => {
it('disables button during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
@ -159,10 +213,7 @@ describe('DiceRoller', () => {
it('resets rolling state after timeout', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
@ -182,10 +233,7 @@ describe('DiceRoller', () => {
describe('Event Emission', () => {
it('emits roll event when button clicked', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
@ -197,8 +245,8 @@ describe('DiceRoller', () => {
it('does not emit roll when canRoll is false', async () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: null,
},
})
@ -209,10 +257,7 @@ describe('DiceRoller', () => {
it('does not emit roll during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
@ -231,6 +276,7 @@ describe('DiceRoller', () => {
const rollData = createRollData({ check_wild_pitch: true })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -244,6 +290,7 @@ describe('DiceRoller', () => {
const rollData = createRollData({ check_passed_ball: true })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -261,6 +308,7 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -278,6 +326,7 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -288,32 +337,116 @@ describe('DiceRoller', () => {
})
// ============================================================================
// Card Instructions Tests
// Chaos d20 Conditional Display Tests
// ============================================================================
describe('Card Instructions', () => {
it('shows card reading instructions when roll exists', () => {
const rollData = createRollData()
describe('Chaos d20 Conditional Display', () => {
/**
* The chaos d20 dice is only displayed when it triggers a Wild Pitch (1)
* or Passed Ball (2) check. Values 3-20 have no game effect and showing
* them creates visual noise. When bases are empty, the chaos check is
* skipped entirely since WP/PB is meaningless without runners.
*/
it('hides chaos d20 when no check triggered (values 3-20)', () => {
const rollData = createRollData({
chaos_d20: 15,
check_wild_pitch: false,
check_passed_ball: false,
chaos_check_skipped: false, // Runners on base, but roll was 3-20
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.card-instructions').exists()).toBe(true)
expect(wrapper.text()).toContain('Use these dice results')
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(false)
})
it('does not show instructions when no roll', () => {
it('hides chaos d20 when chaos check was skipped (bases empty)', () => {
const rollData = createRollData({
chaos_d20: 1, // Would trigger WP but bases empty
check_wild_pitch: false, // Skipped due to no runners
check_passed_ball: false,
chaos_check_skipped: true, // No runners on base
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.card-instructions').exists()).toBe(false)
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(false)
})
it('shows chaos d20 when wild pitch check triggered', () => {
const rollData = createRollData({
chaos_d20: 1,
check_wild_pitch: true,
check_passed_ball: false,
chaos_check_skipped: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(true)
})
it('shows chaos d20 when passed ball check triggered', () => {
const rollData = createRollData({
chaos_d20: 2,
check_wild_pitch: false,
check_passed_ball: true,
chaos_check_skipped: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(true)
})
it('displays correct chaos d20 value when shown', () => {
const rollData = createRollData({
chaos_d20: 1,
check_wild_pitch: true,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Find the chaos d20 component (3rd one when WP/PB triggered)
const chaosDie = diceComponents[2]
expect(chaosDie.props('value')).toBe(1)
})
})
@ -329,6 +462,7 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
@ -340,50 +474,165 @@ describe('DiceRoller', () => {
})
// ============================================================================
// Dice Type Styling Tests
// Dice Color Tests
// ============================================================================
describe('Dice Type Styling', () => {
it('applies d6 styling to d6 dice', () => {
describe('Dice Color', () => {
it('passes dice color to d6 DiceShapes components', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
diceColor: '0066ff', // Blue
},
})
const diceItems = wrapper.findAll('.dice-item')
expect(diceItems[0].classes()).toContain('dice-d6') // d6 one
expect(diceItems[1].classes()).toContain('dice-d6') // d6 two
const diceComponents = wrapper.findAllComponents(DiceShapes)
// First two are d6 dice
expect(diceComponents[0].props('color')).toBe('0066ff')
expect(diceComponents[1].props('color')).toBe('0066ff')
})
it('applies d20 styling to d20 dice', () => {
it('uses white for resolution d20', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceItems = wrapper.findAll('.dice-item')
expect(diceItems[2].classes()).toContain('dice-d20') // chaos d20
expect(diceItems[3].classes()).toContain('dice-d20') // resolution d20
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Last one is resolution d20 (when no chaos shown)
const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('color')).toBe('ffffff')
})
it('applies large value class to d20 dice', () => {
const rollData = createRollData()
it('uses amber for chaos d20 when shown', () => {
const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceValues = wrapper.findAll('.dice-value')
expect(diceValues[2].classes()).toContain('dice-value-large')
expect(diceValues[3].classes()).toContain('dice-value-large')
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Third one is chaos d20 when WP triggered
const chaosD20 = diceComponents[2]
expect(chaosD20.props('color')).toBe('f59e0b')
})
it('uses default red when no diceColor prop provided', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
// No diceColor prop
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('color')).toBe('cc0000')
})
})
// ============================================================================
// Dice Type Tests
// ============================================================================
describe('Dice Types', () => {
it('renders d6 type for first two dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('type')).toBe('d6')
expect(diceComponents[1].props('type')).toBe('d6')
})
it('renders d20 type for resolution die', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Last one is resolution d20
const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('type')).toBe('d20')
})
it('renders d20 type for chaos die when shown', () => {
const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Third one is chaos d20
expect(diceComponents[2].props('type')).toBe('d20')
})
})
// ============================================================================
// Layout Tests
// ============================================================================
describe('Layout', () => {
it('applies compact display class when chaos d20 is hidden', () => {
const rollData = createRollData() // Default: no WP/PB check
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).toContain('dice-display-compact')
})
it('does not apply compact display class when chaos d20 is shown', () => {
const rollData = createRollData({ check_wild_pitch: true, chaos_d20: 1 })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).not.toContain('dice-display-compact')
})
})
@ -404,14 +653,15 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('6')
expect(wrapper.text()).toContain('12')
expect(wrapper.text()).toContain('20')
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('value')).toBe(6)
expect(diceComponents[1].props('value')).toBe(12)
})
it('handles minimum dice values', () => {
@ -422,25 +672,27 @@ describe('DiceRoller', () => {
d6_two_total: 2,
chaos_d20: 1,
resolution_d20: 1,
check_wild_pitch: true, // Show chaos d20
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('1')
expect(wrapper.text()).toContain('2')
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('value')).toBe(1)
expect(diceComponents[1].props('value')).toBe(2)
expect(diceComponents[2].props('value')).toBe(1) // chaos d20
expect(diceComponents[3].props('value')).toBe(1) // resolution d20
})
it('transitions from no roll to roll result', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
props: defaultProps,
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
@ -455,6 +707,7 @@ describe('DiceRoller', () => {
it('clears roll result when pendingRoll set to null', async () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: createRollData(),
},

View File

@ -0,0 +1,461 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
describe('DiceShapes', () => {
const defaultD6Props = {
type: 'd6' as const,
value: 5,
}
const defaultD20Props = {
type: 'd20' as const,
value: 15,
}
// ============================================================================
// D6 Rendering Tests
// ============================================================================
describe('D6 Shape Rendering', () => {
it('renders d6 die container when type is d6', () => {
/**
* The d6 die should render a square-shaped die with rounded corners,
* decorative corner dots suggesting pip positions, and the value centered.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
expect(wrapper.find('.die-container').exists()).toBe(true)
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
})
it('renders rect element for d6 die body', () => {
/**
* D6 dice use a rounded rectangle (rect with rx/ry) to create the
* classic square die shape with softened corners.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const rect = wrapper.find('rect')
expect(rect.exists()).toBe(true)
expect(rect.attributes('rx')).toBe('10')
expect(rect.attributes('ry')).toBe('10')
})
it('renders four corner dots for d6 decoration', () => {
/**
* Four decorative dots in the corners suggest the pip positions
* of a physical die, adding visual authenticity.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const circles = wrapper.findAll('circle')
expect(circles).toHaveLength(4)
})
it('displays the value in the center', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: 3 },
})
expect(wrapper.find('.die-value').text()).toBe('3')
})
it('displays label when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, label: 'd6 (One)' },
})
expect(wrapper.find('.die-label').exists()).toBe(true)
expect(wrapper.find('.die-label').text()).toBe('d6 (One)')
})
it('displays sublabel when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, sublabel: '(3 + 2)' },
})
expect(wrapper.find('.die-sublabel').exists()).toBe(true)
expect(wrapper.find('.die-sublabel').text()).toBe('(3 + 2)')
})
it('hides label when not provided', () => {
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
expect(wrapper.find('.die-label').exists()).toBe(false)
})
})
// ============================================================================
// D20 Rendering Tests
// ============================================================================
describe('D20 Shape Rendering', () => {
it('renders d20 die container when type is d20', () => {
/**
* The d20 die renders a hexagonal shape inspired by the icosahedron
* geometry of a real d20, with facet lines for 3D effect.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
expect(wrapper.find('.die-container').exists()).toBe(true)
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
})
it('renders polygon element for d20 hexagonal shape', () => {
/**
* D20 uses a 6-sided polygon (hexagon) rotated with flat top
* to suggest the multi-faceted nature of an icosahedron.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const polygon = wrapper.find('polygon')
expect(polygon.exists()).toBe(true)
// Verify points attribute contains 6 coordinate pairs
const points = polygon.attributes('points')
expect(points).toBeDefined()
const pointPairs = points!.split(' ')
expect(pointPairs).toHaveLength(6)
})
it('renders facet lines for 3D effect', () => {
/**
* Two vertical lines inside the hexagon create the illusion
* of depth and the faceted surface of a d20.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const lines = wrapper.findAll('line')
expect(lines).toHaveLength(2)
})
it('displays the value with large styling', () => {
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.text()).toBe('15')
expect(valueEl.classes()).toContain('die-value-large')
})
it('displays label when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD20Props, label: 'Resolution' },
})
expect(wrapper.find('.die-label').text()).toBe('Resolution')
})
})
// ============================================================================
// Color Calculation Tests
// ============================================================================
describe('Color Calculations', () => {
it('applies fill color from color prop', () => {
/**
* The color prop (hex without #) should be converted to a proper
* CSS color value and applied to the die fill.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0066ff' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#0066ff')
})
it('uses default red color when no color prop provided', () => {
/**
* Default dice color is cc0000 (red) matching the traditional
* Strat-O-Matic dice color.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#cc0000')
})
it('calculates darker stroke color from fill', () => {
/**
* The stroke color should be a darkened version of the fill
* to create depth and definition around the die edge.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffffff' },
})
const rect = wrapper.find('rect')
const stroke = rect.attributes('stroke')
// White (ffffff) darkened by 20% should be #cccccc
expect(stroke).toBe('#cccccc')
})
it('uses white text on dark backgrounds', () => {
/**
* When the die color is dark (low luminance), the value text
* should be white for readability.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '000000' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('uses dark text on light backgrounds', () => {
/**
* When the die color is light (high luminance), the value text
* should be dark (#1a1a1a) for readability.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffffff' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
it('uses white corner dots on dark d6 backgrounds', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '000066' },
})
const circles = wrapper.findAll('circle')
expect(circles[0].attributes('fill')).toBe('#ffffff')
})
it('uses dark corner dots on light d6 backgrounds', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffff00' },
})
const circles = wrapper.findAll('circle')
expect(circles[0].attributes('fill')).toBe('#333333')
})
})
// ============================================================================
// Size Prop Tests
// ============================================================================
describe('Size Configuration', () => {
it('applies default size of 100px', () => {
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const container = wrapper.find('.die-container')
expect(container.attributes('style')).toContain('width: 100px')
expect(container.attributes('style')).toContain('height: 100px')
})
it('applies custom size from prop', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 80 },
})
const container = wrapper.find('.die-container')
expect(container.attributes('style')).toContain('width: 80px')
expect(container.attributes('style')).toContain('height: 80px')
})
it('scales SVG viewBox to match size', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 120 },
})
const svg = wrapper.find('svg')
expect(svg.attributes('viewBox')).toBe('0 0 120 120')
})
it('scales d6 rect dimensions proportionally', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 80 },
})
const rect = wrapper.find('rect')
// width and height should be size - 8
expect(rect.attributes('width')).toBe('72')
expect(rect.attributes('height')).toBe('72')
})
})
// ============================================================================
// Hexagon Point Calculation Tests
// ============================================================================
describe('Hexagon Point Calculation', () => {
it('generates valid hexagon points for d20', () => {
/**
* The hexagon points should form a valid 6-sided polygon
* with coordinates that create a symmetrical shape.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 100 },
})
const polygon = wrapper.find('polygon')
const points = polygon.attributes('points')!
const pointPairs = points.split(' ')
// Each pair should be valid x,y coordinates
pointPairs.forEach(pair => {
const [x, y] = pair.split(',').map(Number)
expect(x).toBeGreaterThanOrEqual(0)
expect(x).toBeLessThanOrEqual(100)
expect(y).toBeGreaterThanOrEqual(0)
expect(y).toBeLessThanOrEqual(100)
})
})
it('scales hexagon points with size prop', () => {
const smallWrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 50 },
})
const largeWrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 100 },
})
const smallPoints = smallWrapper.find('polygon').attributes('points')!
const largePoints = largeWrapper.find('polygon').attributes('points')!
// First point of small should be roughly half the large
const smallFirst = smallPoints.split(' ')[0].split(',').map(Number)
const largeFirst = largePoints.split(' ')[0].split(',').map(Number)
expect(smallFirst[0]).toBeCloseTo(largeFirst[0] / 2, 0)
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('handles string value prop', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: '20' },
})
expect(wrapper.find('.die-value').text()).toBe('20')
})
it('handles very dark colors (near black)', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0a0a0a' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles very light colors (near white)', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'f5f5f5' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
it('handles mid-luminance colors correctly', () => {
/**
* Colors near the 50% luminance threshold should still pick
* appropriate contrast. 808080 (gray) has exactly 50% luminance.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '808080' },
})
// Gray is exactly at threshold, should use white text
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toBeDefined()
})
it('handles team colors correctly - red team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'cc0000' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#cc0000')
// Text should be white on red
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles team colors correctly - blue team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0066cc' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#0066cc')
// Text should be white on blue
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles team colors correctly - yellow team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffcc00' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#ffcc00')
// Text should be dark on yellow
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
})
// ============================================================================
// Slot Content Tests
// ============================================================================
describe('Slot Content', () => {
it('renders slot content instead of value when provided', () => {
/**
* The die-value slot allows custom content to be rendered
* instead of the raw number value.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
slots: {
default: '<span class="custom-value">★</span>',
},
})
expect(wrapper.find('.custom-value').exists()).toBe(true)
expect(wrapper.find('.custom-value').text()).toBe('★')
})
it('falls back to value prop when no slot content', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: 6 },
})
expect(wrapper.find('.die-value').text()).toBe('6')
})
})
})

View File

@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
import type { RollData, PlayResult } from '~/types'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import ManualOutcomeEntry from '~/components/Gameplay/ManualOutcomeEntry.vue'
import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue'
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
describe('GameplayPanel', () => {
@ -201,7 +201,7 @@ describe('GameplayPanel', () => {
expect(diceRoller.props('canRoll')).toBe(false)
})
it('renders ManualOutcomeEntry component', () => {
it('renders OutcomeWizard component', () => {
const wrapper = mount(GameplayPanel, {
props: {
...defaultProps,
@ -210,7 +210,7 @@ describe('GameplayPanel', () => {
},
})
expect(wrapper.findComponent(ManualOutcomeEntry).exists()).toBe(true)
expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true)
})
it('displays active status when outcome entry active', () => {
@ -315,9 +315,9 @@ describe('GameplayPanel', () => {
},
})
const outcomeEntry = wrapper.findComponent(ManualOutcomeEntry)
const outcomeWizard = wrapper.findComponent(OutcomeWizard)
const payload = { outcome: 'STRIKEOUT' as const, hitLocation: undefined }
await outcomeEntry.vm.$emit('submit', payload)
await outcomeWizard.vm.$emit('submit', payload)
expect(wrapper.emitted('submitOutcome')).toBeTruthy()
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])

View File

@ -95,6 +95,7 @@ export interface SbaTeam {
sname: string // Short name (e.g., "Geese")
lname: string // Long name (e.g., "Everett Geese")
color: string // Hex color code
thumbnail: string | null // Team logo URL
manager_legacy: string
gmid: string | null
gmid2: string | null

View File

@ -72,6 +72,18 @@ export interface GameState {
home_team_is_ai: boolean
away_team_is_ai: boolean
// Team display info (from game_metadata, stored at creation time)
home_team_name?: string | null // Full name: "Chicago Cyclones"
home_team_abbrev?: string | null // Abbreviation: "CHC"
home_team_color?: string | null // Hex color without #: "ff5349"
home_team_dice_color?: string | null // Dice color hex without #, default "cc0000"
home_team_thumbnail?: string | null // Team logo URL
away_team_name?: string | null
away_team_abbrev?: string | null
away_team_color?: string | null
away_team_dice_color?: string | null // Dice color hex without #, default "cc0000"
away_team_thumbnail?: string | null
// Creator (for demo/testing - creator can control home team)
creator_discord_id: string | null
@ -158,6 +170,7 @@ export interface RollData {
resolution_d20: number
check_wild_pitch: boolean
check_passed_ball: boolean
chaos_check_skipped: boolean // True when no runners on base (WP/PB irrelevant)
timestamp: string
}

View File

@ -37,6 +37,7 @@ export type {
SbaPlayer,
Lineup,
TeamLineup,
BenchPlayer,
LineupDataResponse,
SubstitutionType,
SubstitutionRequest,

View File

@ -83,6 +83,33 @@ export interface TeamLineup {
players: Lineup[]
}
/**
* Bench player from RosterLink (for substitutions)
* Backend: SbaRosterLinkData with computed is_pitcher/is_batter
*
* This is distinct from Lineup - it represents roster players
* not currently in the active lineup.
*/
export interface BenchPlayer {
roster_id: number
player_id: number
player_positions: string[] // Natural positions (e.g., ["SS", "2B"])
is_pitcher: boolean // True if player has pitching positions
is_batter: boolean // True if player has batting positions
// Player data (from SBA API)
player: {
id: number
name: string
image: string
headshot: string
// Legacy position fields for backwards compatibility
pos_1: string | null
pos_2: string | null
pos_3: string | null
}
}
/**
* Lineup data response from server
*/