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,
|
||||
team_id: int | None = None,
|
||||
player_id: int | None = None,
|
||||
runners_on_base: bool = True,
|
||||
) -> AbRoll:
|
||||
"""
|
||||
Roll at-bat dice: 1d6 + 2d6 + 2d20
|
||||
@ -65,11 +66,14 @@ class DiceSystem:
|
||||
- chaos_d20 == 2: 5% chance - Passed ball check (use resolution_d20 for confirmation)
|
||||
- chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits)
|
||||
|
||||
If runners_on_base is False, the chaos check is skipped (WP/PB meaningless without runners).
|
||||
|
||||
Args:
|
||||
league_id: 'sba' or 'pd'
|
||||
game_id: Optional UUID of game in progress
|
||||
team_id: Optional team ID for auditing
|
||||
player_id: Optional player/card ID for auditing (polymorphic)
|
||||
runners_on_base: Whether there are runners on base (affects chaos check)
|
||||
|
||||
Returns:
|
||||
AbRoll with all dice results
|
||||
@ -80,6 +84,9 @@ class DiceSystem:
|
||||
chaos_d20 = self._roll_d20()
|
||||
resolution_d20 = self._roll_d20() # Always roll, used for WP/PB or splits
|
||||
|
||||
# Skip chaos check if no runners on base (WP/PB is meaningless)
|
||||
chaos_check_skipped = not runners_on_base
|
||||
|
||||
roll = AbRoll(
|
||||
roll_id=self._generate_roll_id(),
|
||||
roll_type=RollType.AB,
|
||||
@ -94,8 +101,9 @@ class DiceSystem:
|
||||
chaos_d20=chaos_d20,
|
||||
resolution_d20=resolution_d20,
|
||||
d6_two_total=0, # Calculated in __post_init__
|
||||
check_wild_pitch=False,
|
||||
check_passed_ball=False,
|
||||
check_wild_pitch=False, # Calculated in __post_init__
|
||||
check_passed_ball=False, # Calculated in __post_init__
|
||||
chaos_check_skipped=chaos_check_skipped,
|
||||
)
|
||||
|
||||
self._roll_history.append(roll)
|
||||
|
||||
@ -679,8 +679,15 @@ class GameEngine:
|
||||
state_manager=state_manager,
|
||||
)
|
||||
|
||||
# Check if there are runners on base (affects chaos check)
|
||||
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
|
||||
|
||||
# Roll dice
|
||||
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id)
|
||||
ab_roll = dice_system.roll_ab(
|
||||
league_id=state.league_id,
|
||||
game_id=game_id,
|
||||
runners_on_base=runners_on_base,
|
||||
)
|
||||
|
||||
# Use forced outcome if provided (for testing), otherwise need to implement chart lookup
|
||||
if forced_outcome is None:
|
||||
|
||||
@ -198,8 +198,15 @@ class PlayResolver:
|
||||
|
||||
logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}")
|
||||
|
||||
# Check if there are runners on base (affects chaos check)
|
||||
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
|
||||
|
||||
# Roll dice
|
||||
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=state.game_id)
|
||||
ab_roll = dice_system.roll_ab(
|
||||
league_id=state.league_id,
|
||||
game_id=state.game_id,
|
||||
runners_on_base=runners_on_base,
|
||||
)
|
||||
|
||||
# Generate outcome from ratings
|
||||
outcome, hit_location = self.result_chart.get_outcome( # type: ignore
|
||||
|
||||
@ -84,11 +84,20 @@ class AbRoll(DiceRoll):
|
||||
default=False
|
||||
) # chaos_d20 == 2 (still needs resolution_d20 to confirm)
|
||||
|
||||
# Flag to indicate chaos check was skipped (no runners on base)
|
||||
chaos_check_skipped: bool = field(default=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Calculate derived values"""
|
||||
self.d6_two_total = self.d6_two_a + self.d6_two_b
|
||||
self.check_wild_pitch = self.chaos_d20 == 1
|
||||
self.check_passed_ball = self.chaos_d20 == 2
|
||||
|
||||
# Only check for WP/PB if chaos check wasn't skipped (runners on base)
|
||||
if self.chaos_check_skipped:
|
||||
self.check_wild_pitch = False
|
||||
self.check_passed_ball = False
|
||||
else:
|
||||
self.check_wild_pitch = self.chaos_d20 == 1
|
||||
self.check_passed_ball = self.chaos_d20 == 2
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
base = super().to_dict()
|
||||
@ -102,6 +111,7 @@ class AbRoll(DiceRoll):
|
||||
"resolution_d20": self.resolution_d20,
|
||||
"check_wild_pitch": self.check_wild_pitch,
|
||||
"check_passed_ball": self.check_passed_ball,
|
||||
"chaos_check_skipped": self.chaos_check_skipped,
|
||||
}
|
||||
)
|
||||
return base
|
||||
|
||||
@ -147,10 +147,12 @@ class StateManager:
|
||||
home_team_name=home_meta.get("lname"),
|
||||
home_team_abbrev=home_meta.get("abbrev"),
|
||||
home_team_color=home_meta.get("color"),
|
||||
home_team_dice_color=home_meta.get("dice_color", "cc0000"), # Default red
|
||||
home_team_thumbnail=home_meta.get("thumbnail"),
|
||||
away_team_name=away_meta.get("lname"),
|
||||
away_team_abbrev=away_meta.get("abbrev"),
|
||||
away_team_color=away_meta.get("color"),
|
||||
away_team_dice_color=away_meta.get("dice_color", "cc0000"), # Default red
|
||||
away_team_thumbnail=away_meta.get("thumbnail"),
|
||||
)
|
||||
|
||||
@ -414,10 +416,12 @@ class StateManager:
|
||||
home_team_name=home_meta.get("lname"),
|
||||
home_team_abbrev=home_meta.get("abbrev"),
|
||||
home_team_color=home_meta.get("color"),
|
||||
home_team_dice_color=home_meta.get("dice_color", "cc0000"), # Default red
|
||||
home_team_thumbnail=home_meta.get("thumbnail"),
|
||||
away_team_name=away_meta.get("lname"),
|
||||
away_team_abbrev=away_meta.get("abbrev"),
|
||||
away_team_color=away_meta.get("color"),
|
||||
away_team_dice_color=away_meta.get("dice_color", "cc0000"), # Default red
|
||||
away_team_thumbnail=away_meta.get("thumbnail"),
|
||||
)
|
||||
|
||||
|
||||
@ -400,10 +400,12 @@ class GameState(BaseModel):
|
||||
home_team_name: str | None = None # e.g., "Chicago Cyclones"
|
||||
home_team_abbrev: str | None = None # e.g., "CHC"
|
||||
home_team_color: str | None = None # e.g., "ff5349" (no # prefix)
|
||||
home_team_dice_color: str | None = None # Dice color, default "cc0000" (red)
|
||||
home_team_thumbnail: str | None = None # Team logo URL
|
||||
away_team_name: str | None = None
|
||||
away_team_abbrev: str | None = None
|
||||
away_team_color: str | None = None
|
||||
away_team_dice_color: str | None = None # Dice color, default "cc0000" (red)
|
||||
away_team_thumbnail: str | None = None
|
||||
|
||||
# Creator (for demo/testing - creator can control home team)
|
||||
|
||||
@ -335,13 +335,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
|
||||
# return
|
||||
|
||||
# Check if there are runners on base (affects chaos check)
|
||||
runners_on_base = bool(state.on_first or state.on_second or state.on_third)
|
||||
|
||||
# Roll dice
|
||||
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id)
|
||||
ab_roll = dice_system.roll_ab(
|
||||
league_id=state.league_id,
|
||||
game_id=game_id,
|
||||
runners_on_base=runners_on_base,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Dice rolled for game {game_id}: "
|
||||
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
|
||||
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}"
|
||||
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}, "
|
||||
f"chaos_skipped={ab_roll.chaos_check_skipped}"
|
||||
)
|
||||
|
||||
# Store roll in game state for manual outcome validation
|
||||
@ -363,6 +371,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
"resolution_d20": ab_roll.resolution_d20,
|
||||
"check_wild_pitch": ab_roll.check_wild_pitch,
|
||||
"check_passed_ball": ab_roll.check_passed_ball,
|
||||
"chaos_check_skipped": ab_roll.chaos_check_skipped,
|
||||
"timestamp": ab_roll.timestamp.to_iso8601_string(),
|
||||
"message": "Dice rolled - read your card and submit outcome",
|
||||
},
|
||||
|
||||
@ -68,7 +68,8 @@ def mock_ab_roll():
|
||||
resolution_d20=12,
|
||||
d6_two_total=7,
|
||||
check_wild_pitch=False,
|
||||
check_passed_ball=False
|
||||
check_passed_ball=False,
|
||||
chaos_check_skipped=True, # No runners on base in mock_game_state
|
||||
)
|
||||
|
||||
|
||||
@ -118,10 +119,11 @@ async def test_roll_dice_success(mock_manager, mock_game_state, mock_ab_roll):
|
||||
# Call handler
|
||||
await roll_dice_handler('test_sid', {"game_id": str(mock_game_state.game_id)})
|
||||
|
||||
# Verify dice rolled
|
||||
# Verify dice rolled (runners_on_base=False since mock_game_state has no runners)
|
||||
mock_dice.roll_ab.assert_called_once_with(
|
||||
league_id="sba",
|
||||
game_id=mock_game_state.game_id
|
||||
game_id=mock_game_state.game_id,
|
||||
runners_on_base=False,
|
||||
)
|
||||
|
||||
# Verify state updated with roll
|
||||
|
||||
@ -1,125 +1,35 @@
|
||||
<template>
|
||||
<div class="current-situation">
|
||||
<!-- Mobile Layout (Stacked) -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
<!-- Side-by-Side Card Layout -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Current Pitcher Card -->
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Pitcher Image/Badge -->
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-1">
|
||||
<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-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>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header pitcher-header">
|
||||
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
|
||||
<span class="position-info">P</span>
|
||||
<span class="player-name">{{ pitcherName }}</span>
|
||||
</div>
|
||||
</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>
|
||||
<!-- Card Image -->
|
||||
<div class="card-image-container">
|
||||
<img
|
||||
v-if="pitcherPlayer?.image"
|
||||
:src="pitcherPlayer.image"
|
||||
:alt="`${pitcherName} card`"
|
||||
class="card-image"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="card-placeholder pitcher-placeholder">
|
||||
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
|
||||
<span class="placeholder-label">No Card Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -127,37 +37,31 @@
|
||||
<!-- Current Batter Card -->
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Batter Image/Badge -->
|
||||
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-2">
|
||||
<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-2xl">
|
||||
{{ getPlayerFallbackInitial(batterPlayer) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header batter-header">
|
||||
<span class="team-abbrev">{{ batterTeamAbbrev }}</span>
|
||||
<span class="position-info">{{ currentBatter.batting_order }}. {{ currentBatter.position }}</span>
|
||||
<span class="player-name">{{ batterName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Batter Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-1">
|
||||
At Bat
|
||||
</div>
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
|
||||
{{ batterName }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ currentBatter.position }}
|
||||
<span v-if="currentBatter.batting_order" class="ml-2">• Batting {{ currentBatter.batting_order }}</span>
|
||||
<span class="ml-2 text-red-500">Click to view card</span>
|
||||
</div>
|
||||
<!-- Card Image -->
|
||||
<div class="card-image-container">
|
||||
<img
|
||||
v-if="batterPlayer?.image"
|
||||
:src="batterPlayer.image"
|
||||
:alt="`${batterName} card`"
|
||||
class="card-image"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="card-placeholder batter-placeholder">
|
||||
<span class="placeholder-initials">{{ getPlayerFallbackInitial(batterPlayer) }}</span>
|
||||
<span class="placeholder-label">No Card Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -198,11 +102,17 @@ import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
||||
interface Props {
|
||||
currentBatter?: LineupPlayerState | null
|
||||
currentPitcher?: LineupPlayerState | null
|
||||
activeCard?: 'batter' | 'pitcher' | null // Which card to highlight (after dice roll)
|
||||
batterTeamAbbrev?: string
|
||||
pitcherTeamAbbrev?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentBatter: null,
|
||||
currentPitcher: null
|
||||
currentPitcher: null,
|
||||
activeCard: null,
|
||||
batterTeamAbbrev: '',
|
||||
pitcherTeamAbbrev: '',
|
||||
})
|
||||
|
||||
// Debug: Watch for prop changes
|
||||
@ -245,12 +155,6 @@ const pitcherName = computed(() => {
|
||||
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")
|
||||
// Ignores common suffixes like Jr, Sr, II, III, IV
|
||||
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()
|
||||
}
|
||||
|
||||
// Handle image loading errors
|
||||
function handleImageError(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
// Show sibling placeholder
|
||||
const placeholder = img.nextElementSibling
|
||||
if (placeholder) {
|
||||
(placeholder as HTMLElement).style.display = 'flex'
|
||||
}
|
||||
}
|
||||
|
||||
// Player card modal state
|
||||
const isPlayerCardOpen = ref(false)
|
||||
const selectedPlayerData = ref<{
|
||||
@ -275,6 +190,23 @@ const selectedPlayerData = ref<{
|
||||
const selectedPlayerPosition = ref('')
|
||||
const selectedPlayerTeam = ref('')
|
||||
|
||||
// Card highlight state based on activeCard prop
|
||||
const isPitcherActive = computed(() => props.activeCard === 'pitcher')
|
||||
const isBatterActive = computed(() => props.activeCard === 'batter')
|
||||
const hasActiveCard = computed(() => props.activeCard !== null)
|
||||
|
||||
// Dynamic classes for pitcher card
|
||||
const pitcherCardClasses = computed(() => ({
|
||||
'card-active': isPitcherActive.value,
|
||||
'card-inactive': hasActiveCard.value && !isPitcherActive.value,
|
||||
}))
|
||||
|
||||
// Dynamic classes for batter card
|
||||
const batterCardClasses = computed(() => ({
|
||||
'card-active': isBatterActive.value,
|
||||
'card-inactive': hasActiveCard.value && !isBatterActive.value,
|
||||
}))
|
||||
|
||||
// Open player card modal for batter or pitcher
|
||||
function openPlayerCard(type: 'batter' | 'pitcher') {
|
||||
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
||||
@ -289,7 +221,7 @@ function openPlayerCard(type: 'batter' | 'pitcher') {
|
||||
headshot: player.headshot || undefined
|
||||
}
|
||||
selectedPlayerPosition.value = state?.position || ''
|
||||
selectedPlayerTeam.value = '' // Could be enhanced to show team name
|
||||
selectedPlayerTeam.value = type === 'batter' ? props.batterTeamAbbrev : props.pitcherTeamAbbrev
|
||||
isPlayerCardOpen.value = true
|
||||
}
|
||||
|
||||
@ -300,19 +232,134 @@ function closePlayerCard() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Optional: Add subtle animations */
|
||||
.current-situation > div {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
/* Card Container */
|
||||
.player-card {
|
||||
@apply rounded-xl overflow-hidden cursor-pointer;
|
||||
@apply border-2 shadow-lg;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
.pitcher-card {
|
||||
@apply bg-gradient-to-b from-blue-900 to-blue-950 border-blue-600;
|
||||
}
|
||||
|
||||
.batter-card {
|
||||
@apply bg-gradient-to-b from-red-900 to-red-950 border-red-600;
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
@apply px-3 py-2 flex items-center gap-2 text-white;
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
|
||||
.pitcher-header {
|
||||
@apply bg-blue-800/80;
|
||||
}
|
||||
|
||||
.batter-header {
|
||||
@apply bg-red-800/80;
|
||||
}
|
||||
|
||||
.team-abbrev {
|
||||
@apply font-bold text-white/90;
|
||||
}
|
||||
|
||||
.position-info {
|
||||
@apply text-white/70;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
@apply truncate flex-1 text-right font-bold;
|
||||
}
|
||||
|
||||
/* Card Image Container */
|
||||
.card-image-container {
|
||||
@apply relative w-full;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
@apply w-full h-auto object-contain;
|
||||
}
|
||||
|
||||
/* Placeholder when no image */
|
||||
.card-placeholder {
|
||||
@apply w-full flex flex-col items-center justify-center;
|
||||
@apply py-12;
|
||||
}
|
||||
|
||||
.pitcher-placeholder {
|
||||
@apply bg-gradient-to-br from-blue-700 to-blue-900;
|
||||
}
|
||||
|
||||
.batter-placeholder {
|
||||
@apply bg-gradient-to-br from-red-700 to-red-900;
|
||||
}
|
||||
|
||||
.placeholder-initials {
|
||||
@apply text-5xl font-bold text-white/60;
|
||||
}
|
||||
|
||||
.placeholder-label {
|
||||
@apply text-sm text-white/40 mt-2;
|
||||
}
|
||||
|
||||
/* Card highlight transition */
|
||||
.card-transition {
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
/* Active card styling - emphasized */
|
||||
.card-active {
|
||||
@apply scale-105 z-10;
|
||||
animation: pulseGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Inactive card styling - dimmed */
|
||||
.card-inactive {
|
||||
@apply opacity-50 scale-95;
|
||||
}
|
||||
|
||||
/* Pulsing glow for active pitcher card */
|
||||
.pitcher-card.card-active {
|
||||
animation: pulseGlowBlue 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlowBlue {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulsing glow for active batter card */
|
||||
.batter-card.card-active {
|
||||
animation: pulseGlowRed 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlowRed {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px 2px rgba(239, 68, 68, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px 8px rgba(239, 68, 68, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Smaller cards on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.card-header {
|
||||
@apply px-2 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.placeholder-initials {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
.placeholder-label {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -240,41 +240,6 @@
|
||||
</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 -->
|
||||
<PlayerCardModal
|
||||
:is-open="isPlayerCardOpen"
|
||||
@ -445,63 +410,4 @@ function getFielderInfo(position: string): { initials: string; name: string; exi
|
||||
@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>
|
||||
|
||||
@ -62,12 +62,6 @@
|
||||
|
||||
<!-- Mobile Layout (Stacked) -->
|
||||
<div class="lg:hidden space-y-6">
|
||||
<!-- Current Situation -->
|
||||
<CurrentSituation
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
/>
|
||||
|
||||
<!-- Game Board -->
|
||||
<GameBoard
|
||||
:runners="runnersState"
|
||||
@ -76,6 +70,15 @@
|
||||
: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) -->
|
||||
<DecisionPanel
|
||||
v-if="showDecisions"
|
||||
@ -104,8 +107,7 @@
|
||||
:can-submit-outcome="canSubmitOutcome"
|
||||
:outs="gameState?.outs ?? 0"
|
||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||
:batter-player="batterPlayer"
|
||||
:pitcher-player="pitcherPlayer"
|
||||
:dice-color="diceColor"
|
||||
@roll-dice="handleRollDice"
|
||||
@submit-outcome="handleSubmitOutcome"
|
||||
@dismiss-result="handleDismissResult"
|
||||
@ -125,12 +127,6 @@
|
||||
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Game State -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Current Situation -->
|
||||
<CurrentSituation
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
/>
|
||||
|
||||
<!-- Game Board -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
||||
<GameBoard
|
||||
@ -141,6 +137,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Current Situation (below diamond, above gameplay panel) -->
|
||||
<CurrentSituation
|
||||
:current-batter="gameState?.current_batter"
|
||||
:current-pitcher="gameState?.current_pitcher"
|
||||
:active-card="activeCard"
|
||||
:batter-team-abbrev="batterTeamAbbrev"
|
||||
:pitcher-team-abbrev="pitcherTeamAbbrev"
|
||||
/>
|
||||
|
||||
<!-- Decision Panel (Phase F3) -->
|
||||
<DecisionPanel
|
||||
v-if="showDecisions"
|
||||
@ -169,8 +174,7 @@
|
||||
:can-submit-outcome="canSubmitOutcome"
|
||||
:outs="gameState?.outs ?? 0"
|
||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||
:batter-player="batterPlayer"
|
||||
:pitcher-player="pitcherPlayer"
|
||||
:dice-color="diceColor"
|
||||
@roll-dice="handleRollDice"
|
||||
@submit-outcome="handleSubmitOutcome"
|
||||
@dismiss-result="handleDismissResult"
|
||||
@ -382,19 +386,30 @@ const pendingRoll = computed(() => gameStore.pendingRoll)
|
||||
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
||||
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
||||
|
||||
// Resolved player data for post-roll card display
|
||||
const batterPlayer = computed(() => {
|
||||
const batterState = gameState.value?.current_batter
|
||||
if (!batterState) return null
|
||||
const lineup = gameStore.findPlayerInLineup(batterState.lineup_id)
|
||||
return lineup?.player ?? null
|
||||
// Home team's dice color for the dice display
|
||||
const diceColor = computed(() => gameState.value?.home_team_dice_color ?? 'cc0000')
|
||||
|
||||
// Active card for highlighting based on dice roll (d6_one: 1-3 = batter, 4-6 = pitcher)
|
||||
const activeCard = computed<'batter' | 'pitcher' | null>(() => {
|
||||
if (!pendingRoll.value) return null
|
||||
return pendingRoll.value.d6_one <= 3 ? 'batter' : 'pitcher'
|
||||
})
|
||||
|
||||
const pitcherPlayer = computed(() => {
|
||||
const pitcherState = gameState.value?.current_pitcher
|
||||
if (!pitcherState) return null
|
||||
const lineup = gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
||||
return lineup?.player ?? null
|
||||
// Team abbreviations for batter and pitcher cards
|
||||
// Top of inning: away bats, home fields
|
||||
// Bottom of inning: home bats, away fields
|
||||
const batterTeamAbbrev = computed(() => {
|
||||
if (!gameState.value) return ''
|
||||
return gameState.value.half === 'top'
|
||||
? gameState.value.away_team_abbrev ?? ''
|
||||
: gameState.value.home_team_abbrev ?? ''
|
||||
})
|
||||
|
||||
const pitcherTeamAbbrev = computed(() => {
|
||||
if (!gameState.value) return ''
|
||||
return gameState.value.half === 'top'
|
||||
? gameState.value.home_team_abbrev ?? ''
|
||||
: gameState.value.away_team_abbrev ?? ''
|
||||
})
|
||||
|
||||
// Local UI state
|
||||
|
||||
@ -35,34 +35,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Dice Grid -->
|
||||
<div class="dice-grid">
|
||||
<!-- Dice Display Grid -->
|
||||
<div class="dice-display" :class="{ 'dice-display-compact': !showChaosD20 }">
|
||||
<!-- d6 One -->
|
||||
<div class="dice-item dice-d6">
|
||||
<div class="dice-label">d6 (One)</div>
|
||||
<div class="dice-value">{{ pendingRoll.d6_one }}</div>
|
||||
</div>
|
||||
<DiceShapes
|
||||
type="d6"
|
||||
:value="pendingRoll.d6_one"
|
||||
:color="effectiveDiceColor"
|
||||
:size="dieSize"
|
||||
label="d6 (One)"
|
||||
/>
|
||||
|
||||
<!-- d6 Two (showing total) -->
|
||||
<div class="dice-item dice-d6">
|
||||
<div class="dice-label">d6 (Two)</div>
|
||||
<div class="dice-value">{{ pendingRoll.d6_two_total }}</div>
|
||||
<div class="dice-sublabel">
|
||||
({{ pendingRoll.d6_two_a }} + {{ pendingRoll.d6_two_b }})
|
||||
</div>
|
||||
</div>
|
||||
<DiceShapes
|
||||
type="d6"
|
||||
:value="pendingRoll.d6_two_total"
|
||||
:color="effectiveDiceColor"
|
||||
:size="dieSize"
|
||||
label="d6 (Two)"
|
||||
:sublabel="`(${pendingRoll.d6_two_a} + ${pendingRoll.d6_two_b})`"
|
||||
/>
|
||||
|
||||
<!-- Chaos d20 -->
|
||||
<div class="dice-item dice-d20">
|
||||
<div class="dice-label">Chaos d20</div>
|
||||
<div class="dice-value dice-value-large">{{ pendingRoll.chaos_d20 }}</div>
|
||||
</div>
|
||||
<!-- Chaos d20 - only shown when WP/PB check triggered -->
|
||||
<DiceShapes
|
||||
v-if="showChaosD20"
|
||||
type="d20"
|
||||
:value="pendingRoll.chaos_d20"
|
||||
color="f59e0b"
|
||||
:size="dieSize"
|
||||
label="Chaos d20"
|
||||
class="dice-chaos"
|
||||
/>
|
||||
|
||||
<!-- Resolution d20 -->
|
||||
<div class="dice-item dice-d20">
|
||||
<div class="dice-label">Resolution d20</div>
|
||||
<div class="dice-value dice-value-large">{{ pendingRoll.resolution_d20 }}</div>
|
||||
</div>
|
||||
<DiceShapes
|
||||
type="d20"
|
||||
:value="pendingRoll.resolution_d20"
|
||||
color="ffffff"
|
||||
:size="dieSize"
|
||||
label="Resolution d20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Special Event Indicators -->
|
||||
@ -80,18 +92,6 @@
|
||||
Passed Ball Check
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Reading Instructions -->
|
||||
<div class="card-instructions">
|
||||
<div class="instruction-icon">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="instruction-text">
|
||||
Use these dice results to read the outcome from your player's card
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -99,13 +99,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { RollData } from '~/types'
|
||||
import DiceShapes from './DiceShapes.vue'
|
||||
|
||||
interface Props {
|
||||
canRoll: boolean
|
||||
pendingRoll: RollData | null
|
||||
diceColor?: string // Home team's dice_color (hex without #), default 'cc0000'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
diceColor: 'cc0000', // Default red
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
roll: []
|
||||
@ -114,6 +118,20 @@ const emit = defineEmits<{
|
||||
// Local state
|
||||
const isRolling = ref(false)
|
||||
|
||||
// Die size - responsive
|
||||
const dieSize = 90
|
||||
|
||||
// Effective dice color (use prop or default)
|
||||
const effectiveDiceColor = computed(() => props.diceColor || 'cc0000')
|
||||
|
||||
// Computed: Only show chaos d20 when WP/PB check triggered (chaos_d20 == 1 or 2)
|
||||
// Hide when: bases were empty OR chaos_d20 >= 3 (no effect)
|
||||
const showChaosD20 = computed(() => {
|
||||
if (!props.pendingRoll) return false
|
||||
// Show chaos d20 only if a WP or PB check was triggered
|
||||
return props.pendingRoll.check_wild_pitch || props.pendingRoll.check_passed_ball
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleRoll = () => {
|
||||
if (!props.canRoll || isRolling.value) return
|
||||
@ -160,8 +178,8 @@ const formatTimestamp = (timestamp: string): string => {
|
||||
|
||||
/* Dice Results Container */
|
||||
.dice-results {
|
||||
@apply bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-6 shadow-xl;
|
||||
@apply space-y-4;
|
||||
@apply bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 shadow-xl;
|
||||
@apply space-y-3;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@ -178,106 +196,56 @@ const formatTimestamp = (timestamp: string): string => {
|
||||
|
||||
/* Header */
|
||||
.dice-header {
|
||||
@apply flex justify-between items-center pb-3 border-b border-blue-500;
|
||||
@apply flex justify-between items-center pb-3 border-b border-slate-700;
|
||||
}
|
||||
|
||||
/* Dice Grid */
|
||||
.dice-grid {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
/* Dice Display Grid */
|
||||
.dice-display {
|
||||
@apply flex justify-center items-start gap-6 pt-2 pb-4;
|
||||
@apply flex-wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dice-grid {
|
||||
@apply grid-cols-4;
|
||||
/* When only 3 dice shown, they center nicely */
|
||||
.dice-display-compact {
|
||||
@apply gap-8;
|
||||
}
|
||||
|
||||
/* Chaos d20 styling */
|
||||
.dice-chaos {
|
||||
animation: pulseGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 16px rgba(245, 158, 11, 0.7));
|
||||
}
|
||||
}
|
||||
|
||||
/* Individual Dice Items */
|
||||
.dice-item {
|
||||
@apply bg-white rounded-lg p-4 text-center shadow-md;
|
||||
@apply flex flex-col items-center justify-center;
|
||||
@apply min-h-[100px];
|
||||
}
|
||||
|
||||
.dice-d6 {
|
||||
@apply bg-gradient-to-br from-gray-50 to-gray-100;
|
||||
}
|
||||
|
||||
.dice-d20 {
|
||||
@apply bg-gradient-to-br from-yellow-50 to-yellow-100;
|
||||
}
|
||||
|
||||
.dice-label {
|
||||
@apply text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2;
|
||||
}
|
||||
|
||||
.dice-value {
|
||||
@apply text-3xl font-bold text-gray-900;
|
||||
}
|
||||
|
||||
.dice-value-large {
|
||||
@apply text-4xl;
|
||||
}
|
||||
|
||||
.dice-sublabel {
|
||||
@apply text-xs text-gray-500 mt-1;
|
||||
}
|
||||
|
||||
/* Special Events */
|
||||
.special-events {
|
||||
@apply flex flex-wrap gap-2 pt-2;
|
||||
@apply flex flex-wrap justify-center gap-3;
|
||||
}
|
||||
|
||||
.special-event {
|
||||
@apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold;
|
||||
@apply flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold;
|
||||
@apply shadow-md;
|
||||
}
|
||||
|
||||
.wild-pitch {
|
||||
@apply bg-yellow-400 text-yellow-900;
|
||||
@apply bg-yellow-500 text-yellow-900;
|
||||
}
|
||||
|
||||
.passed-ball {
|
||||
@apply bg-orange-400 text-orange-900;
|
||||
@apply bg-orange-500 text-orange-900;
|
||||
}
|
||||
|
||||
/* Card Instructions */
|
||||
.card-instructions {
|
||||
@apply bg-blue-500 bg-opacity-50 rounded-lg p-4;
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.instruction-icon {
|
||||
@apply text-blue-200 flex-shrink-0;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
@apply text-white text-sm font-medium;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.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;
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.dice-display {
|
||||
@apply gap-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
177
frontend-sba/components/Gameplay/DiceShapes.vue
Normal file
177
frontend-sba/components/Gameplay/DiceShapes.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<!-- D6 Die Shape -->
|
||||
<div v-if="type === 'd6'" class="die-container" :style="containerStyle">
|
||||
<svg :viewBox="viewBox" class="die-shape">
|
||||
<!-- Die body with rounded corners -->
|
||||
<rect
|
||||
x="4"
|
||||
y="4"
|
||||
:width="size - 8"
|
||||
:height="size - 8"
|
||||
rx="10"
|
||||
ry="10"
|
||||
:fill="fillColor"
|
||||
:stroke="strokeColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<!-- Corner dots (decorative, suggesting die pips) -->
|
||||
<circle :cx="size * 0.2" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
|
||||
<circle :cx="size * 0.8" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
|
||||
<circle :cx="size * 0.2" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
|
||||
<circle :cx="size * 0.8" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
|
||||
</svg>
|
||||
<div class="die-value" :style="valueStyle">
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
<div v-if="label" class="die-label">{{ label }}</div>
|
||||
<div v-if="sublabel" class="die-sublabel">{{ sublabel }}</div>
|
||||
</div>
|
||||
|
||||
<!-- D20 Die Shape (Hexagonal/Icosahedron-inspired) -->
|
||||
<div v-else-if="type === 'd20'" class="die-container" :style="containerStyle">
|
||||
<svg :viewBox="viewBox" class="die-shape">
|
||||
<!-- Hexagonal shape suggesting a d20 -->
|
||||
<polygon
|
||||
:points="hexagonPoints"
|
||||
:fill="fillColor"
|
||||
:stroke="strokeColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<!-- Inner facet lines for 3D effect -->
|
||||
<line
|
||||
:x1="size * 0.5"
|
||||
:y1="size * 0.1"
|
||||
:x2="size * 0.5"
|
||||
:y2="size * 0.35"
|
||||
:stroke="facetColor"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
:x1="size * 0.5"
|
||||
:y1="size * 0.65"
|
||||
:x2="size * 0.5"
|
||||
:y2="size * 0.9"
|
||||
:stroke="facetColor"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</svg>
|
||||
<div class="die-value die-value-large" :style="valueStyle">
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
<div v-if="label" class="die-label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
type: 'd6' | 'd20'
|
||||
value: number | string
|
||||
color?: string // Hex color without # (e.g., "cc0000")
|
||||
size?: number // Size in pixels (default 100)
|
||||
label?: string
|
||||
sublabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'cc0000', // Default red
|
||||
size: 100,
|
||||
})
|
||||
|
||||
// Computed styles
|
||||
const viewBox = computed(() => `0 0 ${props.size} ${props.size}`)
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}))
|
||||
|
||||
const fillColor = computed(() => `#${props.color}`)
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
// Darken the fill color for stroke
|
||||
return darkenColor(props.color, 0.2)
|
||||
})
|
||||
|
||||
const dotColor = computed(() => {
|
||||
// Use white or dark based on color luminance
|
||||
return isLightColor(props.color) ? '#333333' : '#ffffff'
|
||||
})
|
||||
|
||||
const facetColor = computed(() => {
|
||||
return isLightColor(props.color) ? '#000000' : '#ffffff'
|
||||
})
|
||||
|
||||
const valueStyle = computed(() => ({
|
||||
color: isLightColor(props.color) ? '#1a1a1a' : '#ffffff',
|
||||
}))
|
||||
|
||||
// Hexagon points for d20
|
||||
const hexagonPoints = computed(() => {
|
||||
const s = props.size
|
||||
const cx = s / 2
|
||||
const cy = s / 2
|
||||
const r = s * 0.42 // Radius
|
||||
|
||||
// 6-sided hexagon rotated to have flat top
|
||||
const points = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 2
|
||||
const x = cx + r * Math.cos(angle)
|
||||
const y = cy + r * Math.sin(angle)
|
||||
points.push(`${x},${y}`)
|
||||
}
|
||||
return points.join(' ')
|
||||
})
|
||||
|
||||
// Helper: Check if color is light (for text contrast)
|
||||
function isLightColor(hex: string): boolean {
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
// Using relative luminance formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5
|
||||
}
|
||||
|
||||
// Helper: Darken a hex color
|
||||
function darkenColor(hex: string, amount: number): string {
|
||||
const r = Math.max(0, Math.floor(parseInt(hex.slice(0, 2), 16) * (1 - amount)))
|
||||
const g = Math.max(0, Math.floor(parseInt(hex.slice(2, 4), 16) * (1 - amount)))
|
||||
const b = Math.max(0, Math.floor(parseInt(hex.slice(4, 6), 16) * (1 - amount)))
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.die-container {
|
||||
@apply relative flex flex-col items-center justify-center;
|
||||
}
|
||||
|
||||
.die-shape {
|
||||
@apply absolute inset-0 w-full h-full;
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.die-value {
|
||||
@apply relative z-10 font-bold text-3xl;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.die-value-large {
|
||||
@apply text-4xl;
|
||||
}
|
||||
|
||||
.die-label {
|
||||
@apply absolute -bottom-6 left-1/2 -translate-x-1/2;
|
||||
@apply text-xs font-semibold text-gray-300 uppercase tracking-wide whitespace-nowrap;
|
||||
}
|
||||
|
||||
.die-sublabel {
|
||||
@apply absolute -bottom-10 left-1/2 -translate-x-1/2;
|
||||
@apply text-xs text-gray-400 whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -44,6 +44,7 @@
|
||||
<DiceRoller
|
||||
:can-roll="canRollDice"
|
||||
:pending-roll="null"
|
||||
:dice-color="diceColor"
|
||||
@roll="handleRollDice"
|
||||
/>
|
||||
</div>
|
||||
@ -54,36 +55,9 @@
|
||||
<DiceRoller
|
||||
:can-roll="false"
|
||||
: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
|
||||
:can-submit="canSubmitOutcome"
|
||||
@submit="handleSubmitOutcome"
|
||||
@ -127,7 +101,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 OutcomeWizard from './OutcomeWizard.vue'
|
||||
import PlayResultDisplay from './PlayResult.vue'
|
||||
@ -141,16 +115,14 @@ interface Props {
|
||||
canSubmitOutcome: boolean
|
||||
outs?: number
|
||||
hasRunners?: boolean
|
||||
// Player data for post-roll card display
|
||||
batterPlayer?: SbaPlayer | null
|
||||
pitcherPlayer?: SbaPlayer | null
|
||||
// Dice color from home team (hex without #)
|
||||
diceColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
outs: 0,
|
||||
hasRunners: false,
|
||||
batterPlayer: null,
|
||||
pitcherPlayer: null,
|
||||
diceColor: 'cc0000', // Default red
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -163,14 +135,6 @@ const emit = defineEmits<{
|
||||
const error = ref<string | null>(null)
|
||||
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
|
||||
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
|
||||
|
||||
@ -336,54 +300,6 @@ const handleDismissResult = () => {
|
||||
@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 {
|
||||
@apply space-y-4;
|
||||
@ -426,42 +342,9 @@ const handleDismissResult = () => {
|
||||
@apply text-blue-300;
|
||||
}
|
||||
|
||||
.divider {
|
||||
@apply border-gray-700;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
|
||||
@ -193,11 +193,18 @@ watch(() => props.isOpen, (isOpen) => {
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.player-card-modal {
|
||||
@apply rounded-2xl;
|
||||
@apply rounded-2xl max-w-2xl;
|
||||
max-height: 85vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.player-card-modal {
|
||||
@apply max-w-3xl;
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
.drag-handle {
|
||||
@apply flex justify-center py-2 mb-2;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
||||
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
|
||||
import type { RollData } from '~/types'
|
||||
|
||||
describe('DiceRoller', () => {
|
||||
@ -14,10 +15,17 @@ describe('DiceRoller', () => {
|
||||
resolution_d20: 8,
|
||||
check_wild_pitch: false,
|
||||
check_passed_ball: false,
|
||||
chaos_check_skipped: false,
|
||||
timestamp: '2025-01-13T12:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
canRoll: true,
|
||||
pendingRoll: null as RollData | null,
|
||||
diceColor: 'cc0000', // Default red
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useFakeTimers()
|
||||
@ -30,10 +38,7 @@ describe('DiceRoller', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders roll button when no pending roll', () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
||||
@ -45,6 +50,7 @@ describe('DiceRoller', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -55,27 +61,83 @@ describe('DiceRoller', () => {
|
||||
expect(wrapper.text()).toContain('Dice Results')
|
||||
})
|
||||
|
||||
it('displays all four dice values correctly', () => {
|
||||
it('displays three dice when no WP/PB check triggered (chaos d20 hidden)', () => {
|
||||
/**
|
||||
* When chaos d20 doesn't trigger WP (1) or PB (2), it's hidden since values 3-20
|
||||
* have no game effect. This reduces visual noise in the dice display.
|
||||
*/
|
||||
const rollData = createRollData({
|
||||
d6_one: 5,
|
||||
d6_two_total: 8,
|
||||
chaos_d20: 17,
|
||||
chaos_d20: 17, // Not 1 or 2, so no check triggered
|
||||
resolution_d20: 3,
|
||||
check_wild_pitch: false,
|
||||
check_passed_ball: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceValues = wrapper.findAll('.dice-value')
|
||||
expect(diceValues).toHaveLength(4)
|
||||
expect(diceValues[0].text()).toBe('5')
|
||||
expect(diceValues[1].text()).toBe('8')
|
||||
expect(diceValues[2].text()).toBe('17')
|
||||
expect(diceValues[3].text()).toBe('3')
|
||||
// DiceShapes components are rendered for each die
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents).toHaveLength(3) // chaos d20 hidden when no check triggered
|
||||
})
|
||||
|
||||
it('displays all four dice when wild pitch check triggered', () => {
|
||||
/**
|
||||
* Chaos d20 is shown when it triggers a Wild Pitch check (value == 1),
|
||||
* since this affects gameplay and the user needs to see the dice value.
|
||||
*/
|
||||
const rollData = createRollData({
|
||||
d6_one: 5,
|
||||
d6_two_total: 8,
|
||||
chaos_d20: 1, // Triggers WP check
|
||||
resolution_d20: 3,
|
||||
check_wild_pitch: true,
|
||||
check_passed_ball: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents).toHaveLength(4) // chaos d20 shown for WP check
|
||||
})
|
||||
|
||||
it('displays all four dice when passed ball check triggered', () => {
|
||||
/**
|
||||
* Chaos d20 is shown when it triggers a Passed Ball check (value == 2),
|
||||
* since this affects gameplay and the user needs to see the dice value.
|
||||
*/
|
||||
const rollData = createRollData({
|
||||
d6_one: 5,
|
||||
d6_two_total: 8,
|
||||
chaos_d20: 2, // Triggers PB check
|
||||
resolution_d20: 3,
|
||||
check_wild_pitch: false,
|
||||
check_passed_ball: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents).toHaveLength(4) // chaos d20 shown for PB check
|
||||
})
|
||||
|
||||
it('displays d6_two component dice values', () => {
|
||||
@ -87,6 +149,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -103,10 +166,7 @@ describe('DiceRoller', () => {
|
||||
describe('Button States', () => {
|
||||
it('enables button when canRoll is true', () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
const button = wrapper.find('.roll-button')
|
||||
@ -118,8 +178,8 @@ describe('DiceRoller', () => {
|
||||
it('disables button when canRoll is false', () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: null,
|
||||
},
|
||||
})
|
||||
|
||||
@ -131,10 +191,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
it('shows loading state when rolling', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.find('.roll-button').trigger('click')
|
||||
@ -145,10 +202,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
it('disables button during rolling animation', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.find('.roll-button').trigger('click')
|
||||
@ -159,10 +213,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
it('resets rolling state after timeout', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.find('.roll-button').trigger('click')
|
||||
@ -182,10 +233,7 @@ describe('DiceRoller', () => {
|
||||
describe('Event Emission', () => {
|
||||
it('emits roll event when button clicked', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.find('.roll-button').trigger('click')
|
||||
@ -197,8 +245,8 @@ describe('DiceRoller', () => {
|
||||
it('does not emit roll when canRoll is false', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: null,
|
||||
},
|
||||
})
|
||||
|
||||
@ -209,10 +257,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
it('does not emit roll during rolling animation', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.find('.roll-button').trigger('click')
|
||||
@ -231,6 +276,7 @@ describe('DiceRoller', () => {
|
||||
const rollData = createRollData({ check_wild_pitch: true })
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -244,6 +290,7 @@ describe('DiceRoller', () => {
|
||||
const rollData = createRollData({ check_passed_ball: true })
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -261,6 +308,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -278,6 +326,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -288,32 +337,116 @@ describe('DiceRoller', () => {
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Card Instructions Tests
|
||||
// Chaos d20 Conditional Display Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Card Instructions', () => {
|
||||
it('shows card reading instructions when roll exists', () => {
|
||||
const rollData = createRollData()
|
||||
describe('Chaos d20 Conditional Display', () => {
|
||||
/**
|
||||
* The chaos d20 dice is only displayed when it triggers a Wild Pitch (1)
|
||||
* or Passed Ball (2) check. Values 3-20 have no game effect and showing
|
||||
* them creates visual noise. When bases are empty, the chaos check is
|
||||
* skipped entirely since WP/PB is meaningless without runners.
|
||||
*/
|
||||
|
||||
it('hides chaos d20 when no check triggered (values 3-20)', () => {
|
||||
const rollData = createRollData({
|
||||
chaos_d20: 15,
|
||||
check_wild_pitch: false,
|
||||
check_passed_ball: false,
|
||||
chaos_check_skipped: false, // Runners on base, but roll was 3-20
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.card-instructions').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Use these dice results')
|
||||
const chaosItem = wrapper.find('.dice-chaos')
|
||||
expect(chaosItem.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show instructions when no roll', () => {
|
||||
it('hides chaos d20 when chaos check was skipped (bases empty)', () => {
|
||||
const rollData = createRollData({
|
||||
chaos_d20: 1, // Would trigger WP but bases empty
|
||||
check_wild_pitch: false, // Skipped due to no runners
|
||||
check_passed_ball: false,
|
||||
chaos_check_skipped: true, // No runners on base
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.card-instructions').exists()).toBe(false)
|
||||
const chaosItem = wrapper.find('.dice-chaos')
|
||||
expect(chaosItem.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows chaos d20 when wild pitch check triggered', () => {
|
||||
const rollData = createRollData({
|
||||
chaos_d20: 1,
|
||||
check_wild_pitch: true,
|
||||
check_passed_ball: false,
|
||||
chaos_check_skipped: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const chaosItem = wrapper.find('.dice-chaos')
|
||||
expect(chaosItem.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows chaos d20 when passed ball check triggered', () => {
|
||||
const rollData = createRollData({
|
||||
chaos_d20: 2,
|
||||
check_wild_pitch: false,
|
||||
check_passed_ball: true,
|
||||
chaos_check_skipped: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const chaosItem = wrapper.find('.dice-chaos')
|
||||
expect(chaosItem.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays correct chaos d20 value when shown', () => {
|
||||
const rollData = createRollData({
|
||||
chaos_d20: 1,
|
||||
check_wild_pitch: true,
|
||||
check_passed_ball: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// Find the chaos d20 component (3rd one when WP/PB triggered)
|
||||
const chaosDie = diceComponents[2]
|
||||
expect(chaosDie.props('value')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -329,6 +462,7 @@ describe('DiceRoller', () => {
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
@ -340,50 +474,165 @@ describe('DiceRoller', () => {
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Dice Type Styling Tests
|
||||
// Dice Color Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Dice Type Styling', () => {
|
||||
it('applies d6 styling to d6 dice', () => {
|
||||
describe('Dice Color', () => {
|
||||
it('passes dice color to d6 DiceShapes components', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
diceColor: '0066ff', // Blue
|
||||
},
|
||||
})
|
||||
|
||||
const diceItems = wrapper.findAll('.dice-item')
|
||||
expect(diceItems[0].classes()).toContain('dice-d6') // d6 one
|
||||
expect(diceItems[1].classes()).toContain('dice-d6') // d6 two
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// First two are d6 dice
|
||||
expect(diceComponents[0].props('color')).toBe('0066ff')
|
||||
expect(diceComponents[1].props('color')).toBe('0066ff')
|
||||
})
|
||||
|
||||
it('applies d20 styling to d20 dice', () => {
|
||||
it('uses white for resolution d20', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceItems = wrapper.findAll('.dice-item')
|
||||
expect(diceItems[2].classes()).toContain('dice-d20') // chaos d20
|
||||
expect(diceItems[3].classes()).toContain('dice-d20') // resolution d20
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// Last one is resolution d20 (when no chaos shown)
|
||||
const resolutionD20 = diceComponents[diceComponents.length - 1]
|
||||
expect(resolutionD20.props('color')).toBe('ffffff')
|
||||
})
|
||||
|
||||
it('applies large value class to d20 dice', () => {
|
||||
const rollData = createRollData()
|
||||
it('uses amber for chaos d20 when shown', () => {
|
||||
const rollData = createRollData({
|
||||
check_wild_pitch: true,
|
||||
chaos_d20: 1,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceValues = wrapper.findAll('.dice-value')
|
||||
expect(diceValues[2].classes()).toContain('dice-value-large')
|
||||
expect(diceValues[3].classes()).toContain('dice-value-large')
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// Third one is chaos d20 when WP triggered
|
||||
const chaosD20 = diceComponents[2]
|
||||
expect(chaosD20.props('color')).toBe('f59e0b')
|
||||
})
|
||||
|
||||
it('uses default red when no diceColor prop provided', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
// No diceColor prop
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents[0].props('color')).toBe('cc0000')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Dice Type Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Dice Types', () => {
|
||||
it('renders d6 type for first two dice', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents[0].props('type')).toBe('d6')
|
||||
expect(diceComponents[1].props('type')).toBe('d6')
|
||||
})
|
||||
|
||||
it('renders d20 type for resolution die', () => {
|
||||
const rollData = createRollData()
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// Last one is resolution d20
|
||||
const resolutionD20 = diceComponents[diceComponents.length - 1]
|
||||
expect(resolutionD20.props('type')).toBe('d20')
|
||||
})
|
||||
|
||||
it('renders d20 type for chaos die when shown', () => {
|
||||
const rollData = createRollData({
|
||||
check_wild_pitch: true,
|
||||
chaos_d20: 1,
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
// Third one is chaos d20
|
||||
expect(diceComponents[2].props('type')).toBe('d20')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Layout Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Layout', () => {
|
||||
it('applies compact display class when chaos d20 is hidden', () => {
|
||||
const rollData = createRollData() // Default: no WP/PB check
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceDisplay = wrapper.find('.dice-display')
|
||||
expect(diceDisplay.classes()).toContain('dice-display-compact')
|
||||
})
|
||||
|
||||
it('does not apply compact display class when chaos d20 is shown', () => {
|
||||
const rollData = createRollData({ check_wild_pitch: true, chaos_d20: 1 })
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
const diceDisplay = wrapper.find('.dice-display')
|
||||
expect(diceDisplay.classes()).not.toContain('dice-display-compact')
|
||||
})
|
||||
})
|
||||
|
||||
@ -404,14 +653,15 @@ describe('DiceRoller', () => {
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('6')
|
||||
expect(wrapper.text()).toContain('12')
|
||||
expect(wrapper.text()).toContain('20')
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents[0].props('value')).toBe(6)
|
||||
expect(diceComponents[1].props('value')).toBe(12)
|
||||
})
|
||||
|
||||
it('handles minimum dice values', () => {
|
||||
@ -422,25 +672,27 @@ describe('DiceRoller', () => {
|
||||
d6_two_total: 2,
|
||||
chaos_d20: 1,
|
||||
resolution_d20: 1,
|
||||
check_wild_pitch: true, // Show chaos d20
|
||||
})
|
||||
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: rollData,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('1')
|
||||
expect(wrapper.text()).toContain('2')
|
||||
const diceComponents = wrapper.findAllComponents(DiceShapes)
|
||||
expect(diceComponents[0].props('value')).toBe(1)
|
||||
expect(diceComponents[1].props('value')).toBe(2)
|
||||
expect(diceComponents[2].props('value')).toBe(1) // chaos d20
|
||||
expect(diceComponents[3].props('value')).toBe(1) // resolution d20
|
||||
})
|
||||
|
||||
it('transitions from no roll to roll result', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
canRoll: true,
|
||||
pendingRoll: null,
|
||||
},
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.roll-button').exists()).toBe(true)
|
||||
@ -455,6 +707,7 @@ describe('DiceRoller', () => {
|
||||
it('clears roll result when pendingRoll set to null', async () => {
|
||||
const wrapper = mount(DiceRoller, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
canRoll: false,
|
||||
pendingRoll: createRollData(),
|
||||
},
|
||||
|
||||
461
frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts
Normal file
461
frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts
Normal file
@ -0,0 +1,461 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
|
||||
|
||||
describe('DiceShapes', () => {
|
||||
const defaultD6Props = {
|
||||
type: 'd6' as const,
|
||||
value: 5,
|
||||
}
|
||||
|
||||
const defaultD20Props = {
|
||||
type: 'd20' as const,
|
||||
value: 15,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// D6 Rendering Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('D6 Shape Rendering', () => {
|
||||
it('renders d6 die container when type is d6', () => {
|
||||
/**
|
||||
* The d6 die should render a square-shaped die with rounded corners,
|
||||
* decorative corner dots suggesting pip positions, and the value centered.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-container').exists()).toBe(true)
|
||||
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders rect element for d6 die body', () => {
|
||||
/**
|
||||
* D6 dice use a rounded rectangle (rect with rx/ry) to create the
|
||||
* classic square die shape with softened corners.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.exists()).toBe(true)
|
||||
expect(rect.attributes('rx')).toBe('10')
|
||||
expect(rect.attributes('ry')).toBe('10')
|
||||
})
|
||||
|
||||
it('renders four corner dots for d6 decoration', () => {
|
||||
/**
|
||||
* Four decorative dots in the corners suggest the pip positions
|
||||
* of a physical die, adding visual authenticity.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
const circles = wrapper.findAll('circle')
|
||||
expect(circles).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('displays the value in the center', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, value: 3 },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-value').text()).toBe('3')
|
||||
})
|
||||
|
||||
it('displays label when provided', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, label: 'd6 (One)' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-label').exists()).toBe(true)
|
||||
expect(wrapper.find('.die-label').text()).toBe('d6 (One)')
|
||||
})
|
||||
|
||||
it('displays sublabel when provided', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, sublabel: '(3 + 2)' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-sublabel').exists()).toBe(true)
|
||||
expect(wrapper.find('.die-sublabel').text()).toBe('(3 + 2)')
|
||||
})
|
||||
|
||||
it('hides label when not provided', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-label').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// D20 Rendering Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('D20 Shape Rendering', () => {
|
||||
it('renders d20 die container when type is d20', () => {
|
||||
/**
|
||||
* The d20 die renders a hexagonal shape inspired by the icosahedron
|
||||
* geometry of a real d20, with facet lines for 3D effect.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD20Props,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-container').exists()).toBe(true)
|
||||
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders polygon element for d20 hexagonal shape', () => {
|
||||
/**
|
||||
* D20 uses a 6-sided polygon (hexagon) rotated with flat top
|
||||
* to suggest the multi-faceted nature of an icosahedron.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD20Props,
|
||||
})
|
||||
|
||||
const polygon = wrapper.find('polygon')
|
||||
expect(polygon.exists()).toBe(true)
|
||||
|
||||
// Verify points attribute contains 6 coordinate pairs
|
||||
const points = polygon.attributes('points')
|
||||
expect(points).toBeDefined()
|
||||
const pointPairs = points!.split(' ')
|
||||
expect(pointPairs).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('renders facet lines for 3D effect', () => {
|
||||
/**
|
||||
* Two vertical lines inside the hexagon create the illusion
|
||||
* of depth and the faceted surface of a d20.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD20Props,
|
||||
})
|
||||
|
||||
const lines = wrapper.findAll('line')
|
||||
expect(lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('displays the value with large styling', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD20Props,
|
||||
})
|
||||
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.text()).toBe('15')
|
||||
expect(valueEl.classes()).toContain('die-value-large')
|
||||
})
|
||||
|
||||
it('displays label when provided', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD20Props, label: 'Resolution' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-label').text()).toBe('Resolution')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Color Calculation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Color Calculations', () => {
|
||||
it('applies fill color from color prop', () => {
|
||||
/**
|
||||
* The color prop (hex without #) should be converted to a proper
|
||||
* CSS color value and applied to the die fill.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '0066ff' },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.attributes('fill')).toBe('#0066ff')
|
||||
})
|
||||
|
||||
it('uses default red color when no color prop provided', () => {
|
||||
/**
|
||||
* Default dice color is cc0000 (red) matching the traditional
|
||||
* Strat-O-Matic dice color.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.attributes('fill')).toBe('#cc0000')
|
||||
})
|
||||
|
||||
it('calculates darker stroke color from fill', () => {
|
||||
/**
|
||||
* The stroke color should be a darkened version of the fill
|
||||
* to create depth and definition around the die edge.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'ffffff' },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
const stroke = rect.attributes('stroke')
|
||||
// White (ffffff) darkened by 20% should be #cccccc
|
||||
expect(stroke).toBe('#cccccc')
|
||||
})
|
||||
|
||||
it('uses white text on dark backgrounds', () => {
|
||||
/**
|
||||
* When the die color is dark (low luminance), the value text
|
||||
* should be white for readability.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '000000' },
|
||||
})
|
||||
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #ffffff')
|
||||
})
|
||||
|
||||
it('uses dark text on light backgrounds', () => {
|
||||
/**
|
||||
* When the die color is light (high luminance), the value text
|
||||
* should be dark (#1a1a1a) for readability.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'ffffff' },
|
||||
})
|
||||
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
|
||||
})
|
||||
|
||||
it('uses white corner dots on dark d6 backgrounds', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '000066' },
|
||||
})
|
||||
|
||||
const circles = wrapper.findAll('circle')
|
||||
expect(circles[0].attributes('fill')).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('uses dark corner dots on light d6 backgrounds', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'ffff00' },
|
||||
})
|
||||
|
||||
const circles = wrapper.findAll('circle')
|
||||
expect(circles[0].attributes('fill')).toBe('#333333')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Size Prop Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Size Configuration', () => {
|
||||
it('applies default size of 100px', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
})
|
||||
|
||||
const container = wrapper.find('.die-container')
|
||||
expect(container.attributes('style')).toContain('width: 100px')
|
||||
expect(container.attributes('style')).toContain('height: 100px')
|
||||
})
|
||||
|
||||
it('applies custom size from prop', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, size: 80 },
|
||||
})
|
||||
|
||||
const container = wrapper.find('.die-container')
|
||||
expect(container.attributes('style')).toContain('width: 80px')
|
||||
expect(container.attributes('style')).toContain('height: 80px')
|
||||
})
|
||||
|
||||
it('scales SVG viewBox to match size', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, size: 120 },
|
||||
})
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('viewBox')).toBe('0 0 120 120')
|
||||
})
|
||||
|
||||
it('scales d6 rect dimensions proportionally', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, size: 80 },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
// width and height should be size - 8
|
||||
expect(rect.attributes('width')).toBe('72')
|
||||
expect(rect.attributes('height')).toBe('72')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Hexagon Point Calculation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Hexagon Point Calculation', () => {
|
||||
it('generates valid hexagon points for d20', () => {
|
||||
/**
|
||||
* The hexagon points should form a valid 6-sided polygon
|
||||
* with coordinates that create a symmetrical shape.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD20Props, size: 100 },
|
||||
})
|
||||
|
||||
const polygon = wrapper.find('polygon')
|
||||
const points = polygon.attributes('points')!
|
||||
const pointPairs = points.split(' ')
|
||||
|
||||
// Each pair should be valid x,y coordinates
|
||||
pointPairs.forEach(pair => {
|
||||
const [x, y] = pair.split(',').map(Number)
|
||||
expect(x).toBeGreaterThanOrEqual(0)
|
||||
expect(x).toBeLessThanOrEqual(100)
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
expect(y).toBeLessThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
it('scales hexagon points with size prop', () => {
|
||||
const smallWrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD20Props, size: 50 },
|
||||
})
|
||||
const largeWrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD20Props, size: 100 },
|
||||
})
|
||||
|
||||
const smallPoints = smallWrapper.find('polygon').attributes('points')!
|
||||
const largePoints = largeWrapper.find('polygon').attributes('points')!
|
||||
|
||||
// First point of small should be roughly half the large
|
||||
const smallFirst = smallPoints.split(' ')[0].split(',').map(Number)
|
||||
const largeFirst = largePoints.split(' ')[0].split(',').map(Number)
|
||||
|
||||
expect(smallFirst[0]).toBeCloseTo(largeFirst[0] / 2, 0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles string value prop', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, value: '20' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-value').text()).toBe('20')
|
||||
})
|
||||
|
||||
it('handles very dark colors (near black)', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '0a0a0a' },
|
||||
})
|
||||
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #ffffff')
|
||||
})
|
||||
|
||||
it('handles very light colors (near white)', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'f5f5f5' },
|
||||
})
|
||||
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
|
||||
})
|
||||
|
||||
it('handles mid-luminance colors correctly', () => {
|
||||
/**
|
||||
* Colors near the 50% luminance threshold should still pick
|
||||
* appropriate contrast. 808080 (gray) has exactly 50% luminance.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '808080' },
|
||||
})
|
||||
|
||||
// Gray is exactly at threshold, should use white text
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toBeDefined()
|
||||
})
|
||||
|
||||
it('handles team colors correctly - red team', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'cc0000' },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.attributes('fill')).toBe('#cc0000')
|
||||
// Text should be white on red
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #ffffff')
|
||||
})
|
||||
|
||||
it('handles team colors correctly - blue team', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: '0066cc' },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.attributes('fill')).toBe('#0066cc')
|
||||
// Text should be white on blue
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #ffffff')
|
||||
})
|
||||
|
||||
it('handles team colors correctly - yellow team', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, color: 'ffcc00' },
|
||||
})
|
||||
|
||||
const rect = wrapper.find('rect')
|
||||
expect(rect.attributes('fill')).toBe('#ffcc00')
|
||||
// Text should be dark on yellow
|
||||
const valueEl = wrapper.find('.die-value')
|
||||
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Slot Content Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Slot Content', () => {
|
||||
it('renders slot content instead of value when provided', () => {
|
||||
/**
|
||||
* The die-value slot allows custom content to be rendered
|
||||
* instead of the raw number value.
|
||||
*/
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: defaultD6Props,
|
||||
slots: {
|
||||
default: '<span class="custom-value">★</span>',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.custom-value').exists()).toBe(true)
|
||||
expect(wrapper.find('.custom-value').text()).toBe('★')
|
||||
})
|
||||
|
||||
it('falls back to value prop when no slot content', () => {
|
||||
const wrapper = mount(DiceShapes, {
|
||||
props: { ...defaultD6Props, value: 6 },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.die-value').text()).toBe('6')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -76,10 +76,12 @@ export interface GameState {
|
||||
home_team_name?: string | null // Full name: "Chicago Cyclones"
|
||||
home_team_abbrev?: string | null // Abbreviation: "CHC"
|
||||
home_team_color?: string | null // Hex color without #: "ff5349"
|
||||
home_team_dice_color?: string | null // Dice color hex without #, default "cc0000"
|
||||
home_team_thumbnail?: string | null // Team logo URL
|
||||
away_team_name?: string | null
|
||||
away_team_abbrev?: string | null
|
||||
away_team_color?: string | null
|
||||
away_team_dice_color?: string | null // Dice color hex without #, default "cc0000"
|
||||
away_team_thumbnail?: string | null
|
||||
|
||||
// Creator (for demo/testing - creator can control home team)
|
||||
@ -168,6 +170,7 @@ export interface RollData {
|
||||
resolution_d20: number
|
||||
check_wild_pitch: boolean
|
||||
check_passed_ball: boolean
|
||||
chaos_check_skipped: boolean // True when no runners on base (WP/PB irrelevant)
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user