diff --git a/backend/app/core/dice.py b/backend/app/core/dice.py index 1352f11..7e9b57c 100644 --- a/backend/app/core/dice.py +++ b/backend/app/core/dice.py @@ -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) diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 9c25b87..e1d2ffb 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -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: diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index 5ccc869..f607e2d 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -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 diff --git a/backend/app/core/roll_types.py b/backend/app/core/roll_types.py index 349e986..ba73028 100644 --- a/backend/app/core/roll_types.py +++ b/backend/app/core/roll_types.py @@ -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 diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 700f08e..c739ae8 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -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"), ) diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index b18ce1b..7159833 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -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) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 0320eef..c96d027 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -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", }, diff --git a/backend/tests/unit/websocket/test_manual_outcome_handlers.py b/backend/tests/unit/websocket/test_manual_outcome_handlers.py index dd47fe2..927c6c9 100644 --- a/backend/tests/unit/websocket/test_manual_outcome_handlers.py +++ b/backend/tests/unit/websocket/test_manual_outcome_handlers.py @@ -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 diff --git a/frontend-sba/components/Game/CurrentSituation.vue b/frontend-sba/components/Game/CurrentSituation.vue index 4ddbf9f..730e72d 100644 --- a/frontend-sba/components/Game/CurrentSituation.vue +++ b/frontend-sba/components/Game/CurrentSituation.vue @@ -1,125 +1,35 @@ @@ -99,13 +99,17 @@ + + diff --git a/frontend-sba/components/Gameplay/GameplayPanel.vue b/frontend-sba/components/Gameplay/GameplayPanel.vue index 51d7c78..30c5ef8 100644 --- a/frontend-sba/components/Gameplay/GameplayPanel.vue +++ b/frontend-sba/components/Gameplay/GameplayPanel.vue @@ -44,6 +44,7 @@ @@ -54,36 +55,9 @@ - -
- -
- - {{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD - - {{ activeCardPlayer.name }} -
- - -
- -
- - {{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }} - -
-
-
- -
- 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(), { 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(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 */ diff --git a/frontend-sba/components/Player/PlayerCardModal.vue b/frontend-sba/components/Player/PlayerCardModal.vue index 2a5e846..f70e0df 100644 --- a/frontend-sba/components/Player/PlayerCardModal.vue +++ b/frontend-sba/components/Player/PlayerCardModal.vue @@ -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; diff --git a/frontend-sba/tests/unit/components/Gameplay/DiceRoller.spec.ts b/frontend-sba/tests/unit/components/Gameplay/DiceRoller.spec.ts index 4fd9fc1..dff8103 100644 --- a/frontend-sba/tests/unit/components/Gameplay/DiceRoller.spec.ts +++ b/frontend-sba/tests/unit/components/Gameplay/DiceRoller.spec.ts @@ -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(), }, diff --git a/frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts b/frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts new file mode 100644 index 0000000..055cb44 --- /dev/null +++ b/frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts @@ -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: '', + }, + }) + + 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') + }) + }) +}) diff --git a/frontend-sba/types/game.ts b/frontend-sba/types/game.ts index 03a6825..edbbea7 100644 --- a/frontend-sba/types/game.ts +++ b/frontend-sba/types/game.ts @@ -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 }