Merge pull request #7 from calcorum/feature/dice-display-logic
Redesign dice display with team colors and consolidate player cards
This commit is contained in:
commit
31139c5d4d
@ -56,6 +56,7 @@ class DiceSystem:
|
|||||||
game_id: UUID | None = None,
|
game_id: UUID | None = None,
|
||||||
team_id: int | None = None,
|
team_id: int | None = None,
|
||||||
player_id: int | None = None,
|
player_id: int | None = None,
|
||||||
|
runners_on_base: bool = True,
|
||||||
) -> AbRoll:
|
) -> AbRoll:
|
||||||
"""
|
"""
|
||||||
Roll at-bat dice: 1d6 + 2d6 + 2d20
|
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 == 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)
|
- 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:
|
Args:
|
||||||
league_id: 'sba' or 'pd'
|
league_id: 'sba' or 'pd'
|
||||||
game_id: Optional UUID of game in progress
|
game_id: Optional UUID of game in progress
|
||||||
team_id: Optional team ID for auditing
|
team_id: Optional team ID for auditing
|
||||||
player_id: Optional player/card ID for auditing (polymorphic)
|
player_id: Optional player/card ID for auditing (polymorphic)
|
||||||
|
runners_on_base: Whether there are runners on base (affects chaos check)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AbRoll with all dice results
|
AbRoll with all dice results
|
||||||
@ -80,6 +84,9 @@ class DiceSystem:
|
|||||||
chaos_d20 = self._roll_d20()
|
chaos_d20 = self._roll_d20()
|
||||||
resolution_d20 = self._roll_d20() # Always roll, used for WP/PB or splits
|
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 = AbRoll(
|
||||||
roll_id=self._generate_roll_id(),
|
roll_id=self._generate_roll_id(),
|
||||||
roll_type=RollType.AB,
|
roll_type=RollType.AB,
|
||||||
@ -94,8 +101,9 @@ class DiceSystem:
|
|||||||
chaos_d20=chaos_d20,
|
chaos_d20=chaos_d20,
|
||||||
resolution_d20=resolution_d20,
|
resolution_d20=resolution_d20,
|
||||||
d6_two_total=0, # Calculated in __post_init__
|
d6_two_total=0, # Calculated in __post_init__
|
||||||
check_wild_pitch=False,
|
check_wild_pitch=False, # Calculated in __post_init__
|
||||||
check_passed_ball=False,
|
check_passed_ball=False, # Calculated in __post_init__
|
||||||
|
chaos_check_skipped=chaos_check_skipped,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._roll_history.append(roll)
|
self._roll_history.append(roll)
|
||||||
|
|||||||
@ -679,8 +679,15 @@ class GameEngine:
|
|||||||
state_manager=state_manager,
|
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
|
# 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
|
# Use forced outcome if provided (for testing), otherwise need to implement chart lookup
|
||||||
if forced_outcome is None:
|
if forced_outcome is None:
|
||||||
|
|||||||
@ -198,8 +198,15 @@ class PlayResolver:
|
|||||||
|
|
||||||
logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}")
|
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
|
# 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
|
# Generate outcome from ratings
|
||||||
outcome, hit_location = self.result_chart.get_outcome( # type: ignore
|
outcome, hit_location = self.result_chart.get_outcome( # type: ignore
|
||||||
|
|||||||
@ -84,9 +84,18 @@ class AbRoll(DiceRoll):
|
|||||||
default=False
|
default=False
|
||||||
) # chaos_d20 == 2 (still needs resolution_d20 to confirm)
|
) # 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):
|
def __post_init__(self):
|
||||||
"""Calculate derived values"""
|
"""Calculate derived values"""
|
||||||
self.d6_two_total = self.d6_two_a + self.d6_two_b
|
self.d6_two_total = self.d6_two_a + self.d6_two_b
|
||||||
|
|
||||||
|
# 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_wild_pitch = self.chaos_d20 == 1
|
||||||
self.check_passed_ball = self.chaos_d20 == 2
|
self.check_passed_ball = self.chaos_d20 == 2
|
||||||
|
|
||||||
@ -102,6 +111,7 @@ class AbRoll(DiceRoll):
|
|||||||
"resolution_d20": self.resolution_d20,
|
"resolution_d20": self.resolution_d20,
|
||||||
"check_wild_pitch": self.check_wild_pitch,
|
"check_wild_pitch": self.check_wild_pitch,
|
||||||
"check_passed_ball": self.check_passed_ball,
|
"check_passed_ball": self.check_passed_ball,
|
||||||
|
"chaos_check_skipped": self.chaos_check_skipped,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return base
|
return base
|
||||||
|
|||||||
@ -147,10 +147,12 @@ class StateManager:
|
|||||||
home_team_name=home_meta.get("lname"),
|
home_team_name=home_meta.get("lname"),
|
||||||
home_team_abbrev=home_meta.get("abbrev"),
|
home_team_abbrev=home_meta.get("abbrev"),
|
||||||
home_team_color=home_meta.get("color"),
|
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"),
|
home_team_thumbnail=home_meta.get("thumbnail"),
|
||||||
away_team_name=away_meta.get("lname"),
|
away_team_name=away_meta.get("lname"),
|
||||||
away_team_abbrev=away_meta.get("abbrev"),
|
away_team_abbrev=away_meta.get("abbrev"),
|
||||||
away_team_color=away_meta.get("color"),
|
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"),
|
away_team_thumbnail=away_meta.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -414,10 +416,12 @@ class StateManager:
|
|||||||
home_team_name=home_meta.get("lname"),
|
home_team_name=home_meta.get("lname"),
|
||||||
home_team_abbrev=home_meta.get("abbrev"),
|
home_team_abbrev=home_meta.get("abbrev"),
|
||||||
home_team_color=home_meta.get("color"),
|
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"),
|
home_team_thumbnail=home_meta.get("thumbnail"),
|
||||||
away_team_name=away_meta.get("lname"),
|
away_team_name=away_meta.get("lname"),
|
||||||
away_team_abbrev=away_meta.get("abbrev"),
|
away_team_abbrev=away_meta.get("abbrev"),
|
||||||
away_team_color=away_meta.get("color"),
|
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"),
|
away_team_thumbnail=away_meta.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -400,10 +400,12 @@ class GameState(BaseModel):
|
|||||||
home_team_name: str | None = None # e.g., "Chicago Cyclones"
|
home_team_name: str | None = None # e.g., "Chicago Cyclones"
|
||||||
home_team_abbrev: str | None = None # e.g., "CHC"
|
home_team_abbrev: str | None = None # e.g., "CHC"
|
||||||
home_team_color: str | None = None # e.g., "ff5349" (no # prefix)
|
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
|
home_team_thumbnail: str | None = None # Team logo URL
|
||||||
away_team_name: str | None = None
|
away_team_name: str | None = None
|
||||||
away_team_abbrev: str | None = None
|
away_team_abbrev: str | None = None
|
||||||
away_team_color: 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
|
away_team_thumbnail: str | None = None
|
||||||
|
|
||||||
# Creator (for demo/testing - creator can control home team)
|
# Creator (for demo/testing - creator can control home team)
|
||||||
|
|||||||
@ -335,13 +335,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
|||||||
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
|
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
|
||||||
# return
|
# 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
|
# 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(
|
logger.info(
|
||||||
f"Dice rolled for game {game_id}: "
|
f"Dice rolled for game {game_id}: "
|
||||||
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
|
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
|
# 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,
|
"resolution_d20": ab_roll.resolution_d20,
|
||||||
"check_wild_pitch": ab_roll.check_wild_pitch,
|
"check_wild_pitch": ab_roll.check_wild_pitch,
|
||||||
"check_passed_ball": ab_roll.check_passed_ball,
|
"check_passed_ball": ab_roll.check_passed_ball,
|
||||||
|
"chaos_check_skipped": ab_roll.chaos_check_skipped,
|
||||||
"timestamp": ab_roll.timestamp.to_iso8601_string(),
|
"timestamp": ab_roll.timestamp.to_iso8601_string(),
|
||||||
"message": "Dice rolled - read your card and submit outcome",
|
"message": "Dice rolled - read your card and submit outcome",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -68,7 +68,8 @@ def mock_ab_roll():
|
|||||||
resolution_d20=12,
|
resolution_d20=12,
|
||||||
d6_two_total=7,
|
d6_two_total=7,
|
||||||
check_wild_pitch=False,
|
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
|
# Call handler
|
||||||
await roll_dice_handler('test_sid', {"game_id": str(mock_game_state.game_id)})
|
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(
|
mock_dice.roll_ab.assert_called_once_with(
|
||||||
league_id="sba",
|
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
|
# Verify state updated with roll
|
||||||
|
|||||||
@ -1,125 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="current-situation">
|
<div class="current-situation">
|
||||||
<!-- Mobile Layout (Stacked) -->
|
<!-- Side-by-Side Card Layout -->
|
||||||
<div class="lg:hidden space-y-3">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<!-- Current Pitcher Card -->
|
<!-- Current Pitcher Card -->
|
||||||
<button
|
<button
|
||||||
v-if="currentPitcher"
|
v-if="currentPitcher"
|
||||||
class="w-full text-left 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 hover:shadow-lg transition-shadow cursor-pointer"
|
:class="[
|
||||||
|
'player-card pitcher-card card-transition',
|
||||||
|
pitcherCardClasses
|
||||||
|
]"
|
||||||
@click="openPlayerCard('pitcher')"
|
@click="openPlayerCard('pitcher')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<!-- Card Header -->
|
||||||
<!-- Pitcher Image/Badge -->
|
<div class="card-header pitcher-header">
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-1">
|
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
|
||||||
|
<span class="position-info">P</span>
|
||||||
|
<span class="player-name">{{ pitcherName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Image -->
|
||||||
|
<div class="card-image-container">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
v-if="pitcherPlayer?.image"
|
||||||
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
:src="pitcherPlayer.image"
|
||||||
:alt="pitcherName"
|
:alt="`${pitcherName} card`"
|
||||||
class="w-full h-full object-cover"
|
class="card-image"
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
@error="handleImageError"
|
||||||
>
|
>
|
||||||
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-lg">
|
<div v-else class="card-placeholder pitcher-placeholder">
|
||||||
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
|
||||||
</div>
|
<span class="placeholder-label">No Card Image</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 }}
|
|
||||||
<span class="ml-2 text-blue-500">Tap to view card</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Current Batter Card -->
|
|
||||||
<button
|
|
||||||
v-if="currentBatter"
|
|
||||||
class="w-full text-left 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 hover:shadow-lg transition-shadow cursor-pointer"
|
|
||||||
@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 ring-2 ring-red-300 ring-offset-1">
|
|
||||||
<img
|
|
||||||
v-if="getPlayerPreviewImage(batterPlayer)"
|
|
||||||
:src="getPlayerPreviewImage(batterPlayer)!"
|
|
||||||
:alt="batterName"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
||||||
>
|
|
||||||
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-lg">
|
|
||||||
{{ getPlayerFallbackInitial(batterPlayer) }}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<span class="ml-2 text-red-500">Tap to view card</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop Layout (Side-by-Side) -->
|
|
||||||
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
|
|
||||||
<!-- Current Pitcher Card -->
|
|
||||||
<button
|
|
||||||
v-if="currentPitcher"
|
|
||||||
class="text-left 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 hover:shadow-xl transition-shadow cursor-pointer"
|
|
||||||
@click="openPlayerCard('pitcher')"
|
|
||||||
>
|
|
||||||
<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 ring-2 ring-blue-300 ring-offset-2">
|
|
||||||
<img
|
|
||||||
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
|
||||||
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
|
||||||
:alt="pitcherName"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
||||||
>
|
|
||||||
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl">
|
|
||||||
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
|
||||||
</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 }}
|
|
||||||
<span class="ml-2 text-blue-500">Click to view card</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -127,37 +37,31 @@
|
|||||||
<!-- Current Batter Card -->
|
<!-- Current Batter Card -->
|
||||||
<button
|
<button
|
||||||
v-if="currentBatter"
|
v-if="currentBatter"
|
||||||
class="text-left 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 hover:shadow-xl transition-shadow cursor-pointer"
|
:class="[
|
||||||
|
'player-card batter-card card-transition',
|
||||||
|
batterCardClasses
|
||||||
|
]"
|
||||||
@click="openPlayerCard('batter')"
|
@click="openPlayerCard('batter')"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<!-- Card Header -->
|
||||||
<!-- Batter Image/Badge -->
|
<div class="card-header batter-header">
|
||||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-2">
|
<span class="team-abbrev">{{ batterTeamAbbrev }}</span>
|
||||||
<img
|
<span class="position-info">{{ currentBatter.batting_order }}. {{ currentBatter.position }}</span>
|
||||||
v-if="getPlayerPreviewImage(batterPlayer)"
|
<span class="player-name">{{ batterName }}</span>
|
||||||
:src="getPlayerPreviewImage(batterPlayer)!"
|
|
||||||
:alt="batterName"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
||||||
>
|
|
||||||
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-2xl">
|
|
||||||
{{ getPlayerFallbackInitial(batterPlayer) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batter Details -->
|
<!-- Card Image -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="card-image-container">
|
||||||
<div class="text-sm font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-1">
|
<img
|
||||||
At Bat
|
v-if="batterPlayer?.image"
|
||||||
</div>
|
:src="batterPlayer.image"
|
||||||
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
|
:alt="`${batterName} card`"
|
||||||
{{ batterName }}
|
class="card-image"
|
||||||
</div>
|
@error="handleImageError"
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
>
|
||||||
{{ currentBatter.position }}
|
<div v-else class="card-placeholder batter-placeholder">
|
||||||
<span v-if="currentBatter.batting_order" class="ml-2">• Batting {{ currentBatter.batting_order }}</span>
|
<span class="placeholder-initials">{{ getPlayerFallbackInitial(batterPlayer) }}</span>
|
||||||
<span class="ml-2 text-red-500">Click to view card</span>
|
<span class="placeholder-label">No Card Image</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -198,11 +102,17 @@ import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
|||||||
interface Props {
|
interface Props {
|
||||||
currentBatter?: LineupPlayerState | null
|
currentBatter?: LineupPlayerState | null
|
||||||
currentPitcher?: 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>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
currentBatter: null,
|
currentBatter: null,
|
||||||
currentPitcher: null
|
currentPitcher: null,
|
||||||
|
activeCard: null,
|
||||||
|
batterTeamAbbrev: '',
|
||||||
|
pitcherTeamAbbrev: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Debug: Watch for prop changes
|
// Debug: Watch for prop changes
|
||||||
@ -245,12 +155,6 @@ const pitcherName = computed(() => {
|
|||||||
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get player preview image with fallback priority: headshot > vanity_card > null
|
|
||||||
function getPlayerPreviewImage(player: { headshot?: string | null; vanity_card?: string | null } | null): string | null {
|
|
||||||
if (!player) return null
|
|
||||||
return player.headshot || player.vanity_card || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
|
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
|
||||||
// Ignores common suffixes like Jr, Sr, II, III, IV
|
// Ignores common suffixes like Jr, Sr, II, III, IV
|
||||||
function getPlayerFallbackInitial(player: { name: string } | null): string {
|
function getPlayerFallbackInitial(player: { name: string } | null): string {
|
||||||
@ -264,6 +168,17 @@ function getPlayerFallbackInitial(player: { name: string } | null): string {
|
|||||||
return (parts[0].charAt(0) + parts[parts.length - 1].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
|
// Player card modal state
|
||||||
const isPlayerCardOpen = ref(false)
|
const isPlayerCardOpen = ref(false)
|
||||||
const selectedPlayerData = ref<{
|
const selectedPlayerData = ref<{
|
||||||
@ -275,6 +190,23 @@ const selectedPlayerData = ref<{
|
|||||||
const selectedPlayerPosition = ref('')
|
const selectedPlayerPosition = ref('')
|
||||||
const selectedPlayerTeam = 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
|
// Open player card modal for batter or pitcher
|
||||||
function openPlayerCard(type: 'batter' | 'pitcher') {
|
function openPlayerCard(type: 'batter' | 'pitcher') {
|
||||||
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
||||||
@ -289,7 +221,7 @@ function openPlayerCard(type: 'batter' | 'pitcher') {
|
|||||||
headshot: player.headshot || undefined
|
headshot: player.headshot || undefined
|
||||||
}
|
}
|
||||||
selectedPlayerPosition.value = state?.position || ''
|
selectedPlayerPosition.value = state?.position || ''
|
||||||
selectedPlayerTeam.value = '' // Could be enhanced to show team name
|
selectedPlayerTeam.value = type === 'batter' ? props.batterTeamAbbrev : props.pitcherTeamAbbrev
|
||||||
isPlayerCardOpen.value = true
|
isPlayerCardOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,19 +232,134 @@ function closePlayerCard() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Optional: Add subtle animations */
|
/* Card Container */
|
||||||
.current-situation > div {
|
.player-card {
|
||||||
animation: fadeIn 0.3s ease-in;
|
@apply rounded-xl overflow-hidden cursor-pointer;
|
||||||
|
@apply border-2 shadow-lg;
|
||||||
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
.pitcher-card {
|
||||||
from {
|
@apply bg-gradient-to-b from-blue-900 to-blue-950 border-blue-600;
|
||||||
opacity: 0;
|
}
|
||||||
transform: translateY(10px);
|
|
||||||
|
.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 {
|
50% {
|
||||||
opacity: 1;
|
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||||
transform: translateY(0);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
</style>
|
||||||
|
|||||||
@ -240,41 +240,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="info-card info-card-batter"
|
|
||||||
>
|
|
||||||
<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="info-card-label info-card-label-batter">AT BAT</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-card-name">{{ getBatterName }}</div>
|
|
||||||
<div class="info-card-detail">{{ currentBatter.position }} • #{{ currentBatter.batting_order }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Pitcher Card -->
|
|
||||||
<div
|
|
||||||
v-if="currentPitcher"
|
|
||||||
class="info-card info-card-pitcher"
|
|
||||||
>
|
|
||||||
<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="info-card-label info-card-label-pitcher">PITCHING</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-card-name">{{ getPitcherName }}</div>
|
|
||||||
<div class="info-card-detail">{{ currentPitcher.position }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player Card Modal -->
|
<!-- Player Card Modal -->
|
||||||
<PlayerCardModal
|
<PlayerCardModal
|
||||||
:is-open="isPlayerCardOpen"
|
:is-open="isPlayerCardOpen"
|
||||||
@ -445,63 +410,4 @@ function getFielderInfo(position: string): { initials: string; name: string; exi
|
|||||||
@apply text-[9px] font-bold leading-none;
|
@apply text-[9px] font-bold leading-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile info cards */
|
|
||||||
.info-card {
|
|
||||||
@apply rounded-lg p-3 border-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-batter {
|
|
||||||
@apply bg-red-50 border-red-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-pitcher {
|
|
||||||
@apply bg-blue-50 border-blue-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-label {
|
|
||||||
@apply text-xs font-semibold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-label-batter {
|
|
||||||
@apply text-red-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-label-pitcher {
|
|
||||||
@apply text-blue-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-name {
|
|
||||||
@apply text-sm font-bold text-gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-detail {
|
|
||||||
@apply text-xs text-gray-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode support */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.info-card-batter {
|
|
||||||
@apply bg-red-900/30 border-red-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-pitcher {
|
|
||||||
@apply bg-blue-900/30 border-blue-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-label-batter {
|
|
||||||
@apply text-red-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-label-pitcher {
|
|
||||||
@apply text-blue-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-name {
|
|
||||||
@apply text-gray-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-detail {
|
|
||||||
@apply text-gray-400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -62,12 +62,6 @@
|
|||||||
|
|
||||||
<!-- Mobile Layout (Stacked) -->
|
<!-- Mobile Layout (Stacked) -->
|
||||||
<div class="lg:hidden space-y-6">
|
<div class="lg:hidden space-y-6">
|
||||||
<!-- Current Situation -->
|
|
||||||
<CurrentSituation
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Game Board -->
|
<!-- Game Board -->
|
||||||
<GameBoard
|
<GameBoard
|
||||||
:runners="runnersState"
|
:runners="runnersState"
|
||||||
@ -76,6 +70,15 @@
|
|||||||
:fielding-lineup="fieldingLineup"
|
:fielding-lineup="fieldingLineup"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 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) -->
|
<!-- Decision Panel (Phase F3) -->
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
v-if="showDecisions"
|
v-if="showDecisions"
|
||||||
@ -104,8 +107,7 @@
|
|||||||
:can-submit-outcome="canSubmitOutcome"
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
:batter-player="batterPlayer"
|
:dice-color="diceColor"
|
||||||
:pitcher-player="pitcherPlayer"
|
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
@ -125,12 +127,6 @@
|
|||||||
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
||||||
<!-- Left Column: Game State -->
|
<!-- Left Column: Game State -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Current Situation -->
|
|
||||||
<CurrentSituation
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Game Board -->
|
<!-- Game Board -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
||||||
<GameBoard
|
<GameBoard
|
||||||
@ -141,6 +137,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) -->
|
<!-- Decision Panel (Phase F3) -->
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
v-if="showDecisions"
|
v-if="showDecisions"
|
||||||
@ -169,8 +174,7 @@
|
|||||||
:can-submit-outcome="canSubmitOutcome"
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
:outs="gameState?.outs ?? 0"
|
:outs="gameState?.outs ?? 0"
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
:batter-player="batterPlayer"
|
:dice-color="diceColor"
|
||||||
:pitcher-player="pitcherPlayer"
|
|
||||||
@roll-dice="handleRollDice"
|
@roll-dice="handleRollDice"
|
||||||
@submit-outcome="handleSubmitOutcome"
|
@submit-outcome="handleSubmitOutcome"
|
||||||
@dismiss-result="handleDismissResult"
|
@dismiss-result="handleDismissResult"
|
||||||
@ -382,19 +386,30 @@ const pendingRoll = computed(() => gameStore.pendingRoll)
|
|||||||
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
||||||
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
||||||
|
|
||||||
// Resolved player data for post-roll card display
|
// Home team's dice color for the dice display
|
||||||
const batterPlayer = computed(() => {
|
const diceColor = computed(() => gameState.value?.home_team_dice_color ?? 'cc0000')
|
||||||
const batterState = gameState.value?.current_batter
|
|
||||||
if (!batterState) return null
|
// Active card for highlighting based on dice roll (d6_one: 1-3 = batter, 4-6 = pitcher)
|
||||||
const lineup = gameStore.findPlayerInLineup(batterState.lineup_id)
|
const activeCard = computed<'batter' | 'pitcher' | null>(() => {
|
||||||
return lineup?.player ?? null
|
if (!pendingRoll.value) return null
|
||||||
|
return pendingRoll.value.d6_one <= 3 ? 'batter' : 'pitcher'
|
||||||
})
|
})
|
||||||
|
|
||||||
const pitcherPlayer = computed(() => {
|
// Team abbreviations for batter and pitcher cards
|
||||||
const pitcherState = gameState.value?.current_pitcher
|
// Top of inning: away bats, home fields
|
||||||
if (!pitcherState) return null
|
// Bottom of inning: home bats, away fields
|
||||||
const lineup = gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
const batterTeamAbbrev = computed(() => {
|
||||||
return lineup?.player ?? null
|
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
|
// Local UI state
|
||||||
|
|||||||
@ -35,34 +35,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Dice Grid -->
|
<!-- Dice Display Grid -->
|
||||||
<div class="dice-grid">
|
<div class="dice-display" :class="{ 'dice-display-compact': !showChaosD20 }">
|
||||||
<!-- d6 One -->
|
<!-- d6 One -->
|
||||||
<div class="dice-item dice-d6">
|
<DiceShapes
|
||||||
<div class="dice-label">d6 (One)</div>
|
type="d6"
|
||||||
<div class="dice-value">{{ pendingRoll.d6_one }}</div>
|
:value="pendingRoll.d6_one"
|
||||||
</div>
|
:color="effectiveDiceColor"
|
||||||
|
:size="dieSize"
|
||||||
|
label="d6 (One)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- d6 Two (showing total) -->
|
<!-- d6 Two (showing total) -->
|
||||||
<div class="dice-item dice-d6">
|
<DiceShapes
|
||||||
<div class="dice-label">d6 (Two)</div>
|
type="d6"
|
||||||
<div class="dice-value">{{ pendingRoll.d6_two_total }}</div>
|
:value="pendingRoll.d6_two_total"
|
||||||
<div class="dice-sublabel">
|
:color="effectiveDiceColor"
|
||||||
({{ pendingRoll.d6_two_a }} + {{ pendingRoll.d6_two_b }})
|
:size="dieSize"
|
||||||
</div>
|
label="d6 (Two)"
|
||||||
</div>
|
:sublabel="`(${pendingRoll.d6_two_a} + ${pendingRoll.d6_two_b})`"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Chaos d20 -->
|
<!-- Chaos d20 - only shown when WP/PB check triggered -->
|
||||||
<div class="dice-item dice-d20">
|
<DiceShapes
|
||||||
<div class="dice-label">Chaos d20</div>
|
v-if="showChaosD20"
|
||||||
<div class="dice-value dice-value-large">{{ pendingRoll.chaos_d20 }}</div>
|
type="d20"
|
||||||
</div>
|
:value="pendingRoll.chaos_d20"
|
||||||
|
color="f59e0b"
|
||||||
|
:size="dieSize"
|
||||||
|
label="Chaos d20"
|
||||||
|
class="dice-chaos"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Resolution d20 -->
|
<!-- Resolution d20 -->
|
||||||
<div class="dice-item dice-d20">
|
<DiceShapes
|
||||||
<div class="dice-label">Resolution d20</div>
|
type="d20"
|
||||||
<div class="dice-value dice-value-large">{{ pendingRoll.resolution_d20 }}</div>
|
:value="pendingRoll.resolution_d20"
|
||||||
</div>
|
color="ffffff"
|
||||||
|
:size="dieSize"
|
||||||
|
label="Resolution d20"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Special Event Indicators -->
|
<!-- Special Event Indicators -->
|
||||||
@ -80,18 +92,6 @@
|
|||||||
Passed Ball Check
|
Passed Ball Check
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -99,13 +99,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { RollData } from '~/types'
|
import type { RollData } from '~/types'
|
||||||
|
import DiceShapes from './DiceShapes.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
canRoll: boolean
|
canRoll: boolean
|
||||||
pendingRoll: RollData | null
|
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<{
|
const emit = defineEmits<{
|
||||||
roll: []
|
roll: []
|
||||||
@ -114,6 +118,20 @@ const emit = defineEmits<{
|
|||||||
// Local state
|
// Local state
|
||||||
const isRolling = ref(false)
|
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
|
// Methods
|
||||||
const handleRoll = () => {
|
const handleRoll = () => {
|
||||||
if (!props.canRoll || isRolling.value) return
|
if (!props.canRoll || isRolling.value) return
|
||||||
@ -160,8 +178,8 @@ const formatTimestamp = (timestamp: string): string => {
|
|||||||
|
|
||||||
/* Dice Results Container */
|
/* Dice Results Container */
|
||||||
.dice-results {
|
.dice-results {
|
||||||
@apply bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-6 shadow-xl;
|
@apply bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 shadow-xl;
|
||||||
@apply space-y-4;
|
@apply space-y-3;
|
||||||
animation: slideDown 0.3s ease-out;
|
animation: slideDown 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,106 +196,56 @@ const formatTimestamp = (timestamp: string): string => {
|
|||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.dice-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 Display Grid */
|
||||||
.dice-grid {
|
.dice-display {
|
||||||
@apply grid grid-cols-2 gap-4;
|
@apply flex justify-center items-start gap-6 pt-2 pb-4;
|
||||||
|
@apply flex-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
/* When only 3 dice shown, they center nicely */
|
||||||
.dice-grid {
|
.dice-display-compact {
|
||||||
@apply grid-cols-4;
|
@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 */
|
||||||
.special-events {
|
.special-events {
|
||||||
@apply flex flex-wrap gap-2 pt-2;
|
@apply flex flex-wrap justify-center gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-event {
|
.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 {
|
.wild-pitch {
|
||||||
@apply bg-yellow-400 text-yellow-900;
|
@apply bg-yellow-500 text-yellow-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.passed-ball {
|
.passed-ball {
|
||||||
@apply bg-orange-400 text-orange-900;
|
@apply bg-orange-500 text-orange-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card Instructions */
|
/* Responsive adjustments */
|
||||||
.card-instructions {
|
@media (max-width: 480px) {
|
||||||
@apply bg-blue-500 bg-opacity-50 rounded-lg p-4;
|
.dice-display {
|
||||||
@apply flex items-start gap-3;
|
@apply gap-4;
|
||||||
}
|
|
||||||
|
|
||||||
.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) {
|
|
||||||
.roll-button {
|
|
||||||
@apply ring-2 ring-offset-2 ring-offset-gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roll-button-enabled {
|
|
||||||
@apply ring-green-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roll-button-disabled {
|
|
||||||
@apply bg-gray-700 text-gray-400 ring-gray-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dice-results {
|
|
||||||
@apply from-blue-800 to-blue-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dice-item {
|
|
||||||
@apply shadow-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-instructions {
|
|
||||||
@apply bg-blue-700 bg-opacity-50;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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
|
<DiceRoller
|
||||||
:can-roll="canRollDice"
|
:can-roll="canRollDice"
|
||||||
:pending-roll="null"
|
:pending-roll="null"
|
||||||
|
:dice-color="diceColor"
|
||||||
@roll="handleRollDice"
|
@roll="handleRollDice"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -54,36 +55,9 @@
|
|||||||
<DiceRoller
|
<DiceRoller
|
||||||
:can-roll="false"
|
:can-roll="false"
|
||||||
:pending-roll="pendingRoll"
|
:pending-roll="pendingRoll"
|
||||||
|
:dice-color="diceColor"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Post-Roll Card Display -->
|
|
||||||
<div v-if="activeCardPlayer" class="post-roll-card">
|
|
||||||
<!-- Card Header -->
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-label" :class="showBatterCard ? 'batter-label' : 'pitcher-label'">
|
|
||||||
{{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD
|
|
||||||
</span>
|
|
||||||
<span class="player-name">{{ activeCardPlayer.name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full-Width Card Image -->
|
|
||||||
<div class="card-image-wrapper">
|
|
||||||
<img
|
|
||||||
v-if="activeCardPlayer.image"
|
|
||||||
:src="activeCardPlayer.image"
|
|
||||||
:alt="`${activeCardPlayer.name} card`"
|
|
||||||
class="player-card-image"
|
|
||||||
>
|
|
||||||
<div v-else class="card-placeholder">
|
|
||||||
<span class="placeholder-initials">
|
|
||||||
{{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"/>
|
|
||||||
|
|
||||||
<OutcomeWizard
|
<OutcomeWizard
|
||||||
:can-submit="canSubmitOutcome"
|
:can-submit="canSubmitOutcome"
|
||||||
@submit="handleSubmitOutcome"
|
@submit="handleSubmitOutcome"
|
||||||
@ -127,7 +101,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { RollData, PlayResult, PlayOutcome, SbaPlayer } from '~/types'
|
import type { RollData, PlayResult, PlayOutcome } from '~/types'
|
||||||
import DiceRoller from './DiceRoller.vue'
|
import DiceRoller from './DiceRoller.vue'
|
||||||
import OutcomeWizard from './OutcomeWizard.vue'
|
import OutcomeWizard from './OutcomeWizard.vue'
|
||||||
import PlayResultDisplay from './PlayResult.vue'
|
import PlayResultDisplay from './PlayResult.vue'
|
||||||
@ -141,16 +115,14 @@ interface Props {
|
|||||||
canSubmitOutcome: boolean
|
canSubmitOutcome: boolean
|
||||||
outs?: number
|
outs?: number
|
||||||
hasRunners?: boolean
|
hasRunners?: boolean
|
||||||
// Player data for post-roll card display
|
// Dice color from home team (hex without #)
|
||||||
batterPlayer?: SbaPlayer | null
|
diceColor?: string
|
||||||
pitcherPlayer?: SbaPlayer | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
outs: 0,
|
outs: 0,
|
||||||
hasRunners: false,
|
hasRunners: false,
|
||||||
batterPlayer: null,
|
diceColor: 'cc0000', // Default red
|
||||||
pitcherPlayer: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -163,14 +135,6 @@ const emit = defineEmits<{
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
// Post-roll card display: d6_one 1-3 = batter, 4-6 = pitcher
|
|
||||||
const showBatterCard = computed(() =>
|
|
||||||
props.pendingRoll && props.pendingRoll.d6_one <= 3
|
|
||||||
)
|
|
||||||
const activeCardPlayer = computed(() =>
|
|
||||||
showBatterCard.value ? props.batterPlayer : props.pitcherPlayer
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workflow state computation
|
// Workflow state computation
|
||||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
|
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
|
||||||
|
|
||||||
@ -336,54 +300,6 @@ const handleDismissResult = () => {
|
|||||||
@apply space-y-6;
|
@apply space-y-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Post-Roll Card Display */
|
|
||||||
.post-roll-card {
|
|
||||||
@apply bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-4;
|
|
||||||
@apply border-2 border-amber-200 shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
@apply flex items-center gap-3 mb-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-label {
|
|
||||||
@apply text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batter-label {
|
|
||||||
@apply bg-red-100 text-red-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pitcher-label {
|
|
||||||
@apply bg-blue-100 text-blue-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header .player-name {
|
|
||||||
@apply text-lg font-bold text-gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-image-wrapper {
|
|
||||||
@apply w-full rounded-lg overflow-hidden shadow-lg;
|
|
||||||
@apply ring-2 ring-amber-300 ring-offset-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-card-image {
|
|
||||||
@apply w-full h-auto object-contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-placeholder {
|
|
||||||
@apply w-full h-48 bg-gradient-to-br from-gray-300 to-gray-400;
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-initials {
|
|
||||||
@apply text-4xl font-bold text-gray-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
@apply border-t-2 border-gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* State: Result */
|
/* State: Result */
|
||||||
.state-result {
|
.state-result {
|
||||||
@apply space-y-4;
|
@apply space-y-4;
|
||||||
@ -426,42 +342,9 @@ const handleDismissResult = () => {
|
|||||||
@apply text-blue-300;
|
@apply text-blue-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
|
||||||
@apply border-gray-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Post-roll card dark mode */
|
|
||||||
.post-roll-card {
|
|
||||||
@apply from-amber-900/30 to-orange-900/30 border-amber-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header .player-name {
|
|
||||||
@apply text-gray-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-image-wrapper {
|
|
||||||
@apply ring-amber-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-placeholder {
|
|
||||||
@apply from-gray-600 to-gray-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-initials {
|
|
||||||
@apply text-gray-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batter-label {
|
|
||||||
@apply bg-red-900/50 text-red-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pitcher-label {
|
|
||||||
@apply bg-blue-900/50 text-blue-300;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile optimizations */
|
/* Mobile optimizations */
|
||||||
|
|||||||
@ -193,11 +193,18 @@ watch(() => props.isOpen, (isOpen) => {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.player-card-modal {
|
.player-card-modal {
|
||||||
@apply rounded-2xl;
|
@apply rounded-2xl max-w-2xl;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.player-card-modal {
|
||||||
|
@apply max-w-3xl;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Drag handle */
|
/* Drag handle */
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
@apply flex justify-center py-2 mb-2;
|
@apply flex justify-center py-2 mb-2;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
||||||
|
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
|
||||||
import type { RollData } from '~/types'
|
import type { RollData } from '~/types'
|
||||||
|
|
||||||
describe('DiceRoller', () => {
|
describe('DiceRoller', () => {
|
||||||
@ -14,10 +15,17 @@ describe('DiceRoller', () => {
|
|||||||
resolution_d20: 8,
|
resolution_d20: 8,
|
||||||
check_wild_pitch: false,
|
check_wild_pitch: false,
|
||||||
check_passed_ball: false,
|
check_passed_ball: false,
|
||||||
|
chaos_check_skipped: false,
|
||||||
timestamp: '2025-01-13T12:00:00Z',
|
timestamp: '2025-01-13T12:00:00Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
canRoll: true,
|
||||||
|
pendingRoll: null as RollData | null,
|
||||||
|
diceColor: 'cc0000', // Default red
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllTimers()
|
vi.clearAllTimers()
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
@ -30,10 +38,7 @@ describe('DiceRoller', () => {
|
|||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders roll button when no pending roll', () => {
|
it('renders roll button when no pending roll', () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
||||||
@ -45,6 +50,7 @@ describe('DiceRoller', () => {
|
|||||||
const rollData = createRollData()
|
const rollData = createRollData()
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -55,27 +61,83 @@ describe('DiceRoller', () => {
|
|||||||
expect(wrapper.text()).toContain('Dice Results')
|
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({
|
const rollData = createRollData({
|
||||||
d6_one: 5,
|
d6_one: 5,
|
||||||
d6_two_total: 8,
|
d6_two_total: 8,
|
||||||
chaos_d20: 17,
|
chaos_d20: 17, // Not 1 or 2, so no check triggered
|
||||||
resolution_d20: 3,
|
resolution_d20: 3,
|
||||||
|
check_wild_pitch: false,
|
||||||
|
check_passed_ball: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const diceValues = wrapper.findAll('.dice-value')
|
// DiceShapes components are rendered for each die
|
||||||
expect(diceValues).toHaveLength(4)
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(diceValues[0].text()).toBe('5')
|
expect(diceComponents).toHaveLength(3) // chaos d20 hidden when no check triggered
|
||||||
expect(diceValues[1].text()).toBe('8')
|
})
|
||||||
expect(diceValues[2].text()).toBe('17')
|
|
||||||
expect(diceValues[3].text()).toBe('3')
|
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', () => {
|
it('displays d6_two component dice values', () => {
|
||||||
@ -87,6 +149,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -103,10 +166,7 @@ describe('DiceRoller', () => {
|
|||||||
describe('Button States', () => {
|
describe('Button States', () => {
|
||||||
it('enables button when canRoll is true', () => {
|
it('enables button when canRoll is true', () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const button = wrapper.find('.roll-button')
|
const button = wrapper.find('.roll-button')
|
||||||
@ -118,8 +178,8 @@ describe('DiceRoller', () => {
|
|||||||
it('disables button when canRoll is false', () => {
|
it('disables button when canRoll is false', () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -131,10 +191,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
it('shows loading state when rolling', async () => {
|
it('shows loading state when rolling', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.roll-button').trigger('click')
|
await wrapper.find('.roll-button').trigger('click')
|
||||||
@ -145,10 +202,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
it('disables button during rolling animation', async () => {
|
it('disables button during rolling animation', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.roll-button').trigger('click')
|
await wrapper.find('.roll-button').trigger('click')
|
||||||
@ -159,10 +213,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
it('resets rolling state after timeout', async () => {
|
it('resets rolling state after timeout', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.roll-button').trigger('click')
|
await wrapper.find('.roll-button').trigger('click')
|
||||||
@ -182,10 +233,7 @@ describe('DiceRoller', () => {
|
|||||||
describe('Event Emission', () => {
|
describe('Event Emission', () => {
|
||||||
it('emits roll event when button clicked', async () => {
|
it('emits roll event when button clicked', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.roll-button').trigger('click')
|
await wrapper.find('.roll-button').trigger('click')
|
||||||
@ -197,8 +245,8 @@ describe('DiceRoller', () => {
|
|||||||
it('does not emit roll when canRoll is false', async () => {
|
it('does not emit roll when canRoll is false', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -209,10 +257,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
it('does not emit roll during rolling animation', async () => {
|
it('does not emit roll during rolling animation', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.roll-button').trigger('click')
|
await wrapper.find('.roll-button').trigger('click')
|
||||||
@ -231,6 +276,7 @@ describe('DiceRoller', () => {
|
|||||||
const rollData = createRollData({ check_wild_pitch: true })
|
const rollData = createRollData({ check_wild_pitch: true })
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -244,6 +290,7 @@ describe('DiceRoller', () => {
|
|||||||
const rollData = createRollData({ check_passed_ball: true })
|
const rollData = createRollData({ check_passed_ball: true })
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -261,6 +308,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -278,6 +326,7 @@ describe('DiceRoller', () => {
|
|||||||
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -288,32 +337,116 @@ describe('DiceRoller', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Card Instructions Tests
|
// Chaos d20 Conditional Display Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
describe('Card Instructions', () => {
|
describe('Chaos d20 Conditional Display', () => {
|
||||||
it('shows card reading instructions when roll exists', () => {
|
/**
|
||||||
const rollData = createRollData()
|
* 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, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('.card-instructions').exists()).toBe(true)
|
const chaosItem = wrapper.find('.dice-chaos')
|
||||||
expect(wrapper.text()).toContain('Use these dice results')
|
expect(chaosItem.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show instructions when no roll', () => {
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
canRoll: true,
|
...defaultProps,
|
||||||
pendingRoll: null,
|
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, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
@ -340,50 +474,165 @@ describe('DiceRoller', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dice Type Styling Tests
|
// Dice Color Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
describe('Dice Type Styling', () => {
|
describe('Dice Color', () => {
|
||||||
it('applies d6 styling to d6 dice', () => {
|
it('passes dice color to d6 DiceShapes components', () => {
|
||||||
const rollData = createRollData()
|
const rollData = createRollData()
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
|
canRoll: false,
|
||||||
|
pendingRoll: rollData,
|
||||||
|
diceColor: '0066ff', // Blue
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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('uses white for resolution d20', () => {
|
||||||
|
const rollData = createRollData()
|
||||||
|
const wrapper = mount(DiceRoller, {
|
||||||
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const diceItems = wrapper.findAll('.dice-item')
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(diceItems[0].classes()).toContain('dice-d6') // d6 one
|
// Last one is resolution d20 (when no chaos shown)
|
||||||
expect(diceItems[1].classes()).toContain('dice-d6') // d6 two
|
const resolutionD20 = diceComponents[diceComponents.length - 1]
|
||||||
|
expect(resolutionD20.props('color')).toBe('ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses amber for chaos d20 when shown', () => {
|
||||||
|
const rollData = createRollData({
|
||||||
|
check_wild_pitch: true,
|
||||||
|
chaos_d20: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies d20 styling to d20 dice', () => {
|
|
||||||
const rollData = createRollData()
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const diceItems = wrapper.findAll('.dice-item')
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(diceItems[2].classes()).toContain('dice-d20') // chaos d20
|
// Third one is chaos d20 when WP triggered
|
||||||
expect(diceItems[3].classes()).toContain('dice-d20') // resolution d20
|
const chaosD20 = diceComponents[2]
|
||||||
|
expect(chaosD20.props('color')).toBe('f59e0b')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies large value class to d20 dice', () => {
|
it('uses default red when no diceColor prop provided', () => {
|
||||||
const rollData = createRollData()
|
const rollData = createRollData()
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
|
// No diceColor prop
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const diceValues = wrapper.findAll('.dice-value')
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(diceValues[2].classes()).toContain('dice-value-large')
|
expect(diceComponents[0].props('color')).toBe('cc0000')
|
||||||
expect(diceValues[3].classes()).toContain('dice-value-large')
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('6')
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(wrapper.text()).toContain('12')
|
expect(diceComponents[0].props('value')).toBe(6)
|
||||||
expect(wrapper.text()).toContain('20')
|
expect(diceComponents[1].props('value')).toBe(12)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles minimum dice values', () => {
|
it('handles minimum dice values', () => {
|
||||||
@ -422,25 +672,27 @@ describe('DiceRoller', () => {
|
|||||||
d6_two_total: 2,
|
d6_two_total: 2,
|
||||||
chaos_d20: 1,
|
chaos_d20: 1,
|
||||||
resolution_d20: 1,
|
resolution_d20: 1,
|
||||||
|
check_wild_pitch: true, // Show chaos d20
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: rollData,
|
pendingRoll: rollData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('1')
|
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||||
expect(wrapper.text()).toContain('2')
|
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 () => {
|
it('transitions from no roll to roll result', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: defaultProps,
|
||||||
canRoll: true,
|
|
||||||
pendingRoll: null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
||||||
@ -455,6 +707,7 @@ describe('DiceRoller', () => {
|
|||||||
it('clears roll result when pendingRoll set to null', async () => {
|
it('clears roll result when pendingRoll set to null', async () => {
|
||||||
const wrapper = mount(DiceRoller, {
|
const wrapper = mount(DiceRoller, {
|
||||||
props: {
|
props: {
|
||||||
|
...defaultProps,
|
||||||
canRoll: false,
|
canRoll: false,
|
||||||
pendingRoll: createRollData(),
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -76,10 +76,12 @@ export interface GameState {
|
|||||||
home_team_name?: string | null // Full name: "Chicago Cyclones"
|
home_team_name?: string | null // Full name: "Chicago Cyclones"
|
||||||
home_team_abbrev?: string | null // Abbreviation: "CHC"
|
home_team_abbrev?: string | null // Abbreviation: "CHC"
|
||||||
home_team_color?: string | null // Hex color without #: "ff5349"
|
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
|
home_team_thumbnail?: string | null // Team logo URL
|
||||||
away_team_name?: string | null
|
away_team_name?: string | null
|
||||||
away_team_abbrev?: string | null
|
away_team_abbrev?: string | null
|
||||||
away_team_color?: string | null
|
away_team_color?: string | null
|
||||||
|
away_team_dice_color?: string | null // Dice color hex without #, default "cc0000"
|
||||||
away_team_thumbnail?: string | null
|
away_team_thumbnail?: string | null
|
||||||
|
|
||||||
// Creator (for demo/testing - creator can control home team)
|
// Creator (for demo/testing - creator can control home team)
|
||||||
@ -168,6 +170,7 @@ export interface RollData {
|
|||||||
resolution_d20: number
|
resolution_d20: number
|
||||||
check_wild_pitch: boolean
|
check_wild_pitch: boolean
|
||||||
check_passed_ball: boolean
|
check_passed_ball: boolean
|
||||||
|
chaos_check_skipped: boolean // True when no runners on base (WP/PB irrelevant)
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user