CLAUDE: Redesign dice display with team colors and consolidate player cards

Backend:
- Add home_team_dice_color and away_team_dice_color to GameState model
- Extract dice_color from game metadata in StateManager (default: cc0000)
- Add runners_on_base param to roll_ab for chaos check skipping

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-24 00:16:32 -06:00
parent be31e2ccb4
commit 2b8fea36a8
18 changed files with 1373 additions and 604 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -84,11 +84,20 @@ 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
self.check_wild_pitch = self.chaos_d20 == 1
self.check_passed_ball = self.chaos_d20 == 2 # Only check for WP/PB if chaos check wasn't skipped (runners on base)
if self.chaos_check_skipped:
self.check_wild_pitch = False
self.check_passed_ball = False
else:
self.check_wild_pitch = self.chaos_d20 == 1
self.check_passed_ball = self.chaos_d20 == 2
def to_dict(self) -> dict: def to_dict(self) -> dict:
base = super().to_dict() base = super().to_dict()
@ -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

View File

@ -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"),
) )

View File

@ -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)

View File

@ -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",
}, },

View File

@ -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

View File

@ -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>
<img <span class="position-info">P</span>
v-if="getPlayerPreviewImage(pitcherPlayer)" <span class="player-name">{{ pitcherName }}</span>
: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-lg">
{{ getPlayerFallbackInitial(pitcherPlayer) }}
</div>
</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> </div>
</button>
<!-- VS Indicator --> <!-- Card Image -->
<div class="flex items-center justify-center"> <div class="card-image-container">
<div class="px-4 py-1 bg-gray-800 dark:bg-gray-700 text-white rounded-full text-xs font-bold shadow-lg"> <img
VS v-if="pitcherPlayer?.image"
</div> :src="pitcherPlayer.image"
</div> :alt="`${pitcherName} card`"
class="card-image"
<!-- Current Batter Card --> @error="handleImageError"
<button >
v-if="currentBatter" <div v-else class="card-placeholder pitcher-placeholder">
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" <span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
@click="openPlayerCard('batter')" <span class="placeholder-label">No Card Image</span>
>
<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)!" </div>
: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>
<!-- 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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

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

View File

@ -44,6 +44,7 @@
<DiceRoller <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 */

View File

@ -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;

View File

@ -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('does not show instructions when no roll', () => { it('hides chaos d20 when chaos check was skipped (bases empty)', () => {
const rollData = createRollData({
chaos_d20: 1, // Would trigger WP but bases empty
check_wild_pitch: false, // Skipped due to no runners
check_passed_ball: false,
chaos_check_skipped: true, // No runners on base
})
const wrapper = mount(DiceRoller, { 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, canRoll: false,
pendingRoll: rollData, pendingRoll: rollData,
diceColor: '0066ff', // Blue
}, },
}) })
const diceItems = wrapper.findAll('.dice-item') const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceItems[0].classes()).toContain('dice-d6') // d6 one // First two are d6 dice
expect(diceItems[1].classes()).toContain('dice-d6') // d6 two expect(diceComponents[0].props('color')).toBe('0066ff')
expect(diceComponents[1].props('color')).toBe('0066ff')
}) })
it('applies d20 styling to d20 dice', () => { it('uses white for resolution d20', () => {
const rollData = createRollData() const 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 // Last one is resolution d20 (when no chaos shown)
expect(diceItems[3].classes()).toContain('dice-d20') // resolution d20 const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('color')).toBe('ffffff')
}) })
it('applies large value class to d20 dice', () => { it('uses amber for chaos d20 when shown', () => {
const rollData = createRollData() const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
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') const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceValues[2].classes()).toContain('dice-value-large') // Third one is chaos d20 when WP triggered
expect(diceValues[3].classes()).toContain('dice-value-large') const chaosD20 = diceComponents[2]
expect(chaosD20.props('color')).toBe('f59e0b')
})
it('uses default red when no diceColor prop provided', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
// No diceColor prop
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('color')).toBe('cc0000')
})
})
// ============================================================================
// Dice Type Tests
// ============================================================================
describe('Dice Types', () => {
it('renders d6 type for first two dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('type')).toBe('d6')
expect(diceComponents[1].props('type')).toBe('d6')
})
it('renders d20 type for resolution die', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Last one is resolution d20
const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('type')).toBe('d20')
})
it('renders d20 type for chaos die when shown', () => {
const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Third one is chaos d20
expect(diceComponents[2].props('type')).toBe('d20')
})
})
// ============================================================================
// Layout Tests
// ============================================================================
describe('Layout', () => {
it('applies compact display class when chaos d20 is hidden', () => {
const rollData = createRollData() // Default: no WP/PB check
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).toContain('dice-display-compact')
})
it('does not apply compact display class when chaos d20 is shown', () => {
const rollData = createRollData({ check_wild_pitch: true, chaos_d20: 1 })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).not.toContain('dice-display-compact')
}) })
}) })
@ -404,14 +653,15 @@ describe('DiceRoller', () => {
const wrapper = mount(DiceRoller, { 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(),
}, },

View File

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

View File

@ -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
} }