Compare commits
10 Commits
4cb8e3f6c4
...
701098881a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701098881a | ||
|
|
31139c5d4d | ||
|
|
2b8fea36a8 | ||
|
|
be31e2ccb4 | ||
|
|
52706bed40 | ||
|
|
e058bc4a6c | ||
|
|
64325d7163 | ||
|
|
d60b7a2d60 | ||
|
|
ff3f1746d6 | ||
|
|
3a91a5d477 |
14
CLAUDE.md
14
CLAUDE.md
@ -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
|
||||
|
||||
|
||||
@ -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')
|
||||
@ -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")
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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.)"""
|
||||
|
||||
@ -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
|
||||
|
||||
164
frontend-sba/.claude/MOBILE_TEXT_SELECTION_REVIEW.md
Normal file
164
frontend-sba/.claude/MOBILE_TEXT_SELECTION_REVIEW.md
Normal 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
|
||||
156
frontend-sba/.claude/MOBILE_TOUCH_PATTERNS.md
Normal file
156
frontend-sba/.claude/MOBILE_TOUCH_PATTERNS.md
Normal 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
|
||||
247
frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md
Normal file
247
frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md
Normal 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
|
||||
@ -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
@ -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)"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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% {
|
||||
|
||||
@ -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>
|
||||
|
||||
177
frontend-sba/components/Gameplay/DiceShapes.vue
Normal file
177
frontend-sba/components/Gameplay/DiceShapes.vue
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
504
frontend-sba/components/Gameplay/OutcomeWizard.vue
Normal file
504
frontend-sba/components/Gameplay/OutcomeWizard.vue
Normal 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>
|
||||
344
frontend-sba/components/Lineup/InlineSubstitutionPanel.vue
Normal file
344
frontend-sba/components/Lineup/InlineSubstitutionPanel.vue
Normal 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>
|
||||
205
frontend-sba/components/Lineup/LineupSlotRow.vue
Normal file
205
frontend-sba/components/Lineup/LineupSlotRow.vue
Normal 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>
|
||||
93
frontend-sba/components/Lineup/PositionSelector.vue
Normal file
93
frontend-sba/components/Lineup/PositionSelector.vue
Normal 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>
|
||||
426
frontend-sba/components/Lineup/UnifiedLineupTab.vue
Normal file
426
frontend-sba/components/Lineup/UnifiedLineupTab.vue
Normal 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>
|
||||
358
frontend-sba/components/Player/PlayerCardModal.vue
Normal file
358
frontend-sba/components/Player/PlayerCardModal.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
311
frontend-sba/components/UI/BottomSheet.vue
Normal file
311
frontend-sba/components/UI/BottomSheet.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
220
frontend-sba/constants/outcomeFlow.ts
Normal file
220
frontend-sba/constants/outcomeFlow.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
21
frontend-sba/package-lock.json
generated
21
frontend-sba/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
|
||||
461
frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts
Normal file
461
frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ export type {
|
||||
SbaPlayer,
|
||||
Lineup,
|
||||
TeamLineup,
|
||||
BenchPlayer,
|
||||
LineupDataResponse,
|
||||
SubstitutionType,
|
||||
SubstitutionRequest,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user